Compare commits

..

132 commits

Author SHA1 Message Date
ba9c9f6a7a Merge branch 'release/0.2.17' 2026-06-08 14:45:36 +02:00
8cf10b1b62 chore: bump version to 0.2.17 2026-06-08 14:45:35 +02:00
d3c0ecb5d4 feat: auto update frontend flake hash 2026-06-08 14:45:17 +02:00
6e87f0b388 Merge tag '0.2.16' into develop
0.2.16
2026-06-07 21:01:39 +02:00
e43b1312e5 Merge branch 'release/0.2.16' 2026-06-07 21:01:33 +02:00
1f9aaee186 chore: bump version to 0.2.16 2026-06-07 21:00:42 +02:00
e9206a989c fix(web client): new arrow positions 2026-06-07 20:59:19 +02:00
94340fe02c Merge branch 'feature/layout-side' into develop 2026-06-07 20:30:38 +02:00
fa8b87d213 fix(web client): free mode prise de coin par puissance 2026-06-07 20:29:11 +02:00
0afd3e0e41 fix(web client): free mode reset checkers only after "retry" button 2026-06-07 20:29:11 +02:00
576f9706c2 feat(web client): responsive design 2026-06-07 20:29:11 +02:00
9db942354c doc: design variations 2026-06-01 21:12:44 +02:00
d4a2ea1c53 chore: devenv.lock 2026-05-27 16:18:49 +02:00
3d63939853 chore: clean doc 2026-05-27 16:17:25 +02:00
178bbe3136 chore: clean doc 2026-05-26 20:52:22 +02:00
486649a599 feat(web client): free play mode 2026-05-26 18:28:56 +02:00
f459021f22 Merge tag '0.2.15' into develop
0.2.15
2026-05-25 21:44:27 +02:00
32668d0920 Merge branch 'release/0.2.15' 2026-05-25 21:44:23 +02:00
cd1e7802f7 chore: bump version to 0.2.15 2026-05-25 21:44:00 +02:00
0dfe462760 fix: flake frontendCargoDeps hash 2026-05-25 21:43:31 +02:00
bab83f3b9a Merge tag '0.2.14' into develop
0.2.14
2026-05-25 21:20:10 +02:00
46380ceadf Merge branch 'release/0.2.14' 2026-05-25 21:20:05 +02:00
2fd1ac1c44 chore: bump version to 0.2.14 2026-05-25 21:19:39 +02:00
fac0c1d29f fix(web client): cargo.lock 2026-05-25 21:19:12 +02:00
09f02aaa00 Merge tag '0.2.13' into develop
0.2.13
2026-05-25 21:02:55 +02:00
849b31dbb1 Merge branch 'release/0.2.13' 2026-05-25 21:02:47 +02:00
68a8535397 chore: bump version to 0.2.13 2026-05-25 21:02:27 +02:00
50f5c43a21 chore(nix): update web client front package hash 2026-05-25 21:01:08 +02:00
9e2ff3a9f1 feat(web client): about & legal links 2026-05-25 20:52:29 +02:00
20b8353cfb feat(server): user account deletion 2026-05-25 17:12:23 +02:00
6fd3499d7b feat(web client): content pages 2026-05-25 16:14:25 +02:00
58f5722551 feat(web client): sidebar links style 2026-05-24 21:33:47 +02:00
29a9c9b0a9 fix(relay-server): send email verification at login 2026-05-24 20:49:17 +02:00
91981e6872 fix(web client): sidebar shows login link for anonymous with a nickname 2026-05-24 16:22:50 +02:00
2f40a0a507 fix(web client): websocket url detection on invite screen 2026-05-24 16:14:27 +02:00
a23147556b Merge tag '0.2.12' into develop
0.2.12
2026-05-22 22:18:49 +02:00
0f3c962d02 Merge branch 'release/0.2.12' 2026-05-22 22:18:45 +02:00
8186e23035 chore: bump version to 0.2.12 2026-05-22 22:18:35 +02:00
88221303ef chore: bump version to 0.2.12 2026-05-22 22:15:27 +02:00
7d304f6e1e Merge tag 'v0.2.11' into develop
v0.2.11
2026-05-22 21:43:54 +02:00
da10ddee2b Merge branch 'release/v0.2.11' 2026-05-22 21:43:50 +02:00
6e94f8d89a feat: unify version number 2026-05-22 21:43:41 +02:00
1686079ca9 feat(web client): show version number 2026-05-22 21:42:30 +02:00
4003fc0ef2 fix(web client): profile page without draws 2026-05-22 17:30:23 +02:00
25554126a8 feat(web client): date time format options 2026-05-22 16:54:37 +02:00
9443a04ad6 feat(system): nginx access log 2026-05-22 15:33:22 +02:00
c7bb3a3291 Merge tag 'v0.2.10' into develop
v0.2.10
2026-05-20 20:26:54 +02:00
ad356fe832 Merge branch 'release/v0.2.10' 2026-05-20 20:26:50 +02:00
5923681eb8 fix: smtp_password 2026-05-20 20:26:19 +02:00
5b64a24b81 Merge tag 'v0.2.9' into develop
v0.2.9
2026-05-20 12:37:55 +02:00
469b87bc69 Merge branch 'release/v0.2.9' 2026-05-20 12:37:49 +02:00
9a9da37d19 fix: module.nix credentials permissions 2026-05-20 12:37:36 +02:00
4581b41a2b Merge tag 'v0.2.8' into develop
v0.2.8
2026-05-20 10:45:53 +02:00
c61ea4ae67 Merge branch 'release/v0.2.8' 2026-05-20 10:45:47 +02:00
90f21ac05a fix: module.nix passfile 2026-05-20 10:45:15 +02:00
fbc76f879a Merge tag 'v0.2.7' into develop
v0.2.7
2026-05-19 22:51:54 +02:00
c38e221679 Merge branch 'release/v0.2.7' 2026-05-19 22:51:49 +02:00
ea0c7837a9 fix: EnvironmentFile directive in module.nix 2026-05-19 22:51:33 +02:00
a4a79de732 Merge tag 'v0.2.6' into develop
v0.2.6
2026-05-19 20:36:25 +02:00
d753a839ea Merge branch 'release/v0.2.6' 2026-05-19 20:36:20 +02:00
0baff16c58 refact: fix wasm-bindgen 2026-05-19 20:35:13 +02:00
6dd69f4a62 doc: ui mockups examples from real games 2026-05-13 17:02:34 +02:00
1da8731c3d Merge tag 'v0.2.5' into develop
v0.2.5
2026-05-11 22:10:40 +02:00
97023c408d Merge branch 'release/v0.2.5' 2026-05-11 22:10:35 +02:00
b20cc6c562 fix: sha 2026-05-11 22:10:24 +02:00
6fe697ac62 Merge tag 'v0.2.4' into develop
v0.2.4
2026-05-11 21:25:12 +02:00
dac2645d01 Merge branch 'release/v0.2.4' 2026-05-11 21:25:07 +02:00
a6fa11181d feat: enable cloud smtp services 2026-05-11 21:24:17 +02:00
255d2a56e8 Merge tag 'v0.2.3' into develop
v0.2.3
2026-05-09 18:06:45 +02:00
0aa903644d Merge branch 'hotfix/v0.2.3' 2026-05-09 18:06:35 +02:00
cb65f94dde fix(nix): nginx vhost explicit listen 2026-05-09 18:06:31 +02:00
84c48a566a Merge tag 'v0.2.2' into develop
v0.2.2
2026-05-09 13:50:25 +02:00
9dc803e078 Merge branch 'release/v0.2.2' 2026-05-09 13:50:21 +02:00
18c5eedacd fix: rustPkgs in flake.nix 2026-05-09 13:49:26 +02:00
f14a59bc8d Merge tag 'v0.2.1' into develop
v0.2.1
2026-05-09 13:13:04 +02:00
93e2d3f303 Merge branch 'release/v0.2.1' 2026-05-09 13:12:57 +02:00
ccd63810d5 fix: rust overlay in nix package 2026-05-09 13:12:39 +02:00
546eb1fe33 feat: nix module with smtp options 2026-05-09 09:31:37 +02:00
1ffd479013 fix: nix module without bot & cli dependencies 2026-05-07 23:14:55 +02:00
b067d76e3a feat: nix module 2026-05-07 22:52:15 +02:00
eb09213c57 fix(store): prise de coin par puissance 2026-05-07 17:19:30 +02:00
fbc6a3c432 feat(web client): take & replay game snapshot 2026-05-07 15:30:24 +02:00
a82169fbe5 fix(web client): reactive exit sign 2026-05-07 14:27:53 +02:00
7395d140cc fix(web client): show toss winner 2026-05-07 13:24:05 +02:00
8705cc418b feat(web client): add action button icons 2026-05-07 10:58:22 +02:00
f893ecaf9f fix(store): exit conditions with one checker outside last jan 2026-05-06 19:14:53 +02:00
730802dfd1 fix(web client): normalize cards style 2026-05-06 14:30:39 +02:00
0eb52661e1 feat(web client): sidebar menu icons 2026-05-06 13:24:06 +02:00
ec0a3b0ee1 feat(web client): replace fields highlights by base indicators 2026-05-05 21:04:13 +02:00
e422eab4d5 fix: limit nicknames display length 2026-05-05 20:54:24 +02:00
8f40304f41 fix(web client): show dice animation & sound only once 2026-05-05 17:49:00 +02:00
9755ab1d41 fix(web client): don't highlight all fields when no movement possible 2026-05-05 16:59:04 +02:00
236c6df826 feat: replace navigation bar by collapsable sidebar & hamburger button 2026-05-04 20:32:30 +02:00
e0698986f1 fix(web client): show invitation link until opponent connects 2026-05-04 16:22:24 +02:00
2c3281cc34 feat(web client): ask nickname when joining a room 2026-05-04 15:54:38 +02:00
d24f850882 feat: add email verification & password reset link 2026-05-03 21:39:07 +02:00
440bf12c43 fix(store): check color on opponent corner hit 2026-05-02 21:31:23 +02:00
bf22060614 fix(web client): exit dice used 2026-05-02 21:31:23 +02:00
60f33750eb feat(web client): opponent sounds 2026-05-02 20:01:30 +02:00
e0f059e09c fix(web client): points arrow coloring 2026-05-02 12:20:06 +02:00
5328b8e5aa fix(web client): show jans arrows 2026-05-02 12:10:02 +02:00
e61448b627 feat(web client): local heuristic bot 2026-05-02 11:26:55 +02:00
20134ce468 chore: remove old web-game & web-user-portal crates 2026-05-02 11:11:39 +02:00
2c41e68cd6 feat(web client): color fields for points 2026-05-01 22:43:49 +02:00
60d8e0326a fix(sound): add pre game die roll & opponent points sounds 2026-05-01 21:42:48 +02:00
9bdb32b364 feat(ui): show jan points next to hole counts 2026-05-01 19:09:58 +02:00
7a990eb7e9 fix(ui): make outside board clickable to exit 2026-05-01 18:18:45 +02:00
bceec1f8fe fix: reset board when a player exited all his checkers 2026-04-30 18:09:43 +02:00
d3455def33 feat(ui): merge score panels, move message below board 2026-04-29 21:52:47 +02:00
c69891605e fix: remove language switcher from game panel 2026-04-26 16:00:27 +02:00
04369ea28e feat: generate room name, link & QR code 2026-04-25 22:23:52 +02:00
c46d26ae02 fix: show login name in game 2026-04-25 21:51:16 +02:00
15a2963f7e fix: show login failed error message 2026-04-25 19:08:03 +02:00
3717a34da6 Merge branch 'feature/multiplayer' into develop 2026-04-25 16:50:44 +02:00
557f0249f8 feat: merge web-user-portal & web-game 2026-04-25 16:49:25 +02:00
9cc605409e doc: maj readme 2026-04-24 17:22:59 +02:00
82803ded36 fix: integrate multiplayer 2026-04-23 20:54:52 +02:00
3f3f4598f6 fix: integrate multiplayer (wip) 2026-04-23 17:37:10 +02:00
03b614c62e refact: migrate sqlx + sqlite to tokio-postgresql 2026-04-22 21:52:29 +02:00
4f5e21becb chore: integrate multiplayer code (wip) 2026-04-22 17:42:05 +02:00
2838d59f30 fix(client_web): only animate 2nd checker on 2nd move 2026-04-18 17:11:47 +02:00
00326cd645 feat(backend): use pre-game roll result for the first move 2026-04-18 16:55:49 +02:00
1562ed1e40 fix(doc): rules: opponent's big jan != return jan 2026-04-18 16:21:38 +02:00
89916c63ca fix(bot_local): always hold on point gain 2026-04-18 16:13:45 +02:00
87677a09b0 fix(client_web): pre-game : allow guest to roll die without waiting for host 2026-04-18 16:12:38 +02:00
6995f9c888 feat(client_web): show a '?' when a die is not yet rolled 2026-04-18 16:10:09 +02:00
24f5dba065 feat(client_web): pre-game roll decide first player 2026-04-17 22:22:50 +02:00
b68881fc38 fix(client_web): when "holding" bot sends "Move" instead of "Mark" 2026-04-17 20:56:41 +02:00
9af672823e feat(client_web): use a mp3 file for dice roll sound 2026-04-17 20:04:40 +02:00
43196bcef8 fix(store): check_opponent_can_fill_quarter_rule on both checkers 2026-04-15 19:51:31 +02:00
7e8d0a18c1 chore(web_client): refact 2026-04-15 17:27:14 +02:00
d779f7415a feat(web_client): browser console.log from backend 2026-04-15 17:27:14 +02:00
110 changed files with 24322 additions and 9896 deletions

