chore: clean doc

This commit is contained in:
Henri Bourcereau 2026-05-27 16:17:25 +02:00
parent 178bbe3136
commit 3d63939853
4 changed files with 1 additions and 797 deletions

View file

@ -1,295 +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
```
clients/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 # English keys
│ └── fr.json # French keys
└── src/
├── main.rs # load_locales!() macro + mount_to_body
├── app.rs # Root App component, state, network loop
├── components/
│ ├── mod.rs
│ ├── game_screen.rs # Main in-game UI, move staging
│ ├── board.rs # Board rendering and click handling
│ ├── 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, …
├── backend.rs # BackEndArchitecture impl, engine bridge
└── bot_local.rs # Local bot: random moves, always Go
```
---
## 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`. `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. 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/`.

View file

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

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 |

View file

@ -161,7 +161,7 @@ body {
/* ── Stats grid ──────────────────────────────────────────────────── */ /* ── Stats grid ──────────────────────────────────────────────────── */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }