Compare commits

..

No commits in common. "main" and "python_and_cxx_bindings" have entirely different histories.

136 changed files with 10206 additions and 26508 deletions

3
.gitignore vendored
View file

@ -15,6 +15,3 @@ profile.json
bot/models bot/models
client_web/dist client_web/dist
var var
deploy
clients/**/dist

6963
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,4 @@
[workspace.package]
version = "0.2.17"
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = ["client_cli", "bot", "store", "spiel_bot", "client_web"]
"store",
"clients/backbone-lib",
"clients/web",
"server/protocol",
"server/relay-server",
]
default-members = [
"store",
"clients/backbone-lib",
"server/protocol",
"server/relay-server",
]
# For the server we will need opt-level='3'
[profile.release]
opt-level = 'z' # Minimum space
lto = "fat" # Aggressive Link Time Optimization
codegen-units = 1

View file

@ -2,31 +2,40 @@
This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation. This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation.
The project is on its early stages.
Rules (without "schools") are implemented, as well as a rudimentary terminal interface which allow you to play against a bot which plays randomly.
Training of AI bots is the work in progress.
## Usage ## Usage
Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands. `cargo run --bin=client_cli -- --bot random`
```bash ## Roadmap
# Run the relay server
just build-relay
just run-relay # listens on :8080
# Run the game (separate terminal) - [x] rules
just dev - [x] command line interface
``` - [x] basic bot (random play)
- [ ] AI bot
Open a browser window at `http://127.0.0.1:9091`. You can play against a very basic bot, or invite an other player to connect at the same address. - [ ] network game
- [ ] web client
## Code structure ## Code structure
- game rules and game state are implemented in the _store/_ folder. - game rules and game state are implemented in the _store/_ folder.
- a server for the network game is implemented in _server/relay-server_, which uses _server/protocol_ - the command-line application is implemented in _client_cli/_; it allows you to play against a bot, or to have two bots play against each other
- the web client is in _clients/web_, it connects to the server using the _clients/backbone-lib_ library - the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders.
- the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other
- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. This is a work in progress, they are not performant at all.
## Inspirations ### _store_ package
The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_ and _server/relay-server_ is a [Leptos](https://leptos.dev/)-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented.
The web client UX/UI is inspired by https://playtiao.com. ### _client_cli_ package
`client_cli/src/game_runner.rs` contains the logic to make two bots play against each other.
### _bot_ package
- `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list.
- `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (<https://burn.dev/>).
- `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC).

View file

@ -13,7 +13,7 @@ path = "src/burnrl/main.rs"
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
trictrac-store = { path = "../store" } trictrac-store = { path = "../store", features = ["python"] }
rand = "0.9" rand = "0.9"
env_logger = "0.10" env_logger = "0.10"
burn = { version = "0.20", features = ["ndarray", "autodiff"] } burn = { version = "0.20", features = ["ndarray", "autodiff"] }

5
bot/python/test.py Normal file
View file

@ -0,0 +1,5 @@
import trictrac_store
game = trictrac_store.TricTrac()
print(game.current_player_idx())
print(game.get_legal_actions(game.current_player_idx()))

View file

@ -13,9 +13,9 @@ bincode = "1.3.3"
pico-args = "0.5.0" pico-args = "0.5.0"
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
renet = "0.0.13" renet = "0.0.13"
trictrac-store = { path = "../../store" } trictrac-store = { path = "../store", features = ["python"] }
trictrac-bot = { path = "../../bot" } trictrac-bot = { path = "../bot" }
spiel_bot = { path = "../../spiel_bot" } spiel_bot = { path = "../spiel_bot" }
itertools = "0.13.0" itertools = "0.13.0"
env_logger = "0.11.6" env_logger = "0.11.6"
log = "0.4.20" log = "0.4.20"

25
client_web/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "client_web"
version = "0.1.0"
edition = "2021"
[package.metadata.leptos-i18n]
default = "en"
locales = ["en", "fr"]
[dependencies]
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
trictrac-store = { path = "../store" }
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
futures = "0.3"
rand = "0.9"
gloo-storage = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
# Must be a direct dependency (not just transitive) for the feature to take effect.
getrandom = { version = "0.3", features = ["wasm_js"] }

2
client_web/Trunk.toml Normal file
View file

@ -0,0 +1,2 @@
[serve]
port = 9092

418
client_web/assets/style.css Normal file
View file

@ -0,0 +1,418 @@
/* ── Reset & base ──────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: sans-serif;
background: #c8b084;
display: flex;
justify-content: center;
padding: 1.5rem;
min-height: 100vh;
}
/* ── Login / Connecting screens ────────────────────────────────────── */
.login-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 320px;
margin-top: 4rem;
}
.login-container h1 { font-size: 2rem; text-align: center; margin-bottom: 0.5rem; }
input[type="text"] {
padding: 0.5rem 0.75rem;
font-size: 1rem;
border: 1px solid #aaa;
border-radius: 4px;
}
.error-msg { color: #c00; font-size: 0.9rem; }
.connecting { font-size: 1.2rem; margin-top: 4rem; text-align: center; }
/* ── Buttons ────────────────────────────────────────────────────────── */
.btn {
padding: 0.5rem 1.25rem;
font-size: 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-primary { background: #3a6b3a; color: #fff; }
.btn-secondary { background: #5a4a2a; color: #fff; }
.btn-bot { background: #2a5a7a; color: #fff; }
.btn:not(:disabled):hover { opacity: 0.85; }
/* ── Game container ─────────────────────────────────────────────────── */
.game-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
}
/* ── Language switcher ──────────────────────────────────────────────── */
.lang-switcher {
display: flex;
gap: 0.25rem;
}
.lang-switcher button {
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
border: 1px solid rgba(0,0,0,0.3);
border-radius: 3px;
background: transparent;
cursor: pointer;
color: inherit;
opacity: 0.6;
}
.lang-switcher button.lang-active {
opacity: 1;
font-weight: bold;
background: rgba(0,0,0,0.12);
}
.login-container .lang-switcher {
justify-content: flex-end;
margin-bottom: 1rem;
}
/* ── Top bar ─────────────────────────────────────────────────────────── */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.quit-link {
font-size: 0.85rem;
color: #5a4a2a;
text-decoration: underline;
cursor: pointer;
}
/* ── Player score panel ─────────────────────────────────────────────── */
.player-score-panel {
background: #f5edd8;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
width: 100%;
}
.player-score-header {
margin-bottom: 0.3rem;
}
.player-name {
font-weight: bold;
font-size: 1rem;
}
.score-bars {
display: flex;
flex-direction: column;
gap: 4px;
}
.score-bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.score-bar-label {
font-size: 0.8rem;
color: #555;
width: 3.5rem;
text-align: right;
flex-shrink: 0;
}
.score-bar {
width: 140px;
height: 10px;
background: rgba(0,0,0,0.12);
border-radius: 5px;
overflow: hidden;
flex-shrink: 0;
}
.score-bar-fill {
height: 100%;
border-radius: 5px;
transition: width 0.3s;
}
.score-bar-points { background: #4a7a3a; }
.score-bar-holes { background: #7a4a2a; }
.score-bar-value {
font-size: 0.8rem;
color: #444;
min-width: 2.5rem;
}
.bredouille-badge {
font-size: 0.7rem;
font-weight: bold;
color: #fff;
background: #c07800;
border-radius: 3px;
padding: 0.05em 0.35em;
cursor: default;
}
.player-jans {
margin-top: 0.35rem;
border-top: 1px solid rgba(0,0,0,0.1);
padding-top: 0.25rem;
}
/* ── Board + side panel ─────────────────────────────────────────────── */
.board-and-panel {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 1rem;
}
.side-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 160px;
padding-top: 0.25rem;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* ── Status bar ─────────────────────────────────────────────────────── */
.status-bar {
display: flex;
gap: 1rem;
align-items: center;
font-size: 1.05rem;
font-weight: 500;
}
/* ── Dice bar ───────────────────────────────────────────────────────── */
.dice-bar {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* ── Die face (SVG) ─────────────────────────────────────────────────── */
.die-face rect {
fill: #fffff0;
stroke: #2a1a00;
stroke-width: 2;
}
.die-face circle {
fill: #1a0a00;
}
.die-face.die-used rect {
fill: #d8d4c8;
stroke: #8a7a60;
}
.die-face.die-used circle {
fill: #8a7a60;
}
/* ── Jan panel ──────────────────────────────────────────────────────── */
.jan-panel {
display: flex;
flex-direction: column;
gap: 2px;
background: #f5edd8;
border-radius: 6px;
padding: 0.4rem 1rem;
font-size: 0.9rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
min-width: 260px;
}
.jan-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 2px 4px;
border-radius: 3px;
}
.jan-expandable { cursor: pointer; }
.jan-expandable:hover { background: rgba(0,0,0,0.06); }
.jan-positive { color: #1a5c1a; }
.jan-negative { color: #8b1a1a; }
.jan-label { flex: 1; }
.jan-tag {
font-size: 0.75rem;
padding: 0.1em 0.4em;
border-radius: 3px;
background: rgba(0,0,0,0.08);
color: #555;
white-space: nowrap;
}
.jan-pts { font-weight: bold; text-align: right; min-width: 3rem; }
.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; }
.jan-moves.hidden { display: none; }
.jan-move-line { font-family: monospace; font-size: 0.8rem; color: #444; }
/* ── Game-over overlay ──────────────────────────────────────────────── */
.game-over-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.game-over-box {
background: #f5edd8;
border-radius: 8px;
padding: 2rem 2.5rem;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 1.25rem;
min-width: 260px;
}
.game-over-box h2 {
font-size: 1.75rem;
}
.game-over-winner {
font-size: 1.25rem;
font-weight: bold;
color: #3a6b3a;
}
.game-over-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
/* ── Board ──────────────────────────────────────────────────────────── */
.board {
background: #2e6b2e;
border: 4px solid #1a3d1a;
border-radius: 8px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
user-select: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
position: relative;
}
.board-row {
display: flex;
gap: 4px;
}
.board-quarter {
display: flex;
gap: 2px;
}
.board-bar {
width: 20px;
background: #1a3d1a;
border-radius: 3px;
}
.board-center-bar {
height: 12px;
background: #1a3d1a;
border-radius: 3px;
}
/* ── Fields ─────────────────────────────────────────────────────────── */
.field {
width: 60px;
height: 180px;
background: #d4a843;
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 4px 2px;
position: relative;
transition: background 0.1s;
}
/* Alternating field colours */
.board-quarter .field:nth-child(odd) { background: #c49030; }
.board-quarter .field:nth-child(even) { background: #d4a843; }
.top-row .field { justify-content: flex-start; }
.field.clickable { cursor: pointer; }
.field.clickable:hover { background: #e8c060 !important; }
.field.selected { background: #88bb44 !important; outline: 2px solid #446622; }
.field.dest { background: #aad060 !important; }
.field-num {
font-size: 0.65rem;
color: rgba(0,0,0,0.45);
position: absolute;
bottom: 2px;
}
.top-row .field-num { bottom: auto; top: 2px; }
/* ── Checkers ───────────────────────────────────────────────────────── */
.checker-stack {
display: flex;
flex-direction: column;
align-items: center;
}
.checker {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: bold;
border: 2px solid rgba(0,0,0,0.3);
box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 1px 3px rgba(0,0,0,0.3);
flex-shrink: 0;
}
.checker + .checker { margin-top: 2px; }
.checker.white {
background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc);
color: #333;
}
.checker.black {
background: radial-gradient(circle at 35% 35%, #555555, #111111);
color: #eee;
}

View file

@ -6,7 +6,6 @@
<title>Trictrac</title> <title>Trictrac</title>
<link data-trunk rel="rust" /> <link data-trunk rel="rust" />
<link data-trunk rel="css" href="assets/style.css" /> <link data-trunk rel="css" href="assets/style.css" />
<link data-trunk rel="copy-file" href="assets/diceroll.mp3" />
</head> </head>
<body></body> <body></body>
</html> </html>

View file

@ -0,0 +1,42 @@
{
"room_name_placeholder": "Room name",
"create_room": "Create Room",
"join_room": "Join Room",
"connecting": "Connecting…",
"game_over": "Game over",
"waiting_for_opponent": "Waiting for opponent…",
"your_turn_roll": "Your turn — roll the dice",
"hold_or_go": "Hold or Go?",
"select_move": "Select move {{ n }} of 2",
"your_turn": "Your turn",
"opponent_turn": "Opponent's turn",
"room_label": "Room: {{ id }}",
"quit": "Quit",
"roll_dice": "Roll dice",
"go": "Go",
"empty_move": "Empty move",
"you_suffix": " (you)",
"points_label": "Points",
"holes_label": "Holes",
"bredouille_title": "Can bredouille",
"jan_double": "double",
"jan_simple": "simple",
"jan_filled_quarter": "Quarter filled",
"jan_true_hit_small": "True hit (small jan)",
"jan_true_hit_big": "True hit (big jan)",
"jan_true_hit_corner": "True hit (opp. corner)",
"jan_first_exit": "First to exit",
"jan_six_tables": "Six tables",
"jan_two_tables": "Two tables",
"jan_mezeas": "Mezeas",
"jan_false_hit_small": "False hit (small jan)",
"jan_false_hit_big": "False hit (big jan)",
"jan_contre_two": "Contre two tables",
"jan_contre_mezeas": "Contre mezeas",
"jan_helpless_man": "Helpless man",
"play_vs_bot": "Play vs Bot",
"vs_bot_label": "vs Bot",
"you_win": "You win!",
"opp_wins": "{{ name }} wins!",
"play_again": "Play again"
}

View file

@ -0,0 +1,42 @@
{
"room_name_placeholder": "Nom de la salle",
"create_room": "Créer une salle",
"join_room": "Rejoindre",
"connecting": "Connexion en cours…",
"game_over": "Partie terminée",
"waiting_for_opponent": "En attente de l'adversaire…",
"your_turn_roll": "À votre tour — lancez les dés",
"hold_or_go": "Tenir ou s'en aller ?",
"select_move": "Sélectionner le coup {{ n }} sur 2",
"your_turn": "Votre tour",
"opponent_turn": "Tour de l'adversaire",
"room_label": "Salle : {{ id }}",
"quit": "Quitter",
"roll_dice": "Lancer les dés",
"go": "S'en aller",
"empty_move": "Mouvement impossible",
"you_suffix": " (vous)",
"points_label": "Points",
"holes_label": "Trous",
"bredouille_title": "Peut faire bredouille",
"jan_double": "double",
"jan_simple": "simple",
"jan_filled_quarter": "Remplissage",
"jan_true_hit_small": "Battage à vrai (petit jan)",
"jan_true_hit_big": "Battage à vrai (grand jan)",
"jan_true_hit_corner": "Battage coin adverse",
"jan_first_exit": "Premier sorti",
"jan_six_tables": "Jan de six tables",
"jan_two_tables": "Jan de deux tables",
"jan_mezeas": "Jan de mézéas",
"jan_false_hit_small": "Battage à faux (petit jan)",
"jan_false_hit_big": "Battage à faux (grand jan)",
"jan_contre_two": "Contre jan de deux tables",
"jan_contre_mezeas": "Contre jan de mezeas",
"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 !",
"play_again": "Rejouer"
}

331
client_web/src/app.rs Normal file
View file

@ -0,0 +1,331 @@
use futures::channel::mpsc;
use futures::{FutureExt, StreamExt};
use gloo_storage::{LocalStorage, Storage};
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde::{Deserialize, Serialize};
use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate};
use crate::components::{ConnectingScreen, GameScreen, LoginScreen};
use crate::i18n::I18nContextProvider;
use crate::trictrac::backend::TrictracBackend;
use crate::trictrac::bot_local::bot_decide;
use crate::trictrac::types::{GameDelta, PlayerAction, ViewState};
const RELAY_URL: &str = "ws://127.0.0.1:8080/ws";
const GAME_ID: &str = "trictrac";
const STORAGE_KEY: &str = "trictrac_session";
/// The state the UI needs to render the game screen.
#[derive(Clone, PartialEq)]
pub struct GameUiState {
pub view_state: ViewState,
/// 0 = host, 1 = guest
pub player_id: u16,
pub room_id: String,
pub is_bot_game: bool,
}
/// Which screen is currently shown.
#[derive(Clone, PartialEq)]
pub enum Screen {
Login { error: Option<String> },
Connecting,
Playing(GameUiState),
}
/// Commands sent from UI event handlers into the network task.
pub enum NetCommand {
CreateRoom {
room: String,
},
JoinRoom {
room: String,
},
Reconnect {
relay_url: String,
game_id: String,
room_id: String,
token: u64,
host_state: Option<Vec<u8>>,
},
PlayVsBot,
Action(PlayerAction),
Disconnect,
}
/// Stored in localStorage to reconnect after a page refresh.
#[derive(Serialize, Deserialize)]
struct StoredSession {
relay_url: String,
game_id: String,
room_id: String,
token: u64,
#[serde(default)]
is_host: bool,
#[serde(default)]
view_state: Option<ViewState>,
}
fn save_session(session: &StoredSession) {
LocalStorage::set(STORAGE_KEY, session).ok();
}
fn load_session() -> Option<StoredSession> {
LocalStorage::get::<StoredSession>(STORAGE_KEY).ok()
}
fn clear_session() {
LocalStorage::delete(STORAGE_KEY);
}
#[component]
pub fn App() -> impl IntoView {
let stored = load_session();
let initial_screen = if stored.is_some() {
Screen::Connecting
} else {
Screen::Login { error: None }
};
let screen = RwSignal::new(initial_screen);
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
provide_context(cmd_tx.clone());
if let Some(s) = stored {
let host_state = s
.view_state
.as_ref()
.and_then(|vs| serde_json::to_vec(vs).ok());
cmd_tx
.unbounded_send(NetCommand::Reconnect {
relay_url: s.relay_url,
game_id: s.game_id,
room_id: s.room_id,
token: s.token,
host_state,
})
.ok();
}
spawn_local(async move {
loop {
// Wait for a connect/reconnect command (or PlayVsBot).
// None means "play vs bot"; Some((config, is_reconnect)) means "connect to relay".
let remote_config: Option<(RoomConfig, bool)> = loop {
match cmd_rx.next().await {
Some(NetCommand::PlayVsBot) => break None,
Some(NetCommand::CreateRoom { room }) => {
break Some((
RoomConfig {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room,
rule_variation: 0,
role: RoomRole::Create,
reconnect_token: None,
host_state: None,
},
false,
));
}
Some(NetCommand::JoinRoom { room }) => {
break Some((
RoomConfig {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room,
rule_variation: 0,
role: RoomRole::Join,
reconnect_token: None,
host_state: None,
},
false,
));
}
Some(NetCommand::Reconnect {
relay_url,
game_id,
room_id,
token,
host_state,
}) => {
break Some((
RoomConfig {
relay_url,
game_id,
room_id,
rule_variation: 0,
role: RoomRole::Join,
reconnect_token: Some(token),
host_state,
},
true,
));
}
_ => {} // Ignore game commands while disconnected.
}
};
if remote_config.is_none() {
loop {
let restart = run_local_bot_game(screen, &mut cmd_rx).await;
if !restart { break; }
}
screen.set(Screen::Login { error: None });
continue;
}
let (config, is_reconnect) = remote_config.unwrap();
screen.set(Screen::Connecting);
let room_id_for_storage = config.room_id.clone();
let mut session: GameSession<PlayerAction, GameDelta, ViewState> =
match GameSession::connect::<TrictracBackend>(config).await {
Ok(s) => s,
Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => {
if is_reconnect {
clear_session();
}
screen.set(Screen::Login { error: Some(e) });
continue;
}
};
if !session.is_host {
save_session(&StoredSession {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room_id_for_storage.clone(),
token: session.reconnect_token,
is_host: false,
view_state: None,
});
}
let is_host = session.is_host;
let player_id = session.player_id;
let reconnect_token = session.reconnect_token;
let mut vs = ViewState::default_with_names("Host", "Guest");
loop {
futures::select! {
cmd = cmd_rx.next().fuse() => match cmd {
Some(NetCommand::Action(action)) => {
session.send_action(action);
}
_ => {
clear_session();
session.disconnect();
screen.set(Screen::Login { error: None });
break;
}
},
event = session.next_event().fuse() => match event {
Some(SessionEvent::Update(u)) => {
match u {
ViewStateUpdate::Full(state) => vs = state,
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
}
if is_host {
save_session(&StoredSession {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room_id_for_storage.clone(),
token: reconnect_token,
is_host: true,
view_state: Some(vs.clone()),
});
}
screen.set(Screen::Playing(GameUiState {
view_state: vs.clone(),
player_id,
room_id: room_id_for_storage.clone(),
is_bot_game: false,
}));
}
Some(SessionEvent::Disconnected(reason)) => {
screen.set(Screen::Login { error: reason });
break;
}
None => {
screen.set(Screen::Login { error: None });
break;
}
}
}
}
}
});
view! {
<I18nContextProvider>
{move || match screen.get() {
Screen::Login { error } => view! { <LoginScreen error=error /> }.into_any(),
Screen::Connecting => view! { <ConnectingScreen /> }.into_any(),
Screen::Playing(state) => view! { <GameScreen state=state /> }.into_any(),
}}
</I18nContextProvider>
}
}
/// Runs one local bot game. Returns `true` if the player wants to play again.
async fn run_local_bot_game(
screen: RwSignal<Screen>,
cmd_rx: &mut futures::channel::mpsc::UnboundedReceiver<NetCommand>,
) -> bool {
let mut backend = TrictracBackend::new(0);
backend.player_arrival(0);
backend.player_arrival(1);
let mut vs = ViewState::default_with_names("You", "Bot");
drain_and_update(&mut backend, &mut vs, screen);
loop {
match cmd_rx.next().await {
Some(NetCommand::Action(action)) => {
backend.inform_rpc(0, action);
}
Some(NetCommand::PlayVsBot) => return true,
_ => return false,
}
drain_and_update(&mut backend, &mut vs, screen);
loop {
match bot_decide(backend.get_game()) {
None => break,
Some(action) => {
backend.inform_rpc(1, action);
drain_and_update(&mut backend, &mut vs, screen);
}
}
}
}
}
fn drain_and_update(
backend: &mut TrictracBackend,
vs: &mut ViewState,
screen: RwSignal<Screen>,
) {
for cmd in backend.drain_commands() {
match cmd {
BackendCommand::ResetViewState => {
*vs = backend.get_view_state().clone();
}
BackendCommand::Delta(delta) => {
vs.apply_delta(&delta);
}
_ => {}
}
}
screen.set(Screen::Playing(GameUiState {
view_state: vs.clone(),
player_id: 0,
room_id: String::new(),
is_bot_game: true,
}));
}

View file

@ -0,0 +1,371 @@
use leptos::prelude::*;
use trictrac_store::CheckerMove;
use crate::trictrac::types::{SerTurnStage, ViewState};
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1];
/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6];
const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12];
const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
/// Returns the displayed board value for `field_num` after applying `staged_moves`.
/// Field numbers are always in white's coordinate system (124).
fn displayed_value(
base_board: [i8; 24],
staged_moves: &[(u8, u8)],
is_white: bool,
field_num: u8,
) -> i8 {
let mut val = base_board[(field_num - 1) as usize];
let delta: i8 = if is_white { 1 } else { -1 };
for &(from, to) in staged_moves {
if from == field_num {
val -= delta;
}
if to == field_num {
val += delta;
}
}
val
}
/// Fields whose checkers may be selected as the next origin given already-staged moves.
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter()
.map(|(m1, _)| m1.get_from() as u8)
.filter(|&f| f != 0)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.map(|(_, m2)| m2.get_from() as u8)
.filter(|&f| f != 0)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
/// Pixel center of a board field in the SVG overlay coordinate space.
/// Geometry is derived from CSS: field 60px wide, 180px tall, board padding 4px,
/// board-row gap 4px, board-bar 20px, board-center-bar 12px.
fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
if f == 0 || f > 24 {
return None;
}
let (qi, right, top): (usize, bool, bool) = if is_white {
match f {
13..=18 => (f - 13, false, true),
19..=24 => (f - 19, true, true),
7..=12 => (12 - f, false, false),
1..=6 => (6 - f, true, false),
_ => return None,
}
} else {
match f {
1..=6 => (f - 1, false, true),
7..=12 => (f - 7, true, true),
19..=24 => (24 - f, false, false),
13..=18 => (18 - f, true, false),
_ => return None,
}
};
// Left-quarter field i center x: 4 + i*62 + 30 = 34 + 62i
// Right-quarter field i center x: 4 + 370 + 4 + 20 + 4 + i*62 + 30 = 432 + 62i
let x = if right { 432.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 };
// Top row center y: 4 + 90 = 94; bot row: 4 + 180 + 4 + 12 + 4 + 90 = 294
let y = if top { 94.0 } else { 294.0 };
Some((x, y))
}
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let (x1, y1) = fp;
let (x2, y2) = tp;
let dx = x2 - x1;
let dy = y2 - y1;
let len = (dx * dx + dy * dy).sqrt();
if len < 10.0 {
return view! { <g /> }.into_any();
}
let nx = dx / len;
let ny = dy / len;
let px = -ny;
let py = nx;
// Shrink line ends so arrows don't overlap the checker stack
let lx1 = x1 + nx * 20.0;
let ly1 = y1 + ny * 20.0;
let lx2 = x2 - nx * 15.0;
let ly2 = y2 - ny * 15.0;
// Arrowhead triangle at (x2, y2)
let ah = 15.0_f32;
let aw = 7.0_f32;
let bx = x2 - nx * ah;
let bary = y2 - ny * ah;
let pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2, y2,
bx + px * aw, bary + py * aw,
bx - px * aw, bary - py * aw,
);
let shadow_pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2, y2,
bx + px * (aw + 1.5), bary + py * (aw + 1.5),
bx - px * (aw + 1.5), bary - py * (aw + 1.5),
);
view! {
<g>
// Drop-shadow for readability on coloured fields
<line
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
style="stroke:rgba(0,0,0,0.45);stroke-width:5;stroke-linecap:round"
/>
<polygon points=shadow_pts style="fill:rgba(0,0,0,0.45)" />
// Gold arrow
<line
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
style="stroke:rgba(255,215,0,0.9);stroke-width:3;stroke-linecap:round"
/>
<polygon points=pts style="fill:rgba(255,215,0,0.9)" />
</g>
}
.into_any()
}
/// Valid destinations for a selected origin given already-staged moves.
/// May include 0 (exit); callers handle that case.
fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs.iter()
.filter(|(m1, _)| m1.get_from() as u8 == origin)
.map(|(m1, _)| m1.get_to() as u8)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, m2)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
&& m2.get_from() as u8 == origin
})
.map(|(_, m2)| m2.get_to() as u8)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
#[component]
pub fn Board(
view_state: ViewState,
player_id: u16,
/// Pending origin selection (first click of a move pair).
selected_origin: RwSignal<Option<u8>>,
/// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move.
staged_moves: RwSignal<Vec<(u8, u8)>>,
/// All valid two-move sequences for this turn (empty when not in move stage).
valid_sequences: Vec<(CheckerMove, CheckerMove)>,
) -> impl IntoView {
let board = view_state.board;
let is_move_stage = view_state.active_mp_player == Some(player_id)
&& matches!(
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let is_white = player_id == 0;
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// `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> {
nums.iter()
.map(|&field_num| {
// Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)>
// is Send, which Leptos requires for reactive attribute functions.
let seqs_c = valid_sequences.clone();
let seqs_k = valid_sequences.clone();
view! {
<div
class=move || {
let staged = staged_moves.get();
let val = displayed_value(board, &staged, is_white, field_num);
let is_mine = if is_white { val > 0 } else { val < 0 };
let can_stage = is_move_stage && staged.len() < 2;
let sel = selected_origin.get();
let mut cls = "field".to_string();
if seqs_c.is_empty() {
// No restriction (dice not rolled or not move stage)
if can_stage && (sel.is_some() || is_mine) {
cls.push_str(" clickable");
}
if sel == Some(field_num) { cls.push_str(" selected"); }
if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest");
}
} else if can_stage {
if let Some(origin) = sel {
if origin == field_num {
cls.push_str(" selected clickable");
} else {
let dests = valid_dests_for(&seqs_c, &staged, origin);
// Only highlight non-exit destinations (field 0 = exit has no tile)
if dests.iter().any(|&d| d == field_num && d != 0) {
cls.push_str(" clickable dest");
}
}
} else {
let origins = valid_origins_for(&seqs_c, &staged);
if origins.iter().any(|&o| o == field_num) {
cls.push_str(" clickable");
}
}
}
cls
}
on:click=move |_| {
if !is_move_stage { return; }
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
}
Some(origin) => {
let valid = if seqs_k.is_empty() {
true
} else {
valid_dests_for(&seqs_k, &staged, origin)
.iter()
.any(|&d| d == field_num)
};
if valid {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None);
}
}
None => {
if seqs_k.is_empty() {
let val = displayed_value(board, &staged, is_white, field_num);
if is_white && val > 0 || !is_white && val < 0 {
selected_origin.set(Some(field_num));
}
} else {
let origins = valid_origins_for(&seqs_k, &staged);
if origins.iter().any(|&o| o == field_num) {
let dests = valid_dests_for(&seqs_k, &staged, field_num);
if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
// All destinations are exits: auto-stage
staged_moves.update(|v| v.push((field_num, 0)));
} else {
selected_origin.set(Some(field_num));
}
}
}
}
}
}
>
<span class="field-num">{field_num}</span>
{move || {
let moves = staged_moves.get();
let val = displayed_value(board, &moves, is_white, field_num);
let count = val.unsigned_abs();
(count > 0).then(|| {
let color = if val > 0 { "white" } else { "black" };
let display_n = (count as usize).min(4);
// outermost index: last for top rows, first for bottom rows.
let outer_idx = if is_top_row { display_n - 1 } else { 0 };
let chips: Vec<AnyView> = (0..display_n).map(|i| {
let label = if i == outer_idx && count >= 5 {
count.to_string()
} else {
String::new()
};
view! {
<div class=format!("checker {color}")>{label}</div>
}.into_any()
}).collect();
view! { <div class="checker-stack">{chips}</div> }
})
}}
</div>
}
.into_any()
})
.collect()
};
let (tl, tr, bl, br) = if is_white {
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
} else {
(&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
};
view! {
<div class="board">
<div class="board-row top-row">
<div class="board-quarter">{fields_from(tl, true)}</div>
<div class="board-bar"></div>
<div class="board-quarter">{fields_from(tr, true)}</div>
</div>
<div class="board-center-bar"></div>
<div class="board-row bot-row">
<div class="board-quarter">{fields_from(bl, false)}</div>
<div class="board-bar"></div>
<div class="board-quarter">{fields_from(br, false)}</div>
</div>
// SVG overlay: arrows for hovered jan moves
<svg
width="776" height="388"
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
>
{move || {
let Some(hm) = hovered_moves else { return vec![]; };
let pairs = hm.get();
if pairs.is_empty() { return vec![]; }
// Collect unique individual (from, to) moves; skip empty/exit.
let mut moves: Vec<(usize, usize)> = pairs.iter()
.flat_map(|(m1, m2)| [
(m1.get_from(), m1.get_to()),
(m2.get_from(), m2.get_to()),
])
.filter(|&(f, t)| f != 0 && t != 0)
.collect();
moves.sort_unstable();
moves.dedup();
moves.into_iter()
.filter_map(|(from, to)| {
let p1 = field_center(from, is_white)?;
let p2 = field_center(to, is_white)?;
Some(arrow_svg(p1, p2))
})
.collect()
}}
</svg>
</div>
}
}

View file

@ -16,30 +16,9 @@ fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] {
/// A single die face rendered as SVG. /// A single die face rendered as SVG.
/// `value` 16 shows dots; 0 shows an empty face (not-yet-rolled). /// `value` 16 shows dots; 0 shows an empty face (not-yet-rolled).
/// `used` dims the die. /// `used` dims the die.
/// `is_double` applies a golden glow (both dice same value).
#[component] #[component]
pub fn Die( pub fn Die(value: u8, used: bool) -> impl IntoView {
value: u8, let cls = if used { "die-face die-used" } else { "die-face" };
used: bool,
#[prop(default = false)] is_double: bool,
) -> AnyView {
let mut cls = if used {
"die-face die-used".to_string()
} else {
"die-face".to_string()
};
if is_double && !used {
cls.push_str(" die-double");
}
if value == 0 {
return view! {
<svg class=cls width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
<text x="24" y="32" text-anchor="middle" font-size="24" font-weight="bold"
class="die-question">{"?"}</text>
</svg>
}.into_any();
}
let dots: Vec<AnyView> = dot_positions(value) let dots: Vec<AnyView> = dot_positions(value)
.iter() .iter()
.map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any()) .map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any())
@ -49,5 +28,5 @@ pub fn Die(
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" /> <rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
{dots} {dots}
</svg> </svg>
}.into_any() }
} }

View file

@ -0,0 +1,309 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules};
use crate::app::{GameUiState, NetCommand};
use crate::i18n::*;
use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage};
use super::board::Board;
use super::die::Die;
use super::score_panel::PlayerScorePanel;
#[allow(dead_code)]
/// Returns (d0_used, d1_used) by matching each staged move's distance to a die.
fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
let mut d0 = false;
let mut d1 = false;
for &(from, to) in staged {
let dist = if from < to {
to.saturating_sub(from)
} else {
from.saturating_sub(to)
};
if !d0 && dist == dice.0 {
d0 = true;
} else if !d1 && dist == dice.1 {
d1 = true;
} else if !d0 {
d0 = true;
} else {
d1 = true;
}
}
(d0, d1)
}
/// Split `dice_jans` into (viewer_jans, opponent_jans).
fn split_jans(dice_jans: &[JanEntry], viewer_is_active: bool) -> (Vec<JanEntry>, Vec<JanEntry>) {
let mut mine = Vec::new();
let mut theirs = Vec::new();
for e in dice_jans {
if viewer_is_active {
if e.total >= 0 {
mine.push(e.clone());
} else {
theirs.push(JanEntry {
total: -e.total,
points_per: -e.points_per,
..e.clone()
});
}
} else if e.total >= 0 {
theirs.push(e.clone());
} else {
mine.push(JanEntry {
total: -e.total,
points_per: -e.points_per,
..e.clone()
});
}
}
(mine, theirs)
}
#[component]
pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n();
let vs = state.view_state.clone();
let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id);
let is_move_stage = is_my_turn
&& matches!(
vs.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
// ── Hovered jan moves (shown as arrows on the board) ──────────────────────
let hovered_jan_moves: RwSignal<Vec<(CheckerMove, CheckerMove)>> = RwSignal::new(vec![]);
provide_context(hovered_jan_moves);
// ── Staged move state ──────────────────────────────────────────────────────
let selected_origin: RwSignal<Option<u8>> = RwSignal::new(None);
let staged_moves: RwSignal<Vec<(u8, u8)>> = RwSignal::new(Vec::new());
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let cmd_tx_effect = cmd_tx.clone();
Effect::new(move |_| {
let moves = staged_moves.get();
if moves.len() == 2 {
let to_cm = |&(from, to): &(u8, u8)| {
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
};
cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move(
to_cm(&moves[0]),
to_cm(&moves[1]),
)))
.ok();
staged_moves.set(vec![]);
selected_origin.set(None);
}
});
let dice = vs.dice;
let show_dice = dice != (0, 0);
// ── Button senders ─────────────────────────────────────────────────────────
let cmd_tx_roll = cmd_tx.clone();
let cmd_tx_go = cmd_tx.clone();
let cmd_tx_quit = cmd_tx.clone();
let cmd_tx_end_quit = cmd_tx.clone();
let cmd_tx_end_replay = cmd_tx.clone();
let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice;
let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice;
// ── Valid move sequences for this turn ─────────────────────────────────────
// Computed once per ViewState snapshot; used by Board (highlighting) and the
// empty-move button (visibility).
let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) {
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs.board);
let store_dice = StoreDice { values: dice };
let color = if player_id == 0 { Color::White } else { Color::Black };
let rules = MoveRules::new(&color, &store_board, store_dice);
let raw = rules.get_possible_moves_sequences(true, vec![]);
if player_id == 0 {
raw
} else {
raw.into_iter().map(|(m1, m2)| (m1.mirror(), m2.mirror())).collect()
}
} else {
vec![]
};
// Clone for the empty-move button reactive closure (Board consumes the original).
let valid_seqs_empty = valid_sequences.clone();
// ── Jan split: viewer_jans / opponent_jans ─────────────────────────────────
let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn && !show_roll);
// ── Scores ─────────────────────────────────────────────────────────────────
let my_score = vs.scores[player_id as usize].clone();
let opp_score = vs.scores[1 - player_id as usize].clone();
// ── Capture for closures ───────────────────────────────────────────────────
let stage = vs.stage.clone();
let turn_stage = vs.turn_stage.clone();
let room_id = state.room_id.clone();
let is_bot_game = state.is_bot_game;
// ── Game-over info ─────────────────────────────────────────────────────────
let stage_is_ended = stage == SerStage::Ended;
let winner_is_me = my_score.holes >= 12;
let opp_name_end = opp_score.name.clone();
view! {
<div class="game-container">
// ── Top bar ──────────────────────────────────────────────────────
<div class="top-bar">
<span>{move || if is_bot_game {
t_string!(i18n, vs_bot_label).to_owned()
} else {
t_string!(i18n, room_label, id = room_id.as_str())
}}</span>
<div class="lang-switcher">
<button
class:lang-active=move || i18n.get_locale() == Locale::en
on:click=move |_| i18n.set_locale(Locale::en)
>"EN"</button>
<button
class:lang-active=move || i18n.get_locale() == Locale::fr
on:click=move |_| i18n.set_locale(Locale::fr)
>"FR"</button>
</div>
<a class="quit-link" href="#" on:click=move |e| {
e.prevent_default();
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
}>{t!(i18n, quit)}</a>
</div>
// ── Opponent score (above board) ─────────────────────────────────
<PlayerScorePanel score=opp_score jans=opp_jans is_you=false />
// ── Board + side panel ───────────────────────────────────────────
<div class="board-and-panel">
<Board
view_state=vs
player_id=player_id
selected_origin=selected_origin
staged_moves=staged_moves
valid_sequences=valid_sequences
/>
// ── Side panel ───────────────────────────────────────────────
<div class="side-panel">
// Status message
<div class="status-bar">
<span>{move || {
let n = staged_moves.get().len();
if is_move_stage {
t_string!(i18n, select_move, n = n + 1)
} else {
String::from(match (&stage, is_my_turn, &turn_stage) {
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
(SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent),
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
})
}
}}</span>
</div>
// Dice (always shown when rolled, used state depends on whose turn)
{show_dice.then(|| view! {
<div class="dice-bar">
{move || {
let (d0, d1) = if is_move_stage {
matched_dice_used(&staged_moves.get(), dice)
} else {
(true, true)
};
view! {
<Die value=dice.0 used=d0 />
<Die value=dice.1 used=d1 />
}
}}
</div>
})}
// Action buttons
<div class="action-buttons">
{show_roll.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_roll.unbounded_send(NetCommand::Action(PlayerAction::Roll)).ok();
}>{t!(i18n, roll_dice)}</button>
})}
{show_hold_go.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>{t!(i18n, go)}</button>
})}
{move || {
// Show the empty-move button only when (0,0) is a valid
// first or second move given what has already been staged.
let staged = staged_moves.get();
let show = is_move_stage && staged.len() < 2 && (
valid_seqs_empty.is_empty() || match staged.len() {
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
1 => {
let (f0, t0) = staged[0];
valid_seqs_empty.iter()
.filter(|(m1, _)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
})
.any(|(_, m2)| m2.get_from() == 0)
}
_ => false,
}
);
show.then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
selected_origin.set(None);
staged_moves.update(|v| v.push((0, 0)));
}
>{t!(i18n, empty_move)}</button>
})
}}
</div>
</div>
</div>
// ── Player score (below board) ────────────────────────────────────
<PlayerScorePanel score=my_score jans=my_jans is_you=true />
// ── Game-over overlay ─────────────────────────────────────────────
{stage_is_ended.then(|| {
let winner_text = if winner_is_me {
t_string!(i18n, you_win).to_owned()
} else {
t_string!(i18n, opp_wins, name = opp_name_end.as_str())
};
view! {
<div class="game-over-overlay">
<div class="game-over-box">
<h2>{t!(i18n, game_over)}</h2>
<p class="game-over-winner">{winner_text}</p>
<div class="game-over-actions">
<button class="btn btn-secondary" on:click=move |_| {
cmd_tx_end_quit.unbounded_send(NetCommand::Disconnect).ok();
}>{t!(i18n, quit)}</button>
{is_bot_game.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_end_replay.unbounded_send(NetCommand::PlayVsBot).ok();
}>{t!(i18n, play_again)}</button>
})}
</div>
</div>
</div>
}
})}
</div>
}
}

View file

@ -0,0 +1,77 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use crate::app::NetCommand;
use crate::i18n::*;
#[component]
pub fn LoginScreen(error: Option<String>) -> impl IntoView {
let i18n = use_i18n();
let (room_name, set_room_name) = signal(String::new());
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let cmd_tx_create = cmd_tx.clone();
let cmd_tx_join = cmd_tx.clone();
let cmd_tx_bot = cmd_tx;
view! {
<div class="login-container">
<div class="lang-switcher">
<button
class:lang-active=move || i18n.get_locale() == Locale::en
on:click=move |_| i18n.set_locale(Locale::en)
>"EN"</button>
<button
class:lang-active=move || i18n.get_locale() == Locale::fr
on:click=move |_| i18n.set_locale(Locale::fr)
>"FR"</button>
</div>
<h1>"Trictrac"</h1>
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
<input
type="text"
placeholder=move || t_string!(i18n, room_name_placeholder)
prop:value=move || room_name.get()
on:input=move |ev| set_room_name.set(event_target_value(&ev))
/>
<button
class="btn btn-primary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
cmd_tx_create
.unbounded_send(NetCommand::CreateRoom { room: room_name.get() })
.ok();
}
>
{t!(i18n, create_room)}
</button>
<button
class="btn btn-secondary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
cmd_tx_join
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
.ok();
}
>
{t!(i18n, join_room)}
</button>
<button
class="btn btn-bot"
on:click=move |_| {
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
}
>
{t!(i18n, play_vs_bot)}
</button>
</div>
}
}

View file

@ -2,8 +2,9 @@ mod board;
mod connecting_screen; mod connecting_screen;
mod die; mod die;
mod game_screen; mod game_screen;
mod login_screen;
mod score_panel; mod score_panel;
mod scoring;
pub use connecting_screen::ConnectingScreen; pub use connecting_screen::ConnectingScreen;
pub use game_screen::GameScreen; pub use game_screen::GameScreen;
pub use login_screen::LoginScreen;

View file

@ -0,0 +1,156 @@
use leptos::prelude::*;
use trictrac_store::{CheckerMove, Jan};
use crate::i18n::*;
use crate::trictrac::types::{JanEntry, PlayerScore};
fn jan_label(jan: &Jan) -> String {
let i18n = use_i18n();
match jan {
Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(),
Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(),
Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(),
Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(),
Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(),
Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(),
Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(),
Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(),
Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(),
Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(),
Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(),
Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(),
Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(),
}
}
fn format_move_pair(m1: CheckerMove, m2: CheckerMove) -> String {
let fmt = |m: CheckerMove| -> String {
let (f, t) = (m.get_from(), m.get_to());
if f == 0 && t == 0 {
"".to_string()
} else if t == 0 {
format!("{f}")
} else {
format!("{f}{t}")
}
};
format!("{} & {}", fmt(m1), fmt(m2))
}
fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal<Option<usize>>) -> impl IntoView {
let i18n = use_i18n();
let row_class = if entry.total >= 0 {
"jan-row jan-expandable jan-positive"
} else {
"jan-row jan-expandable jan-negative"
};
let label = jan_label(&entry.jan);
let double_tag = if entry.is_double {
t_string!(i18n, jan_double).to_owned()
} else {
t_string!(i18n, jan_simple).to_owned()
};
let ways_tag = format!("×{}", entry.ways);
let pts_str = if entry.total >= 0 {
format!("+{}", entry.total)
} else {
format!("{}", entry.total)
};
let moves = entry.moves.clone();
let moves_hover = entry.moves.clone();
// RwSignal is Copy so it can be captured by both closures independently.
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
view! {
<div>
<div
class=row_class
on:click=move |_| {
expanded.update(|s| {
*s = if *s == Some(idx) { None } else { Some(idx) };
});
}
on:mouseenter=move |_| {
if let Some(h) = hovered {
h.set(moves_hover.clone());
}
}
on:mouseleave=move |_| {
if let Some(h) = hovered {
h.set(vec![]);
}
}
>
<span class="jan-label">{label}</span>
<span class="jan-tag">{double_tag}</span>
<span class="jan-tag">{ways_tag}</span>
<span class="jan-pts">{pts_str}</span>
</div>
{
let move_lines: Vec<_> = moves.iter()
.map(|&(m1, m2)| {
let text = format_move_pair(m1, m2);
view! { <div class="jan-move-line">{text}</div> }
})
.collect();
view! {
<div class="jan-moves" class:hidden=move || expanded.get() != Some(idx)>
{move_lines}
</div>
}
}
</div>
}
}
#[component]
pub fn PlayerScorePanel(score: PlayerScore, jans: Vec<JanEntry>, is_you: bool) -> impl IntoView {
let i18n = use_i18n();
let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
let holes_pct = format!("{}%", (score.holes as u32 * 100 / 12).min(100));
let points_val = format!("{}/12", score.points);
let holes_val = format!("{}/12", score.holes);
let can_bredouille = score.can_bredouille;
let expanded: RwSignal<Option<usize>> = RwSignal::new(None);
let jan_rows: Vec<_> = jans
.into_iter()
.enumerate()
.map(|(i, entry)| jan_row(i, entry, expanded))
.collect();
view! {
<div class="player-score-panel">
<div class="player-score-header">
<span class="player-name">
{score.name}
{is_you.then(|| t!(i18n, you_suffix))}
</span>
</div>
<div class="score-bars">
<div class="score-bar-row">
<span class="score-bar-label">{t!(i18n, points_label)}</span>
<div class="score-bar">
<div class="score-bar-fill score-bar-points" style=format!("width:{points_pct}")></div>
</div>
<span class="score-bar-value">{points_val}</span>
{can_bredouille.then(|| view! {
<span class="bredouille-badge" title=move || t_string!(i18n, bredouille_title).to_owned()>"B"</span>
})}
</div>
<div class="score-bar-row">
<span class="score-bar-label">{t!(i18n, holes_label)}</span>
<div class="score-bar">
<div class="score-bar-fill score-bar-holes" style=format!("width:{holes_pct}")></div>
</div>
<span class="score-bar-value">{holes_val}</span>
</div>
</div>
{(!jan_rows.is_empty()).then(|| view! {
<div class="player-jans">{jan_rows}</div>
})}
</div>
}
}

12
client_web/src/main.rs Normal file
View file

@ -0,0 +1,12 @@
leptos_i18n::load_locales!();
mod app;
mod components;
mod trictrac;
use app::App;
use leptos::prelude::*;
fn main() {
mount_to_body(|| view! { <App /> })
}

View file

@ -1,7 +1,7 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{Color, Dice, DiceRoller, GameEvent, GameState, Player, Stage, TurnStage}; use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage};
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState}; use crate::trictrac::types::{GameDelta, PlayerAction, ViewState};
// Store PlayerId (u64) values used for the two players. // Store PlayerId (u64) values used for the two players.
const HOST_PLAYER_ID: u64 = 1; const HOST_PLAYER_ID: u64 = 1;
@ -14,28 +14,11 @@ pub struct TrictracBackend {
view_state: ViewState, view_state: ViewState,
/// Arrival flags: have host (index 0) and guest (index 1) joined? /// Arrival flags: have host (index 0) and guest (index 1) joined?
arrived: [bool; 2], arrived: [bool; 2],
/// Die rolled by each player during the ceremony ([host, guest]).
pre_game_dice: [Option<u8>; 2],
/// Number of tied rounds so far.
tie_count: u8,
/// True while the first-player ceremony is running.
ceremony_started: bool,
} }
impl TrictracBackend { impl TrictracBackend {
fn sync_view_state(&mut self) { fn sync_view_state(&mut self) {
let mut vs = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID); self.view_state = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
if self.ceremony_started {
vs.stage = SerStage::PreGameRoll;
vs.pre_game_roll = Some(PreGameRollState {
host_die: self.pre_game_dice[0],
guest_die: self.pre_game_dice[1],
tie_count: self.tie_count,
});
// Both players roll independently; no single "active" player.
vs.active_mp_player = None;
}
self.view_state = vs;
} }
fn broadcast_state(&mut self) { fn broadcast_state(&mut self) {
@ -46,49 +29,6 @@ impl TrictracBackend {
self.commands.push(BackendCommand::Delta(delta)); self.commands.push(BackendCommand::Delta(delta));
} }
/// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest).
fn handle_pre_game_roll(&mut self, mp_player: u16) {
let idx = mp_player as usize;
// Ignore if this player already rolled.
if self.pre_game_dice[idx].is_some() {
return;
}
let single = self.dice_roller.roll().values.0;
self.pre_game_dice[idx] = Some(single);
if let [Some(h), Some(g)] = self.pre_game_dice {
// Both have rolled — broadcast both dice before resolving.
self.broadcast_state();
if h == g {
// Tie: reset for another round.
self.tie_count += 1;
self.pre_game_dice = [None; 2];
self.broadcast_state();
} else {
// Highest die goes first.
let goes_first = if h > g {
HOST_PLAYER_ID
} else {
GUEST_PLAYER_ID
};
self.ceremony_started = false;
let _ = self.game.consume(&GameEvent::BeginGame { goes_first });
// Use pre-game dice roll for the first move
let _ = self.game.consume(&GameEvent::Roll {
player_id: goes_first,
});
let _ = self.game.consume(&GameEvent::RollResult {
player_id: goes_first,
dice: Dice { values: (g, h) },
});
self.broadcast_state();
}
} else {
// Only one die rolled so far — broadcast the partial result.
self.broadcast_state();
}
}
/// Roll dice using the store's DiceRoller and fire Roll + RollResult events. /// Roll dice using the store's DiceRoller and fire Roll + RollResult events.
fn do_roll(&mut self) { fn do_roll(&mut self) {
let dice = self.dice_roller.roll(); let dice = self.dice_roller.roll();
@ -130,72 +70,13 @@ impl TrictracBackend {
pub fn get_game(&self) -> &GameState { pub fn get_game(&self) -> &GameState {
&self.game &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 { impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
fn new(_rule_variation: u16) -> Self { fn new(_rule_variation: u16) -> Self {
let mut game = GameState::new(false); let mut game = GameState::new(false);
game.init_player("Blancs"); game.init_player("Host");
game.init_player("Noirs"); game.init_player("Guest");
let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID); let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
@ -205,9 +86,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
commands: Vec::new(), commands: Vec::new(),
view_state, view_state,
arrived: [false; 2], arrived: [false; 2],
pre_game_dice: [None; 2],
tie_count: 0,
ceremony_started: false,
} }
} }
@ -232,15 +110,11 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
timer_id: mp_player, timer_id: mp_player,
}); });
// Start the ceremony once both players have arrived. // Start the game once both players have arrived.
if self.arrived[0] if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame {
&& self.arrived[1] let _ = self.game.consume(&GameEvent::BeginGame {
&& self.game.stage == trictrac_store::Stage::PreGame goes_first: HOST_PLAYER_ID,
&& !self.ceremony_started });
{
self.ceremony_started = true;
self.pre_game_dice = [None; 2];
self.tie_count = 0;
self.sync_view_state(); self.sync_view_state();
self.commands.push(BackendCommand::ResetViewState); self.commands.push(BackendCommand::ResetViewState);
} else { } else {
@ -261,24 +135,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
} }
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) { fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
// SetName is always accepted regardless of game stage or whose turn it is.
if let PlayerAction::SetName(name) = action {
let store_id = if mp_player == 0 { HOST_PLAYER_ID } else { GUEST_PLAYER_ID };
if let Some(p) = self.game.players.get_mut(&store_id) {
p.name = name;
}
self.broadcast_state();
return;
}
// During the first-player ceremony only PreGameRoll actions are accepted.
if self.ceremony_started {
if matches!(action, PlayerAction::PreGameRoll) {
self.handle_pre_game_roll(mp_player);
}
return;
}
if self.game.stage == trictrac_store::Stage::Ended { if self.game.stage == trictrac_store::Stage::Ended {
return; return;
} }
@ -330,8 +186,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
self.drive_automatic_stages(); self.drive_automatic_stages();
} }
} }
PlayerAction::PreGameRoll => {} // ignored outside ceremony
PlayerAction::SetName(_) => {} // handled at the top of inform_rpc
} }
self.broadcast_state(); self.broadcast_state();
@ -359,7 +213,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use super::{SerStage, SerTurnStage};
use backbone_lib::traits::BackEndArchitecture; use backbone_lib::traits::BackEndArchitecture;
fn make_backend() -> TrictracBackend { fn make_backend() -> TrictracBackend {
@ -378,37 +231,15 @@ mod tests {
.collect() .collect()
} }
/// Drive the ceremony to completion (both players roll until one wins).
fn complete_ceremony(b: &mut TrictracBackend) {
loop {
if b.get_view_state().stage != SerStage::PreGameRoll {
break;
}
let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default();
let host_needs = pgr.host_die.is_none();
let guest_needs = pgr.guest_die.is_none();
if !host_needs && !guest_needs {
break; // both rolled but stage not yet resolved — shouldn't happen
}
if host_needs {
b.inform_rpc(0, PlayerAction::PreGameRoll);
}
if guest_needs {
b.inform_rpc(1, PlayerAction::PreGameRoll);
}
b.drain_commands();
}
}
#[test] #[test]
fn both_players_arrive_starts_ceremony() { fn both_players_arrive_starts_game() {
let mut b = make_backend(); let mut b = make_backend();
b.player_arrival(0); // host b.player_arrival(0); // host
b.drain_commands(); b.drain_commands();
b.player_arrival(1); // guest b.player_arrival(1); // guest
let cmds = b.drain_commands(); let cmds = b.drain_commands();
// ResetViewState should have been issued to start the ceremony. // ResetViewState should have been issued after BeginGame.
let has_reset = cmds let has_reset = cmds
.iter() .iter()
.any(|c| matches!(c, BackendCommand::ResetViewState)); .any(|c| matches!(c, BackendCommand::ResetViewState));
@ -417,44 +248,11 @@ mod tests {
"expected ResetViewState after both players arrive" "expected ResetViewState after both players arrive"
); );
// Stage should now be PreGameRoll, not InGame. // Game should now be InGame.
assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll); use crate::trictrac::types::SerStage;
}
#[test]
fn ceremony_resolves_to_in_game() {
let mut b = make_backend();
b.player_arrival(0);
b.player_arrival(1);
b.drain_commands();
complete_ceremony(&mut b);
assert_eq!(b.get_view_state().stage, SerStage::InGame); assert_eq!(b.get_view_state().stage, SerStage::InGame);
} }
#[test]
fn ceremony_any_order_allowed() {
let mut b = make_backend();
b.player_arrival(0);
b.player_arrival(1);
b.drain_commands();
// Guest may roll before host.
b.inform_rpc(1, PlayerAction::PreGameRoll);
let states = drain_deltas(&mut b);
assert!(
!states.is_empty(),
"guest PreGameRoll should broadcast a state"
);
let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap();
assert!(
pgr.guest_die.is_some(),
"guest die should be set after guest rolls"
);
assert!(pgr.host_die.is_none(), "host die should still be blank");
}
#[test] #[test]
fn unknown_player_kicked() { fn unknown_player_kicked() {
let mut b = make_backend(); let mut b = make_backend();
@ -472,18 +270,12 @@ mod tests {
b.player_arrival(1); b.player_arrival(1);
b.drain_commands(); b.drain_commands();
// Complete ceremony before rolling. // Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame).
complete_ceremony(&mut b); b.inform_rpc(0, PlayerAction::Roll);
// Roll for whoever won the ceremony (either player could go first).
let first_player = b
.get_view_state()
.active_mp_player
.expect("someone should be active");
b.inform_rpc(first_player, PlayerAction::Roll);
let states = drain_deltas(&mut b); let states = drain_deltas(&mut b);
assert!(!states.is_empty(), "expected a state broadcast after roll"); assert!(!states.is_empty(), "expected a state broadcast after roll");
use crate::trictrac::types::SerTurnStage;
let last = states.last().unwrap(); let last = states.last().unwrap();
assert!( assert!(
matches!( matches!(
@ -504,14 +296,14 @@ mod tests {
b.player_arrival(0); b.player_arrival(0);
b.player_arrival(1); b.player_arrival(1);
b.drain_commands(); b.drain_commands();
complete_ceremony(&mut b);
// Identify who goes first and have the OTHER player try to roll. // Guest tries to roll when it's the host's turn.
let active = b.get_view_state().active_mp_player; b.inform_rpc(1, PlayerAction::Roll);
let wrong_player = if active == Some(0) { 1u16 } else { 0u16 };
b.inform_rpc(wrong_player, PlayerAction::Roll);
let cmds = b.drain_commands(); let cmds = b.drain_commands();
assert!(cmds.is_empty(), "wrong player roll should be ignored"); assert!(
cmds.is_empty(),
"guest roll should be ignored when it's host's turn"
);
} }
#[test] #[test]
@ -538,20 +330,3 @@ mod tests {
.any(|c| matches!(c, BackendCommand::TerminateRoom))); .any(|c| matches!(c, BackendCommand::TerminateRoom)));
} }
} }
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
#[cfg(target_arch = "wasm32")]
mod inner {
use web_sys::console;
pub fn console_log(message: String) {
console::log_1(&message.into());
}
}
#[cfg(target_arch = "wasm32")]
pub use inner::console_log;
#[cfg(not(target_arch = "wasm32"))]
pub fn console_log(message: String) {}

View file

@ -0,0 +1,33 @@
use rand::prelude::IndexedRandom;
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
use crate::trictrac::types::PlayerAction;
const GUEST_PLAYER_ID: u64 = 2;
/// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn.
pub fn bot_decide(game: &GameState) -> Option<PlayerAction> {
if game.stage == Stage::Ended {
return None;
}
if game.active_player_id != GUEST_PLAYER_ID {
return None;
}
match game.turn_stage {
TurnStage::RollDice => Some(PlayerAction::Roll),
TurnStage::HoldOrGoChoice => Some(PlayerAction::Go),
TurnStage::Move => {
let rules = MoveRules::new(&Color::Black, &game.board, game.dice);
let sequences = rules.get_possible_moves_sequences(true, vec![]);
let mut rng = rand::rng();
let (m1, m2) = sequences
.choose(&mut rng)
.cloned()
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
// MoveRules with Color::Black mirrors the board internally, so
// returned move coordinates are in mirrored (White) space — mirror back.
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
}
_ => None,
}
}

View file

@ -14,10 +14,6 @@ pub enum PlayerAction {
Go, Go,
/// Acknowledge point marking (hold / advance points). /// Acknowledge point marking (hold / advance points).
Mark, Mark,
/// Roll a single die during the pre-game ceremony to decide who goes first.
PreGameRoll,
/// Declare the player's display name; sent once immediately after connecting.
SetName(String),
} }
// ── Incremental state update broadcast to all clients ──────────────────────── // ── Incremental state update broadcast to all clients ────────────────────────
@ -31,18 +27,6 @@ pub struct GameDelta {
// ── Full game snapshot ──────────────────────────────────────────────────────── // ── Full game snapshot ────────────────────────────────────────────────────────
/// State of the pre-game ceremony where each player rolls one die to decide
/// who goes first. Present only when `stage == SerStage::PreGameRoll`.
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PreGameRollState {
/// Die value (16) rolled by the host; `None` = not yet rolled this round.
pub host_die: Option<u8>,
/// Die value (16) rolled by the guest; `None` = not yet rolled this round.
pub guest_die: Option<u8>,
/// Number of tied rounds so far (0 on the first round).
pub tie_count: u8,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewState { pub struct ViewState {
/// Board positions: index i = field i+1. Positive = white, negative = black. /// Board positions: index i = field i+1. Positive = white, negative = black.
@ -57,11 +41,6 @@ pub struct ViewState {
pub dice: (u8, u8), pub dice: (u8, u8),
/// Jans (scoring events) triggered by the last dice roll. /// Jans (scoring events) triggered by the last dice roll.
pub dice_jans: Vec<JanEntry>, pub dice_jans: Vec<JanEntry>,
/// Last two checker moves played; default when no move has occurred yet.
pub dice_moves: (CheckerMove, CheckerMove),
/// Present while the pre-game ceremony is in progress.
#[serde(default)]
pub pre_game_roll: Option<PreGameRollState>,
} }
/// One scoring event from a dice roll. /// One scoring event from a dice roll.
@ -89,23 +68,11 @@ impl ViewState {
turn_stage: SerTurnStage::RollDice, turn_stage: SerTurnStage::RollDice,
active_mp_player: None, active_mp_player: None,
scores: [ scores: [
PlayerScore { PlayerScore { name: host_name.to_string(), points: 0, holes: 0, can_bredouille: false },
name: host_name.to_string(), PlayerScore { name: guest_name.to_string(), points: 0, holes: 0, can_bredouille: false },
points: 0,
holes: 0,
can_bredouille: false,
},
PlayerScore {
name: guest_name.to_string(),
points: 0,
holes: 0,
can_bredouille: false,
},
], ],
dice: (0, 0), dice: (0, 0),
dice_jans: Vec::new(), dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
pre_game_roll: None,
} }
} }
@ -116,21 +83,25 @@ impl ViewState {
/// Convert a store `GameState` to a `ViewState`. /// Convert a store `GameState` to a `ViewState`.
/// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned /// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned
/// to the host (mp player 0) and guest (mp player 1) respectively. /// to the host (mp player 0) and guest (mp player 1) respectively.
pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self { pub fn from_game_state(
gs: &GameState,
host_store_id: u64,
guest_store_id: u64,
) -> Self {
let board_vec = gs.board.to_vec(); let board_vec = gs.board.to_vec();
let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields"); let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields");
let stage = match gs.stage { let stage = match gs.stage {
Stage::PreGame => SerStage::PreGame, Stage::PreGame => SerStage::PreGame,
Stage::InGame => SerStage::InGame, Stage::InGame => SerStage::InGame,
Stage::Ended => SerStage::Ended, Stage::Ended => SerStage::Ended,
}; };
let turn_stage = match gs.turn_stage { let turn_stage = match gs.turn_stage {
TurnStage::RollDice => SerTurnStage::RollDice, TurnStage::RollDice => SerTurnStage::RollDice,
TurnStage::RollWaiting => SerTurnStage::RollWaiting, TurnStage::RollWaiting => SerTurnStage::RollWaiting,
TurnStage::MarkPoints => SerTurnStage::MarkPoints, TurnStage::MarkPoints => SerTurnStage::MarkPoints,
TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice, TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
TurnStage::Move => SerTurnStage::Move, TurnStage::Move => SerTurnStage::Move,
TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints, TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
}; };
@ -151,12 +122,7 @@ impl ViewState {
holes: p.holes, holes: p.holes,
can_bredouille: p.can_bredouille, can_bredouille: p.can_bredouille,
}) })
.unwrap_or_else(|| PlayerScore { .unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0, can_bredouille: false })
name: String::new(),
points: 0,
holes: 0,
can_bredouille: false,
})
}; };
// is_double for scoring: dice show the same value (both dice identical). // is_double for scoring: dice show the same value (both dice identical).
@ -165,16 +131,13 @@ impl ViewState {
// Build JanEntry list from the PossibleJans map. // Build JanEntry list from the PossibleJans map.
let empty_move = CheckerMove::new(0, 0).unwrap_or_default(); let empty_move = CheckerMove::new(0, 0).unwrap_or_default();
let mut dice_jans: Vec<JanEntry> = gs let mut dice_jans: Vec<JanEntry> = gs.dice_jans
.dice_jans
.iter() .iter()
.map(|(jan, moves)| { .map(|(jan, moves)| {
// HelplessMan: is_double = true only when *both* dice are unplayable // HelplessMan: is_double = true only when *both* dice are unplayable
// (the moves list contains a single (empty, empty) sentinel). // (the moves list contains a single (empty, empty) sentinel).
let is_double = if *jan == Jan::HelplessMan { let is_double = if *jan == Jan::HelplessMan {
moves moves.first().map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
.first()
.map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
.unwrap_or(false) .unwrap_or(false)
} else { } else {
dice_are_double dice_are_double
@ -203,29 +166,10 @@ impl ViewState {
scores: [score_for(host_store_id), score_for(guest_store_id)], scores: [score_for(host_store_id), score_for(guest_store_id)],
dice: (gs.dice.values.0, gs.dice.values.1), dice: (gs.dice.values.0, gs.dice.values.1),
dice_jans, dice_jans,
dice_moves: gs.dice_moves,
pre_game_roll: None,
} }
} }
} }
// ── Scored event (notification) ──────────────────────────────────────────
/// Points scored in a single scoring event, used for the notification panel.
#[derive(Clone, PartialEq)]
pub struct ScoredEvent {
/// Raw points earned (sum of jan values; before hole wrapping).
pub points_earned: u8,
/// Number of holes gained (0 = no hole).
pub holes_gained: u8,
/// Total holes after this event.
pub holes_total: u8,
/// Was bredouille active when the hole was made (doubles hole count)?
pub bredouille: bool,
/// Contributing jans from this player's perspective (totals always positive).
pub jans: Vec<JanEntry>,
}
// ── Score snapshot ──────────────────────────────────────────────────────────── // ── Score snapshot ────────────────────────────────────────────────────────────
#[derive(Clone, PartialEq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Serialize, Deserialize)]
@ -241,8 +185,6 @@ pub struct PlayerScore {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SerStage { pub enum SerStage {
PreGame, PreGame,
/// Both players have arrived; ceremony in progress to decide who goes first.
PreGameRoll,
InGame, InGame,
Ended, Ended,
} }

View file

@ -1,17 +0,0 @@
[package]
name = "backbone-lib"
version.workspace = true
edition = "2024"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", features = ["use-std"] }
bytes = "1.11"
ewebsock = "0.8"
protocol = { path = "../../server/protocol" }
futures = "0.3"
web-time = "1.1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
gloo-timers = { version = "0.3", features = ["futures"] }

View file

@ -1,84 +0,0 @@
//! Background task for the client (non-host) side of a session.
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use crate::platform::sleep_ms;
use crate::protocol::{parse_client_update, send_disconnect, send_rpc};
use crate::session::{BackendMsg, SessionEvent};
use crate::traits::SerializationCap;
pub(crate) async fn client_loop<A, D, VS>(
mut ws_sender: WsSender,
ws_receiver: WsReceiver,
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
event_tx: UnboundedSender<SessionEvent<D, VS>>,
) where
A: SerializationCap,
D: SerializationCap,
VS: SerializationCap,
{
loop {
// 1. Drain outbound actions.
loop {
match action_rx.try_next() {
Ok(Some(BackendMsg::Action(action))) => {
send_rpc(&mut ws_sender, &action);
}
Ok(Some(BackendMsg::Disconnect)) => {
send_disconnect(&mut ws_sender, false);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
Ok(None) => {
send_disconnect(&mut ws_sender, false);
return;
}
Err(_) => break,
}
}
// 2. Drain inbound state updates.
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
match parse_client_update::<VS, D>(data) {
Ok(updates) => {
for u in updates {
event_tx
.unbounded_send(SessionEvent::Update(u))
.ok();
}
}
Err(e) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
}
}
Some(WsEvent::Closed) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(
"Connection closed".to_string(),
)))
.ok();
return;
}
Some(WsEvent::Error(e)) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
Some(_) => continue,
None => break,
}
}
sleep_ms(2).await;
}
}

View file

@ -1,211 +0,0 @@
//! Background task for the host (game server) side of a session.
use std::collections::HashSet;
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use web_time::{Duration, Instant};
use crate::platform::sleep_ms;
use crate::protocol::{
ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state,
send_kick, send_reset,
};
use crate::session::{BackendMsg, SessionEvent};
use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
struct Timer {
id: u16,
fire_at: Instant,
}
pub(crate) async fn host_loop<A, D, VS, Backend>(
mut ws_sender: WsSender,
ws_receiver: WsReceiver,
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
event_tx: UnboundedSender<SessionEvent<D, VS>>,
rule_variation: u16,
host_state: Option<Vec<u8>>,
) where
A: SerializationCap,
D: SerializationCap + Clone,
VS: SerializationCap + Clone,
Backend: BackEndArchitecture<A, D, VS>,
{
let mut backend = host_state
.as_deref()
.and_then(|b| Backend::from_bytes(rule_variation, b))
.unwrap_or_else(|| Backend::new(rule_variation));
backend.player_arrival(0);
// Push initial state to UI immediately.
let initial = backend.get_view_state().clone();
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial)))
.ok();
let mut timers: Vec<Timer> = Vec::new();
let mut cancelled_timers: HashSet<u16> = HashSet::new();
let mut remote_player_count: u16 = 0;
loop {
let mut client_joined = false;
// 1. Drain local actions / detect session drop or disconnect request.
loop {
match action_rx.try_next() {
Ok(Some(BackendMsg::Action(action))) => {
backend.inform_rpc(0, action);
}
Ok(Some(BackendMsg::Disconnect)) => {
send_disconnect(&mut ws_sender, true);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
Ok(None) => {
// All senders dropped — session was dropped without calling disconnect().
send_disconnect(&mut ws_sender, true);
return;
}
Err(_) => break, // Channel empty; nothing pending.
}
}
// 2. Drain WebSocket events from the relay.
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
match parse_server_command::<A>(data) {
ToServerCommand::ClientJoin(id) => {
backend.player_arrival(id);
remote_player_count += 1;
client_joined = true;
}
ToServerCommand::ClientLeft(id) => {
backend.player_departure(id);
remote_player_count = remote_player_count.saturating_sub(1);
}
ToServerCommand::Rpc(id, payload) => {
backend.inform_rpc(id, payload);
}
ToServerCommand::Error(e) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
}
}
Some(WsEvent::Closed) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(
"Connection closed".to_string(),
)))
.ok();
return;
}
Some(WsEvent::Error(e)) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
Some(_) => continue, // Ignore Opened / text messages.
None => break, // No more events this iteration.
}
}
// 3. Fire elapsed timers.
let now = Instant::now();
let mut fired = Vec::new();
timers.retain(|t| {
if t.fire_at <= now {
fired.push(t.id);
false
} else {
true
}
});
for id in fired {
if !cancelled_timers.remove(&id) {
backend.timer_triggered(id);
}
}
// 4. Drain and process backend commands.
let commands = backend.drain_commands();
if commands.is_empty() && !client_joined {
sleep_ms(2).await;
continue;
}
let mut delta_batch: Vec<D> = Vec::new();
let mut reset = false;
for cmd in commands {
match cmd {
BackendCommand::TerminateRoom => {
send_disconnect(&mut ws_sender, true);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
BackendCommand::SetTimer { timer_id, duration } => {
// Cancel any existing timer with the same id, then re-arm.
timers.retain(|t| t.id != timer_id);
cancelled_timers.remove(&timer_id);
timers.push(Timer {
id: timer_id,
fire_at: Instant::now() + Duration::from_secs_f32(duration),
});
}
BackendCommand::CancelTimer { timer_id } => {
cancelled_timers.insert(timer_id);
}
BackendCommand::KickPlayer { player } => {
if remote_player_count > 0 {
send_kick(&mut ws_sender, player);
}
}
BackendCommand::ResetViewState => {
reset = true;
}
BackendCommand::Delta(d) => {
delta_batch.push(d);
}
}
}
if reset {
// Reset supersedes all pending deltas: send fresh full state.
let state = backend.get_view_state().clone();
if remote_player_count > 0 {
send_reset(&mut ws_sender, &state);
}
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state)))
.ok();
} else {
// Broadcast deltas, then notify local UI.
if remote_player_count > 0 && !delta_batch.is_empty() {
send_delta(&mut ws_sender, &delta_batch);
}
for d in delta_batch {
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d)))
.ok();
}
}
// Send full state to clients that joined this iteration.
if client_joined {
send_full_state(&mut ws_sender, backend.get_view_state());
}
sleep_ms(2).await;
}
}

View file

@ -1,10 +0,0 @@
pub mod session;
pub mod traits;
mod client;
mod host;
mod platform;
mod protocol;
pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};

View file

@ -1,48 +0,0 @@
use std::future::Future;
/// Spawns a background task.
/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required)
/// - Native: spawns an OS thread running `futures::executor::block_on`
#[cfg(target_arch = "wasm32")]
pub fn spawn_task<F>(fut: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(fut);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn_task<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
std::thread::spawn(move || {
futures::executor::block_on(fut);
});
}
/// Yields for approximately `ms` milliseconds.
/// - WASM: non-blocking yield via browser timer
/// - Native: blocks the current thread (safe on a dedicated background thread)
#[cfg(target_arch = "wasm32")]
pub async fn sleep_ms(ms: u32) {
gloo_timers::future::TimeoutFuture::new(ms).await;
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn sleep_ms(ms: u32) {
std::thread::sleep(std::time::Duration::from_millis(ms as u64));
}
/// Platform-agnostic bound for types that can be moved into a background task.
/// - WASM: only requires `'static` (single-threaded, no Send needed)
/// - Native: requires `Send + 'static`
#[cfg(target_arch = "wasm32")]
pub trait TaskBound: 'static {}
#[cfg(target_arch = "wasm32")]
impl<T: 'static> TaskBound for T {}
#[cfg(not(target_arch = "wasm32"))]
pub trait TaskBound: Send + 'static {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send + 'static> TaskBound for T {}

View file

@ -1,159 +0,0 @@
//! Wire protocol encoding/decoding helpers.
//!
//! Translates between raw WebSocket binary frames and typed Rust values using
//! postcard serialization and the message-type constants from the `protocol` crate.
use crate::traits::{SerializationCap, ViewStateUpdate};
use bytes::{Buf, BufMut, Bytes, BytesMut};
use ewebsock::{WsMessage, WsSender};
use postcard::{from_bytes, take_from_bytes, to_stdvec};
use protocol::{
CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE,
FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS,
SERVER_ERROR, SERVER_RPC,
};
// ---------------------------------------------------------------------------
// Inbound command types (relay → host)
// ---------------------------------------------------------------------------
pub enum ToServerCommand<A> {
ClientJoin(u16),
ClientLeft(u16),
Rpc(u16, A),
Error(String),
}
// ---------------------------------------------------------------------------
// Send helpers
// ---------------------------------------------------------------------------
fn send_binary(sender: &mut WsSender, data: &[u8]) {
sender.send(WsMessage::Binary(data.to_vec()));
}
pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> {
let bytes = to_stdvec(req).map_err(|e| e.to_string())?;
send_binary(sender, &bytes);
Ok(())
}
pub fn send_rpc<A: SerializationCap>(sender: &mut WsSender, action: &A) {
let raw = to_stdvec(action).expect("Failed to serialize RPC");
let mut buf = BytesMut::with_capacity(1 + raw.len());
buf.put_u8(SERVER_RPC);
buf.put_slice(&raw);
send_binary(sender, &buf);
}
pub fn send_delta<D: SerializationCap>(sender: &mut WsSender, deltas: &[D]) {
let serialized: Vec<u8> = deltas
.iter()
.flat_map(|d| to_stdvec(d).expect("Failed to serialize delta"))
.collect();
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(DELTA_UPDATE);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_full_state<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
let serialized = to_stdvec(state).expect("Failed to serialize full state");
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(FULL_UPDATE);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_reset<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
let serialized = to_stdvec(state).expect("Failed to serialize reset state");
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(RESET);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_kick(sender: &mut WsSender, player_id: u16) {
let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE);
buf.put_u8(CLIENT_GETS_KICKED);
buf.put_u16(player_id);
send_binary(sender, &buf);
}
pub fn send_disconnect(sender: &mut WsSender, as_host: bool) {
let msg = if as_host {
SERVER_DISCONNECTS
} else {
CLIENT_DISCONNECTS_SELF
};
send_binary(sender, &[msg]);
}
// ---------------------------------------------------------------------------
// Receive / parse helpers
// ---------------------------------------------------------------------------
/// Parses the relay's handshake response.
///
/// Returns `(player_id, rule_variation, reconnect_token)`.
pub fn parse_handshake_response(data: Vec<u8>) -> Result<(u16, u16, u64), String> {
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
HAND_SHAKE_RESPONSE => {
let player_id = bytes.get_u16();
let rule_variation = bytes.get_u16();
let token = bytes.get_u64();
Ok((player_id, rule_variation, token))
}
other => Err(format!("Unexpected handshake message id: {other}")),
}
}
pub fn parse_server_command<A: SerializationCap>(data: Vec<u8>) -> ToServerCommand<A> {
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()),
NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()),
CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()),
SERVER_RPC => {
let client_id = bytes.get_u16();
let payload: A =
from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload");
ToServerCommand::Rpc(client_id, payload)
}
other => ToServerCommand::Error(format!("Unknown server message id: {other}")),
}
}
pub fn parse_client_update<VS, D>(
data: Vec<u8>,
) -> Result<Vec<ViewStateUpdate<VS, D>>, String>
where
VS: SerializationCap,
D: SerializationCap,
{
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
DELTA_UPDATE => {
let mut result = Vec::new();
let mut remaining: &[u8] = &bytes;
while !remaining.is_empty() {
let (delta, rest): (D, &[u8]) =
take_from_bytes(remaining).map_err(|e| e.to_string())?;
remaining = rest;
result.push(ViewStateUpdate::Incremental(delta));
}
Ok(result)
}
FULL_UPDATE | RESET => {
let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?;
Ok(vec![ViewStateUpdate::Full(state)])
}
other => Err(format!("Unknown client message id: {other}")),
}
}

View file

@ -1,266 +0,0 @@
//! The public-facing session API.
//!
//! # Usage
//!
//! ```ignore
//! // Connect (async, returns after handshake completes)
//! let mut session: GameSession<MyAction, MyDelta, MyState> =
//! GameSession::connect::<MyBackend>(RoomConfig {
//! relay_url: "ws://localhost:8080/ws".to_string(),
//! game_id: "my-game".to_string(),
//! room_id: "room-42".to_string(),
//! rule_variation: 0,
//! role: RoomRole::Create,
//! reconnect_token: None,
//! })
//! .await?;
//!
//! // In a loop (e.g. Dioxus coroutine with futures::select!):
//! loop {
//! futures::select! {
//! cmd = ui_rx.next().fuse() => session.send_action(cmd),
//! event = session.next_event().fuse() => match event {
//! Some(SessionEvent::Update(u)) => view_state.apply(u),
//! Some(SessionEvent::Disconnected(reason)) | None => break,
//! }
//! }
//! }
//! ```
use ewebsock::{WsEvent, WsMessage};
use futures::StreamExt;
use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};
use protocol::JoinRequest;
use crate::client::client_loop;
use crate::host::host_loop;
use crate::platform::{TaskBound, sleep_ms, spawn_task};
use crate::protocol::{parse_handshake_response, send_join_request};
use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate};
// ---------------------------------------------------------------------------
// Public configuration types
// ---------------------------------------------------------------------------
/// Whether to create a new room (host) or join an existing one (client).
pub enum RoomRole {
Create,
Join,
}
/// Configuration required to connect to a game session.
pub struct RoomConfig {
/// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`).
pub relay_url: String,
/// Game identifier registered on the relay (e.g. `"tic-tac-toe"`).
pub game_id: String,
/// Room identifier shared between host and clients.
pub room_id: String,
/// Game mode/variant. Only used when `role` is `Create`.
pub rule_variation: u16,
pub role: RoomRole,
/// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh.
/// The value is the token returned by a previous successful handshake.
pub reconnect_token: Option<u64>,
/// Serialized backend state for host reconnect.
///
/// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in
/// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host
/// reconnects so the game can resume from the last known state.
/// Ignored for non-host reconnects and normal connections.
pub host_state: Option<Vec<u8>>,
}
/// Error returned by [`GameSession::connect`].
#[derive(Debug)]
pub enum ConnectError {
WebSocket(String),
Handshake(String),
}
impl std::fmt::Display for ConnectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"),
ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"),
}
}
}
// ---------------------------------------------------------------------------
// Internal message type (UI → background task)
// ---------------------------------------------------------------------------
pub(crate) enum BackendMsg<A> {
Action(A),
Disconnect,
}
// ---------------------------------------------------------------------------
// Session event (background task → UI)
// ---------------------------------------------------------------------------
/// Events emitted by the session to the UI.
pub enum SessionEvent<Delta, ViewState> {
/// A state update arrived from the host backend.
Update(ViewStateUpdate<ViewState, Delta>),
/// The session ended. `None` = clean disconnect, `Some(reason)` = error.
Disconnected(Option<String>),
}
// ---------------------------------------------------------------------------
// GameSession
// ---------------------------------------------------------------------------
/// A connected game session.
///
/// Created by [`GameSession::connect`]. Holds channels to the background task
/// that owns the WebSocket connection and (on host) the game backend.
pub struct GameSession<Action, Delta, ViewState> {
/// The player ID assigned by the relay server. Always `0` for the host.
pub player_id: u16,
/// The game mode/variant selected by the host.
pub rule_variation: u16,
/// `true` if this client is hosting the game (runs the backend).
pub is_host: bool,
/// Token to persist in localStorage for reconnect on page refresh.
/// Only meaningful for non-host players (player_id > 0).
pub reconnect_token: u64,
action_tx: UnboundedSender<BackendMsg<Action>>,
event_rx: UnboundedReceiver<SessionEvent<Delta, ViewState>>,
}
impl<A, D, VS> GameSession<A, D, VS>
where
A: SerializationCap + TaskBound,
D: SerializationCap + Clone + TaskBound,
VS: SerializationCap + Clone + TaskBound,
{
/// Connects to the relay server and performs the handshake.
///
/// Returns after the relay confirms the player ID and rule variation.
/// Spawns a background task that drives the WebSocket connection for the
/// lifetime of the session.
///
/// # Errors
/// Returns `Err` if the WebSocket cannot be opened or the handshake fails.
pub async fn connect<Backend>(config: RoomConfig) -> Result<Self, ConnectError>
where
Backend: BackEndArchitecture<A, D, VS> + TaskBound,
{
let create_room = matches!(config.role, RoomRole::Create);
// 1. Open WebSocket.
let (mut ws_sender, ws_receiver) =
ewebsock::connect(&config.relay_url, ewebsock::Options::default())
.map_err(|e| ConnectError::WebSocket(e.to_string()))?;
// 2. Wait for the Opened event (WASM WebSocket is async).
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Opened) => break,
Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)),
Some(WsEvent::Closed) => {
return Err(ConnectError::WebSocket("Connection closed".to_string()));
}
Some(_) => continue,
None => sleep_ms(1).await,
}
}
// 3. Send the join request.
let req = JoinRequest {
game_id: config.game_id,
room_id: config.room_id,
rule_variation: config.rule_variation,
create_room,
reconnect_token: config.reconnect_token,
};
send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?;
// 4. Wait for the handshake response.
let (player_id, rule_variation, reconnect_token) = loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
break parse_handshake_response(data).map_err(ConnectError::Handshake)?;
}
Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)),
Some(WsEvent::Closed) => {
// The relay may have sent a binary error frame just before
// closing. ewebsock can deliver Closed before that frame,
// so drain one more message to catch it.
if let Some(WsEvent::Message(WsMessage::Binary(data))) =
ws_receiver.try_recv()
{
break parse_handshake_response(data)
.map_err(ConnectError::Handshake)?;
}
return Err(ConnectError::Handshake(
"Connection closed during handshake".to_string(),
));
}
Some(_) => continue,
None => sleep_ms(1).await,
}
};
// The relay assigns player_id == 0 exclusively to the host.
let is_host = player_id == 0;
// 5. Set up channels between the UI and the background task.
let (action_tx, action_rx) = mpsc::unbounded::<BackendMsg<A>>();
let (event_tx, event_rx) = mpsc::unbounded::<SessionEvent<D, VS>>();
// 6. Spawn the background event loop.
if is_host {
spawn_task(host_loop::<A, D, VS, Backend>(
ws_sender,
ws_receiver,
action_rx,
event_tx,
rule_variation,
config.host_state,
));
} else {
spawn_task(client_loop::<A, D, VS>(
ws_sender,
ws_receiver,
action_rx,
event_tx,
));
}
Ok(GameSession {
player_id,
rule_variation,
is_host,
reconnect_token,
action_tx,
event_rx,
})
}
/// Sends a game action to the backend (fire-and-forget).
pub fn send_action(&self, action: A) {
self.action_tx
.unbounded_send(BackendMsg::Action(action))
.ok();
}
/// Awaits the next session event.
///
/// Returns `None` if the background task has exited (i.e. the session is
/// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))`
/// before the channel closes.
pub async fn next_event(&mut self) -> Option<SessionEvent<D, VS>> {
self.event_rx.next().await
}
/// Signals the background task to send a graceful disconnect message and
/// shut down. Consumes the session.
pub fn disconnect(self) {
self.action_tx
.unbounded_send(BackendMsg::Disconnect)
.ok();
}
}

View file

@ -1,97 +0,0 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
/// Marker trait for types that can be serialized with postcard.
pub trait SerializationCap: Serialize + DeserializeOwned {}
impl<T> SerializationCap for T where T: Serialize + DeserializeOwned {}
/// State updates delivered to the frontend for rendering.
///
/// - [`Full`](Self::Full): Immediately set all visual state, no animation.
/// - [`Incremental`](Self::Incremental): Apply with animation/transition.
pub enum ViewStateUpdate<ViewState, DeltaInformation> {
/// Complete game state snapshot. Received on join or after a reset.
Full(ViewState),
/// Incremental state change for animated transitions.
Incremental(DeltaInformation),
}
/// Commands emitted by the game backend to control the session.
pub enum BackendCommand<DeltaInformation>
where
DeltaInformation: SerializationCap,
{
/// Incremental state change to be broadcast to all frontends.
Delta(DeltaInformation),
/// Signals a complete reset: discard queued deltas, broadcast fresh full state.
ResetViewState,
/// Forcibly removes a player from the session.
KickPlayer { player: u16 },
/// Schedules a callback after `duration` seconds. Overwrites any existing
/// timer with the same `timer_id`.
SetTimer { timer_id: u16, duration: f32 },
/// Cancels a previously scheduled timer. No-op if already fired or not set.
CancelTimer { timer_id: u16 },
/// Shuts down the entire room and disconnects all players.
TerminateRoom,
}
/// The contract for game-specific server logic.
///
/// Implement this on the host side. The session calls these methods in response
/// to network events and drives `drain_commands` to collect outbound messages.
///
/// # Type Parameters
/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`)
/// * `DeltaInformation` — Incremental state changes for animations
/// * `ViewState` — Complete game snapshot for syncing new clients
pub trait BackEndArchitecture<ServerRpcPayload, DeltaInformation, ViewState>
where
ServerRpcPayload: SerializationCap,
DeltaInformation: SerializationCap,
ViewState: SerializationCap + Clone,
{
/// Creates a new game instance. `rule_variation` selects the game mode.
fn new(rule_variation: u16) -> Self;
/// Attempt to restore a previously running game from serialized bytes.
///
/// Called when the host reconnects after a page refresh. The bytes are the
/// game-specific snapshot produced by the app layer (via `serde_json` or
/// similar) and stored in localStorage.
///
/// Return `None` if restoration is not supported or the bytes are invalid —
/// the caller falls back to `new(rule_variation)`.
fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option<Self>
where
Self: Sized,
{
None
}
/// Called when a player connects. Player will receive a full state snapshot
/// automatically after this returns.
fn player_arrival(&mut self, player: u16);
/// Called when a player disconnects.
fn player_departure(&mut self, player: u16);
/// Called when a player sends a game action.
fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload);
/// Called when a previously scheduled timer fires.
fn timer_triggered(&mut self, timer_id: u16);
/// Returns the complete current game state.
fn get_view_state(&self) -> &ViewState;
/// Collects and clears all pending commands since the last drain.
///
/// Implement with `std::mem::take(&mut self.command_list)`.
fn drain_commands(&mut self) -> Vec<BackendCommand<DeltaInformation>>;
}

View file

@ -1,49 +0,0 @@
[package]
name = "trictrac-web"
version.workspace = true
edition = "2021"
[package.metadata.leptos-i18n]
default = "en"
locales = ["en", "fr"]
[dependencies]
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
leptos_router = { version = "0.7" }
trictrac-store = { path = "../../store" }
backbone-lib = { path = "../backbone-lib" }
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
futures = "0.3"
rand = "0.9"
gloo-storage = "0.3"
qrcodegen = "1.8"
pulldown-cmark = "0.13"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "=0.2.118"
wasm-bindgen-futures = "0.4"
gloo-net = { version = "0.5", features = ["http"] }
gloo-timers = { version = "0.3", features = ["futures"] }
getrandom = { version = "0.3", features = ["wasm_js"] }
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"RequestCredentials",
"AudioContext",
"AudioParam",
"AudioNode",
"AudioDestinationNode",
"AudioScheduledSourceNode",
"GainNode",
"OscillatorNode",
"OscillatorType",
"BaseAudioContext",
"HtmlAudioElement",
"Clipboard",
"Navigator",
"Location",
] }
[dev-dependencies]
wasm-bindgen-test = "0.3"

View file

@ -1,2 +0,0 @@
[serve]
port = 9091

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,167 +0,0 @@
{
"room_name_placeholder": "Room name",
"create_room": "Create Room",
"join_room": "Join Room",
"connecting": "Connecting…",
"game_over": "Game over",
"waiting_for_opponent": "Waiting for opponent…",
"your_turn_roll": "Your turn — roll the dice",
"hold_or_go": "Hold or Go?",
"select_move": "Move a checker ({{ n }} of 2)",
"your_turn": "Your turn",
"opponent_turn": "Opponent's turn",
"room_label": "Room: {{ id }}",
"quit": "Quit",
"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",
"bredouille_title": "Can bredouille",
"jan_double": "double",
"jan_simple": "simple",
"jan_filled_quarter": "Quarter filled",
"jan_true_hit_small": "True hit (small jan)",
"jan_true_hit_big": "True hit (big jan)",
"jan_true_hit_corner": "True hit (opp. corner)",
"jan_first_exit": "First to exit",
"jan_six_tables": "Six tables",
"jan_two_tables": "Two tables",
"jan_mezeas": "Mezeas",
"jan_false_hit_small": "False hit (small jan)",
"jan_false_hit_big": "False hit (big jan)",
"jan_contre_two": "Contre two tables",
"jan_contre_mezeas": "Contre mezeas",
"jan_helpless_man": "Helpless man",
"play_vs_bot": "Play vs Bot",
"vs_bot_label": "vs Bot",
"you_win": "You win!",
"opp_wins": "{{ name }} wins!",
"play_again": "Play again",
"after_opponent_roll": "Opponent rolled",
"after_opponent_go": "Opponent chose to continue",
"after_opponent_move": "Opponent moved — your turn",
"after_opponent_pre_game_roll": "Opponent rolled — your turn",
"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",
"scored_pts": "+{{ n }} pts",
"hole_made": "Hole! {{ holes }}/12",
"bredouille_applied": "Bredouille!",
"hold": "Hold",
"opp_scored_pts": "Opponent +{{ n }} pts",
"opp_hole_made": "Opponent hole! {{ holes }}/12",
"hint_move": "Click a highlighted field to move a checker",
"hint_hold_or_go": "Hold to keep points — Go to reset the setting",
"hint_continue": "Click Continue when ready",
"anonymous_name": "Anonymous",
"login_failed": "Invalid username or password.",
"sign_in": "Sign in",
"sign_out": "Sign out",
"create_account": "Create account",
"account_title": "Account",
"label_username": "Username",
"label_username_or_email": "Username or email",
"label_password": "Password",
"label_confirm_password": "Confirm password",
"passwords_do_not_match": "Passwords do not match.",
"label_email": "Email",
"forgot_password_link": "Forgot password?",
"forgot_password_title": "Reset password",
"forgot_password_email_label": "Email address",
"forgot_password_submit": "Send reset link",
"forgot_password_sent": "If an account with this email exists, a reset link has been sent to that address.",
"reset_password_title": "New password",
"new_password_label": "New password",
"reset_password_submit": "Reset password",
"reset_password_success": "Password reset successfully. You can now sign in.",
"reset_password_invalid": "This reset link is invalid or has expired.",
"verify_email_title": "Email verification",
"verify_email_checking": "Verifying your email…",
"verify_email_success": "Your email has been verified.",
"verify_email_invalid": "This verification link is invalid or has expired.",
"email_not_verified_banner": "Please verify your email address — check your inbox.",
"resend_verification": "Resend verification email",
"verification_email_resent": "Verification email sent.",
"loading": "Loading…",
"member_since": "Member since",
"stat_games": "Games",
"stat_wins": "Wins",
"stat_losses": "Losses",
"stat_draws": "Draws",
"game_history_title": "Game History",
"no_games": "No games recorded yet.",
"col_room": "Room",
"col_started": "Started",
"col_ended": "Ended",
"col_outcome": "Outcome",
"col_detail": "Detail",
"prev_page": "← Prev",
"next_page": "Next →",
"page_label": "Page",
"view_link": "View",
"outcome_win": "win",
"outcome_loss": "loss",
"outcome_draw": "draw",
"players_header": "Players",
"col_player": "Player",
"score_header": "Score",
"game_ongoing": "ongoing",
"anonymous_player": "anonymous",
"started_label": "Started",
"ended_label": "Ended",
"room_detail_title": "Room",
"share_link": "Share this link to invite an opponent",
"copy_link": "Copy link",
"link_copied": "Copied!",
"scan_qr": "or scan the QR code",
"join_code_label": "Join by code",
"join_code_placeholder": "Room code",
"share_btn": "Share",
"nickname_modal_title": "Choose your nickname",
"nickname_modal_hint": "You will play as:",
"nickname_modal_play": "Play",
"nickname_modal_or": "or",
"nickname_modal_sign_in": "Sign in",
"nickname_modal_register": "Create account",
"new_game": "New game",
"language": "Language",
"delete_account_title": "Danger zone",
"delete_account_btn": "Delete my account",
"delete_account_warning": "This action is irreversible. Your account will be permanently deleted.",
"delete_account_confirm_label": "Type your username to confirm:",
"delete_account_confirm_btn": "Delete permanently",
"delete_account_mismatch": "Username does not match.",
"account_deleted": "Your account has been permanently deleted.",
"about": "About",
"legal": "Legal notices",
"free_mode_label": "Free play mode",
"free_mode_tooltip": "Select any checker and try to find a valid move yourself. If your move breaks a rule, you'll see an explanation.",
"reset_move": "Try again",
"err_invalid_move": "This move is not valid with the current dice",
"err_opponent_corner": "Cannot land on the opponent's rest corner",
"err_corner_needs_two": "Must enter and leave the rest corner with 2 checkers at once",
"err_corner_by_effect": "Must take the rest corner directly (by effect), not by force",
"err_exit_needs_all_in_last_jan": "All checkers must be in the last jan before exiting",
"err_exit_by_effect": "Must exit with exact dice value when possible (no overage)",
"err_exit_not_farthest": "With overage, must exit the checker farthest from the exit",
"err_opponent_can_fill_quarter": "Cannot play in a quarter the opponent can still fill",
"err_must_fill_quarter": "Must fill (or keep) a quarter when possible",
"err_must_play_all_dice": "Must play both dice when possible",
"err_must_play_stronger_die": "Must play the stronger die when only one can be played"
}

View file

@ -1,165 +0,0 @@
{
"room_name_placeholder": "Nom de la salle",
"create_room": "Inviter un adversaire",
"join_room": "Rejoindre",
"connecting": "Connexion en cours…",
"game_over": "Partie terminée",
"waiting_for_opponent": "En attente de l'adversaire…",
"your_turn_roll": "À votre tour — lancez les dés",
"hold_or_go": "Tenir ou s'en aller ?",
"select_move": "Déplacez une dame ({{ n }} sur 2)",
"your_turn": "Votre tour",
"opponent_turn": "Tour de l'adversaire",
"room_label": "Salle : {{ id }}",
"quit": "Quitter",
"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",
"bredouille_title": "Peut faire bredouille",
"jan_double": "double",
"jan_simple": "simple",
"jan_filled_quarter": "Remplissage",
"jan_true_hit_small": "Battage à vrai (petit jan)",
"jan_true_hit_big": "Battage à vrai (grand jan)",
"jan_true_hit_corner": "Battage coin adverse",
"jan_first_exit": "Premier sorti",
"jan_six_tables": "Jan de six tables",
"jan_two_tables": "Jan de deux tables",
"jan_mezeas": "Jan de mézéas",
"jan_false_hit_small": "Battage à faux (petit jan)",
"jan_false_hit_big": "Battage à faux (grand jan)",
"jan_contre_two": "Contre jan de deux tables",
"jan_contre_mezeas": "Contre jan de mezeas",
"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 }} a gagné!",
"play_again": "Rejouer",
"after_opponent_roll": "L'adversaire a lancé les dés",
"after_opponent_go": "L'adversaire s'en va",
"after_opponent_move": "L'adversaire a joué — à vous",
"after_opponent_pre_game_roll": "L'adversaire a lancé — à vous",
"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",
"scored_pts": "+{{ n }} pts",
"hole_made": "Trou ! {{ holes }}/12",
"bredouille_applied": "Bredouille !",
"hold": "Tenir",
"opp_scored_pts": "Adversaire +{{ n }} pts",
"opp_hole_made": "Trou adverse ! {{ holes }}/12",
"hint_move": "Cliquez une flêche soulignée pour déplacer",
"hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
"hint_continue": "Cliquez Continuer quand vous êtes prêt",
"anonymous_name": "Anonyme",
"login_failed": "Identifiant ou mot de passe incorrect.",
"sign_in": "Se connecter",
"sign_out": "Se déconnecter",
"create_account": "Créer un compte",
"account_title": "Compte",
"label_username": "Nom d'utilisateur",
"label_username_or_email": "Nom d'utilisateur ou email",
"label_password": "Mot de passe",
"label_confirm_password": "Confirmer le mot de passe",
"passwords_do_not_match": "Les mots de passe ne correspondent pas.",
"label_email": "Email",
"forgot_password_link": "Mot de passe oublié ?",
"forgot_password_title": "Réinitialiser le mot de passe",
"forgot_password_email_label": "Adresse email",
"forgot_password_submit": "Envoyer le lien",
"forgot_password_sent": "Si un compte avec cet email existe, un lien de réinitialisation a été envoyé à cette adresse.",
"reset_password_title": "Nouveau mot de passe",
"new_password_label": "Nouveau mot de passe",
"reset_password_submit": "Réinitialiser",
"reset_password_success": "Mot de passe réinitialisé. Vous pouvez maintenant vous connecter.",
"reset_password_invalid": "Ce lien est invalide ou a expiré.",
"verify_email_title": "Vérification de l'email",
"verify_email_checking": "Vérification en cours…",
"verify_email_success": "Votre email a été vérifié.",
"verify_email_invalid": "Ce lien de vérification est invalide ou a expiré.",
"email_not_verified_banner": "Un email de vérification vous a été envoyé — veuillez consulter votre boîte de réception.",
"resend_verification": "Renvoyer l'email de vérification",
"verification_email_resent": "Email de vérification envoyé.",
"loading": "Chargement…",
"member_since": "Membre depuis le",
"stat_games": "Parties",
"stat_wins": "Victoires",
"stat_losses": "Défaites",
"stat_draws": "Nuls",
"game_history_title": "Historique",
"no_games": "Aucune partie enregistrée.",
"col_room": "Salle",
"col_started": "Début",
"col_ended": "Fin",
"col_outcome": "Résultat",
"col_detail": "Détail",
"prev_page": "← Précédent",
"next_page": "Suivant →",
"page_label": "Page",
"view_link": "Voir",
"outcome_win": "victoire",
"outcome_loss": "défaite",
"outcome_draw": "nul",
"players_header": "Joueurs",
"col_player": "Joueur",
"score_header": "Score",
"game_ongoing": "en cours",
"anonymous_player": "anonyme",
"started_label": "Début",
"ended_label": "Fin",
"room_detail_title": "Salle",
"share_link": "Partagez ce lien pour inviter un adversaire",
"copy_link": "Copier le lien",
"link_copied": "Copié !",
"scan_qr": "ou scannez le QR code",
"share_btn": "Partager",
"nickname_modal_title": "Choisissez votre pseudo",
"nickname_modal_hint": "Vous jouerez sous le nom de :",
"nickname_modal_play": "Jouer",
"nickname_modal_or": "ou",
"nickname_modal_sign_in": "connectez-vous",
"nickname_modal_register": "Créer un compte",
"new_game": "Nouvelle partie",
"language": "Langue",
"delete_account_title": "Zone de danger",
"delete_account_btn": "Supprimer mon compte",
"delete_account_warning": "Cette action est irréversible. Votre compte sera définitivement supprimé.",
"delete_account_confirm_label": "Tapez votre nom d'utilisateur pour confirmer :",
"delete_account_confirm_btn": "Supprimer définitivement",
"delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.",
"account_deleted": "Votre compte a été définitivement supprimé.",
"about": "À propos",
"legal": "Mentions légales",
"free_mode_label": "Mode jeu libre",
"free_mode_tooltip": "Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même. Si votre coup enfreint une règle, une explication s'affichera.",
"reset_move": "Réessayer",
"err_invalid_move": "Ce coup n'est pas valide avec les dés actuels",
"err_opponent_corner": "Interdit de jouer sur le coin de repos adverse",
"err_corner_needs_two": "Le coin de repos doit être pris et quitté avec 2 dames simultanément",
"err_corner_by_effect": "Doit prendre le coin de repos par effet, non par puissance",
"err_exit_needs_all_in_last_jan": "Toutes les dames doivent être dans le jan de retour avant de sortir",
"err_exit_by_effect": "Doit sortir par effet (sans excédant) si c'est possible",
"err_exit_not_farthest": "Avec excédant, doit sortir la dame la plus éloignée de la sortie",
"err_opponent_can_fill_quarter": "Interdit de jouer dans un cadran que l'adversaire peut encore remplir",
"err_must_fill_quarter": "Doit remplir (ou conserver) un cadran si c'est possible",
"err_must_play_all_dice": "Doit jouer les deux dés si c'est possible",
"err_must_play_stronger_die": "Doit jouer le dé le plus fort quand un seul peut être joué"
}

View file

@ -1,12 +0,0 @@
# About
This application allows you to play [trictrac](https://en.wikipedia.org/wiki/Trictrac) against a friend online or locally against a bot.
The source code is available at [github.com/mmai/trictrac](https://github.com/mmai/trictrac)
The application is self-hosted and runs on a simple Raspberry Pi.
## Contact & bug Report
For any questions, bug reports, or feedback, you can contact me at rhumbs@rhumbs.fr.
If you encounter an issue during gameplay, you can copy the context of a game by clicking _Take snapshot_ then paste the resulting code into your message, specifying the expected behavior and the incorrect behavior you observed.

View file

@ -1,12 +0,0 @@
# À propos
Cette application vous permet de jouer au [trictrac](https://fr.wikipedia.org/wiki/Trictrac) contre un ami en ligne ou localement contre un bot.
Le code source est disponible sur [github.com/mmai/trictrac](https://github.com/mmai/trictrac).
L'application est auto hébergée et tourne sur un simple Raspberry Pi.
## Contact et rapport de bogue
Pour toute question, rapport de bogue ou retour d'expérience, vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr.
Si vous constatez une anomalie en cours de jeu, vous pouvez copier le contexte d'une partie en cliquant sur _Prendre un instantané_, puis coller le code obtenu dans le message, en précisant le comportement auquel vous vous attendiez, et le comportement erroné constaté.

View file

@ -1,26 +0,0 @@
# Legal Notices
## Data and Privacy
This site does not use third-party analytics or advertising trackers.
If you create an account, your username, email address, and argon2-hashed password are stored in the database. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties.
Game records (room codes, move history, outcomes) may be stored to display game history on your profile page.
## Cookies and Sessions
A session cookie is stored in your browser when you sign in. It is used solely to keep you authenticated and expires after 30 days of inactivity.
## Contact
The website is created by
Henri Bourcereau\
7 rue Lugeol\
33000 Bordeaux\
France
It is hosted at the same address.
You can contact me at rhumbs@rhumbs.fr

View file

@ -1,28 +0,0 @@
# Mentions légales
## Données et vie privée
Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires.
Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés en base de données. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers.
Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil.
Vous pouvez supprimer votre compte et la totalité des données associées depuis votre page de profil.
## Cookies et sessions
Un cookie de session est stocké dans votre navigateur lorsque vous vous connectez. Il sert uniquement à maintenir votre authentification et expire après 30 jours d'inactivité.
## Contact
Le site est réalisé par
Henri Bourcereau\
7 rue Lugeol\
33000 Bordeaux\
France
Il est hébergé à la même adresse.
Vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr

View file

@ -1 +0,0 @@
Sync this folder to the PAGES_DIR directory of the server running `relay-server`.

View file

@ -1,327 +0,0 @@
use serde::{Deserialize, Serialize};
#[cfg(debug_assertions)]
pub const HTTP_BASE: &str = "http://localhost:8080";
#[cfg(not(debug_assertions))]
pub const HTTP_BASE: &str = "";
fn url(path: &str) -> String {
format!("{HTTP_BASE}{path}")
}
// ── Response types ────────────────────────────────────────────────────────────
#[derive(Clone, Debug, Deserialize)]
pub struct MeResponse {
pub id: i64,
pub username: String,
#[serde(default)]
pub email_verified: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UserProfile {
pub id: i64,
pub username: String,
pub created_at: i64,
pub total_games: i64,
pub wins: i64,
pub losses: i64,
pub draws: i64,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GameSummary {
pub id: i64,
pub game_id: String,
pub room_code: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub result: Option<String>,
pub outcome: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GamesResponse {
pub games: Vec<GameSummary>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Participant {
pub player_id: i64,
pub outcome: Option<String>,
pub username: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GameDetail {
pub id: i64,
pub game_id: String,
pub room_code: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub result: Option<String>,
pub participants: Vec<Participant>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PageContent {
pub title: String,
pub content: String,
}
// ── Request bodies ────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct RegisterBody<'a> {
pub username: &'a str,
pub email: &'a str,
pub password: &'a str,
}
#[derive(Serialize)]
pub struct LoginBody<'a> {
pub username: &'a str,
pub password: &'a str,
}
// ── Fetch helpers ─────────────────────────────────────────────────────────────
pub async fn get_me() -> Result<MeResponse, String> {
let resp = gloo_net::http::Request::get(&url("/auth/me"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn post_login(username: &str, password: &str) -> Result<MeResponse, String> {
let body = LoginBody { username, password };
let resp = gloo_net::http::Request::post(&url("/auth/login"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_register(username: &str, email: &str, password: &str) -> Result<MeResponse, String> {
let body = RegisterBody { username, email, password };
let resp = gloo_net::http::Request::post(&url("/auth/register"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 201 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_logout() -> Result<(), String> {
let resp = gloo_net::http::Request::post(&url("/auth/logout"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 204 {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn delete_account() -> Result<(), String> {
let resp = gloo_net::http::Request::delete(&url("/auth/account"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 204 {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_user_profile(username: &str) -> Result<UserProfile, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<UserProfile>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_user_games(username: &str, page: i64) -> Result<GamesResponse, String> {
let resp = gloo_net::http::Request::get(&url(&format!(
"/users/{username}/games?page={page}&per_page=20"
)))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<GamesResponse>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<GameDetail>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_verify_email(token: &str) -> Result<(), String> {
let resp = gloo_net::http::Request::get(&url(&format!("/auth/verify-email?token={token}")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_resend_verification() -> Result<(), String> {
let resp = gloo_net::http::Request::post(&url("/auth/resend-verification"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn post_forgot_password(email: &str) -> Result<(), String> {
let body = serde_json::json!({ "email": email });
let resp = gloo_net::http::Request::post(&url("/auth/forgot-password"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(), String> {
let body = serde_json::json!({ "token": token, "new_password": new_password });
let resp = gloo_net::http::Request::post(&url("/auth/reset-password"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn get_page(slug: &str, lang: &str) -> Result<PageContent, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/pages/{slug}?lang={lang}")))
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<PageContent>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
// ── Utilities ─────────────────────────────────────────────────────────────────
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.
/// `Default` passes no options (browser default: full date + time).
pub struct DateFormatOptions {
/// "full" | "long" | "medium" | "short" — omit to suppress date part
pub date_style: Option<&'static str>,
/// "full" | "long" | "medium" | "short" — omit to suppress time part
pub time_style: Option<&'static str>,
}
impl Default for DateFormatOptions {
fn default() -> Self {
Self { date_style: None, time_style: None }
}
}
impl DateFormatOptions {
pub fn date_only() -> Self {
Self { date_style: Some("short"), time_style: None }
}
pub fn time_only() -> Self {
Self { date_style: None, time_style: Some("short") }
}
pub fn date_time() -> Self {
Self { date_style: Some("short"), time_style: Some("short") }
}
fn to_js_value(&self) -> wasm_bindgen::JsValue {
if self.date_style.is_none() && self.time_style.is_none() {
return wasm_bindgen::JsValue::UNDEFINED;
}
let obj = js_sys::Object::new();
if let Some(v) = self.date_style {
let _ = js_sys::Reflect::set(&obj, &"dateStyle".into(), &v.into());
}
if let Some(v) = self.time_style {
let _ = js_sys::Reflect::set(&obj, &"timeStyle".into(), &v.into());
}
obj.into()
}
}
pub fn format_ts(ts: i64, locale: &str, opts: &DateFormatOptions) -> String {
let ms = (ts * 1000) as f64;
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
date.to_locale_string(locale, &opts.to_js_value())
.as_string()
.unwrap_or_default()
}

View file

@ -1,831 +0,0 @@
use futures::channel::mpsc;
use futures::{FutureExt, StreamExt};
use gloo_storage::{LocalStorage, Storage};
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::components::{Route, Router, Routes, A};
use leptos_router::hooks::use_location;
use leptos_router::path;
use serde::{Deserialize, Serialize};
use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
use backbone_lib::traits::ViewStateUpdate;
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};
use crate::i18n::*;
use crate::portal::{
account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage,
game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage,
reset_password::ResetPasswordPage, verify_email::VerifyEmailPage,
};
use trictrac_store::CheckerMove;
use std::collections::VecDeque;
/// Newtype wrappers so context lookup can distinguish signals of the same inner type.
#[derive(Clone, Copy)]
pub(crate) struct AnonNickname(pub RwSignal<Option<String>>);
#[derive(Clone, Copy)]
pub(crate) struct AuthEmailVerified(pub RwSignal<bool>);
/// One-shot message shown as a top banner and auto-dismissed after a few seconds.
#[derive(Clone, Copy)]
pub(crate) struct FlashMessage(pub RwSignal<Option<String>>);
fn relay_url() -> String {
#[cfg(debug_assertions)]
{
"ws://localhost:8080/ws".to_string()
}
#[cfg(not(debug_assertions))]
{
let location = web_sys::window().and_then(|w| Some(w.location())).unwrap();
let protocol = location.protocol().unwrap_or_default();
let host = location.host().unwrap_or_default();
let ws_protocol = if protocol == "https:" { "wss" } else { "ws" };
format!("{ws_protocol}://{host}/ws")
}
}
const GAME_ID: &str = "trictrac";
const STORAGE_KEY: &str = "trictrac_session";
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The state the UI needs to render the game screen.
#[derive(Clone, PartialEq)]
pub struct GameUiState {
pub view_state: ViewState,
/// 0 = host, 1 = guest
pub player_id: u16,
pub room_id: String,
pub is_bot_game: bool,
pub waiting_for_confirm: bool,
pub pause_reason: Option<PauseReason>,
pub my_scored_event: Option<ScoredEvent>,
pub opp_scored_event: Option<ScoredEvent>,
pub last_moves: Option<(CheckerMove, CheckerMove)>,
/// True on the echo screen state set alongside a pending item — suppresses dice
/// roll animation and sound since they already played on the pending screen.
pub suppress_dice_anim: bool,
}
/// Reason the UI is paused waiting for the player to click Continue.
#[derive(Clone, Debug, PartialEq)]
pub enum PauseReason {
AfterOpponentRoll,
AfterOpponentGo,
AfterOpponentMove,
AfterOpponentPreGameRoll,
}
/// Which screen is currently shown (used to toggle game overlay).
#[derive(Clone, PartialEq)]
pub enum Screen {
Login { error: Option<String> },
Connecting,
Playing(GameUiState),
}
/// Commands sent from UI event handlers into the network task.
pub enum NetCommand {
CreateRoom {
room: String,
},
JoinRoom {
room: String,
},
Reconnect {
relay_url: String,
game_id: String,
room_id: String,
token: u64,
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,
}
#[derive(Serialize, Deserialize)]
struct StoredSession {
relay_url: String,
game_id: String,
room_id: String,
token: u64,
#[serde(default)]
is_host: bool,
#[serde(default)]
view_state: Option<ViewState>,
}
fn save_session(session: &StoredSession) {
LocalStorage::set(STORAGE_KEY, session).ok();
}
fn load_session() -> Option<StoredSession> {
LocalStorage::get::<StoredSession>(STORAGE_KEY).ok()
}
fn clear_session() {
LocalStorage::delete(STORAGE_KEY);
}
async fn submit_game_result(room_code: String, game_state: ViewState) {
let [score_pl1, score_pl2] = game_state.scores;
let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes);
let outcomes = if score_pl1.holes < score_pl2.holes {
[("0", "loss"), ("1", "win")]
} else if score_pl2.holes < score_pl1.holes {
[("0", "win"), ("1", "loss")]
} else {
[("0", "draw"), ("1", "draw")]
};
let body = serde_json::json!({
"room_code": room_code,
"game_id": GAME_ID,
"result": result_str,
"outcomes": std::collections::HashMap::from(outcomes),
});
let _ = gloo_net::http::Request::post(&format!("{}/games/result", api::HTTP_BASE))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.unwrap()
.send()
.await;
}
#[component]
pub fn App() -> impl IntoView {
let i18n = use_i18n();
let stored = load_session();
let initial_screen = if stored.is_some() {
Screen::Connecting
} else {
Screen::Login { error: None }
};
let screen: RwSignal<Screen> = RwSignal::new(initial_screen);
provide_context(screen);
// Auth: fetch once on load; shared by nav + game + portal components.
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
provide_context(auth_username);
provide_context(AuthEmailVerified(auth_email_verified));
// Set to true once get_me resolves (success or failure) so lobby can
// decide immediately whether to show the nickname modal.
let auth_loaded: RwSignal<bool> = RwSignal::new(false);
provide_context(auth_loaded);
// Nickname chosen by an anonymous player; used instead of "Anonymous".
let anon_nickname: RwSignal<Option<String>> = RwSignal::new(None);
provide_context(AnonNickname(anon_nickname));
let flash: RwSignal<Option<String>> = RwSignal::new(None);
provide_context(FlashMessage(flash));
spawn_local(async move {
if let Ok(me) = api::get_me().await {
auth_username.set(Some(me.username));
auth_email_verified.set(me.email_verified);
}
auth_loaded.set(true);
});
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
let pending: RwSignal<VecDeque<GameUiState>> = RwSignal::new(VecDeque::new());
provide_context(pending);
provide_context(cmd_tx.clone());
if let Some(s) = stored {
let host_state = s
.view_state
.as_ref()
.and_then(|vs| serde_json::to_vec(vs).ok());
cmd_tx
.unbounded_send(NetCommand::Reconnect {
relay_url: s.relay_url,
game_id: s.game_id,
room_id: s.room_id,
token: s.token,
host_state,
})
.ok();
}
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 {
relay_url: relay_url(),
game_id: GAME_ID.to_string(),
room_id: room,
rule_variation: 0,
role: RoomRole::Create,
reconnect_token: None,
host_state: None,
},
false,
));
}
Some(NetCommand::JoinRoom { room }) => {
break Some((
RoomConfig {
relay_url: relay_url(),
game_id: GAME_ID.to_string(),
room_id: room,
rule_variation: 0,
role: RoomRole::Join,
reconnect_token: None,
host_state: None,
},
false,
));
}
Some(NetCommand::Reconnect {
relay_url,
game_id,
room_id,
token,
host_state,
}) => {
break Some((
RoomConfig {
relay_url,
game_id,
room_id,
rule_variation: 0,
role: RoomRole::Join,
reconnect_token: Some(token),
host_state,
},
true,
));
}
_ => {}
}
};
if remote_config.is_none() {
let player_name = auth_username
.get_untracked()
.or_else(|| anon_nickname.get_untracked())
.unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string()));
loop {
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;
}
}
pending.update(|q| q.clear());
screen.set(Screen::Login { error: None });
continue;
}
let (config, is_reconnect) = remote_config.unwrap();
screen.set(Screen::Connecting);
let room_id_for_storage = config.room_id.clone();
let mut session: GameSession<PlayerAction, GameDelta, ViewState> =
match GameSession::connect::<TrictracBackend>(config).await {
Ok(s) => s,
Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => {
if is_reconnect {
clear_session();
}
screen.set(Screen::Login { error: Some(e) });
continue;
}
};
if !session.is_host {
save_session(&StoredSession {
relay_url: relay_url(),
game_id: GAME_ID.to_string(),
room_id: room_id_for_storage.clone(),
token: session.reconnect_token,
is_host: false,
view_state: None,
});
}
let is_host = session.is_host;
let player_id = session.player_id;
let reconnect_token = session.reconnect_token;
let my_name = auth_username
.get_untracked()
.or_else(|| anon_nickname.get_untracked())
.unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string());
// Announce our name to the host backend so it can broadcast it to
// the opponent. Done once immediately after connecting.
session.send_action(PlayerAction::SetName(my_name.clone()));
let mut vs = ViewState::default_with_names("", "");
let mut result_submitted = false;
loop {
futures::select! {
cmd = cmd_rx.next().fuse() => match cmd {
Some(NetCommand::Action(action)) => {
session.send_action(action);
}
_ => {
clear_session();
session.disconnect();
pending.update(|q| q.clear());
screen.set(Screen::Login { error: None });
break;
}
},
event = session.next_event().fuse() => match event {
Some(SessionEvent::Update(u)) => {
let prev_vs = vs.clone();
match u {
ViewStateUpdate::Full(state) => vs = state,
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
}
patch_player_name(&mut vs, player_id, &my_name);
if is_host && !result_submitted && vs.stage == SerStage::Ended {
result_submitted = true;
let room = room_id_for_storage.clone();
let gs = vs.clone();
spawn_local(submit_game_result(room, gs));
}
if is_host {
save_session(&StoredSession {
relay_url: relay_url(),
game_id: GAME_ID.to_string(),
room_id: room_id_for_storage.clone(),
token: reconnect_token,
is_host: true,
view_state: Some(vs.clone()),
});
}
let is_own_move = prev_vs.active_mp_player == Some(player_id);
push_or_show(
&prev_vs,
GameUiState {
view_state: vs.clone(),
player_id,
room_id: room_id_for_storage.clone(),
is_bot_game: false,
waiting_for_confirm: false,
pause_reason: None,
my_scored_event: None,
opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs, is_own_move),
suppress_dice_anim: false,
},
pending,
screen,
);
}
Some(SessionEvent::Disconnected(reason)) => {
pending.update(|q| q.clear());
screen.set(Screen::Login { error: reason });
break;
}
None => {
pending.update(|q| q.clear());
screen.set(Screen::Login { error: None });
break;
}
}
}
}
}
});
view! {
<Router>
<SiteHamburger />
<FlashBanner />
<main>
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
<Route path=path!("/") view=LobbyPage />
<Route path=path!("/account") view=AccountPage />
<Route path=path!("/profile/:username") view=ProfilePage />
<Route path=path!("/games/:id") view=GameDetailPage />
<Route path=path!("/verify-email") view=VerifyEmailPage />
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
<Route path=path!("/reset-password") view=ResetPasswordPage />
<Route path=path!("/page/:slug") view=ContentPage />
</Routes>
</main>
<GameOverlay pending=pending screen=screen />
</Router>
}
}
/// Fixed top banner that shows a flash message and auto-dismisses after 5 seconds.
#[component]
fn FlashBanner() -> impl IntoView {
let flash = use_context::<FlashMessage>()
.expect("FlashMessage context not found")
.0;
Effect::new(move |_| {
if flash.get().is_some() {
spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(5_000).await;
flash.set(None);
});
}
});
move || {
flash.get().map(|msg| {
view! {
<div class="flash-banner">
<span>{ msg }</span>
<button class="flash-dismiss" on:click=move |_| flash.set(None)>""</button>
</div>
}
})
}
}
/// Renders the full-screen game overlay, but only when the current route is "/".
/// This lets the user navigate to profile/account pages while a game is running.
#[component]
fn GameOverlay(
pending: RwSignal<VecDeque<GameUiState>>,
screen: RwSignal<Screen>,
) -> impl IntoView {
let location = use_location();
// Memoize the front of the pending queue so that pushing a new item to the back
// does not re-mount GameScreen (and replay dice animation/sound) when the displayed
// state (the front) hasn't changed.
let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned()));
move || {
if location.pathname.get() != "/" {
return view! {}.into_any();
}
if let Some(state) = pending_front.get() {
return view! {
<div class="game-overlay"><GameScreen state /></div>
}
.into_any();
}
match screen.get() {
Screen::Playing(state) => view! {
<div class="game-overlay"><GameScreen state /></div>
}
.into_any(),
Screen::Connecting => view! {
<div class="game-overlay"><ConnectingScreen /></div>
}
.into_any(),
_ => view! {}.into_any(),
}
}
}
/// Persistent hamburger button + left sidebar — visible on every page.
#[component]
fn SiteHamburger() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().unwrap_or_else(|| RwSignal::new(None));
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
let cmd_tx = use_context::<futures::channel::mpsc::UnboundedSender<NetCommand>>()
.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) ───────────────────────────────
<button
class="game-hamburger"
class:game-hamburger-open=move || sidebar_open.get()
on:click=move |_| sidebar_open.update(|v| *v = !*v)
aria-label="Menu"
>
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
// ── Left sidebar ──────────────────────────────────────────────────────
<div class="game-sidebar" class:game-sidebar-open=move || sidebar_open.get()>
<div class="game-sidebar-header">
<span class="game-sidebar-brand">"Trictrac"</span>
<div class="lang-switcher">
<button
class:lang-active=move || i18n.get_locale() == Locale::en
on:click=move |_| i18n.set_locale(Locale::en)
>"EN"</button>
<button
class:lang-active=move || i18n.get_locale() == Locale::fr
on:click=move |_| i18n.set_locale(Locale::fr)
>"FR"</button>
</div>
</div>
// Language switcher
// <div class="game-sidebar-section">
// <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
// <path fill="currentColor" d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/>
// </svg>
// <span> {t!(i18n, language)}</span>
// <div class="lang-switcher">
// <button
// class:lang-active=move || i18n.get_locale() == Locale::en
// on:click=move |_| i18n.set_locale(Locale::en)
// >"EN"</button>
// <button
// class:lang-active=move || i18n.get_locale() == Locale::fr
// on:click=move |_| i18n.set_locale(Locale::fr)
// >"FR"</button>
// </div>
// </div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"/>
</svg>
{move || {
let tx = cmd_tx_newgame.clone();
Some(view! {
<A href="/" attr:class="game-sidebar-link"
on:click=move |_| { tx.unbounded_send(NetCommand::Disconnect).ok(); sidebar_open.set(false); }>
{t!(i18n, new_game)}
</A>
})
}}
</div>
// Auth
{move || match auth_username.get() {
Some(u) => {
let href = format!("/profile/{u}");
view! {
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M240 192C240 147.8 275.8 112 320 112C364.2 112 400 147.8 400 192C400 236.2 364.2 272 320 272C275.8 272 240 236.2 240 192zM448 192C448 121.3 390.7 64 320 64C249.3 64 192 121.3 192 192C192 262.7 249.3 320 320 320C390.7 320 448 262.7 448 192zM144 544C144 473.3 201.3 416 272 416L368 416C438.7 416 496 473.3 496 544L496 552C496 565.3 506.7 576 520 576C533.3 576 544 565.3 544 552L544 544C544 446.8 465.2 368 368 368L272 368C174.8 368 96 446.8 96 544L96 552C96 565.3 106.7 576 120 576C133.3 576 144 565.3 144 552L144 544z"/>
</svg>
<A href=href attr:class="game-sidebar-link"
on:click=move |_| sidebar_open.set(false)>
{u}
</A>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
</svg>
<a class="game-sidebar-link" on:click=move |_| {
spawn_local(async move {
let _ = api::post_logout().await;
auth_username.set(None);
});
}>{t!(i18n, sign_out)}</a>
</div>
}.into_any()
},
None => view! {
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"/>
</svg>
<A href="/account" attr:class="game-sidebar-link"
on:click=move |_| sidebar_open.set(false)>
{t!(i18n, sign_in)}
</A>
</div>
}.into_any(),
}}
<div class="sidebar-footer">
// ── Debug section ─────────────────────────────────────────────────
// "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! {
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M257.1 96C238.4 96 220.9 105.4 210.5 120.9L184.5 160L128 160C92.7 160 64 188.7 64 224L64 480C64 515.3 92.7 544 128 544L512 544C547.3 544 576 515.3 576 480L576 224C576 188.7 547.3 160 512 160L455.5 160L429.5 120.9C419.1 105.4 401.6 96 382.9 96L257.1 96zM250.4 147.6C251.9 145.4 254.4 144 257.1 144L382.8 144C385.5 144 388 145.3 389.5 147.6L422.7 197.4C427.2 204.1 434.6 208.1 442.7 208.1L512 208.1C520.8 208.1 528 215.3 528 224.1L528 480.1C528 488.9 520.8 496.1 512 496.1L128 496C119.2 496 112 488.8 112 480L112 224C112 215.2 119.2 208 128 208L197.3 208C205.3 208 212.8 204 217.3 197.3L250.5 147.5zM320 448C381.9 448 432 397.9 432 336C432 274.1 381.9 224 320 224C258.1 224 208 274.1 208 336C208 397.9 258.1 448 320 448zM256 336C256 300.7 284.7 272 320 272C355.3 272 384 300.7 384 336C384 371.3 355.3 400 320 400C284.7 400 256 371.3 256 336z"/>
</svg>
<a class="game-sidebar-link" 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()
}}
</a>
</div>
})
}}
// "Replay snapshot" — always visible
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M534.6 182.6C547.1 170.1 547.1 149.8 534.6 137.3L470.6 73.3C461.4 64.1 447.7 61.4 435.7 66.4C423.7 71.4 416 83.1 416 96L416 128L256 128C150 128 64 214 64 320C64 337.7 78.3 352 96 352C113.7 352 128 337.7 128 320C128 249.3 185.3 192 256 192L416 192L416 224C416 236.9 423.8 248.6 435.8 253.6C447.8 258.6 461.5 255.8 470.7 246.7L534.7 182.7zM105.4 457.4C92.9 469.9 92.9 490.2 105.4 502.7L169.4 566.7C178.6 575.9 192.3 578.6 204.3 573.6C216.3 568.6 224 556.9 224 544L224 512L384 512C490 512 576 426 576 320C576 302.3 561.7 288 544 288C526.3 288 512 302.3 512 320C512 390.7 454.7 448 384 448L224 448L224 416C224 403.1 216.2 391.4 204.2 386.4C192.2 381.4 178.5 384.2 169.3 393.3L105.3 457.3z"/>
</svg>
<a class="game-sidebar-link" on:click=move |_| {
replay_text.set(String::new());
replay_error.set(false);
replay_open.set(true);
sidebar_open.set(false);
}>{t!(i18n, replay_snapshot)}</a>
</div>
<div>
<div class="site-nav-infolinks">
<a href="/page/about">{t!(i18n, about)}</a>
<span> - </span>
<a href="/page/legal">{t!(i18n, legal)}</a>
</div>
</div>
<div>
<span class="site-nav-version">"v" {VERSION}</span>
</div>
</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>
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::session::infer_pause_reason;
use crate::game::trictrac::types::{PlayerScore, SerStage, SerTurnStage};
fn score() -> PlayerScore {
PlayerScore {
name: String::new(),
points: 0,
holes: 0,
can_bredouille: false,
}
}
fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option<u16>) -> ViewState {
ViewState {
board: [0i8; 24],
stage: SerStage::InGame,
turn_stage,
active_mp_player: active,
scores: [score(), score()],
dice,
dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
pre_game_roll: None,
}
}
#[test]
fn dice_change_is_after_roll() {
let prev = vs((0, 0), SerTurnStage::RollDice, Some(1));
let next = vs((3, 5), SerTurnStage::Move, Some(1));
assert_eq!(
infer_pause_reason(&prev, &next, 0),
Some(PauseReason::AfterOpponentRoll)
);
}
#[test]
fn hold_to_move_is_after_go() {
let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1));
let next = vs((3, 5), SerTurnStage::Move, Some(1));
assert_eq!(
infer_pause_reason(&prev, &next, 0),
Some(PauseReason::AfterOpponentGo)
);
}
#[test]
fn turn_switch_is_after_move() {
let prev = vs((3, 5), SerTurnStage::Move, Some(1));
let next = vs((3, 5), SerTurnStage::RollDice, Some(0));
assert_eq!(
infer_pause_reason(&prev, &next, 0),
Some(PauseReason::AfterOpponentMove)
);
}
#[test]
fn own_action_returns_none() {
let prev = vs((0, 0), SerTurnStage::RollDice, Some(0));
let next = vs((2, 4), SerTurnStage::Move, Some(0));
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
}
#[test]
fn no_active_player_returns_none() {
let mut prev = vs((0, 0), SerTurnStage::RollDice, None);
prev.stage = SerStage::PreGame;
let mut next = prev.clone();
next.active_mp_player = Some(0);
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
}
}

View file

@ -1,862 +0,0 @@
use leptos::prelude::*;
use trictrac_store::CheckerMove;
use super::die::Die;
use crate::game::trictrac::types::{SerTurnStage, ViewState};
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1];
/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6];
const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12];
const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system.
/// Returns true when `field_num` is the rest corner for this perspective.
#[allow(dead_code)]
fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
if is_white {
field_num == 12
} else {
field_num == 13
}
}
/// Zone CSS class for a field number (field coordinates are always White's 1-24).
fn field_zone_class(field_num: u8) -> &'static str {
match field_num {
1..=6 => "zone-petit",
7..=12 => "zone-grand",
13..=18 => "zone-opponent",
19..=24 => "zone-retour",
_ => "",
}
}
/// Returns (d0_used, d1_used) for the bar dice display.
pub(crate) fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
let mut d0 = false;
let mut d1 = false;
for &(from, to) in staged {
let dist = if to == 0 {
if from > 18 {
(25 as u8).saturating_sub(from)
} else {
from.saturating_sub(0)
}
} else if from < to {
to.saturating_sub(from)
} else {
from.saturating_sub(to)
};
if !d0 && dist == dice.0 {
d0 = true;
} else if !d1 && dist == dice.1 {
d1 = true;
} else if !d0 && dist <= dice.0 && dice.0 <= dice.1 {
d0 = true;
} else {
d1 = true;
}
}
(d0, d1)
}
/// Returns the displayed board value for `field_num` after applying `staged_moves`.
/// Field numbers are always in white's coordinate system (124).
fn displayed_value(
base_board: [i8; 24],
staged_moves: &[(u8, u8)],
is_white: bool,
field_num: u8,
) -> i8 {
let mut val = base_board[(field_num - 1) as usize];
let delta: i8 = if is_white { 1 } else { -1 };
for &(from, to) in staged_moves {
if from == field_num {
val -= delta;
}
if to == field_num {
val += delta;
}
}
val
}
/// Fields whose checkers may be selected as the next origin given already-staged moves.
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs
.iter()
.map(|(m1, _)| m1.get_from() as u8)
.filter(|&f| f != 0)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.map(|(_, m2)| m2.get_from() as u8)
.filter(|&f| f != 0)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
/// Pixel center of a board field in the SVG overlay coordinate space.
/// Geometry: field 60×180px, board padding 4px, row gap 4px, bar 5px, center-bar 12px.
/// Quarter width: 6×60 + 5×2(inter-field gap) = 370px. Board total: 761px.
/// With triangular flèches, arrows target the WIDE BASE of each triangle —
/// that is where the checker stack actually sits.
fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
if f == 0 || f > 24 {
return None;
}
let (qi, right, top): (usize, bool, bool) = if is_white {
match f {
13..=18 => (f - 13, false, true),
19..=24 => (f - 19, true, true),
7..=12 => (12 - f, false, false),
1..=6 => (6 - f, true, false),
_ => return None,
}
} else {
match f {
1..=6 => (f - 1, false, true),
7..=12 => (f - 7, true, true),
19..=24 => (24 - f, false, false),
13..=18 => (18 - f, true, false),
_ => return None,
}
};
// Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
// Right-quarter: 4 + 370(quarter) + 4(gap) + 5(bar) + 4(gap) + i*62 + 30 = 417 + 62i
let x = if right {
417.0 + qi as f32 * 62.0
} else {
34.0 + qi as f32 * 62.0
};
// Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
// (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 4pad 4field-pad 20 ≈ 360)
let y = if top { 30.0 } else { 358.0 };
Some((x, y))
}
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let (x1, y1) = fp;
let (x2, y2) = tp;
let dx = x2 - x1;
let dy = y2 - y1;
let len = (dx * dx + dy * dy).sqrt();
if len < 10.0 {
return view! { <g /> }.into_any();
}
let nx = dx / len;
let ny = dy / len;
let px = -ny;
let py = nx;
// Shrink line ends so arrows don't overlap the checker stack
let lx1 = x1 + nx * 20.0;
let ly1 = y1 + ny * 20.0;
let lx2 = x2 - nx * 15.0;
let ly2 = y2 - ny * 15.0;
// Arrowhead triangle at (x2, y2)
let ah = 15.0_f32;
let aw = 7.0_f32;
let bx = x2 - nx * ah;
let bary = y2 - ny * ah;
let pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2,
y2,
bx + px * aw,
bary + py * aw,
bx - px * aw,
bary - py * aw,
);
let shadow_pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2,
y2,
bx + px * (aw + 1.5),
bary + py * (aw + 1.5),
bx - px * (aw + 1.5),
bary - py * (aw + 1.5),
);
view! {
<g>
// Drop-shadow for readability on coloured fields
<line
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
style="stroke:rgba(0,0,0,0.45);stroke-width:5;stroke-linecap:round"
/>
<polygon points=shadow_pts style="fill:rgba(0,0,0,0.45)" />
// Gold arrow
<line
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
style="stroke:rgba(255,215,0,0.9);stroke-width:3;stroke-linecap:round"
/>
<polygon points=pts style="fill:rgba(255,215,0,0.9)" />
</g>
}
.into_any()
}
/// Valid destinations for a selected origin given already-staged moves.
/// May include 0 (exit); callers handle that case.
fn valid_dests_for(
seqs: &[(CheckerMove, CheckerMove)],
staged: &[(u8, u8)],
origin: u8,
) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs
.iter()
.filter(|(m1, _)| m1.get_from() as u8 == origin)
.map(|(m1, _)| m1.get_to() as u8)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, m2)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
&& m2.get_from() as u8 == origin
})
.map(|(_, m2)| m2.get_to() as u8)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
/// In free-mode: all fields that own a checker (after staged moves applied).
fn free_mode_origins_for(board: [i8; 24], staged: &[(u8, u8)], is_white: bool) -> Vec<u8> {
(1u8..=24)
.filter(|&f| {
let v = displayed_value(board, staged, is_white, f);
if is_white {
v > 0
} else {
v < 0
}
})
.collect()
}
/// In free-mode: destinations reachable from `origin` by the remaining die value,
/// excluding fields occupied by opponent checkers.
fn free_mode_dests_for(
board: [i8; 24],
staged: &[(u8, u8)],
origin: u8,
dice: (u8, u8),
is_white: bool,
all_in_exit: bool,
) -> Vec<u8> {
let to_use: Vec<u8> = match staged.len() {
0 => {
if dice.0 == dice.1 {
vec![dice.0]
} else {
vec![dice.0, dice.1]
}
}
1 => {
let &(f0, t0) = &staged[0];
if t0 == 0 {
// First move was an exit — can't reliably infer die, offer both
if dice.0 == dice.1 {
vec![dice.0]
} else {
vec![dice.0, dice.1]
}
} else {
let dist: u8 = if is_white {
t0.saturating_sub(f0)
} else {
f0.saturating_sub(t0)
};
if dice.0 == dice.1 {
vec![dice.0]
} else if dist == dice.0 {
vec![dice.1]
} else {
vec![dice.0]
}
}
}
_ => return vec![],
};
let opp_present = |f: u8| -> bool {
let v = displayed_value(board, staged, is_white, f);
if is_white {
v < 0
} else {
v > 0
}
};
let mut dests = vec![];
for die in to_use {
if die == 0 {
continue;
}
let dest: i16 = if is_white {
origin as i16 + die as i16
} else {
origin as i16 - die as i16
};
if dest >= 1 && dest <= 24 {
let d = dest as u8;
if !opp_present(d) {
if d == 13 && is_white && displayed_value(board, staged, is_white, 12) < 2 {
// prise de coin par puissance for white
dests.push(12)
} else if d == 12 && !is_white && displayed_value(board, staged, is_white, 13) > -2
{
// prise de coin par puissance for black
dests.push(13)
} else {
dests.push(d);
}
}
} else if all_in_exit {
dests.push(0); // exit
}
}
dests.sort_unstable();
dests.dedup();
dests
}
#[component]
pub fn Board(
view_state: ViewState,
player_id: u16,
/// Pending origin selection (first click of a move pair).
selected_origin: RwSignal<Option<u8>>,
/// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move.
staged_moves: RwSignal<Vec<(u8, u8)>>,
/// All valid two-move sequences for this turn (empty when not in move stage).
valid_sequences: Vec<(CheckerMove, CheckerMove)>,
/// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
#[prop(default = None)]
bar_dice: Option<(u8, u8)>,
/// Whether we're in the move stage (determines used/unused die appearance).
#[prop(default = false)]
bar_is_move: bool,
#[prop(default = false)] is_my_turn: bool,
/// Whether the dice are a double (golden glow).
#[prop(default = false)]
bar_is_double: bool,
/// Checker moves to animate on mount (None when board unchanged).
#[prop(default = None)]
last_moves: Option<(CheckerMove, CheckerMove)>,
/// Fields where a hit (battue) was scored this turn — show ripple animation.
#[prop(default = vec![])]
hit_fields: Vec<u8>,
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
#[prop(default = false)]
suppress_dice_anim: bool,
/// When true, any field with own checkers is selectable as origin; destinations
/// are computed from dice arithmetic rather than from pre-validated sequences.
#[prop(default = RwSignal::new(false))]
free_mode: RwSignal<bool>,
) -> impl IntoView {
let board = view_state.board;
let vs_dice = view_state.dice;
let white_points = view_state.scores[0].points;
let white_can_bredouille = view_state.scores[0].can_bredouille;
let black_points = view_state.scores[1].points;
let black_can_bredouille = view_state.scores[1].can_bredouille;
let is_move_stage = view_state.active_mp_player == Some(player_id)
&& matches!(
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
// True when ANY player is in the Move/HoldOrGoChoice stage — i.e., dice are fresh for the active player.
let active_is_move_stage = matches!(
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let is_white = player_id == 0;
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// Exit-eligible: all the player's checkers are in their last jan.
// White last jan = fields 19-24 (board indices 18-23, positive values).
// Black last jan = fields 1-6 (board indices 0-5, negative values).
let board_snapshot = view_state.board;
let all_in_exit: bool;
let exit_field_test: fn(u8) -> bool;
if is_white {
let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum();
let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum();
all_in_exit = total > 0 && in_exit == total;
exit_field_test = |f| matches!(f, 19..=24);
} else {
let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum();
let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum();
all_in_exit = total > 0 && in_exit == total;
exit_field_test = |f| matches!(f, 1..=6);
}
// 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> {
nums.iter()
.map(|&field_num| {
// Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)>
// is Send, which Leptos requires for reactive attribute functions.
let seqs_c = valid_sequences.clone();
let seqs_k = valid_sequences.clone();
let corner_title = if is_rest_corner(field_num, is_white) {
Some("Coin de repos — must enter and leave with 2 checkers")
} else {
None
};
// §4a — slide delta for the arriving checker at this field.
// Computed once per field at render time; Option<(f32,f32)> is Copy.
let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| {
[m1, m2].iter().find_map(|m| {
if m.get_to() != field_num as usize || m.get_from() == m.get_to() {
return None;
}
let (fx, fy) = field_center(m.get_from(), is_white)?;
let (tx, ty) = field_center(m.get_to(), is_white)?;
let dx = fx - tx;
let dy = fy - ty;
(dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
})
});
// §6e — ripple on hit fields (battue).
let is_hit_field = hit_fields.contains(&field_num);
view! {
<div
id={format!("field-{field_num}")}
title=corner_title
class=move || {
let staged = staged_moves.get();
let val = displayed_value(board, &staged, is_white, field_num);
let is_mine = if is_white { val > 0 } else { val < 0 };
let can_stage = is_move_stage && staged.len() < 2;
let sel = selected_origin.get();
let mut cls = format!("field {}", field_zone_class(field_num));
let is_white_pt = field_num >= 1 && field_num <= white_points;
let is_black_pt = black_points > 0 && field_num >= 25 - black_points;
if is_white_pt {
cls.push_str(if white_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
} else if is_black_pt {
cls.push_str(if black_can_bredouille { " point-bredouille" } else { " point-no-bredouille" });
}
if is_rest_corner(field_num, is_white) {
cls.push_str(" corner");
// Pulse when the corner can be reached this turn
if !seqs_c.is_empty() && seqs_c.iter().any(|(m1, m2)| {
m1.get_to() as u8 == field_num
|| m2.get_to() as u8 == field_num
}) {
cls.push_str(" corner-available");
}
}
if is_rest_corner(field_num, !is_white) {
cls.push_str(" corner");
}
if all_in_exit && exit_field_test(field_num) {
cls.push_str(" exit-eligible");
}
if seqs_c.is_empty() && !is_move_stage {
// No restriction (dice not rolled or not move stage)
if can_stage && (sel.is_some() || is_mine) {
cls.push_str(" clickable");
}
if sel == Some(field_num) { cls.push_str(" selected"); }
if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest");
}
} else if can_stage && free_mode.get() {
// Free-play mode: highlight based on dice arithmetic
if let Some(origin) = sel {
if origin == field_num {
cls.push_str(" selected clickable");
} else {
let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
if dests.iter().any(|&d| d == field_num && d != 0) {
cls.push_str(" clickable dest");
}
}
} else {
let origins = free_mode_origins_for(board, &staged, is_white);
if origins.iter().any(|&o| o == field_num) {
cls.push_str(" clickable");
}
}
} else if can_stage {
if let Some(origin) = sel {
if origin == field_num {
cls.push_str(" selected clickable");
} else {
let dests = valid_dests_for(&seqs_c, &staged, origin);
// Only highlight non-exit destinations (field 0 = exit has no tile)
if dests.iter().any(|&d| d == field_num && d != 0) {
cls.push_str(" clickable dest");
}
}
} else {
let origins = valid_origins_for(&seqs_c, &staged);
if origins.iter().any(|&o| o == field_num) {
cls.push_str(" clickable");
}
}
}
// §6c: highlight fields touched by the hovered jan
if let Some(hm) = hovered_moves {
let pairs = hm.get();
let f = field_num as usize;
let highlighted = pairs.iter().any(|(m1, m2)| {
(m1.get_from() != 0 && m1.get_from() == f)
|| (m1.get_to() != 0 && m1.get_to() == f)
|| (m2.get_from() != 0 && m2.get_from() == f)
|| (m2.get_to() != 0 && m2.get_to() == f)
});
if highlighted {
cls.push_str(" jan-hovered");
}
}
cls
}
on:click=move |_| {
if !is_move_stage { return; }
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
if free_mode.get_untracked() {
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
}
Some(origin) => {
let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
if dests.iter().any(|&d| d == field_num) {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None);
}
}
None => {
let origins = free_mode_origins_for(board, &staged, is_white);
if origins.iter().any(|&o| o == field_num) {
selected_origin.set(Some(field_num));
}
}
}
} else {
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
}
Some(origin) => {
let valid = if seqs_k.is_empty() {
true
} else {
valid_dests_for(&seqs_k, &staged, origin)
.iter()
.any(|&d| d == field_num)
};
if valid {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None);
}
}
None => {
if seqs_k.is_empty() {
let val = displayed_value(board, &staged, is_white, field_num);
if is_white && val > 0 || !is_white && val < 0 {
selected_origin.set(Some(field_num));
}
} else {
let origins = valid_origins_for(&seqs_k, &staged);
if origins.iter().any(|&o| o == field_num) {
selected_origin.set(Some(field_num));
}
}
}
}
}
}
>
<span class="field-num">{field_num}</span>
{move || {
let moves = staged_moves.get();
let val = displayed_value(board, &moves, is_white, field_num);
let count = val.unsigned_abs();
// §6e — ripple on hit (battue) fields; must be inside the
// reactive closure so Leptos uses the same direct rendering
// path as .arriving (avoids node-move that resets animation).
let ripple = is_hit_field.then(|| {
let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" };
view! { <div class=cls></div> }.into_any()
});
let stack = (count > 0).then(|| {
let color = if val > 0 { "white" } else { "black" };
let display_n = (count as usize).min(4);
// outermost index: last for top rows, first for bottom rows.
let outer_idx = if is_top_row { display_n - 1 } else { 0 };
let chips: Vec<AnyView> = (0..display_n).map(|i| {
let label = if i == outer_idx && count >= 5 {
count.to_string()
} else {
String::new()
};
if i == outer_idx {
if let Some((dx, dy)) = slide_delta {
return view! {
<div
class=format!("checker {color} arriving")
style=format!("--slide-dx:{dx:.1}px;--slide-dy:{dy:.1}px")
>{label}</div>
}.into_any();
}
}
view! {
<div class=format!("checker {color}")>{label}</div>
}.into_any()
}).collect();
view! { <div class="checker-stack">{chips}</div> }.into_any()
});
(ripple, stack)
}}
</div>
}
.into_any()
})
.collect()
};
// ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
let bar_content = move |die_idx: u8| -> AnyView {
match bar_dice {
None => view! { <div class="bar-die-slot"></div> }.into_any(),
Some(dice_vals) => {
let die_val = if die_idx == 0 {
dice_vals.0
} else {
dice_vals.1
};
view! {
<div class="bar-die-slot">
{move || {
let staged = staged_moves.get();
let (u0, u1) = if bar_is_move {
bar_matched_dice_used(&staged, dice_vals)
} else if is_my_turn {
(true, true)
} else if active_is_move_stage && !suppress_dice_anim {
// Opponent has fresh dice in their Move stage (first view).
(false, false)
} else {
// Dice are old: either from the previous turn (opponent not yet
// rolled) or this is the echo screen after a pending confirm.
(true, true)
};
let used = if die_idx == 0 { u0 } else { u1 };
view! { <Die value=die_val used=used is_double=bar_is_double /> }
}}
</div>
}
.into_any()
}
}
};
let (tl, tr, bl, br) = if is_white {
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
} else {
(&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
};
view! {
// board-wrapper keeps zone labels outside .board so the SVG overlay
// inside .board stays correctly positioned (position:absolute top:0 left:0
// is relative to .board, not the wrapper).
<div class="board-wrapper">
<div class="board">
<div class="board-row top-row">
<div class="board-quarter">{fields_from(tl, true)}</div>
<div class="board-bar">{bar_content(0)}</div>
<div class="board-quarter">{fields_from(tr, true)}</div>
</div>
<div class="board-center-bar"></div>
<div class="board-row bot-row">
<div class="board-quarter">{fields_from(bl, false)}</div>
<div class="board-bar">{bar_content(1)}</div>
<div class="board-quarter">{fields_from(br, false)}</div>
</div>
// SVG overlay: arrows for hovered jan moves
<svg
width="761" height="388"
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
>
{move || {
let Some(hm) = hovered_moves else { return vec![]; };
let pairs = hm.get();
if pairs.is_empty() { return vec![]; }
// Collect unique individual (from, to) moves; skip empty/exit.
let mut moves: Vec<(usize, usize)> = pairs.iter()
.flat_map(|(m1, m2)| [
(m1.get_from(), m1.get_to()),
(m2.get_from(), m2.get_to()),
])
.filter(|&(f, t)| f != 0 && t != 0)
.collect();
moves.sort_unstable();
moves.dedup();
moves.into_iter()
.filter_map(|(from, to)| {
let p1 = field_center(from, is_white)?;
let p2 = field_center(to, is_white)?;
Some(arrow_svg(p1, p2))
})
.collect()
}}
</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).
{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 && if free_mode.get() {
// In free mode show exit button whenever all checkers are in exit zone
all_in_exit && staged.len() < 2
} else {
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) => if free_mode.get() {
free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
.iter().any(|&d| d == 0)
} else {
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 = if free_mode.get_untracked() {
free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
.iter().any(|&d| d == 0)
} else {
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>
}
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test]
fn test_bar_matched_dice_used() {
assert_eq!((true, false), bar_matched_dice_used(&[(22, 24)], (2, 3)));
assert_eq!((false, true), bar_matched_dice_used(&[(22, 0)], (2, 3)));
assert_eq!((false, true), bar_matched_dice_used(&[(24, 0)], (5, 1)));
assert_eq!((true, false), bar_matched_dice_used(&[(24, 0)], (1, 5)));
}
}

View file

@ -1,647 +0,0 @@
use std::cell::Cell;
use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender;
use gloo_storage::Storage as _;
use leptos::prelude::*;
use trictrac_store::{
Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveError, MoveRules,
};
use super::board::{bar_matched_dice_used, Board};
use super::die::Die;
use crate::app::{GameUiState, NetCommand, PauseReason};
use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
use crate::i18n::*;
use crate::portal::lobby::{qr_svg, room_url};
use super::score_panel::MergedScorePanel;
use super::scoring::ScoringPanel;
#[component]
pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n();
let vs = state.view_state.clone();
let vs_board = vs.board;
let vs_dice = vs.dice;
let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id);
let is_move_stage = is_my_turn
&& matches!(
vs.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let waiting_for_confirm = state.waiting_for_confirm;
let pause_reason = state.pause_reason.clone();
let suppress_dice_anim = state.suppress_dice_anim;
// ── Hovered jan moves (shown as arrows on the board) ──────────────────────
let hovered_jan_moves: RwSignal<Vec<(CheckerMove, CheckerMove)>> = RwSignal::new(vec![]);
provide_context(hovered_jan_moves);
// ── Staged move state ──────────────────────────────────────────────────────
let selected_origin: RwSignal<Option<u8>> = RwSignal::new(None);
let staged_moves: RwSignal<Vec<(u8, u8)>> = RwSignal::new(Vec::new());
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let pending =
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
let cmd_tx_effect = cmd_tx.clone();
let prev_staged_len = Cell::new(0usize);
// ── Free-play mode ─────────────────────────────────────────────────────────
fn load_free_mode() -> bool {
gloo_storage::LocalStorage::get::<bool>("trictrac_free_mode").unwrap_or(false)
}
fn save_free_mode(val: bool) {
gloo_storage::LocalStorage::set("trictrac_free_mode", val).ok();
}
let free_mode: RwSignal<bool> = RwSignal::new(load_free_mode());
let move_error: RwSignal<Option<Option<MoveError>>> = RwSignal::new(None);
Effect::new(move |_| {
let moves = staged_moves.get();
let n = moves.len();
if n > prev_staged_len.get() {
crate::game::sound::play_checker_move();
}
prev_staged_len.set(n);
if n == 2 {
let to_cm = |&(from, to): &(u8, u8)| {
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
};
let m1 = to_cm(&moves[0]);
let m2 = to_cm(&moves[1]);
if free_mode.get_untracked() {
let (vm1, vm2) = if player_id == 0 {
(m1, m2)
} else {
(m1.mirror(), m2.mirror())
};
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs_board);
let store_dice = StoreDice { values: vs_dice };
let color = if player_id == 0 {
Color::White
} else {
Color::Black
};
let rules = MoveRules::new(&color, &store_board, store_dice);
if rules.moves_follow_rules(&(vm1, vm2)) {
cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2)))
.ok();
staged_moves.set(vec![]);
selected_origin.set(None);
prev_staged_len.set(0);
} else {
let specific_err = rules.moves_allowed(&(vm1, vm2)).err();
move_error.set(Some(specific_err));
// Keep staged_moves intact so pieces stay in place until Retry is clicked.
}
} else {
cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2)))
.ok();
staged_moves.set(vec![]);
selected_origin.set(None);
prev_staged_len.set(0);
}
}
});
// ── Auto-roll effect ─────────────────────────────────────────────────────
let show_roll =
is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll;
if show_roll && !waiting_for_confirm {
let cmd_tx_auto = cmd_tx.clone();
Effect::new(move |_| {
cmd_tx_auto
.unbounded_send(NetCommand::Action(PlayerAction::Roll))
.ok();
});
}
let dice = vs.dice;
let show_dice = dice != (0, 0);
// ── Button senders ─────────────────────────────────────────────────────────
let cmd_tx_go = cmd_tx.clone();
let cmd_tx_end_quit = cmd_tx.clone();
let cmd_tx_end_replay = cmd_tx.clone();
let show_hold_go = is_my_turn
&& vs.turn_stage == SerTurnStage::HoldOrGoChoice
&& state.my_scored_event.is_none();
// ── Valid move sequences for this turn ─────────────────────────────────────
let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) {
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs.board);
let store_dice = StoreDice { values: dice };
let color = if player_id == 0 {
Color::White
} else {
Color::Black
};
let rules = MoveRules::new(&color, &store_board, store_dice);
let raw = rules.get_possible_moves_sequences(true, vec![]);
if player_id == 0 {
raw
} else {
raw.into_iter()
.map(|(m1, m2)| (m1.mirror(), m2.mirror()))
.collect()
}
} else {
vec![]
};
let valid_seqs_empty = valid_sequences.clone();
// ── Scores ─────────────────────────────────────────────────────────────────
let my_score = vs.scores[player_id as usize].clone();
let opp_score = vs.scores[1 - player_id as usize].clone();
// ── Ceremony state ──────────────────────────────────────────────────────────
let is_ceremony = vs.stage == SerStage::PreGameRoll;
let pre_game_roll_data: Option<PreGameRollState> = vs.pre_game_roll.clone();
let my_name_ceremony = my_score.name.clone();
let opp_name_ceremony = opp_score.name.clone();
let cmd_tx_ceremony = cmd_tx.clone();
// ── Scoring notifications ──────────────────────────────────────────────────
let my_scored_event = state.my_scored_event.clone();
let opp_scored_event = state.opp_scored_event.clone();
let my_pts_earned: u8 = my_scored_event.as_ref().map_or(0, |e| {
if e.holes_gained == 0 {
e.points_earned
} else {
0
}
});
let opp_pts_earned: u8 = opp_scored_event.as_ref().map_or(0, |e| {
if e.holes_gained == 0 {
e.points_earned
} else {
0
}
});
let my_holes_gained_score: u8 = my_scored_event.as_ref().map_or(0, |e| e.holes_gained);
let opp_holes_gained_score: u8 = opp_scored_event.as_ref().map_or(0, |e| e.holes_gained);
let my_bredouille_flash: bool = my_scored_event
.as_ref()
.map_or(false, |e| e.bredouille && e.holes_gained > 0);
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
let last_moves = state.last_moves;
let hit_fields: Vec<u8> = {
let is_hit_jan = |jan: &Jan| {
matches!(
jan,
Jan::TrueHitSmallJan
| Jan::TrueHitBigJan
| Jan::TrueHitOpponentCorner
| Jan::FalseHitSmallJan
| Jan::FalseHitBigJan
)
};
let mut fields: Vec<u8> = vec![];
for event_opt in [&my_scored_event, &opp_scored_event] {
if let Some(event) = event_opt {
for entry in &event.jans {
if is_hit_jan(&entry.jan) {
for (m1, m2) in &entry.moves {
for m in [m1, m2] {
let to = m.get_to() as u8;
if to != 0 && !fields.contains(&to) {
fields.push(to);
}
}
}
}
}
}
}
fields
};
// ── Sound effects ──────────────────────────────────────────────────────────
let active_is_move_stage = matches!(
vs.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
if show_dice && last_moves.is_none() && active_is_move_stage && !suppress_dice_anim {
crate::game::sound::play_dice_roll();
}
if last_moves.is_some() {
crate::game::sound::play_checker_move();
}
if let Some(ref ev) = my_scored_event {
if ev.holes_gained > 0 {
crate::game::sound::play_hole_scored();
}
}
if let Some(ref ev) = opp_scored_event {
if ev.holes_gained > 0 {
crate::game::sound::play_opp_hole_scored();
}
}
// ── Capture for closures ───────────────────────────────────────────────────
let stage = vs.stage.clone();
let turn_stage = vs.turn_stage.clone();
let turn_stage_for_panel = turn_stage.clone();
let turn_stage_for_sub = turn_stage.clone();
let room_id = state.room_id.clone();
let is_bot_game = state.is_bot_game;
// ── Active player indicator ────────────────────────────────────────────────
let active_player_is_me: Option<bool> = if stage == SerStage::InGame {
Some(is_my_turn)
} else {
None
};
// ── Game-over info ─────────────────────────────────────────────────────────
let stage_is_ended = stage == SerStage::Ended;
let winner_is_me = my_score.holes >= 12;
let my_name_end = my_score.name.clone();
let my_holes_end = my_score.holes;
let opp_name_end = opp_score.name.clone();
let opp_holes_end = opp_score.holes;
let share_url_copied = RwSignal::new(false);
let share_url = if !is_bot_game {
room_url(&room_id)
} else {
String::new()
};
let share_svg = if !is_bot_game {
qr_svg(&share_url)
} else {
String::new()
};
view! {
<div class="game-container">
// ── Share popover (while waiting for opponent) ───────────────────
{(!is_bot_game && stage == SerStage::PreGame).then(|| {
let url_label = share_url.clone();
let url_copy = share_url.clone();
let svg = share_svg.clone();
view! {
<div class="share-popover">
<p class="share-popover-label">{t!(i18n, share_link)}</p>
<div class="share-url-row">
<span class="share-url-text">{url_label}</span>
<button class="share-copy-btn" on:click=move |_| {
#[cfg(target_arch = "wasm32")]
{
let u = url_copy.clone();
wasm_bindgen_futures::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(&u),
).await;
share_url_copied.set(true);
gloo_timers::future::TimeoutFuture::new(2000).await;
share_url_copied.set(false);
}
});
}
}>
{move || if share_url_copied.get() {
t_string!(i18n, link_copied)
} else {
t_string!(i18n, copy_link)
}}
</button>
</div>
<p class="share-popover-label">{t!(i18n, scan_qr)}</p>
<div class="qr-container" inner_html=svg />
</div>
}
})}
// ── Player strip (full-width, in-flow) ───────────────────────────
<MergedScorePanel
my_score=my_score
opp_score=opp_score
my_points_earned=my_pts_earned
opp_points_earned=opp_pts_earned
my_holes_gained=my_holes_gained_score
opp_holes_gained=opp_holes_gained_score
my_bredouille=my_bredouille_flash
active_player_is_me=active_player_is_me
/>
// ── Board + controls (sidebar on wide, footer on narrow) ─────────
<div class="main-body">
<Board
view_state=vs
player_id=player_id
selected_origin=selected_origin
staged_moves=staged_moves
valid_sequences=valid_sequences
bar_dice=show_dice.then_some(dice)
bar_is_move=is_move_stage
is_my_turn=is_my_turn
bar_is_double=is_double_dice
last_moves=last_moves
hit_fields=hit_fields
suppress_dice_anim=suppress_dice_anim
free_mode=free_mode
/>
// ── Controls: dice card + status/actions card ────────────────
<div class="controls">
{show_dice.then(|| view! {
<div class="ctrl-dice">
<div class="ctrl-dice-row">
{move || {
let staged = staged_moves.get();
let (u0, u1) = if suppress_dice_anim {
(true, true)
} else if is_move_stage {
bar_matched_dice_used(&staged, dice)
} else {
(false, false)
};
view! {
<Die value=dice.0 used=u0 is_double=is_double_dice />
<Die value=dice.1 used=u1 is_double=is_double_dice />
}
}}
</div>
<label class="free-mode-toggle">
<input
type="checkbox"
prop:checked=move || free_mode.get()
on:change=move |ev| {
let v = event_target_checked(&ev);
save_free_mode(v);
free_mode.set(v);
move_error.set(None);
}
/>
{t!(i18n, free_mode_label)}
<span class="free-mode-help"
title=move || t_string!(i18n, free_mode_tooltip).to_owned()>
"?"
</span>
</label>
</div>
})}
<div class="ctrl-status">
<div class="game-status">
{move || {
if let Some(ref reason) = pause_reason {
return String::from(match reason {
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
});
}
let n = staged_moves.get().len();
if is_move_stage {
t_string!(i18n, select_move, n = n + 1)
} else {
String::from(match (&stage, is_my_turn, &turn_stage) {
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
(SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent),
(SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
(SerStage::InGame, true, _) => t_string!(i18n, your_turn),
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
})
}
}}
</div>
{move || {
let hint: String = if waiting_for_confirm {
t_string!(i18n, hint_continue).to_owned()
} else if is_move_stage {
t_string!(i18n, hint_move).to_owned()
} else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
t_string!(i18n, hint_hold_or_go).to_owned()
} else {
String::new()
};
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
}}
// ── Free-mode error banner ─────────────────────────────
{move || {
move_error.get().map(|opt_err| {
let msg: String = match opt_err {
None => t_string!(i18n, err_invalid_move).to_owned(),
Some(MoveError::OpponentCorner) => t_string!(i18n, err_opponent_corner).to_owned(),
Some(MoveError::CornerNeedsTwoCheckers) => t_string!(i18n, err_corner_needs_two).to_owned(),
Some(MoveError::CornerByEffectPossible) => t_string!(i18n, err_corner_by_effect).to_owned(),
Some(MoveError::ExitNeedsAllCheckersOnLastQuarter) => t_string!(i18n, err_exit_needs_all_in_last_jan).to_owned(),
Some(MoveError::ExitByEffectPossible) => t_string!(i18n, err_exit_by_effect).to_owned(),
Some(MoveError::ExitNotFarthest) => t_string!(i18n, err_exit_not_farthest).to_owned(),
Some(MoveError::OpponentCanFillQuarter) => t_string!(i18n, err_opponent_can_fill_quarter).to_owned(),
Some(MoveError::MustFillQuarter) => t_string!(i18n, err_must_fill_quarter).to_owned(),
Some(MoveError::MustPlayAllDice) => t_string!(i18n, err_must_play_all_dice).to_owned(),
Some(MoveError::MustPlayStrongerDie) => t_string!(i18n, err_must_play_stronger_die).to_owned(),
};
view! {
<div class="free-mode-error">
<span class="free-mode-error-msg">{msg}</span>
<button
class="btn btn-secondary"
on:click=move |_| {
staged_moves.set(vec![]);
selected_origin.set(None);
move_error.set(None);
}
>{t!(i18n, reset_move)}</button>
</div>
}
})
}}
<div class="board-actions">
{waiting_for_confirm.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
pending.update(|q| { q.pop_front(); });
}>{t!(i18n, continue_btn)}</button>
})}
{show_hold_go.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>{t!(i18n, go)}</button>
})}
{move || {
let staged = staged_moves.get();
let show = is_move_stage && staged.len() < 2 && (
valid_seqs_empty.is_empty() || match staged.len() {
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
1 => {
let (f0, t0) = staged[0];
valid_seqs_empty.iter()
.filter(|(m1, _)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
})
.any(|(_, m2)| m2.get_from() == 0)
}
_ => false,
}
);
show.then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
selected_origin.set(None);
staged_moves.update(|v| v.push((0, 0)));
}
>{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>
</div>
</div>
// ── Scoring notification panels ───────────────────────────────────
<div class="scoring-row">
<div class="scoring-panels-container">
{my_scored_event.map(|event| view! {
<ScoringPanel event=event turn_stage=turn_stage_for_panel />
})}
{opp_scored_event.map(|event| view! {
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true />
})}
</div>
</div>
// ── Pre-game ceremony overlay ─────────────────────────────────────
{is_ceremony.then(|| {
let pgr = pre_game_roll_data.unwrap_or(PreGameRollState {
host_die: None,
guest_die: None,
tie_count: 0,
});
if pgr.host_die != None {
crate::game::sound::play_dice_roll();
}
let my_die = if player_id == 0 { pgr.host_die } else { pgr.guest_die };
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">
<h2>{t!(i18n, pre_game_roll_title)}</h2>
{show_tie.then(|| view! {
<p class="ceremony-tie">{t!(i18n, pre_game_roll_tie)}</p>
})}
<div class="ceremony-dice">
<div class="ceremony-die-slot">
<span class="ceremony-die-label">{my_name_ceremony}{t!(i18n, you_suffix)}</span>
<Die value=my_die.unwrap_or(0) used=false />
</div>
<div class="ceremony-die-slot">
<span class="ceremony-die-label">{opp_name_ceremony}</span>
<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! {
<button class="btn btn-primary" on:click=move |_| {
pending_c.update(|q| { q.pop_front(); });
}>{t!(i18n, continue_btn)}</button>
}
})}
{can_roll.then(|| {
let cmd_tx_c = cmd_tx_ceremony.clone();
view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_c.unbounded_send(NetCommand::Action(PlayerAction::PreGameRoll)).ok();
}>{t!(i18n, pre_game_roll_btn)}</button>
}
})}
</div>
</div>
}
})}
// ── Game-over overlay ─────────────────────────────────────────────
{stage_is_ended.then(|| {
if winner_is_me {
crate::game::sound::play_victory();
} else {
crate::game::sound::play_defeat();
}
let opp_name_end_clone = opp_name_end.clone();
let winner_text = move || if winner_is_me {
t_string!(i18n, you_win).to_owned()
} else {
t_string!(i18n, opp_wins, name = opp_name_end_clone.as_str())
};
view! {
<div class="game-over-overlay">
<div class="game-over-box">
<h2>{t!(i18n, game_over)}</h2>
<p class="game-over-winner">{winner_text}</p>
<div class="game-over-score">
<span class="game-over-score-name">{my_name_end}</span>
<span class="game-over-score-nums">
{format!("{my_holes_end}{opp_holes_end}")}
</span>
<span class="game-over-score-name">{opp_name_end.clone()}</span>
</div>
<div class="game-over-actions">
<button class="btn btn-secondary" on:click=move |_| {
cmd_tx_end_quit.unbounded_send(NetCommand::Disconnect).ok();
}>{t!(i18n, quit)}</button>
{is_bot_game.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_end_replay.unbounded_send(NetCommand::PlayVsBot).ok();
}>{t!(i18n, play_again)}</button>
})}
</div>
</div>
</div>
}
})}
</div>
}
}

View file

@ -1,233 +0,0 @@
#[cfg(target_arch = "wasm32")]
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;
#[cfg(target_arch = "wasm32")]
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use trictrac_store::Jan;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
use crate::game::trictrac::types::PlayerScore;
use crate::i18n::*;
pub fn jan_label(jan: &Jan) -> String {
let i18n = use_i18n();
match jan {
Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(),
Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(),
Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(),
Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(),
Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(),
Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(),
Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(),
Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(),
Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(),
Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(),
Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(),
Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(),
Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(),
}
}
/// Full-width player strip at the top of the game screen.
///
/// - Left side: me (right-aligned toward center): avatar → name → pegs → pts.
/// - Center: "Trictrac" italic title.
/// - Right side: opponent (left-aligned from center): pts → pegs → name → avatar.
/// - Active player zone gets a subtle rounded highlight.
/// - Points animate as a jackpot counter; new peg pops in with an animation.
#[component]
pub fn MergedScorePanel(
my_score: PlayerScore,
opp_score: PlayerScore,
/// Points just earned this turn; 0 = no animation.
#[prop(default = 0)]
my_points_earned: u8,
#[prop(default = 0)] opp_points_earned: u8,
/// Non-zero when a new hole was just scored (triggers peg-pop animation).
#[prop(default = 0)]
my_holes_gained: u8,
#[prop(default = 0)] opp_holes_gained: u8,
/// True when my hole was scored under bredouille (shows ×2 in the flash).
#[prop(default = false)]
my_bredouille: bool,
/// `Some(true)` = my turn active, `Some(false)` = opponent active, `None` = no active turn.
#[prop(default = None)]
active_player_is_me: Option<bool>,
) -> impl IntoView {
let i18n = use_i18n();
// ── Points counter signals ──────────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
let _ = (my_points_earned, opp_points_earned);
#[cfg(not(target_arch = "wasm32"))]
let my_pts_start = my_score.points;
#[cfg(target_arch = "wasm32")]
let my_pts_start = if my_holes_gained == 0 {
my_score.points.saturating_sub(my_points_earned)
} else {
my_score.points
};
let my_displayed_pts: RwSignal<u8> = RwSignal::new(my_pts_start);
#[cfg(not(target_arch = "wasm32"))]
let opp_pts_start = opp_score.points;
#[cfg(target_arch = "wasm32")]
let opp_pts_start = if opp_holes_gained == 0 {
opp_score.points.saturating_sub(opp_points_earned)
} else {
opp_score.points
};
let opp_displayed_pts: RwSignal<u8> = RwSignal::new(opp_pts_start);
// ── Jackpot counter animation (WASM only) ───────────────────────────────
#[cfg(target_arch = "wasm32")]
{
let my_pts_end = my_score.points;
if my_pts_start < my_pts_end {
let is_alive = Arc::new(AtomicBool::new(true));
let alive_c = is_alive.clone();
on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
spawn_local(async move {
for p in (my_pts_start + 1)..=my_pts_end {
TimeoutFuture::new(100).await;
if !is_alive.load(Ordering::Relaxed) {
return;
}
my_displayed_pts.set(p);
crate::game::sound::play_points_tick();
}
});
}
let opp_pts_end = opp_score.points;
if opp_pts_start < opp_pts_end {
let is_alive = Arc::new(AtomicBool::new(true));
let alive_c = is_alive.clone();
on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
spawn_local(async move {
for p in (opp_pts_start + 1)..=opp_pts_end {
TimeoutFuture::new(100).await;
if !is_alive.load(Ordering::Relaxed) {
return;
}
opp_displayed_pts.set(p);
crate::game::sound::play_opp_points_tick();
}
});
}
}
// ── Hole peg tracks ─────────────────────────────────────────────────────
let my_holes = my_score.holes;
let opp_holes = opp_score.holes;
let my_pegs: Vec<AnyView> = (1u8..=12)
.map(|i| {
let filled = i <= my_holes;
let is_new = filled && i == my_holes && my_holes_gained > 0;
view! {
<div class="peg-hole"
class:filled=filled
class:peg-new=is_new>
</div>
}
.into_any()
})
.collect();
let opp_pegs: Vec<AnyView> = (1u8..=12)
.map(|i| {
let filled = i <= opp_holes;
let is_new = filled && i == opp_holes && opp_holes_gained > 0;
view! {
<div class="peg-hole peg-opp"
class:filled=filled
class:peg-new=is_new>
</div>
}
.into_any()
})
.collect();
let my_name = my_score.name.clone();
let opp_name = opp_score.name.clone();
let my_can_bredouille = my_score.can_bredouille;
let opp_can_bredouille = opp_score.can_bredouille;
let my_active = active_player_is_me == Some(true);
let opp_active = active_player_is_me == Some(false);
view! {
<div class="players-strip">
// ── My player: left side, right-aligned toward center ───────────
<div class="strip-player strip-player-left">
<div class="strip-active-zone" class:active=my_active>
<div class="strip-avatar strip-avatar-me"></div>
<div class="score-row-name">
<span class="player-name">{my_name}</span>
<span class="you-tag">{t!(i18n, you_suffix)}</span>
</div>
{my_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
<div class="peg-track">{my_pegs}</div>
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">{move || my_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
</div>
</div>
{(my_holes_gained > 0).then(|| {
let label = if my_bredouille {
format!("Trou {} · ×2 bredouille", my_holes)
} else {
format!("Trou {}", my_holes)
};
view! {
<div class="hole-flash"
class:hole-flash-bredouille=my_bredouille>
{label}
</div>
}
})}
</div>
</div>
// ── Center title ────────────────────────────────────────────────
<div class="strip-center">
<span class="strip-title">"Trictrac"</span>
</div>
// ── Opponent: right side, left-aligned from center ──────────────
<div class="strip-player strip-player-right">
<div class="strip-active-zone" class:active=opp_active>
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">{move || opp_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
</div>
</div>
<div class="peg-track">{opp_pegs}</div>
{opp_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
<div class="score-row-name">
<span class="player-name">{opp_name}</span>
</div>
<div class="strip-avatar strip-avatar-opp"></div>
</div>
</div>
</div>
}
}

View file

@ -1,233 +0,0 @@
use futures::channel::mpsc::UnboundedSender;
#[cfg(target_arch = "wasm32")]
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;
#[cfg(target_arch = "wasm32")]
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use trictrac_store::CheckerMove;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
use crate::app::NetCommand;
use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
use crate::i18n::*;
use super::score_panel::jan_label;
/// One row in the scoring panel. Sets the hovered-moves context on enter
/// (so board shows arrows for that jan's moves), but does NOT clear on
/// leave — clearing is handled by the outer wrapper's mouseleave so that
/// arrows persist while the pointer moves between rows.
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
let i18n = use_i18n();
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
let jan = entry.jan;
let is_double = entry.is_double;
let ways_tag = format!("×{}", entry.ways);
let pts_str = format!("+{}", entry.total);
let moves_hover = entry.moves.clone();
view! {
<div
class="scoring-jan-row"
on:mouseenter=move |_| {
if let Some(h) = hovered {
h.set(moves_hover.clone());
}
}
>
<span class="jan-label">{move || jan_label(&jan)}</span>
<span class="jan-tag">{move || if is_double {
t_string!(i18n, jan_double).to_owned()
} else {
t_string!(i18n, jan_simple).to_owned()
}}</span>
<span class="jan-tag">{ways_tag}</span>
<span class="jan-pts">{pts_str}</span>
</div>
}
}
/// Scoring detail panel, shown to the right of the hole counter in the merged
/// score panel area.
///
/// Lifecycle:
/// 1. Mounts expanded — shows all jan details and draws board arrows.
/// 2. After 3.4 s the arrows clear and the panel auto-minimises to a small "+"
/// button (unless Hold/Go buttons are still needed).
/// 3. The "+" / "" buttons let the player toggle between states at any time.
#[component]
pub fn ScoringPanel(
event: ScoredEvent,
turn_stage: SerTurnStage,
#[prop(default = false)] is_opponent: bool,
) -> impl IntoView {
let i18n = use_i18n();
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let points_earned = event.points_earned;
let holes_gained = event.holes_gained;
let holes_total = event.holes_total;
let bredouille = event.bredouille;
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
let panel_class = if is_opponent {
"scoring-panel scoring-panel-opp"
} else {
"scoring-panel"
};
// minimized: starts false (expanded)
let minimized = RwSignal::new(false);
// Collect all moves from all jans for automatic arrow display.
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
.jans
.iter()
.flat_map(|e| e.moves.iter().cloned())
.collect();
let all_moves_auto = all_moves.clone();
let all_moves_expand = all_moves.clone();
let all_moves_enter = all_moves.clone();
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
// On mount: show all this event's moves as board arrows immediately,
// then after 3.4 s slide to peek and clear the arrows.
//
// Two important constraints:
// 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
// to avoid writing a reactive signal mid-render while Board reads it —
// that triggers Leptos's cycle guard → `unreachable` WASM panic.
// 2. The cancellation flag must be Rc<Cell<bool>>, NOT RwSignal<bool>.
// RwSignal is a NodeId into Leptos's arena; the arena slot is freed
// when ScoringPanel's owner drops (on every GameScreen remount). If the
// 3.4 s future outlives the component and calls is_alive.get_untracked()
// on a freed slot, that also panics with `unreachable`. Rc<Cell<bool>>
// is reference-counted outside the arena and stays valid for as long as
// the future holds onto it.
#[cfg(target_arch = "wasm32")]
if let Some(hm) = hovered_ctx {
let is_alive = Arc::new(AtomicBool::new(true));
let is_alive_cleanup = is_alive.clone();
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
spawn_local(async move {
// Show arrows (runs in the next microtask, after render settles).
hm.set(all_moves);
TimeoutFuture::new(3_400).await;
// Guard: component may have been destroyed while we were waiting.
// is_alive was set to false by on_cleanup, which runs before Leptos
// frees the signal arena slots — so peeked is still valid iff this
// returns true.
if !is_alive.load(Ordering::Relaxed) {
return;
}
hm.set(vec![]);
});
}
view! {
<div
class="scoring-panel-wrapper"
class:scoring-minimized=move || minimized.get()
on:mouseenter=move |_| {
if let Some(hm) = hovered_ctx {
hm.set(all_moves_enter.clone());
}
}
on:mouseleave=move |_| {
if let Some(hm) = hovered_ctx {
hm.set(vec![]);
}
}
>
// "+" expand button — shown only when minimised (CSS hides it otherwise).
<button
class="scoring-expand-btn"
title="Show scoring details"
on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
minimized.set(false);
if let Some(hm) = hovered_ctx {
hm.set(all_moves_expand.clone());
}
}
>
"+"
</button>
// Full panel — hidden when minimised via CSS.
<div class=panel_class>
<div class="scoring-panel-head">
<div class="scoring-total">
{move || if is_opponent {
t_string!(i18n, opp_scored_pts, n = points_earned)
} else {
t_string!(i18n, scored_pts, n = points_earned)
}}
</div>
<button
class="scoring-collapse-btn"
title="Minimise"
on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
minimized.set(true);
if let Some(hm) = hovered_ctx {
hm.set(vec![]);
}
}
>
""
</button>
</div>
{jan_rows}
{(holes_gained > 0).then(|| view! {
<div class="scoring-hole">
<span>{move || if is_opponent {
t_string!(i18n, opp_hole_made, holes = holes_total)
} else {
t_string!(i18n, hole_made, holes = holes_total)
}}</span>
{bredouille.then(|| view! {
<span class="bredouille-badge">
{move || t_string!(i18n, bredouille_applied)}
</span>
})}
</div>
})}
{show_hold_go.then(|| {
let dismissed = RwSignal::new(false);
view! {
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
<button class="btn btn-secondary"
on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
dismissed.set(true);
}
>
{t!(i18n, hold)}
</button>
<button class="btn btn-primary"
on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
cmd_tx
.unbounded_send(NetCommand::Action(PlayerAction::Go))
.ok();
}
>
{t!(i18n, go)}
</button>
</div>
}
})}
</div>
</div>
}
}

View file

@ -1,4 +0,0 @@
pub mod components;
pub mod session;
pub mod sound;
pub mod trictrac;

View file

@ -1,310 +0,0 @@
use futures::channel::mpsc;
use leptos::prelude::*;
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use crate::app::{GameUiState, NetCommand, PauseReason, Screen};
use crate::game::trictrac::backend::TrictracBackend;
use crate::game::trictrac::bot_local::bot_decide;
use crate::game::trictrac::types::{
JanEntry, ScoredEvent, SerStage, SerTurnStage, ViewState,
};
use trictrac_store::CheckerMove;
use std::collections::VecDeque;
/// Runs one local bot game. Returns `true` if the player wants to play again.
pub async fn run_local_bot_game(
screen: RwSignal<Screen>,
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
pending: RwSignal<VecDeque<GameUiState>>,
player_name: String,
) -> bool {
let mut backend = TrictracBackend::new(0);
backend.player_arrival(0);
backend.player_arrival(1);
let mut vs = ViewState::default_with_names(&player_name, "Bot");
for cmd in backend.drain_commands() {
match cmd {
BackendCommand::ResetViewState => {
vs = backend.get_view_state().clone();
}
BackendCommand::Delta(delta) => {
vs.apply_delta(&delta);
}
_ => {}
}
}
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
}
/// 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 {
Some(NetCommand::Action(action)) => {
let prev_vs = vs.clone();
backend.inform_rpc(0, action);
for cmd in backend.drain_commands() {
if let BackendCommand::Delta(delta) = cmd {
vs.apply_delta(&delta);
}
}
patch_bot_names(&mut vs, &player_name);
let scored = compute_scored_event(&prev_vs, &vs, 0);
let opp_scored = compute_scored_event(&prev_vs, &vs, 1);
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: scored,
opp_scored_event: opp_scored,
last_moves: compute_last_moves(&prev_vs, &vs, true),
suppress_dice_anim: false,
}));
}
Some(NetCommand::PlayVsBot) => return true,
_ => return false,
}
loop {
let pgr = backend.get_view_state().pre_game_roll.clone();
match bot_decide(backend.get_game(), pgr.as_ref()) {
None => break,
Some(action) => {
backend.inform_rpc(1, action);
for cmd in backend.drain_commands() {
if let BackendCommand::Delta(delta) = cmd {
let delta_prev_vs = vs.clone();
vs.apply_delta(&delta);
patch_bot_names(&mut vs, &player_name);
push_or_show(
&delta_prev_vs,
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: compute_last_moves(&delta_prev_vs, &vs, false),
suppress_dice_anim: false,
},
pending,
screen,
);
}
}
}
}
}
}
}
/// Patches the player names in a ViewState after a backend delta (bot game: slot 0 = human, 1 = Bot).
pub fn patch_bot_names(vs: &mut ViewState, player_name: &str) {
vs.scores[0].name = player_name.to_string();
vs.scores[1].name = "Bot".to_string();
}
/// Patches the local player's name in a ViewState after a backend delta (multiplayer).
pub fn patch_player_name(vs: &mut ViewState, player_id: u16, name: &str) {
vs.scores[player_id as usize].name = name.to_string();
}
/// Returns the checker moves to animate when the board changed between two ViewStates.
pub fn compute_last_moves(
prev: &ViewState,
next: &ViewState,
own_move: bool,
) -> Option<(CheckerMove, CheckerMove)> {
if prev.board == next.board {
return None;
}
let (m1, m2) = next.dice_moves;
if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
return None;
}
if own_move {
if m2 == CheckerMove::default() {
return None;
}
return Some((m2, CheckerMove::default()));
}
Some((m1, m2))
}
/// Computes a scoring event for `player_id` by comparing the previous and next ViewState.
pub fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
let prev_score = &prev.scores[player_id as usize];
let next_score = &next.scores[player_id as usize];
let holes_gained = next_score.holes.saturating_sub(prev_score.holes);
if holes_gained == 0 && prev_score.points == next_score.points {
return None;
}
let bredouille = holes_gained > 0 && prev_score.can_bredouille;
let my_jans: Vec<JanEntry> = if next.active_mp_player == Some(player_id)
&& prev.active_mp_player == Some(player_id)
{
next.dice_jans
.iter()
.filter(|e| e.total > 0)
.cloned()
.collect()
} else if next.active_mp_player == Some(player_id) && prev.active_mp_player != Some(player_id) {
next.dice_jans
.iter()
.filter(|e| e.total < 0)
.map(|e| JanEntry {
total: -e.total,
points_per: -e.points_per,
..e.clone()
})
.collect()
} else {
return None;
};
let points_earned: u8 = my_jans
.iter()
.fold(0u8, |acc, e| acc.saturating_add(e.total.unsigned_abs()));
if points_earned == 0 && holes_gained == 0 {
return None;
}
Some(ScoredEvent {
points_earned,
holes_gained,
holes_total: next_score.holes,
bredouille,
jans: my_jans,
})
}
/// Either queues the state as a confirmation step or shows it immediately.
pub fn push_or_show(
prev_vs: &ViewState,
new_state: GameUiState,
pending: RwSignal<VecDeque<GameUiState>>,
screen: RwSignal<Screen>,
) {
let scored = compute_scored_event(prev_vs, &new_state.view_state, new_state.player_id);
let opp_scored = compute_scored_event(prev_vs, &new_state.view_state, 1 - new_state.player_id);
if let Some(reason) = infer_pause_reason(prev_vs, &new_state.view_state, new_state.player_id) {
pending.update(|q| {
q.push_back(GameUiState {
waiting_for_confirm: true,
pause_reason: Some(reason),
my_scored_event: scored,
opp_scored_event: opp_scored,
..new_state.clone()
});
});
screen.set(Screen::Playing(GameUiState {
last_moves: None,
suppress_dice_anim: true,
..new_state
}));
} else {
screen.set(Screen::Playing(GameUiState {
my_scored_event: scored,
opp_scored_event: opp_scored,
..new_state
}));
}
}
/// Compares the previous and next ViewState to decide whether the transition
/// warrants a confirmation pause.
pub fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<PauseReason> {
let opponent_id = 1 - player_id;
if next.stage == SerStage::PreGameRoll {
if let (Some(prev_pgr), Some(next_pgr)) = (&prev.pre_game_roll, &next.pre_game_roll) {
let both_now = next_pgr.host_die.is_some() && next_pgr.guest_die.is_some();
let both_before = prev_pgr.host_die.is_some() && prev_pgr.guest_die.is_some();
if both_now && !both_before {
return Some(PauseReason::AfterOpponentPreGameRoll);
}
}
return None;
}
if prev.stage == SerStage::PreGameRoll {
return None;
}
if next.active_mp_player == Some(opponent_id) {
if next.dice != prev.dice {
return Some(PauseReason::AfterOpponentRoll);
}
if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move {
return Some(PauseReason::AfterOpponentGo);
}
}
if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) {
return Some(PauseReason::AfterOpponentMove);
}
None
}

View file

@ -1,255 +0,0 @@
//! Synthesised sound effects using the Web Audio API.
//!
//! All public functions are no-ops on non-WASM targets so callers need no
//! `#[cfg]` guards themselves.
#[cfg(target_arch = "wasm32")]
mod inner {
use std::cell::RefCell;
use web_sys::{AudioContext, OscillatorType};
thread_local! {
static CTX: RefCell<Option<AudioContext>> = const { RefCell::new(None) };
}
fn with_ctx<F: FnOnce(&AudioContext)>(f: F) {
CTX.with(|cell| {
let mut opt = cell.borrow_mut();
if opt.is_none() {
*opt = AudioContext::new().ok();
}
if let Some(ctx) = opt.as_ref() {
f(ctx);
}
});
}
/// Schedule a single oscillator tone with an exponential gain decay.
///
/// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
/// - `duration`: how long (in seconds) until gain reaches ~0
fn play_tone(
ctx: &AudioContext,
freq: f32,
gain: f32,
duration: f64,
start_offset: f64,
wave: OscillatorType,
) {
let t0 = ctx.current_time() + start_offset;
let t1 = t0 + duration;
let Ok(osc) = ctx.create_oscillator() else {
return;
};
let Ok(gain_node) = ctx.create_gain() else {
return;
};
osc.set_type(wave);
osc.frequency().set_value(freq);
let gain_param = gain_node.gain();
let _ = gain_param.set_value_at_time(gain, t0);
// exponential_ramp requires a positive target; 0.001 is inaudible
let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
let dest = ctx.destination();
let _ = osc.connect_with_audio_node(&gain_node);
let _ = gain_node.connect_with_audio_node(&dest);
let _ = osc.start_with_when(t0);
let _ = osc.stop_with_when(t1);
}
/// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
pub fn play_checker_move() {
with_ctx(|ctx| {
// Sine at 300 Hz for the clean attack click
play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
// Triangle at 150 Hz for the woody body resonance
play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
// Sub at 80 Hz for weight
play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
});
}
/// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
///
/// Two layers:
/// - A dense series of detuned sawtooth bursts that thin out over time,
/// modelling the continuous scrape/rattle of dice tumbling.
/// - Five percussive impacts (square clicks + triangle thuds) whose
/// inter-arrival gap shrinks as the dice decelerate and settle.
pub fn play_dice_roll_cinematic() {
with_ctx(|ctx| {
// ── Continuous rolling texture ─────────────────────────────────
// 16 steps over 440 ms; each step is two detuned sawtooth waves
// (the interference between them produces a noise-like texture).
// Gain fades by ~55 % from first to last step.
const N: u32 = 16;
for i in 0..N {
let t = i as f64 * 0.028;
let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
// Quasi-random frequencies so each step sounds different.
let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
}
// ── Impact transients ──────────────────────────────────────────
// Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
// mimicking dice decelerating and settling.
let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
for &(t_off, amp) in impacts {
// Hard click: bright square partials → percussive attack
for &freq in &[700.0f32, 1_050.0, 1_500.0] {
play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
}
// Woody body thud: two low triangle partials
play_tone(
ctx,
130.0,
amp * 0.05,
0.070,
t_off,
OscillatorType::Triangle,
);
play_tone(
ctx,
68.0,
amp * 0.07,
0.090,
t_off,
OscillatorType::Triangle,
);
}
});
}
/// Play the pre-recorded dice-roll MP3 asset.
pub fn play_dice_roll() {
if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
audio.set_volume(0.2);
let _ = audio.play();
}
}
/// Ascending three-note chime (C5 E5 G5).
pub fn play_points_scored() {
with_ctx(|ctx| {
let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
for (freq, offset) in notes {
play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
}
});
}
/// Brief high tick for the jackpot-style points counter (one call per increment).
pub fn play_points_tick() {
with_ctx(|ctx| {
play_tone(ctx, 880.0, 0.18, 0.055, 0.000, OscillatorType::Sine);
play_tone(ctx, 1320.0, 0.07, 0.035, 0.000, OscillatorType::Sine);
});
}
/// Brief low tick for the jackpot-style points counter (one call per increment).
pub fn play_opp_points_tick() {
with_ctx(|ctx| {
play_tone(ctx, 680.0, 0.18, 0.055, 0.000, OscillatorType::Sine);
play_tone(ctx, 1020.0, 0.07, 0.035, 0.000, OscillatorType::Sine);
});
}
/// Triumphant four-note fanfare (C5 E5 G5 C6).
pub fn play_hole_scored() {
with_ctx(|ctx| {
let notes: [(f32, f64, f64); 4] = [
(523.25, 0.0, 0.35),
(659.25, 0.17, 0.35),
(783.99, 0.34, 0.35),
(1046.5, 0.51, 0.55),
];
for (freq, offset, dur) in notes {
play_tone(ctx, freq, 0.12, dur, offset, OscillatorType::Sine);
}
});
}
/// Brief descending minor phrase when the opponent scores a hole.
pub fn play_opp_hole_scored() {
with_ctx(|ctx| {
let notes: [(f32, f64, f64); 3] = [
(392.00, 0.00, 0.32), // G4
(349.23, 0.20, 0.32), // F4
(293.66, 0.40, 0.50), // D4
];
for (freq, offset, dur) in notes {
play_tone(ctx, freq, 0.10, dur, offset, OscillatorType::Sine);
}
});
}
/// Victory fanfare: five-note ascending major (C5E5G5C6E6).
pub fn play_victory() {
with_ctx(|ctx| {
let notes: [(f32, f64, f64, f32); 5] = [
(523.25, 0.00, 0.32, 0.18), // C5
(659.25, 0.20, 0.32, 0.20), // E5
(783.99, 0.40, 0.32, 0.22), // G5
(1046.5, 0.60, 0.50, 0.25), // C6
(1318.5, 0.88, 0.80, 0.28), // E6
];
for (freq, offset, dur, gain) in notes {
play_tone(ctx, freq, gain, dur, offset, OscillatorType::Sine);
play_tone(ctx, freq * 2.0, gain * 0.12, dur, offset, OscillatorType::Sine);
}
});
}
/// Defeat phrase: descending minor (E5Eb5D5C5).
pub fn play_defeat() {
with_ctx(|ctx| {
let notes: [(f32, f64, f64); 4] = [
(659.25, 0.00, 0.45), // E5
(622.25, 0.35, 0.45), // Eb5
(587.33, 0.70, 0.45), // D5
(523.25, 1.05, 0.80), // C5
];
for (freq, offset, dur) in notes {
play_tone(ctx, freq, 0.14, dur, offset, OscillatorType::Sine);
play_tone(ctx, freq / 2.0, 0.06, dur, offset, OscillatorType::Triangle);
}
});
}
}
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
#[cfg(target_arch = "wasm32")]
pub use inner::{
play_checker_move, play_defeat, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
play_opp_hole_scored, play_opp_points_tick, play_points_scored, play_points_tick, play_victory,
};
#[cfg(not(target_arch = "wasm32"))]
pub fn play_checker_move() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_dice_roll() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_dice_roll_cinematic() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_points_scored() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_points_tick() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_opp_points_tick() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_hole_scored() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_opp_hole_scored() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_victory() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_defeat() {}

View file

@ -1,92 +0,0 @@
use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
use super::types::{PlayerAction, PreGameRollState};
const GUEST_PLAYER_ID: u64 = 2;
/// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn.
/// `pgr` is the current pre-game ceremony state if the ceremony is in progress.
pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<PlayerAction> {
// During the ceremony, the bot (guest) rolls when its die is missing.
if game.stage == Stage::PreGame {
if let Some(pgr) = pgr {
if pgr.guest_die.is_none() {
return Some(PlayerAction::PreGameRoll);
}
}
return None;
}
if game.stage == Stage::Ended {
return None;
}
if game.active_player_id != GUEST_PLAYER_ID {
return None;
}
match game.turn_stage {
TurnStage::RollDice => Some(PlayerAction::Roll),
// TurnStage::HoldOrGoChoice => Some(PlayerAction::Go),
TurnStage::Move | TurnStage::HoldOrGoChoice => {
let rules = MoveRules::new(&Color::Black, &game.board, game.dice);
let sequences = rules.get_possible_moves_sequences(true, vec![]);
// MoveRules with Color::Black mirrors the board internally, so
// returned move coordinates are in mirrored (White) space — mirror back.
let (m1, m2) = sequences
.iter()
.max_by(|(m1a, m2a), (m1b, m2b)| {
score_seq(&game.board, m1a, m2a)
.partial_cmp(&score_seq(&game.board, m1b, m2b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned()
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
}
_ => None,
}
}
/// Score a candidate move sequence from the bot's (Black) perspective.
/// `m1` and `m2` are in mirrored (White) space, as returned by MoveRules for Color::Black.
fn score_seq(board: &Board, m1: &CheckerMove, m2: &CheckerMove) -> f32 {
let mut b = board.mirror();
let _ = b.move_checker(&Color::White, *m1);
let _ = b.move_checker(&Color::White, *m2);
evaluate(&b)
}
/// Evaluate a board position from White's perspective (call after mirroring for Black).
fn evaluate(board: &Board) -> f32 {
let mut score = 0.0f32;
let white_fields = board.get_color_fields(Color::White);
let black_fields = board.get_color_fields(Color::Black);
// Quarter fill progress — quarters 1-6, 7-12, 19-24.
// Quarter 13-18 is skipped: field 13 is the opponent's rest corner so White can never fill it.
for &q in &[1usize, 7, 19] {
if board.is_quarter_filled(Color::White, q) {
score += 8.0;
} else {
let missing = board.get_quarter_filling_candidate(Color::White);
score += (6 - missing.len().min(6)) as f32 * 0.3;
}
}
// Singleton exposure: penalise a White singleton at field f only when there is at least
// one Black checker at a field g > f (opponent can potentially threaten it).
let max_black_field = black_fields.iter().map(|(f, _)| *f).max().unwrap_or(0);
for (f, count) in &white_fields {
if *count == 1 && *f < max_black_field {
score -= 0.5;
}
}
// Exit zone progress: reward checkers already in fields 19-24.
for (field, count) in &white_fields {
if *field >= 19 {
score += count.abs() as f32 * 0.3;
}
}
score
}

View file

@ -1,18 +0,0 @@
leptos_i18n::load_locales!();
mod api;
mod app;
mod game;
mod portal;
use app::App;
use i18n::I18nContextProvider;
use leptos::prelude::*;
fn main() {
mount_to_body(|| view! {
<I18nContextProvider>
<App />
</I18nContextProvider>
})
}

View file

@ -1,248 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;
use crate::api;
use crate::app::AuthEmailVerified;
use crate::i18n::*;
#[component]
pub fn AccountPage() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified = use_context::<AuthEmailVerified>()
.expect("auth_email_verified context not found").0;
let navigate = use_navigate();
// Only redirect to profile when the email is actually verified.
Effect::new(move |_| {
if let Some(u) = auth_username.get() {
if auth_email_verified.get() {
navigate(&format!("/profile/{u}"), Default::default());
}
}
});
let tab = RwSignal::new("login");
view! {
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
<div class="portal-card" style="max-width:420px;width:100%">
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
{t!(i18n, account_title)}
</h1>
{move || {
let username = auth_username.get();
let verified = auth_email_verified.get();
if username.is_some() && !verified {
view! { <VerificationBanner /> }.into_any()
} else if username.is_none() {
view! {
<div>
<div class="portal-tabs">
<button
class=move || if tab.get() == "login" { "portal-tab-btn active" } else { "portal-tab-btn" }
on:click=move |_| tab.set("login")
>{t!(i18n, sign_in)}</button>
<button
class=move || if tab.get() == "register" { "portal-tab-btn active" } else { "portal-tab-btn" }
on:click=move |_| tab.set("register")
>{t!(i18n, create_account)}</button>
</div>
{move || if tab.get() == "login" {
view! { <LoginForm /> }.into_any()
} else {
view! { <RegisterForm /> }.into_any()
}}
</div>
}.into_any()
} else {
view! { <span /> }.into_any()
}
}}
</div>
</div>
}
}
#[component]
fn VerificationBanner() -> impl IntoView {
let i18n = use_i18n();
let pending = RwSignal::new(false);
let sent = RwSignal::new(false);
let error = RwSignal::new(String::new());
let resend = move |_| {
if pending.get() { return; }
pending.set(true);
sent.set(false);
error.set(String::new());
wasm_bindgen_futures::spawn_local(async move {
match api::post_resend_verification().await {
Ok(()) => { sent.set(true); }
Err(e) => { error.set(e); }
}
pending.set(false);
});
};
view! {
<div class="portal-verification-banner">
<p>{t!(i18n, email_not_verified_banner)}</p>
<button class="portal-submit-btn" on:click=resend disabled=move || pending.get()>
{t!(i18n, resend_verification)}
</button>
{move || if sent.get() {
view! { <p class="portal-success">{ t_string!(i18n, verification_email_resent).to_string() }</p> }.into_any()
} else if !error.get().is_empty() {
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</div>
}
}
#[component]
fn LoginForm() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified = use_context::<AuthEmailVerified>()
.expect("auth_email_verified context not found").0;
let navigate = use_navigate();
let login = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let u = login.get();
let p = password.get();
let navigate = navigate.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::post_login(&u, &p).await {
Ok(me) => {
auth_username.set(Some(me.username.clone()));
auth_email_verified.set(me.email_verified);
if me.email_verified {
navigate(&format!("/profile/{}", me.username), Default::default());
}
// If not verified, the AccountPage Effect will show the banner.
}
Err(e) => {
let msg = if e.is_empty() {
t_string!(i18n, login_failed).to_string()
} else {
e
};
error.set(msg);
pending.set(false);
}
}
});
};
view! {
<form on:submit=submit>
<label class="portal-label">{t!(i18n, label_username_or_email)}</label>
<input class="portal-input" type="text" required autocomplete="username"
prop:value=move || login.get()
on:input=move |ev| login.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_password)}</label>
<input class="portal-input" type="password" required autocomplete="current-password"
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<div style="text-align:right;margin-bottom:0.75rem">
<a href="/forgot-password" class="portal-link">{t!(i18n, forgot_password_link)}</a>
</div>
<button class="portal-submit-btn" type="submit"
disabled=move || pending.get()
>{t!(i18n, sign_in)}</button>
{move || if !error.get().is_empty() {
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}
}
#[component]
fn RegisterForm() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified = use_context::<AuthEmailVerified>()
.expect("auth_email_verified context not found").0;
let username = RwSignal::new(String::new());
let email = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let confirm_password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
if password.get() != confirm_password.get() {
error.set(t_string!(i18n, passwords_do_not_match).to_string());
return;
}
pending.set(true);
error.set(String::new());
let u = username.get();
let e = email.get();
let p = password.get();
wasm_bindgen_futures::spawn_local(async move {
match api::post_register(&u, &e, &p).await {
Ok(me) => {
auth_username.set(Some(me.username));
auth_email_verified.set(me.email_verified);
// AccountPage shows verification banner when email_verified = false.
}
Err(err) => {
error.set(err);
pending.set(false);
}
}
});
};
view! {
<form on:submit=submit>
<label class="portal-label">{t!(i18n, label_username)}</label>
<input class="portal-input" type="text" required autocomplete="username"
prop:value=move || username.get()
on:input=move |ev| username.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_email)}</label>
<input class="portal-input" type="email" required autocomplete="email"
prop:value=move || email.get()
on:input=move |ev| email.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_password)}</label>
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || confirm_password.get()
on:input=move |ev| confirm_password.set(event_target_value(&ev)) />
<button class="portal-submit-btn" type="submit"
disabled=move || pending.get()
>{t!(i18n, create_account)}</button>
{move || if !error.get().is_empty() {
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}
}

View file

@ -1,51 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use pulldown_cmark::{Options, Parser, html};
use crate::api;
use crate::i18n::*;
#[component]
pub fn ContentPage() -> impl IntoView {
let params = use_params_map();
let slug = move || params.read().get("slug").unwrap_or_default();
let i18n = use_i18n();
let page = LocalResource::new(move || {
let s = slug();
let lang = match i18n.get_locale() {
Locale::en => "en",
Locale::fr => "fr",
};
async move { api::get_page(&s, lang).await }
});
view! {
<div class="portal-main">
{move || match page.get().map(|sw| sw.take()) {
None => view! {
<p class="portal-loading">{t!(i18n, loading)}</p>
}.into_any(),
Some(Err(_)) => view! {
<p class="portal-empty">"Page not found."</p>
}.into_any(),
Some(Ok(p)) => {
let html = md_to_html(&p.content);
view! {
<div class="portal-card content-page" inner_html=html />
}.into_any()
}
}}
</div>
}
}
fn md_to_html(md: &str) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(md, opts);
let mut output = String::new();
html::push_html(&mut output, parser);
output
}

View file

@ -1,66 +0,0 @@
use leptos::prelude::*;
use crate::api;
use crate::i18n::*;
#[component]
pub fn ForgotPasswordPage() -> impl IntoView {
let i18n = use_i18n();
let email = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let sent = RwSignal::new(false);
let error = RwSignal::new(String::new());
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let e = email.get();
wasm_bindgen_futures::spawn_local(async move {
match api::post_forgot_password(&e).await {
Ok(()) => { sent.set(true); }
Err(e) => { error.set(e); }
}
pending.set(false);
});
};
view! {
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
<div class="portal-card" style="max-width:420px;width:100%">
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
{t!(i18n, forgot_password_title)}
</h1>
{move || if sent.get() {
view! {
<p class="portal-success" style="text-align:center">
{t!(i18n, forgot_password_sent)}
</p>
}.into_any()
} else {
view! {
<form on:submit=submit>
<label class="portal-label">{t!(i18n, forgot_password_email_label)}</label>
<input class="portal-input" type="email" required autocomplete="email"
prop:value=move || email.get()
on:input=move |ev| email.set(event_target_value(&ev)) />
<button class="portal-submit-btn" type="submit"
disabled=move || pending.get()
>{t!(i18n, forgot_password_submit)}</button>
{move || if !error.get().is_empty() {
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}.into_any()
}}
<div style="margin-top:1rem;text-align:center">
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
</div>
</div>
</div>
}
}

View file

@ -1,113 +0,0 @@
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::api::{self, GameDetail, Participant};
use crate::i18n::*;
#[component]
pub fn GameDetailPage() -> impl IntoView {
let i18n = use_i18n();
let params = use_params_map();
let id_str = move || params.read().get("id").unwrap_or_default();
let detail = LocalResource::new(move || {
let s = id_str();
async move {
let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?;
api::get_game_detail(id).await
}
});
view! {
<div class="portal-main">
{move || match detail.get().map(|sw| sw.take()) {
None => view! { <p class="portal-loading">{t!(i18n, loading)}</p> }.into_any(),
Some(Err(e)) => view! { <p class="portal-error">{ e }</p> }.into_any(),
Some(Ok(g)) => view! { <GameDetailView game=g /> }.into_any(),
}}
</div>
}
}
#[component]
fn GameDetailView(game: GameDetail) -> impl IntoView {
let i18n = use_i18n();
let locale_tag = match i18n.get_locale() {
Locale::en => "en-GB",
Locale::fr => "fr-FR",
};
let started = api::format_ts(game.started_at, locale_tag, &api::DateFormatOptions::date_only());
let ended = game.ended_at.map(|ts| api::format_ts(ts, locale_tag, &api::DateFormatOptions::date_only()))
.unwrap_or_else(|| t_string!(i18n, game_ongoing).to_string());
view! {
<div class="portal-card">
<h1>{t!(i18n, room_detail_title)} " " { game.room_code.clone() }</h1>
<p class="portal-meta">
{t!(i18n, started_label)} ": " { started.clone() }
" · "
{t!(i18n, ended_label)} ": " { ended }
</p>
<h2>{t!(i18n, players_header)}</h2>
<table>
<thead>
<tr>
<th>{t!(i18n, col_player)}</th>
<th>{t!(i18n, label_username)}</th>
<th>{t!(i18n, col_outcome)}</th>
</tr>
</thead>
<tbody>
{game.participants.iter().map(|p| {
view! { <ParticipantRow participant=p.clone() /> }
}).collect_view()}
</tbody>
</table>
{game.result.as_ref().map(|r| view! {
<div style="margin-top:1.5rem">
<h2>{t!(i18n, score_header)}</h2>
<p style="font-family:var(--font-display);font-size:1.1rem;color:var(--ui-ink)">
{ r.clone() }
</p>
</div>
})}
</div>
}
}
#[component]
fn ParticipantRow(participant: Participant) -> impl IntoView {
let i18n = use_i18n();
let outcome_class = match participant.outcome.as_deref() {
Some("win") => "outcome-win",
Some("loss") => "outcome-loss",
Some("draw") => "outcome-draw",
_ => "",
};
let outcome_text = move || match participant.outcome.as_deref() {
Some("win") => t_string!(i18n, outcome_win),
Some("loss") => t_string!(i18n, outcome_loss),
Some("draw") => t_string!(i18n, outcome_draw),
_ => "",
};
let name = participant.username.clone();
view! {
<tr>
<td>{t!(i18n, col_player)} " " { participant.player_id }</td>
<td>
{match name {
Some(u) => view! {
<A href=format!("/profile/{u}")>{ u }</A>
}.into_any(),
None => view! {
<span style="color:#aa9070">{t!(i18n, anonymous_player)}</span>
}.into_any(),
}}
</td>
<td class=outcome_class>{ outcome_text }</td>
</tr>
}
}

View file

@ -1,356 +0,0 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use leptos_router::components::A;
use leptos_router::hooks::use_query_map;
use crate::app::{AnonNickname, NetCommand, Screen};
use crate::i18n::*;
// ── Room/nickname generation ──────────────────────────────────────────────────
fn generate_room_code() -> String {
use rand::Rng;
let mut rng = rand::rng();
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
(0..6)
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
.collect()
}
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",
];
const NOUN: &[&str] = &[
"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())];
let num: u8 = rng.random_range(10..=99);
format!("{adj}-{noun}-{num}")
}
// ── QR code SVG rendering ─────────────────────────────────────────────────────
pub(crate) fn qr_svg(text: &str) -> String {
use qrcodegen::{QrCode, QrCodeEcc};
let qr = match QrCode::encode_text(text, QrCodeEcc::Medium) {
Ok(q) => q,
Err(_) => return String::new(),
};
let size = qr.size();
let border = 2;
let total = size + 2 * border;
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {t} {t}\" shape-rendering=\"crispEdges\">",
t = total,
);
svg.push_str("<rect width=\"100%\" height=\"100%\" fill=\"#f2e8d0\"/>");
for y in 0..size {
for x in 0..size {
if qr.get_module(x, y) {
svg.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"1\" height=\"1\" fill=\"#2a1508\"/>",
x + border,
y + border,
));
}
}
}
svg.push_str("</svg>");
svg
}
// ── Share URL helper ──────────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
pub(crate) fn room_url(code: &str) -> String {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
format!("{}/?room={}", origin, code)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn room_url(code: &str) -> String {
format!("http://localhost:9091/?room={}", code)
}
// ── Lobby state ───────────────────────────────────────────────────────────────
/// Action to execute once the anonymous player has chosen their nickname.
#[derive(Clone)]
enum PendingLobbyAction {
Create { code: String },
Join { code: String },
}
#[derive(Clone)]
enum LobbyView {
Idle,
Waiting { code: String },
}
// ── LobbyPage ─────────────────────────────────────────────────────────────────
#[component]
pub fn LobbyPage() -> impl IntoView {
let screen = use_context::<RwSignal<Screen>>().expect("Screen context");
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
let anon_nickname = use_context::<AnonNickname>().expect("anon_nickname context").0;
let query = use_query_map();
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
// Non-None while the nickname-chooser modal is open.
let pending_action: RwSignal<Option<PendingLobbyAction>> = RwSignal::new(None);
// ── Auto-join when URL has ?room=CODE ──────────────────────────────────
// Wait for auth to resolve so we join directly when already logged in,
// or show the nickname modal when anonymous.
let join_processed = StoredValue::new(false);
let cmd_tx_q = cmd_tx.clone();
Effect::new(move |_| {
if join_processed.get_value() || !auth_loaded.get() {
return;
}
let Some(code) = query.read().get("room").filter(|s| !s.is_empty()) else {
return;
};
join_processed.set_value(true);
if auth_username.get_untracked().is_some() {
cmd_tx_q
.unbounded_send(NetCommand::JoinRoom { room: code })
.ok();
} else {
pending_action.set(Some(PendingLobbyAction::Join { code }));
}
});
let error = move || match screen.get() {
Screen::Login { error } => error,
_ => None,
};
let cmd_idle = cmd_tx.clone();
let cmd_modal = cmd_tx;
view! {
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
<div class="login-card">
<div class="login-card-header">
<div class="login-board-stripe"></div>
</div>
<div class="login-card-body">
<h1 class="login-title">"Trictrac"</h1>
<p class="login-subtitle">
<em>"Une interprétation numérique"</em>
</p>
<div class="login-ornament">""</div>
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
{move || match view_state.get() {
LobbyView::Idle => view! {
<IdleCard
cmd_tx=cmd_idle.clone()
auth_username=auth_username
view_state=view_state
pending_action=pending_action
/>
}.into_any(),
LobbyView::Waiting { code } => view! {
<WaitingCard code=code />
}.into_any(),
}}
</div>
</div>
// Fixed-position modal overlay; rendered here but escapes layout.
{move || pending_action.get().map(|action| view! {
<NicknameModal
pending=action
cmd_tx=cmd_modal.clone()
view_state=view_state
pending_action=pending_action
anon_nickname=anon_nickname
/>
})}
</div>
}
}
// ── IdleCard: Create + vs Bot + hidden join-by-code ──────────────────────────
#[component]
fn IdleCard(
cmd_tx: UnboundedSender<NetCommand>,
auth_username: RwSignal<Option<String>>,
view_state: RwSignal<LobbyView>,
pending_action: RwSignal<Option<PendingLobbyAction>>,
) -> impl IntoView {
let i18n = use_i18n();
let cmd_bot = cmd_tx.clone();
let cmd_create = cmd_tx.clone();
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();
view_state.set(LobbyView::Waiting { code });
} else {
pending_action.set(Some(PendingLobbyAction::Create { code }));
}
};
view! {
<div class="login-actions">
<button
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>
}
}
// ── NicknameModal ─────────────────────────────────────────────────────────────
#[component]
fn NicknameModal(
pending: PendingLobbyAction,
cmd_tx: UnboundedSender<NetCommand>,
view_state: RwSignal<LobbyView>,
pending_action: RwSignal<Option<PendingLobbyAction>>,
anon_nickname: RwSignal<Option<String>>,
) -> impl IntoView {
let i18n = use_i18n();
// Pre-fill with a random nickname; the player can edit it.
let nick = RwSignal::new(generate_nickname());
let on_play = move |_: leptos::ev::MouseEvent| {
let chosen = nick.get().trim().to_string();
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();
view_state.set(LobbyView::Waiting { code: code.clone() });
}
PendingLobbyAction::Join { code } => {
cmd_tx
.unbounded_send(NetCommand::JoinRoom { room: code.clone() })
.ok();
}
}
pending_action.set(None);
};
view! {
<div class="nickname-backdrop">
<div class="nickname-modal">
<h2 class="nickname-modal-title">{t!(i18n, nickname_modal_title)}</h2>
<p class="nickname-modal-hint">{t!(i18n, nickname_modal_hint)}</p>
<input
class="login-input"
type="text"
style="margin:0"
prop:value=move || nick.get()
on:input=move |ev| nick.set(event_target_value(&ev))
/>
<button
class="login-btn login-btn-primary"
disabled=move || nick.get().trim().is_empty()
on:click=on_play
>
{t!(i18n, nickname_modal_play)}
</button>
<p class="nickname-modal-alt">
{t!(i18n, nickname_modal_or)}
" "
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
</p>
</div>
</div>
}
}
// ── WaitingCard: URL + copy + QR ─────────────────────────────────────────────
#[component]
fn WaitingCard(code: String) -> impl IntoView {
let i18n = use_i18n();
let url = room_url(&code);
let svg = qr_svg(&url);
let copied = RwSignal::new(false);
let on_copy = {
let url = url.clone();
move |_| {
#[cfg(target_arch = "wasm32")]
{
let url = url.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) {
let _ =
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&url)).await;
copied.set(true);
gloo_timers::future::TimeoutFuture::new(2000).await;
copied.set(false);
}
});
}
}
};
view! {
<p style="font-size:0.85rem;color:rgba(242,232,208,0.75);margin-bottom:1rem;text-align:center">
{t!(i18n, waiting_for_opponent)}
</p>
<p style="font-size:0.8rem;color:rgba(242,232,208,0.6);margin-bottom:0.5rem;text-align:center">
{t!(i18n, share_link)}
</p>
<div class="share-url-row">
<span class="share-url-text">{ url.clone() }</span>
<button class="share-copy-btn" on:click=on_copy>
{move || if copied.get() {
t_string!(i18n, link_copied)
} else {
t_string!(i18n, copy_link)
}}
</button>
</div>
<p style="font-size:0.75rem;color:rgba(242,232,208,0.45);margin:1rem 0 0.5rem;text-align:center">
{t!(i18n, scan_qr)}
</p>
<div class="qr-container" inner_html=svg />
}
}

View file

@ -1,8 +0,0 @@
pub mod account;
pub mod content_page;
pub mod forgot_password;
pub mod game_detail;
pub mod lobby;
pub mod profile;
pub mod reset_password;
pub mod verify_email;

View file

@ -1,267 +0,0 @@
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_navigate, hooks::use_params_map};
use crate::api::{self, GameSummary, UserProfile};
use crate::app::{AuthEmailVerified, FlashMessage};
use crate::i18n::*;
#[component]
pub fn ProfilePage() -> impl IntoView {
let params = use_params_map();
let username = move || params.read().get("username").unwrap_or_default();
let profile = LocalResource::new(move || {
let u = username();
async move { api::get_user_profile(&u).await }
});
let i18n = use_i18n();
view! {
<div class="portal-main">
{move || match profile.get().map(|sw| sw.take()) {
None => view! { <p class="portal-loading">{t!(i18n, loading)}</p> }.into_any(),
Some(Err(e)) => view! { <p class="portal-error">{ e }</p> }.into_any(),
Some(Ok(p)) => view! { <ProfileContent profile=p username=username() /> }.into_any(),
}}
</div>
}
}
#[component]
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let page = RwSignal::new(0i64);
let games = LocalResource::new(move || {
let u = username.clone();
let p = page.get();
async move { api::get_user_games(&u, p).await }
});
let locale_tag = match i18n.get_locale() {
Locale::en => "en-GB",
Locale::fr => "fr-FR",
};
let date_format = api::DateFormatOptions {
date_style: Some("long"),
time_style: None,
};
let joined = api::format_ts(profile.created_at, locale_tag, &date_format);
let profile_username = profile.username.clone();
let is_own_profile = move || auth_username.get().as_deref() == Some(&profile_username);
view! {
<div class="portal-card">
<h1>{ profile.username.clone() }</h1>
<p class="portal-meta">{t!(i18n, member_since)} " " { joined }</p>
<div class="stats-grid">
<div class="stat-box">
<div class="value">{ profile.total_games }</div>
<div class="label">{t!(i18n, stat_games)}</div>
</div>
<div class="stat-box">
<div class="value outcome-win">{ profile.wins }</div>
<div class="label">{t!(i18n, stat_wins)}</div>
</div>
<div class="stat-box">
<div class="value outcome-loss">{ profile.losses }</div>
<div class="label">{t!(i18n, stat_losses)}</div>
</div>
</div>
</div>
<div class="portal-card">
<h2>{t!(i18n, game_history_title)}</h2>
{move || match games.get().map(|sw| sw.take()) {
None => view! { <p class="portal-loading">{t!(i18n, loading)}</p> }.into_any(),
Some(Err(e)) => view! { <p class="portal-error">{ e }</p> }.into_any(),
Some(Ok(r)) => {
if r.games.is_empty() {
view! { <p class="portal-empty">{t!(i18n, no_games)}</p> }.into_any()
} else {
view! { <GamesTable games=r.games page=page /> }.into_any()
}
}
}}
</div>
{move || if is_own_profile() {
let uname = profile.username.clone();
view! { <DeleteAccountSection username=uname /> }.into_any()
} else {
view! { <span /> }.into_any()
}}
}
}
#[component]
fn DeleteAccountSection(username: String) -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified = use_context::<AuthEmailVerified>()
.expect("auth_email_verified context not found").0;
let flash = use_context::<FlashMessage>().expect("FlashMessage context not found").0;
let navigate = use_navigate();
let confirming = RwSignal::new(false);
let confirm_input = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
view! {
<div class="portal-card portal-danger-zone">
<h2>{t!(i18n, delete_account_title)}</h2>
{move || if !confirming.get() {
view! {
<div>
<p class="portal-meta" style="margin-bottom:1rem">
{t!(i18n, delete_account_warning)}
</p>
<button class="portal-danger-btn"
on:click=move |_| confirming.set(true)
>{t!(i18n, delete_account_btn)}</button>
</div>
}.into_any()
} else {
// Define submit fresh each reactive call so the closure is FnMut-compatible.
let expected = username.clone();
let nav = navigate.clone();
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
error.set(String::new());
if confirm_input.get() != expected {
error.set(t_string!(i18n, delete_account_mismatch).to_string());
return;
}
pending.set(true);
let nav = nav.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::delete_account().await {
Ok(()) => {
auth_username.set(None);
auth_email_verified.set(false);
flash.set(Some(t_string!(i18n, account_deleted).to_string()));
nav("/", Default::default());
}
Err(e) => {
error.set(e);
pending.set(false);
}
}
});
};
view! {
<form on:submit=submit>
<p class="portal-meta" style="margin-bottom:1rem">
{t!(i18n, delete_account_warning)}
</p>
<label class="portal-label">{t!(i18n, delete_account_confirm_label)}</label>
<input class="portal-input" type="text" required
prop:value=move || confirm_input.get()
on:input=move |ev| confirm_input.set(event_target_value(&ev)) />
{move || if !error.get().is_empty() {
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
<div style="display:flex;gap:0.75rem;margin-top:1rem">
<button class="portal-danger-btn" type="submit"
disabled=move || pending.get()
>{t!(i18n, delete_account_confirm_btn)}</button>
<button class="portal-page-btn" type="button"
on:click=move |_| {
confirming.set(false);
confirm_input.set(String::new());
error.set(String::new());
}
>{t!(i18n, cancel)}</button>
</div>
</form>
}.into_any()
}}
</div>
}
}
#[component]
fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
let i18n = use_i18n();
let locale_tag = match i18n.get_locale() {
Locale::en => "en-GB",
Locale::fr => "fr-FR",
};
let rows = games.clone();
let has_next = games.len() == 20;
view! {
<table>
<thead>
<tr>
<th>{t!(i18n, col_room)}</th>
<th>{t!(i18n, col_started)}</th>
<th>{t!(i18n, col_ended)}</th>
<th>{t!(i18n, col_outcome)}</th>
<th>{t!(i18n, col_detail)}</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|g| {
let started = api::format_ts(g.started_at, locale_tag, &api::DateFormatOptions::date_only());
let ended = g.ended_at.map(|ts| api::format_ts(ts, locale_tag, &api::DateFormatOptions::date_only())).unwrap_or_else(|| "".into());
let outcome_class = match g.outcome.as_deref() {
Some("win") => "outcome-win",
Some("loss") => "outcome-loss",
Some("draw") => "outcome-draw",
_ => "",
};
let outcome_text = move || match g.outcome.as_deref() {
Some("win") => t_string!(i18n, outcome_win),
Some("loss") => t_string!(i18n, outcome_loss),
Some("draw") => t_string!(i18n, outcome_draw),
_ => "",
};
view! {
<tr>
<td>{ g.room_code.clone() }</td>
<td>{ started }</td>
<td>{ ended }</td>
<td class=outcome_class>{ outcome_text }</td>
<td>
<A href=format!("/games/{}", g.id)>{t!(i18n, view_link)}</A>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
<div style="display:flex;gap:0.75rem;margin-top:1.25rem;align-items:center">
{move || if page.get() > 0 {
view! {
<button class="portal-page-btn"
on:click=move |_| page.update(|p| *p -= 1)
>{t!(i18n, prev_page)}</button>
}.into_any()
} else {
view! { <span /> }.into_any()
}}
<span class="portal-meta" style="margin:0">{t!(i18n, page_label)} " " { move || page.get() + 1 }</span>
{if has_next {
view! {
<button class="portal-page-btn"
on:click=move |_| page.update(|p| *p += 1)
>{t!(i18n, next_page)}</button>
}.into_any()
} else {
view! { <span /> }.into_any()
}}
</div>
}
}

View file

@ -1,87 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use crate::api;
use crate::i18n::*;
#[component]
pub fn ResetPasswordPage() -> impl IntoView {
let i18n = use_i18n();
let query = use_query_map();
// Read token once — not reactive, just a plain String.
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
let new_password = RwSignal::new(String::new());
let confirm_password = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let success = RwSignal::new(false);
let error = RwSignal::new(String::new());
if token.is_empty() {
error.set(t_string!(i18n, reset_password_invalid).to_string());
}
// `submit` moves `token: String` — it is FnMut (clones token each call) but not Copy.
// Keep it off of reactive closures: put it directly on <form on:submit=submit>.
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
if new_password.get() != confirm_password.get() {
error.set(t_string!(i18n, passwords_do_not_match).to_string());
return;
}
pending.set(true);
error.set(String::new());
let tok = token.clone();
let pw = new_password.get();
let invalid_msg = t_string!(i18n, reset_password_invalid).to_string();
wasm_bindgen_futures::spawn_local(async move {
match api::post_reset_password(&tok, &pw).await {
Ok(()) => { success.set(true); }
Err(_) => { error.set(invalid_msg); }
}
pending.set(false);
});
};
view! {
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
<div class="portal-card" style="max-width:420px;width:100%">
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
{t!(i18n, reset_password_title)}
</h1>
// Success message — only captures `success` (Copy RwSignal)
{move || success.get().then(|| view! {
<p class="portal-success" style="text-align:center">
{t!(i18n, reset_password_success)}
</p>
<div style="margin-top:1rem;text-align:center">
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
</div>
})}
// Form — `submit` lives directly on the element, not inside a reactive closure
<form on:submit=submit
style:display=move || if success.get() { "none" } else { "" }>
<label class="portal-label">{t!(i18n, new_password_label)}</label>
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || new_password.get()
on:input=move |ev| new_password.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || confirm_password.get()
on:input=move |ev| confirm_password.set(event_target_value(&ev)) />
<button class="portal-submit-btn" type="submit"
prop:disabled=move || pending.get()
>{t!(i18n, reset_password_submit)}</button>
{move || (!error.get().is_empty()).then(|| view! {
<p class="portal-error">{ error.get() }</p>
})}
</form>
</div>
</div>
}
}

View file

@ -1,84 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use crate::api;
use crate::app::AuthEmailVerified;
use crate::i18n::*;
#[derive(Clone, PartialEq)]
enum VerifyStatus {
Checking,
Success,
Error,
}
#[component]
pub fn VerifyEmailPage() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified = use_context::<AuthEmailVerified>()
.expect("auth_email_verified context not found").0;
let query = use_query_map();
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
let status = RwSignal::new(VerifyStatus::Checking);
let tok = token.clone();
wasm_bindgen_futures::spawn_local(async move {
let s = if tok.is_empty() {
VerifyStatus::Error
} else {
match api::get_verify_email(&tok).await {
Ok(()) => {
// Update the current session if the user is already logged in.
auth_email_verified.set(true);
VerifyStatus::Success
}
Err(_) => VerifyStatus::Error,
}
};
status.set(s);
});
let profile_href = move || {
auth_username
.get()
.map(|u| format!("/profile/{u}"))
.unwrap_or_else(|| "/account".to_string())
};
view! {
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
<div class="portal-card" style="max-width:420px;width:100%;text-align:center">
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem">
{t!(i18n, verify_email_title)}
</h1>
{move || match status.get() {
VerifyStatus::Checking => view! {
<p class="portal-empty">{t!(i18n, verify_email_checking)}</p>
}.into_any(),
VerifyStatus::Success => view! {
<div>
<p class="portal-success">{t!(i18n, verify_email_success)}</p>
<div style="margin-top:1rem">
<a href=profile_href class="portal-link">
{t!(i18n, sign_in)}
</a>
</div>
</div>
}.into_any(),
VerifyStatus::Error => view! {
<div>
<p class="portal-error">{t!(i18n, verify_email_invalid)}</p>
<div style="margin-top:1rem">
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
</div>
</div>
}.into_any(),
}}
</div>
</div>
}
}

93
container/flake.lock generated
View file

@ -1,93 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1780511130,
"narHash": "sha256-2v9lT4ya59Lh1FqPeLnz1MoX9y/wz2huqfe9RtQZITk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "535f3e6942cb1cead3929c604320d3db54b542b9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1778003029,
"narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"trictrac": "trictrac"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1778123869,
"narHash": "sha256-hV9D8ET33kXjdoMXBT2bwM/j8WQM1SP/dVKZtjQKhAQ=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "592e5dedf04f0eaff1ed0f01ce5db7407d9fc7be",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"trictrac": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay"
},
"locked": {
"path": "..",
"type": "path"
},
"original": {
"path": "..",
"type": "path"
},
"parent": []
}
},
"root": "root",
"version": 7
}

View file

@ -1,48 +0,0 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
# inputs.trictrac.url = "github:mmai/trictrac";
inputs.trictrac.url = "..";
outputs = { self, nixpkgs, trictrac }:
{
nixosConfigurations = {
container = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
trictrac.nixosModule
({ pkgs, ... }:
let
hostname = "trictrac";
in
{
boot.isContainer = true;
# Let 'nixos-version --json' know about the Git revision
# of this flake.
system.configurationRevision = nixpkgs.lib.mkIf (self ? rev) self.rev;
system.stateVersion = "25.11";
# Network configuration.
networking.useDHCP = false;
networking.firewall.allowedTCPPorts = [ 80 ];
networking.hostName = hostname;
# trictrac.overlay already includes rust-overlay
nixpkgs.overlays = [ trictrac.overlay ];
services.trictrac = {
enable = true;
protocol = "http";
hostname = hostname;
};
environment.systemPackages = with pkgs; [ neovim ];
})
];
};
};
};
}

View file

@ -3,11 +3,10 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1779486363, "lastModified": 1770390537,
"narHash": "sha256-6rNmvwTngbmz/j3DGsEYkyakrHHY8N5wgRD9k35HyuM=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "90692720b2ad7a7811204155900bf6bea3a3b420", "rev": "d6f45cc00829254a9a6f8807c8fbfaf3efa7e629",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -41,10 +40,10 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1778507602, "lastModified": 1769939035,
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a", "rev": "a8ca480175326551d6c4121498316261cbb5b260",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -75,11 +74,10 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1779102034, "lastModified": 1770136044,
"narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "687f05a9184cad4eaf905c48b63649e3a86f5433", "rev": "e576e3c9cf9bad747afcddd9e34f51d18c855b4e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -92,7 +90,6 @@
"nixpkgs-cmake3": { "nixpkgs-cmake3": {
"locked": { "locked": {
"lastModified": 1758213207, "lastModified": 1758213207,
"narHash": "sha256-rqoqF0LEi+6ZT59tr+hTQlxVwrzQsET01U4uUdmqRtM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde", "rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde",

View file

@ -7,11 +7,7 @@ in
packages = [ packages = [
# for Leptos # for Leptos
pkgs.trunk pkgs.trunk
pkgs.lld # pkgs.wasm-bindgen-cli_0_2_114
# for backbone-lib
pkgs.wasm-bindgen-cli_0_2_114
pkgs.binaryen # for wasm-opt
# pour burn-rs # pour burn-rs
pkgs.SDL2_gfx pkgs.SDL2_gfx
@ -25,24 +21,63 @@ in
pkgs.samply # code profiler pkgs.samply # code profiler
pkgs.feedgnuplot # to visualize bots training results pkgs.feedgnuplot # to visualize bots training results
# --- AI training with python ---
# generate python classes from rust code
pkgs.maturin
# required by python numpy
pkgs.libz
# for bevy
pkgs.alsa-lib
pkgs.udev
# bevy fast compile
pkgs.clang
pkgs.lld
# copié de https://github.com/mmai/Hyperspeedcube/blob/develop/devenv.nix
# TODO : retirer ce qui est inutile
# pour erreur à l'exécution, selon https://github.com/emilk/egui/discussions/1587
pkgs.libxkbcommon
pkgs.libGL
# WINIT_UNIX_BACKEND=wayland
pkgs.wayland
# WINIT_UNIX_BACKEND=x11
pkgs.xorg.libXcursor
pkgs.xorg.libXrandr
pkgs.xorg.libXi
pkgs.xorg.libX11
pkgs.vulkan-headers
pkgs.vulkan-loader
# ------------ fin copie
]; ];
services.postgres = {
enable = true;
listen_addresses = "*";
# port = 5432;
initialDatabases = [{ name = "trictrac"; user = "trictrac"; pass = "trictrac"; }];
};
services = {
mailpit = {
enable = true;
};
};
# https://devenv.sh/languages/ # https://devenv.sh/languages/
languages.rust.enable = true; languages.rust.enable = true;
# AI training with python
enterShell = ''
PYTHONPATH=$PYTHONPATH:$PWD/.devenv/state/venv/lib/python3/site-packages
'';
languages.python = {
enable = true;
uv.enable = true;
venv.enable = true;
venv.requirements = "
pip
gymnasium
numpy
stable-baselines3
shimmy
";
};
# https://devenv.sh/scripts/ # https://devenv.sh/scripts/
# scripts.hello.exec = "echo hello from $GREET"; # scripts.hello.exec = "echo hello from $GREET";

48
doc/backlog.md Normal file
View file

@ -0,0 +1,48 @@
# Backlog
## DONE
## TODO
### stack overflow
- <https://crates.io/crates/backtrace-on-stack-overflow>
- <https://users.rust-lang.org/t/how-to-diagnose-a-stack-overflow-issues-cause/17320/11>
- <https://www.reddit.com/r/rust/comments/1d8lxtd/debugging_stack_overflows/>
Méthodes pour limiter la stack : réduire la taille de la pile avant de lancer ton binaire en ligne de commande :
```sh
ulimit -s 6144 # Limite la pile à 6Mo
# just trainbot
RUST_BACKTRACE=1 LD_LIBRARY_PATH=./target/debug ./target/debug/train_dqn_burn
ulimit -s unlimited # Pour revenir à la normale
```
- bot burn
- train = `just trainbot`
- durée d'entrainement selon params ?
- save
- load and run against default bot
- many configs, save models selon config
- retrain against himself ?
### Doc
Cheatsheet : arbre des situations et priorité des règles
### Epic : jeu avec écoles
- déplacement de fiches points : validation physique
- évenements de déclaration d'école & contre école
### Epic : Bot
- PGX
- https://joe-antognini.github.io/ml/jax-tic-tac-toe
- https://www.sotets.uk/pgx/api_usage/
- OpenAi gym
- doc gymnasium <https://gymnasium.farama.org/introduction/basic_usage/>
- Rust implementation for OpenAi gym <https://github.com/MathisWellmann/gym-rs>
- Backgammon (?) <https://github.com/dellalibera/gym-backgammon>

View file

@ -0,0 +1,51 @@
# Game state notation
## History
Jollyvet : rien
1698 Le jeu de trictrac...
Noirs T 1 2 .. 11
Blancs T 1 2 .. 11
1738 Le Grand Trictrac, Bernard Laurent Soumille
A B C D E F G H I K L M
& Z Y X V T S R Q P O N
1816 Guiton
Noirs T 1 2 .. 11
Blancs T 1 2 .. 11
1818 Cours complet de Trictrac, Pierre Marie Michel Lepeintre
m n o p q r s t u v x y
l k j i h g f e d c b a
1852 Le jeu de trictrac rendu facile
Noirs T 1 2 .. 11
Blancs T 1 2 .. 11
## Références actuelles
https://salondesjeux.fr/trictrac.htm : Guiton
Noirs T 1 2 .. 11
Blancs T 1 2 .. 11
http://trictrac.org/content/index2.html
N1 N2 .. N12
B1 B2 .. B12
Backgammon
13 14 .. 23 24
12 11 .. 2 1
=> utilisation de la notation Backgammon : uniformisation de la notation quelque soit le jeu de table.
Non dénuée d'avantages :
- on se débarrasse de la notation spéciale du talon
- on évite confusion entre côté noir et blanc.
- bien que l'orientation change par rapport à la plupart des traité, on suit celle du Lepeintre, et celle des vidéos de Philippe Lalanne
Backgammon notation : https://nymann.dev/2023/05/16/Introducing-the-Backgammon-Position-Notation-BPN-A-Standard-for-Representing-Game-State/
GnuBg : https://www.gnu.org/software/gnubg/manual/html_node/A-technical-description-of-the-Position-ID.html
- implémentation rust https://github.com/bungogood/bkgm/blob/main/src/position.rs

View file

@ -0,0 +1,9 @@
# Pourquoi le trictrac
Je vais montrer pourquoi il faut redécouvrir le trictrac, pourquoi il pourrait être votre nouveau jeu favori.
Je vais montrer pourquoi il est intéressant, complexe pour les humains comme les machines.
En quoi il diffère du backgammon de manière avantageuse. Pourquoi il a quasiment disparu.
Le backgammon était connu à la grande époque du trictrac. Il ne lui manquait que le dé doubleur, apparu au début du XXe siècle, qui a ajouté les prises de décision méta, que le trictrac possèdait déjà sur plusieurs niveaux (via la gestion des bredouilles en fin de partie par exemple).
Le hasard des dés permet au joueur faible d'espérer la victoire même en fin de partie (cf. Le tour du monde en 80 jeux).

40
doc/book/traité.md Normal file
View file

@ -0,0 +1,40 @@
# traité
En 12 chapitres (trous) de 12 sous-chapitres (points / niveaux de compréhension) ?
Célébration -> s'inspirer du _petit traité invitant à la découverte de l'art subtil du go_
comparaison échecs -> comparaison backgammon
Les règles
- le matériel
- le mouvement
- les points
- les écoles
- les combinaisons
La stratégie
- probabilités
- arbres de décision
- l'entraînement
L'encyclopédie
- comparaison avec d'autres jeux
- échecs/go ?
- histoire
- traités
- vocabulaire
- l'esthétique
- l'étiquette
- ressources web
- wikipedia
- l'encyclopédie des jeux + videos youtube
- le dictionnaire du trictrac
- fabriquer un boîtier/plateau de jeu
- jouer en ligne
## rêveries
Trictrac : un domaine grand et complexe, un univers dans lequel on peut s'absorber. Un jeu geek parfait. Qui a la noblesse d'avoir été populaire, qui a la noblesse de règles nécessitant apprentissage et presque companionage.
Pourquoi s'investir dans ce genre d'activité ? Toucher un absolu. Sauver de la mort une pépite du passé. Entrer dans le monde des morts comme Orphée ou Ulysse ?
Et maîtriser un vocabulaire, des gestes, des règles affinées au fil des siècles.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,418 +0,0 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variation 01 — Dés en sidebar droite</title>
<link rel="stylesheet" href="../snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css">
<style>
/* ══════════════════════════════════════════════════════════════════
Variation 01: move dice + status bar to a right sidebar
──────────────────────────────────────────────────────────────────
Changes from baseline:
• .board-bar → 5px thin wood rail (no dice)
• new .game-board-with-sidebar flex wrapper
• new .game-right-sidebar with dice panel + status panel
• .free-mode-toggle moved under the dice
• .game-bottom-strip removed from its original position
• removed both zone-labels-row divs
══════════════════════════════════════════════════════════════════ */
/* 1 ── Collapse the in-board bar to a 5 px wood rail ────────────── */
.board-bar {
width: 5px;
overflow: hidden;
}
.bar-die-slot { display: none; }
/* 2 ── Flex wrapper: board-wrapper left, sidebar right ──────────── */
.game-board-with-sidebar {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
/* 3 ── Right sidebar ────────────────────────────────────────────── */
.game-right-sidebar {
width: 152px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
/* stretch to the full height of the board-wrapper */
align-self: stretch;
}
/* Dice panel ── bord rail card ── */
.sidebar-dice-panel {
background: var(--board-rail);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
padding: 0.6rem 0.75rem 0.75rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.sidebar-dice-row {
display: flex;
gap: 0.55rem;
align-items: center;
justify-content: center;
}
/* Status panel ── fills remaining sidebar height below the dice ── */
.sidebar-status-panel {
background: var(--ui-parchment);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
padding: 0.65rem 0.75rem 0.75rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
flex: 1;
}
.sidebar-status-panel .game-status {
color: var(--ui-ink);
text-shadow: none;
font-size: 1rem;
padding: 0;
width: auto;
text-align: center;
line-height: 1.3;
}
.sidebar-status-panel .game-sub-prompt {
color: #887766;
padding: 0;
width: auto;
text-align: center;
font-size: 0.67rem;
line-height: 1.4;
}
.sidebar-status-panel .board-actions {
flex-wrap: wrap;
justify-content: center;
min-height: 0;
}
.sidebar-dice-panel .free-mode-toggle {
color: var(--ui-parchment);
flex-wrap: wrap;
justify-content: center;
text-align: center;
font-size: 0.7rem;
gap: 0.3rem;
}
</style>
</head>
<body>
<!-- ── Left-side navigation sidebar (unchanged) ──────────────────────── -->
<button class="game-hamburger game-hamburger-open" aria-label="Menu">
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
<div class="game-sidebar game-sidebar-open">
<div class="game-sidebar-header">
<span class="game-sidebar-brand">Trictrac</span>
<div class="lang-switcher">
<button>EN</button>
<button class="lang-active">FR</button>
</div>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"></path></svg>
<a href="#" class="game-sidebar-link">Nouvelle partie</a>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"></path></svg>
<a href="#" class="game-sidebar-link">Se connecter</a>
</div>
<div class="sidebar-footer">
<div><span class="site-nav-version">v0.2.15</span></div>
</div>
</div>
<!-- ── Portal page behind the overlay (unchanged) ────────────────────── -->
<main>
<div style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh" class="portal-main">
<div class="login-card">
<div class="login-card-header"><div class="login-board-stripe"></div></div>
<div class="login-card-body">
<h1 class="login-title">Trictrac</h1>
<p class="login-subtitle"><em>Une interprétation numérique</em></p>
<div class="login-ornament"></div>
<div class="login-actions">
<button class="login-btn login-btn-secondary">Jouer contre le bot</button>
<button class="login-btn login-btn-primary">Inviter un adversaire</button>
</div>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════════════════
GAME OVERLAY
══════════════════════════════════════════════════════════════════════ -->
<div class="game-overlay">
<div class="game-container">
<!-- ── Score area (unchanged) ─────────────────────────────────────── -->
<div class="score-area">
<div class="merged-score-panel">
<div class="score-row score-row-me">
<div class="score-row-name">
<span class="player-name">Anonyme</span>
<span class="you-tag"> (vous)</span>
</div>
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div style="width:50%" class="pts-ghost-bar-fill"></div>
</div>
<div class="pts-counter-row">
<span class="pts-counter">6</span>
<span class="pts-max">/12</span>
</div>
</div>
<div class="peg-track">
<div class="peg-hole"></div><div class="peg-hole"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
</div>
</div>
<div class="score-row-sep"></div>
<div class="score-row score-row-opp">
<div class="score-row-name">
<span class="player-name">Bot</span>
</div>
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div style="width:16%" class="pts-ghost-bar-fill pts-ghost-bar-opp"></div>
</div>
<div class="pts-counter-row">
<span class="pts-counter">2</span>
<span class="pts-max">/12</span>
</div>
</div>
<div class="peg-track">
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
</div>
</div>
</div>
<div class="scoring-panels-container">
<div class="scoring-panel-wrapper">
<button class="scoring-expand-btn" title="Show scoring details">+</button>
<div class="scoring-panel">
<div class="scoring-panel-head">
<div class="scoring-total">+4 pts</div>
<button class="scoring-collapse-btn" title="Minimise"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Battage à vrai (petit jan)</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+4</span>
</div>
</div>
</div>
</div>
</div><!-- /.score-area -->
<!-- ══════════════════════════════════════════════════════════════════
BOARD + RIGHT SIDEBAR (the variation change starts here)
══════════════════════════════════════════════════════════════════ -->
<div class="game-board-with-sidebar">
<!-- ── Board wrapper (board-bar is now a thin 5px rail) ─────────── -->
<div class="board-wrapper">
<div class="board">
<!-- top row ───────────────────────────────────────────────── -->
<div class="board-row top-row">
<div class="board-quarter">
<div class="field zone-opponent corner" id="field-13">
<span class="field-num">13</span>
</div>
<div class="field zone-opponent" id="field-14">
<span class="field-num">14</span>
</div>
<div class="field zone-opponent" id="field-15">
<span class="field-num">15</span>
<div class="checker-stack">
<div class="checker black"></div>
<div class="checker black"></div>
<div class="checker black"></div>
</div>
</div>
<div class="field zone-opponent" id="field-16">
<span class="field-num">16</span>
</div>
<div class="field zone-opponent" id="field-17">
<span class="field-num">17</span>
</div>
<div class="field zone-opponent" id="field-18">
<span class="field-num">18</span>
</div>
</div>
<!-- thin bar — no die slot -->
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-retour" id="field-19">
<span class="field-num">19</span>
<div class="hit-ripple hit-ripple-top"></div>
<div class="checker-stack">
<div class="checker black"></div>
</div>
</div>
<div class="field zone-retour" id="field-20"><span class="field-num">20</span></div>
<div class="field zone-retour" id="field-21"><span class="field-num">21</span></div>
<div class="field zone-retour" id="field-22"><span class="field-num">22</span></div>
<div class="field zone-retour point-no-bredouille" id="field-23"><span class="field-num">23</span></div>
<div class="field zone-retour point-no-bredouille" id="field-24">
<span class="field-num">24</span>
<div class="checker-stack">
<div class="checker black"></div>
<div class="checker black"></div>
<div class="checker black"></div>
<div class="checker black">11</div>
</div>
</div>
</div>
</div><!-- /.board-row.top-row -->
<div class="board-center-bar"></div>
<!-- bottom row ────────────────────────────────────────────── -->
<div class="board-row bot-row">
<div class="board-quarter">
<div class="field zone-grand corner corner-available clickable dest" id="field-12"
title="Coin de repos — must enter and leave with 2 checkers">
<span class="field-num">12</span>
</div>
<div class="field zone-grand clickable dest" id="field-11"><span class="field-num">11</span></div>
<div class="field zone-grand" id="field-10"><span class="field-num">10</span></div>
<div class="field zone-grand" id="field-9">
<span class="field-num">9</span>
<div class="checker-stack"><div class="checker white"></div></div>
</div>
<div class="field zone-grand" id="field-8">
<span class="field-num">8</span>
<div class="checker-stack">
<div class="checker white"></div>
<div class="checker white"></div>
</div>
</div>
<div class="field zone-grand selected clickable" id="field-7">
<span class="field-num">7</span>
<div class="checker-stack">
<div class="checker white"></div>
<div class="checker white"></div>
</div>
</div>
</div>
<!-- thin bar — no die slot -->
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-petit point-no-bredouille" id="field-6"><span class="field-num">6</span></div>
<div class="field zone-petit point-no-bredouille" id="field-5"><span class="field-num">5</span></div>
<div class="field zone-petit point-no-bredouille" id="field-4"><span class="field-num">4</span></div>
<div class="field zone-petit point-no-bredouille" id="field-3"><span class="field-num">3</span></div>
<div class="field zone-petit point-no-bredouille" id="field-2"><span class="field-num">2</span></div>
<div class="field zone-petit point-no-bredouille" id="field-1">
<span class="field-num">1</span>
<div class="checker-stack">
<div class="checker white">10</div>
<div class="checker white"></div>
<div class="checker white"></div>
<div class="checker white"></div>
</div>
</div>
</div>
</div><!-- /.board-row.bot-row -->
<svg style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" width="761" height="388"></svg>
</div><!-- /.board -->
</div><!-- /.board-wrapper -->
<!-- ── Right sidebar ──────────────────────────────────────────── -->
<div class="game-right-sidebar">
<!-- Dice panel -->
<div class="sidebar-dice-panel">
<div class="sidebar-dice-row">
<!-- Die 1 (was in bot-row board-bar) — shows 6 -->
<svg class="die-face" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<!-- Die 2 (was in top-row board-bar) — shows 4 -->
<svg class="die-face" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<!-- Free-play toggle -->
<label class="free-mode-toggle">
<input type="checkbox">Mode jeu libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div><!-- /.sidebar-dice-panel -->
<!-- Status panel (content from .game-bottom-strip) -->
<div class="sidebar-status-panel">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<p class="game-sub-prompt">Cliquez une flêche soulignée pour déplacer</p>
<div class="board-actions"></div>
</div><!-- /.sidebar-status-panel -->
</div><!-- /.game-right-sidebar -->
</div><!-- /.game-board-with-sidebar -->
</div><!-- /.game-container -->
</div><!-- /.game-overlay -->
</body>
</html>

View file

@ -1,628 +0,0 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variation 02 — En-tête horizontal · Scoring latéral</title>
<link rel="stylesheet" href="../snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css">
<style>
/* ══════════════════════════════════════════════════════════════════
Variation 02
─────────────────────────────────────────────────────────────────
Key changes vs baseline:
1. .score-area replaced by .mhdr — one-row match header with
both players side by side (inspired by headerNostyle.html).
The turn indicator lives here so it doesn't need to be in
the status bar.
2. .v02-body is a 3-col flex row:
[scoring column] [board] [right sidebar]
Scoring column is a normal flex child (align-self: flex-start)
so its variable height NEVER displaces the board.
Right sidebar holds dice + status/actions (same idea as
variation 01).
3. Basic responsive:
≤ 1180px: scoring column hidden (scoring panel gone)
≤ 920px : right sidebar hidden, .v02-bottom-strip shown
══════════════════════════════════════════════════════════════════ */
/* ── Suppress baseline elements we are replacing ─────────────────── */
.score-area { display: none !important; }
.board-bar { width: 5px; overflow: hidden; }
.bar-die-slot { display: none; }
.zone-labels-row { display: none; }
.game-bottom-strip { display: none; }
/* ── Game container: tighter gap ─────────────────────────────────── */
.game-container { gap: 0.45rem; }
/* ════════════════════════════════════════════════════════════════════
MATCH HEADER — both players in a single horizontal strip
════════════════════════════════════════════════════════════════════ */
.mhdr {
display: flex;
align-items: center;
width: 100%;
background: var(--ui-parchment);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
padding: 0.45rem 1rem 0.45rem 0.85rem;
gap: 0.6rem;
}
/* Player half — flex, fills half the header */
.mhdr-player {
display: flex;
align-items: center;
gap: 0.55rem;
flex: 1;
min-width: 0;
}
.mhdr-player-right { flex-direction: row-reverse; }
/* Name + you-tag block */
.mhdr-name-block {
display: flex;
flex-direction: column;
min-width: 0;
flex-shrink: 1;
}
.mhdr-player-right .mhdr-name-block { align-items: flex-end; }
.mhdr-player .player-name {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.mhdr-player .you-tag { font-size: 0.62rem; }
/* Peg track inside the header */
.mhdr-pegs {
display: flex;
gap: 3px;
flex-shrink: 0;
}
.mhdr-pegs .ph {
width: 11px;
height: 11px;
border-radius: 50%;
border: 1.5px solid rgba(138,106,40,0.3);
background: rgba(0,0,0,0.06);
flex-shrink: 0;
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
}
.mhdr-pegs .ph.filled-me {
background: #5aab38;
border-color: #3a7828;
box-shadow: 0 0 4px rgba(90,171,56,0.5);
}
.mhdr-pegs .ph.filled-opp {
background: #c05030;
border-color: #8a3018;
box-shadow: 0 0 4px rgba(192,80,48,0.5);
}
/* Bredouille indicator */
.mhdr-bred {
font-size: 0.75rem;
color: var(--ui-gold-dark);
opacity: 0.7;
flex-shrink: 0;
}
/* Score block */
.mhdr-score {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
}
.mhdr-score-num {
font-family: var(--font-display);
font-size: 1.65rem;
font-weight: 600;
color: var(--ui-ink);
font-variant-numeric: tabular-nums;
}
.mhdr-score-max {
font-family: var(--font-ui);
font-size: 0.6rem;
color: #998877;
margin-top: -2px;
}
/* Center column: VS + game info + turn */
.mhdr-center {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
padding: 0 1rem;
border-left: 1px solid rgba(138,106,40,0.18);
border-right: 1px solid rgba(138,106,40,0.18);
}
.mhdr-vs {
font-family: var(--font-ui);
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.14em;
color: #998877;
text-transform: uppercase;
}
.mhdr-info {
font-family: var(--font-display);
font-size: 0.72rem;
color: #aa9980;
font-style: italic;
white-space: nowrap;
}
.mhdr-turn {
font-family: var(--font-ui);
font-size: 0.67rem;
color: var(--ui-green-accent);
font-weight: 500;
}
/* ════════════════════════════════════════════════════════════════════
3-COLUMN BODY
════════════════════════════════════════════════════════════════════ */
.v02-body {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
/* ── Left: scoring column ─────────────────────────────────────────── */
.v02-scoring-col {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
/* align-self: flex-start (default) means the column is only as tall
as its content — it CANNOT displace the board vertically. */
}
.v02-scoring-empty {
font-family: var(--font-display);
font-style: italic;
font-size: 0.75rem;
color: rgba(242,232,208,0.2);
text-align: center;
padding: 1rem 0.5rem;
border: 1px dashed rgba(200,164,72,0.12);
border-radius: 5px;
}
/* Scoring panel in the column: take full column width */
.v02-scoring-col .scoring-panel {
width: 100%;
box-sizing: border-box;
}
.v02-scoring-col .scoring-panel-wrapper {
animation: none;
}
/* ── Right: sidebar ───────────────────────────────────────────────── */
.game-right-sidebar {
width: 152px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: stretch;
}
.sidebar-dice-panel {
background: var(--board-rail);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
padding: 0.6rem 0.75rem 0.75rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.sidebar-dice-row {
display: flex;
gap: 0.55rem;
align-items: center;
justify-content: center;
}
.sidebar-status-panel {
background: var(--ui-parchment);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
padding: 0.65rem 0.75rem 0.75rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
flex: 1;
}
.sidebar-status-panel .game-status {
color: var(--ui-ink);
text-shadow: none;
font-size: 1rem;
padding: 0;
width: auto;
text-align: center;
line-height: 1.3;
}
.sidebar-status-panel .board-actions {
flex-wrap: wrap;
justify-content: center;
min-height: 0;
}
.sidebar-dice-panel .free-mode-toggle {
color: var(--ui-parchment);
flex-wrap: wrap;
justify-content: center;
text-align: center;
font-size: 0.7rem;
gap: 0.3rem;
}
/* ── Mobile fallback strip (shown when sidebar is hidden) ─────────── */
.v02-bottom-strip {
display: none;
background: var(--ui-parchment);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
padding: 0.55rem 1rem;
gap: 0.75rem;
align-items: center;
width: 100%;
}
.v02-bottom-strip-dice {
display: flex;
gap: 0.4rem;
align-items: center;
flex-shrink: 0;
padding-right: 0.75rem;
border-right: 1px solid rgba(138,106,40,0.2);
}
.v02-bottom-strip-status {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
.v02-bottom-strip .game-status {
color: var(--ui-ink);
text-shadow: none;
font-size: 0.95rem;
padding: 0;
width: auto;
}
.v02-bottom-strip .board-actions {
flex-wrap: wrap;
justify-content: center;
}
/* ════════════════════════════════════════════════════════════════════
RESPONSIVE BREAKPOINTS
════════════════════════════════════════════════════════════════════ */
/* Below 1180px: hide left scoring column (board + sidebar still fit) */
@media (max-width: 1180px) {
.v02-scoring-col { display: none; }
}
/* Below 920px: collapse right sidebar → bottom strip */
@media (max-width: 920px) {
.game-right-sidebar { display: none !important; }
.v02-bottom-strip { display: flex !important; }
}
/* Narrow header: hide peg track if needed */
@media (max-width: 700px) {
.mhdr-pegs { display: none; }
.mhdr-center { padding: 0 0.5rem; }
}
</style>
</head>
<body>
<!-- ── Left-side navigation sidebar (unchanged) ──────────────────────── -->
<button class="game-hamburger game-hamburger-open" aria-label="Menu">
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
<div class="game-sidebar game-sidebar-open">
<div class="game-sidebar-header">
<span class="game-sidebar-brand">Trictrac</span>
<div class="lang-switcher">
<button>EN</button>
<button class="lang-active">FR</button>
</div>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"></path></svg>
<a href="#" class="game-sidebar-link">Nouvelle partie</a>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"></path></svg>
<a href="#" class="game-sidebar-link">Se connecter</a>
</div>
<div class="sidebar-footer">
<div><span class="site-nav-version">v0.2.15</span></div>
</div>
</div>
<!-- ── Portal page behind the overlay ────────────────────────────────── -->
<main>
<div style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh" class="portal-main">
<div class="login-card">
<div class="login-card-header"><div class="login-board-stripe"></div></div>
<div class="login-card-body">
<h1 class="login-title">Trictrac</h1>
<p class="login-subtitle"><em>Une interprétation numérique</em></p>
<div class="login-ornament"></div>
<div class="login-actions">
<button class="login-btn login-btn-secondary">Jouer contre le bot</button>
<button class="login-btn login-btn-primary">Inviter un adversaire</button>
</div>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════════════════
GAME OVERLAY
══════════════════════════════════════════════════════════════════════ -->
<div class="game-overlay">
<div class="game-container">
<!-- ══════════════════════════════════════════════════════════════════
MATCH HEADER — both players side by side
══════════════════════════════════════════════════════════════════ -->
<div class="mhdr">
<!-- Player 1 (me) ───────────────────────────────────────────────── -->
<div class="mhdr-player">
<div class="mhdr-name-block">
<span class="player-name">Anonyme</span>
<span class="you-tag">(vous)</span>
</div>
<div class="mhdr-pegs">
<div class="ph filled-me"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div>
</div>
<span class="mhdr-bred" title="Bredouille armée"></span>
<div class="mhdr-score">
<span class="mhdr-score-num">6</span>
<span class="mhdr-score-max">/12</span>
</div>
</div>
<!-- Center ──────────────────────────────────────────────────────── -->
<div class="mhdr-center">
<span class="mhdr-vs">VS</span>
<span class="mhdr-info">jeu en 12 trous</span>
<span class="mhdr-turn">à vous de jouer</span>
</div>
<!-- Player 2 (opponent) ─────────────────────────────────────────── -->
<div class="mhdr-player mhdr-player-right">
<div class="mhdr-name-block">
<span class="player-name">Bot</span>
</div>
<div class="mhdr-pegs">
<div class="ph filled-opp"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div><div class="ph"></div>
<div class="ph"></div>
</div>
<div class="mhdr-score">
<span class="mhdr-score-num">2</span>
<span class="mhdr-score-max">/12</span>
</div>
</div>
</div><!-- /.mhdr -->
<!-- ══════════════════════════════════════════════════════════════════
3-COLUMN BODY: [scoring col] [board] [right sidebar]
══════════════════════════════════════════════════════════════════ -->
<div class="v02-body">
<!-- ── Left: scoring column (variable height, never moves board) ── -->
<div class="v02-scoring-col">
<div class="scoring-panel-wrapper">
<button class="scoring-expand-btn" title="Show scoring details">+</button>
<div class="scoring-panel">
<div class="scoring-panel-head">
<div class="scoring-total">+4 pts</div>
<button class="scoring-collapse-btn" title="Minimise"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Battage à vrai (petit jan)</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+4</span>
</div>
</div>
</div>
<!-- Shown when no scoring event is active -->
<div class="v02-scoring-empty">Aucun événement de marque</div>
</div>
<!-- ── Board ─────────────────────────────────────────────────────── -->
<div class="board-wrapper">
<div class="board">
<!-- top row ──────────────────────────────────────────────── -->
<div class="board-row top-row">
<div class="board-quarter">
<div class="field zone-opponent corner" id="field-13"><span class="field-num">13</span></div>
<div class="field zone-opponent" id="field-14"><span class="field-num">14</span></div>
<div class="field zone-opponent" id="field-15">
<span class="field-num">15</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div><div class="checker black"></div>
</div>
</div>
<div class="field zone-opponent" id="field-16"><span class="field-num">16</span></div>
<div class="field zone-opponent" id="field-17"><span class="field-num">17</span></div>
<div class="field zone-opponent" id="field-18"><span class="field-num">18</span></div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-retour" id="field-19">
<span class="field-num">19</span>
<div class="hit-ripple hit-ripple-top"></div>
<div class="checker-stack"><div class="checker black"></div></div>
</div>
<div class="field zone-retour" id="field-20"><span class="field-num">20</span></div>
<div class="field zone-retour" id="field-21"><span class="field-num">21</span></div>
<div class="field zone-retour" id="field-22"><span class="field-num">22</span></div>
<div class="field zone-retour point-no-bredouille" id="field-23"><span class="field-num">23</span></div>
<div class="field zone-retour point-no-bredouille" id="field-24">
<span class="field-num">24</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div>
<div class="checker black"></div><div class="checker black">11</div>
</div>
</div>
</div>
</div><!-- /.top-row -->
<div class="board-center-bar"></div>
<!-- bottom row ───────────────────────────────────────────── -->
<div class="board-row bot-row">
<div class="board-quarter">
<div class="field zone-grand corner corner-available clickable dest" id="field-12"
title="Coin de repos — must enter and leave with 2 checkers">
<span class="field-num">12</span>
</div>
<div class="field zone-grand clickable dest" id="field-11"><span class="field-num">11</span></div>
<div class="field zone-grand" id="field-10"><span class="field-num">10</span></div>
<div class="field zone-grand" id="field-9">
<span class="field-num">9</span>
<div class="checker-stack"><div class="checker white"></div></div>
</div>
<div class="field zone-grand" id="field-8">
<span class="field-num">8</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
<div class="field zone-grand selected clickable" id="field-7">
<span class="field-num">7</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-petit point-no-bredouille" id="field-6"><span class="field-num">6</span></div>
<div class="field zone-petit point-no-bredouille" id="field-5"><span class="field-num">5</span></div>
<div class="field zone-petit point-no-bredouille" id="field-4"><span class="field-num">4</span></div>
<div class="field zone-petit point-no-bredouille" id="field-3"><span class="field-num">3</span></div>
<div class="field zone-petit point-no-bredouille" id="field-2"><span class="field-num">2</span></div>
<div class="field zone-petit point-no-bredouille" id="field-1">
<span class="field-num">1</span>
<div class="checker-stack">
<div class="checker white">10</div><div class="checker white"></div>
<div class="checker white"></div><div class="checker white"></div>
</div>
</div>
</div>
</div><!-- /.bot-row -->
<svg style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" width="761" height="388"></svg>
</div><!-- /.board -->
</div><!-- /.board-wrapper -->
<!-- ── Right sidebar: dice + status/actions ───────────────────── -->
<div class="game-right-sidebar">
<div class="sidebar-dice-panel">
<div class="sidebar-dice-row">
<!-- Die 1 — 6 -->
<svg class="die-face" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<!-- Die 2 — 4 (used) -->
<svg class="die-face die-used" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<label class="free-mode-toggle">
<input type="checkbox">Mode jeu libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div><!-- /.sidebar-dice-panel -->
<div class="sidebar-status-panel">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions">
<button class="btn btn-secondary">Passer</button>
</div>
</div><!-- /.sidebar-status-panel -->
</div><!-- /.game-right-sidebar -->
</div><!-- /.v02-body -->
<!-- ── Mobile bottom strip (shown below 920px) ────────────────────── -->
<div class="v02-bottom-strip">
<div class="v02-bottom-strip-dice">
<svg class="die-face" width="38" height="38" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<svg class="die-face die-used" width="38" height="38" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<div class="v02-bottom-strip-status">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions"><button class="btn btn-secondary">Passer</button></div>
</div>
</div>
</div><!-- /.game-container -->
</div><!-- /.game-overlay -->
</body>
</html>

View file

@ -1,655 +0,0 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variation 03 — Dark Modern · Dock de contrôle</title>
<link rel="stylesheet" href="../snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css">
<style>
/* ══════════════════════════════════════════════════════════════════
Variation 03: Dark Modern
─────────────────────────────────────────────────────────────────
Design language:
• Dark slate background (no wood grain)
• Frosted-glass panels with thin bright borders
• Clean sans-serif typography throughout
• Brighter, more saturated accent colours
Layout differences from baseline:
1. Compact horizontal player strip above the board
(turn indicator + bredouille inline, no separate status row)
2. .scoring-panel is absolutely overlaid on the board area
(top-left, floats over the board corner — NEVER pushes board)
3. Dice + game-status + board-actions live in a bottom dock bar
below the board (no die in the board bar)
4. Responsive: dock goes vertical on narrow viewports
══════════════════════════════════════════════════════════════════ */
/* ── Dark mode token overrides ───────────────────────────────────── */
:root {
--board-felt: #0d1f10;
--board-rail: #07090a;
--checker-ring: #6ecf6e;
/* Dark glass panels */
--ui-parchment: #1c1c28;
--ui-parchment-dark: #14141e;
--ui-ink: #e0e0f0;
--ui-gold: #e4b830;
--ui-gold-dark: #a07a10;
--ui-green-accent: #40c840;
--ui-red-accent: #e04060;
--surface: rgba(255,255,255,0.055);
--surface-border: rgba(255,255,255,0.10);
--surface-border-hi: rgba(255,255,255,0.18);
}
/* ── Base: dark background, no wood grain ────────────────────────── */
body {
background: #0d0d14;
background-image: none;
}
/* ── Suppress baseline elements ──────────────────────────────────── */
.score-area { display: none !important; }
.board-bar { width: 5px; overflow: hidden; }
.bar-die-slot { display: none; }
.zone-labels-row { display: none; }
.game-bottom-strip { display: none; }
/* ── Game overlay: dark ───────────────────────────────────────────── */
.game-overlay {
background: #0d0d14;
background-image: none;
}
.game-container { gap: 0.5rem; }
/* ── Sidebar: darker ─────────────────────────────────────────────── */
.game-sidebar { background: #0a0a0f; border-right-color: rgba(255,255,255,0.08); }
.game-sidebar-brand { color: var(--ui-gold); }
.game-sidebar-link { color: rgba(224,224,240,0.75); }
.lang-switcher button { color: rgba(224,224,240,0.6); border-color: rgba(255,255,255,0.15); }
.lang-switcher button.lang-active { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.3); color: #e0e0f0; }
.game-hamburger { background: #14141e; border-color: rgba(255,255,255,0.12); }
.site-nav-version { color: rgba(255,255,255,0.2); }
/* ── Board: darker rail, same felt ───────────────────────────────── */
.board { border-color: #07090a; }
.board-center-bar { background: #07090a; }
/* ── Checkers: keep their warm gradient, just tweak the ring ─────── */
/* (Already fine, no override needed) */
/* ── Die face: higher contrast on dark background ─────────────────── */
.die-face rect { fill: #f5f0e0; stroke: #181008; }
.die-face circle { fill: #181008; }
/* ════════════════════════════════════════════════════════════════════
PLAYER STRIP — compact one-row header
════════════════════════════════════════════════════════════════════ */
.v03-strip {
display: flex;
align-items: center;
width: 100%;
background: var(--surface);
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.5rem 0.85rem;
gap: 0.6rem;
}
/* Player half */
.v03-player {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.v03-player-right { flex-direction: row-reverse; }
/* Avatar circle */
.v03-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-ui);
font-size: 0.7rem;
font-weight: 600;
flex-shrink: 0;
letter-spacing: 0;
}
.v03-avatar-me { background: rgba(64,200,64,0.18); border: 1.5px solid #40c840; color: #40c840; }
.v03-avatar-opp { background: rgba(224,64,96,0.18); border: 1.5px solid #e04060; color: #e04060; }
/* Name + tag */
.v03-name {
font-family: var(--font-ui);
font-size: 0.85rem;
font-weight: 500;
color: #d8d8ec;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 110px;
}
.v03-you-tag {
font-size: 0.6rem;
color: rgba(216,216,236,0.4);
font-style: italic;
}
.v03-name-block { display: flex; flex-direction: column; min-width: 0; }
.v03-player-right .v03-name-block { align-items: flex-end; }
/* Dot peg track */
.v03-dots {
display: flex;
gap: 2.5px;
flex-shrink: 0;
}
.v03-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.14);
transition: background 0.25s, border-color 0.25s;
flex-shrink: 0;
}
.v03-dot.on-me { background: #40c840; border-color: #30a830; box-shadow: 0 0 4px rgba(64,200,64,0.5); }
.v03-dot.on-opp { background: #e04060; border-color: #b02840; box-shadow: 0 0 4px rgba(224,64,96,0.5); }
/* Score */
.v03-score {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.v03-score-num {
font-family: var(--font-display);
font-size: 1.55rem;
font-weight: 600;
color: #e0e0f0;
display: block;
}
.v03-score-max {
font-family: var(--font-ui);
font-size: 0.58rem;
color: rgba(224,224,240,0.35);
display: block;
margin-top: -3px;
}
/* Bredouille badge */
.v03-bred {
font-size: 0.72rem;
color: var(--ui-gold);
opacity: 0.65;
flex-shrink: 0;
}
/* Center divider: VS + turn */
.v03-strip-center {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0 0.85rem;
border-left: 1px solid var(--surface-border);
border-right: 1px solid var(--surface-border);
}
.v03-vs {
font-family: var(--font-ui);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.16em;
color: rgba(224,224,240,0.28);
text-transform: uppercase;
}
.v03-game-info {
font-family: var(--font-ui);
font-size: 0.65rem;
color: rgba(224,224,240,0.25);
white-space: nowrap;
}
.v03-turn-pill {
font-family: var(--font-ui);
font-size: 0.62rem;
font-weight: 500;
color: #40c840;
background: rgba(64,200,64,0.12);
border: 1px solid rgba(64,200,64,0.28);
border-radius: 20px;
padding: 0.1em 0.6em;
white-space: nowrap;
}
/* ════════════════════════════════════════════════════════════════════
BOARD WRAP — relative, holds floating scoring panel
════════════════════════════════════════════════════════════════════ */
.v03-board-wrap {
position: relative;
align-self: flex-start;
}
/* ── Floating scoring panel: top-left corner of the board wrap ─────
It overlaps the board (absolute) so it NEVER displaces the board.
The panel grows downward, overlapping the board corner, which is
visually acceptable (it is transparent for events on that area
while a scoring animation plays). */
.v03-scoring-float {
position: absolute;
top: 0;
left: 0;
z-index: 20;
pointer-events: none;
}
.v03-scoring-float .scoring-panel-wrapper {
pointer-events: auto;
animation: none;
}
/* Dark-mode scoring panel */
.v03-scoring-float .scoring-panel {
background: rgba(14,14,22,0.92);
border: 1px solid rgba(64,200,64,0.3);
border-left: 3px solid #40c840;
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
color: #d8d8ec;
width: 280px;
backdrop-filter: blur(6px);
}
.v03-scoring-float .scoring-total { color: #40c840; }
.v03-scoring-float .jan-label { color: #c8c8e0; }
.v03-scoring-float .jan-tag { color: rgba(200,200,224,0.55); background: rgba(255,255,255,0.06); }
.v03-scoring-float .jan-pts { color: #40c840; }
.v03-scoring-float .scoring-collapse-btn { color: rgba(255,255,255,0.3); }
.v03-scoring-float .scoring-collapse-btn:hover { color: rgba(255,255,255,0.65); }
.v03-scoring-float .scoring-expand-btn {
background: rgba(14,14,22,0.9);
border-color: rgba(64,200,64,0.35);
color: #40c840;
}
/* ════════════════════════════════════════════════════════════════════
BOTTOM DOCK — dice · status · actions
════════════════════════════════════════════════════════════════════ */
.v03-dock {
display: flex;
align-items: center;
width: 100%;
background: var(--surface);
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.6rem 1rem;
gap: 0;
}
/* Dice section */
.v03-dock-dice {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
padding-right: 1rem;
border-right: 1px solid var(--surface-border);
}
/* Status + actions section */
.v03-dock-status {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0 1rem;
}
.v03-dock .game-status {
color: #d8d8ec;
text-shadow: none;
font-size: 0.95rem;
padding: 0;
width: auto;
text-align: center;
}
.v03-dock .board-actions {
min-height: 0;
gap: 0.5rem;
}
/* Dark mode buttons */
.v03-dock .btn-primary { background: rgba(64,200,64,0.2); border: 1px solid rgba(64,200,64,0.4); color: #7de87d; }
.v03-dock .btn-primary:hover { background: rgba(64,200,64,0.3); }
.v03-dock .btn-secondary { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); color: rgba(224,224,240,0.7); }
.v03-dock .btn-secondary:hover { background: rgba(255,255,255,0.1); }
/* Free-mode toggle section */
.v03-dock-extra {
flex-shrink: 0;
padding-left: 1rem;
border-left: 1px solid var(--surface-border);
}
.v03-dock .free-mode-toggle {
color: rgba(224,224,240,0.4);
font-size: 0.72rem;
}
.v03-dock .free-mode-help { border-color: rgba(224,224,240,0.2); color: rgba(224,224,240,0.3); }
/* ════════════════════════════════════════════════════════════════════
RESPONSIVE
════════════════════════════════════════════════════════════════════ */
/* Below 900px: dock switches to a 2-row layout */
@media (max-width: 900px) {
.v03-dock {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.v03-dock-dice {
border-right: none;
border-bottom: 1px solid var(--surface-border);
padding-right: 0;
padding-bottom: 0.5rem;
justify-content: center;
}
.v03-dock-extra {
border-left: none;
border-top: 1px solid var(--surface-border);
padding-left: 0;
padding-top: 0.5rem;
text-align: center;
}
}
/* Narrow strip: hide dots on very small screens */
@media (max-width: 680px) {
.v03-dots { display: none; }
.v03-strip-center { padding: 0 0.4rem; }
}
</style>
</head>
<body>
<!-- ── Left navigation sidebar (dark themed) ─────────────────────────── -->
<button class="game-hamburger game-hamburger-open" aria-label="Menu">
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
<div class="game-sidebar game-sidebar-open">
<div class="game-sidebar-header">
<span class="game-sidebar-brand">Trictrac</span>
<div class="lang-switcher">
<button>EN</button>
<button class="lang-active">FR</button>
</div>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"></path></svg>
<a href="#" class="game-sidebar-link">Nouvelle partie</a>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"></path></svg>
<a href="#" class="game-sidebar-link">Se connecter</a>
</div>
<div class="sidebar-footer">
<div><span class="site-nav-version">v0.2.15</span></div>
</div>
</div>
<!-- ── Portal behind the overlay ─────────────────────────────────────── -->
<main>
<div style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh" class="portal-main">
<div class="login-card">
<div class="login-card-header"><div class="login-board-stripe"></div></div>
<div class="login-card-body">
<h1 class="login-title">Trictrac</h1>
<p class="login-subtitle"><em>Une interprétation numérique</em></p>
<div class="login-ornament"></div>
<div class="login-actions">
<button class="login-btn login-btn-secondary">Jouer contre le bot</button>
<button class="login-btn login-btn-primary">Inviter un adversaire</button>
</div>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════════════════
GAME OVERLAY
══════════════════════════════════════════════════════════════════════ -->
<div class="game-overlay">
<div class="game-container">
<!-- ══════════════════════════════════════════════════════════════════
PLAYER STRIP — compact one-row header
══════════════════════════════════════════════════════════════════ -->
<div class="v03-strip">
<!-- Player 1 (me) ───────────────────────────────────────────────── -->
<div class="v03-player">
<div class="v03-avatar v03-avatar-me">A</div>
<div class="v03-name-block">
<span class="v03-name">Anonyme</span>
<span class="v03-you-tag">(vous)</span>
</div>
<div class="v03-dots">
<div class="v03-dot on-me"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div>
</div>
<span class="v03-bred" title="Bredouille armée"></span>
<div class="v03-score">
<span class="v03-score-num">6</span>
<span class="v03-score-max">/12</span>
</div>
</div>
<!-- Center ──────────────────────────────────────────────────────── -->
<div class="v03-strip-center">
<span class="v03-vs">VS</span>
<span class="v03-game-info">jeu en 12 trous</span>
<span class="v03-turn-pill">votre tour</span>
</div>
<!-- Player 2 (opponent) ─────────────────────────────────────────── -->
<div class="v03-player v03-player-right">
<div class="v03-avatar v03-avatar-opp">B</div>
<div class="v03-name-block">
<span class="v03-name">Bot</span>
</div>
<div class="v03-dots">
<div class="v03-dot on-opp"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div><div class="v03-dot"></div>
<div class="v03-dot"></div>
</div>
<div class="v03-score">
<span class="v03-score-num">2</span>
<span class="v03-score-max">/12</span>
</div>
</div>
</div><!-- /.v03-strip -->
<!-- ══════════════════════════════════════════════════════════════════
BOARD WRAP — holds the board + floating scoring panel
══════════════════════════════════════════════════════════════════ -->
<div class="v03-board-wrap">
<!-- Scoring panel: absolutely positioned top-left corner of the board.
Growing tall only overlaps the board, never pushes it down. -->
<div class="v03-scoring-float">
<div class="scoring-panel-wrapper">
<button class="scoring-expand-btn" title="Show scoring">+</button>
<div class="scoring-panel">
<div class="scoring-panel-head">
<div class="scoring-total">+4 pts</div>
<button class="scoring-collapse-btn"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Battage à vrai (petit jan)</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+4</span>
</div>
</div>
</div>
</div>
<!-- Board ────────────────────────────────────────────────────── -->
<div class="board-wrapper">
<div class="board">
<!-- top row ──────────────────────────────────────────────── -->
<div class="board-row top-row">
<div class="board-quarter">
<div class="field zone-opponent corner" id="field-13"><span class="field-num">13</span></div>
<div class="field zone-opponent" id="field-14"><span class="field-num">14</span></div>
<div class="field zone-opponent" id="field-15">
<span class="field-num">15</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div><div class="checker black"></div>
</div>
</div>
<div class="field zone-opponent" id="field-16"><span class="field-num">16</span></div>
<div class="field zone-opponent" id="field-17"><span class="field-num">17</span></div>
<div class="field zone-opponent" id="field-18"><span class="field-num">18</span></div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-retour" id="field-19">
<span class="field-num">19</span>
<div class="hit-ripple hit-ripple-top"></div>
<div class="checker-stack"><div class="checker black"></div></div>
</div>
<div class="field zone-retour" id="field-20"><span class="field-num">20</span></div>
<div class="field zone-retour" id="field-21"><span class="field-num">21</span></div>
<div class="field zone-retour" id="field-22"><span class="field-num">22</span></div>
<div class="field zone-retour point-no-bredouille" id="field-23"><span class="field-num">23</span></div>
<div class="field zone-retour point-no-bredouille" id="field-24">
<span class="field-num">24</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div>
<div class="checker black"></div><div class="checker black">11</div>
</div>
</div>
</div>
</div>
<div class="board-center-bar"></div>
<!-- bottom row ───────────────────────────────────────────── -->
<div class="board-row bot-row">
<div class="board-quarter">
<div class="field zone-grand corner corner-available clickable dest" id="field-12"
title="Coin de repos — must enter and leave with 2 checkers">
<span class="field-num">12</span>
</div>
<div class="field zone-grand clickable dest" id="field-11"><span class="field-num">11</span></div>
<div class="field zone-grand" id="field-10"><span class="field-num">10</span></div>
<div class="field zone-grand" id="field-9">
<span class="field-num">9</span>
<div class="checker-stack"><div class="checker white"></div></div>
</div>
<div class="field zone-grand" id="field-8">
<span class="field-num">8</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
<div class="field zone-grand selected clickable" id="field-7">
<span class="field-num">7</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-petit point-no-bredouille" id="field-6"><span class="field-num">6</span></div>
<div class="field zone-petit point-no-bredouille" id="field-5"><span class="field-num">5</span></div>
<div class="field zone-petit point-no-bredouille" id="field-4"><span class="field-num">4</span></div>
<div class="field zone-petit point-no-bredouille" id="field-3"><span class="field-num">3</span></div>
<div class="field zone-petit point-no-bredouille" id="field-2"><span class="field-num">2</span></div>
<div class="field zone-petit point-no-bredouille" id="field-1">
<span class="field-num">1</span>
<div class="checker-stack">
<div class="checker white">10</div><div class="checker white"></div>
<div class="checker white"></div><div class="checker white"></div>
</div>
</div>
</div>
</div>
<svg style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" width="761" height="388"></svg>
</div><!-- /.board -->
</div><!-- /.board-wrapper -->
</div><!-- /.v03-board-wrap -->
<!-- ══════════════════════════════════════════════════════════════════
BOTTOM DOCK — dice | status + actions | free-mode
══════════════════════════════════════════════════════════════════ -->
<div class="v03-dock">
<!-- Dice ─────────────────────────────────────────────────────── -->
<div class="v03-dock-dice">
<!-- Die 1 — 6 (active) -->
<svg class="die-face" width="52" height="52" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<!-- Die 2 — 4 (used) -->
<svg class="die-face die-used" width="52" height="52" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<!-- Status + actions ─────────────────────────────────────────── -->
<div class="v03-dock-status">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions">
<button class="btn btn-secondary">Passer</button>
</div>
</div>
<!-- Free-mode toggle ─────────────────────────────────────────── -->
<div class="v03-dock-extra">
<label class="free-mode-toggle">
<input type="checkbox">Mode libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div>
</div><!-- /.v03-dock -->
</div><!-- /.game-container -->
</div><!-- /.game-overlay -->
</body>
</html>

View file

@ -1,620 +0,0 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variation 04 — Warm Classic · Scoring en bas</title>
<link rel="stylesheet" href="../snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css">
<style>
/* ══════════════════════════════════════════════════════════════════
Variation 04: v03 layout + warm palette + scoring below board
─────────────────────────────────────────────────────────────────
Differs from 03-dark-modern:
• Warm parchment / wood palette (no dark overrides)
• .scoring-panel moves to a dedicated row BELOW the board
(grows downward → never displaces the board upward)
Light green bg when local player scored,
light red when opponent scored.
• Same compact player strip above the board
• Same bottom dock (dice · status · actions)
══════════════════════════════════════════════════════════════════ */
/* ── Suppress baseline elements ──────────────────────────────────── */
.score-area { display: none !important; }
.board-bar { width: 5px; overflow: hidden; }
.bar-die-slot { display: none; }
.zone-labels-row { display: none; }
.game-bottom-strip { display: none; }
.game-container { gap: 0.5rem; }
/* ════════════════════════════════════════════════════════════════════
PLAYER STRIP — warm palette
════════════════════════════════════════════════════════════════════ */
.v04-strip {
display: flex;
align-items: center;
width: 100%;
background: var(--ui-parchment);
border: 1px solid var(--ui-gold-dark);
border-radius: 5px;
padding: 0.5rem 0.85rem;
gap: 0.6rem;
}
/* Player half */
.v04-player {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.v04-player-right { flex-direction: row-reverse; }
/* Avatar circle */
.v04-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-ui);
font-size: 0.7rem;
font-weight: 600;
flex-shrink: 0;
letter-spacing: 0;
}
.v04-avatar-me {
background: rgba(42,107,60,0.12);
border: 1.5px solid #2a6b3c;
color: #2a6b3c;
}
.v04-avatar-opp {
background: rgba(139,26,26,0.12);
border: 1.5px solid #8b1a1a;
color: #8b1a1a;
}
/* Name */
.v04-name {
font-family: var(--font-ui);
font-size: 0.85rem;
font-weight: 500;
color: var(--ui-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 110px;
}
.v04-you-tag {
font-size: 0.6rem;
color: rgba(58,42,10,0.45);
font-style: italic;
}
.v04-name-block { display: flex; flex-direction: column; min-width: 0; }
.v04-player-right .v04-name-block { align-items: flex-end; }
/* Dot peg track */
.v04-dots {
display: flex;
gap: 2.5px;
flex-shrink: 0;
}
.v04-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgba(58,42,10,0.08);
border: 1px solid rgba(58,42,10,0.18);
flex-shrink: 0;
}
.v04-dot.on-me { background: #2a6b3c; border-color: #1e5029; box-shadow: 0 0 3px rgba(42,107,60,0.4); }
.v04-dot.on-opp { background: #8b1a1a; border-color: #6a1212; box-shadow: 0 0 3px rgba(139,26,26,0.4); }
/* Score */
.v04-score {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.v04-score-num {
font-family: var(--font-display);
font-size: 1.55rem;
font-weight: 600;
color: var(--ui-ink);
display: block;
}
.v04-score-max {
font-family: var(--font-ui);
font-size: 0.58rem;
color: rgba(58,42,10,0.38);
display: block;
margin-top: -3px;
}
/* Bredouille badge */
.v04-bred {
font-size: 0.72rem;
color: var(--ui-gold);
flex-shrink: 0;
}
/* Center: VS + game info + turn pill */
.v04-strip-center {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0 0.85rem;
border-left: 1px solid rgba(58,42,10,0.15);
border-right: 1px solid rgba(58,42,10,0.15);
}
.v04-vs {
font-family: var(--font-ui);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.16em;
color: rgba(58,42,10,0.28);
text-transform: uppercase;
}
.v04-game-info {
font-family: var(--font-ui);
font-size: 0.65rem;
color: rgba(58,42,10,0.28);
white-space: nowrap;
}
.v04-turn-pill {
font-family: var(--font-ui);
font-size: 0.62rem;
font-weight: 500;
color: #2a6b3c;
background: rgba(42,107,60,0.10);
border: 1px solid rgba(42,107,60,0.28);
border-radius: 20px;
padding: 0.1em 0.6em;
white-space: nowrap;
}
/* ════════════════════════════════════════════════════════════════════
SCORING ROW — below the board, in normal flow
Grows downward → board position is never affected.
════════════════════════════════════════════════════════════════════ */
.v04-scoring-row {
width: 100%;
}
/* Override the scoring-panels-container baseline positioning */
.v04-scoring-row .scoring-panels-container {
position: static;
top: auto;
left: auto;
z-index: auto;
display: block;
}
/* ── Scoring panel: light green (local player scored) ─────────────── */
.v04-scoring-row .scoring-panel {
background: #edf7ee;
border: 1px solid #a8d4b0;
border-left: 3px solid #2d7a3c;
box-shadow: 0 2px 8px rgba(42,107,60,0.10);
width: 100%;
box-sizing: border-box;
margin: 0;
}
.v04-scoring-row .scoring-total { color: #1e5829; }
.v04-scoring-row .jan-label { color: #2a3d28; }
.v04-scoring-row .jan-tag { color: rgba(42,80,42,0.6); background: rgba(42,107,60,0.07); }
.v04-scoring-row .jan-pts { color: #1e5829; }
.v04-scoring-row .scoring-collapse-btn { color: rgba(42,80,42,0.4); }
.v04-scoring-row .scoring-collapse-btn:hover { color: rgba(42,80,42,0.75); }
.v04-scoring-row .scoring-expand-btn {
background: #edf7ee;
border-color: #a8d4b0;
color: #2d7a3c;
}
/* ── Opponent scoring panel: light red ────────────────────────────── */
.v04-scoring-row .scoring-panel.opp-scored {
background: #fceaea;
border-color: #dea8a8;
border-left-color: #b52b2b;
box-shadow: 0 2px 8px rgba(139,26,26,0.10);
}
.v04-scoring-row .scoring-panel.opp-scored .scoring-total { color: #7a1e1e; }
.v04-scoring-row .scoring-panel.opp-scored .jan-label { color: #3d2a2a; }
.v04-scoring-row .scoring-panel.opp-scored .jan-tag { color: rgba(100,42,42,0.6); background: rgba(139,26,26,0.07); }
.v04-scoring-row .scoring-panel.opp-scored .jan-pts { color: #7a1e1e; }
.v04-scoring-row .scoring-panel.opp-scored .scoring-collapse-btn { color: rgba(100,42,42,0.4); }
.v04-scoring-row .scoring-panel.opp-scored .scoring-expand-btn {
background: #fceaea;
border-color: #dea8a8;
color: #b52b2b;
}
/* ════════════════════════════════════════════════════════════════════
BOTTOM DOCK — warm palette
════════════════════════════════════════════════════════════════════ */
.v04-dock {
display: flex;
align-items: center;
width: 100%;
background: var(--ui-parchment);
border: 1px solid var(--ui-gold-dark);
border-radius: 5px;
padding: 0.6rem 1rem;
gap: 0;
}
.v04-dock-dice {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
padding-right: 1rem;
border-right: 1px solid rgba(58,42,10,0.15);
}
.v04-dock-status {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0 1rem;
}
.v04-dock .game-status {
text-shadow: none;
font-size: 0.95rem;
padding: 0;
width: auto;
text-align: center;
}
.v04-dock .board-actions {
min-height: 0;
gap: 0.5rem;
}
.v04-dock-extra {
flex-shrink: 0;
padding-left: 1rem;
border-left: 1px solid rgba(58,42,10,0.15);
}
/* ════════════════════════════════════════════════════════════════════
RESPONSIVE
════════════════════════════════════════════════════════════════════ */
@media (max-width: 900px) {
.v04-dock {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.v04-dock-dice {
border-right: none;
border-bottom: 1px solid rgba(58,42,10,0.12);
padding-right: 0;
padding-bottom: 0.5rem;
justify-content: center;
}
.v04-dock-extra {
border-left: none;
border-top: 1px solid rgba(58,42,10,0.12);
padding-left: 0;
padding-top: 0.5rem;
text-align: center;
}
}
@media (max-width: 680px) {
.v04-dots { display: none; }
.v04-strip-center { padding: 0 0.4rem; }
}
</style>
</head>
<body>
<!-- ── Left navigation sidebar ───────────────────────────────────────── -->
<button class="game-hamburger game-hamburger-open" aria-label="Menu">
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
<div class="game-sidebar game-sidebar-open">
<div class="game-sidebar-header">
<span class="game-sidebar-brand">Trictrac</span>
<div class="lang-switcher">
<button>EN</button>
<button class="lang-active">FR</button>
</div>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"></path></svg>
<a href="#" class="game-sidebar-link">Nouvelle partie</a>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"></path></svg>
<a href="#" class="game-sidebar-link">Se connecter</a>
</div>
<div class="sidebar-footer">
<div><span class="site-nav-version">v0.2.15</span></div>
</div>
</div>
<!-- ── Portal behind the overlay ─────────────────────────────────────── -->
<main>
<div style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh" class="portal-main">
<div class="login-card">
<div class="login-card-header"><div class="login-board-stripe"></div></div>
<div class="login-card-body">
<h1 class="login-title">Trictrac</h1>
<p class="login-subtitle"><em>Une interprétation numérique</em></p>
<div class="login-ornament"></div>
<div class="login-actions">
<button class="login-btn login-btn-secondary">Jouer contre le bot</button>
<button class="login-btn login-btn-primary">Inviter un adversaire</button>
</div>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════════════════
GAME OVERLAY
══════════════════════════════════════════════════════════════════════ -->
<div class="game-overlay">
<div class="game-container">
<!-- ════════════════════════════════════════════════════════════════
PLAYER STRIP
════════════════════════════════════════════════════════════════ -->
<div class="v04-strip">
<!-- Player 1 (me) ──────────────────────────────────────────────── -->
<div class="v04-player">
<div class="v04-avatar v04-avatar-me">A</div>
<div class="v04-name-block">
<span class="v04-name">Anonyme</span>
<span class="v04-you-tag">(vous)</span>
</div>
<div class="v04-dots">
<div class="v04-dot on-me"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div>
</div>
<span class="v04-bred" title="Bredouille armée"></span>
<div class="v04-score">
<span class="v04-score-num">6</span>
<span class="v04-score-max">/12</span>
</div>
</div>
<!-- Center ─────────────────────────────────────────────────────── -->
<div class="v04-strip-center">
<span class="v04-vs">VS</span>
<span class="v04-game-info">jeu en 12 trous</span>
<span class="v04-turn-pill">votre tour</span>
</div>
<!-- Player 2 (opponent) ─────────────────────────────────────────── -->
<div class="v04-player v04-player-right">
<div class="v04-avatar v04-avatar-opp">B</div>
<div class="v04-name-block">
<span class="v04-name">Bot</span>
</div>
<div class="v04-dots">
<div class="v04-dot on-opp"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div><div class="v04-dot"></div>
<div class="v04-dot"></div>
</div>
<div class="v04-score">
<span class="v04-score-num">2</span>
<span class="v04-score-max">/12</span>
</div>
</div>
</div><!-- /.v04-strip -->
<!-- ════════════════════════════════════════════════════════════════
BOARD
════════════════════════════════════════════════════════════════ -->
<div class="board-wrapper">
<div class="board">
<!-- top row ──────────────────────────────────────────────────── -->
<div class="board-row top-row">
<div class="board-quarter">
<div class="field zone-opponent corner" id="field-13"><span class="field-num">13</span></div>
<div class="field zone-opponent" id="field-14"><span class="field-num">14</span></div>
<div class="field zone-opponent" id="field-15">
<span class="field-num">15</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div><div class="checker black"></div>
</div>
</div>
<div class="field zone-opponent" id="field-16"><span class="field-num">16</span></div>
<div class="field zone-opponent" id="field-17"><span class="field-num">17</span></div>
<div class="field zone-opponent" id="field-18"><span class="field-num">18</span></div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-retour" id="field-19">
<span class="field-num">19</span>
<div class="hit-ripple hit-ripple-top"></div>
<div class="checker-stack"><div class="checker black"></div></div>
</div>
<div class="field zone-retour" id="field-20"><span class="field-num">20</span></div>
<div class="field zone-retour" id="field-21"><span class="field-num">21</span></div>
<div class="field zone-retour" id="field-22"><span class="field-num">22</span></div>
<div class="field zone-retour point-no-bredouille" id="field-23"><span class="field-num">23</span></div>
<div class="field zone-retour point-no-bredouille" id="field-24">
<span class="field-num">24</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div>
<div class="checker black"></div><div class="checker black">11</div>
</div>
</div>
</div>
</div>
<div class="board-center-bar"></div>
<!-- bottom row ───────────────────────────────────────────────── -->
<div class="board-row bot-row">
<div class="board-quarter">
<div class="field zone-grand corner corner-available clickable dest" id="field-12"
title="Coin de repos — must enter and leave with 2 checkers">
<span class="field-num">12</span>
</div>
<div class="field zone-grand clickable dest" id="field-11"><span class="field-num">11</span></div>
<div class="field zone-grand" id="field-10"><span class="field-num">10</span></div>
<div class="field zone-grand" id="field-9">
<span class="field-num">9</span>
<div class="checker-stack"><div class="checker white"></div></div>
</div>
<div class="field zone-grand" id="field-8">
<span class="field-num">8</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
<div class="field zone-grand selected clickable" id="field-7">
<span class="field-num">7</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-petit point-no-bredouille" id="field-6"><span class="field-num">6</span></div>
<div class="field zone-petit point-no-bredouille" id="field-5"><span class="field-num">5</span></div>
<div class="field zone-petit point-no-bredouille" id="field-4"><span class="field-num">4</span></div>
<div class="field zone-petit point-no-bredouille" id="field-3"><span class="field-num">3</span></div>
<div class="field zone-petit point-no-bredouille" id="field-2"><span class="field-num">2</span></div>
<div class="field zone-petit point-no-bredouille" id="field-1">
<span class="field-num">1</span>
<div class="checker-stack">
<div class="checker white">10</div><div class="checker white"></div>
<div class="checker white"></div><div class="checker white"></div>
</div>
</div>
</div>
</div>
<svg style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" width="761" height="388"></svg>
</div><!-- /.board -->
</div><!-- /.board-wrapper -->
<!-- ════════════════════════════════════════════════════════════════
SCORING ROW — below the board, in normal flow.
Only present when a scoring event occurred this turn.
Green panel = local player scored; red panel = opponent scored.
════════════════════════════════════════════════════════════════ -->
<div class="v04-scoring-row">
<!-- Local player scored (green) -->
<div class="scoring-panels-container">
<div class="scoring-panel-wrapper">
<button class="scoring-expand-btn" title="Afficher le détail">+</button>
<div class="scoring-panel">
<div class="scoring-panel-head">
<div class="scoring-total">+4 pts</div>
<button class="scoring-collapse-btn"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Battage à vrai (petit jan)</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+4</span>
</div>
</div>
</div>
</div>
<!-- Opponent scored (red) — shown when opponent marks points -->
<!--
<div class="scoring-panels-container">
<div class="scoring-panel-wrapper">
<div class="scoring-panel opp-scored">
<div class="scoring-panel-head">
<div class="scoring-total">+2 pts</div>
<button class="scoring-collapse-btn"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Jan de retour</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+2</span>
</div>
</div>
</div>
</div>
-->
</div><!-- /.v04-scoring-row -->
<!-- ════════════════════════════════════════════════════════════════
BOTTOM DOCK — dice | status + actions | free-mode
════════════════════════════════════════════════════════════════ -->
<div class="v04-dock">
<!-- Dice ─────────────────────────────────────────────────────── -->
<div class="v04-dock-dice">
<!-- Die 1 — 6 (active) -->
<svg class="die-face" width="52" height="52" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<!-- Die 2 — 4 (used) -->
<svg class="die-face die-used" width="52" height="52" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<!-- Status + actions ─────────────────────────────────────────── -->
<div class="v04-dock-status">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions">
<button class="btn btn-secondary">Passer</button>
</div>
</div>
<!-- Free-mode toggle ─────────────────────────────────────────── -->
<div class="v04-dock-extra">
<label class="free-mode-toggle">
<input type="checkbox">Mode libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div>
</div><!-- /.v04-dock -->
</div><!-- /.game-container -->
</div><!-- /.game-overlay -->
</body>
</html>

View file

@ -1,582 +0,0 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variation 05 — Warm · Sticky header · Scoring below dock</title>
<link rel="stylesheet" href="../snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css">
<style>
/* ══════════════════════════════════════════════════════════════════
Variation 05 — built on 04, with three layout changes:
1. Player strip: full viewport width, sticky to top edge,
left half right-aligned (me → VS), right half left-aligned
(VS → opp), scores at the extremes.
2. Bottom bar divided: dark-wood dice section (v01 style) |
parchment status+actions section.
3. Scoring row moved below the dock bar.
══════════════════════════════════════════════════════════════════ */
/* ── Suppress baseline elements ──────────────────────────────────── */
.score-area { display: none !important; }
.board-bar { width: 5px; overflow: hidden; }
.bar-die-slot { display: none; }
.zone-labels-row { display: none; }
.game-bottom-strip { display: none; }
.game-container { gap: 0.5rem; }
/* ════════════════════════════════════════════════════════════════════
PLAYER STRIP — full width, sticky, mirror layout
════════════════════════════════════════════════════════════════════ */
.v05-strip {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
width: 100%;
background: var(--ui-parchment);
border-bottom: 2px solid var(--ui-gold-dark);
padding: 0.5rem 0.85rem;
gap: 0.6rem;
box-sizing: border-box;
/* no border-radius: strip spans edge to edge */
}
/* Player half — base */
.v05-player {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
/* Left (me): content right-aligned so avatar sits next to VS */
.v05-player-left { justify-content: flex-end; }
/* Right (opp): content left-aligned so avatar sits next to VS */
.v05-player-right { justify-content: flex-start; }
/* Avatar circle */
.v05-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-ui);
font-size: 0.7rem;
font-weight: 600;
flex-shrink: 0;
}
.v05-avatar-me { background: rgba(42,107,60,0.12); border: 1.5px solid #2a6b3c; color: #2a6b3c; }
.v05-avatar-opp { background: rgba(139,26,26,0.12); border: 1.5px solid #8b1a1a; color: #8b1a1a; }
/* Name block */
.v05-name {
font-family: var(--font-ui);
font-size: 0.85rem;
font-weight: 500;
color: var(--ui-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 110px;
}
.v05-you-tag {
font-size: 0.6rem;
color: rgba(58,42,10,0.45);
font-style: italic;
}
.v05-name-block { display: flex; flex-direction: column; min-width: 0; }
/* Left player: name right-aligned inside its block */
.v05-player-left .v05-name-block { align-items: flex-end; }
/* Dot peg track */
.v05-dots { display: flex; gap: 2.5px; flex-shrink: 0; }
.v05-dot {
width: 9px; height: 9px;
border-radius: 50%;
background: rgba(58,42,10,0.08);
border: 1px solid rgba(58,42,10,0.18);
flex-shrink: 0;
}
.v05-dot.on-me { background: #2a6b3c; border-color: #1e5029; box-shadow: 0 0 3px rgba(42,107,60,0.4); }
.v05-dot.on-opp { background: #8b1a1a; border-color: #6a1212; box-shadow: 0 0 3px rgba(139,26,26,0.4); }
/* Score */
.v05-score { flex-shrink: 0; font-variant-numeric: tabular-nums; line-height: 1; }
.v05-score-num {
font-family: var(--font-display);
font-size: 1.55rem;
font-weight: 600;
color: var(--ui-ink);
display: block;
}
.v05-score-max {
font-family: var(--font-ui);
font-size: 0.58rem;
color: rgba(58,42,10,0.38);
display: block;
margin-top: -3px;
}
/* Right-align score digits under left player */
.v05-player-left .v05-score { text-align: right; }
/* Bredouille badge */
.v05-bred { font-size: 0.72rem; color: var(--ui-gold); flex-shrink: 0; }
/* Center column: VS + game info + turn pill */
.v05-strip-center {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0 0.85rem;
border-left: 1px solid rgba(58,42,10,0.15);
border-right: 1px solid rgba(58,42,10,0.15);
}
.v05-vs {
font-family: var(--font-ui);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.16em;
color: rgba(58,42,10,0.28);
text-transform: uppercase;
}
.v05-game-info {
font-family: var(--font-ui);
font-size: 0.65rem;
color: rgba(58,42,10,0.28);
white-space: nowrap;
}
.v05-turn-pill {
font-family: var(--font-ui);
font-size: 0.62rem;
font-weight: 500;
color: #2a6b3c;
background: rgba(42,107,60,0.10);
border: 1px solid rgba(42,107,60,0.28);
border-radius: 20px;
padding: 0.1em 0.6em;
white-space: nowrap;
}
/* ════════════════════════════════════════════════════════════════════
BOTTOM DOCK — two-section bar: dark wood dice | parchment status
════════════════════════════════════════════════════════════════════ */
.v05-dock {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid var(--ui-gold-dark);
border-radius: 5px;
overflow: hidden; /* clips children corners */
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
}
/* ── Dice section: board-rail dark wood (same as v01 sidebar-dice-panel) */
.v05-dock-dice {
background: var(--board-rail);
border-top: 2px solid var(--ui-gold-dark);
padding: 0.65rem 1rem 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.v05-dock-dice-row {
display: flex;
gap: 0.55rem;
align-items: center;
}
/* Free-mode toggle on dark background (matches v01 sidebar treatment) */
.v05-dock-dice .free-mode-toggle {
color: var(--ui-parchment);
font-size: 0.7rem;
flex-wrap: wrap;
justify-content: center;
text-align: center;
gap: 0.3rem;
}
.v05-dock-dice .free-mode-help {
border-color: rgba(242,232,208,0.35);
color: rgba(242,232,208,0.5);
}
/* ── Status + actions section: parchment */
.v05-dock-main {
background: var(--ui-parchment);
border-top: 2px solid var(--ui-gold-dark);
border-left: 1px solid rgba(58,42,10,0.18);
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.65rem 1rem 0.75rem;
}
.v05-dock-main .game-status {
text-shadow: none;
font-size: 0.95rem;
padding: 0;
width: auto;
text-align: center;
}
.v05-dock-main .board-actions {
min-height: 0;
gap: 0.5rem;
}
/* ════════════════════════════════════════════════════════════════════
SCORING ROW — below the dock, in normal flow
════════════════════════════════════════════════════════════════════ */
.v05-scoring-row {
width: 100%;
}
.v05-scoring-row .scoring-panels-container {
position: static;
top: auto; left: auto;
z-index: auto;
display: block;
}
/* Green panel: local player scored */
.v05-scoring-row .scoring-panel {
background: #edf7ee;
border: 1px solid #a8d4b0;
border-left: 3px solid #2d7a3c;
box-shadow: 0 2px 8px rgba(42,107,60,0.10);
width: 100%;
box-sizing: border-box;
margin: 0;
}
.v05-scoring-row .scoring-total { color: #1e5829; }
.v05-scoring-row .jan-label { color: #2a3d28; }
.v05-scoring-row .jan-tag { color: rgba(42,80,42,0.6); background: rgba(42,107,60,0.07); }
.v05-scoring-row .jan-pts { color: #1e5829; }
.v05-scoring-row .scoring-collapse-btn { color: rgba(42,80,42,0.4); }
.v05-scoring-row .scoring-collapse-btn:hover { color: rgba(42,80,42,0.75); }
.v05-scoring-row .scoring-expand-btn { background: #edf7ee; border-color: #a8d4b0; color: #2d7a3c; }
/* Red panel: opponent scored */
.v05-scoring-row .scoring-panel.opp-scored {
background: #fceaea;
border-color: #dea8a8;
border-left-color: #b52b2b;
}
.v05-scoring-row .scoring-panel.opp-scored .scoring-total { color: #7a1e1e; }
.v05-scoring-row .scoring-panel.opp-scored .jan-label { color: #3d2a2a; }
.v05-scoring-row .scoring-panel.opp-scored .jan-tag { color: rgba(100,42,42,0.6); background: rgba(139,26,26,0.07); }
.v05-scoring-row .scoring-panel.opp-scored .jan-pts { color: #7a1e1e; }
/* ════════════════════════════════════════════════════════════════════
RESPONSIVE
════════════════════════════════════════════════════════════════════ */
@media (max-width: 900px) {
.v05-dock { flex-direction: column; }
.v05-dock-dice {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
border-top: none;
border-bottom: 2px solid var(--ui-gold-dark);
border-left: none;
}
.v05-dock-main { border-left: none; border-top: none; }
}
@media (max-width: 680px) {
.v05-dots { display: none; }
.v05-strip-center { padding: 0 0.4rem; }
}
</style>
</head>
<body>
<!-- ── Left navigation sidebar ───────────────────────────────────────── -->
<button class="game-hamburger game-hamburger-open" aria-label="Menu">
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
<div class="game-sidebar game-sidebar-open">
<div class="game-sidebar-header">
<span class="game-sidebar-brand">Trictrac</span>
<div class="lang-switcher">
<button>EN</button>
<button class="lang-active">FR</button>
</div>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"></path></svg>
<a href="#" class="game-sidebar-link">Nouvelle partie</a>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"></path></svg>
<a href="#" class="game-sidebar-link">Se connecter</a>
</div>
<div class="sidebar-footer">
<div><span class="site-nav-version">v0.2.15</span></div>
</div>
</div>
<!-- ── Portal behind the overlay ─────────────────────────────────────── -->
<main>
<div style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh" class="portal-main">
<div class="login-card">
<div class="login-card-header"><div class="login-board-stripe"></div></div>
<div class="login-card-body">
<h1 class="login-title">Trictrac</h1>
<p class="login-subtitle"><em>Une interprétation numérique</em></p>
<div class="login-ornament"></div>
<div class="login-actions">
<button class="login-btn login-btn-secondary">Jouer contre le bot</button>
<button class="login-btn login-btn-primary">Inviter un adversaire</button>
</div>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════════════════
GAME OVERLAY
══════════════════════════════════════════════════════════════════════ -->
<div class="game-overlay">
<div class="game-container">
<!-- ════════════════════════════════════════════════════════════════
PLAYER STRIP — sticky, full width, mirror layout
Left half: [score] [⚜] [dots] [name] [avatar] → right-aligned
Right half: [avatar] [name] [dots] [score] → left-aligned
════════════════════════════════════════════════════════════════ -->
<div class="v05-strip">
<!-- Left: me, right-aligned toward VS ──────────────────────────── -->
<div class="v05-player v05-player-left">
<div class="v05-score">
<span class="v05-score-num">6</span>
<span class="v05-score-max">/12</span>
</div>
<span class="v05-bred" title="Bredouille armée"></span>
<div class="v05-dots">
<div class="v05-dot on-me"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div>
</div>
<div class="v05-name-block">
<span class="v05-name">Anonyme</span>
<span class="v05-you-tag">(vous)</span>
</div>
<div class="v05-avatar v05-avatar-me">A</div>
</div>
<!-- Center ─────────────────────────────────────────────────────── -->
<div class="v05-strip-center">
<span class="v05-vs">VS</span>
<span class="v05-game-info">jeu en 12 trous</span>
<span class="v05-turn-pill">votre tour</span>
</div>
<!-- Right: opp, left-aligned from VS ───────────────────────────── -->
<div class="v05-player v05-player-right">
<div class="v05-avatar v05-avatar-opp">B</div>
<div class="v05-name-block">
<span class="v05-name">Bot</span>
</div>
<div class="v05-dots">
<div class="v05-dot on-opp"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div><div class="v05-dot"></div>
<div class="v05-dot"></div>
</div>
<div class="v05-score">
<span class="v05-score-num">2</span>
<span class="v05-score-max">/12</span>
</div>
</div>
</div><!-- /.v05-strip -->
<!-- ════════════════════════════════════════════════════════════════
BOARD
════════════════════════════════════════════════════════════════ -->
<div class="board-wrapper">
<div class="board">
<!-- top row ──────────────────────────────────────────────────── -->
<div class="board-row top-row">
<div class="board-quarter">
<div class="field zone-opponent corner" id="field-13"><span class="field-num">13</span></div>
<div class="field zone-opponent" id="field-14"><span class="field-num">14</span></div>
<div class="field zone-opponent" id="field-15">
<span class="field-num">15</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div><div class="checker black"></div>
</div>
</div>
<div class="field zone-opponent" id="field-16"><span class="field-num">16</span></div>
<div class="field zone-opponent" id="field-17"><span class="field-num">17</span></div>
<div class="field zone-opponent" id="field-18"><span class="field-num">18</span></div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-retour" id="field-19">
<span class="field-num">19</span>
<div class="hit-ripple hit-ripple-top"></div>
<div class="checker-stack"><div class="checker black"></div></div>
</div>
<div class="field zone-retour" id="field-20"><span class="field-num">20</span></div>
<div class="field zone-retour" id="field-21"><span class="field-num">21</span></div>
<div class="field zone-retour" id="field-22"><span class="field-num">22</span></div>
<div class="field zone-retour point-no-bredouille" id="field-23"><span class="field-num">23</span></div>
<div class="field zone-retour point-no-bredouille" id="field-24">
<span class="field-num">24</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div>
<div class="checker black"></div><div class="checker black">11</div>
</div>
</div>
</div>
</div>
<div class="board-center-bar"></div>
<!-- bottom row ───────────────────────────────────────────────── -->
<div class="board-row bot-row">
<div class="board-quarter">
<div class="field zone-grand corner corner-available clickable dest" id="field-12"
title="Coin de repos — must enter and leave with 2 checkers">
<span class="field-num">12</span>
</div>
<div class="field zone-grand clickable dest" id="field-11"><span class="field-num">11</span></div>
<div class="field zone-grand" id="field-10"><span class="field-num">10</span></div>
<div class="field zone-grand" id="field-9">
<span class="field-num">9</span>
<div class="checker-stack"><div class="checker white"></div></div>
</div>
<div class="field zone-grand" id="field-8">
<span class="field-num">8</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
<div class="field zone-grand selected clickable" id="field-7">
<span class="field-num">7</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-petit point-no-bredouille" id="field-6"><span class="field-num">6</span></div>
<div class="field zone-petit point-no-bredouille" id="field-5"><span class="field-num">5</span></div>
<div class="field zone-petit point-no-bredouille" id="field-4"><span class="field-num">4</span></div>
<div class="field zone-petit point-no-bredouille" id="field-3"><span class="field-num">3</span></div>
<div class="field zone-petit point-no-bredouille" id="field-2"><span class="field-num">2</span></div>
<div class="field zone-petit point-no-bredouille" id="field-1">
<span class="field-num">1</span>
<div class="checker-stack">
<div class="checker white">10</div><div class="checker white"></div>
<div class="checker white"></div><div class="checker white"></div>
</div>
</div>
</div>
</div>
<svg style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" width="761" height="388"></svg>
</div><!-- /.board -->
</div><!-- /.board-wrapper -->
<!-- ════════════════════════════════════════════════════════════════
BOTTOM DOCK — [dark wood: dice] | [parchment: status + actions]
════════════════════════════════════════════════════════════════ -->
<div class="v05-dock">
<!-- Dark wood dice section (v01 sidebar-dice-panel aesthetic) ── -->
<div class="v05-dock-dice">
<div class="v05-dock-dice-row">
<!-- Die 1 — 6 (active) -->
<svg class="die-face" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<!-- Die 2 — 4 (used) -->
<svg class="die-face die-used" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<label class="free-mode-toggle">
<input type="checkbox">Mode libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div>
<!-- Parchment status + actions section ──────────────────────── -->
<div class="v05-dock-main">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions">
<button class="btn btn-secondary">Passer</button>
</div>
</div>
</div><!-- /.v05-dock -->
<!-- ════════════════════════════════════════════════════════════════
SCORING ROW — below the dock
════════════════════════════════════════════════════════════════ -->
<div class="v05-scoring-row">
<div class="scoring-panels-container">
<div class="scoring-panel-wrapper">
<button class="scoring-expand-btn" title="Afficher le détail">+</button>
<div class="scoring-panel">
<div class="scoring-panel-head">
<div class="scoring-total">+4 pts</div>
<button class="scoring-collapse-btn"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Battage à vrai (petit jan)</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+4</span>
</div>
</div>
</div>
</div>
</div><!-- /.v05-scoring-row -->
</div><!-- /.game-container -->
</div><!-- /.game-overlay -->
</body>
</html>

View file

@ -1,571 +0,0 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variation 06 — Wide fixed header · Scoring below dock</title>
<link rel="stylesheet" href="../snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css">
<style>
/* ══════════════════════════════════════════════════════════════════
Variation 06 — from 05, with:
1. Player strip: position:fixed, true full viewport width,
touches top edge. Element order from VS center outward:
pts-counter | peg-track | player-name | avatar circle.
Uses v01 HTML/style for pts-counter-wrap, peg-track, name.
2. Bottom bar: dark-wood dice (v01 palette) | parchment status.
3. Scoring row below the dock.
══════════════════════════════════════════════════════════════════ */
/* ── Suppress baseline elements ──────────────────────────────────── */
.score-area { display: none !important; }
.board-bar { width: 5px; overflow: hidden; }
.bar-die-slot { display: none; }
.zone-labels-row { display: none; }
.game-bottom-strip { display: none; }
/* Push overlay content below the fixed strip (~54px strip height) */
.game-overlay { padding-top: calc(1.5rem + 54px) !important; }
.game-container { gap: 0.5rem; }
/* ════════════════════════════════════════════════════════════════════
PLAYER STRIP — fixed, true full-width
════════════════════════════════════════════════════════════════════ */
.v06-strip {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 300;
display: flex;
align-items: center;
background: var(--ui-parchment);
border-bottom: 2px solid var(--ui-gold-dark);
padding: 0.35rem 1.25rem;
gap: 0.5rem;
}
/* ── Player half ── */
.v06-player {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
/* Left (me): group pushed RIGHT toward VS → last DOM item = pts = closest to VS */
.v06-player-left { justify-content: flex-end; }
/* Right (opp): group pushed LEFT toward VS → first DOM item = pts = closest to VS */
.v06-player-right { justify-content: flex-start; }
/* ── Avatar circle ── */
.v06-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-ui);
font-size: 0.7rem;
font-weight: 600;
flex-shrink: 0;
}
.v06-avatar-me { background: rgba(42,107,60,0.12); border: 1.5px solid #2a6b3c; color: #2a6b3c; }
.v06-avatar-opp { background: rgba(139,26,26,0.12); border: 1.5px solid #8b1a1a; color: #8b1a1a; }
/* ── Peg holes: same colour logic as merged-score-panel ── */
.v06-strip .peg-track { gap: 3px; }
.v06-strip .peg-hole { width: 12px; height: 12px; }
.v06-strip .peg-hole.filled {
background: #5aab38;
border-color: #3a7828;
box-shadow: 0 0 5px rgba(90,171,56,0.55);
}
.v06-strip .peg-hole.peg-opp.filled {
background: #c05030;
border-color: #8a3018;
box-shadow: 0 0 5px rgba(192,80,48,0.55);
}
/* ── Name block: don't constrain to fixed 120px in the strip ── */
.v06-strip .score-row-name { width: auto; }
/* ── Center column: VS + info + turn pill ── */
.v06-strip-center {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0 0.85rem;
border-left: 1px solid rgba(58,42,10,0.15);
border-right: 1px solid rgba(58,42,10,0.15);
}
.v06-vs {
font-family: var(--font-ui);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.16em;
color: rgba(58,42,10,0.28);
text-transform: uppercase;
}
.v06-game-info {
font-family: var(--font-ui);
font-size: 0.65rem;
color: rgba(58,42,10,0.28);
white-space: nowrap;
}
.v06-turn-pill {
font-family: var(--font-ui);
font-size: 0.62rem;
font-weight: 500;
color: #2a6b3c;
background: rgba(42,107,60,0.10);
border: 1px solid rgba(42,107,60,0.28);
border-radius: 20px;
padding: 0.1em 0.6em;
white-space: nowrap;
}
/* ════════════════════════════════════════════════════════════════════
BOTTOM DOCK — [dark wood: dice] | [parchment: status + actions]
════════════════════════════════════════════════════════════════════ */
.v06-dock {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid var(--ui-gold-dark);
border-radius: 5px;
overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
}
/* Dark wood dice section — matches v01 .sidebar-dice-panel */
.v06-dock-dice {
background: var(--board-rail);
border-top: 2px solid var(--ui-gold-dark);
padding: 0.65rem 1rem 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.v06-dock-dice-row { display: flex; gap: 0.55rem; align-items: center; }
.v06-dock-dice .free-mode-toggle {
color: var(--ui-parchment);
font-size: 0.7rem;
flex-wrap: wrap;
justify-content: center;
text-align: center;
gap: 0.3rem;
}
.v06-dock-dice .free-mode-help {
border-color: rgba(242,232,208,0.35);
color: rgba(242,232,208,0.5);
}
/* Parchment status + actions section */
.v06-dock-main {
background: var(--ui-parchment);
border-top: 2px solid var(--ui-gold-dark);
border-left: 1px solid rgba(58,42,10,0.18);
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.65rem 1rem 0.75rem;
}
.v06-dock-main .game-status {
text-shadow: none;
font-size: 0.95rem;
padding: 0;
width: auto;
text-align: center;
}
.v06-dock-main .board-actions { min-height: 0; gap: 0.5rem; }
/* ════════════════════════════════════════════════════════════════════
SCORING ROW — below the dock
════════════════════════════════════════════════════════════════════ */
.v06-scoring-row { width: 100%; }
.v06-scoring-row .scoring-panels-container {
position: static;
top: auto; left: auto; z-index: auto;
display: block;
}
/* Green: local player scored */
.v06-scoring-row .scoring-panel {
background: #edf7ee;
border: 1px solid #a8d4b0;
border-left: 3px solid #2d7a3c;
box-shadow: 0 2px 8px rgba(42,107,60,0.10);
width: 100%;
box-sizing: border-box;
margin: 0;
}
.v06-scoring-row .scoring-total { color: #1e5829; }
.v06-scoring-row .jan-label { color: #2a3d28; }
.v06-scoring-row .jan-tag { color: rgba(42,80,42,0.6); background: rgba(42,107,60,0.07); }
.v06-scoring-row .jan-pts { color: #1e5829; }
.v06-scoring-row .scoring-collapse-btn { color: rgba(42,80,42,0.4); }
.v06-scoring-row .scoring-expand-btn { background: #edf7ee; border-color: #a8d4b0; color: #2d7a3c; }
/* Red: opponent scored */
.v06-scoring-row .scoring-panel.opp-scored {
background: #fceaea;
border-color: #dea8a8;
border-left-color: #b52b2b;
}
.v06-scoring-row .scoring-panel.opp-scored .scoring-total { color: #7a1e1e; }
.v06-scoring-row .scoring-panel.opp-scored .jan-pts { color: #7a1e1e; }
/* ════════════════════════════════════════════════════════════════════
RESPONSIVE
════════════════════════════════════════════════════════════════════ */
@media (max-width: 900px) {
.v06-dock { flex-direction: column; }
.v06-dock-dice {
flex-direction: row; flex-wrap: wrap; justify-content: center;
border-top: none;
border-bottom: 2px solid var(--ui-gold-dark);
border-left: none;
}
.v06-dock-main { border-left: none; border-top: none; }
}
@media (max-width: 760px) {
.v06-strip .peg-track { display: none; }
.v06-strip-center { padding: 0 0.4rem; }
}
</style>
</head>
<body>
<!-- ── Left navigation sidebar ───────────────────────────────────────── -->
<button class="game-hamburger game-hamburger-open" aria-label="Menu">
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
<div class="game-sidebar game-sidebar-open">
<div class="game-sidebar-header">
<span class="game-sidebar-brand">Trictrac</span>
<div class="lang-switcher">
<button>EN</button>
<button class="lang-active">FR</button>
</div>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"></path></svg>
<a href="#" class="game-sidebar-link">Nouvelle partie</a>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"></path></svg>
<a href="#" class="game-sidebar-link">Se connecter</a>
</div>
<div class="sidebar-footer">
<div><span class="site-nav-version">v0.2.15</span></div>
</div>
</div>
<!-- ── Portal behind the overlay ─────────────────────────────────────── -->
<main>
<div style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh" class="portal-main">
<div class="login-card">
<div class="login-card-header"><div class="login-board-stripe"></div></div>
<div class="login-card-body">
<h1 class="login-title">Trictrac</h1>
<p class="login-subtitle"><em>Une interprétation numérique</em></p>
<div class="login-ornament"></div>
<div class="login-actions">
<button class="login-btn login-btn-secondary">Jouer contre le bot</button>
<button class="login-btn login-btn-primary">Inviter un adversaire</button>
</div>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════════════════
GAME OVERLAY
══════════════════════════════════════════════════════════════════════ -->
<div class="game-overlay">
<div class="game-container">
<!-- ════════════════════════════════════════════════════════════════
PLAYER STRIP — fixed, full viewport width, touches top edge.
Left half (me): DOM → [avatar][name][pegs][pts]
justify-content: flex-end
Visual: …[avatar][name][pegs][pts]|VS
pts closest to VS, avatar at far left.
Right half (opp): DOM → [pts][pegs][name][avatar]
justify-content: flex-start
Visual: VS|[pts][pegs][name][avatar]…
pts closest to VS, avatar at far right.
════════════════════════════════════════════════════════════════ -->
<div class="v06-strip">
<!-- Left: me, right-aligned ────────────────────────────────────── -->
<div class="v06-player v06-player-left">
<!-- 4. Avatar — outermost (leftmost) -->
<div class="v06-avatar v06-avatar-me">A</div>
<!-- 3. Player name + (vous) -->
<div class="score-row-name">
<span class="player-name">Anonyme</span>
<span class="you-tag">(vous)</span>
</div>
<!-- 2. Peg hole track — 6 filled of 12 -->
<div class="peg-track">
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole"></div>
<div class="peg-hole"></div>
<div class="peg-hole"></div>
<div class="peg-hole"></div>
<div class="peg-hole"></div>
<div class="peg-hole"></div>
</div>
<!-- 1. Points counter — closest to VS (rightmost) -->
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div style="width:50%" class="pts-ghost-bar-fill"></div>
</div>
<div class="pts-counter-row">
<span class="pts-counter">6</span>
<span class="pts-max">/12</span>
</div>
</div>
</div><!-- /.v06-player-left -->
<!-- Center ─────────────────────────────────────────────────────── -->
<div class="v06-strip-center">
<span class="v06-vs">VS</span>
<span class="v06-game-info">jeu en 12 trous</span>
<span class="v06-turn-pill">votre tour</span>
</div>
<!-- Right: opp, left-aligned ───────────────────────────────────── -->
<div class="v06-player v06-player-right">
<!-- 1. Points counter — closest to VS (leftmost) -->
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div style="width:16%" class="pts-ghost-bar-fill pts-ghost-bar-opp"></div>
</div>
<div class="pts-counter-row">
<span class="pts-counter">2</span>
<span class="pts-max">/12</span>
</div>
</div>
<!-- 2. Peg hole track — 2 filled of 12 -->
<div class="peg-track">
<div class="peg-hole peg-opp filled"></div>
<div class="peg-hole peg-opp filled"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div>
</div>
<!-- 3. Player name -->
<div class="score-row-name">
<span class="player-name">Bot</span>
</div>
<!-- 4. Avatar — outermost (rightmost) -->
<div class="v06-avatar v06-avatar-opp">B</div>
</div><!-- /.v06-player-right -->
</div><!-- /.v06-strip -->
<!-- ════════════════════════════════════════════════════════════════
BOARD
════════════════════════════════════════════════════════════════ -->
<div class="board-wrapper">
<div class="board">
<!-- top row ──────────────────────────────────────────────────── -->
<div class="board-row top-row">
<div class="board-quarter">
<div class="field zone-opponent corner" id="field-13"><span class="field-num">13</span></div>
<div class="field zone-opponent" id="field-14"><span class="field-num">14</span></div>
<div class="field zone-opponent" id="field-15">
<span class="field-num">15</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div><div class="checker black"></div>
</div>
</div>
<div class="field zone-opponent" id="field-16"><span class="field-num">16</span></div>
<div class="field zone-opponent" id="field-17"><span class="field-num">17</span></div>
<div class="field zone-opponent" id="field-18"><span class="field-num">18</span></div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-retour" id="field-19">
<span class="field-num">19</span>
<div class="hit-ripple hit-ripple-top"></div>
<div class="checker-stack"><div class="checker black"></div></div>
</div>
<div class="field zone-retour" id="field-20"><span class="field-num">20</span></div>
<div class="field zone-retour" id="field-21"><span class="field-num">21</span></div>
<div class="field zone-retour" id="field-22"><span class="field-num">22</span></div>
<div class="field zone-retour point-no-bredouille" id="field-23"><span class="field-num">23</span></div>
<div class="field zone-retour point-no-bredouille" id="field-24">
<span class="field-num">24</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div>
<div class="checker black"></div><div class="checker black">11</div>
</div>
</div>
</div>
</div>
<div class="board-center-bar"></div>
<!-- bottom row ───────────────────────────────────────────────── -->
<div class="board-row bot-row">
<div class="board-quarter">
<div class="field zone-grand corner corner-available clickable dest" id="field-12"
title="Coin de repos — must enter and leave with 2 checkers">
<span class="field-num">12</span>
</div>
<div class="field zone-grand clickable dest" id="field-11"><span class="field-num">11</span></div>
<div class="field zone-grand" id="field-10"><span class="field-num">10</span></div>
<div class="field zone-grand" id="field-9">
<span class="field-num">9</span>
<div class="checker-stack"><div class="checker white"></div></div>
</div>
<div class="field zone-grand" id="field-8">
<span class="field-num">8</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
<div class="field zone-grand selected clickable" id="field-7">
<span class="field-num">7</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-petit point-no-bredouille" id="field-6"><span class="field-num">6</span></div>
<div class="field zone-petit point-no-bredouille" id="field-5"><span class="field-num">5</span></div>
<div class="field zone-petit point-no-bredouille" id="field-4"><span class="field-num">4</span></div>
<div class="field zone-petit point-no-bredouille" id="field-3"><span class="field-num">3</span></div>
<div class="field zone-petit point-no-bredouille" id="field-2"><span class="field-num">2</span></div>
<div class="field zone-petit point-no-bredouille" id="field-1">
<span class="field-num">1</span>
<div class="checker-stack">
<div class="checker white">10</div><div class="checker white"></div>
<div class="checker white"></div><div class="checker white"></div>
</div>
</div>
</div>
</div>
<svg style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" width="761" height="388"></svg>
</div><!-- /.board -->
</div><!-- /.board-wrapper -->
<!-- ════════════════════════════════════════════════════════════════
BOTTOM DOCK — [dark wood: dice] | [parchment: status + actions]
════════════════════════════════════════════════════════════════ -->
<div class="v06-dock">
<!-- Dark wood dice section -->
<div class="v06-dock-dice">
<div class="v06-dock-dice-row">
<svg class="die-face" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<svg class="die-face die-used" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<label class="free-mode-toggle">
<input type="checkbox">Mode libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div>
<!-- Parchment status + actions section -->
<div class="v06-dock-main">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions">
<button class="btn btn-secondary">Passer</button>
</div>
</div>
</div><!-- /.v06-dock -->
<!-- ════════════════════════════════════════════════════════════════
SCORING ROW — below the dock
════════════════════════════════════════════════════════════════ -->
<div class="v06-scoring-row">
<div class="scoring-panels-container">
<div class="scoring-panel-wrapper">
<button class="scoring-expand-btn" title="Afficher le détail">+</button>
<div class="scoring-panel">
<div class="scoring-panel-head">
<div class="scoring-total">+4 pts</div>
<button class="scoring-collapse-btn"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Battage à vrai (petit jan)</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+4</span>
</div>
</div>
</div>
</div>
</div><!-- /.v06-scoring-row -->
</div><!-- /.game-container -->
</div><!-- /.game-overlay -->
</body>
</html>

View file

@ -1,711 +0,0 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variation 07 — Scrolling header · Responsive sidebar/footer</title>
<link rel="stylesheet" href="../snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css">
<style>
/* ══════════════════════════════════════════════════════════════════
Variation 07 — from 06, with:
1. Strip scrolls with the page (in-flow, not fixed).
Breaks out of game-overlay padding via 100vw + margin trick.
2. Center: "Trictrac" title only (no VS / game-info / turn).
3. No progress bar under the score counter.
4. Circles are 38 px, styled as real white/black checkers.
5. Active player gets a slightly darker rounded background
spanning from circle to score counter.
6. Controls area styled like v02: separate rounded cards,
0.5rem gap, game-status color: var(--ui-ink).
7. Responsive: ≥920px → dice+status in right sidebar column;
<920px dice+status in a bottom footer bar.
══════════════════════════════════════════════════════════════════ */
/* ── Suppress baseline elements ──────────────────────────────────── */
.score-area { display: none !important; }
.board-bar { width: 5px; overflow: hidden; }
.bar-die-slot { display: none; }
.zone-labels-row { display: none; }
.game-bottom-strip { display: none; }
/* Allow strip to extend beyond padded overlay without scrollbar */
.game-overlay { overflow-x: hidden !important; }
.game-container { gap: 0.5rem; }
/* ════════════════════════════════════════════════════════════════════
PLAYER STRIP — in-flow, full viewport width, touches top edge.
The overlay has position:fixed; inset:0; padding:1.5rem so the
game-container starts 1.5rem below the viewport top.
margin-top: -1.5rem → pulls strip flush to viewport top
width: 100vw → fills the full viewport width
margin-left: calc(50% - 50vw)
50% = half of the game-container's content width (centered)
50vw = half of the viewport width
→ net offset puts the left edge at the viewport left edge
════════════════════════════════════════════════════════════════════ */
.v07-strip {
width: 100vw;
margin-top: -1.5rem;
margin-left: calc(50% - 50vw);
/* stays in normal flex flow — scrolls with the overlay */
display: flex;
align-items: center;
background: var(--ui-parchment);
border-bottom: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
padding: 0.35rem 1.5rem;
gap: 0.5rem;
}
/* ── Player halves ── */
.v07-player {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
/* Left (me): push active-zone rightward toward VS */
.v07-player-left { justify-content: flex-end; }
/* Right (opp): push active-zone leftward toward VS */
.v07-player-right { justify-content: flex-start; }
/* ── Active zone: inner container that receives the highlight ── */
.v07-active-zone {
display: flex;
align-items: center;
gap: 0.7rem;
border-radius: 8px;
padding: 0.28rem 0.5rem;
}
.v07-active-zone.active {
background: rgba(58,42,10,0.08);
}
/* ── Checker-style avatar circles ── */
.v07-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-ui);
font-size: 0.78rem;
font-weight: 600;
flex-shrink: 0;
}
/* White checker gradient (me) */
.v07-avatar-me {
background-image:
radial-gradient(ellipse 50% 35% at 36% 30%,
rgba(255,255,255,0.65) 0%, transparent 100%),
radial-gradient(circle,
transparent 68%, rgba(160,130,70,0.22) 68.5%,
rgba(160,130,70,0.22) 71.5%, transparent 72%),
radial-gradient(circle,
transparent 43%, rgba(160,130,70,0.17) 43.5%,
rgba(160,130,70,0.17) 46.5%, transparent 47%),
radial-gradient(circle at 38% 32%,
#ffffff 0%, #f5edd8 52%, #c0b288 100%);
border: 1.8px solid #c8a448;
box-shadow: 0 2px 6px rgba(0,0,0,0.35), inset 0 -1px 3px rgba(0,0,0,0.15);
color: #443322;
}
/* Black checker gradient (opp) */
.v07-avatar-opp {
background-image:
radial-gradient(ellipse 40% 28% at 36% 30%,
rgba(110,65,30,0.38) 0%, transparent 100%),
radial-gradient(circle,
transparent 68%, rgba(200,164,72,0.18) 68.5%,
rgba(200,164,72,0.18) 71.5%, transparent 72%),
radial-gradient(circle,
transparent 43%, rgba(200,164,72,0.13) 43.5%,
rgba(200,164,72,0.13) 46.5%, transparent 47%),
radial-gradient(circle at 38% 32%,
#4a2e1a 0%, #1c1008 45%, #1a0f06 100%);
border: 1.8px solid #c8a448;
box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 -1px 3px rgba(0,0,0,0.4);
color: #c8b898;
}
/* ── Peg holes inside the strip ── */
.v07-strip .peg-track { gap: 3px; }
.v07-strip .peg-hole { width: 12px; height: 12px; }
.v07-strip .peg-hole.filled {
background: #5aab38; border-color: #3a7828;
box-shadow: 0 0 5px rgba(90,171,56,0.55);
}
.v07-strip .peg-hole.peg-opp.filled {
background: #c05030; border-color: #8a3018;
box-shadow: 0 0 5px rgba(192,80,48,0.55);
}
/* ── Name block: auto width (not the 120px baseline) ── */
.v07-strip .score-row-name { width: auto; }
/* ── Score counter: no ghost bar ── */
.v07-strip .pts-counter-wrap { padding-bottom: 0; }
/* ── Center: "Trictrac" title only ── */
.v07-strip-center {
flex-shrink: 0;
padding: 0 1rem;
border-left: 1px solid rgba(138,106,40,0.2);
border-right: 1px solid rgba(138,106,40,0.2);
}
.v07-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 600;
font-style: italic;
color: var(--ui-ink);
letter-spacing: 0.03em;
white-space: nowrap;
}
/* ════════════════════════════════════════════════════════════════════
BODY — flex row: [board-wrapper] [sidebar]
════════════════════════════════════════════════════════════════════ */
.v07-body {
display: flex;
align-items: flex-start;
gap: 0.5rem;
width: 100%;
}
/* ════════════════════════════════════════════════════════════════════
RIGHT SIDEBAR (wide ≥920px) — matches v02 .game-right-sidebar style
════════════════════════════════════════════════════════════════════ */
.v07-sidebar {
width: 152px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: stretch;
}
/* Dark-wood dice card */
.v07-sidebar-dice {
background: var(--board-rail);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
padding: 0.6rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.v07-sidebar-dice-row {
display: flex;
gap: 0.55rem;
align-items: center;
justify-content: center;
}
.v07-sidebar-dice .free-mode-toggle {
color: var(--ui-parchment);
font-size: 0.7rem;
flex-wrap: wrap;
justify-content: center;
text-align: center;
gap: 0.3rem;
}
.v07-sidebar-dice .free-mode-help {
border-color: rgba(242,232,208,0.35);
color: rgba(242,232,208,0.5);
}
/* Parchment status card */
.v07-sidebar-status {
background: var(--ui-parchment);
border-radius: 5px;
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
padding: 0.65rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
flex: 1;
}
.v07-sidebar-status .game-status {
color: var(--ui-ink);
text-shadow: none;
font-size: 1rem;
padding: 0;
width: auto;
text-align: center;
line-height: 1.3;
}
.v07-sidebar-status .board-actions {
flex-wrap: wrap;
justify-content: center;
min-height: 0;
}
/* ════════════════════════════════════════════════════════════════════
BOTTOM BAR (narrow <920px) v06 two-section style + v02 rounding
════════════════════════════════════════════════════════════════════ */
.v07-bottom-bar {
display: none; /* shown via media query */
align-items: stretch;
gap: 0.5rem; /* v02: 0.5rem gap between sections */
width: 100%;
}
/* Dark-wood dice section */
.v07-foot-dice {
background: var(--board-rail);
border-radius: 5px; /* v02 round corners */
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
padding: 0.6rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.v07-foot-dice-row { display: flex; gap: 0.5rem; align-items: center; }
.v07-foot-dice .free-mode-toggle {
color: var(--ui-parchment);
font-size: 0.7rem;
flex-wrap: wrap;
justify-content: center;
text-align: center;
gap: 0.3rem;
}
.v07-foot-dice .free-mode-help {
border-color: rgba(242,232,208,0.35);
color: rgba(242,232,208,0.5);
}
/* Parchment status section */
.v07-foot-main {
background: var(--ui-parchment);
border-radius: 5px; /* v02 round corners */
border-top: 2px solid var(--ui-gold-dark);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
padding: 0.65rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
flex: 1;
}
.v07-foot-main .game-status {
color: var(--ui-ink); /* v02 explicit colour */
text-shadow: none;
font-size: 1rem;
padding: 0;
width: auto;
text-align: center;
line-height: 1.3;
}
.v07-foot-main .board-actions {
flex-wrap: wrap;
justify-content: center;
min-height: 0;
}
/* ════════════════════════════════════════════════════════════════════
SCORING ROW — below bottom-bar (or below sidebar+board on wide)
════════════════════════════════════════════════════════════════════ */
.v07-scoring-row { width: 100%; }
.v07-scoring-row .scoring-panels-container {
position: static;
top: auto; left: auto; z-index: auto;
display: block;
}
/* Green: local player scored */
.v07-scoring-row .scoring-panel {
background: #edf7ee;
border: 1px solid #a8d4b0;
border-left: 3px solid #2d7a3c;
box-shadow: 0 2px 8px rgba(42,107,60,0.10);
width: 100%;
box-sizing: border-box;
margin: 0;
}
.v07-scoring-row .scoring-total { color: #1e5829; }
.v07-scoring-row .jan-label { color: #2a3d28; }
.v07-scoring-row .jan-tag { color: rgba(42,80,42,0.6); background: rgba(42,107,60,0.07); }
.v07-scoring-row .jan-pts { color: #1e5829; }
.v07-scoring-row .scoring-collapse-btn { color: rgba(42,80,42,0.4); }
.v07-scoring-row .scoring-expand-btn { background: #edf7ee; border-color: #a8d4b0; color: #2d7a3c; }
/* Red: opponent scored */
.v07-scoring-row .scoring-panel.opp-scored {
background: #fceaea;
border-color: #dea8a8;
border-left-color: #b52b2b;
}
.v07-scoring-row .scoring-panel.opp-scored .scoring-total { color: #7a1e1e; }
.v07-scoring-row .scoring-panel.opp-scored .jan-pts { color: #7a1e1e; }
/* ════════════════════════════════════════════════════════════════════
RESPONSIVE — 920px breakpoint
════════════════════════════════════════════════════════════════════ */
/* Wide (≥920px): show sidebar, hide bottom bar */
@media (min-width: 920px) {
.v07-sidebar { display: flex; }
.v07-bottom-bar { display: none; }
}
/* Narrow (<920px): hide sidebar, show bottom bar */
@media (max-width: 919px) {
.v07-sidebar { display: none !important; }
.v07-bottom-bar { display: flex; }
}
/* Very narrow: hide peg tracks in header */
@media (max-width: 680px) {
.v07-strip .peg-track { display: none; }
.v07-strip-center { padding: 0 0.5rem; }
}
</style>
</head>
<body>
<!-- ── Left navigation sidebar ───────────────────────────────────────── -->
<button class="game-hamburger game-hamburger-open" aria-label="Menu">
<span class="hb-bar hb-top"></span>
<span class="hb-bar hb-mid"></span>
<span class="hb-bar hb-bot"></span>
</button>
<div class="game-sidebar game-sidebar-open">
<div class="game-sidebar-header">
<span class="game-sidebar-brand">Trictrac</span>
<div class="lang-switcher">
<button>EN</button>
<button class="lang-active">FR</button>
</div>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M304 70.1C313.1 61.9 326.9 61.9 336 70.1L568 278.1C577.9 286.9 578.7 302.1 569.8 312C560.9 321.9 545.8 322.7 535.9 313.8L527.9 306.6L527.9 511.9C527.9 547.2 499.2 575.9 463.9 575.9L175.9 575.9C140.6 575.9 111.9 547.2 111.9 511.9L111.9 306.6L103.9 313.8C94 322.6 78.9 321.8 70 312C61.1 302.2 62 287 71.8 278.1L304 70.1zM320 120.2L160 263.7L160 512C160 520.8 167.2 528 176 528L224 528L224 424C224 384.2 256.2 352 296 352L344 352C383.8 352 416 384.2 416 424L416 528L464 528C472.8 528 480 520.8 480 512L480 263.7L320 120.3zM272 528L368 528L368 424C368 410.7 357.3 400 344 400L296 400C282.7 400 272 410.7 272 424L272 528z"></path></svg>
<a href="#" class="game-sidebar-link">Nouvelle partie</a>
</div>
<div class="game-sidebar-section">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M416 160L480 160C497.7 160 512 174.3 512 192L512 448C512 465.7 497.7 480 480 480L416 480C398.3 480 384 494.3 384 512C384 529.7 398.3 544 416 544L480 544C533 544 576 501 576 448L576 192C576 139 533 96 480 96L416 96C398.3 96 384 110.3 384 128C384 145.7 398.3 160 416 160zM406.6 342.6C419.1 330.1 419.1 309.8 406.6 297.3L278.6 169.3C266.1 156.8 245.8 156.8 233.3 169.3C220.8 181.8 220.8 202.1 233.3 214.6L306.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L306.7 352L233.3 425.4C220.8 437.9 220.8 458.2 233.3 470.7C245.8 483.2 266.1 483.2 278.6 470.7L406.6 342.7z"></path></svg>
<a href="#" class="game-sidebar-link">Se connecter</a>
</div>
<div class="sidebar-footer">
<div><span class="site-nav-version">v0.2.15</span></div>
</div>
</div>
<!-- ── Portal behind the overlay ─────────────────────────────────────── -->
<main>
<div style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh" class="portal-main">
<div class="login-card">
<div class="login-card-header"><div class="login-board-stripe"></div></div>
<div class="login-card-body">
<h1 class="login-title">Trictrac</h1>
<p class="login-subtitle"><em>Une interprétation numérique</em></p>
<div class="login-ornament"></div>
<div class="login-actions">
<button class="login-btn login-btn-secondary">Jouer contre le bot</button>
<button class="login-btn login-btn-primary">Inviter un adversaire</button>
</div>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════════════════
GAME OVERLAY
══════════════════════════════════════════════════════════════════════ -->
<div class="game-overlay">
<div class="game-container">
<!-- ════════════════════════════════════════════════════════════════
PLAYER STRIP
In-flow (scrolls with page). Reaches full viewport width via
width:100vw + margin-left:calc(50%-50vw).
Touches viewport top via margin-top:-1.5rem.
Left half DOM order: [avatar][name][pegs][pts] → justify:flex-end
Right half DOM order: [pts][pegs][name][avatar] → justify:flex-start
Both: pts-counter is the item closest to VS.
════════════════════════════════════════════════════════════════ -->
<div class="v07-strip">
<!-- Left: me (active) ──────────────────────────────────────────── -->
<div class="v07-player v07-player-left">
<div class="v07-active-zone active">
<!-- avatar outermost (leftmost) -->
<div class="v07-avatar v07-avatar-me">A</div>
<!-- name -->
<div class="score-row-name">
<span class="player-name">Anonyme</span>
<span class="you-tag">(vous)</span>
</div>
<!-- peg track -->
<div class="peg-track">
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole filled"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
<div class="peg-hole"></div><div class="peg-hole"></div>
</div>
<!-- score: closest to VS -->
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">6</span>
<span class="pts-max">/12</span>
</div>
</div>
</div>
</div><!-- /.v07-player-left -->
<!-- Center: title ──────────────────────────────────────────────── -->
<div class="v07-strip-center">
<span class="v07-title">Trictrac</span>
</div>
<!-- Right: opp (not active) ────────────────────────────────────── -->
<div class="v07-player v07-player-right">
<div class="v07-active-zone">
<!-- score: closest to VS -->
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">2</span>
<span class="pts-max">/12</span>
</div>
</div>
<!-- peg track -->
<div class="peg-track">
<div class="peg-hole peg-opp filled"></div>
<div class="peg-hole peg-opp filled"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
<div class="peg-hole peg-opp"></div><div class="peg-hole peg-opp"></div>
</div>
<!-- name -->
<div class="score-row-name">
<span class="player-name">Bot</span>
</div>
<!-- avatar outermost (rightmost) -->
<div class="v07-avatar v07-avatar-opp">B</div>
</div>
</div><!-- /.v07-player-right -->
</div><!-- /.v07-strip -->
<!-- ════════════════════════════════════════════════════════════════
BODY — [board] [sidebar (wide only)]
════════════════════════════════════════════════════════════════ -->
<div class="v07-body">
<!-- Board ─────────────────────────────────────────────────────── -->
<div class="board-wrapper">
<div class="board">
<!-- top row ────────────────────────────────────────────────── -->
<div class="board-row top-row">
<div class="board-quarter">
<div class="field zone-opponent corner" id="field-13"><span class="field-num">13</span></div>
<div class="field zone-opponent" id="field-14"><span class="field-num">14</span></div>
<div class="field zone-opponent" id="field-15">
<span class="field-num">15</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div><div class="checker black"></div>
</div>
</div>
<div class="field zone-opponent" id="field-16"><span class="field-num">16</span></div>
<div class="field zone-opponent" id="field-17"><span class="field-num">17</span></div>
<div class="field zone-opponent" id="field-18"><span class="field-num">18</span></div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-retour" id="field-19">
<span class="field-num">19</span>
<div class="hit-ripple hit-ripple-top"></div>
<div class="checker-stack"><div class="checker black"></div></div>
</div>
<div class="field zone-retour" id="field-20"><span class="field-num">20</span></div>
<div class="field zone-retour" id="field-21"><span class="field-num">21</span></div>
<div class="field zone-retour" id="field-22"><span class="field-num">22</span></div>
<div class="field zone-retour point-no-bredouille" id="field-23"><span class="field-num">23</span></div>
<div class="field zone-retour point-no-bredouille" id="field-24">
<span class="field-num">24</span>
<div class="checker-stack">
<div class="checker black"></div><div class="checker black"></div>
<div class="checker black"></div><div class="checker black">11</div>
</div>
</div>
</div>
</div>
<div class="board-center-bar"></div>
<!-- bottom row ─────────────────────────────────────────────── -->
<div class="board-row bot-row">
<div class="board-quarter">
<div class="field zone-grand corner corner-available clickable dest" id="field-12"
title="Coin de repos — must enter and leave with 2 checkers">
<span class="field-num">12</span>
</div>
<div class="field zone-grand clickable dest" id="field-11"><span class="field-num">11</span></div>
<div class="field zone-grand" id="field-10"><span class="field-num">10</span></div>
<div class="field zone-grand" id="field-9">
<span class="field-num">9</span>
<div class="checker-stack"><div class="checker white"></div></div>
</div>
<div class="field zone-grand" id="field-8">
<span class="field-num">8</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
<div class="field zone-grand selected clickable" id="field-7">
<span class="field-num">7</span>
<div class="checker-stack"><div class="checker white"></div><div class="checker white"></div></div>
</div>
</div>
<div class="board-bar"></div>
<div class="board-quarter">
<div class="field zone-petit point-no-bredouille" id="field-6"><span class="field-num">6</span></div>
<div class="field zone-petit point-no-bredouille" id="field-5"><span class="field-num">5</span></div>
<div class="field zone-petit point-no-bredouille" id="field-4"><span class="field-num">4</span></div>
<div class="field zone-petit point-no-bredouille" id="field-3"><span class="field-num">3</span></div>
<div class="field zone-petit point-no-bredouille" id="field-2"><span class="field-num">2</span></div>
<div class="field zone-petit point-no-bredouille" id="field-1">
<span class="field-num">1</span>
<div class="checker-stack">
<div class="checker white">10</div><div class="checker white"></div>
<div class="checker white"></div><div class="checker white"></div>
</div>
</div>
</div>
</div>
<svg style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible" width="761" height="388"></svg>
</div><!-- /.board -->
</div><!-- /.board-wrapper -->
<!-- Right sidebar (wide ≥920px) ─────────────────────────────── -->
<div class="v07-sidebar">
<div class="v07-sidebar-dice">
<div class="v07-sidebar-dice-row">
<svg class="die-face" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<svg class="die-face die-used" width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<label class="free-mode-toggle">
<input type="checkbox">Mode jeu libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div>
<div class="v07-sidebar-status">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions">
<button class="btn btn-secondary">Passer</button>
</div>
</div>
</div><!-- /.v07-sidebar -->
</div><!-- /.v07-body -->
<!-- ════════════════════════════════════════════════════════════════
BOTTOM BAR (narrow <920px) two rounded cards, 0.5rem gap
════════════════════════════════════════════════════════════════ -->
<div class="v07-bottom-bar">
<div class="v07-foot-dice">
<div class="v07-foot-dice-row">
<svg class="die-face" width="44" height="44" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="24" r="4.5"></circle>
<circle cx="35" cy="24" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
<svg class="die-face die-used" width="44" height="44" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7"></rect>
<circle cx="13" cy="13" r="4.5"></circle>
<circle cx="35" cy="13" r="4.5"></circle>
<circle cx="13" cy="35" r="4.5"></circle>
<circle cx="35" cy="35" r="4.5"></circle>
</svg>
</div>
<label class="free-mode-toggle">
<input type="checkbox">Mode libre
<span class="free-mode-help" title="Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même.">?</span>
</label>
</div>
<div class="v07-foot-main">
<div class="game-status">Déplacez une dame (1 sur 2)</div>
<div class="board-actions">
<button class="btn btn-secondary">Passer</button>
</div>
</div>
</div><!-- /.v07-bottom-bar -->
<!-- ════════════════════════════════════════════════════════════════
SCORING ROW — always below controls
════════════════════════════════════════════════════════════════ -->
<div class="v07-scoring-row">
<div class="scoring-panels-container">
<div class="scoring-panel-wrapper">
<button class="scoring-expand-btn" title="Afficher le détail">+</button>
<div class="scoring-panel">
<div class="scoring-panel-head">
<div class="scoring-total">+4 pts</div>
<button class="scoring-collapse-btn"></button>
</div>
<div class="scoring-jan-row">
<span class="jan-label">Battage à vrai (petit jan)</span>
<span class="jan-tag">simple</span>
<span class="jan-tag">×1</span>
<span class="jan-pts">+4</span>
</div>
</div>
</div>
</div>
</div><!-- /.v07-scoring-row -->
</div><!-- /.game-container -->
</div><!-- /.game-overlay -->
</body>
</html>

31
doc/python.md Normal file
View file

@ -0,0 +1,31 @@
# Python bindings
## Génération bindings
```sh
# Generate trictrac python lib as a wheel
maturin build -m store/Cargo.toml --release
# Install wheel in local python env
pip install --no-deps --force-reinstall --prefix .devenv/state/venv target/wheels/*.whl
```
## Usage
Pour vérifier l'accès à la lib : lancer le shell interactif `python`
```python
Python 3.13.11 (main, Dec 5 2025, 16:06:33) [GCC 15.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import trictrac_store
>>> game = trictrac_store.TricTrac()
>>> game.get_active_player_id()
1
```
### Appels depuis python
`python bot/python/test.py`
## Interfaces
## Entraînement

536
doc/python_research.md Normal file
View file

@ -0,0 +1,536 @@
# Trictrac — Research Notes: Engine & OpenSpiel Integration
> Generated from a deep read of `trictrac/store/src/` and `forks/open_spiel/open_spiel/python/games/trictrac.py`.
---
## 1. Architecture Overview
The project connects two codebases through a compiled Python extension:
```
┌─────────────────────────────────────┐
│ trictrac/store/ (Rust crate) │
│ - full game rules engine │
│ - pyengine.rs → PyO3 bindings │
│ compiled by maturin → .whl │
└──────────────┬──────────────────────┘
│ import trictrac_store
┌──────────────▼──────────────────────┐
│ forks/open_spiel/.../trictrac.py │
│ - TrictracGame (pyspiel.Game) │
│ - TrictracState (pyspiel.State) │
│ registered as "python_trictrac" │
└─────────────────────────────────────┘
```
Build pipeline:
- `just pythonlib` (in `trictrac/`) → `maturin build -m store/Cargo.toml --release``.whl` into `target/wheels/`
- `just installtrictrac` (in `forks/open_spiel/`) → `pip install --force-reinstall` the wheel into the devenv venv
The Rust crate is named `trictrac-store` (package) but produces a lib named `trictrac_store` (the Python module name, set in `Cargo.toml` `[lib] name`).
---
## 2. Rust Engine: Module Map
| Module | Responsibility |
|---|---|
| `board.rs` | Board representation, checker manipulation, quarter analysis |
| `dice.rs` | `Dice` struct, `DiceRoller`, bit encoding |
| `player.rs` | `Player` struct (score, bredouille), `Color`, `PlayerId`, `CurrentPlayer` |
| `game.rs` | `GameState` state machine, `GameEvent` enum, `Stage`/`TurnStage` |
| `game_rules_moves.rs` | `MoveRules`: move validation and generation |
| `game_rules_points.rs` | `PointsRules`: jan detection and scoring |
| `training_common.rs` | `TrictracAction` enum, action-space encoding (size 514) |
| `pyengine.rs` | PyO3 Python module exposing `TricTrac` class |
| `lib.rs` | Crate root, re-exports |
---
## 3. Board Representation
```rust
pub struct Board {
positions: [i8; 24],
}
```
- 24 fields indexed 023 internally, 124 externally.
- Positive values = White checkers on that field; negative = Black.
- Initial state: `[15, 0, ..., 0, -15]` — all 15 white pieces on field 1, all 15 black pieces on field 24.
- Field 0 is a sentinel for "exited the board" (never stored in the array).
**Mirroring** is the central symmetry operation used throughout:
```rust
pub fn mirror(&self) -> Self {
let mut positions = self.positions.map(|c| 0 - c);
positions.reverse();
Board { positions }
}
```
This negates all values (swapping who owns each checker) and reverses the array (swapping directions). The entire engine always reasons from White's perspective; Black's moves are handled by mirroring the board first.
**Quarter structure**: fields 16, 712, 1318, 1924. This maps to the four tables of Trictrac:
- 16: White's "petit jan" (own table)
- 712: White's "grand jan"
- 1318: Black's "grand jan" (= White's opponent territory)
- 1924: Black's "petit jan" / White's "jan de retour"
The "coin de repos" (rest corner) is field 12 for White, field 13 for Black.
---
## 4. Dice
```rust
pub struct Dice {
pub values: (u8, u8),
}
```
Dice are always a pair (never quadrupled for doubles, unlike Backgammon). The `DiceRoller` uses `StdRng` seeded from OS entropy (or an optional fixed seed for tests). Bit encoding: `"{d1:0>3b}{d2:0>3b}"` — 3 bits each, 6 bits total.
---
## 5. Player State
```rust
pub struct Player {
pub name: String,
pub color: Color, // White or Black
pub points: u8, // 011 (points within current hole)
pub holes: u8, // holes won (game ends at >12)
pub can_bredouille: bool,
pub can_big_bredouille: bool,
pub dice_roll_count: u8, // rolls since last new_pick_up()
}
```
`PlayerId` is a `u64` alias. Player 1 = White, Player 2 = Black (set at init time; this is fixed for the session in pyengine).
---
## 6. Game State Machine
### Stages
```rust
pub enum Stage { PreGame, InGame, Ended }
pub enum TurnStage {
RollDice, // 1 — player must request a roll
RollWaiting, // 0 — waiting for dice result from outside
MarkPoints, // 2 — points are being marked (schools mode only)
HoldOrGoChoice, // 3 — player won a hole; choose to Go or Hold
Move, // 4 — player must move checkers
MarkAdvPoints, // 5 — mark opponent's points after the move (schools mode)
}
```
### Turn lifecycle (schools disabled — the default in pyengine)
```
RollWaiting
│ RollResult → auto-mark points
├─[no hole]──→ Move
│ │ Move → mark opponent's points → switch player
│ └───────────────────────────────→ RollDice (next player)
└─[hole won]─→ HoldOrGoChoice
├─ Go ──→ new_pick_up() → RollDice (same player)
└─ Move ──→ mark opponent's points → switch player → RollDice
```
In schools mode (`schools_enabled = true`), the player explicitly marks their own points (`Mark` event) and then the opponent's points after moving (`MarkAdvPoints` stage).
### Key events
```rust
pub enum GameEvent {
BeginGame { goes_first: PlayerId },
EndGame { reason: EndGameReason },
PlayerJoined { player_id, name },
PlayerDisconnected { player_id },
Roll { player_id }, // triggers RollWaiting
RollResult { player_id, dice }, // provides dice values
Mark { player_id, points }, // explicit point marking (schools mode)
Go { player_id }, // choose to restart position after hole
Move { player_id, moves: (CheckerMove, CheckerMove) },
PlayError,
}
```
### Initialization in pyengine
```rust
fn new() -> Self {
let mut game_state = GameState::new(false); // schools_enabled = false
game_state.init_player("player1");
game_state.init_player("player2");
game_state.consume(&GameEvent::BeginGame { goes_first: 1 });
TricTrac { game_state }
}
```
Player 1 (White) always goes first. `active_player_id` uses 1-based indexing; pyengine converts to 0-based for the Python side with `active_player_id - 1`.
---
## 7. Scoring System (Jans)
Points are awarded after each dice roll based on "jans" (scoring events) detected by `PointsRules`. All computation assumes White's perspective (board is mirrored for Black before calling).
### Jan types
| Jan | Points (normal / doublet) | Direction |
|---|---|---|
| `TrueHitSmallJan` | 4 / 6 | → active player |
| `TrueHitBigJan` | 2 / 4 | → active player |
| `TrueHitOpponentCorner` | 4 / 6 | → active player |
| `FilledQuarter` | 4 / 6 | → active player |
| `FirstPlayerToExit` | 4 / 6 | → active player |
| `SixTables` | 4 / 6 | → active player |
| `TwoTables` | 4 / 6 | → active player |
| `Mezeas` | 4 / 6 | → active player |
| `FalseHitSmallJan` | 4 / 6 | → opponent |
| `FalseHitBigJan` | 2 / 4 | → opponent |
| `ContreTwoTables` | 4 / 6 | → opponent |
| `ContreMezeas` | 4 / 6 | → opponent |
| `HelplessMan` | 2 / 4 | → opponent |
A single roll can trigger multiple jans, each scored independently. The jan detection process:
1. Try both dice orderings
2. Detect "tout d'une" (combined dice move as a virtual single die)
3. Prefer true hits over false hits for the same move
4. Check quarter-filling opportunities
5. Check rare jans (SixTables at roll 3, TwoTables, Mezeas) given specific board positions and talon counts
### Hole scoring
```rust
fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool {
let sum_points = p.points + points;
let jeux = sum_points / 12; // number of completed holes
let holes = match (jeux, p.can_bredouille) {
(0, _) => 0,
(_, false) => 2 * jeux - 1, // no bredouille bonus
(_, true) => 2 * jeux, // bredouille doubles the holes
};
p.points = sum_points % 12;
p.holes += holes;
...
}
```
- 12 points = 1 "jeu", which yields 1 or 2 holes depending on bredouille status.
- Scoring any points clears the opponent's `can_bredouille`.
- Completing a hole resets `can_bredouille` for the scorer.
- Game ends when `holes > 12`.
- Score reported to OpenSpiel: `holes * 12 + points`.
### Points from both rolls
After a roll, the active player's points (`dice_points.0`) are auto-marked immediately. After the Move, the opponent's points (`dice_points.1`) are marked (they were computed at roll-time from the pre-move board).
---
## 8. Move Rules
`MoveRules` always works from White's perspective. Key constraints enforced by `moves_allowed()`:
1. **Opponent's corner forbidden**: Cannot land on field 13 (opponent's rest corner for White).
2. **Corner needs two checkers**: The rest corner (field 12) must be taken or vacated with exactly 2 checkers simultaneously.
3. **Corner by effect vs. by power**: If the corner can be taken directly ("par effet"), you cannot take it "par puissance" (using combined dice).
4. **Exit preconditions**: All checkers must be in fields 1924 before any exit is allowed.
5. **Exit by effect priority**: If a normal exit is possible, exceedant moves (using overflow) are forbidden.
6. **Farthest checker first**: When exiting with exceedant, must exit the checker at the highest field.
7. **Must play all dice**: If both dice can be played, playing only one is invalid.
8. **Must play strongest die**: If only one die can be played, it must be the higher value die.
9. **Must fill quarter**: If a quarter can be completed, the move must complete it.
10. **Cannot block opponent's fillable quarter**: Cannot move into a quarter the opponent can still fill.
The board state after each die application is simulated to check two-step sequences.
---
## 9. Action Space (training_common.rs)
Total size: **514 actions**.
| Index | Action | Description |
|---|---|---|
| 0 | `Roll` | Request dice roll (not used in OpenSpiel mode) |
| 1 | `Go` | After winning hole: reset board and continue |
| 2257 | `Move { dice_order: true, checker1, checker2 }` | Move with die[0] first |
| 258513 | `Move { dice_order: false, checker1, checker2 }` | Move with die[1] first |
Move encoding: `index = 2 + (0 if dice_order else 256) + checker1 * 16 + checker2`
`checker1` and `checker2` are **ordinal positions** (1-based) of specific checkers counted left-to-right across all White-occupied fields, not field indices. Checker 0 = "no move" (empty move). Range: 015 (16 values each).
### Mirror pattern in get_legal_actions / apply_action
For player 2 (Black):
```rust
// get_legal_actions: mirror game state before computing
let mirror = self.game_state.mirror();
get_valid_action_indices(&mirror)
// apply_action: convert action → event on mirrored state, then mirror the event back
a.to_event(&self.game_state.mirror())
.map(|e| e.get_mirror(false))
```
This ensures Black's actions are computed as if Black were White on a mirrored board, then translated back to real-board coordinates.
---
## 10. Python Bindings (pyengine.rs)
The `TricTrac` PyO3 class exposes:
| Method | Signature | Description |
|---|---|---|
| `new()` | `→ TricTrac` | Create game, init 2 players, begin with player 1 |
| `needs_roll()` | `→ bool` | True when in `RollWaiting` stage |
| `is_game_ended()` | `→ bool` | True when `Stage::Ended` |
| `current_player_idx()` | `→ u64` | 0 or 1 (active_player_id 1) |
| `get_legal_actions(player_idx)` | `→ Vec<usize>` | Action indices for player; empty if not their turn |
| `action_to_string(player_idx, action_idx)` | `→ String` | Human-readable action description |
| `apply_dice_roll(dices: (u8, u8))` | `→ PyResult<()>` | Inject dice result; errors if not in RollWaiting |
| `apply_action(action_idx)` | `→ PyResult<()>` | Apply a game action; validates before applying |
| `get_score(player_id)` | `→ i32` | `holes * 12 + points` for player (1-indexed!) |
| `get_players_scores()` | `→ [i32; 2]` | `[score_p1, score_p2]` |
| `get_tensor(player_idx)` | `→ Vec<i8>` | 36-element state vector (mirrored for player 1) |
| `get_observation_string(player_idx)` | `→ String` | Human-readable state (mirrored for player 1) |
| `__str__()` | `→ String` | Debug representation of game state |
Note: `get_score(player_id)` takes a 1-based player ID (1 or 2), unlike `current_player_idx()` which returns 0-based.
---
## 11. State Tensor Encoding (36 bytes)
```
[0..23] Board positions (i8): +N white / N black checkers per field
[24] Active player: 0=White, 1=Black
[25] TurnStage: 0=RollWaiting, 1=RollDice, 2=MarkPoints, 3=HoldOrGoChoice,
4=Move, 5=MarkAdvPoints
[26] Dice value 1 (i8)
[27] Dice value 2 (i8)
[28] White: points (011)
[29] White: holes (012)
[30] White: can_bredouille (0 or 1)
[31] White: can_big_bredouille (0 or 1)
[32] Black: points
[33] Black: holes
[34] Black: can_bredouille
[35] Black: can_big_bredouille
```
When called for player 1 (Black), the entire state is mirrored first (`game_state.mirror().to_vec()`).
### State ID (base64 string for hashing)
108 bits packed as 18 base64 characters:
- 77 bits: GNUbg-inspired board position encoding (run-length with separators)
- 1 bit: active player color
- 3 bits: turn stage
- 6 bits: dice (3 bits per die)
- 10 bits: white player (4 pts + 4 holes + 2 flags)
- 10 bits: black player
- Padded to 108 bits, grouped as 18 × 6-bit base64 chunks
---
## 12. OpenSpiel Integration (trictrac.py)
### Game registration
```python
pyspiel.register_game(_GAME_TYPE, TrictracGame)
```
Key parameters:
- `short_name = "python_trictrac"`
- `dynamics = SEQUENTIAL`
- `chance_mode = EXPLICIT_STOCHASTIC`
- `information = PERFECT_INFORMATION`
- `utility = GENERAL_SUM` (both players can score positive; no zero-sum constraint)
- `reward_model = REWARDS` (intermediate rewards, not just terminal)
- `num_distinct_actions = 514`
- `max_chance_outcomes = 36`
- `min_utility = 0.0`, `max_utility = 200.0`
- `max_game_length = 3000` (rough estimate)
### Chance node handling
When `needs_roll()` is true, the state is a chance node. OpenSpiel samples one of 36 outcomes (uniform):
```python
def _roll_from_chance_idx(self, action):
return [(i,j) for i in range(1,7) for j in range(1,7)][action]
def chance_outcomes(self):
p = 1.0 / 36
return [(i, p) for i in range(0, 36)]
```
Action 0 → (1,1), action 1 → (1,2), …, action 35 → (6,6). The chance action is then passed to `apply_dice_roll((d1, d2))` on the Rust side.
### Player action handling
When not a chance node:
```python
def _legal_actions(self, player):
return self._store.get_legal_actions(player)
def _apply_action(self, action):
self._store.apply_action(action)
```
The `Roll` action (index 0) is never returned by `get_legal_actions` in this mode because the Rust side only returns Roll actions from `TurnStage::RollDice`, which is bypassed in the pyengine flow (the RollWaiting→chance node path takes over).
### Returns
```python
def returns(self):
return self._store.get_players_scores()
# → [holes_p1 * 12 + points_p1, holes_p2 * 12 + points_p2]
```
These are cumulative scores available at any point during the game (not just terminal), consistent with `reward_model = REWARDS`.
---
## 13. Known Issues and Inconsistencies
### 13.1 `observation_string` missing return (trictrac.py:156)
```python
def observation_string(self, player):
self._store.get_observation_string(player) # result discarded, returns None
```
Should be `return self._store.get_observation_string(player)`.
### 13.2 `observation_tensor` not populating buffer (trictrac.py:159)
```python
def observation_tensor(self, player, values):
self._store.get_tensor(player) # result discarded, values not filled
```
OpenSpiel's API expects `values` (a mutable buffer, typically a flat numpy array) to be filled in-place. The returned `Vec<i8>` from `get_tensor()` is discarded. Should copy data into `values`.
### 13.3 Debug print statement active (trictrac.py:140)
```python
print("in apply action", self.is_chance_node(), action)
```
This fires on every action application. Should be removed or guarded.
### 13.4 Color swap on new_pick_up disabled
In `game.rs:new_pick_up()`:
```rust
// XXX : switch colors
// désactivé pour le moment car la vérification des mouvements échoue,
// cf. https://code.rhumbs.fr/henri/trictrac/issues/31
// p.color = p.color.opponent_color();
```
In authentic Trictrac, players swap colors between "relevés" (pick-ups after a hole is won with Go). This is commented out, so the same player always plays White and the same always plays Black throughout the entire game.
### 13.5 `can_big_bredouille` tracked but not implemented
The `can_big_bredouille` flag is stored in `Player` and serialized in state encoding, but the scoring logic never reads it. Grande bredouille (a rare extra bonus) is not implemented.
### 13.6 `Roll` action in action space but unused in OpenSpiel mode
`TrictracAction::Roll` (index 0) exists in the 514-action space and in `get_valid_actions()` (for `TurnStage::RollDice`). However, in pyengine, the game starts at `RollWaiting` (dice have been requested but not yet rolled), so `TurnStage::RollDice` is never reached from OpenSpiel's perspective. The chance node mechanism replaces the Roll action entirely. The action space slot 0 is permanently wasted from OpenSpiel's point of view.
### 13.7 `get_valid_actions` panics on `RollWaiting`
```rust
TurnStage::MarkPoints | TurnStage::MarkAdvPoints | TurnStage::RollWaiting => {
panic!("get_valid_actions not implemented for turn stage {:?}", ...)
}
```
If `get_legal_actions` were ever called while `needs_roll()` is true, this would panic. OpenSpiel's turn logic avoids this because chance nodes are handled separately, but it is a latent danger.
### 13.8 PPO training script uses wrong model name
`trictrac_ppo.py` saves to `ppo_backgammon_model.ckpt` — clearly copied from a backgammon example without renaming. Also uses `tensorflow.compat.v1` despite the PyTorch PPO import.
### 13.9 Opponent points marked at pre-move board state
The opponent's `dice_points.1` is computed at roll time (before the active player moves), but applied to the opponent after the move. This means the opponent's scoring is evaluated on the board position that existed before the active player moved — which is per the rules of Trictrac (points are based on where pieces could be hit at the moment of the roll), but it's worth noting this subtlety.
---
## 14. Data Flow: A Complete Turn
```
Python (OpenSpiel) → Rust (trictrac_store)
─────────────────────────────────────────────────────
is_chance_node() ← needs_roll() [TurnStage == RollWaiting]
(true at game start)
chance_outcomes() → [(0,p)..(35,p)]
_apply_action(chance_idx)
_roll_from_chance_idx(idx) → (d1, d2)
apply_dice_roll((d1, d2)) → consume(RollResult{dice})
→ auto-mark active player's points
→ if hole: TurnStage=HoldOrGoChoice
→ else: TurnStage=Move
current_player() → 0 or 1
_legal_actions(player) ← get_legal_actions(player_idx)
→ get_valid_actions on (possibly mirrored) state
→ Vec<usize> of valid action indices
_apply_action(action_idx) → apply_action(action_idx)
→ TrictracAction::from_action_index
→ to_event on (mirrored) state
→ mirror event back if player==2
→ validate → consume
→ mark opponent points
→ switch active player
→ TurnStage=RollDice (→ pyengine starts next turn)
Wait — pyengine starts at RollWaiting, not RollDice!
The next is_chance_node() call will be true again.
```
Note on turn transition: After a `Move` event in `game.rs`, turn stage becomes `RollDice` (not `RollWaiting`). The pyengine `needs_roll()` checks for `RollWaiting`. So after a move, `is_chance_node()` returns false — OpenSpiel will ask for a regular player action. But `get_valid_actions` at `TurnStage::RollDice` returns only `Roll` (index 0), which is **not** the chance path.
This reveals a subtlety: after the Move event, the active player has already been switched, so `current_player()` returns the new active player, and `get_legal_actions` returns `[0]` (Roll). OpenSpiel then applies action 0, which calls `apply_action(0)``TrictracAction::Roll``GameEvent::Roll` → TurnStage becomes `RollWaiting`. Then the next call to `is_chance_node()` returns true, and the chance mechanism kicks in again.
So the full sequence in OpenSpiel terms is:
```
[Chance] dice roll → [Player] move → [Player] Roll action → [Chance] dice roll → ...
```
The `Roll` action IS used — it is the bridge between Move completion and the next chance node.
---
## 15. Summary of Design Choices
| Choice | Rationale |
|---|---|
| All rules engine in Rust | Performance, correctness, can be used in other contexts (CLI, native bots) |
| Mirror pattern for Black | Avoids duplicating all rule logic for both colors |
| Schools disabled by default | Simpler turn structure for RL training; full protocol for human play |
| GENERAL_SUM + REWARDS | Trictrac is not strictly zero-sum; intermediate hole rewards are informative for training |
| Action index for checkers (not fields) | Reduces action space; ordinal checker numbering is compact |
| 514 action slots | 1 Roll + 1 Go + 256 × 2 move combinations (ordered by die priority × 16 × 16 checker pairs) |
| Chance node = dice roll | Standard OpenSpiel pattern for stochastic games |

130
doc/refs/inspirations.md Normal file
View file

@ -0,0 +1,130 @@
# Inspirations
tools
- config clippy ?
- bacon : tests runner (ou loom ?)
## Rust libs
cf. <https://blessed.rs/crates>
nombres aléatoires avec seed : <https://richard.dallaway.com/posts/2021-01-04-repeat-resume/>
- cli : <https://lib.rs/crates/pico-args> ( ou clap )
- reseau async : tokio
- web serveur : axum (uses tokio)
- <https://fasterthanli.me/series/updating-fasterthanli-me-for-2022/part-2#the-opinions-of-axum-also-nice-error-handling>
- db : sqlx
- eyre, color-eyre (Results)
- tracing (logging)
- rayon ( sync <-> parallel )
- front : yew + tauri
- egui
- <https://docs.rs/board-game/latest/board_game/>
## network games
- <https://www.mattkeeter.com/projects/pont/>
- <https://github.com/jackadamson/onitama> (wasm, rooms)
- <https://github.com/UkoeHB/renet2>
- <https://github.com/UkoeHB/bevy_simplenet>
## Others
- plugins avec <https://github.com/extism/extism>
## Backgammon existing projects
- go : <https://bgammon.org/blog/20240101-hello-world/>
- protocole de communication : <https://code.rocket9labs.com/tslocum/bgammon/src/branch/main/PROTOCOL.md>
- ocaml : <https://github.com/jacobhilton/backgammon?tab=readme-ov-file>
cli example : <https://www.jacobh.co.uk/backgammon/>
- lib rust backgammon
- <https://github.com/carlostrub/backgammon>
- <https://github.com/marktani/backgammon>
- network webtarot
- front ?
## cli examples
### GnuBackgammon
(No game) new game
gnubg rolls 3, anthon rolls 1.
GNU Backgammon Positions ID: 4HPwATDgc/ABMA
Match ID : MIEFAAAAAAAA
+12-11-10--9--8--7-------6--5--4--3--2--1-+ O: gnubg
| X O | | O X | 0 points
| X O | | O X | Rolled 31
| X O | | O |
| X | | O |
| X | | O |
^| |BAR| | (Cube: 1)
| O | | X |
| O | | X |
| O X | | X |
| O X | | X O |
| O X | | X O | 0 points
+13-14-15-16-17-18------19-20-21-22-23-24-+ X: anthon
gnubg moves 8/5 6/5.
### jacobh
Move 11: player O rolls a 6-2.
Player O estimates that they have a 90.6111% chance of winning.
Os borne off: none
24 23 22 21 20 19 18 17 16 15 14 13
---
| v v v v v v | | v v v v v v |
| | | |
| X O O O | | O O O |
| X O O O | | O O |
| O | | |
| | X | |
| | | |
| | | |
| | | |
| | | |
|------------------------------| |------------------------------|
| | | |
| | | |
| | | |
| | | |
| X | | |
| X X | | X |
| X X X | | X O |
| X X X | | X O O |
| | | |
| ^ ^ ^ ^ ^ ^ | | ^ ^ ^ ^ ^ ^ |
---
1 2 3 4 5 6 7 8 9 10 11 12
Xs borne off: none
Move 12: player X rolls a 6-3.
Your move (? for help): bar/22
Illegal move: it is possible to move more.
Your move (? for help): ?
Enter the start and end positions, separated by a forward slash (or any non-numeric character), of each counter you want to move.
Each position should be number from 1 to 24, "bar" or "off".
Unlike in standard notation, you should enter each counter movement individually. For example:
24/18 18/13
bar/3 13/10 13/10 8/5
2/off 1/off
You can also enter these commands:
p - show the previous move
n - show the next move
<enter> - toggle between showing the current and last moves
help - show this help text
quit - abandon game

61
doc/refs/journal.md Normal file
View file

@ -0,0 +1,61 @@
# Journal
```sh
devenv init
cargo init
cargo add pico-args
```
Organisation store / server / client selon <https://herluf-ba.github.io/making-a-turn-based-multiplayer-game-in-rust-01-whats-a-turn-based-game-anyway>
_store_ est la bibliothèque contenant le _reducer_ qui transforme l'état du jeu en fonction des évènements. Elle est utilisée par le _server_ et le _client_. Seuls les évènements sont transmis entre clients et serveur.
## Config neovim debugger launchers
Cela se passe dans la config neovim (lua/plugins/overrides.lua)
## Organisation du store
lib
- game::GameState
- error
- dice
- board
- user
- user
## Algorithme de détermination des coups
- strategy::choose_move
- GameRules.get_possible_moves_sequences(with_excedents: bool)
- get_possible_moves_sequences_by_dices(dice_max, dice_min, with_excedents, false);
- get_possible_moves_sequences_by_dices(dice_min, dice_max, with_excedents, true);
- has_checkers_outside_last_quarter() ok
- board.get_possible_moves ok
- check_corner_rules(&(first_move, second_move)) ok
- handle_event
- state.validate (ok)
- rules.moves_follow_rules (ok)
- moves_possible ok
- moves_follows_dices ok
- moves_allowed (ok)
- check_corner_rules ok
- can_take_corner_by_effect ok
- get_possible_moves_sequences -> cf. l.15
- check_exit_rules
- get_possible_moves_sequences(without exedents) -> cf l.15
- get_quarter_filling_moves_sequences
- get_possible_moves_sequences -> cf l.15
- state.consume (RollResult) (ok)
- get_rollresult_jans -> points_rules.get_result_jans (ok)
- get_jans (ok)
- get_jans_by_ordered_dice (ok)
- get_jans_by_ordered_dice ( dices.poped )
- move_rules.get_scoring_quarter_filling_moves_sequences (ok)
- get_quarter_filling_moves_sequences cf l.8 (ok)
- board.get_quarter_filling_candidate -> is_quarter_fillable ok
- move_rules.get_possible_moves_sequence -> cf l.15
- get_jans_points -> jan.get_points ok

View file

@ -1,410 +0,0 @@
# LAWS AND RULES OF TRICTRAC
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._
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.
## 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.
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.
## 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 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_).
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.
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.
## ARTICLE V: FIRST-MOVE PRIVILEGE
To determine who plays first at the start of a game, each player rolls a die with a dice cup; the player who rolls the higher number generally takes the white checkers and begins, playing both numbers rolled.
An alternative method: one player rolls both dice; the player closest to the higher die plays first, playing both numbers rolled.
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).
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.
In case of a replay, the player who had first-move privilege in the drawn round retains it for that replay and any subsequent replays.
## ARTICLE VI: ROLLING AND PLAYING THE DICE
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).
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.
Dice must not be picked up before the move is fully played and all points marked (including school penalties).
## ARTICLE VII: MOVEMENT OF CHECKERS
Checkers always move in the same direction — opposite to the opponent's — and never backwards.
In the course of a game, checkers travel from the stack to the rest corner (the twelfth field), then back to the opponent's stack on the return.
A checker may only be placed, or made to rest during a chained move, on an empty field or one already occupied by one or more of the player's own checkers. The rest corner is an exception to this rule (see Article IX).
A checker may not be placed on a field occupied by the opponent's checker(s).
## ARTICLE VIII: EXITING CHECKERS
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 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.
When exiting, all non-exiting numbers must be played within the jan when possible. It is therefore not permitted to play one number in a way that forces the second to be played only as an overflow. Likewise, if a number cannot be played within the jan due to the presence of opponent checkers, it may not be played as an overflow using a checker closer to the exit rail.
When a player has exited all their checkers, they score 4 points for the last exited checker on a normal roll, or 6 points on a double.
The checkers of both players are then reset and returned to their respective stacks; play continues with no change to the score. By privilege, the player who exited first rolls again and plays (first-move privilege).
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.
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 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.
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.
The hit is always fictitious — it exists only as a potential; no checker is actually moved.
A checker may be hit in one, two, or three ways:
- **One way**: only one of the direct die values, or the combined sum, could cover the checker.
- **Two ways**: both direct die values could cover it, or one direct value and the combined sum.
- **Three ways**: both direct values can cover it, and the combined sum can as well.
By its nature, a double allows at most one or two ways to hit:
- **One way**: one direct value, or the combined sum.
- **Two ways**: one direct value and the combined sum.
Only one way is counted on a double, even when two checkers on a field could each cover the opponent's checker.
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.
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.
Points for true hits must be marked before those given to the opponent for false hits. The opponent must mark their false-hit points in due time, under penalty of school (see Article XVI).
## ARTICLE XI: HITTING THE CORNER
This reward jan occurs when a player holds their own rest corner, the opponent's corner is empty, and the player rolls numbers that would let them take the opponent's corner simultaneously, without unstacking their own corner (i.e., without using the two checkers already holding it).
Since taking the opponent's corner is actually forbidden, the player hits it instead, scoring **4 points** on a normal roll and **6 points** on a double. This hit can never be false, as it uses two direct die values and never the combined sum.
## ARTICLE XII: OPENING JANS
There are three opening jans: the **two tables jan**, the **mezeas jan**, and the **six tables jan**. They can only be achieved at the start of a game, a round, or their respective new settings.
### TWO TABLES JAN
At the start of a game, round, or new setting — when only two checkers have been deployed — if the player rolls numbers that would place one checker on their own empty rest corner and one on the opponent's, a two tables jan is scored if the opponent's corner is also empty; otherwise, if the opponent has already taken their corner, a **contre two tables** is scored instead.
In the first case, hitting both corners is worth **4 points** on a normal roll and **6 points** on a double, credited to the player. In the second case, the same point value is credited to the opponent as a false hit.
### MEZEAS JAN
At the start of a game, round, or new setting — having taken one's corner with only two checkers deployed — if on the very next roll one or two aces (1s) are rolled, a mezeas jan is scored if the opponent's corner is empty; otherwise, a **contre mezeas** is scored.
In the first case, hitting the corner is worth **4 points** per ace and **6 points** for a double; in the second case, the same value is credited to the opponent.
### SIX TABLES JAN (THREE-ROLL JAN)
At the start of a game, round, or new setting — having placed one checker on four of the first six fields (fields 27) during the first two rolls — if on the third roll the player could fill the remaining two fields, they score a six tables jan. This jan is worth **4 points** in all cases, as it cannot be achieved with a double.
The player is not obliged to actually fill those two fields; they are free to play the roll however they prefer.
## ARTICLE XIII: SMALL JAN, BIG JAN & RETURN JAN
### 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.
Each player may fill their small jan, big jan, and return jan.
A full jan may be broken and then refilled. Over the course of a game, a player may fill several different jans successively, or the same jan multiple times.
### FILLING
A jan is filled when the player rolls numbers that complete the full jan by bringing in the last checker.
A jan may be filled in one, two, or three ways:
- **One way**: the last half-field can be covered by one direct die value or by the combined sum in a chained move.
- **Two ways**: it can be covered by either direct die value, or by one direct value and the combined sum.
- **Three ways**: it can be covered by either direct value, and also by the combined sum.
Each way of filling is worth **4 points** on a normal roll and **6 points** on a double.
For a jan to be filled in multiple ways, exactly one checker must be missing — only the last checker brought in actually fills the jan. Therefore, a player fills in only one way when taking the last field simultaneously or covering both last half-fields in the same move.
A double allows at most two ways to fill.
A jan is not effectively filled when, though able to complete it with one die, the player must break it to play the other. When a player "fills in passing" this way, no points are scored and they are not obliged to perform the filling.
The return jan may not be filled by counting either of the two checkers holding the rest corner as a way of filling — doing so would unstack the corner, which is forbidden.
After marking points for filling a jan, the player must actually fill it with one or two checkers, under penalty of false move and school (see Article XVII).
If the jan can be filled in multiple ways, the player fills it with the checker of their choice and is free to play the other die as they wish.
### CONSERVING
A full jan is conserved when the player can play both dice without breaking it — that is, without using any of the twelve checkers that compose the full jan.
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).
By privilege, the full return jan may be conserved by exiting one, two, or three checkers.
As with filling, when it is possible to play without breaking a full jan, the player must actually conserve it — under penalty of false move and school (see Article XVII).
## ARTICLE XIV: FORBIDDEN JANS
It is forbidden to place a checker in the opponent's small jan or big jan as long as the opponent retains the material possibility of filling that jan with the checkers available to them.
This prohibition normally ends once the opponent has moved enough checkers beyond the fields needed to complete the jan, so that those checkers can no longer serve to fill it — making the full jan materially impossible.
Once the opponent can no longer complete the full jan, the player has the right to place one or more of their own checkers there.
However, when playing a chained move, it is always permitted to use the empty fields of the opponent's big jan (including the corner) as a resting field — even if it can still be filled — in order to pass a checker into the return jan, as long as the return jan is not itself forbidden.
## ARTICLE XV: SCORING
Points and holes won must always be marked before touching one's checkers to play, or before rolling the dice for the next move if those points come from the opponent's roll (helpless man, contre-jans, schools).
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.
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.
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.
Multiple holes, both single and double, may be won in the same move.
In the scored game, a round may be won simple, double, or quadruple depending on whether the holes were scored consecutively. If the holes were not consecutive, the round is simple. If at least 6 holes were consecutive, the round is won in small bredouille and counts double. If at least 12 holes were consecutive, it is won in big bredouille and counts quadruple.
As with the hole bredouille, this advantage applies equally to the first and second player to score holes. The second player takes the flag and places it at their peg's starting position on the starting rail. If the first player wins new holes, they take the flag back and return it to the central hole. The round is then necessarily won simple.
## 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.
**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).
**Leaving**: after marking the hole(s), the player verbally announces their intention to leave, as the opponent may object in case of a fault or school. Once the opponent has verbally agreed or begun breaking their position, all tokens are reset to zero and all checkers of both players are returned to their stacks. Only the holes won remain. No remainder points may be marked and the opponent cannot mark points or holes for this move. Play resumes from the start; by privilege, the player who left has first-move privilege for this new setting — they roll and play.
In the scored game, once a player has scored at least 6 holes, if either player leaves, the round ends. The winner is whoever has the most holes. In case of a tie, the round is drawn and replayed.
## ARTICLE XVII: FAULTS AND SCHOOLS
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.
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.
**3. Marking faults** — always harmful; occur when points or holes are forgotten, over-marked, or marked incorrectly. The opponent may penalise the player by "sending them to school." A school is committed once dice have been rolled or checkers touched, or once a token marker has been advanced too far and released. In some cases the school is committed as soon as an intentional declaration is made (leaving, school, invitation to play).
A school is worth to the opponent exactly as many points as were missed or over-marked in a move. Moreover, in case of over-marking, the opponent corrects the mark by removing the excess points. When marking school points, or just after, the opponent must announce it by saying: "School!" or "N points of school."
No one is obliged to mark a school — there is no "school of school." But if it is marked, it must be marked in full, or the opponent may request rectification. The opponent may also force the faulty player to correct their mark without marking the school.
A **false school** occurs when a player marks an incorrect school or a school that does not exist. This is itself a school that the opponent may mark in their favour.
A **school escalation** occurs when a player who committed a school has had it marked by their opponent, believes the opponent erred, removes that school and marks it in their own favour as a false school — but the opponent, maintaining the school was valid, removes the player's mark, restores the first school, and adds further points for this second school. The dispute could continue indefinitely unless the players reach a frank resolution.
At any time a player may ask their opponent to explain the points they are marking or removing. The opponent must explain.
No school of holes is incurred for marking a hole won bredouille as a simple hole. But a school of points is incurred for holes forgotten due to points earned, or for holes over-marked for points not earned.
School points are marked last.
## ARTICLE XVIII: SEQUENCE OF PLAY
For a move to be regular, each player must act in the following order:
As soon as the opponent's move ends:
1. If applicable, mark the opponent's helpless man penalties or contre-jans.
2. If applicable and desired, mark the opponent's schools and announce: "School!"; rectify any false moves and marking errors.
Then:
3. Roll the dice for one's own move. If applicable, the opponent then marks any schools committed in steps 1 and 2. Resolve false schools and school escalations.
4. If applicable, mark points for opening jans, reward jans (checker hits and corner hits), filled or conserved jans, or exit points.
5. If applicable, decide to stay or leave:
- **Stay**: reset tokens and mark any remainder points.
- **Leave**: announce it, then break the position after the opponent's agreement. Reset all checkers, reset all tokens, and roll again to play (unless the game or round is over).
6. In case of exit: reset checkers, do not reset either player's tokens, and roll again to play.
7. Play both numbers rolled if possible.
The opponent then plays following the same sequence.
8. If applicable, mark any schools the opponent committed in their steps 1 and 2, as soon as they have rolled the dice, or interrupt the roll to rectify the marking.
Failure to respect this sequence is a fault and may be penalised by the opponent.
## ARTICLE XIX: THE SCORED GAME
### THREE- AND FOUR-PLAYER GAME
The number of rounds chosen for the game must be a multiple of the number of players, so that each player faces every opponent the same number of times.
With three players, the first round pairing is drawn by lot. The player who draws the highest number begins with white checkers and plays the two numbers rolled.
For the second round, the winner is replaced by the third player, but first-move privilege belongs to the player who stayed at the table.
For the following round, the third player remains but the others alternate.
Each player thus plays two rounds in a row against different opponents. Only the winner of the first round plays just once — at the very beginning and at the very end of the game.
In case of a replay, the same players remain and the player who had first-move privilege in the drawn round retains it.
With four players, the game is played in teams of two. Teammates share wins and losses.
Play proceeds as in the three-player game: each player plays two rounds in a row — one against each opponent — then gives way to their partner. Likewise, only the winner of the first round plays just once, at the start and end of the game.
Players not currently in a round may advise those at the table (opponents in three-player, teammates in four-player) according to their interest; but they are forbidden to touch any game component.
### PAYMENTS
Each round is paid to the winner in as many tokens as holes scored, minus those of the loser. The loser also pays a **consolation** of two additional tokens to the winner, and to the other player in the three-player game.
If the round is won in small bredouille, each hole won by the winner is paid double (2 tokens) and the consolation is also doubled (4 tokens).
If the round is won in big bredouille, each hole is paid quadruple (4 tokens) and the consolation is quadrupled (8 tokens).
Each hole won by the loser is deducted from the total and is always worth only one token.
In case of a replay, the consolation price doubles the previous drawn round's price, doubling again at each successive replay.
In the three-player game, the loser must additionally always pay the consolation to the non-playing player, whatever the consolation price.
Moreover, after each defeat, the loser sets aside one token (sometimes two) to track the number of losses and allow later settlement of bets.
All these tokens form a **queue** that is paid at the end of the game to the player who won the most tokens in rounds. In case of a tie, the queue is split equally among the winning players.
The queue is not mandatory when scoring is kept in writing, but may be counted by convention.
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.
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.
The first double bet is called the **postillon** and is paid 28 tokens (including 20 from the queue); each subsequent bet costs 8 tokens. This payment is made between each pair of players. With three players, it is possible to win or lose two postillons among other bets, one per opponent.
Finally, the definitive settlement converts each player's winnings into chips (or another equivalent whose value was established beforehand — for example, one chip = 5 tokens).
## ARTICLE XX: END OF GAME
### THE ORDINARY GAME
The ordinary game — also called the "tour" of trictrac — ends when a player wins their twelfth and final hole. This hole may be won through the player's own dice roll or the opponent's (helpless man, schools); the player need not leave to end the game.
By prior convention, the game may be won simple or double. It is won double — in big bredouille — when a player scores all twelve holes consecutively. The second player to mark may also achieve this by taking the flag and keeping it until they score twelve holes without the first player scoring again and taking back the flag. If neither player scores twelve holes consecutively, the game is won simple.
Another convention allows the game to be won quadruple if the winner was the only player to score. The second player to mark may win triple by achieving big bredouille as described above. The game is won double if both players' bredouilles are cancelled and the loser failed to score at least six holes; otherwise it is won simple.
Settlement is then made according to the stakes established before the game.
The game ends when the loser has paid their debt to the winner.
### THE SCORED GAME
As stated in Article II, the scored game consists of an agreed number of rounds. When those rounds have been played — including any replays — settlement takes place by performing the count and payments as described in Article XIX.
The game ends when all debts have been settled.
### APPENDIX: SCORING TABLE
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.
| 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

@ -1,394 +0,0 @@
# LOIS ET RÈGLES DU TRICTRAC
ÉDITION 2013 COMPLÉMENT AU DICTIONNAIRE RAISONNÉ DU JEU DE TRICTRAC www.trictrac.org par Michel MALFILÂTRE (trictrac.org)
Il y a deux sortes de partie au grand trictrac : la partie ordinaire et la partie à écrire.
A l'une comme à l'autre, les lois et les règles principales du jeu sont les mêmes ; mais le but, la marque et donc les paiements sont différents.
## ARTICLE I : LA PARTIE ORDINAIRE
Elle se dispute entre deux joueurs, le but est de marquer le premier 12 jeux ou trous. Un trou vaut 12 points.
## ARTICLE II : LA PARTIE À ÉCRIRE
Elle peut se disputer à 2, 3 ou 4 joueurs en équipe ou « à la chouette ». Le but est de gagner un maximum de jetons, en jouant un certain nombre de marqués ou manches, qui est déterminé d'un commun accord entre les joueurs.
Un marqué oppose toujours deux joueurs. A trois ou à quatre, les joueurs alternent à chaque marqué suivant un ordre défini.
Pour gagner un marqué, il faut prendre un minimum de 6 trous et quun joueur s'en aille, c'est-à-dire s'arrête (voir article XV). Le nombre maximum de trous pouvant être pris nest généralement pas limité, mais les joueurs peuvent en décider différemment.
En cas d'égalité de trous, à partir des 6 requis, lorsqu'un des joueurs s'en va, le marqué est nul et il y a refait, c'est-à-dire que le marqué doit être rejoué aussitôt.
## ARTICLE III : LE MATÉRIEL
Pour jouer, on utilise un tablier appelé trictrac formé de deux tables : la table du PETIT JAN et la table du GRAND JAN. Dans la première table se trouve le petit jan de chaque joueur et dans la seconde le grand jan de chaque joueur. Le petit jan dun joueur constitue aussi le jan de retour de lautre joueur. Chaque jan est composé de 6 flèches, ou lames de couleur alternes.
Le tablier comprend au total 24 cases triangulaires et 30 trous pratiqués dans les rebords et les bandes.
Un trou est pratiqué à la base de chaque flèche. Ces trous sont destinés à recevoir le fichet de chaque joueur afin de marquer les jeux (ou trous) gagnés. Les trois trous situés sur chaque bande latérale servent à placer les fichets au début du jeu ainsi que le pavillon.
Outre ces trois fichets dont le pavillon, on se sert de 30 dames, 15 blanches et 15 noires (ou de deux autres couleurs différentes), de trois jetons, de deux cornets et de deux dés cubiques aux faces numérotées de 1 à 6.
Par ailleurs, la partie à écrire se joue avec des jetons utilisés pour les paiements, ou bien avec un papier et un crayon permettant d'établir un compte de jetons.
## ARTICLE IV : POSITION DE DÉPART
Au début de la partie, on empile toutes les dames en deux talons distincts, l'un formé des dames blanches et l'autre des dames noires.
Chaque talon est constitué vis-à-vis de l'autre sur une case de coin située contre une des deux bandes latérales extérieures appelée alors bande de départ et qui deviendra éventuellement par la suite la bande de sortie.
Chaque joueur devra utiliser les dames du talon le plus proche de lui. Les coins situés contre lautre bande latérale extérieure sont les coins de repos. La douzième case de chaque joueur, en comptant le talon pour la première, constitue donc son coin de repos ou simplement appelé : coin.
On place les fichets dans les 3 trous de la bande de départ, le pavillon occupant le trou central. On dispose les trois jetons contre cette bande entre les deux talons.
## ARTICLE V : LA PRIMAUTÉ DU DÉ
Pour déterminer celui qui va jouer en premier au début d'une partie, chaque joueur jette un dé avec un cornet et celui qui a amené le plus fort nombre prend généralement les dames blanches et commence en jouant les deux nombres ainsi obtenus.
Une autre méthode peut être employée : un joueur lance les deux dés avec un cornet et le joueur se trouvant placé le plus près du plus fort dé, jouera en premier les deux nombres ainsi obtenus.
Dans les deux cas, si les dés amenés sont semblables, il faut jeter à nouveau les dés.
On ne peut donc pas commencer une partie par un doublet (double).
A chaque relevé, la primauté appartient par privilège à celui qui a sorti en premier toutes ses dames ou qui s'en est allé (voir articles VIII et XV).
A la partie à écrire, à deux joueurs, la primauté alterne à chaque marqué ; à trois ou quatre joueurs, elle appartient à celui qui reste pour affronter un nouvel adversaire.
Lorsqu'il y a refait, le joueur qui avait la primauté au marqué nul précédent, la conserve pour ce refait et pour les éventuels suivants, en cas de refaits successifs.
## ARTICLE VI : JETER ET JOUER LES DÉS
Les deux dés doivent être jetés ensemble avec un cornet. Ils sont bons lorsquils se posent à plat dans le trictrac, même sur une dame ou un jeton. Si un dé est cassé, posé sur une bande ou hors du trictrac, il est mauvais et les deux dés doivent être relancés.
On peut jouer les deux nombres obtenus par les dés avec deux dames, chacune jouant un nombre, ou avec une seule dame jouant « tout d'une » les deux nombres successivement (pour 6 et as : le 6 permet d'avancer une dame de six cases et las une autre dame dune case ; ou bien une seule et même dame de sept cases laquelle dame exécute alors deux sauts successifs en se reposant obligatoirement sur la première ou sur la sixième case afin datteindre la septième case d'arrivée).
Il est obligatoire de jouer les deux nombres si cela est possible. Si on ne peut en jouer qu'un seul et qu'on ait le choix, on doit jouer le plus fort.
Tout nombre non joué est pénalisé : c'est un JAN-QUI-NE-PEUT qui vaut en faveur de l'adversaire 2 points d'impuissance par nombre.
Les dés ne doivent pas être relevés avant que le coup ne soit joué entièrement et tous les points marqués (écoles comprises).
## ARTICLE VII : LE MOUVEMENT DES DAMES
On joue ses dames toujours dans le même sens, qui est contraire à celui de son adversaire, et sans jamais rétrograder.
Dans la marche du trictrac, les dames peuvent parcourir le tablier de leur talon jusqu'à leur coin de repos (douzième case), puis passer au retour jusqu'au talon adverse.
On ne peut placer une dame, ou la faire se reposer pour jouer tout d'une, que sur une case vide ou déjà occupée par une ou plusieurs de ses propres dames. Exception est faite à cette règle concernant les coins de repos (voir article IX).
Il n'est pas possible de jouer sur une case qu'occupe l'adversaire avec une ou plusieurs de ses dames.
## ARTICLE VIII : LA SORTIE DES DAMES
Lorsque toutes les dames d'un joueur se trouvent rassemblées dans son dernier jan ou jan de retour, elles sont sorties hors du trictrac en usant du privilège de la bande de sortie qui attribue à cette bande la valeur d'une case.
Une dame peut être sortie par un nombre sortant qui la fait aboutir directement à cette bande et par un nombre excédant qui fait aboutir la dame la plus éloignée de la bande de sortie au delà de celle-ci. Les autres nombres ou nombres défaillants doivent être joués à l'intérieur du jan.
Il est possible de sortir une dame en la jouant tout d'une. Il est permis de ne pas sortir une dame par un nombre sortant mais d'en jouer une autre à l'intérieur du jan comme un nombre défaillant si cela est possible ; mais un nombre excédant doit obligatoirement faire sortir une dame.
Lors de la sortie, il faut jouer la totalité des nombres non sortants à l'intérieur du jan lorsque cela est possible. Il n'est donc pas permis de jouer l'un des nombres de telle sorte que le second ne puisse être joué autrement que comme nombre excédant. De même si un nombre ne peut être joué dans le jan à cause de la présence d'une ou plusieurs dames adverses, il n'est pas permis de jouer ce nombre comme excédant par une dame située plus près de la bande de sortie.
Quand un joueur a fait sortir toutes ses dames, il gagne, pour la dernière sortante, 4 points par un coup simple ou 6 points par un doublet.
Ensuite les dames des deux camps sont relevées et replacées à leur talon respectif ; le jeu se poursuit ainsi sans que la marque des points ne soit autrement modifiée. Par privilège, le joueur qui avait sorti toutes ses dames en premier jette de nouveau les dés et joue (primauté).
La sortie des dames peut se produire plusieurs fois dans une partie.
## ARTICLE IX : LE COIN DE REPOS
On ne peut prendre son coin de repos que d'emblée, c'est-à-dire en y portant deux dames simultanément. De même, on ne peut le quitter que d'emblée. On doit donc l'occuper avec au moins deux dames. Il est interdit de placer ou de laisser une seule dame sur son coin de repos.
Il est interdit dans quelque circonstance que ce soit de placer une ou plusieurs dames sur le coin de repos de l'adversaire.
Un coin vide peut cependant servir de passage à une dame quelconque pour s'y reposer afin de jouer tout d'une.
On peut prendre son coin naturellement, par effet, ou bien par puissance si celui de l'adversaire est vide et qu'on pourrait le prendre d'emblée. Ainsi, par privilège, on prend son
propre coin à la place comme si on rétrogradait d'une case.
Si on a la possibilité de prendre son coin à la fois par effet et par puissance, on doit le prendre par effet.
Après avoir quitté son coin, on peut le reprendre dans les mêmes conditions.
## ARTICLE X : LA BATTERIE DES DAMES
On fait ce JAN DE RÉCOMPENSE lorsqu'une dame adverse est découverte, seule en demi-case et qu'on amène des nombres avec lesquels on pourrait couvrir cette dame avec une ou plusieurs des siennes.
La batterie est toujours fictive, à l'état de puissance ; elle ne s'opère jamais en réalité. Aucune dame n'est déplacée.
On peut battre d'une, de deux ou de trois façons :
- On bat d'une façon lorsqu'on ne peut couvrir cette dame que par un des nombres directs obtenus par les dés, ou par les deux nombres réunis.
- On bat de deux façons lorsqu'on peut couvrir la dame par l'un et l'autre nombres directs, ou par un nombre direct et les deux nombres réunis.
- Enfin, on bat de trois façons lorsque pouvant couvrir la dame par l'un et l'autre nombres directs, on le peut aussi par les deux nombres réunis.
De par sa nature, le doublet ne permet de battre une dame que dune ou de deux façons seulement :
- On bat dune façon par un nombre direct ou par les deux nombres réunis.
- On bat de deux façons par un nombre direct et par les deux nombres réunis.
On ne bat une dame que dune seule façon par un doublet même lorsquon dispose sur une case de deux dames pouvant couvrir cette dame adverse.
Plusieurs dames peuvent être battues lors un même coup.
Pour chaque dame battue et pour chaque façon de la battre, ce jan de récompense vaut 2 points par un coup simple ou 4 points par un doublet lorsque cette dame se trouve placée dans un des grands jans ; si la dame battue se trouve dans un des petits jans ou un des jans de retour, chaque façon vaut 4 points par un coup simple ou 6 points par un doublet.
Les jans de récompense doivent être marqués par le joueur qui les réalise (sous peine d'être « envoyé à l'école », voir article XVI).
Pour battre une dame adverse par les deux nombres réunis, on doit bénéficier d'un repos pour battre. C'est une case où doit aboutir l'un ou l'autre nombre afin de pouvoir porter, fictivement, le second sur la dame à battre. Cette case doit être vide, ou bien déjà tenue par son propre camp, ou encore être occupée en demi-case par une dame adverse, laquelle est alors également battue.
On fait un JAN-QUI-NE-PEUT lorsque pour battre une dame adverse par les deux nombres réunis on ne trouve pas de passage libre et quon est alors obligé de se reposer sur une case pleine tenue par l'adversaire. La batterie s'effectue alors « à faux » et c'est l'adversaire qui gagne pour ce jan-qui-ne-peut autant de points qu'on en aurait gagné si on avait battu « à vrai ».
Une dame battue à vrai ne peut l'être à faux dans un même coup. Mais plusieurs dames peuvent être battues en même temps : alors certaines peuvent l'être à vrai et d'autres à faux.
Les points obtenus pour les batteries à vrai doivent se marquer avant ceux donnés pour les batteries à faux à ladversaire qui devra les marquer en temps utile sous peine dêtre envoyé à lécole (voir article XVI).
## ARTICLE XI : LA BATTERIE DU COIN
On fait ce jan de récompense lorsqu'on a pris son coin de repos, que celui de l'adversaire est vide et qu'on amène des nombres avec lesquels on pourrait prendre d'emblée ce dernier sans dédoubler son propre coin, c'est-à-dire sans se servir des deux dames qui le tiennent.
Comme la faculté de prendre le coin adverse n'existe pas, on le bat, ce qui vaut 4 points par un coup simple et 6 points par un doublet. Cette batterie ne peut jamais seffectuer à faux puisquelle se réalise avec les deux nombres directs et jamais par les deux nombres réunis.
## ARTICLE XII : JANS DE DÉPART
Il y en a trois : le JAN DE DEUX TABLES, le JAN DE MÉZÉAS et le JAN DE SIX TABLES. Ils ne peuvent être réalisés qu'au début d'une partie, d'un marqué ou d'un de leurs
relevés éventuels.
### JAN DE DEUX TABLES
Lorsqu'au commencement d'une partie, d'un marqué ou d'un relevé, n'ayant encore que deux dames abattues, on amène des nombres avec lesquels on pourrait placer une de ces dames sur son propre coin de repos, vide, et l'autre sur celui de l'adversaire, alors on fait un jan de deux tables si ce coin est également vide ; mais on fait un contre-jan de deux tables si l'adversaire a déjà pris son coin.
Dans le premier cas, cette batterie des deux coins vaut pour soi 4 points par un coup simple et 6 points par un doublet ; dans le deuxième cas, cette batterie à faux vaut le même nombre de points pour l'adversaire.
### JAN DE MÉZÉAS
Lorsqu'au commencement d'une partie, d'un marqué ou d'un relevé, ayant pris son coin avec deux dames seulement abattues, on amène le coup suivant un ou deux as, on fait alors un jan de mézéas si le coin adverse est vide ; mais on fait un contre-jan de mézéas si l'adversaire a déjà pris son coin.
Dans le premier cas, cette batterie du coin vaut pour soi 4 points par un as et 6 points par le doublet ; dans le deuxième cas, cette batterie à faux vaut le même nombre de points pour l'adversaire.
### JAN DE SIX TABLES ou JAN DE TROIS COUPS
Lorsqu'au commencement d'une partie, d'un marqué ou d'un relevé, ayant garni d'une dame quatre de ses six premières flèches (cases 2 à 7) lors des deux premiers coups, on amène au troisième des nombres avec lesquels on pourrait garnir les deux autres flèches, alors on fait un jan de six tables ou jan de trois coups. Ce jan vaut 4 points dans tous les cas puisqu'il ne peut être réalisé par un doublet.
On n'est point obligé de garnir effectivement ces deux flèches : on a la faculté de jouer ce coup de la manière qu'on préfère.
## ARTICLE XIII : PETIT JAN, GRAND JAN & JAN DE RETOUR
### LE PLEIN
Un jan est plein lorsqu'un joueur occupe chacune des six cases de ce jan avec au moins deux de ses dames.
Chaque joueur peut faire le plein de son petit jan, de son grand jan et de son jan de retour.
Le plein d'un jan peut être rompu puis refait. Il est possible de faire successivement au cours d'une partie le plein de plusieurs jans différents ou du même.
### REMPLIR
On remplit un jan lorsqu'on amène des nombres qui permettent d'achever le plein en y apportant la dernière dame.
On peut remplir d'une, de deux ou de trois façons :
- On remplit d'une façon lorsqu'on peut couvrir dune dame la dernière demi-case par un nombre direct ou par les deux nombres réunis en jouant tout d'une.
- On remplit de deux façons lorsqu'on peut couvrir cette dernière demi-case par l'un et l'autre nombres directs ou par un nombre direct et les deux réunis.
- Enfin, on remplit de trois façons lorsque, pouvant la couvrir par l'un et l'autre nombres directs, on le peut aussi par les deux nombres réunis.
Chaque façon de remplir vaut 4 points par un coup simple et 6 points par un doublet.
Pour pouvoir remplir un jan de plusieurs façons, il faut nécessairement qu'il n'y manque qu'une seule dame puisque seule la dernière dame apportée remplit effectivement ; on ne remplit donc que d'une seule façon en faisant la dernière case d'emblée ou en couvrant dans le même coup les deux dernières demi-cases.
Le doublet, par sa nature, ne permet tout au plus que deux façons de remplir.
On ne remplit pas effectivement un jan lorsque, pouvant y faire le plein par un nombre, on doit le rompre pour jouer l'autre. Lorsque ainsi on « remplit en passant », on ne doit rien marquer. On nest donc pas tenu deffectuer ce remplissage.
On ne peut remplir son jan de retour en utilisant ou en comptant comme façon de remplir l'une des deux dames qui tiennent son coin de repos car ce serait le dédoubler, ce qui est interdit.
Après avoir marqué les points pour le remplissage du jan, il est obligatoire de remplir effectivement avec une ou deux dames, sous peine de fausse case et décole (voir article XVII).
Si on peut remplir de plusieurs façons, on remplit effectivement avec la dame de son choix et on est libre de jouer lautre à sa guise.
### CONSERVER
On conserve le plein d'un jan lorsqu'on peut jouer les deux nombres amenés sans le rompre, c'est-à-dire sans se servir d'aucune des douze dames qui composent ce plein.
La conservation d'un plein vaut 4 points par un coup simple et 6 points par un doublet. Il ne peut y avoir plus dune seule façon de conserver.
On peut user du privilège de conserver par impuissance lorsque, le plein réalisé, la disposition du jeu ne permet pas de jouer l'un ou les deux nombres. Seul le nombre 6 permet cette conservation puisque les nombres inférieurs peuvent tous être joués à l'intérieur du jan, quitte à rompre le plein.
Par privilège, on peut conserver le plein de son jan de retour grâce à la sortie dune, de deux ou de trois de ses dames.
Comme pour le remplissage, il est obligatoire de conserver effectivement le plein d'un jan lorsque il est possible de jouer sans le rompre, sous peine de fausse case et décole (voir article XVII).
## ARTICLE XIV : JANS INTERDITS
Il est interdit de placer une de ses dames dans le petit jan ou le grand jan de l'adversaire tant que celui-ci a la possibilité matérielle de remplir ce jan avec les dames dont il dispose.
Cette interdiction cesse donc normalement dès que l'adversaire a passé suffisamment de dames au delà des cases qui lui restent à faire pour remplir ce jan, de manière que ces dames ne puissent plus servir à faire ce plein qui est ainsi devenu impossible à réaliser faute de dames.
Ainsi l'adversaire ne pouvant plus réaliser le plein de ce jan, on a le droit d'y jouer en y plaçant une ou plusieurs de ses propres des dames.
Par contre, en jouant tout d'une, il est toujours possible d'utiliser les cases vides du grand jan adverse (y compris le coin), même s'il peut encore être rempli, afin de s'y reposer pour passer une dame dans le jan de retour, dès lors que ce dernier n'est plus interdit.
## ARTICLE XV : LA MARQUE
On doit toujours marquer les jeux et les points qu'on gagne avant de toucher ses dames pour jouer ou avant de jeter les dés pour le coup suivant si ces points proviennent du coup de l'adversaire (jan-qui-ne-peut, contre-jans, écoles).
La marque des points s'effectue au moyen des jetons. Pour 2 points, on place le jeton de son côté, à la pointe de sa deuxième flèche ou entre sa deuxième et sa troisième flèche ; pour 4 points, à la pointe de sa quatrième flèche ou entre sa quatrième et sa cinquième flèche ; pour 6 points, à la pointe de sa sixième flèche ou contre la bande transversale ; pour 8 points, de l'autre côté de cette bande, dans son grand jan ; et pour 10 points, contre la bande latérale de son grand jan ou à la pointe de la flèche de son coin de repos. 12 ou 0 points se marquent contre la bande de départ, entre les deux talons, comme au début de la partie.
12 points font un jeu ou trou. Si les 12 points du jeu ont été marqués d'affilée à partir de zéro au talon, c'est-à-dire sans que l'adversaire n'ait marqué aucun point pendant cette série, le jeu est gagné bredouille et vaut 2 trous. Cet avantage du jeu double est valable aussi bien pour le premier joueur à marquer que pour le second. Le premier marque avec un seul jeton et peut gagner le jeu bredouille tant que son adversaire ne marque rien. Mais alors, si ce dernier marque, il le fait avec un double jeton nommé « bredouille » et continue de marquer ainsi tant que le premier joueur ne marque pas de nouveaux points. S'il marque ainsi au moins 12 points, il gagne le jeu bredouille en second. Mais auparavant, si le premier joueur vient à marquer à nouveau, il débredouille son adversaire en lui ôtant un des deux jetons et alors aucun des joueurs ne peut plus gagner le jeu bredouille. Par conséquent, lorsque les deux joueurs ont chacun une marque avec un simple jeton, le jeu sera nécessairement gagné simple par l'un ou l'autre joueur.
La marque des jeux s'effectue au moyen des fichets.
Chaque joueur marque les jeux qu'il a gagnés en faisant progresser son fichet dans la série de trous pratiqués dans les bandes à la base des douze flèches de son petit jan et de son grand jan. Le premier trou se trouve à la base de son talon et le douzième et dernier à la base de son coin de repos.
Les trous acquis doivent se marquer avant de toucher aux jetons. Le cas échéant, on démarque ensuite le jeton (bredouillé ou non) de l'adversaire en le remettant à zéro au talon. Puis, on démarque également son jeton (bredouillé ou non) si on a obtenu juste 12 points ; ou bien on marque l'excédent, appelé points de reste, avec un jeton à la manière normale.
Si, lors du même coup on donne des points à l'adversaire, celui-ci devra ensuite les marquer en partant de zéro avec un ou deux jetons selon qu'on a marqué ou non des points de reste.
On peut gagner plusieurs jeux, simples et doubles, dans un même coup.
A la partie à écrire, le marqué peut être gagné simple, double ou quadruple selon que les trous qui le composent ont été ou non pris d'affilée (les autres marqués multiples sont rares et ne peuvent se jouer que par convention).
Si les trous n'ont pas été pris d'affilée, le marqué est simple. Si au moins 6 trous ont été pris d'affilée, le marqué est gagné en petite bredouille et compte double. Si au moins 12 trous ont été pris d'affilée, le marqué est gagné en grande bredouille et compte quadruple.
Comme pour le jeu bredouille, cet avantage est valable pour le premier ainsi que pour le second joueur à marquer des trous. Le second joueur prend alors le pavillon qu'il met à l'emplacement initial de son fichet dans la bande de départ. Si le premier joueur gagne de
nouveaux trous, il ôte le pavillon à son adversaire et le remet dans le trou central. Alors le marqué sera nécessairement gagné simple par l'un ou l'autre joueur.
## ARTICLE XVI : LA TENUE
Lorsqu'on gagne un ou plusieurs jeux grâce aux points obtenus par son propre coup de dés, on a le choix entre tenir (rester) ou user du privilège de s'en aller. Mais si les points qui donnent le ou les trous proviennent du coup de l'adversaire (jan-qui-ne-peut, écoles), on doit obligatoirement tenir.
Quand on tient, après avoir marqué son ou ses trous, on démarque le cas échéant son adversaire, on marque éventuellement ses points de reste et on continue de jouer normalement. L'adversaire marque ensuite les points que pourrait lui rapporter ce coup (voir article XV).
Quand on décide de s'en aller, après avoir marqué son ou ses trous, on prévient verbalement son adversaire de son intention, car celui-ci peut s'y opposer en cas de faute ou d'école. Quand ce dernier a agréé verbalement ou en commençant à rompre son jeu, on démarque alors tous les jetons, et les dames des deux camps sont toutes relevées et remises au talon. Seuls les trous gagnés restent acquis aux joueurs. Aucun point de reste ne peut être
marqué et l'adversaire ne peut pas marquer de points ou de trous pour ce coup. La partie reprend comme au début et, par privilège, le joueur qui sen est allé a la primauté du dé pour ce relevé . Il rejette donc les dés et joue.
A la partie à écrire, à partir du moment où un joueur a marqué au moins six trous, si l'un ou l'autre joueur s'en va, le marqué est terminé.
Le gagnant est alors celui qui a marqué le plus de trous. En cas d'égalité, le marqué est nul et il y a refait.
Ainsi, pour gagner une grande bredouille, il convient donc de toujours tenir à partir du sixième trou, acquis en petite bredouille, jusqu'à l'obtention du douzième et pouvoir sen aller ; sinon on continue à jouer pour un treizième trou ou davantage si nécessaire ou si on le souhaite. Il faut aussi que l'adversaire ne marque pas un trou et sen aille, ce qui terminerait ainsi le marqué simple ; mais dans le cas où celui-ci déciderait de tenir, il pourrait gagner aussi une grande bredouille en jouant sans jamais sen aller jusquà lobtention de douze trous, ou plus si on en a déjà soi-même marqués davantage ou simplement sil le souhaite. Alors il doit pouvoir sen aller pour terminer le marqué.
## ARTICLE XVII : LES FAUTES ET LES ÉCOLES
Il y a trois types de faute à ce jeu :
1°) Les simples fautes, peu préjudiciables à l'adversaire et dont certaines peuvent être rectifiées normalement (exemples : jouer hors tour, lancer les dés hors de la table, bousculer accidentellement la disposition du jeu, ou bien oublier de marquer une école). On n'encourt aucune sanction pour ces fautes.
2°) Les fautes de case ou fausse case. Elles peuvent être préjudiciables et se produisent lorsqu'on ne joue pas ses dames aux cases où l'on devrait en fonction des nombres obtenus ou si l'on viole une loi du jeu en jouant ses dames d'une manière interdite (lois relatives au coin de repos, aux jans interdits, au remplissage et à la conservation). Les fautes de case peuvent susciter une école lorsquon a marqué des points pour un jan et quon ne le réalise pas effectivement alors quon y est obligé par la règle (ex : marquer pour remplir ou pour conserver et ne pas le faire). Alors, en plus du respect que l'on doit à la règle : « dame touchée, dame abandonnée, dame jouée », sauf si on a prévenu en disant : « j'adoube », on doit se soumettre à la décision de l'adversaire quant à l'éventuelle rectification de la faute et à la manière de le faire.
Ladversaire doit signaler la ou les fautes avant de jeter les dés pour son propre coup ; il a la liberté de rectifier cette ou ces fautes selon son intérêt, tout en respectant les règles, ou bien de laisser le jeu tel quel sans rectification. Lorsquon a pris son coin par puissance alors quon pouvait le prendre par effet, on peut être empêché par ladversaire de le prendre lors de ce coup si la faute est reconnue et quil est possible de jouer autrement. Si par fausse case on a couvert une dame en demi-case, on peut aussi être empêché de la couvrir lors du même coup si la faute est reconnue.
3°) Les fautes de marque sont toujours préjudiciables ; elles ont lieu lorsqu'on oublie de marquer des points ou des trous, lorsqu'on en marque trop, ou lorsqu'on les marque incorrectement. On peut alors être pénalisé et « envoyé à l'école » par l'adversaire. Cette faute ou école, est consommée lorsqu'on a jeté les dés ou touché ses dames pour jouer ou encore lorsqu'on a trop avancé son jeton de marque et qu'on l'a lâché. Dans certains cas, lécole est consommée dès quon a fait une déclaration intentionnelle (sen aller, école, invitation à jouer).
L'école rapporte autant de points à l'adversaire qu'on en a marqué en moins ou en trop lors d'un coup. De plus, dans ce dernier cas, l'adversaire rétablit correctement la marque en supprimant les points qu'on a indûment marqués. En marquant ces points d'école, ou juste après l'avoir fait, l'adversaire doit le signaler en disant: « Ecole ! » ou « tant de points décole ».
Nul n'est tenu de marquer une école, on ne fait donc pas « école d'école ». Mais si on la marque, elle doit l'être dans son intégralité sous peine de rectification si l'adversaire le désire. On peut aussi forcer le joueur fautif à rectifier correctement sa marque, sans marquer l'école.
On fait une fausse école lorsqu'on marque, soit incorrectement une école, soit une école qui n'existe pas. C'est donc une école que l'adversaire joueur peut marquer normalement à son avantage.
Il y a augmentation décole, lorsquun joueur a fait une école que son adversaire a marquée et que, pensant que ce dernier sest trompé, il démarque cette école et les marque à son profit pour fausse école ; mais ladversaire, persistant à croire lécole justifiée, démarque alors le joueur, rectifie la première école et ajoute encore à son profit les points pour cette deuxième école (des points marqués pour la soi-disant fausse école). Laffaire pourrait continuer selon le même scénario indéfiniment si les joueurs nen venaient alors à une franche explication.
A tout moment un joueur peut demander à son adversaire la raison des points quil marque (pour école ou autres cas) ou quil lui enlève. Ce dernier doit alors sexpliquer.
On ne fait pas école de trou en marquant simple un jeu gagné bredouille. Mais on fait école de points pour les trous quon omet de marquer en raison des points gagnés ou pour les trous marqués en trop pour des points non gagnés.
Les points d'école se marquent après tous les autres.
## ARTICLE XVIII : LE DÉROULEMENT DES COUPS
Pour que le coup soit régulier, chaque joueur doit obligatoirement agir dans cet ordre :
Dès la fin du coup de ladversaire :
1. Le cas échéant, marquer les jan-qui-ne-peut ou les contre-jans de l'adversaire.
2. Le cas échéant et si on le souhaite, marquer les écoles de l'adversaire et le signaler en disant : « École ! » ; éventuellement rectifier les fausses cases et la marque.
Ensuite :
3. Jeter les dés pour son coup. Le cas échéant, l'adversaire marque alors les écoles qu'on a faites relativement aux 1° et 2°. Règlement des fausses écoles et augmentations décole.
4. Le cas échéant, marquer pour les jans de départ, les jans de récompense (batteries des dames et du coin), les jans remplis ou conservés, ou les points pour la sortie.
5. Le cas échéant, décider de tenir ou de s'en aller :
- Tenir : démarquer les points et marquer éventuellement ses propres points de reste.
- S'en aller : l'annoncer puis rompre son jeu après accord de ladversaire. Ensuite, relever ses dames, démarquer tous les points et jeter de nouveau les dés pour jouer (sauf si la partie ou le marqué est terminé).
6. En cas de sortie : relever ses dames, ne pas démarquer les points des joueurs et jeter de nouveau les dés pour jouer.
7. Jouer les deux nombres obtenus par les dés si on le peut.
C'est alors à l'adversaire de jouer selon le même processus.
8. Le cas échéant, marquer les écoles que l'adversaire a pu faire, relativement à son 1° et 2°, dès qu'il a jeté les dés, ou rompre ceux-ci pour procéder à la rectification de la marque.
Le non-respect de cet ordre de jeu est une faute, qui peut éventuellement être pénalisée par l'adversaire.
## ARTICLE XIX : LA PARTIE À ÉCRIRE
### LE DÉROULEMENT DE LA PARTIE À TROIS OU QUATRE JOUEURS
Le nombre de marqués choisi pour la partie doit être un multiple du nombre de joueurs participants, de sorte qu'un joueur puisse rencontrer chacun de ses adversaires autant de fois que ceux-ci se rencontrent entre eux.
A trois joueurs, on tire au sort ceux qui vont jouer le premier marqué.
Ensuite, c'est celui qui tire le plus fort nombre qui commence avec les dames blanches et joue les deux nombres obtenus.
Pour le deuxième marqué, le gagnant est remplacé par le troisième joueur, mais la primauté du dé appartient à celui qui est resté à la table.
Pour le marqué suivant, le troisième joueur reste à la table mais les autres alternent.
Chacun joue ainsi deux marqués de suite avec des adversaires différents. Seul le gagnant du premier marqué ne joue qu'une seule fois au début de la partie et à la fin.
En cas de refait, les mêmes joueurs restent pour rejouer et celui qui avait la primauté du dé au marqué nul, la conserve.
A quatre joueurs, on joue en équipe de deux. Les équipiers partagent les gains et les pertes.
On procède comme pour trois joueurs et chacun joue deux marqués de suite, un contre chaque adversaire, et cède ensuite la place à son partenaire. Pareillement, seul le gagnant du premier marqué ne joue qu'une seule fois, au début et à la fin de la partie.
Les joueurs qui ne participent pas au marqué en cours peuvent conseiller ceux qui sont à la table (adversaires à trois joueurs ou équipiers à quatre) selon leur intérêt ; mais il leur est interdit de toucher aux éléments du jeu.
### LES PAIEMENTS
Chaque marqué est payé au vainqueur autant de jetons qu'il a acquis de trous, déduction faite de ceux du vaincu. Ce dernier paye en outre une consolation de deux jetons supplémentaires au vainqueur ainsi qu'à l'autre joueur si la partie se joue à trois.
Si le marqué est gagné en petite bredouille, chaque trou acquis par le vainqueur est alors payé double (2 jetons) ainsi que la consolation (4 jetons).
Si le marqué est gagné en grande bredouille, chaque trou acquis par le vainqueur est alors payé quadruple (4 jetons) ainsi que la consolation (8 jetons).
Chaque trou acquis par le vaincu est déduit du compte et ne vaut toujours qu'un seul jeton.
En cas de refait, le prix de la consolation est le double de celui du marqué nul précédent. Il double à chaque refait successif.
A trois joueurs, le vaincu doit toujours payer de surcroît la consolation à celui qui n'a pas joué ce marqué, quel que soit le prix de cette consolation.
De plus, à chaque défaite subie, le vaincu met un jeton (parfois deux) de côté pour en indiquer le nombre et permettre le règlement ultérieur des paris.
L'ensemble de ces jetons forme une queue qui revient à la fin de la partie au joueur ayant gagné le plus de jetons lors des marqués. En cas d'égalité, cette queue de jetons doit être divisée équitablement entre les joueurs gagnants.
Il n'est pas obligatoire de recourir à cette queue de jetons lorsque la marque est tenue par écrit, mais on peut la compter par convention.
Ensuite, chaque joueur règle équitablement les paris qu'il a perdus avec chacun de ses adversaires.
On appelle pari tout marqué qui excède le contingent de chaque joueur. Ce contingent correspond à la moyenne des marqués joués entre deux adversaires.
Ainsi, si deux joueurs s'affrontent en huit marqués, le contingent de chacun est de quatre, et tout marqué gagné ou perdu par un joueur au delà de ces quatre est un pari gagné ou perdu. Ce gain ou cette perte est double puisquun pari gagné par un joueur est en plus un pari perdu pour son adversaire : si un joueur gagne cinq marqués, lautre nen peut gagner que trois.
Le premier double pari s'appelle le postillon et se paie 28 jetons, dont 20 de queue ; chacun des paris suivants se paie 8 jetons. Ce paiement s'effectue entre chaque joueur. A trois joueurs, il est ainsi possible de gagner ou de perdre deux postillons entre autres paris, un par adversaire.
Enfin, on procède au règlement définitif de la partie en convertissant les gains des joueurs en fiches, ou en tout autre équivalent, dont la valeur a été préalablement établie par les joueurs (une fiche peut, par exemple, valoir 5 jetons).
## ARTICLE XX : FIN DE LA PARTIE
### LA PARTIE ORDINAIRE
La partie ordinaire, appelée aussi le « tour » de trictrac, est terminée quand un joueur obtient son douzième et dernier trou. Ce trou peut être obtenu grâce aux points gagnés par son propre coup de dé ou par celui de ladversaire ; il nest pas nécessaire de pouvoir sen aller pour terminer le tour.
Selon convention préalable, la partie peut être gagnée simple ou double. Elle est gagnée double, cest-à-dire en grande bredouille, lorsquun joueur réussit à marquer les douze trous daffilée. Le second joueur à marquer peut jouir aussi de cette faculté en prenant le pavillon et en le gardant jusquà parvenir à marquer les douze trous sans que le premier joueur ne marque à nouveau et ne lui enlève alors le pavillon. Au cas où aucun des joueurs ne réussit à marquer les douze trous daffilée, la partie est gagnée simple.
Une autre convention permet de gagner la partie quadruple quand le gagnant a été le seul à marquer. Alors la partie est gagnée triple par le second joueur à marquer sil réalise la grande bredouille comme indiqué précédemment. La partie est gagnée double si, tous les joueurs étant débredouillés, le perdant na pas réussi à marquer au moins six trous, sinon elle est gagnée simple.
On procède alors au règlement en fonction de lenjeu établi avant le début de la partie.
La partie est terminée lorsque le perdant sest acquitté de sa dette envers le gagnant.
### LA PARTIE À ÉCRIRE
Comme indiqué à larticle II, la partie à écrire consiste en un certain nombre de marqués convenu à lavance entre les joueurs. Lorsque ces marqués ont été joués, ainsi que les éventuels refaits, on procède au règlement de la partie en effectuant le compte et les paiements comme indiqué à larticle XIX.
La partie est terminée lorsque toutes les dettes ont été réglées au(x) vainqueur(s).
### ANNEXE : TARIF
Cest le tableau récapitulatif de la valeur en points de toutes les rencontres : jans et figures de ce jeu.
Lire « J » pour le joueur (celui qui a lancé les dés) et « A » pour ladversaire : ils désignent le bénéficiaire. Les chiffres indiquent le nombre de points gagnés.
| RENCONTRES | | occurrence | Par dé simple | Par doublet |
| ------------------------------------------------ | --- | ---------- | ------------- | ----------- |
| Jan de six tables (jan de trois coups) | J | | 4 | - |
| Jan de deux tables | J | | 4 | 6 |
| Contre-jan de deux tables | A | | 4 | 6 |
| Jan de mézéas | J | | 4 | 6 |
| Contre-jan de mézéas | A | | 4 | 6 |
| Petit jan rempli | J | Par façon | 4 | 6 |
| Petit jan conservé | J | | 4 | 6 |
| Grand jan rempli | J | Par façon | 4 | 6 |
| Grand jan conservé | J | | 4 | 6 |
| Jan de retour rempli | J | Par façon | 4 | 6 |
| Jan de retour conservé | J | | 4 | 6 |
| Dame battue à vrai dans la table des petits jans | J | Par façon | 4 | 6 |
| Dame battue à faux dans la table des petits jans | A | Par façon | 4 | 6 |
| Dame battue à vrai dans la table des grands jans | J | Par façon | 2 | 4 |
| Dame battue à faux dans la table des grands jans | A | Par façon | 2 | 4 |
| Coin battu | J | | 4 | 6 |
| Sortie (de la dernière dame) | J | | 4 | 6 |
| Impuissance (nombre non joué) | A | Par nombre | 2 | 2 |
| Pile de misère réalisée | J | | 4 | 6 |
| Pile de misère conservée | J | | 4 | 6 |
Les écoles valent à ladversaire le nombre de points exact quon aurait dû marquer en plus ou en moins par rapport à ceux quon a réellement marqués.

417
doc/refs/outputs.md Normal file
View file

@ -0,0 +1,417 @@
# Outputs
## 50 episodes - 1000 steps max - desktop
{"episode": 0, "reward": -1798.7162, "steps count": 1000, "duration": 11}
{"episode": 1, "reward": -1794.8162, "steps count": 1000, "duration": 32}
{"episode": 2, "reward": -1387.7109, "steps count": 1000, "duration": 58}
{"episode": 3, "reward": -42.5005, "steps count": 1000, "duration": 82}
{"episode": 4, "reward": -48.2005, "steps count": 1000, "duration": 109}
{"episode": 5, "reward": 1.2000, "steps count": 1000, "duration": 141}
{"episode": 6, "reward": 8.8000, "steps count": 1000, "duration": 184}
{"episode": 7, "reward": 6.9002, "steps count": 1000, "duration": 219}
{"episode": 8, "reward": 16.5001, "steps count": 1000, "duration": 248}
{"episode": 9, "reward": -2.6000, "steps count": 1000, "duration": 281}
{"episode": 10, "reward": 3.0999, "steps count": 1000, "duration": 324}
{"episode": 11, "reward": -34.7004, "steps count": 1000, "duration": 497}
{"episode": 12, "reward": -15.7998, "steps count": 1000, "duration": 466}
{"episode": 13, "reward": 6.9000, "steps count": 1000, "duration": 496}
{"episode": 14, "reward": 6.3000, "steps count": 1000, "duration": 540}
{"episode": 15, "reward": -2.6000, "steps count": 1000, "duration": 581}
{"episode": 16, "reward": -33.0003, "steps count": 1000, "duration": 641}
{"episode": 17, "reward": -36.8000, "steps count": 1000, "duration": 665}
{"episode": 18, "reward": -10.1997, "steps count": 1000, "duration": 753}
{"episode": 19, "reward": -88.1014, "steps count": 1000, "duration": 837}
{"episode": 20, "reward": -57.5002, "steps count": 1000, "duration": 881}
{"episode": 21, "reward": -17.7997, "steps count": 1000, "duration": 1159}
{"episode": 22, "reward": -25.4000, "steps count": 1000, "duration": 1235}
{"episode": 23, "reward": -104.4013, "steps count": 995, "duration": 1290}
{"episode": 24, "reward": -268.6004, "steps count": 1000, "duration": 1322}
{"episode": 25, "reward": -743.6052, "steps count": 1000, "duration": 1398}
{"episode": 26, "reward": -821.5029, "steps count": 1000, "duration": 1427}
{"episode": 27, "reward": -211.5993, "steps count": 1000, "duration": 1409}
{"episode": 28, "reward": -276.1974, "steps count": 1000, "duration": 1463}
{"episode": 29, "reward": -222.9980, "steps count": 1000, "duration": 1509}
{"episode": 30, "reward": -298.9973, "steps count": 1000, "duration": 1560}
{"episode": 31, "reward": -164.0011, "steps count": 1000, "duration": 1752}
{"episode": 32, "reward": -221.0990, "steps count": 1000, "duration": 1807}
{"episode": 33, "reward": -260.9996, "steps count": 1000, "duration": 1730}
{"episode": 34, "reward": -420.5959, "steps count": 1000, "duration": 1767}
{"episode": 35, "reward": -407.2964, "steps count": 1000, "duration": 1815}
{"episode": 36, "reward": -291.2966, "steps count": 1000, "duration": 1870}
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting
error: Recipe `trainbot` was terminated on line 24 by signal 6
## 50 episodes - 700 steps max - desktop
const MEMORY_SIZE: usize = 4096;
const DENSE_SIZE: usize = 128;
const EPS_DECAY: f64 = 1000.0;
const EPS_START: f64 = 0.9;
const EPS_END: f64 = 0.05;
> Entraînement
> {"episode": 0, "reward": -862.8993, "steps count": 700, "duration": 6}
> {"episode": 1, "reward": -418.8971, "steps count": 700, "duration": 13}
> {"episode": 2, "reward": -64.9999, "steps count": 453, "duration": 14}
> {"episode": 3, "reward": -142.8002, "steps count": 700, "duration": 31}
> {"episode": 4, "reward": -74.4004, "steps count": 700, "duration": 45}
> {"episode": 5, "reward": -40.2002, "steps count": 700, "duration": 58}
> {"episode": 6, "reward": -21.1998, "steps count": 700, "duration": 70}
> {"episode": 7, "reward": 99.7000, "steps count": 642, "duration": 79}
> {"episode": 8, "reward": -5.9999, "steps count": 700, "duration": 99}
> {"episode": 9, "reward": -7.8999, "steps count": 700, "duration": 118}
> {"episode": 10, "reward": 92.5000, "steps count": 624, "duration": 117}
> {"episode": 11, "reward": -17.1998, "steps count": 700, "duration": 144}
> {"episode": 12, "reward": 1.7000, "steps count": 700, "duration": 157}
> {"episode": 13, "reward": -7.9000, "steps count": 700, "duration": 172}
> {"episode": 14, "reward": -7.9000, "steps count": 700, "duration": 196}
> {"episode": 15, "reward": -2.8000, "steps count": 700, "duration": 214}
> {"episode": 16, "reward": 16.8002, "steps count": 700, "duration": 250}
> {"episode": 17, "reward": -47.7001, "steps count": 700, "duration": 272}
> k{"episode": 18, "reward": -13.6000, "steps count": 700, "duration": 288}
> {"episode": 19, "reward": -79.9002, "steps count": 700, "duration": 304}
> {"episode": 20, "reward": -355.5985, "steps count": 700, "duration": 317}
> {"episode": 21, "reward": -205.5001, "steps count": 700, "duration": 333}
> {"episode": 22, "reward": -207.3974, "steps count": 700, "duration": 348}
> {"episode": 23, "reward": -161.7999, "steps count": 700, "duration": 367}
---
const MEMORY_SIZE: usize = 8192;
const DENSE_SIZE: usize = 128;
const EPS_DECAY: f64 = 10000.0;
const EPS_START: f64 = 0.9;
const EPS_END: f64 = 0.05;
> Entraînement
> {"episode": 0, "reward": -1119.9921, "steps count": 700, "duration": 6}
> {"episode": 1, "reward": -928.6963, "steps count": 700, "duration": 13}
> {"episode": 2, "reward": -364.5009, "steps count": 380, "duration": 11}
> {"episode": 3, "reward": -797.5981, "steps count": 700, "duration": 28}
> {"episode": 4, "reward": -577.5994, "steps count": 599, "duration": 34}
> {"episode": 5, "reward": -725.2992, "steps count": 700, "duration": 49}
> {"episode": 6, "reward": -638.8995, "steps count": 700, "duration": 59}
> {"episode": 7, "reward": -1039.1932, "steps count": 700, "duration": 73}
> field invalid : White, 3, Board { positions: [13, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -2, 0, -11] }
thread 'main' panicked at store/src/game.rs:556:65:
called `Result::unwrap()` on an `Err` value: FieldInvalid
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: Recipe `trainbot` failed on line 27 with exit code 101
---
# [allow(unused)]
const MEMORY_SIZE: usize = 8192;
const DENSE_SIZE: usize = 256;
const EPS_DECAY: f64 = 10000.0;
const EPS_START: f64 = 0.9;
const EPS_END: f64 = 0.05;
> Entraînement
> {"episode": 0, "reward": -1102.6925, "steps count": 700, "duration": 9}
> field invalid : White, 6, Board { positions: [14, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, 0, 0, -13] }
thread 'main' panicked at store/src/game.rs:556:65:
called `Result::unwrap()` on an `Err` value: FieldInvalid
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: Recipe `trainbot` failed on line 27 with exit code 101
---
const MEMORY_SIZE: usize = 8192;
const DENSE_SIZE: usize = 256;
const EPS_DECAY: f64 = 1000.0;
const EPS_START: f64 = 0.9;
const EPS_END: f64 = 0.05;
> Entraînement
> {"episode": 0, "reward": -1116.2921, "steps count": 700, "duration": 9}
> {"episode": 1, "reward": -1116.2922, "steps count": 700, "duration": 18}
> {"episode": 2, "reward": -1119.9921, "steps count": 700, "duration": 29}
> {"episode": 3, "reward": -1089.1927, "steps count": 700, "duration": 41}
> {"episode": 4, "reward": -1116.2921, "steps count": 700, "duration": 53}
> {"episode": 5, "reward": -684.8043, "steps count": 700, "duration": 66}
> {"episode": 6, "reward": 0.3000, "steps count": 700, "duration": 80}
> {"episode": 7, "reward": 2.0000, "steps count": 700, "duration": 96}
> {"episode": 8, "reward": 30.9001, "steps count": 700, "duration": 112}
> {"episode": 9, "reward": 0.3000, "steps count": 700, "duration": 128}
> {"episode": 10, "reward": 0.3000, "steps count": 700, "duration": 141}
> {"episode": 11, "reward": 8.8000, "steps count": 700, "duration": 155}
> {"episode": 12, "reward": 7.1000, "steps count": 700, "duration": 169}
> {"episode": 13, "reward": 17.3001, "steps count": 700, "duration": 190}
> {"episode": 14, "reward": -107.9005, "steps count": 700, "duration": 210}
> {"episode": 15, "reward": 7.1001, "steps count": 700, "duration": 236}
> {"episode": 16, "reward": 17.3001, "steps count": 700, "duration": 268}
> {"episode": 17, "reward": 7.1000, "steps count": 700, "duration": 283}
> {"episode": 18, "reward": -5.9000, "steps count": 700, "duration": 300}
> {"episode": 19, "reward": -36.8009, "steps count": 700, "duration": 316}
> {"episode": 20, "reward": 19.0001, "steps count": 700, "duration": 332}
> {"episode": 21, "reward": 113.3000, "steps count": 461, "duration": 227}
> field invalid : White, 1, Board { positions: [0, 2, 2, 0, 2, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -3, -7, -2, -1, 0, -1, -1] }
thread 'main' panicked at store/src/game.rs:556:65:
called `Result::unwrap()` on an `Err` value: FieldInvalid
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: Recipe `trainbot` failed on line 27 with exit code 101
---
num_episodes: 50,
// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant
// max_steps: 700, // must be set in environment.rs with the MAX_STEPS constant
dense_size: 256, // neural network complexity
eps_start: 0.9, // epsilon initial value (0.9 => more exploration)
eps_end: 0.05,
eps_decay: 1000.0,
> Entraînement
> {"episode": 0, "reward": -1118.8921, "steps count": 700, "duration": 9}
> {"episode": 1, "reward": -1119.9921, "steps count": 700, "duration": 17}
> {"episode": 2, "reward": -1118.8921, "steps count": 700, "duration": 28}
> {"episode": 3, "reward": -283.5977, "steps count": 700, "duration": 41}
> {"episode": 4, "reward": -23.4998, "steps count": 700, "duration": 54}
> {"episode": 5, "reward": -31.9999, "steps count": 700, "duration": 68}
> {"episode": 6, "reward": 2.0000, "steps count": 700, "duration": 82}
> {"episode": 7, "reward": 109.3000, "steps count": 192, "duration": 26}
> {"episode": 8, "reward": -4.8000, "steps count": 700, "duration": 102}
> {"episode": 9, "reward": 15.6001, "steps count": 700, "duration": 124}
> {"episode": 10, "reward": 15.6002, "steps count": 700, "duration": 144}
> {"episode": 11, "reward": -65.7008, "steps count": 700, "duration": 162}
> {"episode": 12, "reward": 19.0002, "steps count": 700, "duration": 182}
> {"episode": 13, "reward": 20.7001, "steps count": 700, "duration": 197}
> {"episode": 14, "reward": 12.2002, "steps count": 700, "duration": 229}
> {"episode": 15, "reward": -32.0007, "steps count": 700, "duration": 242}
> {"episode": 16, "reward": 10.5000, "steps count": 700, "duration": 287}
> {"episode": 17, "reward": 24.1001, "steps count": 700, "duration": 318}
> {"episode": 18, "reward": 25.8002, "steps count": 700, "duration": 335}
> {"episode": 19, "reward": 29.2001, "steps count": 700, "duration": 367}
> {"episode": 20, "reward": 9.1000, "steps count": 700, "duration": 366}
> {"episode": 21, "reward": 3.7001, "steps count": 700, "duration": 398}
> {"episode": 22, "reward": 10.5000, "steps count": 700, "duration": 417}
> {"episode": 23, "reward": 10.5000, "steps count": 700, "duration": 438}
> {"episode": 24, "reward": 13.9000, "steps count": 700, "duration": 444}
> {"episode": 25, "reward": 7.1000, "steps count": 700, "duration": 486}
> {"episode": 26, "reward": 12.2001, "steps count": 700, "duration": 499}
> {"episode": 27, "reward": 8.8001, "steps count": 700, "duration": 554}
> {"episode": 28, "reward": -6.5000, "steps count": 700, "duration": 608}
> {"episode": 29, "reward": -3.1000, "steps count": 700, "duration": 633}
> {"episode": 30, "reward": -32.0001, "steps count": 700, "duration": 696}
> {"episode": 31, "reward": 22.4002, "steps count": 700, "duration": 843}
> {"episode": 32, "reward": -77.9004, "steps count": 700, "duration": 817}
> {"episode": 33, "reward": -368.5993, "steps count": 700, "duration": 827}
> {"episode": 34, "reward": -254.6986, "steps count": 700, "duration": 852}
> {"episode": 35, "reward": -433.1992, "steps count": 700, "duration": 884}
> {"episode": 36, "reward": -521.6010, "steps count": 700, "duration": 905}
> {"episode": 37, "reward": -71.1004, "steps count": 700, "duration": 930}
> {"episode": 38, "reward": -251.0004, "steps count": 700, "duration": 956}
> {"episode": 39, "reward": -594.7045, "steps count": 700, "duration": 982}
> {"episode": 40, "reward": -154.4001, "steps count": 700, "duration": 1008}
> {"episode": 41, "reward": -171.3994, "steps count": 700, "duration": 1033}
> {"episode": 42, "reward": -118.7004, "steps count": 700, "duration": 1059}
> {"episode": 43, "reward": -137.4003, "steps count": 700, "duration": 1087}
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting
error: Recipe `trainbot` was terminated on line 27 by signal 6
---
num_episodes: 40,
// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant
// max_steps: 1500, // must be set in environment.rs with the MAX_STEPS constant
dense_size: 256, // neural network complexity
eps_start: 0.9, // epsilon initial value (0.9 => more exploration)
eps_end: 0.05,
eps_decay: 1000.0,
> Entraînement
> {"episode": 0, "reward": -2399.9993, "steps count": 1500, "duration": 31}
> {"episode": 1, "reward": -2061.6736, "steps count": 1500, "duration": 81}
> {"episode": 2, "reward": -48.9010, "steps count": 1500, "duration": 145}
> {"episode": 3, "reward": 3.8000, "steps count": 1500, "duration": 215}
> {"episode": 4, "reward": -6.3999, "steps count": 1500, "duration": 302}
> {"episode": 5, "reward": 20.8004, "steps count": 1500, "duration": 374}
> {"episode": 6, "reward": 49.6992, "steps count": 1500, "duration": 469}
> {"episode": 7, "reward": 29.3002, "steps count": 1500, "duration": 597}
> {"episode": 8, "reward": 34.3999, "steps count": 1500, "duration": 710}
> {"episode": 9, "reward": 115.3003, "steps count": 966, "duration": 515}
> {"episode": 10, "reward": 25.9004, "steps count": 1500, "duration": 852}
> {"episode": 11, "reward": -122.0007, "steps count": 1500, "duration": 1017}
> {"episode": 12, "reward": -274.9966, "steps count": 1500, "duration": 1073}
> {"episode": 13, "reward": 54.8994, "steps count": 651, "duration": 518}
> {"episode": 14, "reward": -439.8978, "steps count": 1500, "duration": 1244}
> {"episode": 15, "reward": -506.1997, "steps count": 1500, "duration": 1676}
> {"episode": 16, "reward": -829.5031, "steps count": 1500, "duration": 1855}
> {"episode": 17, "reward": -545.2961, "steps count": 1500, "duration": 1892}
> {"episode": 18, "reward": -795.2026, "steps count": 1500, "duration": 2008}
> {"episode": 19, "reward": -637.1031, "steps count": 1500, "duration": 2124}
> {"episode": 20, "reward": -989.6997, "steps count": 1500, "duration": 2241}
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting
error: Recipe `trainbot` was terminated on line 27 by signal 6
---
num_episodes: 40,
// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant
// max_steps: 1000, // must be set in environment.rs with the MAX_STEPS constant
dense_size: 256, // neural network complexity
eps_start: 0.9, // epsilon initial value (0.9 => more exploration)
eps_end: 0.05,
eps_decay: 10000.0,
> Entraînement
> {"episode": 0, "reward": -1598.8848, "steps count": 1000, "duration": 16}
> {"episode": 1, "reward": -1531.9866, "steps count": 1000, "duration": 34}
> {"episode": 2, "reward": -515.6000, "steps count": 530, "duration": 25}
> {"episode": 3, "reward": -396.1008, "steps count": 441, "duration": 27}
> {"episode": 4, "reward": -540.6996, "steps count": 605, "duration": 43}
> {"episode": 5, "reward": -976.0975, "steps count": 1000, "duration": 89}
> {"episode": 6, "reward": -1014.2944, "steps count": 1000, "duration": 117}
> {"episode": 7, "reward": -806.7012, "steps count": 1000, "duration": 140}
> {"episode": 8, "reward": -1276.6891, "steps count": 1000, "duration": 166}
> {"episode": 9, "reward": -1554.3855, "steps count": 1000, "duration": 197}
> {"episode": 10, "reward": -1178.3925, "steps count": 1000, "duration": 219}
> {"episode": 11, "reward": -1457.4869, "steps count": 1000, "duration": 258}
> {"episode": 12, "reward": -1475.8882, "steps count": 1000, "duration": 291}
---
num_episodes: 40,
// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant
// max_steps: 1000, // must be set in environment.rs with the MAX_STEPS constant
dense_size: 256, // neural network complexity
eps_start: 0.9, // epsilon initial value (0.9 => more exploration)
eps_end: 0.05,
eps_decay: 3000.0,
> Entraînement
> {"episode": 0, "reward": -1598.8848, "steps count": 1000, "duration": 15}
> {"episode": 1, "reward": -1599.9847, "steps count": 1000, "duration": 33}
> {"episode": 2, "reward": -751.7018, "steps count": 1000, "duration": 57}
> {"episode": 3, "reward": -402.8979, "steps count": 1000, "duration": 81}
> {"episode": 4, "reward": -289.2985, "steps count": 1000, "duration": 108}
> {"episode": 5, "reward": -231.4988, "steps count": 1000, "duration": 140}
> {"episode": 6, "reward": -138.0006, "steps count": 1000, "duration": 165}
> {"episode": 7, "reward": -145.0998, "steps count": 1000, "duration": 200}
> {"episode": 8, "reward": -60.4005, "steps count": 1000, "duration": 236}
> {"episode": 9, "reward": -35.7999, "steps count": 1000, "duration": 276}
> {"episode": 10, "reward": -42.2002, "steps count": 1000, "duration": 313}
> {"episode": 11, "reward": 69.0002, "steps count": 874, "duration": 300}
> {"episode": 12, "reward": 93.2000, "steps count": 421, "duration": 153}
> {"episode": 13, "reward": -324.9010, "steps count": 866, "duration": 364}
> {"episode": 14, "reward": -1331.3883, "steps count": 1000, "duration": 478}
> {"episode": 15, "reward": -1544.5859, "steps count": 1000, "duration": 514}
> {"episode": 16, "reward": -1599.9847, "steps count": 1000, "duration": 552}
---
Nouveaux points...
num_episodes: 40,
// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant
// max_steps: 1000, // must be set in environment.rs with the MAX_STEPS constant
dense_size: 256, // neural network complexity
eps_start: 0.9, // epsilon initial value (0.9 => more exploration)
eps_end: 0.05,
eps_decay: 3000.0,
> Entraînement
> {"episode": 0, "reward": -1798.1161, "steps count": 1000, "duration": 15}
> {"episode": 1, "reward": -1800.0162, "steps count": 1000, "duration": 34}
> {"episode": 2, "reward": -1718.6151, "steps count": 1000, "duration": 57}
> {"episode": 3, "reward": -1369.5055, "steps count": 1000, "duration": 82}
> {"episode": 4, "reward": -321.5974, "steps count": 1000, "duration": 115}
> {"episode": 5, "reward": -213.2988, "steps count": 1000, "duration": 148}
> {"episode": 6, "reward": -175.4995, "steps count": 1000, "duration": 172}
> {"episode": 7, "reward": -126.1011, "steps count": 1000, "duration": 203}
> {"episode": 8, "reward": -105.1011, "steps count": 1000, "duration": 242}
> {"episode": 9, "reward": -46.3007, "steps count": 1000, "duration": 281}
> {"episode": 10, "reward": -57.7006, "steps count": 1000, "duration": 323}
> {"episode": 11, "reward": -15.7997, "steps count": 1000, "duration": 354}
> {"episode": 12, "reward": -38.6999, "steps count": 1000, "duration": 414}
> {"episode": 13, "reward": 10.7002, "steps count": 1000, "duration": 513}
> {"episode": 14, "reward": -10.1999, "steps count": 1000, "duration": 585}
> {"episode": 15, "reward": -8.3000, "steps count": 1000, "duration": 644}
> {"episode": 16, "reward": -463.4984, "steps count": 973, "duration": 588}
> {"episode": 17, "reward": -148.8951, "steps count": 1000, "duration": 646}
> {"episode": 18, "reward": 3.0999, "steps count": 1000, "duration": 676}
> {"episode": 19, "reward": -12.0999, "steps count": 1000, "duration": 753}
> {"episode": 20, "reward": 6.9000, "steps count": 1000, "duration": 801}
> {"episode": 21, "reward": 14.5001, "steps count": 1000, "duration": 850}
> {"episode": 22, "reward": -19.6999, "steps count": 1000, "duration": 937}
> {"episode": 23, "reward": 83.0000, "steps count": 456, "duration": 532}
> {"episode": 24, "reward": -13.9998, "steps count": 1000, "duration": 1236}
> {"episode": 25, "reward": 25.9003, "steps count": 1000, "duration": 1264}
> {"episode": 26, "reward": 1.2002, "steps count": 1000, "duration": 1349}
> {"episode": 27, "reward": 3.1000, "steps count": 1000, "duration": 1364}
> {"episode": 28, "reward": -6.4000, "steps count": 1000, "duration": 1392}
> {"episode": 29, "reward": -4.4998, "steps count": 1000, "duration": 1444}
> {"episode": 30, "reward": 3.1000, "steps count": 1000, "duration": 1611}
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting
---
num_episodes: 40,
// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant
// max_steps: 700, // must be set in environment.rs with the MAX_STEPS constant
dense_size: 256, // neural network complexity
eps_start: 0.9, // epsilon initial value (0.9 => more exploration)
eps_end: 0.05,
eps_decay: 3000.0,
{"episode": 0, "reward": -1256.1014, "steps count": 700, "duration": 9}
{"episode": 1, "reward": -1256.1013, "steps count": 700, "duration": 20}
{"episode": 2, "reward": -1256.1014, "steps count": 700, "duration": 31}
{"episode": 3, "reward": -1258.7015, "steps count": 700, "duration": 44}
{"episode": 4, "reward": -1206.8009, "steps count": 700, "duration": 56}
{"episode": 5, "reward": -473.2974, "steps count": 700, "duration": 68}
{"episode": 6, "reward": -285.2984, "steps count": 700, "duration": 82}
{"episode": 7, "reward": -332.6987, "steps count": 700, "duration": 103}
{"episode": 8, "reward": -359.2984, "steps count": 700, "duration": 114}
{"episode": 9, "reward": -118.7008, "steps count": 700, "duration": 125}
{"episode": 10, "reward": -83.9004, "steps count": 700, "duration": 144}
{"episode": 11, "reward": -68.7006, "steps count": 700, "duration": 165}
{"episode": 12, "reward": -49.7002, "steps count": 700, "duration": 180}
{"episode": 13, "reward": -68.7002, "steps count": 700, "duration": 204}
{"episode": 14, "reward": -38.3001, "steps count": 700, "duration": 223}
{"episode": 15, "reward": -19.2999, "steps count": 700, "duration": 240}
{"episode": 16, "reward": -19.1998, "steps count": 700, "duration": 254}
{"episode": 17, "reward": -21.1999, "steps count": 700, "duration": 250}
{"episode": 18, "reward": -26.8998, "steps count": 700, "duration": 280}
{"episode": 19, "reward": -11.6999, "steps count": 700, "duration": 301}
{"episode": 20, "reward": -13.5998, "steps count": 700, "duration": 317}
{"episode": 21, "reward": 5.4000, "steps count": 700, "duration": 334}
{"episode": 22, "reward": 3.5000, "steps count": 700, "duration": 353}
{"episode": 23, "reward": 13.0000, "steps count": 700, "duration": 374}
{"episode": 24, "reward": 7.3001, "steps count": 700, "duration": 391}
{"episode": 25, "reward": -4.1000, "steps count": 700, "duration": 408}
{"episode": 26, "reward": -17.3998, "steps count": 700, "duration": 437}
{"episode": 27, "reward": 11.1001, "steps count": 700, "duration": 480}
{"episode": 28, "reward": -4.1000, "steps count": 700, "duration": 505}
{"episode": 29, "reward": -13.5999, "steps count": 700, "duration": 522}
{"episode": 30, "reward": -0.3000, "steps count": 700, "duration": 540}
{"episode": 31, "reward": -15.4998, "steps count": 700, "duration": 572}
{"episode": 32, "reward": 14.9001, "steps count": 700, "duration": 630}
{"episode": 33, "reward": -4.1000, "steps count": 700, "duration": 729}
{"episode": 34, "reward": 5.4000, "steps count": 700, "duration": 777}
{"episode": 35, "reward": 7.3000, "steps count": 700, "duration": 748}
{"episode": 36, "reward": 9.2001, "steps count": 700, "duration": 767}
{"episode": 37, "reward": 13.0001, "steps count": 700, "duration": 791}
{"episode": 38, "reward": -13.5999, "steps count": 700, "duration": 813}
{"episode": 39, "reward": 26.3002, "steps count": 700, "duration": 838}
> Sauvegarde du modèle de validation
> Modèle de validation sauvegardé : models/burn_dqn_50_model.mpk
> Chargement du modèle pour test
> Chargement du modèle depuis : models/burn_dqn_50_model.mpk
> Test avec le modèle chargé
> Episode terminé. Récompense totale: 70.00, Étapes: 700

182
doc/refs/renet_echo.rs Normal file
View file

@ -0,0 +1,182 @@
use std::{
collections::HashMap,
net::{SocketAddr, UdpSocket},
sync::mpsc::{self, Receiver, TryRecvError},
thread,
time::{Duration, Instant, SystemTime},
};
use renet::{
transport::{
ClientAuthentication, NetcodeClientTransport, NetcodeServerTransport, ServerAuthentication, ServerConfig, NETCODE_USER_DATA_BYTES,
},
ClientId, ConnectionConfig, DefaultChannel, RenetClient, RenetServer, ServerEvent,
};
// Helper struct to pass an username in the user data
struct Username(String);
impl Username {
fn to_netcode_user_data(&self) -> [u8; NETCODE_USER_DATA_BYTES] {
let mut user_data = [0u8; NETCODE_USER_DATA_BYTES];
if self.0.len() > NETCODE_USER_DATA_BYTES - 8 {
panic!("Username is too big");
}
user_data[0..8].copy_from_slice(&(self.0.len() as u64).to_le_bytes());
user_data[8..self.0.len() + 8].copy_from_slice(self.0.as_bytes());
user_data
}
fn from_user_data(user_data: &[u8; NETCODE_USER_DATA_BYTES]) -> Self {
let mut buffer = [0u8; 8];
buffer.copy_from_slice(&user_data[0..8]);
let mut len = u64::from_le_bytes(buffer) as usize;
len = len.min(NETCODE_USER_DATA_BYTES - 8);
let data = user_data[8..len + 8].to_vec();
let username = String::from_utf8(data).unwrap();
Self(username)
}
}
fn main() {
env_logger::init();
println!("Usage: server [SERVER_PORT] or client [SERVER_ADDR] [USER_NAME]");
let args: Vec<String> = std::env::args().collect();
let exec_type = &args[1];
match exec_type.as_str() {
"client" => {
let server_addr: SocketAddr = args[2].parse().unwrap();
let username = Username(args[3].clone());
client(server_addr, username);
}
"server" => {
let server_addr: SocketAddr = format!("0.0.0.0:{}", args[2]).parse().unwrap();
server(server_addr);
}
_ => {
println!("Invalid argument, first one must be \"client\" or \"server\".");
}
}
}
const PROTOCOL_ID: u64 = 7;
fn server(public_addr: SocketAddr) {
let connection_config = ConnectionConfig::default();
let mut server: RenetServer = RenetServer::new(connection_config);
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap();
let server_config = ServerConfig {
current_time,
max_clients: 64,
protocol_id: PROTOCOL_ID,
public_addresses: vec![public_addr],
authentication: ServerAuthentication::Unsecure,
};
let socket: UdpSocket = UdpSocket::bind(public_addr).unwrap();
let mut transport = NetcodeServerTransport::new(server_config, socket).unwrap();
let mut usernames: HashMap<ClientId, String> = HashMap::new();
let mut received_messages = vec![];
let mut last_updated = Instant::now();
loop {
let now = Instant::now();
let duration = now - last_updated;
last_updated = now;
server.update(duration);
transport.update(duration, &mut server).unwrap();
received_messages.clear();
while let Some(event) = server.get_event() {
match event {
ServerEvent::ClientConnected { client_id } => {
let user_data = transport.user_data(client_id).unwrap();
let username = Username::from_user_data(&user_data);
usernames.insert(client_id, username.0);
println!("Client {} connected.", client_id)
}
ServerEvent::ClientDisconnected { client_id, reason } => {
println!("Client {} disconnected: {}", client_id, reason);
usernames.remove_entry(&client_id);
}
}
}
for client_id in server.clients_id() {
while let Some(message) = server.receive_message(client_id, DefaultChannel::ReliableOrdered) {
let text = String::from_utf8(message.into()).unwrap();
let username = usernames.get(&client_id).unwrap();
println!("Client {} ({}) sent text: {}", username, client_id, text);
let text = format!("{}: {}", username, text);
received_messages.push(text);
}
}
for text in received_messages.iter() {
server.broadcast_message(DefaultChannel::ReliableOrdered, text.as_bytes().to_vec());
}
transport.send_packets(&mut server);
thread::sleep(Duration::from_millis(50));
}
}
fn client(server_addr: SocketAddr, username: Username) {
let connection_config = ConnectionConfig::default();
let mut client = RenetClient::new(connection_config);
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap();
let client_id = current_time.as_millis() as u64;
let authentication = ClientAuthentication::Unsecure {
server_addr,
client_id,
user_data: Some(username.to_netcode_user_data()),
protocol_id: PROTOCOL_ID,
};
let mut transport = NetcodeClientTransport::new(current_time, authentication, socket).unwrap();
let stdin_channel: Receiver<String> = spawn_stdin_channel();
let mut last_updated = Instant::now();
loop {
let now = Instant::now();
let duration = now - last_updated;
last_updated = now;
client.update(duration);
transport.update(duration, &mut client).unwrap();
if transport.is_connected() {
match stdin_channel.try_recv() {
Ok(text) => client.send_message(DefaultChannel::ReliableOrdered, text.as_bytes().to_vec()),
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => panic!("Channel disconnected"),
}
while let Some(text) = client.receive_message(DefaultChannel::ReliableOrdered) {
let text = String::from_utf8(text.into()).unwrap();
println!("{}", text);
}
}
transport.send_packets(&mut client).unwrap();
thread::sleep(Duration::from_millis(50));
}
}
fn spawn_stdin_channel() -> Receiver<String> {
let (tx, rx) = mpsc::channel::<String>();
thread::spawn(move || loop {
let mut buffer = String::new();
std::io::stdin().read_line(&mut buffer).unwrap();
tx.send(buffer.trim_end().to_string()).unwrap();
});
rx
}

View file

@ -1,67 +0,0 @@
# Trictrac Vocabulary — French / English
This table maps the French game terminology to the English terms used in this codebase (primarily the `store` crate). Where a code identifier exists, it is shown in `monospace`.
| French | English (code) | Notes |
| -------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
| tablier | board | `Board` |
| case / flèche | field | `Field` (124, 0 = exit); "flèche" (arrow) and "case" both refer to a field/point |
| demi-case | half-field | A field occupied by exactly one checker |
| dame | checker | `Checker`; a playing piece |
| talon | stack | The starting pile of 15 checkers before they are deployed |
| coin de repos / coin | rest corner / corner | `corner`; field 12 (White) or 13 (Black) |
| bande de départ | starting rail | The side rail where stacks start; holds the pegs and flag |
| bande de sortie | exit rail | Same rail, used as an extra field value during exit |
| petit jan | small jan | Fields 16; `is_field_in_small_jan` |
| grand jan | big jan | Fields 712 (White's side, opponent's near zone) |
| jan de retour | return jan | Fields 1924; same fields as opponent's small jan ; where checkers gather before exiting; `last quarter` |
| table des petits jans | small jan table | The board half containing both players' small jans (fields 112) |
| table des grands jans | big jan table | The board half containing both players' big jans (fields 1324) |
| plein (d'un jan) | filled (jan) | All 6 fields of a jan hold ≥ 2 checkers |
| remplir | fill | Scoring event: completing the fill of a jan; `FilledQuarter` |
| conserver | conserve | Scoring event: maintaining a filled jan without breaking it; `FilledQuarter` |
| jan de récompense — battre à vrai | true hit | `TrueHitSmallJan`, `TrueHitBigJan`, `TrueHitOpponentCorner` |
| jan de récompense — battre à faux | false hit | `FalseHitSmallJan`, `FalseHitBigJan` |
| batterie du coin | corner hit | `TrueHitOpponentCorner`; hitting the opponent's empty rest corner |
| jan-qui-ne-peut / impuissance | helpless man | `HelplessMan`; a die value that cannot be played (penalty for opponent) |
| jan de deux tables | two tables jan | `TwoTables` |
| contre-jan de deux tables | contre two tables | `ContreTwoTables` |
| jan de mézéas | mezeas jan | `Mezeas` |
| contre-jan de mézéas | contre mezeas | `ContreMezeas` |
| jan de six tables / jan de trois coups | six tables jan | `SixTables`; also called "three-roll jan" |
| sortie (première) | first player to exit | `FirstPlayerToExit` |
| sortie (nombre sortant) | exit (exact exit) | Moving a checker off the board with an exact die value |
| nombre excédant | overflow number | Die value exceeding the checker's distance to the exit rail |
| nombre défaillant | failing number | A die value that cannot be played within the jan |
| tout d'une | chained move | `chained move`; one checker playing both dice successively |
| repos (case de repos) | rest (resting field) | An intermediate field where a checker pauses in a chained move |
| doublet | double | `is_double`; both dice show the same value |
| dé / dés | die / dice | `Dice` |
| cornet | dice cup | — |
| par puissance | by puissance | `is_move_by_puissance`; taking own corner using opponent's empty corner as virtual step |
| par effet | by effect | `can_take_corner_by_effect`; taking own corner by normal die values |
| d'emblée | simultaneously | Two checkers entering (or leaving) the corner at the same time |
| dédoubler | unstack corner | Using one of the two corner-holding checkers (forbidden for corner exits) |
| trou / jeu | hole | `holes`; 12 points = 1 hole; the primary scoring unit |
| fichet | peg | Physical marker tracking holes won along the board edge |
| jeton | token | Physical marker tracking points within a game (012) |
| pavillon | flag | The bredouille marker taken by the second player to score |
| bredouille | bredouille | `can_bredouille`; winning a hole while opponent scored nothing |
| petite bredouille | small bredouille | Winning a round (marqué) with ≥ 6 consecutive holes |
| grande bredouille | big bredouille | `can_big_bredouille`; winning a round with ≥ 12 consecutive holes |
| relevé | new setting | Resetting checkers to their stacks after a hole or exit |
| primauté | first-move privilege | The right to roll first, held by the player who exited or left first |
| s'en aller | leave / go | `Go` event; choosing to start a new setting after winning a hole |
| tenir | stay / hold | Choosing to continue after winning a hole instead of leaving |
| marqué | round | A scoring round in the "partie à écrire" |
| partie ordinaire | ordinary game | First to 12 holes wins |
| partie à écrire | scored game | Multi-round game played for tokens |
| à la chouette | chouette | Three- or four-player format |
| refait | replay | A drawn round (equal holes) that must be replayed |
| consolation | consolation | Bonus tokens paid to the winner and, in 3-player games, the non-playing player |
| postillon | postillon | The first "double bet" in final payment settlement |
| école | school | `schools`; a penalty for a marking error; opponent scores the missed points |
| fausse case | false move | Playing a checker to the wrong field |
| fausse école | false school | Incorrectly claiming or marking a school penalty |
| augmentation d'école | school escalation | Back-and-forth dispute over a school penalty |
| pile de misère | misery pile | A special scoring configuration (not yet implemented in the codebase) |

292
doc/research.md Normal file
View file

@ -0,0 +1,292 @@
# Trictrac — Research Notes
## 1. Rust Engine: Module Map
| Module | Responsibility |
| ---------------------- | ------------------------------------------------------------------------- |
| `board.rs` | Board representation, checker manipulation, quarter analysis |
| `dice.rs` | `Dice` struct, `DiceRoller`, bit encoding |
| `player.rs` | `Player` struct (score, bredouille), `Color`, `PlayerId`, `CurrentPlayer` |
| `game.rs` | `GameState` state machine, `GameEvent` enum, `Stage`/`TurnStage` |
| `game_rules_moves.rs` | `MoveRules`: move validation and generation |
| `game_rules_points.rs` | `PointsRules`: jan detection and scoring |
| `training_common.rs` | `TrictracAction` enum, action-space encoding (size 514) |
| `pyengine.rs` | PyO3 Python module exposing `TricTrac` class |
| `lib.rs` | Crate root, re-exports |
---
## 2. Board Representation
```rust
pub struct Board {
positions: [i8; 24],
}
```
- 24 fields indexed 023 internally, 124 externally.
- Positive values = White checkers on that field; negative = Black.
- Initial state: `[15, 0, ..., 0, -15]` — all 15 white pieces on field 1, all 15 black pieces on field 24.
- Field 0 is a sentinel for "exited the board" (never stored in the array).
**Mirroring** is the central symmetry operation used throughout:
```rust
pub fn mirror(&self) -> Self {
let mut positions = self.positions.map(|c| 0 - c);
positions.reverse();
Board { positions }
}
```
This negates all values (swapping who owns each checker) and reverses the array (swapping directions). The entire engine always reasons from White's perspective; Black's moves are handled by mirroring the board first.
**Quarter structure**: fields 16, 712, 1318, 1924. This maps to the four tables of Trictrac:
- 16: White's "petit jan" (own table)
- 712: White's "grand jan"
- 1318: Black's "grand jan" (= White's opponent territory)
- 1924: Black's "petit jan" / White's "jan de retour"
The "coin de repos" (rest corner) is field 12 for White, field 13 for Black.
---
## 3. Dice
```rust
pub struct Dice {
pub values: (u8, u8),
}
```
Dice are always a pair (never quadrupled for doubles, unlike Backgammon). The `DiceRoller` uses `StdRng` seeded from OS entropy (or an optional fixed seed for tests). Bit encoding: `"{d1:0>3b}{d2:0>3b}"` — 3 bits each, 6 bits total.
---
## 4. Player State
```rust
pub struct Player {
pub name: String,
pub color: Color, // White or Black
pub points: u8, // 011 (points within current hole)
pub holes: u8, // holes won (game ends at >12)
pub can_bredouille: bool,
pub can_big_bredouille: bool,
pub dice_roll_count: u8, // rolls since last new_pick_up()
}
```
`PlayerId` is a `u64` alias. Player 1 = White, Player 2 = Black (set at init time; this is fixed for the session in pyengine).
---
## 5. Game State Machine
### Stages
```rust
pub enum Stage { PreGame, InGame, Ended }
pub enum TurnStage {
RollDice, // 1 — player must request a roll
RollWaiting, // 0 — waiting for dice result from outside
MarkPoints, // 2 — points are being marked (schools mode only)
HoldOrGoChoice, // 3 — player won a hole; choose to Go or Hold
Move, // 4 — player must move checkers
MarkAdvPoints, // 5 — mark opponent's points after the move (schools mode)
}
```
### Turn lifecycle (schools disabled — the default)
```
RollWaiting
│ RollResult → auto-mark points
├─[no hole]──→ Move
│ │ Move → mark opponent's points → switch player
│ └───────────────────────────────→ RollDice (next player)
└─[hole won]─→ HoldOrGoChoice
├─ Go ──→ new_pick_up() → RollDice (same player)
└─ Move ──→ mark opponent's points → switch player → RollDice
```
In schools mode (`schools_enabled = true`), the player explicitly marks their own points (`Mark` event) and then the opponent's points after moving (`MarkAdvPoints` stage).
### Key events
```rust
pub enum GameEvent {
BeginGame { goes_first: PlayerId },
EndGame { reason: EndGameReason },
PlayerJoined { player_id, name },
PlayerDisconnected { player_id },
Roll { player_id }, // triggers RollWaiting
RollResult { player_id, dice }, // provides dice values
Mark { player_id, points }, // explicit point marking (schools mode)
Go { player_id }, // choose to restart position after hole
Move { player_id, moves: (CheckerMove, CheckerMove) },
PlayError,
}
```
### Initialization in pyengine
```rust
fn new() -> Self {
let mut game_state = GameState::new(false); // schools_enabled = false
game_state.init_player("player1");
game_state.init_player("player2");
game_state.consume(&GameEvent::BeginGame { goes_first: 1 });
TricTrac { game_state }
}
```
Player 1 (White) always goes first. `active_player_id` uses 1-based indexing; pyengine converts to 0-based for the Python side with `active_player_id - 1`.
---
## 6. Scoring System (Jans)
Points are awarded after each dice roll based on "jans" (scoring events) detected by `PointsRules`. All computation assumes White's perspective (board is mirrored for Black before calling).
### Jan types
| Jan | Points (normal / doublet) | Direction |
| ----------------------- | ------------------------- | --------------- |
| `TrueHitSmallJan` | 4 / 6 | → active player |
| `TrueHitBigJan` | 2 / 4 | → active player |
| `TrueHitOpponentCorner` | 4 / 6 | → active player |
| `FilledQuarter` | 4 / 6 | → active player |
| `FirstPlayerToExit` | 4 / 6 | → active player |
| `SixTables` | 4 / 6 | → active player |
| `TwoTables` | 4 / 6 | → active player |
| `Mezeas` | 4 / 6 | → active player |
| `FalseHitSmallJan` | 4 / 6 | → opponent |
| `FalseHitBigJan` | 2 / 4 | → opponent |
| `ContreTwoTables` | 4 / 6 | → opponent |
| `ContreMezeas` | 4 / 6 | → opponent |
| `HelplessMan` | 2 / 4 | → opponent |
A single roll can trigger multiple jans, each scored independently. The jan detection process:
1. Try both dice orderings
2. Detect "tout d'une" (combined dice move as a virtual single die)
3. Prefer true hits over false hits for the same move
4. Check quarter-filling opportunities
5. Check rare jans (SixTables at roll 3, TwoTables, Mezeas) given specific board positions and talon counts
### Hole scoring
```rust
fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool {
let sum_points = p.points + points;
let jeux = sum_points / 12; // number of completed holes
let holes = match (jeux, p.can_bredouille) {
(0, _) => 0,
(_, false) => 2 * jeux - 1, // no bredouille bonus
(_, true) => 2 * jeux, // bredouille doubles the holes
};
p.points = sum_points % 12;
p.holes += holes;
...
}
```
- 12 points = 1 "jeu", which yields 1 or 2 holes depending on bredouille status.
- Scoring any points clears the opponent's `can_bredouille`.
- Completing a hole resets `can_bredouille` for the scorer.
- Game ends when `holes > 12`.
- Score reported to OpenSpiel: `holes * 12 + points`.
### Points from both rolls
After a roll, the active player's points (`dice_points.0`) are auto-marked immediately. After the Move, the opponent's points (`dice_points.1`) are marked (they were computed at roll-time from the pre-move board).
---
## 7. Move Rules
`MoveRules` always works from White's perspective. Key constraints enforced by `moves_allowed()`:
1. **Opponent's corner forbidden**: Cannot land on field 13 (opponent's rest corner for White).
2. **Corner needs two checkers**: The rest corner (field 12) must be taken or vacated with exactly 2 checkers simultaneously.
3. **Corner by effect vs. by power**: If the corner can be taken directly ("par effet"), you cannot take it "par puissance" (using combined dice).
4. **Exit preconditions**: All checkers must be in fields 1924 before any exit is allowed.
5. **Exit by effect priority**: If a normal exit is possible, exceedant moves (using overflow) are forbidden.
6. **Farthest checker first**: When exiting with exceedant, must exit the checker at the highest field.
7. **Must play all dice**: If both dice can be played, playing only one is invalid.
8. **Must play strongest die**: If only one die can be played, it must be the higher value die.
9. **Must fill quarter**: If a quarter can be completed, the move must complete it.
10. **Cannot block opponent's fillable quarter**: Cannot move into a quarter the opponent can still fill.
The board state after each die application is simulated to check two-step sequences.
---
## 8. Action Space (training_common.rs)
Total size: **514 actions**.
| Index | Action | Description |
| ------- | ------------------------------------------------ | ---------------------------------------------- |
| 0 | `Roll` | Request dice roll (not used in OpenSpiel mode) |
| 1 | `Go` | After winning hole: reset board and continue |
| 2257 | `Move { dice_order: true, checker1, checker2 }` | Move with die[0] first |
| 258513 | `Move { dice_order: false, checker1, checker2 }` | Move with die[1] first |
Move encoding: `index = 2 + (0 if dice_order else 256) + checker1 * 16 + checker2`
`checker1` and `checker2` are **ordinal positions** (1-based) of specific checkers counted left-to-right across all White-occupied fields, not field indices. Checker 0 = "no move" (empty move). Range: 015 (16 values each).
### Mirror pattern in get_legal_actions / apply_action
For player 2 (Black):
```rust
// get_legal_actions: mirror game state before computing
let mirror = self.game_state.mirror();
get_valid_action_indices(&mirror)
// apply_action: convert action → event on mirrored state, then mirror the event back
a.to_event(&self.game_state.mirror())
.map(|e| e.get_mirror(false))
```
This ensures Black's actions are computed as if Black were White on a mirrored board, then translated back to real-board coordinates.
---
## 9. Known Issues and Inconsistencies
### 9.1 Color swap on new_pick_up disabled
In `game.rs:new_pick_up()`:
```rust
// XXX : switch colors
// désactivé pour le moment car la vérification des mouvements échoue,
// cf. https://code.rhumbs.fr/henri/trictrac/issues/31
// p.color = p.color.opponent_color();
```
In authentic Trictrac, players swap colors between "relevés" (pick-ups after a hole is won with Go). This is commented out, so the same player always plays White and the same always plays Black throughout the entire game.
### 9.2 `can_big_bredouille` tracked but not implemented
The `can_big_bredouille` flag is stored in `Player` and serialized in state encoding, but the scoring logic never reads it. Grande bredouille (a rare extra bonus) is not implemented.
### 9.3 `get_valid_actions` panics on `RollWaiting`
```rust
TurnStage::MarkPoints | TurnStage::MarkAdvPoints | TurnStage::RollWaiting => {
panic!("get_valid_actions not implemented for turn stage {:?}", ...)
}
```
If `get_legal_actions` were ever called while `needs_roll()` is true, this would panic.
### 9.4 Opponent points marked at pre-move board state
The opponent's `dice_points.1` is computed at roll time (before the active player moves), but applied to the opponent after the move. This means the opponent's scoring is evaluated on the board position that existed before the active player moved — which is per the rules of Trictrac (points are based on where pieces could be hit at the moment of the roll), but it's worth noting this subtlety.

Some files were not shown because too many files have changed in this diff Show more