3
.gitignore vendored
View file

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

6296
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,55 +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",
"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",
"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"
}

View file

@ -1,55 +0,0 @@
{
"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": "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",
"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",
"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",
"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"
}

View file

@ -1,626 +0,0 @@
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, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState,
};
use trictrac_store::CheckerMove;
use std::collections::VecDeque;
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,
/// True when this state is a buffered snapshot awaiting player confirmation.
pub waiting_for_confirm: bool,
/// Why we are paused — drives the status-bar message in GameScreen.
pub pause_reason: Option<PauseReason>,
/// Points scored by this player in the transition to this state (if any).
pub my_scored_event: Option<ScoredEvent>,
pub opp_scored_event: Option<ScoredEvent>,
/// Checker moves to animate on this render. None when board is unchanged.
pub last_moves: Option<(CheckerMove, CheckerMove)>,
}
/// Reason the UI is paused waiting for the player to click Continue.
#[derive(Clone, Debug, PartialEq)]
pub enum PauseReason {
AfterOpponentRoll,
AfterOpponentGo,
AfterOpponentMove,
}
/// 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>();
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 {
// 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, pending).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 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();
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),
}
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()),
});
}
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),
},
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! {
<I18nContextProvider>
{move || {
let q = pending.get();
if let Some(front) = q.front() {
view! { <GameScreen state=front.clone() /> }.into_any()
} else {
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>,
pending: RwSignal<VecDeque<GameUiState>>,
) -> bool {
let mut backend = TrictracBackend::new(0);
backend.player_arrival(0);
backend.player_arrival(1);
let mut vs = ViewState::default_with_names("You", "Bot");
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,
waiting_for_confirm: false,
pause_reason: None,
my_scored_event: None,
opp_scored_event: None,
last_moves: None,
}));
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);
}
}
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),
}));
}
Some(NetCommand::PlayVsBot) => return true,
_ => return false,
}
loop {
match bot_decide(backend.get_game()) {
None => break,
Some(action) => {
let prev_vs = vs.clone();
backend.inform_rpc(1, action);
for cmd in backend.drain_commands() {
if let BackendCommand::Delta(delta) = cmd {
vs.apply_delta(&delta);
}
}
push_or_show(
&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(&prev_vs, &vs),
},
pending,
screen,
);
}
}
}
}
}
/// Returns the checker moves to animate when the board changed between two ViewStates.
/// Returns `None` when the board is unchanged or no real moves were recorded.
fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> {
if prev.board == next.board {
return None;
}
let (m1, m2) = next.dice_moves;
if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
// Relies on the engine invariant: dice_moves is updated atomically with the board
// change in the Move event handler. Any future engine path that mutates the board
// without setting dice_moves would bypass this guard and replay stale animation.
return None;
}
Some((m1, m2))
}
/// Computes a scoring event for `player_id` by comparing the previous and next
/// ViewState. Returns `None` when no points changed for that player.
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;
// Determine which dice_jans are "mine" depending on who was the active roller.
let my_jans: Vec<JanEntry> = if next.active_mp_player == Some(player_id)
&& prev.active_mp_player == Some(player_id)
{
// My own roll: positive totals are mine.
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) {
// Opponent just moved: negative totals (their penalty) are scored for me.
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 buffered confirmation step (when the transition
/// warrants a pause) or shows it immediately. Always updates `screen` to the
/// live state so the UI falls through to the right content once pending drains.
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) {
// Scoring notifications go on the buffered (paused) state only.
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()
});
});
// Animation belongs to the buffered confirmation step; clear it on the
// fallback live state so it doesn't fire again after the queue drains.
screen.set(Screen::Playing(GameUiState {
last_moves: None,
..new_state
}));
} else {
// No pause: show scoring directly on the live state.
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. Returns None when it is the local player's
/// own action (no pause needed).
fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<PauseReason> {
let opponent_id = 1 - player_id;
if next.active_mp_player == Some(opponent_id) {
// Dice changed → opponent just rolled.
if next.dice != prev.dice {
return Some(PauseReason::AfterOpponentRoll);
}
// Was at HoldOrGoChoice, now Move, opponent still active → opponent went.
if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move
{
return Some(PauseReason::AfterOpponentGo);
}
}
// Turn switched to us → opponent moved.
if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) {
return Some(PauseReason::AfterOpponentMove);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::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()),
message: "".into(),
}
}
#[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,408 +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 crate::app::{GameUiState, NetCommand, PauseReason};
use crate::i18n::*;
use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage};
use super::board::Board;
use super::score_panel::PlayerScorePanel;
use super::scoring::ScoringPanel;
#[component]
pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n();
let vs = state.view_state.clone();
let message = format!("{}", vs.message);
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();
// ── 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::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.
let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice;
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_quit = 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();
// ── Scoring notifications ──────────────────────────────────────────────────
let my_scored_event = state.my_scored_event.clone();
let opp_scored_event = state.opp_scored_event.clone();
let hole_toast_info = my_scored_event
.as_ref()
.filter(|e| e.holes_gained > 0)
.map(|e| (e.holes_total, e.bredouille));
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
let last_moves = state.last_moves;
// §6e — 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 just appeared (no preceding moves in this snapshot).
if show_dice && last_moves.is_none() {
crate::sound::play_dice_roll_cinematic();
}
// Checker move: moves were committed in the preceding action.
if last_moves.is_some() {
crate::sound::play_checker_move();
}
// Scoring: hole takes priority over plain points.
if let Some(ref ev) = my_scored_event {
if ev.holes_gained > 0 {
crate::sound::play_hole_scored();
} else {
crate::sound::play_points_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;
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>
// ── Status bar — full width, above board (§10b) ──────────────────
<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),
});
}
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),
})
}
}}
</div>
// ── Contextual sub-prompt (§8a) ──────────────────────────────────
{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> })
}}
// ── Opponent score (above board) ─────────────────────────────────
<PlayerScorePanel score=opp_score 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
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
/>
// ── Side panel (scoring panels only) ─────────────────────────
<div class="side-panel">
{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>
// ── Player score (below board) ────────────────────────────────────
<PlayerScorePanel score=my_score is_you=true />
// ── Action buttons below board ────────────────────────────
<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 || {
// 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>
{format!("{message}")}
</div>
// ── Game-over overlay ─────────────────────────────────────────────
{stage_is_ended.then(|| {
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>
}
})}
// ── Hole toast (§6a) — board-centered overlay when a hole is won ──
{hole_toast_info.map(|(holes_total, bredouille)| view! {
<div class="hole-toast" class:hole-toast-bredouille=bredouille>
<div class="hole-toast-title">"Trou !"</div>
<div class="hole-toast-count">{format!("{holes_total} / 12")}</div>
{bredouille.then(|| view! {
<div class="hole-toast-bredouille">"× 2 bredouille"</div>
})}
</div>
})}
</div>
}
}

View file

@ -1,95 +0,0 @@
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-card">
// ── Decorative board header ─────────────────────────────────────
<div class="login-card-header">
<div class="login-board-stripe"></div>
</div>
// ── Card body ──────────────────────────────────────────────────
<div class="login-card-body">
<div class="login-lang-switcher">
<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>
<h1 class="login-title">"Trictrac"</h1>
<p class="login-subtitle">
<em>"Une interprétation numérique"</em>
</p>
<div class="login-ornament">""</div>
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
<input
class="login-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))
/>
<div class="login-actions">
<button
class="login-btn login-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="login-btn login-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="login-btn login-btn-bot"
on:click=move |_| {
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
}
>
{t!(i18n, play_vs_bot)}
</button>
</div>
</div>
</div>
}
}

View file

@ -1,74 +0,0 @@
use leptos::prelude::*;
use trictrac_store::Jan;
use crate::i18n::*;
use crate::trictrac::types::PlayerScore;
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(),
}
}
#[component]
pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
let i18n = use_i18n();
let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
let points_val = format!("{}/12", score.points);
let holes = score.holes;
let can_bredouille = score.can_bredouille;
// 12 peg holes; filled up to `holes`
let pegs: Vec<AnyView> = (1u8..=12)
.map(|i| {
let cls = if i <= holes {
"peg-hole filled"
} else {
"peg-hole"
};
view! { <div class=cls></div> }.into_any()
})
.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">
{can_bredouille.then(|| view! {
<span class="bredouille-badge" title=move || t_string!(i18n, bredouille_title).to_owned()>"B"</span>
})}
<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>
</div>
<div class="score-bar-row">
<span class="score-bar-label">{t!(i18n, holes_label)}</span>
<div class="peg-track">{pegs}</div>
<span class="score-bar-value">{format!("{holes}/12")}</span>
</div>
</div>
</div>
}
}

View file

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

View file

@ -1,33 +0,0 @@
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::Mark),
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

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

View file

@ -0,0 +1,84 @@
//! 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

@ -0,0 +1,211 @@
//! 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

@ -0,0 +1,10 @@
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

@ -0,0 +1,48 @@
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

@ -0,0 +1,159 @@
//! 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

@ -0,0 +1,266 @@
//! 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

@ -0,0 +1,97 @@
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

@ -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" }
trictrac-bot = { path = "../../bot" }
spiel_bot = { path = "../../spiel_bot" }
itertools = "0.13.0"
env_logger = "0.11.6"
log = "0.4.20"

View file

