Compare commits

..

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

123 changed files with 10038 additions and 14981 deletions

3
.gitignore vendored
View file

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

7036
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,4 @@
[workspace]
resolver = "2"
members = [
"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
members = ["client_cli", "bot", "store", "spiel_bot", "client_web"]

113
README.md
View file

@ -2,133 +2,40 @@
This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation.
The project is still on its early stages.
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
Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands.
```bash
# Run the relay server
just build-relay
just run-relay # listens on :8080
# Run the game (separate terminal)
just dev
```
Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name.
Playing with the cli against the 'random' bot: `cargo run --bin=client_cli -- --bot random`
`cargo run --bin=client_cli -- --bot random`
## Roadmap
- [x] rules
- [x] command line interface
- [x] basic bot (random play)
- [ ] web client (in progress)
- [ ] network game (in progress)
- [ ] AI bot
- [ ] network game
- [ ] web client
## Code structure
- game rules and game state are implemented in the _store/_ folder.
- 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 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 bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders.
### _store_ package
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.
### _clients/cli_ package
### _client_cli_ package
`clients/cli/src/game_runner.rs` contains the logic to make two bots play against each other.
`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).
### multiplayer game
Packages "clients/backbone-lib", "clients/web/game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
The system consists of:
- A **relay server** (Axum/Tokio) that routes messages between players and manages rooms, without knowing anything about game rules.
- A **backbone library** that handles WebSocket connection, handshake, and message routing, exposing an async API to the game frontend.
- Game-specific **backend logic** implementing the `BackEndArchitecture` trait, which runs only on the hosting client.
- A **Leptos frontend** that connects to a session and reacts to state updates.
There is no dedicated game server. One of the players acts as the host: their browser runs the game backend locally. The relay server only forwards messages — it never touches game state.
```
┌─────────────────────────────────────────────────────────────┐
│ Host Client │
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │ Leptos UI │◄──►│ GameSession │◄──►│ Backend │ │
│ └─────────────┘ └────────┬─────────┘ └────────────┘ │
└───────────────────────────── │ ────────────────────────────┘
│ WebSocket
┌──────▼──────┐
│ Relay Server│
└──────┬──────┘
│ WebSocket
┌───────────────────────────────│────────────────────────────┐
│ ┌─────────────┐ ┌─────────▼────────┐ │
│ │ Leptos UI │◄──►│ GameSession │ (no backend) │
│ └─────────────┘ └──────────────────┘ │
│ Remote Client │
└─────────────────────────────────────────────────────────────┘
```
#### Data flow
- **Actions** (e.g. "place stone at B3") flow from the UI to the host backend via `GameSession::send_action()`.
- **State updates** flow back as `ViewStateUpdate::Full` (full snapshot, on join or reset) or `ViewStateUpdate::Incremental` (delta, for animations).
- **Timers** are managed by the host's background task (wall-clock, no polling required from the game).
#### backbone-lib session API
The key design choice: `backbone-lib` owns a background async task per session. The Leptos app never drives a loop — it just awaits on events.
#### Workspace
**server/protocol**
Shared message-type constants and the `JoinRequest` struct used during the WebSocket handshake.
**server/relay-server**
Listens on port 8080. Loads `GameConfig.json` on startup to know which games exist and their player limits:
```json
[{ "name": "trictrac", "max_players": 10 }]
```
Games can be added at runtime via the `/reload` endpoint. `/enlist` lists active rooms. A watchdog cleans up inactive rooms every 20 minutes.
For production, put it behind a reverse proxy with SSL (the browser requires `wss://` on HTTPS pages). Example Caddy config:
```
your-domain.com {
handle_path /api/* {
reverse_proxy localhost:8080
}
file_server
}
```
**clients/backbone-lib**
Modules:
| Module | Purpose |
| ---------- | ---------------------------------------------------------------------------------------------------------- |
| `session` | `GameSession`, `connect()`, `SessionEvent`, `RoomConfig` |
| `host` | Background async task for the hosting client (drives `BackEndArchitecture`, manages timers) |
| `client` | Background async task for non-hosting clients |
| `protocol` | Wire encoding/decoding helpers (postcard + message-type bytes) |
| `platform` | `spawn_task` / `sleep_ms` abstractions (WASM: `spawn_local` + gloo-timers; native: thread + thread::sleep) |
| `traits` | `BackEndArchitecture`, `BackendCommand`, `ViewStateUpdate`, `SerializationCap` |

View file

@ -13,7 +13,7 @@ path = "src/burnrl/main.rs"
pretty_assertions = "1.4.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
trictrac-store = { path = "../store" }
trictrac-store = { path = "../store", features = ["python"] }
rand = "0.9"
env_logger = "0.10"
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"
pretty_assertions = "1.4.0"
renet = "0.0.13"
trictrac-store = { path = "../../store" }
trictrac-bot = { path = "../../bot" }
spiel_bot = { path = "../../spiel_bot" }
trictrac-store = { path = "../store", features = ["python"] }
trictrac-bot = { path = "../bot" }
spiel_bot = { path = "../spiel_bot" }
itertools = "0.13.0"
env_logger = "0.11.6"
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>
<link data-trunk rel="rust" />
<link data-trunk rel="css" href="assets/style.css" />
<link data-trunk rel="copy-file" href="assets/diceroll.mp3" />
</head>
<body></body>
</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.
/// `value` 16 shows dots; 0 shows an empty face (not-yet-rolled).
/// `used` dims the die.
/// `is_double` applies a golden glow (both dice same value).
#[component]
pub fn Die(
value: u8,
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();
}
pub fn Die(value: u8, used: bool) -> impl IntoView {
let cls = if used { "die-face die-used" } else { "die-face" };
let dots: Vec<AnyView> = dot_positions(value)
.iter()
.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" />
{dots}
</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 die;
mod game_screen;
mod login_screen;
mod score_panel;
mod scoring;
pub use connecting_screen::ConnectingScreen;
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 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.
const HOST_PLAYER_ID: u64 = 1;
@ -14,28 +14,11 @@ pub struct TrictracBackend {
view_state: ViewState,
/// Arrival flags: have host (index 0) and guest (index 1) joined?
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 {
fn sync_view_state(&mut self) {
let mut vs = 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;
self.view_state = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
}
fn broadcast_state(&mut self) {
@ -46,49 +29,6 @@ impl TrictracBackend {
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.
fn do_roll(&mut self) {
let dice = self.dice_roller.roll();
@ -130,72 +70,13 @@ impl TrictracBackend {
pub fn get_game(&self) -> &GameState {
&self.game
}
/// Build a backend pre-loaded with the given `ViewState` snapshot so a bot
/// game can resume from an arbitrary position (debug feature).
pub fn from_view_state(vs: ViewState, player_name: &str) -> Self {
let mut game = GameState::new(false);
game.board.set_positions(&Color::White, vs.board);
game.stage = match vs.stage {
SerStage::InGame => Stage::InGame,
SerStage::Ended => Stage::Ended,
_ => Stage::InGame,
};
game.turn_stage = match vs.turn_stage {
SerTurnStage::RollDice => TurnStage::RollDice,
SerTurnStage::RollWaiting => TurnStage::RollWaiting,
SerTurnStage::MarkPoints => TurnStage::MarkPoints,
SerTurnStage::HoldOrGoChoice => TurnStage::HoldOrGoChoice,
SerTurnStage::Move => TurnStage::Move,
SerTurnStage::MarkAdvPoints => TurnStage::MarkAdvPoints,
};
game.dice = Dice { values: vs.dice };
game.active_player_id = match vs.active_mp_player {
Some(0) => HOST_PLAYER_ID,
Some(1) => GUEST_PLAYER_ID,
_ => HOST_PLAYER_ID,
};
let build_player = |score: &crate::game::trictrac::types::PlayerScore,
color: Color|
-> Player {
let mut p = Player::new(score.name.clone(), color);
p.points = score.points;
p.holes = score.holes;
p.can_bredouille = score.can_bredouille;
p
};
game.players.insert(HOST_PLAYER_ID, build_player(&vs.scores[0], Color::White));
game.players.insert(GUEST_PLAYER_ID, build_player(&vs.scores[1], Color::Black));
let mut view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
view_state.scores[0].name = player_name.to_string();
view_state.scores[1].name = "Bot".to_string();
TrictracBackend {
game,
dice_roller: DiceRoller::default(),
commands: Vec::new(),
view_state,
arrived: [true, true],
pre_game_dice: [None; 2],
tie_count: 0,
ceremony_started: false,
}
}
}
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
fn new(_rule_variation: u16) -> Self {
let mut game = GameState::new(false);
game.init_player("Blancs");
game.init_player("Noirs");
game.init_player("Host");
game.init_player("Guest");
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(),
view_state,
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,
});
// Start the ceremony once both players have arrived.
if self.arrived[0]
&& self.arrived[1]
&& self.game.stage == trictrac_store::Stage::PreGame
&& !self.ceremony_started
{
self.ceremony_started = true;
self.pre_game_dice = [None; 2];
self.tie_count = 0;
// Start the game once both players have arrived.
if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame {
let _ = self.game.consume(&GameEvent::BeginGame {
goes_first: HOST_PLAYER_ID,
});
self.sync_view_state();
self.commands.push(BackendCommand::ResetViewState);
} else {
@ -261,24 +135,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
}
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 {
return;
}
@ -330,8 +186,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
self.drive_automatic_stages();
}
}
PlayerAction::PreGameRoll => {} // ignored outside ceremony
PlayerAction::SetName(_) => {} // handled at the top of inform_rpc
}
self.broadcast_state();
@ -359,7 +213,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
#[cfg(test)]
mod tests {
use super::*;
use super::{SerStage, SerTurnStage};
use backbone_lib::traits::BackEndArchitecture;
fn make_backend() -> TrictracBackend {
@ -378,37 +231,15 @@ mod tests {
.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]
fn both_players_arrive_starts_ceremony() {
fn both_players_arrive_starts_game() {
let mut b = make_backend();
b.player_arrival(0); // host
b.drain_commands();
b.player_arrival(1); // guest
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
.iter()
.any(|c| matches!(c, BackendCommand::ResetViewState));
@ -417,44 +248,11 @@ mod tests {
"expected ResetViewState after both players arrive"
);
// Stage should now be PreGameRoll, not InGame.
assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll);
}
#[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);
// Game should now be InGame.
use crate::trictrac::types::SerStage;
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]
fn unknown_player_kicked() {
let mut b = make_backend();
@ -472,18 +270,12 @@ mod tests {
b.player_arrival(1);
b.drain_commands();
// Complete ceremony before rolling.
complete_ceremony(&mut b);
// 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);
// Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame).
b.inform_rpc(0, PlayerAction::Roll);
let states = drain_deltas(&mut b);
assert!(!states.is_empty(), "expected a state broadcast after roll");
use crate::trictrac::types::SerTurnStage;
let last = states.last().unwrap();
assert!(
matches!(
@ -504,14 +296,14 @@ mod tests {
b.player_arrival(0);
b.player_arrival(1);
b.drain_commands();
complete_ceremony(&mut b);
// Identify who goes first and have the OTHER player try to roll.
let active = b.get_view_state().active_mp_player;
let wrong_player = if active == Some(0) { 1u16 } else { 0u16 };
b.inform_rpc(wrong_player, PlayerAction::Roll);
// Guest tries to roll when it's the host's turn.
b.inform_rpc(1, PlayerAction::Roll);
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]
@ -538,20 +330,3 @@ mod tests {
.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,
/// Acknowledge point marking (hold / advance points).
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 ────────────────────────
@ -31,18 +27,6 @@ pub struct GameDelta {
// ── 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)]
pub struct ViewState {
/// Board positions: index i = field i+1. Positive = white, negative = black.
@ -57,11 +41,6 @@ pub struct ViewState {
pub dice: (u8, u8),
/// Jans (scoring events) triggered by the last dice roll.
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.
@ -89,23 +68,11 @@ impl ViewState {
turn_stage: SerTurnStage::RollDice,
active_mp_player: None,
scores: [
PlayerScore {
name: host_name.to_string(),
points: 0,
holes: 0,
can_bredouille: false,
},
PlayerScore {
name: guest_name.to_string(),
points: 0,
holes: 0,
can_bredouille: false,
},
PlayerScore { name: host_name.to_string(), points: 0, holes: 0, can_bredouille: false },
PlayerScore { name: guest_name.to_string(), points: 0, holes: 0, can_bredouille: false },
],
dice: (0, 0),
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`.
/// `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.
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: [i8; 24] = board_vec.try_into().expect("board is always 24 fields");
let stage = match gs.stage {
Stage::PreGame => SerStage::PreGame,
Stage::InGame => SerStage::InGame,
Stage::Ended => SerStage::Ended,
Stage::InGame => SerStage::InGame,
Stage::Ended => SerStage::Ended,
};
let turn_stage = match gs.turn_stage {
TurnStage::RollDice => SerTurnStage::RollDice,
TurnStage::RollWaiting => SerTurnStage::RollWaiting,
TurnStage::MarkPoints => SerTurnStage::MarkPoints,
TurnStage::RollDice => SerTurnStage::RollDice,
TurnStage::RollWaiting => SerTurnStage::RollWaiting,
TurnStage::MarkPoints => SerTurnStage::MarkPoints,
TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
TurnStage::Move => SerTurnStage::Move,
TurnStage::Move => SerTurnStage::Move,
TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
};
@ -151,12 +122,7 @@ impl ViewState {
holes: p.holes,
can_bredouille: p.can_bredouille,
})
.unwrap_or_else(|| PlayerScore {
name: String::new(),
points: 0,
holes: 0,
can_bredouille: false,
})
.unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0, can_bredouille: false })
};
// 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.
let empty_move = CheckerMove::new(0, 0).unwrap_or_default();
let mut dice_jans: Vec<JanEntry> = gs
.dice_jans
let mut dice_jans: Vec<JanEntry> = gs.dice_jans
.iter()
.map(|(jan, moves)| {
// HelplessMan: is_double = true only when *both* dice are unplayable
// (the moves list contains a single (empty, empty) sentinel).
let is_double = if *jan == Jan::HelplessMan {
moves
.first()
.map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
moves.first().map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
.unwrap_or(false)
} else {
dice_are_double
@ -203,29 +166,10 @@ impl ViewState {
scores: [score_for(host_store_id), score_for(guest_store_id)],
dice: (gs.dice.values.0, gs.dice.values.1),
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 ────────────────────────────────────────────────────────────
#[derive(Clone, PartialEq, Serialize, Deserialize)]
@ -241,8 +185,6 @@ pub struct PlayerScore {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SerStage {
PreGame,
/// Both players have arrived; ceremony in progress to decide who goes first.
PreGameRoll,
InGame,
Ended,
}

View file

@ -1,17 +0,0 @@
[package]
name = "backbone-lib"
version = "0.1.0"
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,48 +0,0 @@
[package]
name = "trictrac-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"] }
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"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
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,144 +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"
}

View file

@ -1,144 +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 un champ surligné 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": "Veuillez vérifier votre adresse email — consultez 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",
"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",
"join_code_label": "Rejoindre avec un code",
"join_code_placeholder": "Code de la salle",
"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": "Se connecter",
"nickname_modal_register": "Créer un compte",
"new_game": "Nouvelle partie",
"language": "Langue"
}

View file

@ -1,253 +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>,
}
// ── 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 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)
}
}
// ── Utilities ─────────────────────────────────────────────────────────────────
pub fn format_ts(ts: i64) -> String {
let ms = (ts * 1000) as f64;
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED)
.as_string()
.unwrap_or_default()
}

View file

@ -1,747 +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, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
verify_email::VerifyEmailPage,
};
use trictrac_store::CheckerMove;
use std::collections::VecDeque;
const RELAY_URL: &str = "ws://localhost: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,
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(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(anon_nickname);
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.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,
));
}
_ => {}
}
};
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.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 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.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()),
});
}
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 />
<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 />
</Routes>
</main>
<GameOverlay pending=pending screen=screen />
</Router>
}
}
/// 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
<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>
{move || match auth_username.get() {
Some(u) => {
let href = format!("/profile/{u}");
view! {
<A href=href attr:class="game-sidebar-link"
on:click=move |_| sidebar_open.set(false)>
{u}
</A>
<button class="game-sidebar-btn" on:click=move |_| {
spawn_local(async move {
let _ = api::post_logout().await;
auth_username.set(None);
});
}>{t!(i18n, sign_out)}</button>
}.into_any()
},
None => view! {
<A href="/account" attr:class="game-sidebar-link"
on:click=move |_| sidebar_open.set(false)>
{t!(i18n, sign_in)}
</A>
}.into_any(),
}}
</div>
// ── Debug section ─────────────────────────────────────────────────
<div class="game-sidebar-section" style="flex-direction:column;gap:0.4rem">
<span class="game-sidebar-label">{t!(i18n, debug_section)}</span>
// "Take snapshot" — only visible while a game is in progress
{move || {
let Screen::Playing(ref state) = screen.get() else { return None; };
let vs = state.view_state.clone();
let tx = cmd_tx_snapshot.clone();
Some(view! {
<button class="game-sidebar-btn" on:click=move |_| {
if let Ok(json) = serde_json::to_string(&vs) {
#[cfg(target_arch = "wasm32")]
{
let json_c = json.clone();
spawn_local(async move {
if let Some(cb) = web_sys::window()
.map(|w| w.navigator().clipboard())
{
let _ = wasm_bindgen_futures::JsFuture::from(
cb.write_text(&json_c),
).await;
snapshot_copied.set(true);
gloo_timers::future::TimeoutFuture::new(2000).await;
snapshot_copied.set(false);
}
});
}
let _ = tx; // suppress unused warning on non-wasm
}
}>
{move || if snapshot_copied.get() {
t_string!(i18n, snapshot_copied).to_owned()
} else {
t_string!(i18n, take_snapshot).to_owned()
}}
</button>
})
}}
// "Replay snapshot" — always visible
<button class="game-sidebar-btn" on:click=move |_| {
replay_text.set(String::new());
replay_error.set(false);
replay_open.set(true);
sidebar_open.set(false);
}>{t!(i18n, replay_snapshot)}</button>
</div>
</div>
// ── Replay snapshot modal ─────────────────────────────────────────────
<div class="ceremony-overlay" style="z-index:300"
style:display=move || if replay_open.get() { "" } else { "none" }
on:click=move |_| replay_open.set(false)>
<div class="ceremony-box" style="min-width:340px;max-width:480px;width:90vw"
on:click=|e| e.stop_propagation()>
<h2 style="font-size:1.3rem">{t!(i18n, replay_snapshot)}</h2>
<p class="game-sub-prompt" style="margin:0;text-align:center">
{t!(i18n, replay_paste_hint)}
</p>
<textarea
style="width:100%;min-height:120px;background:rgba(0,0,0,0.25);border:1px solid rgba(200,164,72,0.35);border-radius:4px;color:var(--ui-parchment);font-family:var(--font-ui);font-size:0.75rem;padding:0.5rem;resize:vertical;box-sizing:border-box"
placeholder="{ \"board\": [...], ... }"
prop:value=move || replay_text.get()
on:input=move |e| {
use leptos::prelude::event_target_value;
replay_text.set(event_target_value(&e));
replay_error.set(false);
}
/>
{move || replay_error.get().then(|| view! {
<p style="color:var(--ui-red-accent);font-size:0.8rem;margin:0">
{t!(i18n, replay_invalid_state)}
</p>
})}
<div style="display:flex;gap:0.75rem;justify-content:center">
<button class="btn btn-secondary" on:click=move |_| replay_open.set(false)>
{t!(i18n, cancel)}
</button>
<button class="btn btn-primary" on:click=move |_| {
let text = replay_text.get_untracked();
match serde_json::from_str::<ViewState>(&text) {
Ok(vs) => {
cmd_tx_replay
.unbounded_send(NetCommand::ReplaySnapshot(vs))
.ok();
replay_open.set(false);
}
Err(_) => replay_error.set(true),
}
}>{t!(i18n, replay_start)}</button>
</div>
</div>
</div>
}
}
#[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,726 +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.
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, gap 4px, bar 20px, center-bar 12px.
/// 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) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
let x = if right {
480.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
}
#[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,
) -> impl IntoView {
let board = view_state.board;
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 {
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; }
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));
// 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();
// §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)
};
// Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
let (label_tl, label_tr, label_bl, label_br) = if is_white {
("", "jan de retour", "grand jan", "petit jan")
} else {
("petit jan", "grand jan", "jan de retour", "")
};
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="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_tl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_tr}</div>
</div>
<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="824" 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 && match staged.len() {
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
1 => {
let (f0, t0) = staged[0];
seqs_exit.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.any(|(_, m2)| m2.get_to() == 0)
}
_ => false,
};
show.then(|| {
let seqs_exit_cls = seqs_exit.clone();
let seqs_exit_click = seqs_exit.clone();
let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) =
if is_white {
(
"position:absolute;right:-60px;top:15px;width:50px;height:50px",
"10", "31", "23,17 32,25 23,33",
)
} else {
(
"position:absolute;left:-60px;top:15px;width:50px;height:50px",
"40", "19", "27,17 18,25 27,33",
)
};
view! {
<div
title="Exit"
style=pos_style
class=move || {
let staged = staged_moves.get();
let sel = selected_origin.get();
let active = match sel {
Some(origin) => seqs_exit_cls.is_empty()
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
.iter()
.any(|&d| d == 0),
None => false,
};
if active { "exit-btn exit-active" } else { "exit-btn" }
}
on:click=move |_| {
if !is_move_stage { return; }
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
let Some(origin) = selected_origin.get_untracked() else {
return;
};
let valid = seqs_exit_click.is_empty()
|| valid_dests_for(&seqs_exit_click, &staged, origin)
.iter()
.any(|&d| d == 0);
if valid {
staged_moves.update(|v| v.push((origin, 0)));
selected_origin.set(None);
}
}
>
<svg width="50" height="50" viewBox="0 0 50 50">
<circle
cx="25" cy="25" r="20"
style="fill:rgba(10,20,10,0.75);stroke:rgba(210,170,30,0.75);stroke-width:2.5"
/>
<line
x1=line_x1 y1="25" x2=line_x2 y2="25"
style="stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round"
/>
<polyline
points=head_pts
style="fill:none;stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round"
/>
</svg>
</div>
}
.into_any()
})
}}
</div>
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_bl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_br}</div>
</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,549 +0,0 @@
use std::cell::Cell;
use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
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::board::Board;
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 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();
// Non-reactive counter so we can detect when staged_moves grows without
// returning a value from the Effect (which causes a Leptos reactive loop
// when the Effect also writes to the same signal it reads).
let prev_staged_len = Cell::new(0usize);
Effect::new(move |_| {
let moves = staged_moves.get();
let n = moves.len();
// Play checker sound whenever a move is added (own moves, immediate feedback).
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()
};
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);
// Reset the counter so the next turn starts clean.
prev_staged_len.set(0);
}
});
// ── Auto-roll effect ─────────────────────────────────────────────────────
// GameScreen is fully re-mounted on every ViewState update (state is a
// plain prop, not a signal), so this effect fires exactly once per
// RollDice phase entry and will not double-send.
// Guard: suppressed while waiting_for_confirm — the AfterOpponentMove
// buffered state shows the human's RollDice turn but the auto-roll must
// wait until the buffer is drained and the live screen state is shown.
// Guard: never auto-roll during the pre-game ceremony (the ceremony overlay
// has its own Roll button for PlayerAction::PreGameRoll).
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();
// Only show the fallback Go button when there is no ScoringPanel showing it.
let show_hold_go = is_my_turn
&& vs.turn_stage == SerTurnStage::HoldOrGoChoice
&& state.my_scored_event.is_none();
// ── 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();
// ── Scores ─────────────────────────────────────────────────────────────────
let my_score = vs.scores[player_id as usize].clone();
let opp_score = vs.scores[1 - player_id as usize].clone();
// ── Ceremony state (extracted before vs is moved into Board) ────────────────
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();
// Values for MergedScorePanel — extracted before events are consumed.
// Don't animate points when a hole was gained (points wrap around 12).
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;
// fields where a battue (hit) was scored; ripple animation shown there.
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 (fire once on mount = once per state snapshot) ──────────
// Dice roll: dice are fresh for the currently active player (Move stage means
// someone just rolled). Skipped on turn-switch states where the old dice linger
// in RollDice/MarkPoints stage before the opponent has rolled.
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();
}
// Checker move: moves were committed in the preceding action.
if last_moves.is_some() {
crate::game::sound::play_checker_move();
}
// Scoring: hole fanfare plays immediately; per-point ticks are driven by
// MergedScorePanel's counter animation so play_points_scored is not called here.
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;
// ── 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! {
// ── Game container ────────────────────────────────────────────────────
<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>
}
})}
// ── Merged scoreboard + scoring panels ─────────────
// score-area is position:relative so the scoring-panels-container
// can be absolute-positioned at the right of the hole counter.
<div class="score-area">
<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
/>
// Scoring detail panels — stacked at the right, overlapping if needed.
<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>
// ── Board ────────────────────────────────────────────────────────
<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
/>
// ── Status, hints, and actions — cream strip below board ─
<div class="game-bottom-strip">
<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> })
}}
<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>
})}
// Fallback Go button when no scoring panel (e.g. after reconnect)
{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>
// ── 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,235 +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(),
}
}
/// Merged scoreboard showing both players above the board.
///
/// - Two stacked rows for a clear race-to-12 visual comparison.
/// - Points shown as an animated jackpot counter (ticks up on each new point).
/// - Hole pegs are larger and use green (me) / red (opponent) instead of gold.
/// - When a hole is gained, the new peg pops in and a brief non-blocking label
/// appears instead of the old blocking toast popup.
#[component]
pub fn MergedScorePanel(
my_score: PlayerScore,
opp_score: PlayerScore,
/// Points just earned this turn; 0 = no animation. Set to 0 when a hole
/// was gained (points wrap around 12, counter stays at end value).
#[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,
) -> impl IntoView {
let i18n = use_i18n();
// ── Points counter signals ──────────────────────────────────────────────
// When no hole was gained: start from (current - earned) and tick up.
// When a hole was gained: points wrapped around 12, so skip the animation.
// On non-WASM there is no animation; start directly at the final value.
// Suppress the unused-variable warning for animation-only params.
#[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();
}
});
}
}
// ── Ghost bar widths (show the end value immediately — static reference) ─
let my_bar_style = format!("width:{}%", (my_score.points as u32 * 100 / 12).min(100));
let opp_bar_style = format!("width:{}%", (opp_score.points as u32 * 100 / 12).min(100));
// ── 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;
view! {
<div class="merged-score-panel">
// ── My player row ───────────────────────────────────────────
<div class="score-row score-row-me">
<div class="score-row-name">
<span class="player-name">{my_name}</span>
<span class="you-tag">{t!(i18n, you_suffix)}</span>
</div>
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div class="pts-ghost-bar-fill" style=my_bar_style></div>
</div>
<div class="pts-counter-row">
<span class="pts-counter">{move || my_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
</div>
</div>
<div class="peg-track">{my_pegs}</div>
{my_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
// Flash sits in the free space to the right of the pegs.
// margin-left:auto keeps it right-aligned inside the flex row
// without adding a new row, so the board never shifts down.
{(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 class="score-row-sep"></div>
// ── Opponent row ────────────────────────────────────────────
<div class="score-row score-row-opp">
<div class="score-row-name">
<span class="player-name">{opp_name}</span>
</div>
<div class="pts-counter-wrap">
<div class="pts-ghost-bar-track">
<div class="pts-ghost-bar-fill pts-ghost-bar-opp" style=opp_bar_style></div>
</div>
<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>
</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,46 +0,0 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::components::A;
use crate::api;
use crate::i18n::*;
#[component]
pub fn SiteNav() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let logout = move |_| {
spawn_local(async move {
let _ = api::post_logout().await;
auth_username.set(None);
});
};
view! {
<nav class="site-nav">
<A href="/" attr:class="site-nav-brand">"Trictrac"</A>
<div class="site-nav-spacer" />
<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>
{move || match auth_username.get() {
Some(u) => view! {
<A href=format!("/profile/{u}")>{ u.clone() }</A>
<button class="site-nav-btn" on:click=logout>{t!(i18n, sign_out)}</button>
}.into_any(),
None => view! {
<A href="/account">{t!(i18n, sign_in)}</A>
}.into_any(),
}}
</nav>
}
}

View file

@ -1,247 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;
use crate::api;
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::<RwSignal<bool>>().expect("auth_email_verified context not found");
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::<RwSignal<bool>>().expect("auth_email_verified context not found");
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::<RwSignal<bool>>().expect("auth_email_verified context not found");
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,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,109 +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 started = api::format_ts(game.started_at);
let ended = game.ended_at.map(api::format_ts)
.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,403 +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::{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::<RwSignal<Option<String>>>().expect("anon_nickname context");
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 join_open = RwSignal::new(false);
let join_code = RwSignal::new(String::new());
let cmd_bot = cmd_tx.clone();
let cmd_create = cmd_tx.clone();
let cmd_join = cmd_tx;
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>
// Hidden "join by code" fallback
<div style="margin-top:1.25rem;text-align:center">
<button
class="portal-page-btn"
style="font-size:0.75rem;opacity:0.7"
on:click=move |_| join_open.update(|v| *v = !*v)
>
{move || if join_open.get() { "" } else { "" }}
{t!(i18n, join_code_label)}
</button>
{move || {
let cmd = cmd_join.clone();
join_open.get().then(|| view! {
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
<input
class="login-input"
style="margin:0"
type="text"
placeholder=move || t_string!(i18n, join_code_placeholder)
prop:value=move || join_code.get()
on:input=move |ev| join_code.set(event_target_value(&ev))
/>
<button
class="login-btn login-btn-secondary"
style="margin:0;padding:0 1rem"
disabled=move || join_code.get().is_empty()
on:click=move |_| {
let code = join_code.get();
if auth_username.get_untracked().is_some() {
cmd.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
} else {
pending_action.set(Some(PendingLobbyAction::Join { code }));
}
}
>
{t!(i18n, join_room)}
</button>
</div>
})
}}
</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>
" · "
<A href="/account">{t!(i18n, nickname_modal_register)}</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,7 +0,0 @@
pub mod account;
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,153 +0,0 @@
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::api::{self, GameSummary, UserProfile};
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 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 joined = api::format_ts(profile.created_at);
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 class="stat-box">
<div class="value outcome-draw">{ profile.draws }</div>
<div class="label">{t!(i18n, stat_draws)}</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>
}
}
#[component]
fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
let i18n = use_i18n();
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);
let ended = g.ended_at.map(api::format_ts).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,83 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use crate::api;
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::<RwSignal<bool>>().expect("auth_email_verified context not found");
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": 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_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,10 +3,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1776863933,
"lastModified": 1770390537,
"owner": "cachix",
"repo": "devenv",
"rev": "863b4204725efaeeb73811e376f928232b720646",
"rev": "d6f45cc00829254a9a6f8807c8fbfaf3efa7e629",
"type": "github"
},
"original": {
@ -40,10 +40,10 @@
]
},
"locked": {
"lastModified": 1776796298,
"lastModified": 1769939035,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
"type": "github"
},
"original": {
@ -74,10 +74,10 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1776734388,
"lastModified": 1770136044,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
"rev": "e576e3c9cf9bad747afcddd9e34f51d18c855b4e",
"type": "github"
},
"original": {

View file

@ -7,11 +7,7 @@ in
packages = [
# for Leptos
pkgs.trunk
pkgs.lld
# for backbone-lib
pkgs.wasm-bindgen-cli_0_2_114
pkgs.binaryen # for wasm-opt
# pkgs.wasm-bindgen-cli_0_2_114
# pour burn-rs
pkgs.SDL2_gfx
@ -25,24 +21,63 @@ in
pkgs.samply # code profiler
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/
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/
# 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.

View file

@ -1,307 +0,0 @@
# client_web — UI/UX Design Proposals
A structured critique of the current interface compared to the physical game, followed by concrete upgrade proposals. Organised from most impactful to most effort-intensive.
---
## Aesthetic Direction
**Concept: "18th-century French gaming salon"**
The physical trictrac board is a piece of furniture — carved mahogany rails, felt or baize surface, ivory and ebony checkers, brass pegs in drilled holes, gilt scoring tokens. The online interface should feel like playing on that table under candlelight in a Parisian salon.
- **Typography**: Pair a classical serif (e.g. [Cormorant Garamond](https://fonts.google.com/specimen/Cormorant+Garamond)) for headings and score readouts with a clean humanist sans (e.g. [Jost](https://fonts.google.com/specimen/Jost)) for UI controls and status text.
- **Palette**: Forest green felt board (`#1d3d28`), alternating ivory (`#f0e6c8`) and deep burgundy (`#7a1e2a`) triangular fields, dark mahogany rails (`#2a1508`), aged parchment panels (`#f2e8d0`), gilt gold accents (`#c8a448`).
- **Unforgettable detail**: Triangular fields (true *flèches*) rendered in CSS with a wood-grain body surrounding them.
---
## 1. Board Shape: Rectangles → True Triangles
**Current state**: Fields are 60×180px rectangles with a rounded corner. No backgammon/trictrac board looks like this.
**Physical game**: Fields are elongated triangles (*flèches*) pointing from the rail toward the center bar, alternating two colors.
**Proposal**: Replace `.field` `<div>` elements with SVG triangles, or use CSS `clip-path: polygon(50% 0%, 0% 100%, 100% 100%)` for bottom-row triangles and `clip-path: polygon(0% 0%, 100% 0%, 50% 100%)` for top-row triangles. The field background and checker stack become SVG foreignObject or positioned elements inside. This is a large structural change to `board.rs` but is the single highest-impact visual improvement.
The board body between triangles becomes visible as the wood/felt surface — this naturally creates the physical board's "relief" without any extra decoration.
---
## 2. Jan Zone Visual Identity
**Current state**: The four quarters (small jan, big jan, return jan, last jan) are visually identical — same field color scheme, no labels or separation beyond the center bar.
**Physical game**: Players must constantly know which quarter they are in because the rules differ radically per zone (forbidden jans, filling values, hit scoring values differ between small-jan-table and big-jan-table, corner position, exit zone).
**Proposals**:
### 2a. Zone labels
Add thin labels (`"petit jan"`, `"grand jan"`, `"jan de retour"`) beneath the board-row (or as a subtle strip above/below the quarter). These should use the serif font at very small size and low opacity — decorative, not noisy.
### 2b. Field color shift per zone
The physical game uses alternating colors within each quarter, but different quarters can use slightly different base hues:
- Small jan (fields 16): warm ivory / burgundy
- Big jan / corner zone (fields 712): same, but field 12 gets a distinct "corner" treatment (see §4)
- Return jan (fields 1318): very subtly cooler ivory / dark teal instead of burgundy — signals "opponent's territory"
- Last jan / exit (fields 1924): subtly warmer, indicating checkers are "almost home"
### 2c. Small-jan-table / big-jan-table highlight during hit scoring
When a hit is being scored, briefly tint the entire table (fields 112 or 1324) to make the point value distinction (4 pts vs 2 pts) spatially obvious. This fires as a 300ms flash synchronized with the scoring notification.
---
## 3. Rest Corner (Field 12 / 13) Special Appearance
**Current state**: Field 12 looks identical to field 11. Nothing indicates its unique rules (must enter/leave with 2 checkers simultaneously, cannot be landed on by a single checker).
**Physical game**: The corner is a corner — it is literally in the corner of the table, a distinct physical location.
**Proposals**:
- Give field 12 (and 13 for Black) a **crown or arch shape** at the tip of the triangle, using a small SVG ornament.
- Apply a **slightly warmer gold** field color to distinguish it.
- When the player has two checkers there, show a subtle **lock icon** or a gilded ring around the checker stack to indicate "corner held."
- When the corner is available to be taken *par puissance*, add a gentle pulsing outline on field 12 to indicate the privilege is available.
- Tooltip or popover: on hover, show a brief note "Coin de repos — must enter and leave with 2 checkers."
---
## 4. Checker Rendering: Static → Animated
**Current state**: Checkers appear and disappear between `ViewState` snapshots. No movement animation.
**Physical game**: Checkers slide across the board with a satisfying click sound.
**Proposals**:
### 4a. Slide animation
Diff the board array between the previous and current `ViewState`. For each checker that moved from field A to field B, apply a CSS or Web Animation API translation from `field_center(A)` to `field_center(B)` (duration ~250ms, ease-out). This requires keeping the previous `ViewState` as state in `GameScreen` and computing a diff when a new state arrives.
### 4b. Lift effect during staging
When the player clicks an origin field and a checker becomes "selected," apply a `transform: scale(1.15) translateY(-4px)` with a subtle drop shadow increase. Visually lifts the checker off the board.
### 4c. Checker appearance
Replace the CSS `radial-gradient` circles with SVG:
- **White**: ivory `#f5edd8` with a pearl sheen gradient, thin gilt ring border, engraved concentric circles
- **Black**: ebony `#1a0f06` with subtle grain texture, same gilt ring
A stack of 5+ checkers can render a "perspective stack" — each checker at a slight y offset with a shadow, giving depth.
---
## 5. Dice: Static → Rolling Animation
**Current state**: Dice appear with their final value immediately. No sense of randomness or anticipation.
**Physical game**: Dice are shaken in a cup (*cornet*) and tumbled out. The roll is a theatrical moment.
**Proposals**:
### 5a. Roll animation
When `SerTurnStage` transitions from `RollDice` to `Move`, animate both dice with a fast face-cycling (showing random faces for ~400ms, decelerating to final value). Pure CSS `animation` on the die-face SVG circles, cycling via `keyframes`.
### 5b. Dice cups
Add two SVG/CSS dice cups above the dice display. During rolling, they visually "tip" (rotate 90° via CSS transform) and the dice "fall out." A subtle translate-y on the dice moves them downward into view.
### 5c. Double visual
When both dice show the same value, add a subtle golden glow around both — visually communicating that it is a double (which affects scoring: 6 pts instead of 4, etc.).
### 5d. Used-die visual
When one die has been consumed by a staged move, slide it slightly down and reduce opacity (current: gray-out). Animate the "used" transition with `transition: all 0.15s`.
---
## 6. Scoring Notifications: Side Panel → Layered Toasts
**Current state**: Scoring events appear as a small cream panel in the side panel column (`scoring-panel`). They are easily missed, especially opponent events.
**Physical game**: Scoring is the central drama of every turn — points are loudly marked, bredouille doubled, holes recorded with pegs.
**Proposals**:
### 6a. Board-overlaid toast for holes
When a hole is won, display a large centered overlay on the board — not a modal, but a translucent toast with gilt border: `"Trou ! ×2 bredouille"`. Auto-dismiss after 1.5s or on click. This is the most important event and deserves the most visual weight.
### 6b. Scoring event animation
When `my_scored_event` appears, animate the panel sliding in from the right with a 200ms ease-out. Jan rows stagger in (each with `animation-delay: n * 50ms`).
### 6c. Jan hover → board highlight synchronization
The current arrow-on-hover feature is good. Extend it: when hovering a jan row, also highlight the relevant fields with a faint golden shimmer instead of (or in addition to) the arrows. This ties the abstract jan name to a concrete board location.
### 6d. Bredouille treatment
Bredouille doubles a hole's value — a massive game event. Currently shown as a small amber badge. Proposals:
- The toast for a bredouille hole should animate in differently: bigger, gold shimmer background
- Show a small animated flag (*pavillon*) icon in the score panel when bredouille is active, matching the physical game's token
### 6e. Hit scoring visual
When a hit is scored (*battue*), show a brief visual on the opponent's half-field checker — a faint concentric ring expanding outward (CSS `animation: ripple 0.4s ease-out`). This communicates the "fictitious" nature of the hit: something happened at that checker's position, but it didn't move.
---
## 7. Score Panel: Progress Bars → Pegs and Holes
**Current state**: Points and holes are displayed as progress bars (012) and numeric values. Functional but abstract.
**Physical game**: Points are tracked with physical tokens (*jetons*) placed on the board surface at specific field tips. Holes are tracked with pegs (*fichets*) in holes drilled along the rail at each field base.
**Proposals**:
### 7a. Hole tracker: 12 dots/pegs
Replace the `score-bar-holes` progress bar with a row of 12 small circles ("drilled holes") in a horizontal strip. Filled holes are rendered as a gilded peg inserted (solid gold circle). Unfilled holes are empty rings. This is a `<svg>` with 12 `<circle>` elements. The filled count animates one peg at a time (sequenced `animation-delay`).
### 7b. Point tracker: token on board
For points (011), show a small token image positioned at the corresponding field tip along the near rail — mirroring the physical game exactly. This is ambitious but highly authentic. A simpler approach: replace the thin progress bar with a 12-cell dot track where one glowing token is positioned.
### 7c. Bredouille indicator
When `can_bredouille` is true for a player, show the token as a double-token (two stacked icons) or add a small flag icon next to the token.
---
## 8. Status Communication: Text → Contextual Guidance
**Current state**: A single text line (`"Select move 1"`, `"Opponent's turn"`) in the status bar. New players have no idea what to do or why they can't do something.
**Physical game**: Human players narrate what's happening; experienced players understand the state from context.
**Proposals**:
### 8a. Contextual sub-prompt
Below the primary status, show a secondary hint line in smaller text:
- During `Move` stage: `"Click a highlighted field to move a checker"`
- During `HoldOrGoChoice`: `"Hold to keep points and keep playing — Go to reset and start a new setting"`
- When waiting for confirm: `"↑ Opponent scored points — click Continue when ready"`
### 8b. Forbidden-jan visual cue
When a field is in the opponent's jan and the player cannot land there (forbidden jan rule), show those fields with a subtle `✕` pattern or darker tint rather than just being unclickable. This communicates *why* the fields aren't selectable.
### 8c. Exit-eligible highlight
When all player checkers are in the last jan (fields 1924), add a subtle directional glow to the exit rail (the right/left edge of the board depending on player). A small "EXIT →" arrow indicator could appear.
### 8d. Can-take-corner indicator
When the player can take their corner (field 12 or 13 is the valid destination), add a brief pulse to that field beyond the standard `.dest` highlight — the corner rules are special enough to warrant extra visual salience.
---
## 9. Bug Fix: Hold Button Is Non-Functional
**File**: `src/components/scoring.rs` line 91
The "Hold" button in the `ScoringPanel` has no `on:click` handler. In the physical game, "Hold" (*tenir*) means: stay in the current setting, mark remainder points, and continue playing normally.
`PlayerAction` does not currently include a `Hold` variant. In the current implementation, if the player simply does nothing (doesn't click Go), the game waits — but there is no message sent to the backend to confirm "staying."
**Fix required**: Add `PlayerAction::Hold` (or reuse `Mark`) and connect the Hold button's `on:click` to send it. The backend needs to handle it by advancing past `HoldOrGoChoice` without triggering `GameEvent::Go`.
---
## 10. Layout: Side Panel → Integrated Design
**Current state**: The board and side panel sit side-by-side (`board-and-panel: flex-direction row`). The side panel (min-width 160px) contains status, dice, scoring, and buttons stacked vertically.
**Proposals**:
### 10a. Move dice inside the board
Place the dice display centered in the **board-bar** (the vertical divider between quarters). Currently the bar is 20px wide — widen it to ~80px and center two dice there. This puts dice physically near the board action, matching the physical game where dice land on the board surface. The bar color becomes a darker felt strip.
### 10b. Status bar above the board
Move the primary status message to a full-width strip directly above the board, styled with the serif font at larger size. This gives it appropriate visual weight and removes it from the cramped side panel.
### 10c. Action buttons below the board (or in score panels)
"Continue," "Go," and "Hold" buttons can live below the board in a centered button row. The side panel then becomes purely informational (scoring panels), which can slide in from the right.
### 10d. Mobile: rotate board 90° option
The board is ~776px wide. On narrow screens, offer a portrait mode where the board is rendered rotated 90° (each player's quarters stacked vertically), with a scroll-independent panel above/below for controls.
---
## 11. Login Screen: Form → Atmosphere
**Current state**: A plain 320px-wide column with a `<h1>Trictrac</h1>`, a text input, and three buttons. Functional but gives no sense of what the game is.
**Physical game**: A trictrac board is an object of beauty — players set it out, prepare the checkers, and roll for first-move privilege.
**Proposals**:
### 11a. Illustrated header
A high-quality SVG illustration of the board (simplified top-down view, showing the triangular fields, checker stacks at starting positions, dice) as the page hero. Possibly animated: the two stacks slowly deploying two checkers as the page loads.
### 11b. Typography treatment
"TRICTRAC" as a large display heading in a classical-weight serif, possibly with subtle tracking and a gilt color. Below it, the French subtitle: *"Jeu de trictrac — XVIIIe siècle"* in small-caps at reduced opacity.
### 11c. Mode selection
The three buttons (Create / Join / vs Bot) styled as wooden tiles or embossed cards rather than plain buttons.
---
## 12. Game-Over Modal: Generic → Ceremonial
**Current state**: A centered modal with "Game Over," the winner's name, and Quit/Play Again buttons.
**Physical game**: The end of a game involves settling accounts, noting the final hole count, and potentially recording results.
**Proposals**:
- Show a **final score parchment** — both players' hole counts displayed like a ledger entry, with the winner's name engraved in gilt text
- Animate the modal entrance with a slight downward reveal (the parchment "unrolling")
- Show the hole difference: `"8 — 3"` in large numerals with a small flourish between them
- If bredouille applied to the winning holes: `"✕ 2 bredouille"` annotation
- "Play again" styled as "Rejouer" / "Play again" with a dice icon
---
## Implementation Priority
| Priority | Proposal | Effort | Impact |
|----------|-----------|--------|--------|
| 1 | §9 Fix Hold button (bug) | Low | Correctness |
| 2 | §3 Rest corner special appearance | Low | Clarity |
| 3 | §8bd Forbidden jan + exit + corner cues | Medium | Clarity |
| 4 | §5ad Dice roll animation | Medium | Delight |
| 5 | §6ab Scoring toasts + animation | Medium | Drama |
| 6 | §7a Hole tracker (12 peg dots) | Low | Authenticity |
| 7 | §2ab Jan zone labels + color shift | Low | Orientation |
| 8 | §4a Checker slide animation | High | Polish |
| 9 | §1 Triangular fields | High | Authenticity |
| 10 | §10ab Dice in bar + status above board | Medium | Layout |
| 11 | §6e Hit ripple animation | Medium | Comprehension |
| 12 | §11 Login redesign | Medium | First impression |
| 13 | §12 Game-over modal | Low | Finish |
| 14 | §4c SVG checkers | Medium | Aesthetics |
| 15 | §7bc Token tracker on rail | High | Authenticity |
---
## Typography and CSS Variables Proposal
Replace the anonymous `sans-serif` body font and introduce a CSS variable system:
```css
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;600&family=Jost:wght@300;400;500&display=swap');
:root {
/* Board */
--board-felt: #1d3d28;
--board-rail: #2a1508;
--field-ivory: #f0e6c8;
--field-burgundy: #7a1e2a;
--field-corner: #c8a030; /* rest corner accent */
--field-exit-glow: #e8c060;
/* Checkers */
--checker-white: #f5edd8;
--checker-black: #1a0f06;
--checker-ring: #c8a448; /* gilt border */
/* UI */
--ui-parchment: #f2e8d0;
--ui-parchment-dark: #e4d8b8;
--ui-ink: #2a1a08;
--ui-gold: #c8a448;
--ui-gold-dark: #8a6a28;
--ui-green-accent: #3a6b2a;
--ui-red-accent: #7a1e2a;
/* Typography */
--font-display: 'Cormorant Garamond', Georgia, serif;
--font-ui: 'Jost', system-ui, sans-serif;
}
```

View file

@ -1,381 +0,0 @@
# client_web — Alternative Design Proposals: Neon Arcade Future
A second aesthetic direction: bold, playful, unapologetically modern. Where the first proposal channels an 18th-century gaming salon, this one asks: *what if trictrac ran on a holographic table in a Tokyo arcade, 2089?*
This document proposes a complete visual redesign with no obligation to mirror physical game objects. The priority is delight, readability, and memorability.
---
## Aesthetic Direction: "Holographic Arcade"
**Core concept**: The board floats in dark space as a self-illuminated slab. Fields pulse with neon light. Checkers are luminous marbles that leave light trails as they move. Scoring events trigger particle explosions. Every interaction has a micro-animation.
**The one unforgettable thing**: When a hole is won, the entire board floods with a colour wave — a full-screen shimmer that fades in 800ms — like a pinball machine tilting into multiball.
**Color palette**: Built on darkness, with high-saturation accents — cyan, magenta, gold. Not gradients on white (the generic AI aesthetic); instead, near-black backgrounds with glowing, luminous elements.
**Typography**: [Space Grotesk](https://fonts.google.com/specimen/Space+Grotesk) is overused. Instead:
- Display: [Syne](https://fonts.google.com/specimen/Syne) — geometric, confident, slightly alien
- Numerics: [DM Mono](https://fonts.google.com/specimen/DM+Mono) — for scores, dice values, field numbers — crisp monospace with personality
- UI labels: [Outfit](https://fonts.google.com/specimen/Outfit) — friendly, modern, clear at small sizes
```css
:root {
/* Base */
--void: #09090f; /* near-black with blue tint */
--surface: #12121f; /* board slab */
--surface-raised: #1a1a2e; /* panels, cards */
--surface-glass: rgba(255,255,255,0.05); /* glassmorphism */
/* Neon accents */
--cyan: #00e5ff;
--cyan-dim: #0099bb;
--magenta: #e040fb;
--gold: #ffd740;
--gold-dim: #c8a820;
--green-neon: #69ff47;
--orange-neon: #ff6d3a;
/* Player colors */
--player-white: #e8e0ff; /* soft violet-white */
--player-black: #1a0040; /* deep indigo-black */
--player-white-glow: #b39ddb;
--player-black-glow: #7c4dff;
/* Typography */
--font-display: 'Syne', sans-serif;
--font-mono: 'DM Mono', monospace;
--font-ui: 'Outfit', sans-serif;
/* Glow radii */
--glow-sm: 0 0 8px;
--glow-md: 0 0 16px;
--glow-lg: 0 0 32px;
}
```
---
## 1. Board: A Floating Holographic Slab
**Concept**: The board is a dark rectangular surface that appears to float — slight perspective tilt (CSS `perspective` + `rotateX(3deg)`), a thin neon border (1px cyan on top, 1px dimmer on bottom for depth), and a subtle inner glow that makes the board feel luminous from within.
```css
.board {
background: var(--surface);
border: 1px solid var(--cyan-dim);
box-shadow:
0 0 0 1px rgba(0,229,255,0.1),
0 0 40px rgba(0,229,255,0.08),
0 24px 60px rgba(0,0,0,0.8);
transform: perspective(1200px) rotateX(2deg);
transform-origin: center bottom;
border-radius: 4px;
}
```
The board background gets a very subtle **noise texture overlay** (SVG `<feTurbulence>` or a PNG grain layer at 3% opacity) — just enough to prevent it from looking like a flat rectangle, giving it material presence.
The center bar and side bars become **glowing dividers**: 4px wide, gradient from `var(--cyan)` at top to `var(--magenta)` at bottom, with a matching glow.
---
## 2. Fields: Neon Triangles with Zone Color Identity
Triangular fields (CSS `clip-path: polygon`) are essential here — they're geometric and modern, not just historically authentic.
Each quarter gets its **own neon color identity**, using a very dark base with a glowing triangle border:
| Quarter | Fields | Primary accent | Secondary (alternating) |
|---------|--------|---------------|------------------------|
| Small jan | 16 | `#00e5ff` (cyan) | `#0077aa` (dim cyan) |
| Big jan | 712 | `#7c4dff` (violet) | `#4a2a99` (dim violet) |
| Return jan | 1318 | `#e040fb` (magenta) | `#991a99` (dim magenta) |
| Last jan | 1924 | `#ffd740` (gold) | `#aa8800` (dim gold) |
The field itself is dark (`#14141f`). The color lives in a **glowing triangle border** — achieved with a layered `clip-path` + `::before` pseudo-element 2px larger that shows through as the border, with a CSS `filter: blur(3px)` outer glow:
```css
.field {
background: #14141f;
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
position: relative;
}
.field::before {
content: '';
position: absolute;
inset: -2px;
background: var(--field-accent-color);
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
filter: blur(4px);
opacity: 0.4;
z-index: -1;
}
```
**On hover (clickable fields)**: the glow intensifies (`opacity: 0.9`, `filter: blur(6px)`) and the field interior lightens slightly. A ripple animation radiates outward from the click point.
**Selected field**: the entire field interior fills with a semi-transparent neon color — not just the border — and a 2px dashed animated border spins around it (`animation: spin-border 1s linear infinite`).
---
## 3. Checkers: Luminous Marbles
Forget CSS circles with radial gradients. Each checker is a **glowing orb** with:
- A dark, slightly translucent core
- A radial highlight in the upper-left (simulating a point light source)
- A colored halo that radiates outward onto the field triangle
- A subtle inner reflection ring
```css
.checker.white {
background: radial-gradient(circle at 35% 30%,
#ffffff,
#c8c0e0 40%,
#8878c0 70%,
#3a2a60
);
box-shadow:
inset 0 2px 6px rgba(255,255,255,0.8),
inset 0 -2px 4px rgba(0,0,0,0.4),
0 0 12px rgba(179,157,219,0.6), /* violet-white glow */
0 0 24px rgba(124,77,255,0.3); /* outer violet halo */
}
.checker.black {
background: radial-gradient(circle at 35% 30%,
#7c4dff,
#4a2d99 40%,
#1a0a40 70%,
#09040f
);
box-shadow:
inset 0 2px 6px rgba(124,77,255,0.5),
inset 0 -2px 4px rgba(0,0,0,0.8),
0 0 12px rgba(124,77,255,0.7),
0 0 24px rgba(124,77,255,0.3);
}
```
**Stack depth**: A stack of N checkers renders with each checker offset by 6px vertically and slightly scaled (0.97× per level deeper), creating genuine 3D stack depth without any 3D CSS transform. The count label floats above as a monospace number in `var(--gold)`.
**Selection animation**: On click to select, the top checker of the stack does a quick `scale(1.2) translateY(-8px)` bounce (150ms spring easing), then settles at `scale(1.1) translateY(-4px)` while selected.
**Movement animation**: When a move is confirmed (board state diff), selected checkers do a **light-trail arc** — a bezier path from origin field center to destination, with a fading cyan streak left behind (`box-shadow` animated along the path via `@property` interpolation or JS Web Animation API). Duration: 300ms.
---
## 4. Dice: Holographic Crystals
Replace the SVG ivory dice with **translucent crystal cubes**:
- Each die face is a dark glass square with a thin neon border
- Pips are glowing dots — cyan for normal, gold for doubles
- The die face has a subtle `backdrop-filter: blur(4px)` on a glass background
```css
.die-face rect {
fill: rgba(255, 255, 255, 0.04);
stroke: var(--cyan);
stroke-width: 1.5;
rx: 6;
filter: drop-shadow(0 0 6px var(--cyan));
}
.die-face circle {
fill: var(--cyan);
filter: drop-shadow(0 0 4px var(--cyan));
}
```
**Double dice**: Both pips and borders switch to `var(--gold)`, with a stronger glow (`drop-shadow(0 0 8px var(--gold))`).
**Roll animation**: 600ms sequence —
1. Both dice **shatter outward** (`scale(0) rotate(720deg)`, opacity 0 → 1) appearing from nothing
2. During 400ms they rapidly cycle through face values (random pips swap every 60ms via CSS `animation`)
3. Final 200ms they decelerate and **snap** to the rolled values with a brief flash pulse
**Used die**: Fades the border to `rgba(255,255,255,0.1)` and dims pips to `rgba(255,255,255,0.2)` — the die goes "offline." A thin strikethrough line appears diagonally.
---
## 5. The Hole Tracker: Orbital Rings
Instead of progress bars, score and hole progress are visualised as **concentric orbital rings** beside each player's name panel — inspired by loading spinners, but static and data-driven.
- **Outer ring** (thick, 6px): hole progress. 12 segments, each one lights up as a hole is won. Segments are `var(--gold)` when won, near-invisible dark when empty.
- **Inner ring** (thin, 3px): point progress within the current hole. Continuously filled arc from 0° to (points/12 × 360°). Color: `var(--cyan)` for the active player, `var(--magenta)` for the opponent.
The arc fills animate with `stroke-dashoffset` transition (0.4s ease-out) on every point gain.
**Bredouille state**: The outer ring segments pulse — a slow `opacity: 0.6 → 1 → 0.6` sinusoidal glow — as long as bredouille is active. A small flag icon (⚑) in `var(--gold)` appears beside the ring.
---
## 6. Scoring Events: Light Shows
### Hole won — Full-board colour wave
A `position:fixed` `::after` overlay expands from the scoring player's side of the board:
- Radial gradient expanding from one edge: `rgba(255,215,64,0)``rgba(255,215,64,0.15)``rgba(255,215,64,0)`
- Duration: 800ms, ease-in-out
- Simultaneously: the scoring player's orbital rings segments animate sequentially (each segment snaps on with a 50ms delay)
- A large centered text `"+1 TROU"` in `var(--font-display)` at 3rem scales from 60% to 110% with `opacity: 0 → 1 → 0`, duration 1.2s
### Bredouille — The cascade
On top of the hole wave, add:
- A **confetti burst** of small colored squares (pure CSS: 20 `<span>` elements with randomised `animation-delay` and `translate`/`rotate` keyframes) in cyan, magenta, gold
- The `"+1 TROU"` text instead reads `"BREDOUILLE ×2"` in `var(--magenta)`
- The board border flashes: `border-color` cycles cyan → magenta → gold → cyan over 0.6s
### Jan scored — Notification card
Each jan scored gets a **toast card** that slides in from the right edge:
- Dark glass background (`rgba(26,26,46,0.95)`) with a left border in the jan's quarter color
- Jan name in `var(--font-ui)` bold, points in `var(--font-mono)` large
- Progress: `"+4 pts"` in cyan, `"+6 pts (double)"` in gold
- Cards stack vertically if multiple jans fire; each staggered by 80ms
- Auto-dismiss with a rightward slide-out after 3s
### Hit scored — Ripple on the target checker
When a hit is scored on a specific field, that field's checker emits a **sonar ripple**:
- 3 concentric rings expand from the checker's center, each `opacity: 1 → 0, scale: 1 → 2.5`
- Color: cyan for true hits, magenta for false hits (giving to opponent)
- Duration: 600ms per ring, staggered by 200ms
---
## 7. Player Panels: Glassmorphism Cards
Replace the cream `background: #f5edd8` panels with **glass cards** floating above the void:
```css
.player-score-panel {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid rgba(255, 255, 255, 0.2); /* top catches light */
backdrop-filter: blur(12px) saturate(1.5);
border-radius: 12px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
```
**Active player panel**: the border glow of the active player's card brightens: `border-color: var(--cyan)` with `box-shadow: 0 0 16px rgba(0,229,255,0.2)`. A tiny animated pulse on the left edge (`width: 3px, animation: pulse 1.5s ease-in-out infinite`) indicates it is their turn.
**Player name**: Displayed in `var(--font-display)` at 1.1rem. A small colored dot (cyan for player 1, magenta for player 2) precedes the name — acts as the "you" indicator without needing a text suffix.
---
## 8. Status Bar: Dynamic Ambient Messaging
Replace the single plain-text status line with a **contextual bar** that changes character per game stage:
| Stage | Style | Color |
|-------|-------|-------|
| Waiting for opponent | Slow pulsing dots animation `... ` | Dim white |
| Your turn (roll) | "YOUR MOVE" in `var(--font-display)` with a blinking cursor | Cyan |
| Opponent's turn | Subtle shimmer on text | Dim magenta |
| Move selection | "SELECT CHECKER ①" with animated underline on "SELECT" | Cyan |
| Hold or Go | "SCORE!" with a spinning star ✦ | Gold |
| Paused (continue) | The bar has a pulsing amber background strip | Amber |
| Game over | Text cycles through all player colors | Full rainbow |
The bar itself is 3px tall and spans the full board width, showing a **neon progress shimmer** during the opponent's turn (a traveling gleam, like CSS `animation: shimmer` on a gradient).
---
## 9. Jan Zone Awareness: Neon Underlay
Rather than labels, the four quarters glow with their zone color in the background of the board — very subtle, just 4% opacity fills under the triangles:
```css
.board-quarter-small-jan { background: rgba(0, 229, 255, 0.04); }
.board-quarter-big-jan { background: rgba(124, 77, 255, 0.04); }
.board-quarter-return-jan { background: rgba(224, 64, 251, 0.04); }
.board-quarter-last-jan { background: rgba(255, 215, 64, 0.04); }
```
When hovering a scoring-notification row that references a specific jan (e.g. "Big jan conserved"), the corresponding quarter's background pulses from 4% → 15% opacity for 600ms. This replaces the arrow overlay with a spatial, zone-level highlight — more legible and visually coherent.
---
## 10. Rest Corner: The Crown Field
Field 12 (White) and 13 (Black) get a distinct appearance:
- The triangle is outlined in `var(--gold)` instead of its quarter's color
- A small **crown SVG** (⚜ or ♛) floats centered in the triangle at 30% opacity when empty, brighter when held
- When the player holds the corner (2 checkers there), the triangle interior fills with a very subtle gold shimmer animation (`background-position: 0% → 100%` on a diagonal gradient, 2s loop)
- When the corner is available to be taken *par puissance*, the crown pulses at 1Hz
---
## 11. Login Screen: Warp Speed Entrance
**Hero**: A dark void with an animated **particle field** — small white/cyan dots drifting slowly, like stars. Pure CSS with 50 `<span>` elements (or a single `<canvas>` for performance), each with randomised `animation-delay` and drift keyframe.
**Title**: "TRICTRAC" in `var(--font-display)` at 5rem, with a **chromatic aberration effect** — three slightly offset copies in cyan, magenta, and white, blended with `mix-blend-mode: screen`. The word appears with a `clip-path: inset(100% 0 0 0) → inset(0% 0 0 0)` reveal animation (the text "rises" into view).
**Tagline**: `"XVIIIe siècle · En ligne · ∞"` in `var(--font-mono)` at 0.85rem, appearing letter-by-letter with a 20ms interval.
**Mode cards**: Instead of three buttons, three **holographic tiles** in a row:
- Each is a glass card with an icon, label, and a colored accent strip on the bottom
- On hover: the card lifts (`translateY(-4px)`) and the bottom strip color floods the card (low opacity fill)
- CREATE: cyan accent; JOIN: violet accent; vs BOT: orange accent
**Room code input**: Dark glass input with a cyan border glow on focus, monospace font for the code, no placeholder text (just a blinking cursor showing it's ready). The input border animates a traveling gleam on focus.
---
## 12. Game-Over Screen: Score Reveal Ceremony
Instead of a modal over a frozen game, the game-over sequence is a **full-page takeover**:
1. **Board fades out** (800ms fade): the board dims to 20% opacity
2. **Score card rises** from the bottom: a tall glass card with both players' hole counts displayed large in `var(--font-display)``"8"` vs `"3"` — in their respective colors
3. **Winner highlight**: the winning number scales up to 200% with a gold burst radiation behind it
4. **Bredouille annotation**: if applicable, `"× 2"` appears beside the number with a magenta glow, then the number updates to the effective doubled count
5. **Continue options**: two buttons slide up last — "QUIT" and "REJOUER" — with the rejouer button pulsing in cyan
---
## 13. Global Micro-Interactions
These apply throughout and give the interface a consistently tactile feel:
- **Button press**: `scale(0.96)` on `:active`, 80ms, then spring back. No `opacity` change — scale is more physical.
- **Button focus**: neon outline ring animated in from 0 to full radius (not the browser default outline).
- **Panel hover**: glass cards shift `box-shadow` slightly for a lifted feel.
- **Page load**: all elements stagger in with a `translateY(10px) → 0 + opacity 0 → 1`, each component with a `animation-delay` offset (board: 0ms, panels: 100ms, side panel: 200ms).
- **Custom cursor** (optional): replace the default cursor with a small circle that trails slightly behind the real cursor position — creates a luxurious "lag" feeling. Pure JS: interpolate cursor position toward mouse position at 80% each frame.
---
## Implementation Notes for Leptos/WASM
### What's straightforward in pure CSS
- All color variables, glass panels, glow effects, orbital rings (SVG `stroke-dashoffset`)
- Dice roll animation (CSS keyframes)
- Toast slide-ins (CSS `@keyframes` + `animation`)
- Confetti (CSS `@keyframes` on positioned `<div>` elements)
- Particle field on login (CSS-only with many `<span>` elements)
### What needs a small JS/WASM component
- **Board perspective tilt** with mouse-tracking (subtle parallax) — `mouse_position` signal driving CSS custom property
- **Checker light-trail movement** — needs previous/next board diff, then Web Animation API or `requestAnimationFrame`
- **Chromatic aberration on title** — CSS filter or SVG filter, but the animation needs JS timing
### What needs Rust/Leptos state
- **Board diff for animation**: store previous `[i8; 24]` alongside current in `GameScreen` as a `Memo`, compute moved checkers
- **Event timing for sequences**: hole-won wave → score reveal → dismiss must be orchestrated; a `RwSignal<Option<AnimationState>>` in `GameScreen` drives each phase
### Progressive approach
The proposals above can be adopted incrementally. Suggested order:
1. CSS variables + dark theme + Syne/DM Mono fonts → immediate impact, zero logic change
2. Glass panels, neon borders, glow effects → pure CSS
3. Orbital ring score tracker → SVG component
4. Triangular fields + zone colors → `board.rs` structural change
5. Dice animation → CSS keyframes in `die.rs`
6. Toast notifications → new `toast.rs` component
7. Hole-won wave → CSS overlay + Leptos signal
8. Checker animation → board diff + Web Animation API

View file

@ -1,289 +0,0 @@
# client_web Crate Overview
A Leptos-based WASM frontend for trictrac. Builds to a single-page app served by Trunk on port 9092.
---
## File Structure
```
client_web/
├── Cargo.toml # Dependencies and i18n locale config
├── Trunk.toml # Serve port 9092
├── index.html # Shell: mounts WASM + links CSS
├── assets/style.css # All styles (~472 lines, no framework)
├── locales/
│ ├── en.json # 52 English keys
│ └── fr.json # 52 French keys
└── src/
├── main.rs # load_locales!() macro + mount_to_body
├── app.rs # Root App component, state, network loop (571 lines)
├── components/
│ ├── mod.rs
│ ├── game_screen.rs # Main in-game UI, move staging (324 lines)
│ ├── board.rs # Board rendering and click handling (372 lines)
│ ├── die.rs # SVG die face
│ ├── score_panel.rs # Points/holes bar for one player
│ ├── scoring.rs # Jan-by-jan scoring notification panel
│ ├── login_screen.rs # Room create/join
│ └── connecting_screen.rs
└── trictrac/
├── mod.rs
├── types.rs # Protocol types: ViewState, JanEntry, PlayerAction, … (217 lines)
├── backend.rs # BackEndArchitecture impl, engine bridge (332 lines)
└── bot_local.rs # Local bot: random moves, always Go (34 lines)
```
---
## Component Tree
```
App ← manages screen, pending queue, network task
└─ I18nContextProvider
├─ LoginScreen ← room name input, create/join/bot buttons
├─ ConnectingScreen ← spinner while connecting
└─ GameScreen ← in-game UI; receives GameUiState prop
├─ PlayerScorePanel ← opponent score (above board)
├─ Board ← 24 interactive fields; SVG arrow overlay
├─ side panel
│ ├─ status bar ← localised turn/action prompt
│ ├─ dice bar ← two Die components
│ ├─ ScoringPanel (me) ← my jans this turn, hold/go buttons
│ ├─ ScoringPanel (opponent) ← opponent jans (shown during pause)
│ └─ action buttons ← Continue / Go / Empty Move
└─ PlayerScorePanel ← my score (below board)
[game-over overlay modal]
```
---
## Screens and Transitions
```
Login ──(connect)──→ Connecting ──(game start)──→ Playing
↑ │
└──(reconnect)─────┘
Playing ──(disconnect / game over)──→ Login
```
`app.rs` drives transitions via `RwSignal<Screen>`.
---
## State Management
### Root signals (live in `App`, provided via Leptos context)
| Signal | Type | Purpose |
|--------|------|---------|
| `screen` | `RwSignal<Screen>` | Which screen is shown |
| `pending` | `RwSignal<VecDeque<GameUiState>>` | Buffered states awaiting "Continue" |
| `cmd_tx` | `UnboundedSender<NetCommand>` | UI → network command channel |
Both `pending` and `cmd_tx` are provided as context so any descendant can read/write them without prop-drilling.
### GameScreen-local signals
| Signal | Type | Purpose |
|--------|------|---------|
| `selected_origin` | `RwSignal<Option<u8>>` | First clicked field during move staging |
| `staged_moves` | `RwSignal<Vec<(u8, u8)>>` | Accumulated (origin, dest) pairs for this turn |
| `hovered_jan_moves` | `RwSignal<Vec<(CheckerMove, CheckerMove)>>` | Moves to draw arrows for on hover |
### Data flow
```
Network task (async in App)
↓ SessionEvent::Update
push_or_show() → pending queue or screen.set()
GameScreen re-renders (GameUiState prop)
User clicks field → staged_moves effect → NetCommand::Action(Move)
User clicks Go/Continue → cmd_tx.send or pending.pop_front()
```
---
## Network and Session
The multiplayer layer is provided by `backbone-lib` (local fork at `../../forks/multiplayer/`). `App` spawns an async task (via `spawn_local`) that multiplexes:
- `cmd_rx`: commands from UI components
- `session.next_event()`: updates from the server
### StoredSession (localStorage key: `"trictrac_session"`)
```rust
struct StoredSession {
relay_url: String,
game_id: String,
room_id: String,
token: u64, // reconnect token issued by server
is_host: bool,
view_state: Option<ViewState>, // host saves last known state; guest saves None
}
```
On page load, if a stored session exists, App goes directly to Connecting and sends `NetCommand::Reconnect`. Failed reconnects clear the session and return to Login.
---
## Pause / Confirmation Flow
Certain opponent events are paused so the local player can see what happened before their turn starts.
Pause triggers (`infer_pause_reason()` in `app.rs`):
| Reason | Condition |
|--------|-----------|
| `AfterOpponentRoll` | Opponent is active; dice values changed |
| `AfterOpponentGo` | Opponent chose Go (HoldOrGoChoice→Move transition) |
| `AfterOpponentMove` | Turn switched to us |
While a state is in the pending queue, `GameScreen` shows a "Continue" button. Clicking it calls `pending.pop_front()`; if the queue empties, the live state is displayed.
---
## Game Engine Integration
**File**: `src/trictrac/backend.rs`
`TrictracBackend` implements the `BackEndArchitecture` trait. It owns a `GameState` from `trictrac-store` and translates between the UI protocol and the engine's event model.
### PlayerAction → GameEvent mapping
| PlayerAction | GameEvents emitted |
|---|---|
| `Roll` | `GameEvent::Roll`, `GameEvent::RollResult(d1, d2)` |
| `Move(m1, m2)` | `GameEvent::Move` (after validation) |
| `Go` | `GameEvent::Go` |
| `Mark` | internal; drives `MarkPoints`/`MarkAdvPoints` loop automatically |
`drive_automatic_stages()` loops through scoring stages without waiting for player input — these are not interactive in the current implementation (schools are not implemented).
### ViewState construction
`ViewState::from_game_state()` in `types.rs` converts the engine state to the serialisable snapshot sent to clients:
- `board: [i8; 24]` — direct copy of `Board::positions`
- `dice: [u8; 2]` — current dice values
- `stage / turn_stage` — serialisable enums (`SerStage`, `SerTurnStage`)
- `scores: [PlayerScore; 2]` — points, holes, `can_bredouille`
- `dice_jans: Vec<JanEntry>` — scoring events for the current turn, sorted descending by points
- `active_player_index: usize` — 0 = host, 1 = guest
### Bot
`bot_local.rs` runs in the browser (no server call). It inspects `GameState` directly and returns a `PlayerAction`:
- **RollDice**: always Roll
- **HoldOrGoChoice**: always Go
- **Move**: picks a random legal sequence from `MoveRules::get_possible_moves_sequences()`; mirrors moves because Black's board is mirrored
---
## Board Rendering (`board.rs`)
### Layout
The 24 fields are split into 4 quarters of 6. Each player sees the board from their own perspective:
```
White's view:
TOP-LEFT [1318] | TOP-RIGHT [1924]
─────────────────────────────────────────
BOT-LEFT [127] | BOT-RIGHT [61]
Black's view (mirror):
TOP-LEFT [16] | TOP-RIGHT [712]
─────────────────────────────────────────
BOT-LEFT [2419] | BOT-RIGHT [1813]
```
Fields are 60 × 180 px, alternating gold (`#d4a843` / `#c49030`). Checkers are 40 px SVG circles (radial gradient). Up to 4 are stacked visually; a text label is shown when count > 4.
### Highlighting
Field CSS classes are computed reactively inside the `view!` macro closure:
| Class | Meaning |
|-------|---------|
| `.clickable` | Valid origin during Move stage (lime green) |
| `.selected` | Currently selected origin (darker green + outline) |
| `.dest` | Valid destination for the selected origin |
`valid_sequences` (from `MoveRules`) is computed once per render and used to derive `valid_origins_for()` and `valid_dests_for()`. The displayed checker count (`displayed_value()`) accounts for staged-but-not-yet-sent moves so the board previews the move visually.
### SVG arrow overlay
When the player hovers a row in the ScoringPanel, the corresponding checker moves are drawn as gold arrows over the board. `field_center()` maps field numbers to pixel coordinates; `arrow_svg()` renders the path with a drop-shadow.
---
## Scoring Display (`scoring.rs`, `score_panel.rs`)
`compute_scored_event()` in `app.rs` diffs consecutive `ViewState` snapshots to produce a `ScoredEvent`:
- `points_earned: i32`
- `holes_gained: u8`
- `jans: Vec<JanEntry>` — only events relevant to the beneficiary
`ScoringPanel` renders one `JanEntry` per row. Hovering a row writes that entry's moves into `hovered_jan_moves`, triggering the arrow overlay on the board.
`PlayerScorePanel` shows a colour-filled bar (animated via CSS `transition: width 0.3s`) for points (012) and holes (012). Bredouille state is shown with a small indicator.
---
## Internationalisation
`leptos_i18n::load_locales!()` is a compile-time macro that reads `locales/en.json` and `locales/fr.json` and generates a typed `i18n` module. There are 52 keys covering UI labels, game-state prompts, jan names, and status messages.
Usage in components:
```rust
let i18n = use_i18n();
t!(i18n, your_turn_roll) // → reactive View
t_string!(i18n, scored_pts, pts = 4) // → String with interpolation
```
The language switcher (top bar and login screen) calls `i18n.set_locale(Locale::en | Locale::fr)`, which triggers a full reactive re-render.
---
## Styling
`assets/style.css` is a single hand-written stylesheet (~472 lines). No CSS framework.
Key design tokens:
- Body background: `#c8b084` (tan)
- Board background: `#2e6b2e` (dark green)
- Fields: `#d4a843` / `#c49030` (gold alternating)
- Interactive fields: `#aad060` (lime, clickable) / `#709a20` (darker, selected)
- UI panels: `#f5edd8` (cream)
Layout uses Flexbox and CSS Grid throughout. Score bars animate with `transition: width 0.3s`. Field clicks give immediate feedback via `transition: background 0.1s`. No media queries — the layout is designed for desktop/tablet.
---
## Protocol Types (`types.rs`)
| Type | Role |
|------|------|
| `PlayerAction` | `Roll \| Move(CheckerMove, CheckerMove) \| Go \| Mark` — UI → backend |
| `GameDelta` | `{ state: ViewState }` — broadcast to all clients on every change |
| `ViewState` | Full serialisable snapshot of engine state |
| `JanEntry` | One scoring event: jan type, points, ways, moves, is_double |
| `ScoredEvent` | Points/holes delta + jan list for one player in one turn |
| `PlayerScore` | name, points (011), holes (012), can_bredouille |
| `SerStage` | `PreGame \| InGame \| Ended` |
| `SerTurnStage` | `RollDice \| RollWaiting \| MarkPoints \| HoldOrGoChoice \| Move \| MarkAdvPoints` |
`CheckerMove` comes directly from `trictrac-store`; fields are 1-indexed (0 = stack/exit).
---
## Build
```bash
trunk serve # dev server at http://localhost:9092
trunk build --release # WASM release bundle
```
`index.html` uses Trunk's `data-trunk` attributes: `rel="rust"` compiles `src/main.rs` to WASM; `rel="css"` copies `assets/style.css`. The WASM binary and generated JS glue land in `dist/`.

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) |

View file

@ -1,6 +1,6 @@
# Trictrac — store crate overview
# Trictrac — Research Notes
## 1. Module Map
## 1. Rust Engine: Module Map
| Module | Responsibility |
| ---------------------- | ------------------------------------------------------------------------- |
@ -11,6 +11,7 @@
| `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 |
---
@ -142,7 +143,7 @@ fn new() -> Self {
}
```
Player 1 (White) always goes first. `active_player_id` uses 1-based indexing
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`.
---
@ -196,7 +197,8 @@ fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool {
- 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`.
- Game ends when `holes > 12`.
- Score reported to OpenSpiel: `holes * 12 + points`.
### Points from both rolls
@ -227,12 +229,12 @@ The board state after each die application is simulated to check two-step sequen
Total size: **514 actions**.
| Index | Action | Description |
| ------- | ------------------------------------------------ | -------------------------------------------- |
| 0 | `Roll` | Request dice roll |
| 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 |
| 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`

5
doc/specs/vocabulary.md Normal file
View file

@ -0,0 +1,5 @@
# Vocabulary
Dames : checkers / men
cases : points
cadrant : quarter

View file

@ -1,211 +0,0 @@
# Trictrac Rules — Quick Reference
This document summarises the rules of grand trictrac based on the 2013 Malfilâtre edition ([full text](refs/laws_and_rules_of_trictrac.md)).
French terms follow the mapping in [vocabulary.md](refs/vocabulary.md).
---
## 1. Board and Starting Position
- 24 triangular fields (_flèches_ / _cases_), numbered 124 from each player's perspective.
- 4 quarters of 6 fields: **small jan** (16), **big jan** (712), **opponent's big jan** (1318), **return jan** (1924, exit zone).
- Field 12 (White) / 13 (Black) is the **rest corner** (_coin de repos_).
- Each player starts with all 15 checkers in a stack (_talon_) on field 1.
- Checkers always move in the same direction (White: 1→24; Black: mirror of that).
## 2. Dice and Movement
- Both dice are rolled together; both must be played if possible.
- If only one can be played and there is a choice, the higher number must be played.
- A single checker may play both dice successively — a **chained move** (_tout d'une_) — stopping on an intermediate resting field (_repos_) between the two dice.
- **Fields are single-color**: a checker may only land on an empty field or one already occupied by own checkers.
- Landing on a field with ≥ 1 opponent checker is **forbidden** (blocked field).
- An unplayed number is a **helpless man** (_jan-qui-ne-peut_): 2 points penalty per unplayed die, credited to the opponent.
## 3. The Rest Corner (Field 12 / 13)
- Must be entered **simultaneously** (_d'emblée_): exactly 2 checkers must enter together.
- Must be vacated simultaneously: exactly 2 checkers must leave together.
- Always holds ≥ 2 checkers while occupied; a single checker there is forbidden.
- Two ways to take the corner:
- **By effect** (_par effet_): normal die values land exactly on it.
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could land exactly on the opponent's corner, but by privilege he takes their own instead (as if stepping back one field).
- If both by-effect and by-puissance are possible, by-effect takes priority.
- An empty corner may serve as a resting field during a chained move (not a landing).
- Placing checkers on the **opponent's** corner is always forbidden.
## 4. Scoring: Points and Holes
- Points are tracked with tokens (011); **12 points = 1 hole** (_trou_).
- A **hole won bredouille** (_bredouille_) counts as **2 holes**: the active player scored 12 consecutive points from zero without the opponent scoring anything in between. The second player to start marking takes a double token (the _pavillon_ / flag) and can also win bredouille.
- The ordinary game ends when one player reaches **12 holes**.
## 5. Scoring Events (Jans)
All point values: normal roll / double.
### 5a. Opening Jans (first rolls of a setting only)
| Jan | Condition | Points |
| --------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------ |
| **Two tables jan** | First 2 checkers deployed; roll covers both rest corners; opponent's corner is empty | 4 / 6 (to player) |
| **Contre two tables** | Same, but opponent has already taken their corner | 4 / 6 (to opponent, as false hit) |
| **Mezeas jan** | Corner just taken (2 checkers); next roll shows one or two aces; opponent's corner empty | 4 per ace / 6 for double (to player) |
| **Contre mezeas** | Same, but opponent's corner is occupied | 4 / 6 (to opponent) |
| **Six tables jan** | After 2 rolls a checker is on 4 of the first 6 fields; 3rd roll could complete all 6 | 4 (always; not possible on a double) |
### 5b. Jan Filling and Conserving
A jan is **full** (_plein_) when all 6 of its fields hold ≥ 2 own checkers.
- **Filling**: the last checker is brought in to complete the jan.
- Up to 3 ways: each direct die value covering the last field, or the combined sum (chained move).
- Each way: **4 / 6** points.
- Doubles allow at most 2 ways.
- "Filling in passing" (player must break the jan to play the other die) scores nothing.
- **Conserving**: both dice can be played without disturbing any of the 12 checkers of the full jan.
- Worth **4 / 6** points (at most one way).
- Conservation by helplessness (_par impuissance_): only die value 6 triggers this (smaller values can always be played within the jan by breaking it).
- The full return jan may be conserved by exiting one or more checkers.
- After marking points for filling, the player **must** actually fill the jan with the appropriate checker(s) — failure is a false move and a school.
### 5c. Hitting (_Jan de Récompense_)
Hitting is **always fictitious**: a checker is "hit" when a die value could cover an opponent checker on a half-field (_demi-case_), but **no actual checker moves**. The opponent's checker stays.
**True hit**: a die (direct or combined sum) fictitiously covers the opponent checker.
- In the **small jan table** (fields 112): **4 / 6** points per way.
- In the **big jan table** (fields 1324): **2 / 4** points per way.
Ways to hit:
- **1 way**: only one direct die, or only the combined sum, covers the checker.
- **2 ways**: both direct dice cover it, or one die + the combined sum.
- **3 ways**: both dice + the combined sum (requires a normal roll; doubles max at 2 ways).
**Combined-sum hit** requires a free **resting field** between the two dice stops: the field must be empty, own, or a single opponent checker (which is then also hit).
**False hit** (_à faux_): the combined sum could hit but no valid resting field exists (all intermediate options are full opponent fields). The opponent gains the points the player would have scored.
- True-hit points are always marked before false-hit points.
- A checker already hit truly cannot also be hit falsely in the same move.
- Multiple checkers may be hit simultaneously (some true, some false).
**Corner hit**: player holds their own corner; opponent's corner is empty; the dice could simultaneously take the opponent's corner. Worth **4 / 6** points. Never false.
### 5d. Exit
- When all 15 checkers are in the return jan (fields 1924), the player may exit.
- The exit rail counts as one additional field value.
- **Exact exit**: die value brings the checker directly to the exit rail — allowed.
- **Overflow** (_nombre excédant_): die value would carry the farthest checker past the rail — must exit.
- **Failing number** (_nombre défaillant_): die cannot reach or overflow — must play within the jan.
- A player may choose not to use an exact exit value and play within the jan instead — but overflow must always exit.
- It is forbidden to deliberately play a die within the jan to force the second die to be played as an overflow (using a checker closer to the exit).
- When the last checker exits: **4 points** on a normal roll, **6 points** on a double.
- After exit: checkers reset; the player who exited keeps first-move privilege for the new setting.
## 6. Forbidden Jans
A player **may not** place a checker in the opponent's small jan or big jan as long as the opponent can still materially complete a full jan there (i.e., enough of their own checkers remain to fill it).
Exception: during a chained move, an empty field in the opponent's big jan (including their empty corner) may serve as a resting field to pass a checker into the return jan.
## 7. Sequence of Play
Each turn follows this order:
1. Mark opponent's helpless man points or contre-jans from the **previous** move.
2. Mark opponent's schools; rectify false moves if any.
3. **Roll dice.** Opponent may mark schools for steps 12.
4. Mark own points: opening jans, hits, fills, conserves, exit.
5. Decide to **stay** (_tenir_) or **leave** (_s'en aller_) if a hole was won on own roll.
6. If exiting: reset checkers, keep token positions, roll again.
7. Play both dice.
Points and holes must always be marked **before** touching checkers for the next move.
## 8. Staying or Leaving
After winning one or more holes on **own dice roll**, the player chooses:
- **Stay** (_tenir_): mark holes, reset opponent token to zero, mark remainder points, continue.
- **Leave** (_s'en aller_): announce it; opponent agrees or raises a fault. All checkers and tokens reset to zero; only holes remain. Player who left has first-move privilege in the new setting. Remainder points are forfeited; opponent scores nothing for that move.
If the winning points come from the **opponent's** roll (helpless man, schools), the player **must** stay — leaving is not an option.
---
## 9. Schools (Marking Penalties) — _Not Yet Implemented_
Schools are penalties for marking errors. They are worth exactly the number of points over- or under-marked on the faulty move. They are marked last in the turn sequence.
Key rules:
- A school is committed once dice are rolled or a token has been advanced too far and released.
- The opponent is never obliged to mark a school — but if they do, it must be marked in full.
- **False school**: incorrectly claiming a school — itself becomes a school for the opponent.
- **School escalation** (_augmentation d'école_): dispute over a school that escalates back and forth.
- No "school of school" exists (marking a school is never itself penalised).
- No school of holes for marking a bredouille hole as simple; a school of points applies for forgetting holes due to earned points.
---
## 10. The Scored Game (_Partie à Écrire_) — _Not Yet Implemented_
The scored game is played for an agreed number of **rounds** (_marqués_) and supports 2, 3, or 4 players (the 3/4-player format is called _chouette_).
### Goal of a Round
- A player must score at least **6 holes** and then **leave** to win the round.
- If both players are tied at ≥ 6 holes when one leaves, the round is **drawn** (_refait_) and replayed immediately.
- Winner of a round = player with the most holes after a leave.
### Bredouille in the Scored Game
- **Small bredouille** (_petite bredouille_): ≥ 6 consecutive holes → round counts **double**.
- **Big bredouille** (_grande bredouille_): ≥ 12 consecutive holes → round counts **quadruple**.
- The second player to score holes takes the flag (_pavillon_) at their peg. If the first player scores again, they take back the flag, cancelling both bredouilles.
### Payments
Each round is settled in tokens:
- Winner receives (winner's holes loser's holes) tokens, plus **consolation** of 2 tokens.
- Small bredouille: each winner hole worth 2 tokens; consolation = 4.
- Big bredouille: each winner hole worth 4 tokens; consolation = 8.
- Loser holes always deducted at 1 token each.
- In 3-player games, the non-playing player also receives consolation from the loser.
- Replays double the consolation price each time.
A **queue** accumulates tokens from each defeat and is paid at game end to the player with the most tokens.
**Bets** (_paris_): rounds played beyond each player's average (the _contingent_). The first double-bet is the **postillon** (28 tokens, including 20 from the queue); each subsequent bet costs 8 tokens.
### Multi-Player Rotation (3 or 4 Players)
- 3 players: after each round, the winner is replaced by the third player; first-move privilege stays with the player who remained.
- 4 players: two teams of two; each player plays two rounds in a row then gives way to their partner.
- Non-active players may advise (opponents in 3-player, teammates in 4-player) but may not touch game components.
---
## 11. Implementation Status Summary
| Feature | Status |
| ----------------------------------------------- | ------------------------------ |
| Board state, movement, rest corner | Implemented |
| Helpless man | Implemented |
| True / false hits (small jan, big jan, corner) | Implemented |
| Jan filling and conserving (small, big, return) | Implemented |
| Opening jans (two tables, mezeas, six tables) | Implemented |
| Exit and exit points | Implemented |
| Bredouille (hole bredouille) | Implemented (`can_bredouille`) |
| Forbidden jans | Implemented |
| Stay / leave (_s'en aller_) | Implemented (`Go` event) |
| Big bredouille (`can_big_bredouille`) | Field exists, not used |
| Schools | Not implemented |
| Scored game / rounds | Not implemented |
| Misery pile (_pile de misère_) | Not implemented |

62
flake.lock generated
View file

@ -1,62 +0,0 @@
{
"nodes": {
"nixpkgs": {
"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_2": {
"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",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"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"
}
}
},
"root": "root",
"version": 7
}

189
flake.nix
View file

@ -1,174 +1,41 @@
{
description = "Trictrac";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
rust-overlay.url = "github:oxalica/rust-overlay";
};
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, rust-overlay }:
let
systems = [ "x86_64-linux" "i686-linux" "aarch64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
nixpkgsFor = forAllSystems (system:
import nixpkgs {
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
# let pkgs = nixpkgs.legacyPackages.${system}; in
let pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlay ];
}
);
in
{
overlay = final: prev:
let
# Extend final privately with rust-overlay to get rust-bin for the WASM
# toolchain without exposing rust-overlay attributes to consumers.
rustPkgs = final.extend rust-overlay.overlays.default;
in
config = { allowUnfree = true; };
}; in
{
# devShell = import ./shell.nix { inherit pkgs; };
devShell = with pkgs; mkShell rec {
trictrac-front =
let
# WASM build needs wasm32-unknown-unknown target in the Rust toolchain
rustToolchain = rustPkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-unknown-unknown" ];
};
rustPlatform = final.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
};
# Must match the wasm-bindgen version in Cargo.lock
wasm-bindgen-version = "0.2.121";
wasm-bindgen-cli = final.buildWasmBindgenCli rec {
version = wasm-bindgen-version;
src = final.fetchCrate {
pname = "wasm-bindgen-cli";
inherit version;
hash = "sha256-ZOMgFNOcGkO66Jz/Z83eoIu+DIzo3Z/vq6Z5g6BDY/w=";
};
cargoDeps = rustPlatform.fetchCargoVendor {
inherit src;
name = "wasm-bindgen-cli-vendor";
hash = "sha256-DPdCDPTAPBrbqLUqnCwQu1dePs9lGg85JCJOCIr9qjU=";
};
};
frontendCargoDeps = rustPlatform.fetchCargoVendor {
src = ./.;
name = "trictrac-frontend-vendor";
hash = "sha256-eCuQcgKhdqHDRmRRK2cjmvRZZ661ecDYn0HIZWKDpSE=";
};
in
final.stdenv.mkDerivation {
name = "trictrac-front";
src = ./.;
nativeBuildInputs = with final; [
rustToolchain
lld
rustPlatform.cargoSetupHook
wasm-bindgen-cli
trunk
binaryen
nativeBuildInputs = [
pkg-config
llvmPackages.bintools # To use lld linker
];
cargoDeps = frontendCargoDeps;
buildInputs = [
cargo rustc rustfmt rustPackages.clippy # rust
# pre-commit
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
alsa-lib udev
vulkan-loader # needed for GPU acceleration
xlibsWrapper xorg.libXcursor xorg.libXrandr xorg.libXi # To use x11 feature
# libxkbcommon wayland # To use wayland feature
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
# Pin tool versions so trunk finds them in PATH instead of downloading
cat >> clients/web/Trunk.toml << 'EOF'
[tools]
wasm-bindgen = { version = "${wasm-bindgen-version}" }
wasm-opt = { version = "version_124" }
EOF
pushd clients/web
trunk build --release --offline
popd
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -R clients/web/dist/. $out/
runHook postInstall
shellHook = ''
export HOST=127.0.0.1
export PORT=7000
'';
};
trictrac = with final; rustPlatform.buildRustPackage {
pname = "trictrac";
version = "0.2.0";
src = ./.;
nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ];
# Build only the relay server; skip WASM/bot crates
cargoBuildFlags = [ "-p" "relay-server" ];
doCheck = false;
cargoLock = {
lockFile = ./Cargo.lock;
};
postInstall = ''
install -m 644 ${./server/relay-server/GameConfig.json} $out/GameConfig.json
'';
meta = with lib; {
description = "A online game of trictrac";
homepage = "https://github.com/mmai/trictrac";
license = licenses.gpl3;
platforms = platforms.unix;
};
};
trictrac-docker = with final;
let
port = "8080";
entrypoint = writeScript "entrypoint.sh" ''
#!${runtimeShell}
# Populate a writable working dir with static files + config
mkdir -p /var/lib/trictrac
for f in ${trictrac-front}/*; do
ln -sf "$f" "/var/lib/trictrac/$(basename "$f")"
done
cp -n ${trictrac}/GameConfig.json /var/lib/trictrac/ 2>/dev/null || true
cd /var/lib/trictrac
echo "Starting trictrac server on port ${port}"
exec ${trictrac}/bin/relay-server
'';
in
dockerTools.buildImage {
name = "mmai/trictrac";
tag = "latest";
copyToRoot = buildEnv {
name = "trictrac-env";
paths = [ busybox ];
};
config = {
Entrypoint = [ entrypoint ];
ExposedPorts = {
"${port}/tcp" = { };
};
};
};
};
packages = forAllSystems (system: {
inherit (nixpkgsFor.${system}) trictrac trictrac-front trictrac-docker;
});
defaultPackage = forAllSystems (system: self.packages.${system}.trictrac);
# trictrac service module
nixosModule = import ./module.nix;
};
}
}
);
}

View file

@ -9,47 +9,17 @@ shell:
runcli:
RUST_LOG=info cargo run --bin=client_cli
[working-directory: 'clients/web']
dev:
[working-directory: 'client_web/']
dev-leptos:
trunk serve
test-web:
wasm-pack test --node clients/web
[working-directory: 'clients/web']
build:
[working-directory: 'client_web']
build-leptos:
trunk build --release
cp dist/index.html ../../deploy/index.html
cp dist/*.wasm ../../deploy/
cp dist/*.js ../../deploy/
cp dist/*.css ../../deploy/
[working-directory: 'deploy']
run-relay:
./relay-server
build-relay:
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
mkdir -p deploy
cp target/release/relay-server deploy
cp -u server/relay-server/GameConfig.json deploy/
# start a trictrac container with nixos-container
# `boot.enableContainers = true` must be set on local nixos system
local:
cd container && nix flake update nixpkgs trictrac && cd -
sudo nixos-container destroy trictrac
sudo nixos-container create trictrac --flake ./container/
nixos-container start trictrac
machinectl
docker-build:
nix build .#trictrac-docker
docker-run: docker-build
docker load < ./result
docker run mmai/trictrac -P
docker-publish: docker-build
docker push mmai/trictrac
cp dist/index.html /home/henri/travaux/programmes/forks/multiplayer/deploy/trictrac.html
cp dist/*.wasm /home/henri/travaux/programmes/forks/multiplayer/deploy/
cp dist/*.js /home/henri/travaux/programmes/forks/multiplayer/deploy/
cp dist/*.css /home/henri/travaux/programmes/forks/multiplayer/deploy/
runclibots:
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
@ -62,7 +32,16 @@ profile:
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
cargo build --profile profiling
samply record ./target/profiling/client_cli --bot dummy,dummy
pythonlib:
rm -rf target/wheels
maturin build -m store/Cargo.toml --release
pip install --no-deps --force-reinstall --prefix .devenv/state/venv target/wheels/*.whl
cxxlib:
cargo build --release -p trictrac-store
@echo "Static lib: $(ls target/release/libtrictrac_store.a)"
@echo "CXX header: $(find target -name 'cxxengine.rs.h' | head -1)"
trainbot algo:
#python ./store/python/trainModel.py
# cargo run --bin=train_dqn # ok
# ./bot/scripts/trainValid.sh
./bot/scripts/train.sh {{algo}}
@ -75,4 +54,3 @@ profiletrainbot:
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
cargo build --profile profiling --bin=train_dqn_burn
LD_LIBRARY_PATH=./target/profiling samply record ./target/profiling/train_dqn_burn

View file

@ -1,210 +0,0 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.trictrac;
in
{
options = {
services.trictrac = {
enable = mkEnableOption "trictrac";
user = mkOption {
type = types.str;
default = "trictrac";
description = "User under which trictrac is ran.";
};
group = mkOption {
type = types.str;
default = "trictrac";
description = "Group under which trictrac is ran.";
};
protocol = mkOption {
type = types.enum [ "http" "https" ];
default = "https";
description = "Web server protocol.";
};
hostname = mkOption {
type = types.str;
default = "trictrac.localhost";
description = "Public domain name of the trictrac web app.";
};
apiPort = mkOption {
type = types.port;
default = 8080;
description = "Port the relay server listens on.";
};
smtp = {
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "SMTP server hostname.";
};
port = mkOption {
type = types.port;
default = 1025;
description = "SMTP server port.";
};
from = mkOption {
type = types.str;
default = "noreply@trictrac.local";
description = "Sender address for outgoing mail.";
};
user = mkOption {
type = types.str;
default = "";
description = "SMTP username (leave empty to skip authentication).";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/secrets/trictrac-smtp-password";
description = ''
Path to a file containing a single line: SMTP_PASSWORD=<secret>.
Loaded as a systemd EnvironmentFile so the secret never appears in
the Nix store or process environment of other units.
'';
};
};
createDatabaseLocally = mkOption {
type = types.bool;
default = true;
example = false;
description = "Create a local PostgreSQL database for trictrac.";
};
};
};
config = mkIf cfg.enable {
users.users.trictrac = mkIf (cfg.user == "trictrac") {
group = cfg.group;
isSystemUser = true;
};
users.groups.trictrac = mkIf (cfg.group == "trictrac") { };
services.nginx = {
enable = true;
# map needed for WebSocket Connection header upgrade
appendHttpConfig = ''
upstream trictrac-api {
server 127.0.0.1:${toString cfg.apiPort};
}
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
'';
virtualHosts =
let
proxyConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
'';
withSSL = cfg.protocol == "https";
in
{
"${cfg.hostname}" = {
enableACME = withSSL;
forceSSL = withSSL;
# Explicit listen so this vhost isn't shadowed by a default_server
# created by other virtual hosts with forceSSL = true.
listen = if withSSL then [
{ addr = "0.0.0.0"; port = 443; ssl = true; }
{ addr = "[::]"; port = 443; ssl = true; }
] else [
{ addr = "0.0.0.0"; port = 80; ssl = false; }
{ addr = "[::]"; port = 80; ssl = false; }
];
locations."/" = {
extraConfig = proxyConfig;
proxyPass = "http://trictrac-api/";
};
};
};
};
services.postgresql = mkIf cfg.createDatabaseLocally {
enable = mkDefault true;
ensureDatabases = [ "trictrac" ];
ensureUsers = [
{
name = cfg.user;
ensureDBOwnership = true;
}
];
# Allow the trictrac service user to connect via TCP without a password
authentication = mkAfter ''
host trictrac ${cfg.user} 127.0.0.1/32 trust
host trictrac ${cfg.user} ::1/128 trust
'';
};
systemd.services.trictrac-server =
let
setupScript = pkgs.writeShellScript "trictrac-setup" ''
set -euo pipefail
# Symlink frontend static files into the state directory so the
# relay server can serve them from its working directory.
for f in ${pkgs.trictrac-front}/*; do
ln -sf "$f" "$STATE_DIRECTORY/$(basename "$f")"
done
# Seed a writable GameConfig.json on first run; admins may edit it later.
if [ ! -f "$STATE_DIRECTORY/GameConfig.json" ]; then
install -m 644 ${pkgs.trictrac}/GameConfig.json "$STATE_DIRECTORY/GameConfig.json"
fi
'';
in
{
description = "trictrac relay server";
after = [ "network.target" ] ++ optional cfg.createDatabaseLocally "postgresql.service";
requires = optional cfg.createDatabaseLocally "postgresql.service";
wantedBy = [ "multi-user.target" ];
environment = {
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
APP_URL = "${cfg.protocol}://${cfg.hostname}";
SMTP_HOST = cfg.smtp.host;
SMTP_PORT = toString cfg.smtp.port;
SMTP_FROM = cfg.smtp.from;
} // optionalAttrs (cfg.smtp.user != "") {
SMTP_USER = cfg.smtp.user;
};
serviceConfig = {
User = cfg.user;
Group = cfg.group;
# systemd creates /var/lib/trictrac and sets STATE_DIRECTORY accordingly
StateDirectory = "trictrac";
StateDirectoryMode = "0755";
WorkingDirectory = "/var/lib/trictrac";
ExecStartPre = "${setupScript}";
ExecStart = "${pkgs.trictrac}/bin/relay-server";
EnvironmentFile = mkIf (cfg.smtp.passwordFile != null) cfg.smtp.passwordFile;
Restart = "on-failure";
RestartSec = "5s";
};
};
};
meta = {
maintainers = with lib.maintainers; [ mmai ];
};
}

View file

@ -1,7 +0,0 @@
[package]
name = "protocol"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }

View file

@ -1,72 +0,0 @@
//! The ids for messages that we use. They will be used consistent across the server and the client.
//! Also contains the protocol structure for joining a game.
use serde::{Deserialize, Serialize};
/// The buffer sizes for the channels for intra VPS communication.
pub const CHANNEL_BUFFER_SIZE: usize = 256;
// Client -> Server.
/// The message to announce a new client (Client->Server) followed by u16 client id.
pub const NEW_CLIENT: u8 = 0;
/// The message size for a new client (Header + Client Id) (u8 + u16)
pub const NEW_CLIENT_MSG_SIZE: usize = 3;
/// A client disconnects from the game. (Client->Server) and removes him from the room. followed by u16 client id.
pub const CLIENT_DISCONNECTS: u8 = 1;
/// The disconnect client message size (Header + Client Id) (u8 + u16)
pub const CLIENT_DISCONNECT_MSG_SIZE: usize = 3;
/// Client -> Server RPC followed by u16 Clientid, followed by payload from postcard or other coding. (Client->Server)
pub const SERVER_RPC: u8 = 2;
/// The disconnection message that is used for disconnecting without any arguments, that gets passed through the web socket layer.
pub const CLIENT_DISCONNECTS_SELF: u8 = 3;
// Server -> Client
/// The server disconnects from the game and the room gets closed.
pub const SERVER_DISCONNECTS: u8 = 0;
/// The disconnection message is just the byte itself.
pub const SERVER_DISCONNECT_MSG_SIZE: usize = 1;
/// A client gets kicked, meant for the situation, when no more clients should get accepted. followed by u16 client id. The receiving tokio task has to act on its own. (Server -> Client)
pub const CLIENT_GETS_KICKED: u8 = 1;
/// Delta update. Followed by payload for every delta update. May carry several delta messages in one pass.
pub const DELTA_UPDATE: u8 = 2;
/// Flagging a full update. Followed by payload for full update.
pub const FULL_UPDATE: u8 = 3;
/// The message to reset the game. This is also followed by a full update. Difference is, that every client will get the full update.
pub const RESET: u8 = 4;
/// The error message we add.
pub const SERVER_ERROR: u8 = 5;
/// The response message for the handshake.
pub const HAND_SHAKE_RESPONSE: u8 = 6;
// Sizes of entries.
/// For the handshake we respond with player id (u16), rule variation (u16), and reconnect token (u64).
pub const HAND_SHAKE_RESPONSE_SIZE: usize = 13;
/// The size of a new client. (u16)
pub const CLIENT_ID_SIZE: usize = 2;
/// The join request. This struct is used on the server and on the client.
#[derive(Deserialize, Serialize)]
pub struct JoinRequest {
/// Which game do we want to join.
pub game_id: String,
/// Which room do we want to join.
pub room_id: String,
/// The rule variation that is applied, this gets only interpreted if a room gets constructed.
pub rule_variation: u16,
/// Do we want to create a room and act as a server?
pub create_room: bool,
/// Reconnect token from a previous session. `None` = fresh join/create, `Some` = reconnect.
pub reconnect_token: Option<u64>,
}

View file

@ -1,28 +0,0 @@
[package]
name = "relay-server"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.48.0", features = ["full"] }
axum = { version = "0.8.7", features = ["ws"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
futures-util = "0.3.31"
postcard = "1.1.3"
bytes = "1.11.0"
tracing = "0.1.41"
tower-http = { version = "0.6.7", features = ["fs", "cors"] }
protocol = { path = "../protocol" }
rand = "0.8"
# User management / auth
tokio-postgres = "0.7"
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
tower-sessions = "0.14"
axum-login = "0.18"
argon2 = "0.5"
time = "0.3"
thiserror = "1"
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname"] }

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