Compare commits

..

5 commits

12 changed files with 532 additions and 139 deletions

View file

@ -52,6 +52,8 @@ body {
width: 1.2em;
height: 1.2em;
color: var(--ui-parchment);
vertical-align: -0.25em;
margin-right: 0.7em;
}
/* ── Site navigation ─────────────────────────────────────────────── */
@ -1857,6 +1859,14 @@ a:hover { text-decoration: underline; }
font-style: italic;
}
.ceremony-result {
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 600;
color: var(--ui-gold-dark);
letter-spacing: 0.04em;
}
/* ── Nickname modal (anonymous player name chooser) ─────────────────── */
.nickname-backdrop {
position: fixed;

View file

@ -15,6 +15,15 @@
"roll_dice": "Roll dice",
"go": "Go",
"empty_move": "Empty move",
"cancel_move": "Cancel move",
"debug_section": "Debug",
"take_snapshot": "Take snapshot",
"snapshot_copied": "Copied!",
"replay_snapshot": "Replay snapshot",
"replay_paste_hint": "Paste a snapshot JSON to start a bot game from that position.",
"replay_start": "Start",
"replay_invalid_state": "Invalid snapshot — paste the JSON copied by Take snapshot.",
"cancel": "Cancel",
"you_suffix": " (you)",
"points_label": "Points",
"holes_label": "Holes",
@ -46,6 +55,8 @@
"pre_game_roll_title": "Who goes first?",
"pre_game_roll_btn": "Roll",
"pre_game_roll_tie": "Tie! Roll again",
"toss_you_first": "You go first!",
"toss_opp_first": "{{ name }} goes first!",
"pre_game_roll_your_die": "Your die",
"pre_game_roll_opp_die": "Opponent's die",
"continue_btn": "Continue",

View file

@ -1,6 +1,6 @@
{
"room_name_placeholder": "Nom de la salle",
"create_room": "Créer une salle",
"create_room": "Inviter un adversaire",
"join_room": "Rejoindre",
"connecting": "Connexion en cours…",
"game_over": "Partie terminée",
@ -15,6 +15,15 @@
"roll_dice": "Lancer les dés",
"go": "S'en aller",
"empty_move": "Mouvement impossible",
"cancel_move": "Annuler le déplacement",
"debug_section": "Debug",
"take_snapshot": "Prendre un instantané",
"snapshot_copied": "Copié !",
"replay_snapshot": "Rejouer un instantané",
"replay_paste_hint": "Collez un instantané JSON pour démarrer une partie contre le bot depuis cette position.",
"replay_start": "Démarrer",
"replay_invalid_state": "Instantané invalide — collez le JSON copié par « Prendre un instantané ».",
"cancel": "Annuler",
"you_suffix": " (vous)",
"points_label": "Points",
"holes_label": "Trous",
@ -36,8 +45,8 @@
"jan_helpless_man": "Dame impuissante",
"play_vs_bot": "Jouer contre le bot",
"vs_bot_label": "contre le bot",
"you_win": "Vous avez gagné !",
"opp_wins": "{{ name }} gagne !",
"you_win": "Vous avez gagné!",
"opp_wins": "{{ name }} a gagné!",
"play_again": "Rejouer",
"after_opponent_roll": "L'adversaire a lancé les dés",
"after_opponent_go": "L'adversaire s'en va",
@ -46,6 +55,8 @@
"pre_game_roll_title": "Qui joue en premier ?",
"pre_game_roll_btn": "Lancer",
"pre_game_roll_tie": "Égalité ! Relancez",
"toss_you_first": "Vous commencez !",
"toss_opp_first": "{{ name }} commence !",
"pre_game_roll_your_die": "Votre dé",
"pre_game_roll_opp_die": "Dé adverse",
"continue_btn": "Continuer",
@ -119,7 +130,7 @@
"copy_link": "Copier le lien",
"link_copied": "Copié !",
"scan_qr": "ou scannez le QR code",
"join_code_label": "Rejoindre par code",
"join_code_label": "Rejoindre avec un code",
"join_code_placeholder": "Code de la salle",
"share_btn": "Partager",
"nickname_modal_title": "Choisissez votre pseudo",

View file

@ -15,6 +15,7 @@ use crate::api;
use crate::game::components::{ConnectingScreen, GameScreen};
use crate::game::session::{
compute_last_moves, patch_player_name, push_or_show, run_local_bot_game,
run_local_bot_game_with_backend,
};
use crate::game::trictrac::backend::TrictracBackend;
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
@ -83,6 +84,8 @@ pub enum NetCommand {
host_state: Option<Vec<u8>>,
},
PlayVsBot,
/// Start a bot game with the board/score position from a previously taken snapshot.
ReplaySnapshot(ViewState),
Action(PlayerAction),
Disconnect,
}
@ -190,9 +193,14 @@ pub fn App() -> impl IntoView {
spawn_local(async move {
loop {
let mut snapshot_init: Option<ViewState> = None;
let remote_config: Option<(RoomConfig, bool)> = loop {
match cmd_rx.next().await {
Some(NetCommand::PlayVsBot) => break None,
Some(NetCommand::ReplaySnapshot(vs)) => {
snapshot_init = Some(vs);
break None;
}
Some(NetCommand::CreateRoom { room }) => {
break Some((
RoomConfig {
@ -251,8 +259,23 @@ pub fn App() -> impl IntoView {
.or_else(|| anon_nickname.get_untracked())
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
loop {
let restart =
run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()).await;
let restart = match snapshot_init.take() {
Some(vs) => {
let backend = TrictracBackend::from_view_state(vs, &player_name);
run_local_bot_game_with_backend(
screen,
&mut cmd_rx,
pending,
player_name.clone(),
backend,
)
.await
}
None => {
run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone())
.await
}
};
if !restart {
break;
}
@ -446,8 +469,14 @@ fn SiteHamburger() -> impl IntoView {
.expect("cmd_tx not found in context");
let sidebar_open = RwSignal::new(false);
let snapshot_copied = RwSignal::new(false);
let replay_open = RwSignal::new(false);
let replay_text = RwSignal::new(String::new());
let replay_error = RwSignal::new(false);
let cmd_tx_newgame = cmd_tx.clone();
let cmd_tx_snapshot = cmd_tx.clone();
let cmd_tx_replay = cmd_tx.clone();
view! {
// ── Hamburger button (☰ → ✕ animation) ───────────────────────────────
@ -543,6 +572,100 @@ fn SiteHamburger() -> impl IntoView {
}.into_any(),
}}
</div>
// ── Debug section ─────────────────────────────────────────────────
<div class="game-sidebar-section" style="flex-direction:column;gap:0.4rem">
<span class="game-sidebar-label">{t!(i18n, debug_section)}</span>
// "Take snapshot" — only visible while a game is in progress
{move || {
let Screen::Playing(ref state) = screen.get() else { return None; };
let vs = state.view_state.clone();
let tx = cmd_tx_snapshot.clone();
Some(view! {
<button class="game-sidebar-btn" on:click=move |_| {
if let Ok(json) = serde_json::to_string(&vs) {
#[cfg(target_arch = "wasm32")]
{
let json_c = json.clone();
spawn_local(async move {
if let Some(cb) = web_sys::window()
.map(|w| w.navigator().clipboard())
{
let _ = wasm_bindgen_futures::JsFuture::from(
cb.write_text(&json_c),
).await;
snapshot_copied.set(true);
gloo_timers::future::TimeoutFuture::new(2000).await;
snapshot_copied.set(false);
}
});
}
let _ = tx; // suppress unused warning on non-wasm
}
}>
{move || if snapshot_copied.get() {
t_string!(i18n, snapshot_copied).to_owned()
} else {
t_string!(i18n, take_snapshot).to_owned()
}}
</button>
})
}}
// "Replay snapshot" — always visible
<button class="game-sidebar-btn" on:click=move |_| {
replay_text.set(String::new());
replay_error.set(false);
replay_open.set(true);
sidebar_open.set(false);
}>{t!(i18n, replay_snapshot)}</button>
</div>
</div>
// ── Replay snapshot modal ─────────────────────────────────────────────
<div class="ceremony-overlay" style="z-index:300"
style:display=move || if replay_open.get() { "" } else { "none" }
on:click=move |_| replay_open.set(false)>
<div class="ceremony-box" style="min-width:340px;max-width:480px;width:90vw"
on:click=|e| e.stop_propagation()>
<h2 style="font-size:1.3rem">{t!(i18n, replay_snapshot)}</h2>
<p class="game-sub-prompt" style="margin:0;text-align:center">
{t!(i18n, replay_paste_hint)}
</p>
<textarea
style="width:100%;min-height:120px;background:rgba(0,0,0,0.25);border:1px solid rgba(200,164,72,0.35);border-radius:4px;color:var(--ui-parchment);font-family:var(--font-ui);font-size:0.75rem;padding:0.5rem;resize:vertical;box-sizing:border-box"
placeholder="{ \"board\": [...], ... }"
prop:value=move || replay_text.get()
on:input=move |e| {
use leptos::prelude::event_target_value;
replay_text.set(event_target_value(&e));
replay_error.set(false);
}
/>
{move || replay_error.get().then(|| view! {
<p style="color:var(--ui-red-accent);font-size:0.8rem;margin:0">
{t!(i18n, replay_invalid_state)}
</p>
})}
<div style="display:flex;gap:0.75rem;justify-content:center">
<button class="btn btn-secondary" on:click=move |_| replay_open.set(false)>
{t!(i18n, cancel)}
</button>
<button class="btn btn-primary" on:click=move |_| {
let text = replay_text.get_untracked();
match serde_json::from_str::<ViewState>(&text) {
Ok(vs) => {
cmd_tx_replay
.unbounded_send(NetCommand::ReplaySnapshot(vs))
.ok();
replay_open.set(false);
}
Err(_) => replay_error.set(true),
}
}>{t!(i18n, replay_start)}</button>
</div>
</div>
</div>
}
}

View file

@ -312,13 +312,8 @@ pub fn Board(
exit_field_test = |f| matches!(f, 1..=6);
}
// Show a clickable exit sign outside the board when bearing off is possible.
let has_exit_move = valid_sequences
.iter()
.any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0);
let show_exit_btn = all_in_exit && is_move_stage && has_exit_move;
let seqs_exit_cls = valid_sequences.clone();
let seqs_exit_click = valid_sequences.clone();
// Sequences clone for the reactive exit button (show/hide + class + click).
let seqs_exit = valid_sequences.clone();
// `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc).
let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> {
@ -624,70 +619,88 @@ pub fn Board(
</svg>
// Exit sign: circle+arrow outside the board, next to the last exit field.
// White exits to the right (top-right quarter); Black exits to the left (top-left).
{show_exit_btn.then(|| {
let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) =
if is_white {
(
"position:absolute;right:-60px;top:15px;width:50px;height:50px",
"10", "31", "23,17 32,25 23,33",
)
} else {
(
"position:absolute;left:-60px;top:15px;width:50px;height:50px",
"40", "19", "27,17 18,25 27,33",
)
};
view! {
<div
title="Exit"
style=pos_style
class=move || {
let staged = staged_moves.get();
let sel = selected_origin.get();
let active = match sel {
Some(origin) => seqs_exit_cls.is_empty()
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
.iter()
.any(|&d| d == 0),
None => false,
};
if active { "exit-btn exit-active" } else { "exit-btn" }
}
on:click=move |_| {
if !is_move_stage { return; }
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
let Some(origin) = selected_origin.get_untracked() else {
return;
};
let valid = seqs_exit_click.is_empty()
|| valid_dests_for(&seqs_exit_click, &staged, origin)
.iter()
.any(|&d| d == 0);
if valid {
staged_moves.update(|v| v.push((origin, 0)));
selected_origin.set(None);
{move || {
// Recompute on every staged_moves change: the exit button must appear
// even when the initial board has a checker outside the exit zone,
// because the first move can bring all checkers in (e.g. 15→21, 19→exit).
let staged = staged_moves.get();
let show = is_move_stage && match staged.len() {
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
1 => {
let (f0, t0) = staged[0];
seqs_exit.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.any(|(_, m2)| m2.get_to() == 0)
}
_ => false,
};
show.then(|| {
let seqs_exit_cls = seqs_exit.clone();
let seqs_exit_click = seqs_exit.clone();
let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) =
if is_white {
(
"position:absolute;right:-60px;top:15px;width:50px;height:50px",
"10", "31", "23,17 32,25 23,33",
)
} else {
(
"position:absolute;left:-60px;top:15px;width:50px;height:50px",
"40", "19", "27,17 18,25 27,33",
)
};
view! {
<div
title="Exit"
style=pos_style
class=move || {
let staged = staged_moves.get();
let sel = selected_origin.get();
let active = match sel {
Some(origin) => seqs_exit_cls.is_empty()
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
.iter()
.any(|&d| d == 0),
None => false,
};
if active { "exit-btn exit-active" } else { "exit-btn" }
}
}
>
<svg width="50" height="50" viewBox="0 0 50 50">
<circle
cx="25" cy="25" r="20"
style="fill:rgba(10,20,10,0.75);stroke:rgba(210,170,30,0.75);stroke-width:2.5"
/>
<line
x1=line_x1 y1="25" x2=line_x2 y2="25"
style="stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round"
/>
<polyline
points=head_pts
style="fill:none;stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round"
/>
</svg>
</div>
}
.into_any()
})}
on:click=move |_| {
if !is_move_stage { return; }
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
let Some(origin) = selected_origin.get_untracked() else {
return;
};
let valid = seqs_exit_click.is_empty()
|| valid_dests_for(&seqs_exit_click, &staged, origin)
.iter()
.any(|&d| d == 0);
if valid {
staged_moves.update(|v| v.push((origin, 0)));
selected_origin.set(None);
}
}
>
<svg width="50" height="50" viewBox="0 0 50 50">
<circle
cx="25" cy="25" r="20"
style="fill:rgba(10,20,10,0.75);stroke:rgba(210,170,30,0.75);stroke-width:2.5"
/>
<line
x1=line_x1 y1="25" x2=line_x2 y2="25"
style="stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round"
/>
<polyline
points=head_pts
style="fill:none;stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round"
/>
</svg>
</div>
}
.into_any()
})
}}
</div>
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_bl}</div>

View file

@ -424,6 +424,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
>{t!(i18n, empty_move)}</button>
})
}}
{move || {
(is_move_stage && staged_moves.get().len() == 1).then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
staged_moves.set(vec![]);
selected_origin.set(None);
}
>{t!(i18n, cancel_move)}</button>
})
}}
</div>
</div>
@ -442,6 +453,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die };
let can_roll = my_die.is_none() && !waiting_for_confirm;
let show_tie = pgr.tie_count > 0;
let toss_result: Option<bool> = match (my_die, opp_die) {
(Some(m), Some(o)) if m != o => Some(m > o),
_ => None,
};
let opp_name_toss = opp_name_ceremony.clone();
view! {
<div class="ceremony-overlay">
<div class="ceremony-box">
@ -459,6 +475,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
<Die value=opp_die.unwrap_or(0) used=false />
</div>
</div>
{toss_result.map(|i_win| {
let text = move || if i_win {
t_string!(i18n, toss_you_first).to_owned()
} else {
t_string!(i18n, toss_opp_first, name = opp_name_toss.as_str()).to_owned()
};
view! { <p class="ceremony-result">{text}</p> }
})}
{waiting_for_confirm.then(|| {
let pending_c = pending;
view! {

View file

@ -50,6 +50,44 @@ pub async fn run_local_bot_game(
suppress_dice_anim: false,
}));
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
}
/// Runs a bot game from a pre-built backend and initial ViewState (used for snapshot replay).
/// Returns `true` if the player wants to play again.
pub async fn run_local_bot_game_with_backend(
screen: RwSignal<Screen>,
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
pending: RwSignal<VecDeque<GameUiState>>,
player_name: String,
backend: TrictracBackend,
) -> bool {
let mut vs = backend.get_view_state().clone();
patch_bot_names(&mut vs, &player_name);
screen.set(Screen::Playing(GameUiState {
view_state: vs.clone(),
player_id: 0,
room_id: String::new(),
is_bot_game: true,
waiting_for_confirm: false,
pause_reason: None,
my_scored_event: None,
opp_scored_event: None,
last_moves: None,
suppress_dice_anim: false,
}));
run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await
}
async fn run_local_bot_game_loop(
screen: RwSignal<Screen>,
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
pending: RwSignal<VecDeque<GameUiState>>,
player_name: String,
mut backend: TrictracBackend,
mut vs: ViewState,
) -> bool {
use futures::StreamExt;
loop {
match cmd_rx.next().await {

View file

@ -1,5 +1,5 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
use trictrac_store::{Color, Dice, DiceRoller, GameEvent, GameState, Player, Stage, TurnStage};
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
@ -130,6 +130,65 @@ impl TrictracBackend {
pub fn get_game(&self) -> &GameState {
&self.game
}
/// Build a backend pre-loaded with the given `ViewState` snapshot so a bot
/// game can resume from an arbitrary position (debug feature).
pub fn from_view_state(vs: ViewState, player_name: &str) -> Self {
let mut game = GameState::new(false);
game.board.set_positions(&Color::White, vs.board);
game.stage = match vs.stage {
SerStage::InGame => Stage::InGame,
SerStage::Ended => Stage::Ended,
_ => Stage::InGame,
};
game.turn_stage = match vs.turn_stage {
SerTurnStage::RollDice => TurnStage::RollDice,
SerTurnStage::RollWaiting => TurnStage::RollWaiting,
SerTurnStage::MarkPoints => TurnStage::MarkPoints,
SerTurnStage::HoldOrGoChoice => TurnStage::HoldOrGoChoice,
SerTurnStage::Move => TurnStage::Move,
SerTurnStage::MarkAdvPoints => TurnStage::MarkAdvPoints,
};
game.dice = Dice { values: vs.dice };
game.active_player_id = match vs.active_mp_player {
Some(0) => HOST_PLAYER_ID,
Some(1) => GUEST_PLAYER_ID,
_ => HOST_PLAYER_ID,
};
let build_player = |score: &crate::game::trictrac::types::PlayerScore,
color: Color|
-> Player {
let mut p = Player::new(score.name.clone(), color);
p.points = score.points;
p.holes = score.holes;
p.can_bredouille = score.can_bredouille;
p
};
game.players.insert(HOST_PLAYER_ID, build_player(&vs.scores[0], Color::White));
game.players.insert(GUEST_PLAYER_ID, build_player(&vs.scores[1], Color::Black));
let mut view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
view_state.scores[0].name = player_name.to_string();
view_state.scores[1].name = "Bot".to_string();
TrictracBackend {
game,
dice_roller: DiceRoller::default(),
commands: Vec::new(),
view_state,
arrived: [true, true],
pre_game_dice: [None; 2],
tie_count: 0,
ceremony_started: false,
}
}
}
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {

View file

@ -21,12 +21,12 @@ fn generate_nickname() -> String {
use rand::Rng;
let mut rng = rand::rng();
const ADJ: &[&str] = &[
"swift", "brave", "noble", "fierce", "clever", "bold", "cunning",
"agile", "sharp", "golden", "iron", "silver", "quick", "daring", "wild",
"swift", "brave", "noble", "fierce", "clever", "bold", "cunning", "agile", "sharp",
"golden", "iron", "silver", "quick", "daring", "wild",
];
const NOUN: &[&str] = &[
"fox", "hawk", "wolf", "lion", "bear", "rook", "knight",
"duke", "earl", "lance", "blade", "crown", "dame", "ace", "star",
"fox", "hawk", "wolf", "lion", "bear", "rook", "knight", "duke", "earl", "lance", "blade",
"crown", "dame", "ace", "star",
];
let adj = ADJ[rng.random_range(0..ADJ.len())];
let noun = NOUN[rng.random_range(0..NOUN.len())];
@ -124,7 +124,9 @@ pub fn LobbyPage() -> impl IntoView {
};
join_processed.set_value(true);
if auth_username.get_untracked().is_some() {
cmd_tx_q.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
cmd_tx_q
.unbounded_send(NetCommand::JoinRoom { room: code })
.ok();
} else {
pending_action.set(Some(PendingLobbyAction::Join { code }));
}
@ -203,7 +205,9 @@ fn IdleCard(
let on_create = move |_: leptos::ev::MouseEvent| {
let code = generate_room_code();
if auth_username.get_untracked().is_some() {
cmd_create.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok();
cmd_create
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
.ok();
view_state.set(LobbyView::Waiting { code });
} else {
pending_action.set(Some(PendingLobbyAction::Create { code }));
@ -213,12 +217,18 @@ fn IdleCard(
view! {
<div class="login-actions">
<button
class="login-btn login-btn-bot"
class="login-btn login-btn-secondary"
on:click=move |_| { cmd_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M352 64C352 46.3 337.7 32 320 32C302.3 32 288 46.3 288 64L288 128L192 128C139 128 96 171 96 224L96 448C96 501 139 544 192 544L448 544C501 544 544 501 544 448L544 224C544 171 501 128 448 128L352 128L352 64zM160 432C160 418.7 170.7 408 184 408L216 408C229.3 408 240 418.7 240 432C240 445.3 229.3 456 216 456L184 456C170.7 456 160 445.3 160 432zM280 432C280 418.7 290.7 408 304 408L336 408C349.3 408 360 418.7 360 432C360 445.3 349.3 456 336 456L304 456C290.7 456 280 445.3 280 432zM400 432C400 418.7 410.7 408 424 408L456 408C469.3 408 480 418.7 480 432C480 445.3 469.3 456 456 456L424 456C410.7 456 400 445.3 400 432zM224 240C250.5 240 272 261.5 272 288C272 314.5 250.5 336 224 336C197.5 336 176 314.5 176 288C176 261.5 197.5 240 224 240zM368 288C368 261.5 389.5 240 416 240C442.5 240 464 261.5 464 288C464 314.5 442.5 336 416 336C389.5 336 368 314.5 368 288zM64 288C64 270.3 49.7 256 32 256C14.3 256 0 270.3 0 288L0 384C0 401.7 14.3 416 32 416C49.7 416 64 401.7 64 384L64 288zM608 256C590.3 256 576 270.3 576 288L576 384C576 401.7 590.3 416 608 416C625.7 416 640 401.7 640 384L640 288C640 270.3 625.7 256 608 256z"/>
</svg>
{t!(i18n, play_vs_bot)}
</button>
<button class="login-btn login-btn-primary" on:click=on_create>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M598.1 139.4C608.8 131.6 611.2 116.6 603.4 105.9C595.6 95.2 580.6 92.8 569.9 100.6L495.4 154.8L485.5 148.2C465.8 135 442.6 128 418.9 128L359.7 128L359.3 128L215.7 128C189 128 163.2 136.9 142.3 153.1L70.1 100.6C59.4 92.8 44.4 95.2 36.6 105.9C28.8 116.6 31.2 131.6 41.9 139.4L129.9 203.4C139.5 210.3 152.6 209.3 161 201L164.9 197.1C178.4 183.6 196.7 176 215.8 176L262.1 176L170.4 267.7C154.8 283.3 154.8 308.6 170.4 324.3L171.2 325.1C218 372 294 372 340.9 325.1L368 298L465.8 395.8C481.4 411.4 481.4 436.7 465.8 452.4L456 462.2L425 431.2C415.6 421.8 400.4 421.8 391.1 431.2C381.8 440.6 381.7 455.8 391.1 465.1L419.1 493.1C401.6 503.5 381.9 509.8 361.5 511.6L313 463C303.6 453.6 288.4 453.6 279.1 463C269.8 472.4 269.7 487.6 279.1 496.9L294.1 511.9L290.3 511.9C254.2 511.9 219.6 497.6 194.1 472.1L65 343C55.6 333.6 40.4 333.6 31.1 343C21.8 352.4 21.7 367.6 31.1 376.9L160.2 506.1C194.7 540.6 241.5 560 290.3 560L342.1 560L343.1 561L344.1 560L349.8 560C398.6 560 445.4 540.6 479.9 506.1L499.8 486.2C501 485 502.1 483.9 503.2 482.7C503.9 482.2 504.5 481.6 505.1 481L609 377C618.4 367.6 618.4 352.4 609 343.1C599.6 333.8 584.4 333.7 575.1 343.1L521.3 396.9C517.1 384.1 510 372 499.8 361.8L385 247C375.6 237.6 360.4 237.6 351.1 247L307 291.1C280.5 317.6 238.5 319.1 210.3 295.7L309 197C322.4 183.6 340.6 176 359.6 175.9L368.1 175.9L368.3 175.9L419.1 175.9C433.3 175.9 447.2 180.1 459 188L482.7 204C491.1 209.6 502 209.3 510.1 203.4L598.1 139.4z"/>
</svg>
{t!(i18n, create_room)}
</button>
</div>
@ -283,15 +293,23 @@ fn NicknameModal(
let on_play = move |_: leptos::ev::MouseEvent| {
let chosen = nick.get().trim().to_string();
let chosen = if chosen.is_empty() { generate_nickname() } else { chosen };
let chosen = if chosen.is_empty() {
generate_nickname()
} else {
chosen
};
anon_nickname.set(Some(chosen));
match &pending {
PendingLobbyAction::Create { code } => {
cmd_tx.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok();
cmd_tx
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
.ok();
view_state.set(LobbyView::Waiting { code: code.clone() });
}
PendingLobbyAction::Join { code } => {
cmd_tx.unbounded_send(NetCommand::JoinRoom { room: code.clone() }).ok();
cmd_tx
.unbounded_send(NetCommand::JoinRoom { room: code.clone() })
.ok();
}
}
pending_action.set(None);

View file

@ -2,40 +2,40 @@
2013 EDITION — SUPPLEMENT TO THE REASONED DICTIONARY OF THE GAME OF TRICTRAC www.trictrac.org by Michel MALFILÂTRE (trictrac.org)
*Translator's note: French game terms are preserved in italics on first use. See [vocabulary.md](vocabulary.md) for a complete French/English mapping.*
_Translator's note: French game terms are preserved in italics on first use. See [vocabulary.md](vocabulary.md) for a complete French/English mapping._
There are two types of game in grand trictrac: the ordinary game and the scored game.
In both, the main laws and rules are the same; but the goal, scoring, and payments differ.
## ARTICLE I: THE ORDINARY GAME
It is played between two players; the goal is to be the first to score 12 holes (*trous*). One hole equals 12 points.
It is played between two players; the goal is to be the first to score 12 holes (_trous_). One hole equals 12 points.
## ARTICLE II: THE SCORED GAME
It can be played by 2, 3, or 4 players in teams or in *chouette* format. The goal is to win as many tokens as possible by playing an agreed number of rounds (*marqués*). A round always pits two players against each other. With three or four players, participants rotate at each round in a defined order.
It can be played by 2, 3, or 4 players in teams or in _chouette_ format. The goal is to win as many tokens as possible by playing an agreed number of rounds (_marqués_). A round always pits two players against each other. With three or four players, participants rotate at each round in a defined order.
To win a round, a player must score at least 6 holes and then leave (*s'en aller*) (see Article XV). The maximum number of holes per round is generally unlimited, but players may agree otherwise.
To win a round, a player must score at least 6 holes and then leave (_s'en aller_) (see Article XV). The maximum number of holes per round is generally unlimited, but players may agree otherwise.
If both players are tied at or above 6 holes when one player leaves, the round is drawn and must be replayed (*refait*) immediately.
If both players are tied at or above 6 holes when one player leaves, the round is drawn and must be replayed (_refait_) immediately.
## ARTICLE III: EQUIPMENT
The game is played on a board called a *trictrac*, composed of two tables: the small jan table and the big jan table. The first table contains each player's small jan and the second contains each player's big jan. One player's small jan is also the other player's return jan. Each jan consists of 6 alternately coloured fields (*flèches*).
The game is played on a board called a _trictrac_, composed of two tables: the small jan table and the big jan table. The first table contains each player's small jan and the second contains each player's big jan. One player's small jan is also the other player's return jan. Each jan consists of 6 alternately coloured fields (_flèches_).
The board has 24 triangular fields in total and 30 holes drilled into its rails and bands.
A hole is drilled at the base of each field. These holes hold each player's peg (*fichet*) to record the holes (games) won. The three holes on each side rail serve to place pegs at the start of the game, along with the flag (*pavillon*).
A hole is drilled at the base of each field. These holes hold each player's peg (_fichet_) to record the holes (games) won. The three holes on each side rail serve to place pegs at the start of the game, along with the flag (_pavillon_).
In addition to these three pegs (one of which is the flag), the game uses 30 checkers — 15 white and 15 black (or two other contrasting colours) — three tokens (*jetons*), two dice cups (*cornets*), and two six-sided dice.
In addition to these three pegs (one of which is the flag), the game uses 30 checkers — 15 white and 15 black (or two other contrasting colours) — three tokens (_jetons_), two dice cups (_cornets_), and two six-sided dice.
The scored game is also played with tokens used for payments, or with paper and pencil to keep a token account.
## ARTICLE IV: STARTING POSITION
At the start of the game, all checkers are stacked into two separate stacks (*talons*): one of white checkers, one of black. Each stack is placed facing the other on a corner field adjacent to one of the two outer side rails, called the starting rail. This rail may later become the exit rail.
At the start of the game, all checkers are stacked into two separate stacks (_talons_): one of white checkers, one of black. Each stack is placed facing the other on a corner field adjacent to one of the two outer side rails, called the starting rail. This rail may later become the exit rail.
Each player uses the checkers from the stack closest to them. The corner fields against the other outer side rail are the rest corners. The twelfth field of each player — counting the stack as the first — is therefore that player's rest corner, or simply: *corner*.
Each player uses the checkers from the stack closest to them. The corner fields against the other outer side rail are the rest corners. The twelfth field of each player — counting the stack as the first — is therefore that player's rest corner, or simply: _corner_.
Pegs are placed in the 3 holes of the starting rail, with the flag occupying the central hole. Three tokens are placed against this rail between the two stacks.
@ -47,7 +47,7 @@ An alternative method: one player rolls both dice; the player closest to the hig
In both cases, if the dice show the same value, they must be re-rolled. A game may therefore not begin with a double.
After each new setting (*relevé*), first-move privilege belongs to the player who first exited all their checkers or who left first (see Articles VIII and XV).
After each new setting (_relevé_), first-move privilege belongs to the player who first exited all their checkers or who left first (see Articles VIII and XV).
In the scored game with two players, first-move privilege alternates each round. With three or four players, it belongs to the player who remains to face a new opponent.
@ -57,11 +57,11 @@ In case of a replay, the player who had first-move privilege in the drawn round
Both dice must be rolled together with a dice cup. They are valid when they land flat inside the board, even if resting on a checker or token. If a die is broken, rests on a rail, or lands outside the board, both dice must be re-rolled.
The two numbers may be played with two checkers — each playing one number — or with a single checker playing both numbers successively in a chained move (*tout d'une*) (for 6 and 1: the 6 advances a checker six fields and the 1 advances another one field; or a single checker moves seven fields total, stopping on the first or sixth field as a resting point before reaching the seventh).
The two numbers may be played with two checkers — each playing one number — or with a single checker playing both numbers successively in a chained move (_tout d'une_) (for 6 and 1: the 6 advances a checker six fields and the 1 advances another one field; or a single checker moves seven fields total, stopping on the first or sixth field as a resting point before reaching the seventh).
Both numbers must be played if possible. If only one can be played and there is a choice, the higher number must be played.
Any unplayed number is penalised: this is a *jan-qui-ne-peut* (helpless man), worth 2 helplessness points per unplayed number, credited to the opponent.
Any unplayed number is penalised: this is a _jan-qui-ne-peut_ (helpless man), worth 2 helplessness points per unplayed number, credited to the opponent.
Dice must not be picked up before the move is fully played and all points marked (including school penalties).
@ -79,7 +79,7 @@ A checker may not be placed on a field occupied by the opponent's checker(s).
When all of a player's checkers are gathered in their last jan (return jan), they are exited from the board using the exit rail privilege, which grants this rail the value of one additional field.
A checker may be exited by an exact exit number that brings it directly to the exit rail, or by an overflow number (*nombre excédant*) that would carry the farthest checker beyond the rail. Other numbers — failing numbers (*nombres défaillants*) — must be played within the jan.
A checker may be exited by an exact exit number that brings it directly to the exit rail, or by an overflow number (_nombre excédant_) that would carry the farthest checker beyond the rail. Other numbers — failing numbers (_nombres défaillants_) — must be played within the jan.
A checker may be exited in a chained move. A player may choose not to exit a checker on an exact exit number and instead play another checker within the jan as a failing number, if possible; but an overflow number must always exit a checker.
@ -93,13 +93,13 @@ Exiting can occur multiple times in a game.
## ARTICLE IX: THE REST CORNER
The rest corner may only be taken simultaneously (*d'emblée*): two checkers must enter it at the same time. Likewise, it may only be vacated simultaneously. It must therefore always be occupied by at least two checkers. It is forbidden to place or leave a single checker on one's own rest corner.
The rest corner may only be taken simultaneously (_d'emblée_): two checkers must enter it at the same time. Likewise, it may only be vacated simultaneously. It must therefore always be occupied by at least two checkers. It is forbidden to place or leave a single checker on one's own rest corner.
Under any circumstances, it is forbidden to place one or more checkers on the opponent's rest corner.
An empty corner may, however, serve as a resting field for any checker during a chained move.
A player may take their corner naturally, by effect (*par effet*), or by puissance (*par puissance*) — the latter when the opponent's corner is empty and the player could take it simultaneously. By privilege, the player takes their own corner instead, as if stepping back one field.
A player may take their corner by effect (_par effet_, naturally), or by puissance (_par puissance_) — the latter when the opponent's corner is empty and the player could take it simultaneously. By privilege, the player takes their own corner instead, as if stepping back one field.
If a player can take their corner both by effect and by puissance, they must take it by effect.
@ -107,7 +107,7 @@ After vacating the corner, it may be retaken under the same conditions.
## ARTICLE X: HITTING CHECKERS
This *jan de récompense* (reward jan) occurs when an opponent's checker is exposed alone on a half-field and the player rolls numbers that could cover it with one or more of their own checkers.
This _jan de récompense_ (reward jan) occurs when an opponent's checker is exposed alone on a half-field and the player rolls numbers that could cover it with one or more of their own checkers.
The hit is always fictitious — it exists only as a potential; no checker is actually moved.
@ -127,14 +127,15 @@ Only one way is counted on a double, even when two checkers on a field could eac
Multiple checkers may be hit in the same move.
For each checker hit and for each way it is hit, this reward jan is worth:
- **2 points** on a normal roll, **4 points** on a double — if the hit checker is in the big jan table.
- **4 points** on a normal roll, **6 points** on a double — if the hit checker is in the small jan table or return jan.
Reward jans must be marked by the player who achieves them (under penalty of being "sent to school" — see Article XVI).
To hit a checker using the combined sum, the player must have a resting field (*repos*): a field where one die can land so that the second can (fictitiously) reach the target checker. This resting field must be either empty, already held by the player's own checkers, or occupied by a single opponent checker — which is then also hit.
To hit a checker using the combined sum, the player must have a resting field (_repos_): a field where one die can land so that the second can (fictitiously) reach the target checker. This resting field must be either empty, already held by the player's own checkers, or occupied by a single opponent checker — which is then also hit.
A *helpless man* (*jan-qui-ne-peut*) occurs when, attempting to hit using the combined sum, no free resting field exists and the player must stop on a full field held by the opponent. The hit is then a **false hit** (*à faux*), and the opponent gains as many points as the player would have scored with a true hit.
A _helpless man_ (_jan-qui-ne-peut_) occurs when, attempting to hit using the combined sum, no free resting field exists and the player must stop on a full field held by the opponent. The hit is then a **false hit** (_à faux_), and the opponent gains as many points as the player would have scored with a true hit.
A checker already hit with a true hit cannot also be hit with a false hit in the same move. However, multiple checkers may be hit simultaneously — some truly, others falsely.
@ -172,7 +173,7 @@ The player is not obliged to actually fill those two fields; they are free to pl
### THE FULL JAN (PLEIN)
A jan is full (*plein*) when a player occupies each of its six fields with at least two of their own checkers.
A jan is full (_plein_) when a player occupies each of its six fields with at least two of their own checkers.
Each player may fill their small jan, big jan, and return jan.
@ -208,7 +209,7 @@ A full jan is conserved when the player can play both dice without breaking it
Conserving a full jan is worth **4 points** on a normal roll and **6 points** on a double. There can be at most one way to conserve.
A player may use the privilege of conserving by helplessness (*par impuissance*) when, having a full jan, the position prevents playing one or both numbers. Only the number 6 allows this conservation, as all lower numbers can be played within the jan (even if it means breaking the full jan).
A player may use the privilege of conserving by helplessness (_par impuissance_) when, having a full jan, the position prevents playing one or both numbers. Only the number 6 allows this conservation, as all lower numbers can be played within the jan (even if it means breaking the full jan).
By privilege, the full return jan may be conserved by exiting one, two, or three checkers.
@ -230,11 +231,11 @@ Points and holes won must always be marked before touching one's checkers to pla
Points are marked with tokens. For **2 points**, the token is placed at the tip of the player's second field or between the second and third fields; for **4 points**, at the fourth or between the fourth and fifth; for **6 points**, at the sixth or against the cross-rail; for **8 points**, on the other side of that rail, in the big jan; for **10 points**, against the side rail of the big jan or at the tip of the rest corner field. **12 or 0 points** are marked against the starting rail between the two stacks, as at the start of the game.
12 points make a hole. If the 12 points of a hole were all scored consecutively from zero — that is, without the opponent having scored any points during that run — the hole is won *bredouille* and counts as **2 holes**. This double-hole advantage applies equally to the first and second player to start marking. The first player to mark uses a single token and can win the hole bredouille as long as the opponent scores nothing. If the opponent then scores, they mark with a double token called the *bredouille* and continue marking this way as long as the first player scores nothing. If they reach at least 12 points in this fashion, they win the hole bredouille in second. But if the first player scores again beforehand, they remove one of the opponent's two tokens (*débredouiller*), and neither player can thereafter win the hole bredouille. Once both players each have a single-token mark, the hole will necessarily be won simple by one or the other.
12 points make a hole. If the 12 points of a hole were all scored consecutively from zero — that is, without the opponent having scored any points during that run — the hole is won _bredouille_ and counts as **2 holes**. This double-hole advantage applies equally to the first and second player to start marking. The first player to mark uses a single token and can win the hole bredouille as long as the opponent scores nothing. If the opponent then scores, they mark with a double token called the _bredouille_ and continue marking this way as long as the first player scores nothing. If they reach at least 12 points in this fashion, they win the hole bredouille in second. But if the first player scores again beforehand, they remove one of the opponent's two tokens (_débredouiller_), and neither player can thereafter win the hole bredouille. Once both players each have a single-token mark, the hole will necessarily be won simple by one or the other.
Holes are marked with pegs. Each player advances their peg along the row of holes drilled at the base of the twelve fields in their small and big jans. The first hole is at the base of the stack, the twelfth and last at the base of the rest corner.
Earned holes must be marked before touching the tokens. Any opponent token (bredouille or not) is then reset to zero at the starting rail. The player's own token is also reset if they scored exactly 12 points; otherwise the remainder — *points de reste* — are marked normally with a token.
Earned holes must be marked before touching the tokens. Any opponent token (bredouille or not) is then reset to zero at the starting rail. The player's own token is also reset if they scored exactly 12 points; otherwise the remainder — _points de reste_ — are marked normally with a token.
If on the same move the opponent is owed points, they mark them afterwards, starting from zero, using one or two tokens depending on whether the player marked any remainder points.
@ -246,7 +247,7 @@ As with the hole bredouille, this advantage applies equally to the first and sec
## ARTICLE XVI: STAYING OR LEAVING
When a player wins one or more holes through their own dice roll, they may choose to stay (*tenir*) or use the privilege of leaving (*s'en aller*). If the winning points come from the opponent's roll (helpless man, schools), the player must stay.
When a player wins one or more holes through their own dice roll, they may choose to stay (_tenir_) or use the privilege of leaving (_s'en aller_). If the winning points come from the opponent's roll (helpless man, schools), the player must stay.
**Staying**: after marking the hole(s), the player resets the opponent's token if necessary, marks any remainder points, and continues playing normally. The opponent then marks any points they may have earned from this move (see Article XV).
@ -260,7 +261,7 @@ There are three types of fault in this game:
**1. Simple faults** — of little harm to the opponent; some can be corrected normally (e.g., playing out of turn, rolling outside the board, accidentally disturbing the position, forgetting to mark a school). No penalty is incurred for these faults.
**2. False move faults (*fausse case*)** — potentially harmful; occur when a checker is not played to the correct field given the numbers rolled, or when a rule of play is violated (laws regarding the rest corner, forbidden jans, filling, and conserving). A false move may give rise to a school when points have been marked for a jan that is not then actually performed — as the rules require (e.g., marking for filling or conserving but not doing so). In addition to the rule "checker touched, checker abandoned, checker played" (unless the player said "*j'adoube*"), the player must accept the opponent's decision regarding rectification of the fault.
**2. False move faults (_fausse case_)** — potentially harmful; occur when a checker is not played to the correct field given the numbers rolled, or when a rule of play is violated (laws regarding the rest corner, forbidden jans, filling, and conserving). A false move may give rise to a school when points have been marked for a jan that is not then actually performed — as the rules require (e.g., marking for filling or conserving but not doing so). In addition to the rule "checker touched, checker abandoned, checker played" (unless the player said "_j'adoube_"), the player must accept the opponent's decision regarding rectification of the fault.
The opponent must point out the fault(s) before rolling for their own move; they may rectify the fault in their own interest, while respecting the rules, or leave the position unchanged. If a corner was taken by puissance when it could have been taken by effect, the opponent may prevent the player from taking it on that move if the fault is recognised and an alternative play exists. If a half-field was falsely covered, the opponent may also prevent the covering.
@ -349,7 +350,7 @@ The queue is not mandatory when scoring is kept in writing, but may be counted b
Each player then settles their outstanding bets equitably with each opponent.
A **bet** (*pari*) is any round exceeding each player's contingent. The contingent is the average number of rounds played between two opponents.
A **bet** (_pari_) is any round exceeding each player's contingent. The contingent is the average number of rounds played between two opponents.
Thus, if two players play eight rounds, each player's contingent is four, and any round won or lost beyond four is a bet won or lost. This gain or loss is doubled since a bet won by one player is also a bet lost by the other.
@ -381,29 +382,29 @@ The game ends when all debts have been settled.
This table summarises the point value of all scoring events: jans and figures of the game.
"J" = the player (who rolled the dice); "A" = the opponent (*adversaire*): they indicate who benefits. Numbers indicate points scored.
"J" = the player (who rolled the dice); "A" = the opponent (_adversaire_): they indicate who benefits. Numbers indicate points scored.
| SCORING EVENT | Beneficiary | Per occurrence | Normal roll | Double |
|---|---|---|---|---|
| Six tables jan (three-roll jan) | J | — | 4 | — |
| Two tables jan | J | — | 4 | 6 |
| Contre two tables | A | — | 4 | 6 |
| Mezeas jan | J | — | 4 | 6 |
| Contre mezeas | A | — | 4 | 6 |
| Small jan filled | J | Per way | 4 | 6 |
| Small jan conserved | J | — | 4 | 6 |
| Big jan filled | J | Per way | 4 | 6 |
| Big jan conserved | J | — | 4 | 6 |
| Return jan filled | J | Per way | 4 | 6 |
| Return jan conserved | J | — | 4 | 6 |
| True hit in small jan table | J | Per way | 4 | 6 |
| False hit in small jan table | A | Per way | 4 | 6 |
| True hit in big jan table | J | Per way | 2 | 4 |
| False hit in big jan table | A | Per way | 2 | 4 |
| Corner hit | J | — | 4 | 6 |
| Exit (last checker) | J | — | 4 | 6 |
| Helpless man (unplayed number) | A | Per number | 2 | 2 |
| Misery pile achieved | J | — | 4 | 6 |
| Misery pile conserved | J | — | 4 | 6 |
| SCORING EVENT | Beneficiary | Per occurrence | Normal roll | Double |
| ------------------------------- | ----------- | -------------- | ----------- | ------ |
| Six tables jan (three-roll jan) | J | — | 4 | — |
| Two tables jan | J | — | 4 | 6 |
| Contre two tables | A | — | 4 | 6 |
| Mezeas jan | J | — | 4 | 6 |
| Contre mezeas | A | — | 4 | 6 |
| Small jan filled | J | Per way | 4 | 6 |
| Small jan conserved | J | — | 4 | 6 |
| Big jan filled | J | Per way | 4 | 6 |
| Big jan conserved | J | — | 4 | 6 |
| Return jan filled | J | Per way | 4 | 6 |
| Return jan conserved | J | — | 4 | 6 |
| True hit in small jan table | J | Per way | 4 | 6 |
| False hit in small jan table | A | Per way | 4 | 6 |
| True hit in big jan table | J | Per way | 2 | 4 |
| False hit in big jan table | A | Per way | 2 | 4 |
| Corner hit | J | — | 4 | 6 |
| Exit (last checker) | J | — | 4 | 6 |
| Helpless man (unplayed number) | A | Per number | 2 | 2 |
| Misery pile achieved | J | — | 4 | 6 |
| Misery pile conserved | J | — | 4 | 6 |
School penalties are worth to the opponent exactly the number of points that were over- or under-marked on that move.

View file

@ -28,10 +28,9 @@ French terms follow the mapping in [vocabulary.md](refs/vocabulary.md).
- Must be entered **simultaneously** (_d'emblée_): exactly 2 checkers must enter together.
- Must be vacated simultaneously: exactly 2 checkers must leave together.
- Always holds ≥ 2 checkers while occupied; a single checker there is forbidden.
- Three ways to take the corner:
- Two ways to take the corner:
- **By effect** (_par effet_): normal die values land exactly on it.
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could take both corners simultaneously, but by privilege takes their own instead (as if stepping back one field).
- **By chance** (_par effet_): general case when it results from the dice.
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could land exactly on the opponent's corner, but by privilege he takes their own instead (as if stepping back one field).
- If both by-effect and by-puissance are possible, by-effect takes priority.
- An empty corner may serve as a resting field during a chained move (not a landing).
- Placing checkers on the **opponent's** corner is always forbidden.

View file

@ -702,6 +702,23 @@ impl MoveRules {
}
board.unmove_checker(color, first_move);
}
// ── Par puissance (corner taken by force) ────────────────────────────
// Neither corner is taken via the normal loop above because the die
// would land on field 13 (opponent corner), which is always rejected
// by check_corner_rules. Generate the canonical par-puissance pair
// once here; the deduplication step in get_possible_moves_sequences
// removes any duplicate produced by the swapped-dice second pass.
if !self.can_take_corner_by_effect() {
if let Some(seq) = self.try_puissance_corner_seq(dice1, dice2) {
if filling_seqs.map_or(true, |seqs| seqs.is_empty() || seqs.contains(&seq))
&& !moves_seqs.contains(&seq)
{
moves_seqs.push(seq);
}
}
}
moves_seqs
}
@ -781,6 +798,60 @@ impl MoveRules {
let (count2, opt_color2) = res2.unwrap();
count1 > 0 && count2 > 0 && opt_color1 == Some(color) && opt_color2 == Some(color)
}
/// Returns the par-puissance corner move pair if the conditions are met:
/// both corners empty, each die has an own checker exactly one field before
/// the opponent's corner (field 13). The move with the lower source field
/// is returned first (canonical ordering so both dice-order calls produce
/// the same pair and the outer deduplication collapses them to one entry).
fn try_puissance_corner_seq(&self, dice1: u8, dice2: u8) -> Option<(CheckerMove, CheckerMove)> {
let own_corner: Field = 12; // MoveRules always works from White's perspective
let opp_corner: Field = 13;
let (count_own, _) = self.board.get_field_checkers(own_corner).ok()?;
let (count_opp, _) = self.board.get_field_checkers(opp_corner).ok()?;
if count_own > 0 || count_opp > 0 {
return None;
}
// Source field for each die: the field whose checker would reach the
// opponent's corner with a normal move.
let f1 = opp_corner.checked_sub(dice1 as usize)?;
let f2 = opp_corner.checked_sub(dice2 as usize)?;
if f1 == 0 || f2 == 0 {
return None;
}
let has_white = |f: Field| -> bool {
self.board
.get_field_checkers(f)
.map(|(c, col)| c >= 1 && col == Some(&Color::White))
.unwrap_or(false)
};
if dice1 == dice2 {
// Doublet: both moves from the same field, need ≥ 2 own checkers.
let ok = self
.board
.get_field_checkers(f1)
.map(|(c, col)| c >= 2 && col == Some(&Color::White))
.unwrap_or(false);
if !ok {
return None;
}
let m = CheckerMove::new(f1, own_corner).ok()?;
Some((m, m))
} else {
if !has_white(f1) || !has_white(f2) {
return None;
}
// Canonical: lower source field first.
let (fa, fb) = if f1 <= f2 { (f1, f2) } else { (f2, f1) };
let ma = CheckerMove::new(fa, own_corner).ok()?;
let mb = CheckerMove::new(fb, own_corner).ok()?;
Some((ma, mb))
}
}
}
#[cfg(test)]
@ -1588,6 +1659,21 @@ mod tests {
),
];
assert_eq!(moves, state.get_possible_moves_sequences(true, vec![]));
// Prise de coin par puissance
let mut board = Board::new();
board.set_positions(
&crate::Color::White,
[
0, 0, 0, 0, 0, 0, 0, 0, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, -13,
],
);
let state = MoveRules::new(&Color::White, &board, Dice { values: (3, 2) });
let moves = vec![(
CheckerMove::new(10, 12).unwrap(),
CheckerMove::new(11, 12).unwrap(),
)];
assert_eq!(moves, state.get_possible_moves_sequences(true, vec![]));
}
#[test]