@ -1,6 +1,6 @@
[package]
name = "client_web"
version = "0.1.0"
name = "trictrac-web"
version.workspace = true
edition = "2021"
[package.metadata.leptos-i18n]
@ -9,22 +9,27 @@ 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_router = { version = "0.7" }
trictrac-store = { path = "../../store" }
backbone-lib = { path = "../backbone-lib" }
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
futures = "0.3"
rand = "0.9"
gloo-storage = "0.3"
qrcodegen = "1.8"
pulldown-cmark = "0.13"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "=0.2.118"
wasm-bindgen-futures = "0.4"
gloo-net = { version = "0.5", features = ["http"] }
gloo-timers = { version = "0.3", features = ["futures"] }
# getrandom 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"] }
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"RequestCredentials",
"AudioContext",
"AudioParam",
"AudioNode",
@ -34,4 +39,11 @@ web-sys = { version = "0.3", features = [
"OscillatorNode",
"OscillatorType",
"BaseAudioContext",
"HtmlAudioElement",
"Clipboard",
"Navigator",
"Location",
] }
[dev-dependencies]
wasm-bindgen-test = "0.3"

2
clients/web/Trunk.toml Normal file
View file

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

Binary file not shown.

2519
clients/web/assets/style.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@
<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>

167
clients/web/locales/en.json Normal file
View file

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

165
clients/web/locales/fr.json Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

327
clients/web/src/api.rs Normal file
View file

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

831
clients/web/src/app.rs Normal file
View file

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

View file

@ -2,7 +2,7 @@ use leptos::prelude::*;
use trictrac_store::CheckerMove;
use super::die::Die;
use crate::trictrac::types::{SerTurnStage, ViewState};
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];
@ -39,11 +39,17 @@ fn field_zone_class(field_num: u8) -> &'static str {
}
/// Returns (d0_used, d1_used) for the bar dice display.
fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
pub(crate) fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
let mut d0 = false;
let mut d1 = false;
for &(from, to) in staged {
let dist = if from < to {
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)
@ -52,7 +58,7 @@ fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
d0 = true;
} else if !d1 && dist == dice.1 {
d1 = true;
} else if !d0 {
} else if !d0 && dist <= dice.0 && dice.0 <= dice.1 {
d0 = true;
} else {
d1 = true;
@ -106,7 +112,8 @@ fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -
}
/// 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.
/// Geometry: field 60×180px, board padding 4px, row gap 4px, bar 5px, center-bar 12px.
/// Quarter width: 6×60 + 5×2(inter-field gap) = 370px. Board total: 761px.
/// With triangular flèches, arrows target the WIDE BASE of each triangle —
/// that is where the checker stack actually sits.
fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
@ -131,9 +138,9 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
}
};
// 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
// Right-quarter: 4 + 370(quarter) + 4(gap) + 5(bar) + 4(gap) + i*62 + 30 = 417 + 62i
let x = if right {
480.0 + qi as f32 * 62.0
417.0 + qi as f32 * 62.0
} else {
34.0 + qi as f32 * 62.0
};
@ -240,6 +247,107 @@ fn valid_dests_for(
v
}
/// In free-mode: all fields that own a checker (after staged moves applied).
fn free_mode_origins_for(board: [i8; 24], staged: &[(u8, u8)], is_white: bool) -> Vec<u8> {
(1u8..=24)
.filter(|&f| {
let v = displayed_value(board, staged, is_white, f);
if is_white {
v > 0
} else {
v < 0
}
})
.collect()
}
/// In free-mode: destinations reachable from `origin` by the remaining die value,
/// excluding fields occupied by opponent checkers.
fn free_mode_dests_for(
board: [i8; 24],
staged: &[(u8, u8)],
origin: u8,
dice: (u8, u8),
is_white: bool,
all_in_exit: bool,
) -> Vec<u8> {
let to_use: Vec<u8> = match staged.len() {
0 => {
if dice.0 == dice.1 {
vec![dice.0]
} else {
vec![dice.0, dice.1]
}
}
1 => {
let &(f0, t0) = &staged[0];
if t0 == 0 {
// First move was an exit — can't reliably infer die, offer both
if dice.0 == dice.1 {
vec![dice.0]
} else {
vec![dice.0, dice.1]
}
} else {
let dist: u8 = if is_white {
t0.saturating_sub(f0)
} else {
f0.saturating_sub(t0)
};
if dice.0 == dice.1 {
vec![dice.0]
} else if dist == dice.0 {
vec![dice.1]
} else {
vec![dice.0]
}
}
}
_ => return vec![],
};
let opp_present = |f: u8| -> bool {
let v = displayed_value(board, staged, is_white, f);
if is_white {
v < 0
} else {
v > 0
}
};
let mut dests = vec![];
for die in to_use {
if die == 0 {
continue;
}
let dest: i16 = if is_white {
origin as i16 + die as i16
} else {
origin as i16 - die as i16
};
if dest >= 1 && dest <= 24 {
let d = dest as u8;
if !opp_present(d) {
if d == 13 && is_white && displayed_value(board, staged, is_white, 12) < 2 {
// prise de coin par puissance for white
dests.push(12)
} else if d == 12 && !is_white && displayed_value(board, staged, is_white, 13) > -2
{
// prise de coin par puissance for black
dests.push(13)
} else {
dests.push(d);
}
}
} else if all_in_exit {
dests.push(0); // exit
}
}
dests.sort_unstable();
dests.dedup();
dests
}
#[component]
pub fn Board(
view_state: ViewState,
@ -266,17 +374,34 @@ pub fn Board(
/// Fields where a hit (battue) was scored this turn — show ripple animation.
#[prop(default = vec![])]
hit_fields: Vec<u8>,
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
#[prop(default = false)]
suppress_dice_anim: bool,
/// When true, any field with own checkers is selectable as origin; destinations
/// are computed from dice arithmetic rather than from pre-validated sequences.
#[prop(default = RwSignal::new(false))]
free_mode: RwSignal<bool>,
) -> impl IntoView {
let board = view_state.board;
let vs_dice = view_state.dice;
let white_points = view_state.scores[0].points;
let white_can_bredouille = view_state.scores[0].can_bredouille;
let black_points = view_state.scores[1].points;
let black_can_bredouille = view_state.scores[1].can_bredouille;
let is_move_stage = view_state.active_mp_player == Some(player_id)
&& matches!(
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
// True when ANY player is in the Move/HoldOrGoChoice stage — i.e., dice are fresh for the active player.
let active_is_move_stage = matches!(
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let is_white = player_id == 0;
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// Exit-eligible (§8c): all the player's checkers are in their last jan.
// 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;
@ -294,6 +419,9 @@ pub fn Board(
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()
@ -335,6 +463,13 @@ pub fn Board(
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
@ -352,7 +487,7 @@ pub fn Board(
cls.push_str(" exit-eligible");
}
if seqs_c.is_empty() {
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");
@ -361,6 +496,23 @@ pub fn Board(
if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest");
}
} else if can_stage && free_mode.get() {
// Free-play mode: highlight based on dice arithmetic
if let Some(origin) = sel {
if origin == field_num {
cls.push_str(" selected clickable");
} else {
let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
if dests.iter().any(|&d| d == field_num && d != 0) {
cls.push_str(" clickable dest");
}
}
} else {
let origins = free_mode_origins_for(board, &staged, is_white);
if origins.iter().any(|&o| o == field_num) {
cls.push_str(" clickable");
}
}
} else if can_stage {
if let Some(origin) = sel {
if origin == field_num {
@ -402,6 +554,26 @@ pub fn Board(
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
if free_mode.get_untracked() {
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
}
Some(origin) => {
let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
if dests.iter().any(|&d| d == field_num) {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None);
}
}
None => {
let origins = free_mode_origins_for(board, &staged, is_white);
if origins.iter().any(|&o| o == field_num) {
selected_origin.set(Some(field_num));
}
}
}
} else {
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
@ -428,11 +600,6 @@ pub fn Board(
} 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));
}
}
@ -507,8 +674,13 @@ pub fn Board(
bar_matched_dice_used(&staged, dice_vals)
} else if is_my_turn {
(true, true)
} else {
} 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 /> }
@ -526,23 +698,11 @@ pub fn Board(
(&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>
@ -557,7 +717,7 @@ pub fn Board(
</div>
// SVG overlay: arrows for hovered jan moves
<svg
width="824" height="388"
width="761" height="388"
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
>
{move || {
@ -583,12 +743,120 @@ pub fn Board(
.collect()
}}
</svg>
// Exit sign: circle+arrow outside the board, next to the last exit field.
// White exits to the right (top-right quarter); Black exits to the left (top-left).
{move || {
// Recompute on every staged_moves change: the exit button must appear
// even when the initial board has a checker outside the exit zone,
// because the first move can bring all checkers in (e.g. 15→21, 19→exit).
let staged = staged_moves.get();
let show = is_move_stage && if free_mode.get() {
// In free mode show exit button whenever all checkers are in exit zone
all_in_exit && staged.len() < 2
} else {
match staged.len() {
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
1 => {
let (f0, t0) = staged[0];
seqs_exit.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.any(|(_, m2)| m2.get_to() == 0)
}
_ => false,
}
};
show.then(|| {
let seqs_exit_cls = seqs_exit.clone();
let seqs_exit_click = seqs_exit.clone();
let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) =
if is_white {
(
"position:absolute;right:-60px;top:15px;width:50px;height:50px",
"10", "31", "23,17 32,25 23,33",
)
} else {
(
"position:absolute;left:-60px;top:15px;width:50px;height:50px",
"40", "19", "27,17 18,25 27,33",
)
};
view! {
<div
title="Exit"
style=pos_style
class=move || {
let staged = staged_moves.get();
let sel = selected_origin.get();
let active = match sel {
Some(origin) => if free_mode.get() {
free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
.iter().any(|&d| d == 0)
} else {
seqs_exit_cls.is_empty()
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
.iter()
.any(|&d| d == 0)
},
None => false,
};
if active { "exit-btn exit-active" } else { "exit-btn" }
}
on:click=move |_| {
if !is_move_stage { return; }
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
let Some(origin) = selected_origin.get_untracked() else {
return;
};
let valid = if free_mode.get_untracked() {
free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
.iter().any(|&d| d == 0)
} else {
seqs_exit_click.is_empty()
|| valid_dests_for(&seqs_exit_click, &staged, origin)
.iter()
.any(|&d| d == 0)
};
if valid {
staged_moves.update(|v| v.push((origin, 0)));
selected_origin.set(None);
}
}
>
<svg width="50" height="50" viewBox="0 0 50 50">
<circle
cx="25" cy="25" r="20"
style="fill:rgba(10,20,10,0.75);stroke:rgba(210,170,30,0.75);stroke-width:2.5"
/>
<line
x1=line_x1 y1="25" x2=line_x2 y2="25"
style="stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round"
/>
<polyline
points=head_pts
style="fill:none;stroke:rgba(210,170,30,0.85);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round"
/>
</svg>
</div>
<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>
}
.into_any()
})
}}
</div>
</div>
}
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test]
fn test_bar_matched_dice_used() {
assert_eq!((true, false), bar_matched_dice_used(&[(22, 24)], (2, 3)));
assert_eq!((false, true), bar_matched_dice_used(&[(22, 0)], (2, 3)));
assert_eq!((false, true), bar_matched_dice_used(&[(24, 0)], (5, 1)));
assert_eq!((true, false), bar_matched_dice_used(&[(24, 0)], (1, 5)));
}
}

View file

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

View file

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

View file

@ -2,15 +2,18 @@ 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;
#[cfg(target_arch = "wasm32")]
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use crate::app::NetCommand;
use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
use crate::i18n::*;
use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
use super::score_panel::jan_label;
@ -48,6 +51,14 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
}
}
/// 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,
@ -69,22 +80,21 @@ pub fn ScoringPanel(
"scoring-panel"
};
// ── Lifecycle signals ──────────────────────────────────────────────────
// peeked: added after 3.4 s (slide to peek strip)
// revealed: added on first hover of the peek strip (stay open permanently)
let peeked = RwSignal::new(false);
let revealed = RwSignal::new(false);
// minimized: starts false (expanded)
let minimized = RwSignal::new(false);
// ── Collect all moves from all jans for automatic arrow display ────────
// 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_click = all_moves.clone();
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.
@ -120,36 +130,14 @@ pub fn ScoringPanel(
return;
}
hm.set(vec![]);
peeked.set(true);
});
}
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
view! {
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
// pointer-events are on by default (parent .side-panel sets none,
// and .scoring-panel-wrapper overrides back to auto in CSS).
<div
class="scoring-panel-wrapper"
class:peeked=move || peeked.get()
class:revealed=move || revealed.get()
// Click toggles revealed↔peeked when the panel is in its peeked state.
on:click=move |_| {
if peeked.get_untracked() {
revealed.update(|r| *r = !*r);
}
// Show arrows when clicking to open, clear when clicking to close.
if let Some(hm) = hovered_ctx {
if !revealed.get_untracked() {
hm.set(all_moves_click.clone());
} else {
hm.set(vec![]);
}
}
}
class:scoring-minimized=move || minimized.get()
on:mouseenter=move |_| {
// Show all event moves as arrows while the cursor is inside.
if let Some(hm) = hovered_ctx {
hm.set(all_moves_enter.clone());
}
@ -160,7 +148,24 @@ pub fn ScoringPanel(
}
}
>
// "+" 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)
@ -168,6 +173,20 @@ pub fn ScoringPanel(
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">
@ -187,17 +206,22 @@ pub fn ScoringPanel(
let dismissed = RwSignal::new(false);
view! {
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
// stop_propagation so these buttons don't also toggle the panel
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
<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| {
<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();
}>
cmd_tx
.unbounded_send(NetCommand::Action(PlayerAction::Go))
.ok();
}
>
{t!(i18n, go)}
</button>
</div>

View file

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

View file

@ -0,0 +1,310 @@
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

@ -128,6 +128,14 @@ mod inner {
});
}
/// 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| {
@ -138,6 +146,22 @@ mod inner {
});
}
/// 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| {
@ -148,7 +172,54 @@ mod inner {
(1046.5, 0.51, 0.55),
];
for (freq, offset, dur) in notes {
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
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);
}
});
}
@ -158,14 +229,27 @@ mod inner {
#[cfg(target_arch = "wasm32")]
pub use inner::{
play_checker_move, play_dice_roll_cinematic, play_hole_scored, play_points_scored,
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,7 +1,7 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage};
use trictrac_store::{Color, Dice, DiceRoller, GameEvent, GameState, Player, Stage, TurnStage};
use crate::trictrac::types::{GameDelta, PlayerAction, ViewState};
use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
// Store PlayerId (u64) values used for the two players.
const HOST_PLAYER_ID: u64 = 1;
@ -14,11 +14,28 @@ 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) {
self.view_state = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
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;
}
fn broadcast_state(&mut self) {
@ -29,6 +46,49 @@ 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();
@ -70,13 +130,72 @@ 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("Host");
game.init_player("Guest");
game.init_player("Blancs");
game.init_player("Noirs");
let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
@ -86,6 +205,9 @@ 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,
}
}
@ -110,11 +232,15 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
timer_id: mp_player,
});
// 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,
});
// 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;
self.sync_view_state();
self.commands.push(BackendCommand::ResetViewState);
} else {
@ -135,6 +261,24 @@ 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;
}
@ -167,7 +311,6 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
moves: (m1, m2),
};
if self.game.validate(&event) {
self.game.debug_message = format!("Event {:?} validated", event);
let _ = self.game.consume(&event);
self.drive_automatic_stages();
}
@ -187,6 +330,8 @@ 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();
@ -214,6 +359,7 @@ 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 {
@ -232,15 +378,37 @@ 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_game() {
fn both_players_arrive_starts_ceremony() {
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 after BeginGame.
// ResetViewState should have been issued to start the ceremony.
let has_reset = cmds
.iter()
.any(|c| matches!(c, BackendCommand::ResetViewState));
@ -249,11 +417,44 @@ mod tests {
"expected ResetViewState after both players arrive"
);
// Game should now be InGame.
use crate::trictrac::types::SerStage;
// 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);
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();
@ -271,12 +472,18 @@ mod tests {
b.player_arrival(1);
b.drain_commands();
// Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame).
b.inform_rpc(0, PlayerAction::Roll);
// 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);
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!(
@ -297,14 +504,14 @@ mod tests {
b.player_arrival(0);
b.player_arrival(1);
b.drain_commands();
complete_ceremony(&mut b);
// Guest tries to roll when it's the host's turn.
b.inform_rpc(1, PlayerAction::Roll);
// 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);
let cmds = b.drain_commands();
assert!(
cmds.is_empty(),
"guest roll should be ignored when it's host's turn"
);
assert!(cmds.is_empty(), "wrong player roll should be ignored");
}
#[test]
@ -331,3 +538,20 @@ 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,92 @@
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

@ -14,6 +14,10 @@ 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 ────────────────────────
@ -27,6 +31,18 @@ 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.
@ -43,7 +59,9 @@ pub struct ViewState {
pub dice_jans: Vec<JanEntry>,
/// Last two checker moves played; default when no move has occurred yet.
pub dice_moves: (CheckerMove, CheckerMove),
pub message: String,
/// Present while the pre-game ceremony is in progress.
#[serde(default)]
pub pre_game_roll: Option<PreGameRollState>,
}
/// One scoring event from a dice roll.
@ -87,7 +105,7 @@ impl ViewState {
dice: (0, 0),
dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
message: "".into(),
pre_game_roll: None,
}
}
@ -186,7 +204,7 @@ impl ViewState {
dice: (gs.dice.values.0, gs.dice.values.1),
dice_jans,
dice_moves: gs.dice_moves,
message: gs.get_debug_message(),
pre_game_roll: None,
}
}
}
@ -223,6 +241,8 @@ 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,
}

18
clients/web/src/main.rs Normal file
View file

@ -0,0 +1,18 @@
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

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

View file

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

View file

@ -0,0 +1,66 @@
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

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

View file

@ -0,0 +1,356 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use leptos_router::components::A;
use leptos_router::hooks::use_query_map;
use crate::app::{AnonNickname, NetCommand, Screen};
use crate::i18n::*;
// ── Room/nickname generation ──────────────────────────────────────────────────
fn generate_room_code() -> String {
use rand::Rng;
let mut rng = rand::rng();
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
(0..6)
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
.collect()
}
fn generate_nickname() -> String {
use rand::Rng;
let mut rng = rand::rng();
const ADJ: &[&str] = &[
"swift", "brave", "noble", "fierce", "clever", "bold", "cunning", "agile", "sharp",
"golden", "iron", "silver", "quick", "daring", "wild",
];
const NOUN: &[&str] = &[
"fox", "hawk", "wolf", "lion", "bear", "rook", "knight", "duke", "earl", "lance", "blade",
"crown", "dame", "ace", "star",
];
let adj = ADJ[rng.random_range(0..ADJ.len())];
let noun = NOUN[rng.random_range(0..NOUN.len())];
let num: u8 = rng.random_range(10..=99);
format!("{adj}-{noun}-{num}")
}
// ── QR code SVG rendering ─────────────────────────────────────────────────────
pub(crate) fn qr_svg(text: &str) -> String {
use qrcodegen::{QrCode, QrCodeEcc};
let qr = match QrCode::encode_text(text, QrCodeEcc::Medium) {
Ok(q) => q,
Err(_) => return String::new(),
};
let size = qr.size();
let border = 2;
let total = size + 2 * border;
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {t} {t}\" shape-rendering=\"crispEdges\">",
t = total,
);
svg.push_str("<rect width=\"100%\" height=\"100%\" fill=\"#f2e8d0\"/>");
for y in 0..size {
for x in 0..size {
if qr.get_module(x, y) {
svg.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"1\" height=\"1\" fill=\"#2a1508\"/>",
x + border,
y + border,
));
}
}
}
svg.push_str("</svg>");
svg
}
// ── Share URL helper ──────────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
pub(crate) fn room_url(code: &str) -> String {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
format!("{}/?room={}", origin, code)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn room_url(code: &str) -> String {
format!("http://localhost:9091/?room={}", code)
}
// ── Lobby state ───────────────────────────────────────────────────────────────
/// Action to execute once the anonymous player has chosen their nickname.
#[derive(Clone)]
enum PendingLobbyAction {
Create { code: String },
Join { code: String },
}
#[derive(Clone)]
enum LobbyView {
Idle,
Waiting { code: String },
}
// ── LobbyPage ─────────────────────────────────────────────────────────────────
#[component]
pub fn LobbyPage() -> impl IntoView {
let screen = use_context::<RwSignal<Screen>>().expect("Screen context");
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
let anon_nickname = use_context::<AnonNickname>().expect("anon_nickname context").0;
let query = use_query_map();
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
// Non-None while the nickname-chooser modal is open.
let pending_action: RwSignal<Option<PendingLobbyAction>> = RwSignal::new(None);
// ── Auto-join when URL has ?room=CODE ──────────────────────────────────
// Wait for auth to resolve so we join directly when already logged in,
// or show the nickname modal when anonymous.
let join_processed = StoredValue::new(false);
let cmd_tx_q = cmd_tx.clone();
Effect::new(move |_| {
if join_processed.get_value() || !auth_loaded.get() {
return;
}
let Some(code) = query.read().get("room").filter(|s| !s.is_empty()) else {
return;
};
join_processed.set_value(true);
if auth_username.get_untracked().is_some() {
cmd_tx_q
.unbounded_send(NetCommand::JoinRoom { room: code })
.ok();
} else {
pending_action.set(Some(PendingLobbyAction::Join { code }));
}
});
let error = move || match screen.get() {
Screen::Login { error } => error,
_ => None,
};
let cmd_idle = cmd_tx.clone();
let cmd_modal = cmd_tx;
view! {
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
<div class="login-card">
<div class="login-card-header">
<div class="login-board-stripe"></div>
</div>
<div class="login-card-body">
<h1 class="login-title">"Trictrac"</h1>
<p class="login-subtitle">
<em>"Une interprétation numérique"</em>
</p>
<div class="login-ornament">""</div>
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
{move || match view_state.get() {
LobbyView::Idle => view! {
<IdleCard
cmd_tx=cmd_idle.clone()
auth_username=auth_username
view_state=view_state
pending_action=pending_action
/>
}.into_any(),
LobbyView::Waiting { code } => view! {
<WaitingCard code=code />
}.into_any(),
}}
</div>
</div>
// Fixed-position modal overlay; rendered here but escapes layout.
{move || pending_action.get().map(|action| view! {
<NicknameModal
pending=action
cmd_tx=cmd_modal.clone()
view_state=view_state
pending_action=pending_action
anon_nickname=anon_nickname
/>
})}
</div>
}
}
// ── IdleCard: Create + vs Bot + hidden join-by-code ──────────────────────────
#[component]
fn IdleCard(
cmd_tx: UnboundedSender<NetCommand>,
auth_username: RwSignal<Option<String>>,
view_state: RwSignal<LobbyView>,
pending_action: RwSignal<Option<PendingLobbyAction>>,
) -> impl IntoView {
let i18n = use_i18n();
let cmd_bot = cmd_tx.clone();
let cmd_create = cmd_tx.clone();
let on_create = move |_: leptos::ev::MouseEvent| {
let code = generate_room_code();
if auth_username.get_untracked().is_some() {
cmd_create
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
.ok();
view_state.set(LobbyView::Waiting { code });
} else {
pending_action.set(Some(PendingLobbyAction::Create { code }));
}
};
view! {
<div class="login-actions">
<button
class="login-btn login-btn-secondary"
on:click=move |_| { cmd_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M352 64C352 46.3 337.7 32 320 32C302.3 32 288 46.3 288 64L288 128L192 128C139 128 96 171 96 224L96 448C96 501 139 544 192 544L448 544C501 544 544 501 544 448L544 224C544 171 501 128 448 128L352 128L352 64zM160 432C160 418.7 170.7 408 184 408L216 408C229.3 408 240 418.7 240 432C240 445.3 229.3 456 216 456L184 456C170.7 456 160 445.3 160 432zM280 432C280 418.7 290.7 408 304 408L336 408C349.3 408 360 418.7 360 432C360 445.3 349.3 456 336 456L304 456C290.7 456 280 445.3 280 432zM400 432C400 418.7 410.7 408 424 408L456 408C469.3 408 480 418.7 480 432C480 445.3 469.3 456 456 456L424 456C410.7 456 400 445.3 400 432zM224 240C250.5 240 272 261.5 272 288C272 314.5 250.5 336 224 336C197.5 336 176 314.5 176 288C176 261.5 197.5 240 224 240zM368 288C368 261.5 389.5 240 416 240C442.5 240 464 261.5 464 288C464 314.5 442.5 336 416 336C389.5 336 368 314.5 368 288zM64 288C64 270.3 49.7 256 32 256C14.3 256 0 270.3 0 288L0 384C0 401.7 14.3 416 32 416C49.7 416 64 401.7 64 384L64 288zM608 256C590.3 256 576 270.3 576 288L576 384C576 401.7 590.3 416 608 416C625.7 416 640 401.7 640 384L640 288C640 270.3 625.7 256 608 256z"/>
</svg>
{t!(i18n, play_vs_bot)}
</button>
<button class="login-btn login-btn-primary" on:click=on_create>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path fill="currentColor" d="M598.1 139.4C608.8 131.6 611.2 116.6 603.4 105.9C595.6 95.2 580.6 92.8 569.9 100.6L495.4 154.8L485.5 148.2C465.8 135 442.6 128 418.9 128L359.7 128L359.3 128L215.7 128C189 128 163.2 136.9 142.3 153.1L70.1 100.6C59.4 92.8 44.4 95.2 36.6 105.9C28.8 116.6 31.2 131.6 41.9 139.4L129.9 203.4C139.5 210.3 152.6 209.3 161 201L164.9 197.1C178.4 183.6 196.7 176 215.8 176L262.1 176L170.4 267.7C154.8 283.3 154.8 308.6 170.4 324.3L171.2 325.1C218 372 294 372 340.9 325.1L368 298L465.8 395.8C481.4 411.4 481.4 436.7 465.8 452.4L456 462.2L425 431.2C415.6 421.8 400.4 421.8 391.1 431.2C381.8 440.6 381.7 455.8 391.1 465.1L419.1 493.1C401.6 503.5 381.9 509.8 361.5 511.6L313 463C303.6 453.6 288.4 453.6 279.1 463C269.8 472.4 269.7 487.6 279.1 496.9L294.1 511.9L290.3 511.9C254.2 511.9 219.6 497.6 194.1 472.1L65 343C55.6 333.6 40.4 333.6 31.1 343C21.8 352.4 21.7 367.6 31.1 376.9L160.2 506.1C194.7 540.6 241.5 560 290.3 560L342.1 560L343.1 561L344.1 560L349.8 560C398.6 560 445.4 540.6 479.9 506.1L499.8 486.2C501 485 502.1 483.9 503.2 482.7C503.9 482.2 504.5 481.6 505.1 481L609 377C618.4 367.6 618.4 352.4 609 343.1C599.6 333.8 584.4 333.7 575.1 343.1L521.3 396.9C517.1 384.1 510 372 499.8 361.8L385 247C375.6 237.6 360.4 237.6 351.1 247L307 291.1C280.5 317.6 238.5 319.1 210.3 295.7L309 197C322.4 183.6 340.6 176 359.6 175.9L368.1 175.9L368.3 175.9L419.1 175.9C433.3 175.9 447.2 180.1 459 188L482.7 204C491.1 209.6 502 209.3 510.1 203.4L598.1 139.4z"/>
</svg>
{t!(i18n, create_room)}
</button>
</div>
}
}
// ── NicknameModal ─────────────────────────────────────────────────────────────
#[component]
fn NicknameModal(
pending: PendingLobbyAction,
cmd_tx: UnboundedSender<NetCommand>,
view_state: RwSignal<LobbyView>,
pending_action: RwSignal<Option<PendingLobbyAction>>,
anon_nickname: RwSignal<Option<String>>,
) -> impl IntoView {
let i18n = use_i18n();
// Pre-fill with a random nickname; the player can edit it.
let nick = RwSignal::new(generate_nickname());
let on_play = move |_: leptos::ev::MouseEvent| {
let chosen = nick.get().trim().to_string();
let chosen = if chosen.is_empty() {
generate_nickname()
} else {
chosen
};
anon_nickname.set(Some(chosen));
match &pending {
PendingLobbyAction::Create { code } => {
cmd_tx
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
.ok();
view_state.set(LobbyView::Waiting { code: code.clone() });
}
PendingLobbyAction::Join { code } => {
cmd_tx
.unbounded_send(NetCommand::JoinRoom { room: code.clone() })
.ok();
}
}
pending_action.set(None);
};
view! {
<div class="nickname-backdrop">
<div class="nickname-modal">
<h2 class="nickname-modal-title">{t!(i18n, nickname_modal_title)}</h2>
<p class="nickname-modal-hint">{t!(i18n, nickname_modal_hint)}</p>
<input
class="login-input"
type="text"
style="margin:0"
prop:value=move || nick.get()
on:input=move |ev| nick.set(event_target_value(&ev))
/>
<button
class="login-btn login-btn-primary"
disabled=move || nick.get().trim().is_empty()
on:click=on_play
>
{t!(i18n, nickname_modal_play)}
</button>
<p class="nickname-modal-alt">
{t!(i18n, nickname_modal_or)}
" "
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
</p>
</div>
</div>
}
}
// ── WaitingCard: URL + copy + QR ─────────────────────────────────────────────
#[component]
fn WaitingCard(code: String) -> impl IntoView {
let i18n = use_i18n();
let url = room_url(&code);
let svg = qr_svg(&url);
let copied = RwSignal::new(false);
let on_copy = {
let url = url.clone();
move |_| {
#[cfg(target_arch = "wasm32")]
{
let url = url.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) {
let _ =
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&url)).await;
copied.set(true);
gloo_timers::future::TimeoutFuture::new(2000).await;
copied.set(false);
}
});
}
}
};
view! {
<p style="font-size:0.85rem;color:rgba(242,232,208,0.75);margin-bottom:1rem;text-align:center">
{t!(i18n, waiting_for_opponent)}
</p>
<p style="font-size:0.8rem;color:rgba(242,232,208,0.6);margin-bottom:0.5rem;text-align:center">
{t!(i18n, share_link)}
</p>
<div class="share-url-row">
<span class="share-url-text">{ url.clone() }</span>
<button class="share-copy-btn" on:click=on_copy>
{move || if copied.get() {
t_string!(i18n, link_copied)
} else {
t_string!(i18n, copy_link)
}}
</button>
</div>
<p style="font-size:0.75rem;color:rgba(242,232,208,0.45);margin:1rem 0 0.5rem;text-align:center">
{t!(i18n, scan_qr)}
</p>
<div class="qr-container" inner_html=svg />
}
}

View file

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

View file

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

View file

@ -0,0 +1,87 @@
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

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

93
container/flake.lock generated Normal file
View file

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

48
container/flake.nix Normal file
View file

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

View file

@ -8,7 +8,10 @@ in
# for Leptos
pkgs.trunk
pkgs.lld
# pkgs.wasm-bindgen-cli_0_2_114
# for backbone-lib
pkgs.wasm-bindgen-cli_0_2_114
pkgs.binaryen # for wasm-opt
# pour burn-rs
pkgs.SDL2_gfx
@ -24,6 +27,19 @@ in
];
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;

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/`.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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,212 +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), **return jan** (1318), **last 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.
- Three ways to take the corner:
- **By effect** (_par effet_): normal die values land exactly on it.
- **By puissance** (_par puissance_): the opponent's corner is empty; the player could take both corners simultaneously, but by privilege takes their own instead (as if stepping back one field).
- **By chance** (_par effet_): general case when it results from the dice.
- 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 last 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 Normal file
View file

@ -0,0 +1,62 @@
{
"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
}

203
flake.nix
View file

@ -1,41 +1,174 @@
{
description = "Trictrac";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
# let pkgs = nixpkgs.legacyPackages.${system}; in
let pkgs = import nixpkgs {
inherit system;
config = { allowUnfree = true; };
}; in
{
# devShell = import ./shell.nix { inherit pkgs; };
devShell = with pkgs; mkShell rec {
nativeBuildInputs = [
pkg-config
llvmPackages.bintools # To use lld linker
];
buildInputs = [
cargo rustc rustfmt rustPackages.clippy # rust
# pre-commit
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;
shellHook = ''
export HOST=127.0.0.1
export PORT=7000
'';
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
rust-overlay.url = "github:oxalica/rust-overlay";
};
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 {
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
{
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;
};
frontendCargoDeps = rustPlatform.fetchCargoVendor {
src = ./.;
name = "trictrac-frontend-vendor";
hash = "sha256-XBxdRT/f69GDfVc18/DnnAiY1vjMGMWfcYot0K0jevg=";
};
# Must match the wasm-bindgen version in Cargo.lock
wasm-bindgen-version = "0.2.118";
# 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=";
# };
# };
in
final.stdenv.mkDerivation {
name = "trictrac-front";
src = ./.;
nativeBuildInputs = with final; [
rustToolchain
lld
rustPlatform.cargoSetupHook
wasm-bindgen-cli_0_2_118
trunk
binaryen
];
cargoDeps = frontendCargoDeps;
buildPhase = ''
runHook preBuild
export HOME=$TMPDIR
# 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
'';
};
trictrac = with final; rustPlatform.buildRustPackage {
pname = "trictrac";
version = "0.2.17"; # trictrac-version
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;
};
}

102
justfile
View file

@ -2,6 +2,48 @@
# ^ A shebang isn't required, but allows a justfile to be executed
# like a script, with `./justfile test`, for example.
# Bump the project version and start a git-flow release.
# Usage: just bump 0.2.12
# After running, finish with: git flow release finish <version>
bump version:
git flow release start {{version}}
sed -i '/^\[workspace\.package\]/,/^\[/{s/^version = ".*"/version = "{{version}}"/}' Cargo.toml
sed -i 's/version = "[0-9.]*"; # trictrac-version/version = "{{version}}"; # trictrac-version/' flake.nix
just frontend-flake-hash
git add Cargo.toml flake.nix
git commit -m "chore: bump version to {{version}}"
@echo "Done. Finish with: `git flow release finish {{version}}`"
# Get new trictrac front-end nix package hash and update flake.nix with it
frontend-flake-hash:
#!/usr/bin/env bash
set -euo pipefail
FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
# Match only non-commented hash lines (i.e. lines where 'hash' is the first non-space token)
CURRENT_HASH=$(grep -P '^\s+hash = "' flake.nix | grep -oP 'sha256-[^"]+' | head -1)
echo "Current hash: $CURRENT_HASH"
sed -i "s|$CURRENT_HASH|$FAKE_HASH|" flake.nix
set +e
OUTPUT=$(nix build .#trictrac-front --no-link 2>&1)
set -e
NEW_HASH=$(echo "$OUTPUT" | grep -oP 'got:\s+\Ksha256-\S+' || true)
if [ -n "$NEW_HASH" ]; then
sed -i "s|$FAKE_HASH|$NEW_HASH|" flake.nix
if [ "$NEW_HASH" = "$CURRENT_HASH" ]; then
echo "Hash already up to date: $CURRENT_HASH"
else
echo "Updated: $CURRENT_HASH$NEW_HASH"
fi
else
sed -i "s|$FAKE_HASH|$CURRENT_HASH|" flake.nix
printf "Unexpected build output (no hash mismatch found):\n%s\n" "$OUTPUT" >&2
exit 1
fi
# Sync pages content to production server
pages-deploy:
rsync -av clients/web/pages/ raspberry:/var/lib/trictrac/pages/
doc:
cargo doc --no-deps
shell:
@ -9,17 +51,60 @@ shell:
runcli:
RUST_LOG=info cargo run --bin=client_cli
[working-directory: 'client_web/']
dev-leptos:
# example: fix-wasm-version 0.2.118
fix-wasm-version version:
cargo update \
-p wasm-bindgen --precise {{version}} \
-p wasm-bindgen-futures \
-p wasm-bindgen-test \
-p js-sys \
-p web-sys
[working-directory: 'clients/web']
dev:
trunk serve
[working-directory: 'client_web']
build-leptos:
test-web:
wasm-pack test --node clients/web
[working-directory: 'clients/web']
build:
trunk build --release
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/
cp dist/index.html ../../deploy/index.html
cp dist/*.wasm ../../deploy/
cp dist/*.js ../../deploy/
cp dist/*.css ../../deploy/
[working-directory: 'deploy']
run-relay:
PAGES_DIR=../clients/web/pages ./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/
# generate web stats report from the current nginx logs
stats:
ssh -t raspberry sudo goaccess /var/log/nginx/trictrac_access.log --log-format=COMBINED -o html > var/stats/report.html
# 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
runclibots:
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
@ -45,3 +130,4 @@ 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

235
module.nix Normal file
View file

@ -0,0 +1,235 @@
{ 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.";
};
pages_dir = mkOption {
type = types.str;
default = "/var/lib/trictrac/pages";
description = "Directory containing content pages.";
};
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.nullOr types.port;
default = null;
description = "SMTP server port. Defaults to 465 when tls = true, 1025 otherwise.";
};
tls = mkOption {
type = types.bool;
default = false;
description = "Use TLS (port 465). Required for Resend and other cloud SMTP providers.";
};
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). Use \"resend\" for Resend.";
};
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";
listenPort = if withSSL then 443 else 80;
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 = [
{ addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
{ addr = "[::]"; port = listenPort; ssl = withSSL; }
];
locations."/" = {
extraConfig = proxyConfig;
proxyPass = "http://trictrac-api/";
};
extraConfig = ''
error_log /var/log/nginx/trictrac_error.log;
access_log /var/log/nginx/trictrac_access.log;
'';
};
};
};
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
'';
startScript = pkgs.writeShellScript "trictrac-start" (
optionalString (cfg.smtp.passwordFile != null) ''
export SMTP_PASSWORD="$(< "$CREDENTIALS_DIRECTORY/smtp-pass")"
'' + ''
exec ${pkgs.trictrac}/bin/relay-server
''
);
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}";
PAGES_DIR = cfg.pages_dir;
SMTP_HOST = cfg.smtp.host;
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
else if cfg.smtp.tls then 465 else 1025);
SMTP_FROM = cfg.smtp.from;
} // optionalAttrs cfg.smtp.tls {
SMTP_TLS = "true";
} // 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";
LoadCredential = mkIf (cfg.smtp.passwordFile != null) "smtp-pass:${cfg.smtp.passwordFile}";
ExecStartPre = "${setupScript}";
ExecStart = "${startScript}";
Restart = "on-failure";
RestartSec = "5s";
};
};
};
meta = {
maintainers = with lib.maintainers; [ mmai ];
};
}

View file

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

View file

@ -0,0 +1,72 @@
//! 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

@ -0,0 +1,28 @@
[package]
name = "relay-server"
version.workspace = true
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", "tokio1-rustls-tls"] }

View file

@ -0,0 +1,6 @@
[
{
"name": "trictrac",
"max_players": 10
}
]

View file

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS game_records (
id BIGSERIAL PRIMARY KEY,
game_id TEXT NOT NULL,
room_code TEXT NOT NULL,
started_at BIGINT NOT NULL,
ended_at BIGINT,
result TEXT
);
CREATE TABLE IF NOT EXISTS game_participants (
id BIGSERIAL PRIMARY KEY,
game_record_id BIGINT NOT NULL REFERENCES game_records(id),
user_id BIGINT REFERENCES users(id),
player_id BIGINT NOT NULL,
outcome TEXT
);

View file

@ -0,0 +1,3 @@
-- Prevent duplicate participant rows if POST /games/result is called more than once.
CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_unique
ON game_participants(game_record_id, player_id);

View file

@ -0,0 +1,12 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS email_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
expires_at BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);

View file

@ -0,0 +1,96 @@
//! Authentication backend for axum-login.
//!
//! Implements [`AuthUser`] on [`db::User`] and provides [`AuthBackend`] which
//! validates credentials against the database using Argon2 password hashing.
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::password_hash::rand_core::OsRng;
use argon2::Argon2;
use axum_login::{AuthUser, AuthnBackend, UserId};
use deadpool_postgres::Pool;
use crate::db;
// ── AuthUser ─────────────────────────────────────────────────────────────────
impl AuthUser for db::User {
type Id = i64;
fn id(&self) -> Self::Id {
self.id
}
/// Changing the password invalidates all existing sessions for this user.
fn session_auth_hash(&self) -> &[u8] {
self.password_hash.as_bytes()
}
}
// ── Credentials ──────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct Credentials {
/// Accepts either a username or an email address.
pub login: String,
pub password: String,
}
// ── Error ────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("database error: {0}")]
Database(#[from] db::DbError),
#[error("password hashing error")]
PasswordHash,
}
// ── Backend ───────────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct AuthBackend {
pool: Pool,
}
impl AuthBackend {
pub fn new(pool: Pool) -> Self {
Self { pool }
}
}
impl AuthnBackend for AuthBackend {
type User = db::User;
type Credentials = Credentials;
type Error = AuthError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let Some(user) = db::get_user_by_username_or_email(&self.pool, &creds.login).await? else {
return Ok(None);
};
let parsed = PasswordHash::new(&user.password_hash).map_err(|_| AuthError::PasswordHash)?;
let valid = Argon2::default()
.verify_password(creds.password.as_bytes(), &parsed)
.is_ok();
Ok(valid.then_some(user))
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
Ok(db::get_user_by_id(&self.pool, *user_id).await?)
}
}
// ── Password hashing helper ───────────────────────────────────────────────────
/// Hashes a plaintext password with Argon2id. Used by the registration endpoint.
pub fn hash_password(password: &str) -> Result<String, AuthError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|_| AuthError::PasswordHash)
}

View file

@ -0,0 +1,378 @@
//! Database access layer.
//!
//! All PostgreSQL interaction is funnelled through this module. Functions return
//! `Result<_, DbError>` so callers can handle errors uniformly.
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
use tokio_postgres::{NoTls, error::SqlState};
use std::time::{SystemTime, UNIX_EPOCH};
/// A registered user as stored in the database.
#[derive(Clone, Debug)]
pub struct User {
pub id: i64,
pub username: String,
pub email: String,
pub password_hash: String,
pub created_at: i64,
pub email_verified: bool,
}
/// Aggregated game statistics for a user's public profile.
pub struct UserStats {
pub total: i64,
pub wins: i64,
pub losses: i64,
pub draws: i64,
}
/// A condensed game entry returned by [`get_user_games`].
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(Debug, thiserror::Error)]
pub enum DbError {
#[error("connection pool error: {0}")]
Pool(#[from] deadpool_postgres::PoolError),
#[error("database error: {0}")]
Db(#[from] tokio_postgres::Error),
}
impl DbError {
pub fn is_unique_violation(&self) -> bool {
if let DbError::Db(e) = self {
e.code() == Some(&SqlState::UNIQUE_VIOLATION)
} else {
false
}
}
}
pub fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
/// Connects to the PostgreSQL database at `url` and runs all pending migrations.
pub async fn init_db(url: &str) -> Pool {
let pg_config: tokio_postgres::Config = url.parse().expect("Invalid DATABASE_URL");
let manager = Manager::from_config(
pg_config,
NoTls,
ManagerConfig { recycling_method: RecyclingMethod::Fast },
);
let pool = Pool::builder(manager)
.max_size(5)
.build()
.expect("Failed to build connection pool");
let client = pool.get().await.expect("Failed to get connection for migrations");
client
.batch_execute(include_str!("../migrations/001_init.sql"))
.await
.expect("Migration 001 failed");
client
.batch_execute(include_str!("../migrations/002_participants_unique.sql"))
.await
.expect("Migration 002 failed");
client
.batch_execute(include_str!("../migrations/003_email_verification.sql"))
.await
.expect("Migration 003 failed");
pool
}
// ── Users ────────────────────────────────────────────────────────────────────
fn user_from_row(r: &tokio_postgres::Row) -> User {
User {
id: r.get("id"),
username: r.get("username"),
email: r.get("email"),
password_hash: r.get("password_hash"),
created_at: r.get("created_at"),
email_verified: r.get("email_verified"),
}
}
pub async fn create_user(
pool: &Pool,
username: &str,
email: &str,
password_hash: &str,
) -> Result<i64, DbError> {
let client = pool.get().await?;
let row = client
.query_one(
"INSERT INTO users (username, email, password_hash, created_at, email_verified) \
VALUES ($1, $2, $3, $4, FALSE) RETURNING id",
&[&username, &email, &password_hash, &now_unix()],
)
.await?;
Ok(row.get(0))
}
pub async fn get_user_by_id(pool: &Pool, id: i64) -> Result<Option<User>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE id = $1",
&[&id],
)
.await?;
Ok(row.as_ref().map(user_from_row))
}
pub async fn get_user_by_username(pool: &Pool, username: &str) -> Result<Option<User>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE username = $1",
&[&username],
)
.await?;
Ok(row.as_ref().map(user_from_row))
}
pub async fn get_user_by_email(pool: &Pool, email: &str) -> Result<Option<User>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE email = $1",
&[&email],
)
.await?;
Ok(row.as_ref().map(user_from_row))
}
/// Looks up a user by username first; if not found, tries by email.
pub async fn get_user_by_username_or_email(pool: &Pool, login: &str) -> Result<Option<User>, DbError> {
if let Some(u) = get_user_by_username(pool, login).await? {
return Ok(Some(u));
}
get_user_by_email(pool, login).await
}
pub async fn set_email_verified(pool: &Pool, user_id: i64) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"UPDATE users SET email_verified = TRUE WHERE id = $1",
&[&user_id],
)
.await?;
Ok(())
}
pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"UPDATE users SET password_hash = $1 WHERE id = $2",
&[&hash, &user_id],
)
.await?;
Ok(())
}
/// Permanently deletes a user and their auth data.
/// Game history rows are kept but de-associated (user_id set to NULL).
pub async fn delete_user(pool: &Pool, user_id: i64) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"UPDATE game_participants SET user_id = NULL WHERE user_id = $1",
&[&user_id],
)
.await?;
client
.execute("DELETE FROM email_tokens WHERE user_id = $1", &[&user_id])
.await?;
client
.execute("DELETE FROM users WHERE id = $1", &[&user_id])
.await?;
Ok(())
}
// ── Email tokens ──────────────────────────────────────────────────────────────
pub async fn create_email_token(
pool: &Pool,
user_id: i64,
token: &str,
kind: &str,
expires_at: i64,
) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"INSERT INTO email_tokens (user_id, token, kind, expires_at, created_at) \
VALUES ($1, $2, $3, $4, $5)",
&[&user_id, &token, &kind, &expires_at, &now_unix()],
)
.await?;
Ok(())
}
/// Removes all tokens of the given kind for a user (call before creating a fresh one).
pub async fn delete_email_tokens(pool: &Pool, user_id: i64, kind: &str) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"DELETE FROM email_tokens WHERE user_id = $1 AND kind = $2",
&[&user_id, &kind],
)
.await?;
Ok(())
}
/// Atomically deletes the token row and returns the `user_id` if the token
/// exists and has not expired. Returns `None` for missing or expired tokens.
pub async fn consume_email_token(
pool: &Pool,
token: &str,
kind: &str,
) -> Result<Option<i64>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"DELETE FROM email_tokens WHERE token = $1 AND kind = $2 \
RETURNING user_id, expires_at",
&[&token, &kind],
)
.await?;
Ok(row.and_then(|r| {
let expires_at: i64 = r.get("expires_at");
if expires_at >= now_unix() {
Some(r.get("user_id"))
} else {
None
}
}))
}
// ── Game records ─────────────────────────────────────────────────────────────
/// Creates a new game record when a room opens. Returns the record id.
pub async fn insert_game_record(
pool: &Pool,
game_id: &str,
room_code: &str,
) -> Result<i64, DbError> {
let client = pool.get().await?;
let row = client
.query_one(
"INSERT INTO game_records (game_id, room_code, started_at) \
VALUES ($1, $2, $3) RETURNING id",
&[&game_id, &room_code, &now_unix()],
)
.await?;
Ok(row.get(0))
}
/// Stamps `ended_at` and stores the opaque result JSON supplied by the game.
pub async fn close_game_record(
pool: &Pool,
record_id: i64,
result_json: Option<&str>,
) -> Result<(), DbError> {
// AND ended_at IS NULL prevents overwriting a result already set by POST /games/result
let client = pool.get().await?;
client
.execute(
"UPDATE game_records SET ended_at = $1, result = $2 \
WHERE id = $3 AND ended_at IS NULL",
&[&now_unix(), &result_json, &record_id],
)
.await?;
Ok(())
}
/// Records a player's participation in a game. `user_id` is `None` for anonymous players.
pub async fn insert_participant(
pool: &Pool,
record_id: i64,
user_id: Option<i64>,
player_id: u16,
outcome: Option<&str>,
) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"INSERT INTO game_participants (game_record_id, user_id, player_id, outcome) \
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
&[&record_id, &user_id, &(player_id as i64), &outcome],
)
.await?;
Ok(())
}
/// Returns win/loss/draw counts for a user. All values are 0 when the user has no games.
pub async fn get_user_stats(pool: &Pool, user_id: i64) -> Result<UserStats, DbError> {
let client = pool.get().await?;
let row = client
.query_one(
"SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN outcome = 'win' THEN 1 ELSE 0 END), 0::BIGINT) as wins,
COALESCE(SUM(CASE WHEN outcome = 'loss' THEN 1 ELSE 0 END), 0::BIGINT) as losses,
COALESCE(SUM(CASE WHEN outcome = 'draw' THEN 1 ELSE 0 END), 0::BIGINT) as draws
FROM game_participants
WHERE user_id = $1",
&[&user_id],
)
.await?;
Ok(UserStats {
total: row.get("total"),
wins: row.get("wins"),
losses: row.get("losses"),
draws: row.get("draws"),
})
}
/// Returns a paginated list of games a user participated in, newest first.
pub async fn get_user_games(
pool: &Pool,
user_id: i64,
page: i64,
per_page: i64,
) -> Result<Vec<GameSummary>, DbError> {
let client = pool.get().await?;
let rows = client
.query(
"SELECT gr.id, gr.game_id, gr.room_code, gr.started_at, gr.ended_at, gr.result, gp.outcome
FROM game_records gr
JOIN game_participants gp ON gp.game_record_id = gr.id
WHERE gp.user_id = $1
ORDER BY gr.started_at DESC
LIMIT $2 OFFSET $3",
&[&user_id, &per_page, &(page * per_page)],
)
.await?;
Ok(rows
.into_iter()
.map(|r| GameSummary {
id: r.get("id"),
game_id: r.get("game_id"),
room_code: r.get("room_code"),
started_at: r.get("started_at"),
ended_at: r.get("ended_at"),
result: r.get("result"),
outcome: r.get("outcome"),
})
.collect())
}

View file

@ -0,0 +1,599 @@
//! This module does the whole initialization and handshake thing.
//! The general protocol of connecting is :
//! WASM Client -> Websocket: postcard serialized join request.
//! Websocket -> WASM Client: u16 player id, u16 rule variation, u64 reconnect token.
use crate::db;
use crate::hand_shake::ClientServerSpecificData::{Client, Server};
use crate::hand_shake::DisconnectEndpointSpecification::{DisconnectClient, DisconnectServer};
use crate::lobby::{AppState, Room};
use axum::extract::ws::Message::Binary;
use axum::extract::ws::{Message, WebSocket};
use bytes::{BufMut, Bytes, BytesMut};
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{sink::SinkExt, stream::StreamExt};
use postcard::from_bytes;
use protocol::{
CHANNEL_BUFFER_SIZE, CLIENT_DISCONNECT_MSG_SIZE, CLIENT_DISCONNECTS, HAND_SHAKE_RESPONSE,
HAND_SHAKE_RESPONSE_SIZE, JoinRequest, NEW_CLIENT, NEW_CLIENT_MSG_SIZE,
SERVER_DISCONNECT_MSG_SIZE, SERVER_DISCONNECTS, SERVER_ERROR,
};
use rand::random;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{broadcast, mpsc};
/// Is called on error, sends a text message because e-websocket can not interpret closing messages.
/// This text message is encoded as a binary message.
async fn send_closing_message(sender: &mut SplitSink<WebSocket, Message>, closing_message: String) {
let raw_data = closing_message.as_bytes();
let mut msg = BytesMut::with_capacity(1 + raw_data.len());
msg.put_u8(SERVER_ERROR);
msg.put_slice(raw_data);
let _ = sender.send(Message::Binary(msg.into())).await;
let _ = sender.send(Message::Close(None)).await;
}
/// The handshake result we get for the joining the room.
pub struct HandshakeResult {
/// The id of the player we play.
pub player_id: u16,
/// The complete identifier of the room as stored in the hashmap.
pub room_id: String,
/// The rule variation we apply.
pub rule_variation: u16,
/// The reconnect token for this player — sent back to the client for localStorage storage.
pub token: u64,
/// The internal connection information.
pub specific_data: ClientServerSpecificData,
}
/// Contains all the channel information for internal communication.
pub enum ClientServerSpecificData {
/// In this case we are servicing the server.
Server(Receiver<Bytes>, broadcast::Sender<Bytes>),
/// In this case we are servicing a client.
Client(broadcast::Receiver<Bytes>, Sender<Bytes>),
}
/// This data is data we need to keep for the disconnect handling and cleanup.
pub struct DisconnectData {
/// The id of the player we play.
pub player_id: u16,
/// The complete identifier of the room as stored in the hashmap.
pub room_id: String,
/// The sender we use.
pub sender: DisconnectEndpointSpecification,
}
/// Contains the information where to send error data to in case of disconnection.
pub enum DisconnectEndpointSpecification {
/// If we are servicing the server, we broadcast the info to all clients.
DisconnectServer(broadcast::Sender<Bytes>),
/// If we are servicing the client, we send data to the server.
DisconnectClient(Sender<Bytes>),
}
/// Construction of DisconnectData from Handshake result.
impl From<&HandshakeResult> for DisconnectData {
fn from(value: &HandshakeResult) -> Self {
match &value.specific_data {
Server(_, internal_sender) => DisconnectData {
player_id: value.player_id,
room_id: value.room_id.clone(),
sender: DisconnectServer(internal_sender.clone()),
},
Client(_, internal_sender) => DisconnectData {
player_id: value.player_id,
room_id: value.room_id.clone(),
sender: DisconnectClient(internal_sender.clone()),
},
}
}
}
/// Gets an initial connection result, where a room is constructed
/// and game and existence / non existence of room is checked for legality.
struct InitialConnectionResult {
/// Flags, if we are a server.
is_server: bool,
/// The complete room we have for internal administration.
compound_room_id: String,
/// Which game do we want to join.
game_id: String,
/// Which room do we want to join.
room_id: String,
/// The rule variation that is applied, this gets only interpreted if a room gets constructed.
rule_variation: u16,
/// The maximum amount of players a room allows (0 = infinite).
max_players: u16,
/// Reconnect token from the client, if this is a reconnect attempt.
reconnect_token: Option<u64>,
}
/// Reads in the join request from the web socket, verifies if game exists and generates the final room name.
async fn get_initial_query(
sender: &mut SplitSink<WebSocket, Message>,
receiver: &mut SplitStream<WebSocket>,
state: Arc<AppState>,
) -> Option<InitialConnectionResult> {
// First we get a room opening and joining request. This is the first binary message we received.
let my_data = loop {
let Some(raw_data) = receiver.next().await else {
tracing::warn!("WebSocket closed before handshake completed");
send_closing_message(sender, "Initial error during handshake.".into()).await;
return None;
};
match raw_data {
Err(err) => {
tracing::error!(?err, "Initial error during handshake.");
send_closing_message(sender, "Initial error during handshake.".into()).await;
return None;
}
Ok(Binary(data)) => {
break data;
}
// We do not care about any other message like ping pong messages.
Ok(_) => {}
}
};
// Now we get some data and we try to convert it into the required format.
let working_struct = match from_bytes::<JoinRequest>(&my_data) {
Ok(req) => req,
Err(e) => {
tracing::error!(error = ?e, "Failed to parse join request");
send_closing_message(sender, "Failed to parse join request.".into()).await;
return None;
}
};
// Let us take a look, if the game exists.
let games = state.configs.read().await;
let game_exists = games.contains_key(&working_struct.game_id);
let max_players = if game_exists {
games[&working_struct.game_id]
} else {
0
};
drop(games);
if !game_exists {
tracing::error!(
optional_game = working_struct.game_id,
"Requested illegal game."
);
send_closing_message(sender, format!("Unknown game {}.", &working_struct.game_id)).await;
return None;
}
// The final room id is the combination of game and room id.
let room_id = format!(
"{}#{}",
working_struct.room_id.as_str(),
working_struct.game_id.as_str()
);
let is_server = working_struct.create_room;
Some(InitialConnectionResult {
is_server,
compound_room_id: room_id,
game_id: working_struct.game_id,
room_id: working_struct.room_id,
rule_variation: working_struct.rule_variation,
max_players,
reconnect_token: working_struct.reconnect_token,
})
}
/// Connects and eventually establishes a room.
pub async fn init_and_connect(
sender: &mut SplitSink<WebSocket, Message>,
receiver: &mut SplitStream<WebSocket>,
state: Arc<AppState>,
user_id: Option<i64>,
) -> Option<HandshakeResult> {
let start_result = get_initial_query(sender, receiver, state.clone()).await?;
if let Some(token) = start_result.reconnect_token {
process_handshake_reconnect(sender, state, start_result, token, user_id).await
} else if start_result.is_server {
process_handshake_server(sender, state, start_result, user_id).await
} else {
process_handshake_client(sender, state, start_result, user_id).await
}
}
/// Does the handshake, if we are connected to a client.
async fn process_handshake_client(
sender: &mut SplitSink<WebSocket, Message>,
state: Arc<AppState>,
initial_result: InitialConnectionResult,
user_id: Option<i64>,
) -> Option<HandshakeResult> {
let mut rooms = state.rooms.lock().await;
let Some(local_room) = rooms.get_mut(&initial_result.compound_room_id) else {
drop(rooms);
send_closing_message(
sender,
format!(
"Room {} does not exist for game {}.",
&initial_result.room_id, &initial_result.game_id
),
)
.await;
return None;
};
// Do we fit in? max_players == 0 means "infinite".
if initial_result.max_players != 0 && local_room.amount_of_players >= initial_result.max_players
{
drop(rooms);
send_closing_message(
sender,
format!(
"Room {} exceeded max amount of players {}.",
&initial_result.room_id, initial_result.max_players
),
)
.await;
return None;
}
// Save guard against the case, that we have run out of client ids.
if local_room.next_client_id > u16::MAX - 100 {
drop(rooms);
send_closing_message(
sender,
format!("Room {} run out of client ids.", &initial_result.room_id),
)
.await;
tracing::error!("Server run out of client ids.");
return None;
}
local_room.amount_of_players += 1;
let player_id = local_room.next_client_id;
local_room.next_client_id += 1;
let token: u64 = random();
local_room.player_tokens.insert(player_id, token);
local_room.connected_players.push(player_id);
local_room.user_ids.insert(player_id, user_id);
let to_server_sender = local_room.to_host_sender.clone();
let receiver = local_room.host_to_client_broadcaster.subscribe();
let rule_variation = local_room.rule_variation;
drop(rooms);
// Here we send a message to the server, that a new client has joined.
let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE);
msg.put_u8(NEW_CLIENT); // Message-Type
msg.put_u16(player_id); // player id.
let result = to_server_sender.send(msg.into()).await;
if let Err(error) = result {
// We have to leave the room again.
let mut rooms = state.rooms.lock().await;
if let Some(room) = rooms.get_mut(&initial_result.compound_room_id) {
room.amount_of_players -= 1;
room.player_tokens.remove(&player_id);
}
drop(rooms);
tracing::error!(?error, "Server unexpectedly left during handshake");
send_closing_message(sender, "Server unexpectedly left during handshake".into()).await;
return None;
}
Some(HandshakeResult {
room_id: initial_result.compound_room_id,
player_id,
rule_variation,
token,
specific_data: Client(receiver, to_server_sender),
})
}
/// Opens a new room and generates the handshake result for the server.
async fn process_handshake_server(
sender: &mut SplitSink<WebSocket, Message>,
state: Arc<AppState>,
initial_result: InitialConnectionResult,
user_id: Option<i64>,
) -> Option<HandshakeResult> {
// Insert a game record before taking the rooms lock (best-effort: failures don't abort the handshake).
let game_record_id =
match db::insert_game_record(&state.db, &initial_result.game_id, &initial_result.room_id)
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!("Failed to create game record for room {}: {e}", initial_result.room_id);
None
}
};
let mut rooms = state.rooms.lock().await;
if rooms.contains_key(&initial_result.compound_room_id) {
drop(rooms);
send_closing_message(
sender,
format!(
"Room {} already exists for game {}.",
&initial_result.room_id, &initial_result.game_id
),
)
.await;
// User error no need for error tracing.
return None;
}
// Here we create a new room.
let (to_server_sender, to_server_receiver) = mpsc::channel(CHANNEL_BUFFER_SIZE);
let (to_client_sender, _) = broadcast::channel(CHANNEL_BUFFER_SIZE);
let token: u64 = random();
let mut player_tokens = HashMap::new();
player_tokens.insert(0u16, token);
let mut user_ids = HashMap::new();
user_ids.insert(0u16, user_id);
let new_room = Room {
next_client_id: 1,
amount_of_players: 1,
rule_variation: initial_result.rule_variation,
to_host_sender: to_server_sender,
host_to_client_broadcaster: to_client_sender.clone(),
player_tokens,
host_connected: true,
connected_players: Vec::new(),
game_record_id,
user_ids,
};
rooms.insert(initial_result.compound_room_id.clone(), new_room);
drop(rooms);
let hand_shake_result = HandshakeResult {
room_id: initial_result.compound_room_id,
player_id: 0,
rule_variation: initial_result.rule_variation,
token,
specific_data: Server(to_server_receiver, to_client_sender),
};
Some(hand_shake_result)
}
/// Reconnects a previously connected player (host or client) using their stored token.
///
/// **Client reconnect**: resubscribes to the broadcast channel and notifies the host
/// via `NEW_CLIENT` so it delivers a fresh `FULL_UPDATE`.
///
/// **Host reconnect**: creates a new mpsc channel (the old one died with the WebSocket),
/// replaces `room.to_host_sender`, and queues `NEW_CLIENT` / `CLIENT_DISCONNECTS`
/// messages so the host backend can reconstruct who is currently in the room.
async fn process_handshake_reconnect(
sender: &mut SplitSink<WebSocket, Message>,
state: Arc<AppState>,
initial_result: InitialConnectionResult,
reconnect_token: u64,
user_id: Option<i64>,
) -> Option<HandshakeResult> {
let mut rooms = state.rooms.lock().await;
let Some(local_room) = rooms.get_mut(&initial_result.compound_room_id) else {
drop(rooms);
send_closing_message(
sender,
format!(
"Room {} no longer exists for game {}.",
&initial_result.room_id, &initial_result.game_id
),
)
.await;
return None;
};
// Find the player whose token matches.
let player_id = match local_room
.player_tokens
.iter()
.find(|&(_, &t)| t == reconnect_token)
.map(|(&id, _)| id)
{
Some(id) => id,
None => {
drop(rooms);
tracing::warn!("Reconnect attempt with invalid token in room {}", &initial_result.room_id);
send_closing_message(sender, "Invalid reconnect token.".into()).await;
return None;
}
};
// ------------------------------------------------------------------ Host reconnect
if player_id == 0 {
if local_room.host_connected {
drop(rooms);
send_closing_message(sender, "Host is already connected.".into()).await;
return None;
}
// Create a fresh mpsc channel (the previous receiver was dropped when the
// host's WebSocket closed).
let (new_sender, new_receiver) = mpsc::channel(CHANNEL_BUFFER_SIZE);
local_room.to_host_sender = new_sender.clone();
local_room.host_connected = true;
local_room.user_ids.insert(0u16, user_id);
let broadcaster = local_room.host_to_client_broadcaster.clone();
let rule_variation = local_room.rule_variation;
// Collect the players we need to notify about.
let connected = local_room.connected_players.clone();
let all_non_host: Vec<u16> = local_room
.player_tokens
.keys()
.filter(|&&pid| pid != 0)
.copied()
.collect();
drop(rooms);
// Queue NEW_CLIENT for every currently connected player so the host backend
// increments remote_player_count and sends a FULL_UPDATE.
for pid in &connected {
let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE);
msg.put_u8(NEW_CLIENT);
msg.put_u16(*pid);
let _ = new_sender.send(msg.into()).await;
}
// Queue CLIENT_DISCONNECTS for players who left while the host was away so
// the backend can start their grace-period timers.
for pid in all_non_host {
if !connected.contains(&pid) {
let mut msg = BytesMut::with_capacity(CLIENT_DISCONNECT_MSG_SIZE);
msg.put_u8(CLIENT_DISCONNECTS);
msg.put_u16(pid);
let _ = new_sender.send(msg.into()).await;
}
}
tracing::info!(room = &initial_result.room_id, "Host reconnected");
return Some(HandshakeResult {
room_id: initial_result.compound_room_id,
player_id: 0,
rule_variation,
token: reconnect_token,
specific_data: Server(new_receiver, broadcaster),
});
}
// ---------------------------------------------------------------- Client reconnect
local_room.amount_of_players += 1;
local_room.connected_players.push(player_id);
local_room.user_ids.insert(player_id, user_id);
let to_server_sender = local_room.to_host_sender.clone();
let broadcast_receiver = local_room.host_to_client_broadcaster.subscribe();
let rule_variation = local_room.rule_variation;
drop(rooms);
// Notify the host that this player has rejoined so it sends a FULL_UPDATE.
let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE);
msg.put_u8(NEW_CLIENT);
msg.put_u16(player_id);
if let Err(error) = to_server_sender.send(msg.into()).await {
let mut rooms = state.rooms.lock().await;
if let Some(room) = rooms.get_mut(&initial_result.compound_room_id) {
room.amount_of_players -= 1;
room.connected_players.retain(|&p| p != player_id);
}
drop(rooms);
tracing::error!(?error, "Host unavailable during reconnect handshake");
send_closing_message(sender, "Host is no longer available.".into()).await;
return None;
}
tracing::info!(
player_id,
room = &initial_result.room_id,
"Player reconnected"
);
Some(HandshakeResult {
room_id: initial_result.compound_room_id,
player_id,
rule_variation,
token: reconnect_token,
specific_data: Client(broadcast_receiver, to_server_sender),
})
}
/// Informs the partner of the connection result, returns a bool as a success flag.
pub async fn inform_client_of_connection(
sender: &mut SplitSink<WebSocket, Message>,
status: &HandshakeResult,
) -> bool {
let mut msg = BytesMut::with_capacity(HAND_SHAKE_RESPONSE_SIZE);
msg.put_u8(HAND_SHAKE_RESPONSE);
msg.put_u16(status.player_id);
msg.put_u16(status.rule_variation);
msg.put_u64(status.token);
let result = sender.send(Message::Binary(msg.into())).await;
result.is_ok()
}
/// Performs the shutdown of the system and sends a last message.
pub async fn shutdown_connection(
wrapped_sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
disconnect_data: DisconnectData,
app_state: Arc<AppState>,
error_message: &'static str,
) {
match disconnect_data.sender {
DisconnectServer(broadcaster) => {
// Mark the host as disconnected and start a 30-second grace period.
// If the host reconnects within that window the grace task does nothing;
// otherwise it broadcasts SERVER_DISCONNECTS and removes the room.
{
let mut rooms = app_state.rooms.lock().await;
if let Some(room) = rooms.get_mut(&disconnect_data.room_id) {
room.host_connected = false;
}
}
let state_clone = app_state.clone();
let room_id = disconnect_data.room_id.clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
let game_record_id = {
let mut rooms = state_clone.rooms.lock().await;
if let Some(room) = rooms.get(&room_id) {
if !room.host_connected {
let record_id = room.game_record_id;
rooms.remove(&room_id);
record_id
} else {
return; // host reconnected
}
} else {
return; // room already removed
}
};
// Room lock released — broadcast and close the DB record.
let mut msg = BytesMut::with_capacity(SERVER_DISCONNECT_MSG_SIZE);
msg.put_u8(SERVER_DISCONNECTS);
let _ = broadcaster.send(msg.into());
tracing::info!(room_id, "Host grace period expired — room removed");
if let Some(record_id) = game_record_id {
if let Err(e) = db::close_game_record(&state_clone.db, record_id, None).await {
tracing::warn!("Failed to close game record {record_id}: {e}");
}
}
});
}
DisconnectClient(sender) => {
// Inform server first.
let mut msg = BytesMut::with_capacity(CLIENT_DISCONNECT_MSG_SIZE);
msg.put_u8(CLIENT_DISCONNECTS);
msg.put_u16(disconnect_data.player_id);
let _ = sender.send(msg.into()).await;
// Subtract one client from the room.
let mut rooms = app_state.rooms.lock().await;
// Check if the room still exists.
if let Some(room) = rooms.get_mut(&disconnect_data.room_id) {
room.amount_of_players -= 1;
room.connected_players.retain(|&p| p != disconnect_data.player_id);
// Note: we intentionally keep the token in player_tokens so the
// client can use it to reconnect as long as the room exists.
}
drop(rooms);
}
}
let mut sender = wrapped_sender.lock().await;
// Send the message to the WASM point.
send_closing_message(&mut sender, error_message.into()).await;
}

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