From 557f0249f8560df9bc3397e1531ea3d669458ff3 Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 25 Apr 2026 16:49:25 +0200
Subject: [PATCH] feat: merge web-user-portal & web-game
---
Cargo.lock | 23 +
Cargo.toml | 1 +
clients/web/Cargo.toml | 41 +
clients/web/Trunk.toml | 2 +
clients/web/assets/diceroll.mp3 | Bin 0 -> 32896 bytes
clients/web/assets/style.css | 1396 +++++++++++++++++
clients/web/index.html | 12 +
clients/web/locales/en.json | 96 ++
clients/web/locales/fr.json | 96 ++
clients/web/src/api.rs | 191 +++
clients/web/src/app.rs | 459 ++++++
clients/web/src/game/components/board.rs | 594 +++++++
.../src/game/components/connecting_screen.rs | 9 +
clients/web/src/game/components/die.rs | 53 +
.../web/src/game/components/game_screen.rs | 470 ++++++
clients/web/src/game/components/mod.rs | 9 +
.../web/src/game/components/score_panel.rs | 70 +
clients/web/src/game/components/scoring.rs | 209 +++
clients/web/src/game/mod.rs | 4 +
clients/web/src/game/session.rs | 253 +++
clients/web/src/game/sound.rs | 182 +++
clients/web/src/game/trictrac/backend.rs | 487 ++++++
clients/web/src/game/trictrac/bot_local.rs | 43 +
clients/web/src/game/trictrac/mod.rs | 3 +
clients/web/src/game/trictrac/types.rs | 256 +++
clients/web/src/main.rs | 14 +
clients/web/src/nav.rs | 51 +
clients/web/src/portal/account.rs | 166 ++
clients/web/src/portal/game_detail.rs | 109 ++
clients/web/src/portal/lobby.rs | 88 ++
clients/web/src/portal/mod.rs | 4 +
clients/web/src/portal/profile.rs | 153 ++
justfile | 21 +-
server/relay-server/src/main.rs | 7 +-
34 files changed, 5562 insertions(+), 10 deletions(-)
create mode 100644 clients/web/Cargo.toml
create mode 100644 clients/web/Trunk.toml
create mode 100644 clients/web/assets/diceroll.mp3
create mode 100644 clients/web/assets/style.css
create mode 100644 clients/web/index.html
create mode 100644 clients/web/locales/en.json
create mode 100644 clients/web/locales/fr.json
create mode 100644 clients/web/src/api.rs
create mode 100644 clients/web/src/app.rs
create mode 100644 clients/web/src/game/components/board.rs
create mode 100644 clients/web/src/game/components/connecting_screen.rs
create mode 100644 clients/web/src/game/components/die.rs
create mode 100644 clients/web/src/game/components/game_screen.rs
create mode 100644 clients/web/src/game/components/mod.rs
create mode 100644 clients/web/src/game/components/score_panel.rs
create mode 100644 clients/web/src/game/components/scoring.rs
create mode 100644 clients/web/src/game/mod.rs
create mode 100644 clients/web/src/game/session.rs
create mode 100644 clients/web/src/game/sound.rs
create mode 100644 clients/web/src/game/trictrac/backend.rs
create mode 100644 clients/web/src/game/trictrac/bot_local.rs
create mode 100644 clients/web/src/game/trictrac/mod.rs
create mode 100644 clients/web/src/game/trictrac/types.rs
create mode 100644 clients/web/src/main.rs
create mode 100644 clients/web/src/nav.rs
create mode 100644 clients/web/src/portal/account.rs
create mode 100644 clients/web/src/portal/game_detail.rs
create mode 100644 clients/web/src/portal/lobby.rs
create mode 100644 clients/web/src/portal/mod.rs
create mode 100644 clients/web/src/portal/profile.rs
diff --git a/Cargo.lock b/Cargo.lock
index fcb2626..8ce19af 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8700,6 +8700,29 @@ dependencies = [
"transpose",
]
+[[package]]
+name = "trictrac-web"
+version = "0.1.0"
+dependencies = [
+ "backbone-lib",
+ "futures",
+ "getrandom 0.3.4",
+ "gloo-net 0.5.0",
+ "gloo-storage",
+ "gloo-timers",
+ "js-sys",
+ "leptos",
+ "leptos_i18n",
+ "leptos_router",
+ "rand 0.9.3",
+ "serde",
+ "serde_json",
+ "trictrac-store",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
diff --git a/Cargo.toml b/Cargo.toml
index c0c930c..94a1c6b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"store",
"clients/cli",
"clients/backbone-lib",
+ "clients/web",
"clients/web-game",
"clients/web-user-portal",
"server/protocol",
diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml
new file mode 100644
index 0000000..04857a6
--- /dev/null
+++ b/clients/web/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "trictrac-web"
+version = "0.1.0"
+edition = "2021"
+
+[package.metadata.leptos-i18n]
+default = "en"
+locales = ["en", "fr"]
+
+[dependencies]
+leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
+leptos_router = { version = "0.7" }
+trictrac-store = { path = "../../store" }
+backbone-lib = { path = "../backbone-lib" }
+leptos = { version = "0.7", features = ["csr"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1"
+futures = "0.3"
+rand = "0.9"
+gloo-storage = "0.3"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+gloo-net = { version = "0.5", features = ["http"] }
+gloo-timers = { version = "0.3", features = ["futures"] }
+getrandom = { version = "0.3", features = ["wasm_js"] }
+js-sys = "0.3"
+web-sys = { version = "0.3", features = [
+ "RequestCredentials",
+ "AudioContext",
+ "AudioParam",
+ "AudioNode",
+ "AudioDestinationNode",
+ "AudioScheduledSourceNode",
+ "GainNode",
+ "OscillatorNode",
+ "OscillatorType",
+ "BaseAudioContext",
+ "HtmlAudioElement",
+] }
diff --git a/clients/web/Trunk.toml b/clients/web/Trunk.toml
new file mode 100644
index 0000000..bae5297
--- /dev/null
+++ b/clients/web/Trunk.toml
@@ -0,0 +1,2 @@
+[serve]
+port = 9091
diff --git a/clients/web/assets/diceroll.mp3 b/clients/web/assets/diceroll.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..b16adff42da3003b35d59566b353a5db2fa0c7b7
GIT binary patch
literal 32896
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
literal 0
HcmV?d00001
diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css
new file mode 100644
index 0000000..b2d89a4
--- /dev/null
+++ b/clients/web/assets/style.css
@@ -0,0 +1,1396 @@
+/* ── Google Fonts ───────────────────────────────────────────────── */
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap');
+
+/* ── Design tokens ──────────────────────────────────────────────────── */
+:root {
+ --board-felt: #1d3d28;
+ --board-rail: #2a1508;
+ --field-ivory: #f0e6c8;
+ --field-burgundy: #7a1e2a;
+ --field-corner: #b8900a;
+ --checker-white: #f5edd8;
+ --checker-black: #1a0f06;
+ --checker-ring: #c8a448;
+ --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;
+ --font-display: 'Cormorant Garamond', Georgia, serif;
+ --font-ui: 'Jost', system-ui, sans-serif;
+}
+
+/* ── Reset & base ──────────────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--font-ui);
+ background: #8a7050;
+ background-image:
+ radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
+ radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%),
+ repeating-linear-gradient(
+ 45deg, transparent, transparent 3px,
+ rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px
+ );
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.hidden { display: none !important; }
+
+/* ── Site navigation ─────────────────────────────────────────────── */
+.site-nav {
+ background: var(--board-rail);
+ border-bottom: 2px solid var(--ui-gold-dark);
+ padding: 0 1.5rem;
+ height: 52px;
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.4);
+ flex-shrink: 0;
+}
+
+.site-nav-brand {
+ font-family: var(--font-display);
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--ui-gold);
+ text-decoration: none;
+ letter-spacing: 0.1em;
+}
+.site-nav-brand:hover { color: #e0b840; }
+
+.site-nav-spacer { flex: 1; }
+
+.site-nav a {
+ font-family: var(--font-ui);
+ font-size: 0.9rem;
+ color: var(--ui-parchment);
+ text-decoration: none;
+ opacity: 0.8;
+ transition: opacity 0.15s, color 0.15s;
+}
+.site-nav a:hover { opacity: 1; }
+
+.site-nav-btn {
+ padding: 0.3rem 0.9rem;
+ font-family: var(--font-ui);
+ font-size: 0.85rem;
+ font-weight: 500;
+ letter-spacing: 0.03em;
+ border: 1px solid rgba(200,164,72,0.4);
+ border-radius: 4px;
+ background: transparent;
+ color: var(--ui-parchment);
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+.site-nav-btn:hover {
+ background: rgba(200,164,72,0.12);
+ border-color: var(--ui-gold);
+}
+
+/* ── Portal main content area ────────────────────────────────────── */
+.portal-main {
+ flex: 1;
+ max-width: 900px;
+ width: 100%;
+ margin: 2rem auto;
+ padding: 0 1.5rem;
+}
+
+.portal-card {
+ background: var(--ui-parchment);
+ border: 1px solid rgba(200,164,72,0.3);
+ border-top: 3px solid var(--ui-gold-dark);
+ border-radius: 6px;
+ padding: 1.75rem 2rem;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
+ margin-bottom: 1.5rem;
+}
+
+.portal-card h1 {
+ font-family: var(--font-display);
+ font-size: 2rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ letter-spacing: 0.04em;
+ margin-bottom: 0.25rem;
+}
+.portal-card h2 {
+ font-family: var(--font-display);
+ font-size: 1.35rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ margin-bottom: 0.75rem;
+}
+
+.portal-meta {
+ font-size: 0.85rem;
+ color: #665544;
+ margin-bottom: 1.5rem;
+ font-family: var(--font-ui);
+}
+
+/* ── Stats grid ──────────────────────────────────────────────────── */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+.stat-box {
+ background: var(--ui-parchment);
+ border: 1px solid rgba(200,164,72,0.28);
+ border-radius: 6px;
+ padding: 1rem;
+ text-align: center;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.1);
+}
+.stat-box .value {
+ font-family: var(--font-display);
+ font-size: 2.2rem;
+ font-weight: 600;
+ color: var(--ui-gold-dark);
+}
+.stat-box .label {
+ font-size: 0.78rem;
+ color: #665544;
+ margin-top: 0.2rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+/* ── Tables ──────────────────────────────────────────────────────── */
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+ font-family: var(--font-ui);
+}
+th {
+ text-align: left;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 2px solid rgba(200,164,72,0.4);
+ color: #665544;
+ font-weight: 500;
+ font-size: 0.8rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+td {
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid rgba(200,164,72,0.12);
+ color: var(--ui-ink);
+}
+tr:last-child td { border-bottom: none; }
+tr:hover td { background: rgba(200,164,72,0.05); }
+
+a { color: var(--ui-gold-dark); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Outcome classes ─────────────────────────────────────────────── */
+.outcome-win { color: var(--ui-green-accent); font-weight: 600; }
+.outcome-loss { color: var(--ui-red-accent); font-weight: 600; }
+.outcome-draw { color: #c07020; font-weight: 600; }
+
+/* ── Portal tabs ─────────────────────────────────────────────────── */
+.portal-tabs {
+ display: flex;
+ gap: 0;
+ margin-bottom: 1.5rem;
+ border-bottom: 1px solid rgba(200,164,72,0.3);
+}
+.portal-tab-btn {
+ padding: 0.55rem 1.5rem;
+ font-family: var(--font-ui);
+ font-size: 0.9rem;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: #665544;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: color 0.15s, border-color 0.15s;
+}
+.portal-tab-btn.active {
+ color: var(--ui-ink);
+ border-bottom-color: var(--ui-gold-dark);
+ font-weight: 500;
+}
+
+/* ── Portal form ─────────────────────────────────────────────────── */
+.portal-label {
+ display: block;
+ font-size: 0.82rem;
+ color: #665544;
+ margin-bottom: 0.3rem;
+ letter-spacing: 0.03em;
+}
+.portal-input {
+ width: 100%;
+ padding: 0.55rem 0.85rem;
+ font-size: 0.95rem;
+ font-family: var(--font-ui);
+ border: 1px solid rgba(138,106,40,0.35);
+ border-radius: 5px;
+ background: rgba(255,252,240,0.8);
+ color: var(--ui-ink);
+ outline: none;
+ margin-bottom: 1rem;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+.portal-input:focus {
+ border-color: var(--ui-gold);
+ box-shadow: 0 0 0 3px rgba(200,164,72,0.18);
+}
+.portal-submit-btn {
+ padding: 0.6rem 2rem;
+ font-family: var(--font-ui);
+ font-size: 0.9rem;
+ font-weight: 500;
+ background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%);
+ color: #e8f0e0;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.25);
+ transition: opacity 0.15s;
+}
+.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; }
+.portal-submit-btn:not(:disabled):hover { opacity: 0.9; }
+
+.portal-page-btn {
+ padding: 0.35rem 0.9rem;
+ font-family: var(--font-ui);
+ font-size: 0.85rem;
+ background: var(--board-rail);
+ color: var(--ui-parchment);
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ opacity: 0.85;
+ transition: opacity 0.15s;
+}
+.portal-page-btn:hover { opacity: 1; }
+
+.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; }
+.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; }
+.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; }
+.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; }
+
+/* ── Game overlay (full-screen, covers portal during play) ───────── */
+.game-overlay {
+ position: fixed;
+ inset: 0;
+ background: #8a7050;
+ background-image:
+ radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
+ radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%),
+ repeating-linear-gradient(
+ 45deg, transparent, transparent 3px,
+ rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px
+ );
+ z-index: 200;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 1.5rem;
+ overflow-y: auto;
+}
+
+/* ── Login card (§11) ───────────────────────────────────────────────── */
+.login-card {
+ width: 340px;
+ margin-top: 5vh;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow:
+ 0 20px 60px rgba(0,0,0,0.55),
+ 0 0 0 1px rgba(200,164,72,0.35),
+ 0 0 0 5px rgba(42,21,8,0.9),
+ 0 0 0 6px rgba(200,164,72,0.2);
+ background: var(--ui-parchment);
+}
+
+/* Decorative header — row of triangular flèches like the actual board */
+.login-card-header {
+ height: 52px;
+ background: var(--board-felt);
+ position: relative;
+ overflow: hidden;
+}
+
+.login-board-stripe {
+ position: absolute;
+ inset: 0;
+ background:
+ repeating-linear-gradient(
+ 90deg,
+ var(--field-burgundy) 0, var(--field-burgundy) 50%,
+ var(--field-ivory) 50%, var(--field-ivory) 100%
+ );
+ background-size: 34px 100%;
+ clip-path: polygon(
+ 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%,
+ 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%,
+ 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%,
+ 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%,
+ 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%,
+ 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%,
+ 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%,
+ 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%,
+ 97.06% 100%, 100% 0%
+ );
+ opacity: 0.9;
+}
+
+.login-card-body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0;
+ padding: 1.5rem 2rem 2rem;
+}
+
+.login-lang-switcher {
+ align-self: flex-end;
+ margin-bottom: 0.75rem;
+}
+
+/* Override lang-switcher colours for the parchment card */
+.login-card .lang-switcher button {
+ color: var(--ui-ink);
+ border-color: rgba(42,21,8,0.2);
+ opacity: 0.5;
+}
+.login-card .lang-switcher button.lang-active {
+ opacity: 1;
+ background: rgba(42,21,8,0.08);
+ border-color: rgba(42,21,8,0.35);
+}
+
+.login-title {
+ font-family: var(--font-display);
+ font-size: 3.25rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ letter-spacing: 0.12em;
+ text-align: center;
+ line-height: 1;
+ margin-bottom: 0.3rem;
+}
+
+.login-subtitle {
+ font-family: var(--font-display);
+ font-size: 0.85rem;
+ color: rgba(42,26,8,0.55);
+ text-align: center;
+ letter-spacing: 0.06em;
+ font-style: italic;
+ margin-bottom: 1.25rem;
+}
+.login-subtitle sup {
+ font-size: 0.65em;
+ vertical-align: super;
+}
+
+.login-ornament {
+ color: var(--ui-gold);
+ font-size: 1rem;
+ opacity: 0.7;
+ margin-bottom: 1.25rem;
+ letter-spacing: 0.3em;
+ text-align: center;
+}
+
+.error-msg {
+ color: #c03030;
+ font-size: 0.85rem;
+ text-align: center;
+ margin-bottom: 0.5rem;
+}
+
+.login-input {
+ width: 100%;
+ padding: 0.55rem 0.85rem;
+ font-size: 0.95rem;
+ font-family: var(--font-ui);
+ border: 1px solid rgba(138,106,40,0.4);
+ border-radius: 5px;
+ background: rgba(255,252,240,0.8);
+ color: var(--ui-ink);
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ margin-bottom: 1rem;
+}
+.login-input:focus {
+ border-color: var(--ui-gold);
+ box-shadow: 0 0 0 3px rgba(200,164,72,0.2);
+}
+
+.login-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.55rem;
+ width: 100%;
+}
+
+/* Login buttons styled as embossed wooden tiles */
+.login-btn {
+ width: 100%;
+ padding: 0.65rem 1rem;
+ font-family: var(--font-ui);
+ font-size: 0.9rem;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
+ position: relative;
+}
+.login-btn:disabled { opacity: 0.35; cursor: default; }
+.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); }
+.login-btn:not(:disabled):active { transform: translateY(0); }
+
+.login-btn-primary {
+ background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%);
+ color: #e8f0e0;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12);
+}
+.login-btn-secondary {
+ background: linear-gradient(160deg, #3a2010 0%, #241408 100%);
+ color: #e4d4b4;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08);
+}
+.login-btn-bot {
+ background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%);
+ color: #d0e0f0;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08);
+}
+
+/* ── Connecting screen ──────────────────────────────────────────────── */
+.connecting {
+ font-family: var(--font-display);
+ font-size: 1.4rem;
+ font-style: italic;
+ margin-top: 4rem;
+ text-align: center;
+ color: var(--ui-parchment);
+ text-shadow: 0 1px 4px rgba(0,0,0,0.4);
+}
+
+/* ── Game-action buttons ─────────────────────────────────────────────── */
+.btn {
+ padding: 0.5rem 1.25rem;
+ font-size: 0.95rem;
+ font-family: var(--font-ui);
+ font-weight: 500;
+ letter-spacing: 0.03em;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: opacity 0.15s, box-shadow 0.15s;
+}
+.btn:disabled { opacity: 0.4; cursor: default; }
+.btn-primary { background: var(--ui-green-accent); color: #fff; }
+.btn-secondary { background: var(--board-rail); color: #e8d8b8; }
+.btn-bot { background: #2a5a7a; color: #fff; }
+.btn:not(:disabled):hover {
+ opacity: 0.9;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.25);
+}
+
+/* ── Game container ─────────────────────────────────────────────────── */
+.game-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+/* ── Language switcher (in-game) ────────────────────────────────────── */
+.lang-switcher { display: flex; gap: 0.25rem; }
+
+.lang-switcher button {
+ font-size: 0.7rem;
+ font-family: var(--font-ui);
+ letter-spacing: 0.05em;
+ padding: 0.15rem 0.4rem;
+ border: 1px solid rgba(200,164,72,0.3);
+ border-radius: 3px;
+ background: transparent;
+ cursor: pointer;
+ color: var(--ui-parchment);
+ opacity: 0.55;
+}
+.lang-switcher button.lang-active {
+ opacity: 1;
+ font-weight: 500;
+ background: rgba(200,164,72,0.15);
+ border-color: rgba(200,164,72,0.6);
+}
+
+/* ── Top bar ─────────────────────────────────────────────────────────── */
+.top-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ color: var(--ui-parchment);
+ font-size: 0.85rem;
+ opacity: 0.8;
+}
+
+.quit-link {
+ font-size: 0.8rem;
+ color: var(--ui-parchment);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ cursor: pointer;
+ opacity: 0.7;
+}
+.quit-link:hover { opacity: 1; }
+
+.playing-as {
+ font-size: 0.75rem;
+ color: rgba(242,232,208,0.7);
+ font-family: var(--font-ui);
+}
+.playing-as strong { color: rgba(242,232,208,0.9); }
+
+/* ── Game status bar (§10b) — above board ───────────────────────────── */
+.game-status {
+ font-family: var(--font-display);
+ font-size: 1.2rem;
+ font-style: italic;
+ color: var(--ui-parchment);
+ text-align: center;
+ letter-spacing: 0.04em;
+ padding: 0.2rem 1rem 0;
+ width: 100%;
+ text-shadow: 0 1px 4px rgba(0,0,0,0.4);
+}
+
+/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */
+.game-sub-prompt {
+ font-family: var(--font-ui);
+ font-size: 0.72rem;
+ color: rgba(240,228,192,0.5);
+ text-align: center;
+ letter-spacing: 0.04em;
+ padding: 0.15rem 1rem 0;
+ width: 100%;
+}
+
+/* ── Player score panel ─────────────────────────────────────────────── */
+.player-score-panel {
+ background: var(--ui-parchment);
+ border-radius: 5px;
+ padding: 0.45rem 1.25rem;
+ font-size: 0.88rem;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.25);
+ width: 100%;
+ border-top: 2px solid var(--ui-gold-dark);
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+}
+
+.player-score-header {
+ flex-shrink: 0;
+ min-width: 90px;
+}
+
+.player-name {
+ font-family: var(--font-display);
+ font-weight: 600;
+ font-size: 1.05rem;
+ color: var(--ui-ink);
+ letter-spacing: 0.02em;
+}
+
+.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; }
+
+.score-bar-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+}
+
+.score-bar-label {
+ font-size: 0.75rem;
+ color: #665544;
+ width: 3rem;
+ text-align: right;
+ flex-shrink: 0;
+}
+
+/* ── Points bar ─────────────────────────────────────────────────────── */
+.score-bar {
+ flex: 1;
+ max-width: 220px;
+ height: 8px;
+ background: rgba(0,0,0,0.1);
+ border-radius: 4px;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.score-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.35s ease-out;
+}
+
+.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); }
+
+.score-bar-value {
+ font-size: 0.75rem;
+ color: #665544;
+ min-width: 2.5rem;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */
+.peg-track {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ flex-shrink: 0;
+}
+
+.peg-hole {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ border: 1.5px solid rgba(138,106,40,0.45);
+ background: rgba(0,0,0,0.06);
+ flex-shrink: 0;
+ transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s;
+}
+
+.peg-hole.filled {
+ background: var(--ui-gold);
+ border-color: var(--ui-gold-dark);
+ box-shadow: 0 0 4px rgba(200,164,72,0.6);
+}
+
+.bredouille-badge {
+ font-size: 0.62rem;
+ font-weight: 500;
+ color: #fff8e0;
+ background: linear-gradient(135deg, #c88800, #8a5800);
+ border: 1px solid rgba(200,164,72,0.5);
+ border-radius: 3px;
+ padding: 0.1em 0.4em;
+ letter-spacing: 0.06em;
+ cursor: default;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.25);
+}
+
+/* ── Board + side panel ─────────────────────────────────────────────── */
+.board-and-panel {
+ position: relative;
+}
+
+.side-panel {
+ position: absolute;
+ right: -8px;
+ top: 10px;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding-top: 0.15rem;
+ pointer-events: none;
+}
+
+.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
+
+/* ── Dice bar ───────────────────────────────────────────────────────── */
+.dice-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.4rem 0.6rem;
+ background: rgba(42,21,8,0.15);
+ border-radius: 6px;
+ border: 1px solid rgba(200,164,72,0.2);
+ width: fit-content;
+}
+
+/* ── Die face (SVG) ─────────────────────────────────────────────────── */
+@keyframes die-tumble {
+ 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; }
+ 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; }
+ 45% { transform: rotate(-10deg) scale(0.91); }
+ 62% { transform: rotate(6deg) scale(1.06); }
+ 76% { transform: rotate(-3deg) scale(0.98); }
+ 88% { transform: rotate(1.5deg) scale(1.01); }
+ 100% { transform: rotate(0deg) scale(1); opacity: 1; }
+}
+
+.die-face {
+ filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3));
+ animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both;
+}
+
+.die-face rect {
+ fill: #fffef0;
+ stroke: #2a1a00;
+ stroke-width: 2;
+ transition: fill 0.18s, stroke 0.18s;
+}
+.die-face circle {
+ fill: #1a0a00;
+ transition: fill 0.18s;
+}
+
+.bar-die-slot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; }
+.die-face.die-double {
+ filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3));
+}
+
+.die-face.die-used { animation: none; opacity: 0.55; }
+.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; }
+.die-face.die-used circle { fill: #9a8a70; }
+
+.die-face .die-question { fill: #1a0a00; font-family: sans-serif; }
+.die-face.die-used .die-question { fill: #9a8a70; }
+
+/* ── Jan panel ──────────────────────────────────────────────────────── */
+.jan-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ background: var(--ui-parchment);
+ border-radius: 5px;
+ padding: 0.4rem 0.9rem;
+ font-size: 0.88rem;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.15);
+ min-width: 260px;
+ border-top: 2px solid rgba(138,106,40,0.35);
+}
+
+.jan-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 2px 4px;
+ border-radius: 3px;
+}
+.jan-expandable { cursor: pointer; }
+.jan-expandable:hover { background: rgba(0,0,0,0.05); }
+
+.jan-positive { color: #1a5c1a; }
+.jan-negative { color: #8b1a1a; }
+
+.jan-label { flex: 1; }
+.jan-tag {
+ font-size: 0.72rem;
+ padding: 0.1em 0.4em;
+ border-radius: 3px;
+ background: rgba(0,0,0,0.07);
+ color: #665544;
+ white-space: nowrap;
+}
+.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; }
+
+.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; }
+.jan-moves.hidden { display: none; }
+.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; }
+
+/* ── Game-over overlay (§12) ────────────────────────────────────────── */
+.game-over-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.65);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
+
+@keyframes game-over-appear {
+ from { transform: translateY(-24px) scale(0.94); opacity: 0; }
+ to { transform: translateY(0) scale(1); opacity: 1; }
+}
+
+.game-over-box {
+ background: var(--ui-parchment);
+ border-radius: 8px;
+ padding: 2.5rem 3rem;
+ text-align: center;
+ box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark);
+ display: flex;
+ flex-direction: column;
+ gap: 1.1rem;
+ min-width: 300px;
+ animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1);
+}
+
+.game-over-box h2 {
+ font-family: var(--font-display);
+ font-size: 2rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ letter-spacing: 0.06em;
+}
+
+.game-over-winner {
+ font-family: var(--font-display);
+ font-size: 1.25rem;
+ color: var(--ui-green-accent);
+ font-style: italic;
+}
+
+.game-over-score {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 0.6rem 1rem;
+ background: rgba(0,0,0,0.05);
+ border-radius: 5px;
+ border: 1px solid rgba(138,106,40,0.2);
+}
+
+.game-over-score-name {
+ font-family: var(--font-display);
+ font-size: 0.9rem;
+ color: #665544;
+ font-style: italic;
+ max-width: 80px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.game-over-score-nums {
+ font-family: var(--font-display);
+ font-size: 2.25rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ letter-spacing: 0.08em;
+ line-height: 1;
+}
+
+.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
+
+/* ── Scoring notification panel (§6b) ───────────────────────────────── */
+@keyframes scoring-panel-enter {
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+}
+
+.scoring-panel-wrapper {
+ pointer-events: auto;
+ animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+ transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+ filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38));
+}
+
+.scoring-panel-wrapper.peeked {
+ transform: translateX(100%);
+}
+
+.scoring-panel-wrapper.revealed {
+ transform: translateX(0);
+}
+
+.scoring-panel-wrapper.peeked:not(.revealed) {
+ cursor: pointer;
+}
+
+.scoring-panel {
+ background: var(--ui-parchment);
+ border-radius: 5px;
+ padding: 0.45rem 0.85rem;
+ font-size: 0.84rem;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.15);
+ border-left: 3px solid var(--ui-green-accent);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
+}
+
+.scoring-total {
+ font-family: var(--font-display);
+ font-weight: 600;
+ font-size: 1rem;
+ color: #1a5c1a;
+ white-space: nowrap;
+}
+
+.scoring-jan-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 2px 3px;
+ border-radius: 3px;
+ cursor: default;
+ white-space: nowrap;
+}
+.scoring-jan-row:hover { background: rgba(0,0,0,0.05); }
+
+.scoring-panel-opp { border-left-color: var(--board-rail); }
+.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); }
+
+.scoring-hole {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-weight: 600;
+ color: var(--ui-red-accent);
+ margin-top: 3px;
+ padding-top: 4px;
+ border-top: 1px solid rgba(0,0,0,0.1);
+}
+
+.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; }
+
+@media (min-width: 1492px) {
+ .side-panel {
+ right: auto;
+ left: calc(100% + 1rem);
+ }
+ .scoring-panel-wrapper.peeked,
+ .scoring-panel-wrapper.revealed {
+ transform: none;
+ cursor: default;
+ }
+}
+
+/* ── Board wrapper ──────────────────────────────────────────────────── */
+.board-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+/* ── Zone labels (§2a) ──────────────────────────────────────────────── */
+.zone-labels-row {
+ display: flex;
+ gap: 4px;
+ padding: 0 8px;
+}
+
+.zone-label {
+ font-family: var(--font-display);
+ font-size: 0.57rem;
+ font-style: italic;
+ color: rgba(240,228,192,0.48);
+ letter-spacing: 0.1em;
+ text-align: center;
+ pointer-events: none;
+ line-height: 1;
+}
+
+.zone-label-quarter { width: 370px; flex-shrink: 0; }
+.zone-label-bar { width: 68px; flex-shrink: 0; }
+
+/* ── Board ──────────────────────────────────────────────────────────── */
+.board {
+ background: var(--board-felt);
+ border: 4px solid var(--board-rail);
+ border-radius: 6px;
+ padding: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ user-select: none;
+ box-shadow:
+ 0 6px 16px rgba(0,0,0,0.5),
+ inset 0 1px 0 rgba(255,255,255,0.04);
+ position: relative;
+}
+
+.board-row { display: flex; gap: 4px; }
+.board-quarter { display: flex; gap: 2px; }
+
+.board-bar {
+ width: 68px;
+ background: var(--board-rail);
+ border-radius: 4px;
+ box-shadow: inset 0 0 6px rgba(0,0,0,0.5);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ overflow: visible;
+}
+
+.board-center-bar {
+ height: 12px;
+ background: var(--board-rail);
+ border-radius: 2px;
+ box-shadow: inset 0 0 4px rgba(0,0,0,0.4);
+}
+
+/* ── Fields (§1) ────────────────────────────────────────────────────── */
+.field {
+ --fc: var(--field-ivory);
+ width: 60px;
+ height: 180px;
+ background: transparent;
+ isolation: isolate;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 4px 2px;
+ position: relative;
+}
+
+.field::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ background: var(--fc);
+ clip-path: polygon(0% 100%, 50% 0%, 100% 100%);
+ transition: background 0.12s;
+}
+
+.top-row .field::before {
+ clip-path: polygon(0% 0%, 100% 0%, 50% 100%);
+}
+
+.top-row .field { justify-content: flex-start; }
+
+/* ── Zone alternating colours (§2b) ────────────────────────────────── */
+.board-quarter .field.zone-petit:nth-child(odd),
+.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); }
+.board-quarter .field.zone-petit:nth-child(even),
+.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); }
+
+.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; }
+.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; }
+
+.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
+.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
+
+.field.corner::after {
+ content: '♛';
+ position: absolute;
+ z-index: -1;
+ bottom: 22px;
+ font-size: 0.7rem;
+ color: rgba(255,248,210,0.38);
+ pointer-events: none;
+ line-height: 1;
+}
+.top-row .field.corner::after { bottom: auto; top: 22px; }
+
+@keyframes corner-pulse {
+ 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); }
+ 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); }
+}
+.field.corner.corner-available {
+ animation: corner-pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes exit-glow {
+ 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); }
+ 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); }
+}
+.field.exit-eligible {
+ animation: exit-glow 2s ease-in-out infinite;
+}
+
+.field.jan-hovered {
+ --fc: rgba(190, 140, 35, 0.8) !important;
+}
+
+@keyframes hit-ripple {
+ from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; }
+ to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; }
+}
+.hit-ripple {
+ position: absolute;
+ left: 50%;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 2px solid rgba(200, 164, 72, 0.9);
+ pointer-events: none;
+ animation: hit-ripple 0.5s ease-out forwards;
+}
+.hit-ripple-top { top: 26px; }
+.hit-ripple-bot { bottom: 26px; }
+
+.field.clickable {
+ cursor: pointer;
+ --fc: #8fc840 !important;
+}
+.field.clickable:hover { --fc: #74aa28 !important; }
+.field.selected {
+ --fc: #5a8a18 !important;
+ outline: 2px solid rgba(255,255,255,0.3);
+ outline-offset: -2px;
+}
+
+.field-num {
+ font-size: 0.58rem;
+ color: rgba(0,0,0,0.28);
+ position: absolute;
+ bottom: 3px;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.board-quarter .field.zone-petit:nth-child(odd) .field-num,
+.board-quarter .field.zone-grand:nth-child(odd) .field-num,
+.board-quarter .field.zone-retour:nth-child(odd) .field-num,
+.board-quarter .field.zone-opponent:nth-child(odd) .field-num {
+ color: rgba(240,215,190,0.38);
+}
+
+.field.corner .field-num { color: rgba(255,248,200,0.4); }
+.top-row .field-num { bottom: auto; top: 3px; }
+
+/* ── Checkers ───────────────────────────────────────────────────────── */
+.checker-stack {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.checker {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.78rem;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.checker + .checker { margin-top: -4px; }
+
+.checker.white {
+ 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%, var(--checker-white) 52%, #c0b288 100%);
+ border: 1.8px solid var(--checker-ring);
+ box-shadow:
+ 0 2px 6px rgba(0,0,0,0.4),
+ inset 0 -1px 3px rgba(0,0,0,0.15);
+ color: #443322;
+}
+
+.checker.black {
+ 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%, var(--checker-black) 100%);
+ border: 1.8px solid var(--checker-ring);
+ box-shadow:
+ 0 2px 6px rgba(0,0,0,0.55),
+ inset 0 -1px 3px rgba(0,0,0,0.4);
+ color: #c8b898;
+}
+
+/* ── Hole toast (§6a) ───────────────────────────────────────────────── */
+@keyframes toast-rise {
+ from { transform: translate(-50%, -40%); opacity: 0; }
+ to { transform: translate(-50%, -50%); opacity: 1; }
+}
+@keyframes toast-fade {
+ from { opacity: 1; }
+ to { opacity: 0; pointer-events: none; }
+}
+
+.hole-toast {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(22,10,2,0.93);
+ border: 2px solid var(--ui-gold);
+ border-radius: 8px;
+ padding: 1.5rem 3.5rem;
+ text-align: center;
+ z-index: 50;
+ pointer-events: none;
+ box-shadow:
+ 0 12px 40px rgba(0,0,0,0.65),
+ 0 0 0 1px rgba(200,164,72,0.25),
+ inset 0 1px 0 rgba(200,164,72,0.1);
+ animation:
+ toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1),
+ toast-fade 0.5s ease-in 1.4s forwards;
+}
+
+.hole-toast-title {
+ font-family: var(--font-display);
+ font-size: 3.25rem;
+ font-weight: 600;
+ color: var(--ui-gold);
+ letter-spacing: 0.1em;
+ line-height: 1;
+}
+
+.hole-toast-count {
+ font-family: var(--font-display);
+ font-size: 1.1rem;
+ color: rgba(200,164,72,0.68);
+ margin-top: 0.35rem;
+ letter-spacing: 0.06em;
+}
+
+.hole-toast-bredouille {
+ font-family: var(--font-ui);
+ font-size: 0.75rem;
+ letter-spacing: 0.08em;
+ color: rgba(200,164,72,0.55);
+ margin-top: 0.4rem;
+ text-transform: uppercase;
+}
+
+@keyframes bredouille-shimmer {
+ 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); }
+ 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); }
+}
+.hole-toast.hole-toast-bredouille {
+ border-width: 2.5px;
+ border-color: var(--ui-gold);
+ padding: 2rem 4rem;
+ animation:
+ toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1),
+ bredouille-shimmer 0.9s ease-in-out 0.3s 2,
+ toast-fade 0.5s ease-in 2.2s forwards;
+}
+.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; }
+.hole-toast.hole-toast-bredouille .hole-toast-bredouille {
+ font-size: 0.85rem;
+ color: rgba(200,164,72,0.8);
+ letter-spacing: 0.14em;
+}
+
+/* ── Checker slide animation (§4a) ─────────────────────────────────── */
+@keyframes checker-slide-in {
+ from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); }
+ to { transform: none; }
+}
+.checker.arriving {
+ animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+.field:has(.checker.arriving) {
+ isolation: auto;
+ z-index: 10;
+ position: relative;
+}
+
+/* ── Checker lift on selected field (§4b) ───────────────────────────── */
+.field.selected .checker-stack {
+ transform: translateY(-5px);
+ filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6));
+ transition: transform 0.12s ease-out, filter 0.12s ease-out;
+}
+
+/* ── Action buttons below board (§10c) ──────────────────────────────── */
+.board-actions {
+ display: flex;
+ gap: 0.55rem;
+ justify-content: center;
+ align-items: center;
+ flex-wrap: wrap;
+ min-height: 2rem;
+}
+
+/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */
+.ceremony-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.65);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
+
+.ceremony-box {
+ background: var(--ui-parchment);
+ border-radius: 8px;
+ padding: 2.5rem 3rem;
+ text-align: center;
+ box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1.4rem;
+ min-width: 300px;
+ animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1);
+}
+
+.ceremony-box h2 {
+ font-family: var(--font-display);
+ font-size: 1.8rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ letter-spacing: 0.06em;
+}
+
+.ceremony-dice {
+ display: flex;
+ gap: 3rem;
+ align-items: flex-end;
+}
+
+.ceremony-die-slot {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.ceremony-die-label {
+ font-family: var(--font-ui);
+ font-size: 0.85rem;
+ color: var(--ui-ink);
+ font-weight: 500;
+}
+
+.ceremony-tie {
+ font-family: var(--font-display);
+ font-size: 1rem;
+ color: var(--ui-red-accent);
+ font-style: italic;
+}
diff --git a/clients/web/index.html b/clients/web/index.html
new file mode 100644
index 0000000..7399dbc
--- /dev/null
+++ b/clients/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Trictrac
+
+
+
+
+
+
diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json
new file mode 100644
index 0000000..3b32f20
--- /dev/null
+++ b/clients/web/locales/en.json
@@ -0,0 +1,96 @@
+{
+ "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",
+ "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",
+ "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",
+ "sign_in": "Sign in",
+ "sign_out": "Sign out",
+ "create_account": "Create account",
+ "account_title": "Account",
+ "label_username": "Username",
+ "label_password": "Password",
+ "label_email": "Email",
+ "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"
+}
diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json
new file mode 100644
index 0000000..9af6f10
--- /dev/null
+++ b/clients/web/locales/fr.json
@@ -0,0 +1,96 @@
+{
+ "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",
+ "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",
+ "pre_game_roll_your_die": "Votre dé",
+ "pre_game_roll_opp_die": "Dé adverse",
+ "continue_btn": "Continuer",
+ "scored_pts": "+{{ n }} pts",
+ "hole_made": "Trou ! {{ holes }}/12",
+ "bredouille_applied": "Bredouille !",
+ "hold": "Tenir",
+ "opp_scored_pts": "Adversaire +{{ n }} pts",
+ "opp_hole_made": "Trou adverse ! {{ holes }}/12",
+ "hint_move": "Cliquez un champ surligné pour déplacer",
+ "hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
+ "hint_continue": "Cliquez Continuer quand vous êtes prêt",
+ "sign_in": "Se connecter",
+ "sign_out": "Se déconnecter",
+ "create_account": "Créer un compte",
+ "account_title": "Compte",
+ "label_username": "Nom d'utilisateur",
+ "label_password": "Mot de passe",
+ "label_email": "Email",
+ "loading": "Chargement…",
+ "member_since": "Membre depuis",
+ "stat_games": "Parties",
+ "stat_wins": "Victoires",
+ "stat_losses": "Défaites",
+ "stat_draws": "Nuls",
+ "game_history_title": "Historique",
+ "no_games": "Aucune partie enregistrée.",
+ "col_room": "Salle",
+ "col_started": "Début",
+ "col_ended": "Fin",
+ "col_outcome": "Résultat",
+ "col_detail": "Détail",
+ "prev_page": "← Précédent",
+ "next_page": "Suivant →",
+ "page_label": "Page",
+ "view_link": "Voir",
+ "outcome_win": "victoire",
+ "outcome_loss": "défaite",
+ "outcome_draw": "nul",
+ "players_header": "Joueurs",
+ "col_player": "Joueur",
+ "score_header": "Score",
+ "game_ongoing": "en cours",
+ "anonymous_player": "anonyme",
+ "started_label": "Début",
+ "ended_label": "Fin",
+ "room_detail_title": "Salle"
+}
diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs
new file mode 100644
index 0000000..29032c0
--- /dev/null
+++ b/clients/web/src/api.rs
@@ -0,0 +1,191 @@
+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,
+}
+
+#[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,
+ pub result: Option,
+ pub outcome: Option,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+pub struct GamesResponse {
+ pub games: Vec,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+pub struct Participant {
+ pub player_id: i64,
+ pub outcome: Option,
+ pub username: Option,
+}
+
+#[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,
+ pub result: Option,
+ pub participants: Vec,
+}
+
+// ── 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 {
+ 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::().await.map_err(|e| e.to_string())
+ } else {
+ Err(format!("status {}", resp.status()))
+ }
+}
+
+pub async fn post_login(username: &str, password: &str) -> Result {
+ 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::().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 {
+ 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::().await.map_err(|e| e.to_string())
+ } else {
+ let text = resp.text().await.unwrap_or_default();
+ Err(text)
+ }
+}
+
+pub async fn post_logout() -> Result<(), String> {
+ let resp = gloo_net::http::Request::post(&url("/auth/logout"))
+ .credentials(web_sys::RequestCredentials::Include)
+ .send()
+ .await
+ .map_err(|e| e.to_string())?;
+ if resp.status() == 204 {
+ Ok(())
+ } else {
+ Err(format!("status {}", resp.status()))
+ }
+}
+
+pub async fn get_user_profile(username: &str) -> Result {
+ 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::().await.map_err(|e| e.to_string())
+ } else {
+ Err(format!("status {}", resp.status()))
+ }
+}
+
+pub async fn get_user_games(username: &str, page: i64) -> Result {
+ 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::().await.map_err(|e| e.to_string())
+ } else {
+ Err(format!("status {}", resp.status()))
+ }
+}
+
+pub async fn get_game_detail(id: i64) -> Result {
+ 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::().await.map_err(|e| e.to_string())
+ } else {
+ Err(format!("status {}", resp.status()))
+ }
+}
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+
+pub fn format_ts(ts: i64) -> String {
+ let ms = (ts * 1000) as f64;
+ let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
+ date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED)
+ .as_string()
+ .unwrap_or_default()
+}
diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs
new file mode 100644
index 0000000..8d604b6
--- /dev/null
+++ b/clients/web/src/app.rs
@@ -0,0 +1,459 @@
+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};
+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, push_or_show, run_local_bot_game,
+};
+use crate::game::trictrac::backend::TrictracBackend;
+use crate::game::trictrac::types::{
+ GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState,
+};
+use crate::i18n::I18nContextProvider;
+use crate::nav::SiteNav;
+use crate::portal::{account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage};
+use trictrac_store::CheckerMove;
+
+use std::collections::VecDeque;
+
+const RELAY_URL: &str = "ws://localhost:8080/ws";
+const GAME_ID: &str = "trictrac";
+const STORAGE_KEY: &str = "trictrac_session";
+
+/// The state the UI needs to render the game screen.
+#[derive(Clone, PartialEq)]
+pub struct GameUiState {
+ pub view_state: ViewState,
+ /// 0 = host, 1 = guest
+ pub player_id: u16,
+ pub room_id: String,
+ pub is_bot_game: bool,
+ pub waiting_for_confirm: bool,
+ pub pause_reason: Option,
+ pub my_scored_event: Option,
+ pub opp_scored_event: Option,
+ 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,
+ AfterOpponentPreGameRoll,
+}
+
+/// Which screen is currently shown (used to toggle game overlay).
+#[derive(Clone, PartialEq)]
+pub enum Screen {
+ Login { error: Option },
+ 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>,
+ },
+ PlayVsBot,
+ 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,
+}
+
+fn save_session(session: &StoredSession) {
+ LocalStorage::set(STORAGE_KEY, session).ok();
+}
+
+fn load_session() -> Option {
+ LocalStorage::get::(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 stored = load_session();
+ let initial_screen = if stored.is_some() {
+ Screen::Connecting
+ } else {
+ Screen::Login { error: None }
+ };
+ let screen: RwSignal = RwSignal::new(initial_screen);
+ provide_context(screen);
+
+ // Auth: fetch once on load; shared by nav + game + portal components.
+ let auth_username: RwSignal> = RwSignal::new(None);
+ provide_context(auth_username);
+ spawn_local(async move {
+ if let Ok(me) = api::get_me().await {
+ auth_username.set(Some(me.username));
+ }
+ });
+
+ let (cmd_tx, mut cmd_rx) = mpsc::unbounded::();
+ let pending: RwSignal> = 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 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,
+ ));
+ }
+ _ => {}
+ }
+ };
+
+ 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 =
+ match GameSession::connect::(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("Blancs", "Noirs");
+ 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),
+ }
+
+ if is_host && !result_submitted && vs.stage == SerStage::Ended {
+ result_submitted = true;
+ let room = room_id_for_storage.clone();
+ let gs = vs.clone();
+ spawn_local(submit_game_result(room, gs));
+ }
+
+ if is_host {
+ save_session(&StoredSession {
+ relay_url: RELAY_URL.to_string(),
+ game_id: GAME_ID.to_string(),
+ room_id: room_id_for_storage.clone(),
+ token: reconnect_token,
+ is_host: true,
+ view_state: Some(vs.clone()),
+ });
+ }
+ let is_own_move = prev_vs.active_mp_player == Some(player_id);
+ push_or_show(
+ &prev_vs,
+ GameUiState {
+ view_state: vs.clone(),
+ player_id,
+ room_id: room_id_for_storage.clone(),
+ is_bot_game: false,
+ waiting_for_confirm: false,
+ pause_reason: None,
+ my_scored_event: None,
+ opp_scored_event: None,
+ last_moves: compute_last_moves(&prev_vs, &vs, is_own_move),
+ },
+ 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! {
+
+
+ // Nav: hidden while game overlay is active
+
+
+ // Portal pages — always mounted for router stability
+
+ "Page not found."
}>
+
+
+
+
+
+
+
+ // Game overlay: fixed, covers portal during play
+ {move || {
+ let q = pending.get();
+ let front = q.front().cloned();
+ if let Some(state) = front {
+ return view! {
+
+ }.into_any();
+ }
+ match screen.get() {
+ Screen::Playing(state) => view! {
+
+ }.into_any(),
+ Screen::Connecting => view! {
+
+ }.into_any(),
+ _ => view! { }.into_any(),
+ }
+ }}
+
+
+ }
+}
+
+#[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) -> 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);
+ }
+}
diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs
new file mode 100644
index 0000000..1e4a5a3
--- /dev/null
+++ b/clients/web/src/game/components/board.rs
@@ -0,0 +1,594 @@
+use leptos::prelude::*;
+use trictrac_store::CheckerMove;
+
+use super::die::Die;
+use crate::game::trictrac::types::{SerTurnStage, ViewState};
+
+/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
+const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
+const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
+const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
+const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1];
+
+/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
+const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6];
+const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12];
+const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
+const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
+
+/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system.
+/// Returns true when `field_num` is the rest corner for this perspective.
+#[allow(dead_code)]
+fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
+ if is_white {
+ field_num == 12
+ } else {
+ field_num == 13
+ }
+}
+
+/// Zone CSS class for a field number (field coordinates are always White's 1-24).
+fn field_zone_class(field_num: u8) -> &'static str {
+ match field_num {
+ 1..=6 => "zone-petit",
+ 7..=12 => "zone-grand",
+ 13..=18 => "zone-opponent",
+ 19..=24 => "zone-retour",
+ _ => "",
+ }
+}
+
+/// Returns (d0_used, d1_used) for the bar dice display.
+fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
+ let mut d0 = false;
+ let mut d1 = false;
+ for &(from, to) in staged {
+ let dist = if from < to {
+ to.saturating_sub(from)
+ } else {
+ from.saturating_sub(to)
+ };
+ if !d0 && dist == dice.0 {
+ d0 = true;
+ } else if !d1 && dist == dice.1 {
+ d1 = true;
+ } else if !d0 {
+ d0 = true;
+ } else {
+ d1 = true;
+ }
+ }
+ (d0, d1)
+}
+
+/// Returns the displayed board value for `field_num` after applying `staged_moves`.
+/// Field numbers are always in white's coordinate system (1–24).
+fn displayed_value(
+ base_board: [i8; 24],
+ staged_moves: &[(u8, u8)],
+ is_white: bool,
+ field_num: u8,
+) -> i8 {
+ let mut val = base_board[(field_num - 1) as usize];
+ let delta: i8 = if is_white { 1 } else { -1 };
+ for &(from, to) in staged_moves {
+ if from == field_num {
+ val -= delta;
+ }
+ if to == field_num {
+ val += delta;
+ }
+ }
+ val
+}
+
+/// Fields whose checkers may be selected as the next origin given already-staged moves.
+fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec {
+ let mut v: Vec = match staged.len() {
+ 0 => seqs
+ .iter()
+ .map(|(m1, _)| m1.get_from() as u8)
+ .filter(|&f| f != 0)
+ .collect(),
+ 1 => {
+ let (f0, t0) = staged[0];
+ seqs.iter()
+ .filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
+ .map(|(_, m2)| m2.get_from() as u8)
+ .filter(|&f| f != 0)
+ .collect()
+ }
+ _ => vec![],
+ };
+ v.sort_unstable();
+ v.dedup();
+ v
+}
+
+/// Pixel center of a board field in the SVG overlay coordinate space.
+/// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px.
+/// With triangular flèches, arrows target the WIDE BASE of each triangle —
+/// that is where the checker stack actually sits.
+fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
+ if f == 0 || f > 24 {
+ return None;
+ }
+ let (qi, right, top): (usize, bool, bool) = if is_white {
+ match f {
+ 13..=18 => (f - 13, false, true),
+ 19..=24 => (f - 19, true, true),
+ 7..=12 => (12 - f, false, false),
+ 1..=6 => (6 - f, true, false),
+ _ => return None,
+ }
+ } else {
+ match f {
+ 1..=6 => (f - 1, false, true),
+ 7..=12 => (f - 7, true, true),
+ 19..=24 => (24 - f, false, false),
+ 13..=18 => (18 - f, true, false),
+ _ => return None,
+ }
+ };
+ // Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
+ // Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
+ let x = if right {
+ 480.0 + qi as f32 * 62.0
+ } else {
+ 34.0 + qi as f32 * 62.0
+ };
+ // Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
+ // (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360)
+ let y = if top { 30.0 } else { 358.0 };
+ Some((x, y))
+}
+
+/// SVG `` element drawing one arrow (shadow + gold) from `fp` to `tp`.
+fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
+ let (x1, y1) = fp;
+ let (x2, y2) = tp;
+ let dx = x2 - x1;
+ let dy = y2 - y1;
+ let len = (dx * dx + dy * dy).sqrt();
+ if len < 10.0 {
+ return view! { }.into_any();
+ }
+ let nx = dx / len;
+ let ny = dy / len;
+ let px = -ny;
+ let py = nx;
+
+ // Shrink line ends so arrows don't overlap the checker stack
+ let lx1 = x1 + nx * 20.0;
+ let ly1 = y1 + ny * 20.0;
+ let lx2 = x2 - nx * 15.0;
+ let ly2 = y2 - ny * 15.0;
+
+ // Arrowhead triangle at (x2, y2)
+ let ah = 15.0_f32;
+ let aw = 7.0_f32;
+ let bx = x2 - nx * ah;
+ let bary = y2 - ny * ah;
+ let pts = format!(
+ "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
+ x2,
+ y2,
+ bx + px * aw,
+ bary + py * aw,
+ bx - px * aw,
+ bary - py * aw,
+ );
+ let shadow_pts = format!(
+ "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
+ x2,
+ y2,
+ bx + px * (aw + 1.5),
+ bary + py * (aw + 1.5),
+ bx - px * (aw + 1.5),
+ bary - py * (aw + 1.5),
+ );
+
+ view! {
+
+ // Drop-shadow for readability on coloured fields
+
+
+ // Gold arrow
+
+
+
+ }
+ .into_any()
+}
+
+/// Valid destinations for a selected origin given already-staged moves.
+/// May include 0 (exit); callers handle that case.
+fn valid_dests_for(
+ seqs: &[(CheckerMove, CheckerMove)],
+ staged: &[(u8, u8)],
+ origin: u8,
+) -> Vec {
+ let mut v: Vec = match staged.len() {
+ 0 => seqs
+ .iter()
+ .filter(|(m1, _)| m1.get_from() as u8 == origin)
+ .map(|(m1, _)| m1.get_to() as u8)
+ .collect(),
+ 1 => {
+ let (f0, t0) = staged[0];
+ seqs.iter()
+ .filter(|(m1, m2)| {
+ m1.get_from() as u8 == f0
+ && m1.get_to() as u8 == t0
+ && m2.get_from() as u8 == origin
+ })
+ .map(|(_, m2)| m2.get_to() as u8)
+ .collect()
+ }
+ _ => vec![],
+ };
+ v.sort_unstable();
+ v.dedup();
+ v
+}
+
+#[component]
+pub fn Board(
+ view_state: ViewState,
+ player_id: u16,
+ /// Pending origin selection (first click of a move pair).
+ selected_origin: RwSignal>,
+ /// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move.
+ staged_moves: RwSignal>,
+ /// All valid two-move sequences for this turn (empty when not in move stage).
+ valid_sequences: Vec<(CheckerMove, CheckerMove)>,
+ /// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
+ #[prop(default = None)]
+ bar_dice: Option<(u8, u8)>,
+ /// Whether we're in the move stage (determines used/unused die appearance).
+ #[prop(default = false)]
+ bar_is_move: bool,
+ #[prop(default = false)] is_my_turn: bool,
+ /// Whether the dice are a double (golden glow).
+ #[prop(default = false)]
+ bar_is_double: bool,
+ /// Checker moves to animate on mount (None when board unchanged).
+ #[prop(default = None)]
+ last_moves: Option<(CheckerMove, CheckerMove)>,
+ /// Fields where a hit (battue) was scored this turn — show ripple animation.
+ #[prop(default = vec![])]
+ hit_fields: Vec,
+) -> impl IntoView {
+ let board = view_state.board;
+ let is_move_stage = view_state.active_mp_player == Some(player_id)
+ && matches!(
+ view_state.turn_stage,
+ SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
+ );
+ let is_white = player_id == 0;
+ let hovered_moves = use_context::>>();
+
+ // Exit-eligible (§8c): all the player's checkers are in their last jan.
+ // White last jan = fields 19-24 (board indices 18-23, positive values).
+ // Black last jan = fields 1-6 (board indices 0-5, negative values).
+ let board_snapshot = view_state.board;
+ let all_in_exit: bool;
+ let exit_field_test: fn(u8) -> bool;
+ if is_white {
+ let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum();
+ let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum();
+ all_in_exit = total > 0 && in_exit == total;
+ exit_field_test = |f| matches!(f, 19..=24);
+ } else {
+ let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum();
+ let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum();
+ all_in_exit = total > 0 && in_exit == total;
+ exit_field_test = |f| matches!(f, 1..=6);
+ }
+
+ // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc).
+ let fields_from = |nums: &[u8], is_top_row: bool| -> Vec {
+ nums.iter()
+ .map(|&field_num| {
+ // Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)>
+ // is Send, which Leptos requires for reactive attribute functions.
+ let seqs_c = valid_sequences.clone();
+ let seqs_k = valid_sequences.clone();
+ let corner_title = if is_rest_corner(field_num, is_white) {
+ Some("Coin de repos — must enter and leave with 2 checkers")
+ } else {
+ None
+ };
+ // §4a — slide delta for the arriving checker at this field.
+ // Computed once per field at render time; Option<(f32,f32)> is Copy.
+ let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| {
+ [m1, m2].iter().find_map(|m| {
+ if m.get_to() != field_num as usize || m.get_from() == m.get_to() {
+ return None;
+ }
+ let (fx, fy) = field_center(m.get_from(), is_white)?;
+ let (tx, ty) = field_center(m.get_to(), is_white)?;
+ let dx = fx - tx;
+ let dy = fy - ty;
+ (dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
+ })
+ });
+ // §6e — ripple on hit fields (battue).
+ let is_hit_field = hit_fields.contains(&field_num);
+ view! {
+
+ }
+ .into_any()
+ })
+ .collect()
+ };
+
+ // ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
+ let bar_content = move |die_idx: u8| -> AnyView {
+ match bar_dice {
+ None => view! {
}.into_any(),
+ Some(dice_vals) => {
+ let die_val = if die_idx == 0 {
+ dice_vals.0
+ } else {
+ dice_vals.1
+ };
+ view! {
+
+ {move || {
+ let staged = staged_moves.get();
+ let (u0, u1) = if bar_is_move {
+ bar_matched_dice_used(&staged, dice_vals)
+ } else if is_my_turn {
+ (true, true)
+ } else {
+ (false, false)
+ };
+ let used = if die_idx == 0 { u0 } else { u1 };
+ view! { }
+ }}
+
+ }
+ .into_any()
+ }
+ }
+ };
+
+ let (tl, tr, bl, br) = if is_white {
+ (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
+ } else {
+ (&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
+ };
+
+ // Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
+ let (label_tl, label_tr, label_bl, label_br) = if is_white {
+ ("", "jan de retour", "grand jan", "petit jan")
+ } else {
+ ("petit jan", "grand jan", "jan de retour", "")
+ };
+
+ view! {
+ // board-wrapper keeps zone labels outside .board so the SVG overlay
+ // inside .board stays correctly positioned (position:absolute top:0 left:0
+ // is relative to .board, not the wrapper).
+
+
+
{label_tl}
+
+
{label_tr}
+
+
+
+
{fields_from(tl, true)}
+
{bar_content(0)}
+
{fields_from(tr, true)}
+
+
+
+
{fields_from(bl, false)}
+
{bar_content(1)}
+
{fields_from(br, false)}
+
+ // SVG overlay: arrows for hovered jan moves
+
+ {move || {
+ let Some(hm) = hovered_moves else { return vec![]; };
+ let pairs = hm.get();
+ if pairs.is_empty() { return vec![]; }
+ // Collect unique individual (from, to) moves; skip empty/exit.
+ let mut moves: Vec<(usize, usize)> = pairs.iter()
+ .flat_map(|(m1, m2)| [
+ (m1.get_from(), m1.get_to()),
+ (m2.get_from(), m2.get_to()),
+ ])
+ .filter(|&(f, t)| f != 0 && t != 0)
+ .collect();
+ moves.sort_unstable();
+ moves.dedup();
+ moves.into_iter()
+ .filter_map(|(from, to)| {
+ let p1 = field_center(from, is_white)?;
+ let p2 = field_center(to, is_white)?;
+ Some(arrow_svg(p1, p2))
+ })
+ .collect()
+ }}
+
+
+
+
{label_bl}
+
+
{label_br}
+
+
+ }
+}
diff --git a/clients/web/src/game/components/connecting_screen.rs b/clients/web/src/game/components/connecting_screen.rs
new file mode 100644
index 0000000..6f40da5
--- /dev/null
+++ b/clients/web/src/game/components/connecting_screen.rs
@@ -0,0 +1,9 @@
+use leptos::prelude::*;
+
+use crate::i18n::*;
+
+#[component]
+pub fn ConnectingScreen() -> impl IntoView {
+ let i18n = use_i18n();
+ view! { {t!(i18n, connecting)}
}
+}
diff --git a/clients/web/src/game/components/die.rs b/clients/web/src/game/components/die.rs
new file mode 100644
index 0000000..7576280
--- /dev/null
+++ b/clients/web/src/game/components/die.rs
@@ -0,0 +1,53 @@
+use leptos::prelude::*;
+
+/// (cx, cy) positions for dots on a 48×48 die face.
+fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] {
+ match value {
+ 1 => &[("24", "24")],
+ 2 => &[("35", "13"), ("13", "35")],
+ 3 => &[("35", "13"), ("24", "24"), ("13", "35")],
+ 4 => &[("13", "13"), ("35", "13"), ("13", "35"), ("35", "35")],
+ 5 => &[("13", "13"), ("35", "13"), ("24", "24"), ("13", "35"), ("35", "35")],
+ 6 => &[("13", "13"), ("35", "13"), ("13", "24"), ("35", "24"), ("13", "35"), ("35", "35")],
+ _ => &[],
+ }
+}
+
+/// A single die face rendered as SVG.
+/// `value` 1–6 shows dots; 0 shows an empty face (not-yet-rolled).
+/// `used` dims the die.
+/// `is_double` applies a golden glow (both dice same value).
+#[component]
+pub fn Die(
+ value: u8,
+ used: bool,
+ #[prop(default = false)] is_double: bool,
+) -> AnyView {
+ let mut cls = if used {
+ "die-face die-used".to_string()
+ } else {
+ "die-face".to_string()
+ };
+ if is_double && !used {
+ cls.push_str(" die-double");
+ }
+ if value == 0 {
+ return view! {
+
+
+ {"?"}
+
+ }.into_any();
+ }
+ let dots: Vec = dot_positions(value)
+ .iter()
+ .map(|&(cx, cy)| view! { }.into_any())
+ .collect();
+ view! {
+
+
+ {dots}
+
+ }.into_any()
+}
diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs
new file mode 100644
index 0000000..3806a41
--- /dev/null
+++ b/clients/web/src/game/components/game_screen.rs
@@ -0,0 +1,470 @@
+use std::cell::Cell;
+use std::collections::VecDeque;
+
+use futures::channel::mpsc::UnboundedSender;
+use leptos::prelude::*;
+use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
+
+use super::die::Die;
+use crate::app::{GameUiState, NetCommand, PauseReason};
+use crate::i18n::*;
+use crate::game::trictrac::types::{PlayerAction, PreGameRollState, 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 auth_username =
+ use_context::>>().unwrap_or_else(|| RwSignal::new(None));
+ let vs = state.view_state.clone();
+ let player_id = state.player_id;
+ let is_my_turn = vs.active_mp_player == Some(player_id);
+ let is_move_stage = is_my_turn
+ && matches!(
+ vs.turn_stage,
+ SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
+ );
+ let waiting_for_confirm = state.waiting_for_confirm;
+ let pause_reason = state.pause_reason.clone();
+
+ // ── Hovered jan moves (shown as arrows on the board) ──────────────────────
+ let hovered_jan_moves: RwSignal> = RwSignal::new(vec![]);
+ provide_context(hovered_jan_moves);
+
+ // ── Staged move state ──────────────────────────────────────────────────────
+ let selected_origin: RwSignal> = RwSignal::new(None);
+ let staged_moves: RwSignal> = RwSignal::new(Vec::new());
+
+ let cmd_tx = use_context::>()
+ .expect("UnboundedSender not found in context");
+ let pending =
+ use_context::>>().expect("pending not found in context");
+ let cmd_tx_effect = cmd_tx.clone();
+ // Non-reactive counter so we can detect when staged_moves grows without
+ // returning a value from the Effect (which causes a Leptos reactive loop
+ // when the Effect also writes to the same signal it reads).
+ let prev_staged_len = Cell::new(0usize);
+
+ Effect::new(move |_| {
+ let moves = staged_moves.get();
+ let n = moves.len();
+ // Play checker sound whenever a move is added (own moves, immediate feedback).
+ if n > prev_staged_len.get() {
+ crate::game::sound::play_checker_move();
+ }
+ prev_staged_len.set(n);
+ if n == 2 {
+ let to_cm = |&(from, to): &(u8, u8)| {
+ CheckerMove::new(from as usize, to as usize).unwrap_or_default()
+ };
+ cmd_tx_effect
+ .unbounded_send(NetCommand::Action(PlayerAction::Move(
+ to_cm(&moves[0]),
+ to_cm(&moves[1]),
+ )))
+ .ok();
+ staged_moves.set(vec![]);
+ selected_origin.set(None);
+ // Reset the counter so the next turn starts clean.
+ prev_staged_len.set(0);
+ }
+ });
+
+ // ── Auto-roll effect ─────────────────────────────────────────────────────
+ // GameScreen is fully re-mounted on every ViewState update (state is a
+ // plain prop, not a signal), so this effect fires exactly once per
+ // RollDice phase entry and will not double-send.
+ // Guard: suppressed while waiting_for_confirm — the AfterOpponentMove
+ // buffered state shows the human's RollDice turn but the auto-roll must
+ // wait until the buffer is drained and the live screen state is shown.
+ // Guard: never auto-roll during the pre-game ceremony (the ceremony overlay
+ // has its own Roll button for PlayerAction::PreGameRoll).
+ let show_roll =
+ is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll;
+ if show_roll && !waiting_for_confirm {
+ let cmd_tx_auto = cmd_tx.clone();
+ Effect::new(move |_| {
+ cmd_tx_auto
+ .unbounded_send(NetCommand::Action(PlayerAction::Roll))
+ .ok();
+ });
+ }
+
+ let dice = vs.dice;
+ let show_dice = dice != (0, 0);
+
+ // ── Button senders ─────────────────────────────────────────────────────────
+ let cmd_tx_go = cmd_tx.clone();
+ let cmd_tx_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();
+
+ // ── Ceremony state (extracted before vs is moved into Board) ────────────────
+ let is_ceremony = vs.stage == SerStage::PreGameRoll;
+ let pre_game_roll_data: Option = 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 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 = {
+ let is_hit_jan = |jan: &Jan| {
+ matches!(
+ jan,
+ Jan::TrueHitSmallJan
+ | Jan::TrueHitBigJan
+ | Jan::TrueHitOpponentCorner
+ | Jan::FalseHitSmallJan
+ | Jan::FalseHitBigJan
+ )
+ };
+ let mut fields: Vec = 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::game::sound::play_dice_roll();
+ }
+ // Checker move: moves were committed in the preceding action.
+ if last_moves.is_some() {
+ crate::game::sound::play_checker_move();
+ }
+ // Scoring: hole takes priority over plain points.
+ if let Some(ref ev) = my_scored_event {
+ if ev.holes_gained > 0 {
+ crate::game::sound::play_hole_scored();
+ } else {
+ crate::game::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! {
+
+ // ── Top bar ──────────────────────────────────────────────────────
+
+
{move || if is_bot_game {
+ t_string!(i18n, vs_bot_label).to_owned()
+ } else {
+ t_string!(i18n, room_label, id = room_id.as_str())
+ }}
+
+ "EN"
+ "FR"
+
+
+ {move || auth_username.get().map(|u| view! {
+
"▶ " {u}
+ })}
+
+
{t!(i18n, quit)}
+
+
+ // ── Opponent score (above board) ─────────────────────────────────
+
+
+ // ── Status bar — full width, above board (§10b) ──────────────────
+
+ {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),
+ })
+ }
+ }}
+
+
+ // ── 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! {
{hint}
})
+ }}
+
+ // ── Board + side panel ───────────────────────────────────────────
+
+
+
+ // ── Side panel (scoring panels only) ─────────────────────────
+
+ {my_scored_event.map(|event| view! {
+
+ })}
+ {opp_scored_event.map(|event| view! {
+
+ })}
+
+
+
+ // ── Action buttons below board (§10c) ────────────────────────────
+
+ {waiting_for_confirm.then(|| view! {
+ {t!(i18n, continue_btn)}
+ })}
+ // Fallback Go button when no scoring panel (e.g. after reconnect)
+ {show_hold_go.then(|| view! {
+ {t!(i18n, go)}
+ })}
+ {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! {
+ {t!(i18n, empty_move)}
+ })
+ }}
+
+
+ // ── Player score (below board) ────────────────────────────────────
+
+
+ // ── 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,
+ });
+ 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;
+ view! {
+
+
+
{t!(i18n, pre_game_roll_title)}
+ {show_tie.then(|| view! {
+
{t!(i18n, pre_game_roll_tie)}
+ })}
+
+
+ {my_name_ceremony}{t!(i18n, you_suffix)}
+
+
+
+ {opp_name_ceremony}
+
+
+
+ {waiting_for_confirm.then(|| {
+ let pending_c = pending;
+ view! {
+
{t!(i18n, continue_btn)}
+ }
+ })}
+ {can_roll.then(|| {
+ let cmd_tx_c = cmd_tx_ceremony.clone();
+ view! {
+
{t!(i18n, pre_game_roll_btn)}
+ }
+ })}
+
+
+ }
+ })}
+
+ // ── 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! {
+
+
+
{t!(i18n, game_over)}
+
{winner_text}
+
+ {my_name_end}
+
+ {format!("{my_holes_end} — {opp_holes_end}")}
+
+ {opp_name_end.clone()}
+
+
+ {t!(i18n, quit)}
+ {is_bot_game.then(|| view! {
+ {t!(i18n, play_again)}
+ })}
+
+
+
+ }
+ })}
+
+ // ── Hole toast (§6a) — board-centered overlay when a hole is won ──
+ {hole_toast_info.map(|(holes_total, bredouille)| view! {
+
+
"Trou !"
+
{format!("{holes_total} / 12")}
+ {bredouille.then(|| view! {
+
"× 2 bredouille"
+ })}
+
+ })}
+
+ }
+}
diff --git a/clients/web/src/game/components/mod.rs b/clients/web/src/game/components/mod.rs
new file mode 100644
index 0000000..4c48cbd
--- /dev/null
+++ b/clients/web/src/game/components/mod.rs
@@ -0,0 +1,9 @@
+mod board;
+mod connecting_screen;
+mod die;
+mod game_screen;
+mod score_panel;
+mod scoring;
+
+pub use connecting_screen::ConnectingScreen;
+pub use game_screen::GameScreen;
diff --git a/clients/web/src/game/components/score_panel.rs b/clients/web/src/game/components/score_panel.rs
new file mode 100644
index 0000000..2ce14ef
--- /dev/null
+++ b/clients/web/src/game/components/score_panel.rs
@@ -0,0 +1,70 @@
+use leptos::prelude::*;
+use trictrac_store::Jan;
+
+use crate::i18n::*;
+use crate::game::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 = (1u8..=12)
+ .map(|i| {
+ let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" };
+ view! {
}.into_any()
+ })
+ .collect();
+
+ view! {
+
+
+
+
+
{t!(i18n, points_label)}
+
+
{points_val}
+ {can_bredouille.then(|| view! {
+
"B"
+ })}
+
+
+
{t!(i18n, holes_label)}
+
{pegs}
+
{format!("{holes}/12")}
+
+
+
+ }
+}
diff --git a/clients/web/src/game/components/scoring.rs b/clients/web/src/game/components/scoring.rs
new file mode 100644
index 0000000..d1966be
--- /dev/null
+++ b/clients/web/src/game/components/scoring.rs
@@ -0,0 +1,209 @@
+use futures::channel::mpsc::UnboundedSender;
+#[cfg(target_arch = "wasm32")]
+use gloo_timers::future::TimeoutFuture;
+use leptos::prelude::*;
+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::i18n::*;
+use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
+
+use super::score_panel::jan_label;
+
+/// One row in the scoring panel. Sets the hovered-moves context on enter
+/// (so board shows arrows for that jan's moves), but does NOT clear on
+/// leave — clearing is handled by the outer wrapper's mouseleave so that
+/// arrows persist while the pointer moves between rows.
+fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
+ let i18n = use_i18n();
+ let hovered = use_context::>>();
+ let jan = entry.jan;
+ let is_double = entry.is_double;
+ let ways_tag = format!("×{}", entry.ways);
+ let pts_str = format!("+{}", entry.total);
+ let moves_hover = entry.moves.clone();
+
+ view! {
+
+ {move || jan_label(&jan)}
+ {move || if is_double {
+ t_string!(i18n, jan_double).to_owned()
+ } else {
+ t_string!(i18n, jan_simple).to_owned()
+ }}
+ {ways_tag}
+ {pts_str}
+
+ }
+}
+
+#[component]
+pub fn ScoringPanel(
+ event: ScoredEvent,
+ turn_stage: SerTurnStage,
+ #[prop(default = false)] is_opponent: bool,
+) -> impl IntoView {
+ let i18n = use_i18n();
+ let cmd_tx = use_context::>()
+ .expect("UnboundedSender not found in context");
+
+ let points_earned = event.points_earned;
+ let holes_gained = event.holes_gained;
+ let holes_total = event.holes_total;
+ let bredouille = event.bredouille;
+ let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
+ let panel_class = if is_opponent {
+ "scoring-panel scoring-panel-opp"
+ } else {
+ "scoring-panel"
+ };
+
+ // ── 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);
+
+ // ── 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_enter = all_moves.clone();
+
+ let hovered_ctx = use_context::>>();
+
+ // On mount: show all this event's moves as board arrows immediately,
+ // then after 3.4 s slide to peek and clear the arrows.
+ //
+ // Two important constraints:
+ // 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
+ // to avoid writing a reactive signal mid-render while Board reads it —
+ // that triggers Leptos's cycle guard → `unreachable` WASM panic.
+ // 2. The cancellation flag must be Rc>, NOT RwSignal.
+ // RwSignal is a NodeId into Leptos's arena; the arena slot is freed
+ // when ScoringPanel's owner drops (on every GameScreen remount). If the
+ // 3.4 s future outlives the component and calls is_alive.get_untracked()
+ // on a freed slot, that also panics with `unreachable`. Rc>
+ // is reference-counted outside the arena and stays valid for as long as
+ // the future holds onto it.
+ #[cfg(target_arch = "wasm32")]
+ if let Some(hm) = hovered_ctx {
+ let is_alive = Arc::new(AtomicBool::new(true));
+ let is_alive_cleanup = is_alive.clone();
+ // on_cleanup requires Send + Sync; Arc satisfies both.
+ on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
+
+ spawn_local(async move {
+ // Show arrows (runs in the next microtask, after render settles).
+ hm.set(all_moves);
+
+ TimeoutFuture::new(3_400).await;
+ // Guard: component may have been destroyed while we were waiting.
+ // is_alive was set to false by on_cleanup, which runs before Leptos
+ // frees the signal arena slots — so peeked is still valid iff this
+ // returns true.
+ if !is_alive.load(Ordering::Relaxed) {
+ return;
+ }
+ hm.set(vec![]);
+ 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).
+
+
+
+ {move || if is_opponent {
+ t_string!(i18n, opp_scored_pts, n = points_earned)
+ } else {
+ t_string!(i18n, scored_pts, n = points_earned)
+ }}
+
+ {jan_rows}
+ {(holes_gained > 0).then(|| view! {
+
+ {move || if is_opponent {
+ t_string!(i18n, opp_hole_made, holes = holes_total)
+ } else {
+ t_string!(i18n, hole_made, holes = holes_total)
+ }}
+ {bredouille.then(|| view! {
+
+ {move || t_string!(i18n, bredouille_applied)}
+
+ })}
+
+ })}
+ {show_hold_go.then(|| {
+ let dismissed = RwSignal::new(false);
+ view! {
+
+ // stop_propagation so these buttons don't also toggle the panel
+
+ {t!(i18n, hold)}
+
+
+ {t!(i18n, go)}
+
+
+ }
+ })}
+
+
+ }
+}
diff --git a/clients/web/src/game/mod.rs b/clients/web/src/game/mod.rs
new file mode 100644
index 0000000..c92b6f2
--- /dev/null
+++ b/clients/web/src/game/mod.rs
@@ -0,0 +1,4 @@
+pub mod components;
+pub mod session;
+pub mod sound;
+pub mod trictrac;
diff --git a/clients/web/src/game/session.rs b/clients/web/src/game/session.rs
new file mode 100644
index 0000000..e648d01
--- /dev/null
+++ b/clients/web/src/game/session.rs
@@ -0,0 +1,253 @@
+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,
+ cmd_rx: &mut mpsc::UnboundedReceiver,
+ pending: RwSignal>,
+) -> 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,
+ }));
+
+ 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);
+ }
+ }
+ 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),
+ }));
+ }
+ 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);
+ 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),
+ },
+ pending,
+ screen,
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// 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 {
+ 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 = 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>,
+ screen: RwSignal,
+) {
+ 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,
+ ..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 {
+ 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
+}
diff --git a/clients/web/src/game/sound.rs b/clients/web/src/game/sound.rs
new file mode 100644
index 0000000..5637ccd
--- /dev/null
+++ b/clients/web/src/game/sound.rs
@@ -0,0 +1,182 @@
+//! Synthesised sound effects using the Web Audio API.
+//!
+//! All public functions are no-ops on non-WASM targets so callers need no
+//! `#[cfg]` guards themselves.
+
+#[cfg(target_arch = "wasm32")]
+mod inner {
+ use std::cell::RefCell;
+ use web_sys::{AudioContext, OscillatorType};
+
+ thread_local! {
+ static CTX: RefCell> = const { RefCell::new(None) };
+ }
+
+ fn with_ctx(f: F) {
+ CTX.with(|cell| {
+ let mut opt = cell.borrow_mut();
+ if opt.is_none() {
+ *opt = AudioContext::new().ok();
+ }
+ if let Some(ctx) = opt.as_ref() {
+ f(ctx);
+ }
+ });
+ }
+
+ /// Schedule a single oscillator tone with an exponential gain decay.
+ ///
+ /// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
+ /// - `duration`: how long (in seconds) until gain reaches ~0
+ fn play_tone(
+ ctx: &AudioContext,
+ freq: f32,
+ gain: f32,
+ duration: f64,
+ start_offset: f64,
+ wave: OscillatorType,
+ ) {
+ let t0 = ctx.current_time() + start_offset;
+ let t1 = t0 + duration;
+
+ let Ok(osc) = ctx.create_oscillator() else {
+ return;
+ };
+ let Ok(gain_node) = ctx.create_gain() else {
+ return;
+ };
+
+ osc.set_type(wave);
+ osc.frequency().set_value(freq);
+
+ let gain_param = gain_node.gain();
+ let _ = gain_param.set_value_at_time(gain, t0);
+ // exponential_ramp requires a positive target; 0.001 is inaudible
+ let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
+
+ let dest = ctx.destination();
+ let _ = osc.connect_with_audio_node(&gain_node);
+ let _ = gain_node.connect_with_audio_node(&dest);
+
+ let _ = osc.start_with_when(t0);
+ let _ = osc.stop_with_when(t1);
+ }
+
+ /// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
+ pub fn play_checker_move() {
+ with_ctx(|ctx| {
+ // Sine at 300 Hz for the clean attack click
+ play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
+ // Triangle at 150 Hz for the woody body resonance
+ play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
+ // Sub at 80 Hz for weight
+ play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
+ });
+ }
+
+ /// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
+ ///
+ /// Two layers:
+ /// - A dense series of detuned sawtooth bursts that thin out over time,
+ /// modelling the continuous scrape/rattle of dice tumbling.
+ /// - Five percussive impacts (square clicks + triangle thuds) whose
+ /// inter-arrival gap shrinks as the dice decelerate and settle.
+ pub fn play_dice_roll_cinematic() {
+ with_ctx(|ctx| {
+ // ── Continuous rolling texture ─────────────────────────────────
+ // 16 steps over 440 ms; each step is two detuned sawtooth waves
+ // (the interference between them produces a noise-like texture).
+ // Gain fades by ~55 % from first to last step.
+ const N: u32 = 16;
+ for i in 0..N {
+ let t = i as f64 * 0.028;
+ let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
+ // Quasi-random frequencies so each step sounds different.
+ let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
+ let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
+ play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
+ play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
+ }
+
+ // ── Impact transients ──────────────────────────────────────────
+ // Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
+ // mimicking dice decelerating and settling.
+ let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
+ for &(t_off, amp) in impacts {
+ // Hard click: bright square partials → percussive attack
+ for &freq in &[700.0f32, 1_050.0, 1_500.0] {
+ play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
+ }
+ // Woody body thud: two low triangle partials
+ play_tone(
+ ctx,
+ 130.0,
+ amp * 0.05,
+ 0.070,
+ t_off,
+ OscillatorType::Triangle,
+ );
+ play_tone(
+ ctx,
+ 68.0,
+ amp * 0.07,
+ 0.090,
+ t_off,
+ OscillatorType::Triangle,
+ );
+ }
+ });
+ }
+
+ /// Play the pre-recorded dice-roll MP3 asset.
+ pub fn play_dice_roll() {
+ if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
+ audio.set_volume(0.2);
+ let _ = audio.play();
+ }
+ }
+
+ /// Ascending three-note chime (C5 – E5 – G5).
+ pub fn play_points_scored() {
+ with_ctx(|ctx| {
+ let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
+ for (freq, offset) in notes {
+ play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
+ }
+ });
+ }
+
+ /// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
+ pub fn play_hole_scored() {
+ with_ctx(|ctx| {
+ let notes: [(f32, f64, f64); 4] = [
+ (523.25, 0.0, 0.35),
+ (659.25, 0.17, 0.35),
+ (783.99, 0.34, 0.35),
+ (1046.5, 0.51, 0.55),
+ ];
+ for (freq, offset, dur) in notes {
+ play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
+ }
+ });
+ }
+}
+
+// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
+
+#[cfg(target_arch = "wasm32")]
+pub use inner::{
+ play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
+ play_points_scored,
+};
+
+#[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_hole_scored() {}
diff --git a/clients/web/src/game/trictrac/backend.rs b/clients/web/src/game/trictrac/backend.rs
new file mode 100644
index 0000000..ca28204
--- /dev/null
+++ b/clients/web/src/game/trictrac/backend.rs
@@ -0,0 +1,487 @@
+use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
+use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
+
+use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
+
+// Store PlayerId (u64) values used for the two players.
+const HOST_PLAYER_ID: u64 = 1;
+const GUEST_PLAYER_ID: u64 = 2;
+
+pub struct TrictracBackend {
+ game: GameState,
+ dice_roller: DiceRoller,
+ commands: Vec>,
+ 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; 2],
+ /// Number of tied rounds so far.
+ tie_count: u8,
+ /// True while the first-player ceremony is running.
+ ceremony_started: bool,
+}
+
+impl TrictracBackend {
+ fn sync_view_state(&mut self) {
+ let mut vs = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
+ if self.ceremony_started {
+ vs.stage = SerStage::PreGameRoll;
+ vs.pre_game_roll = Some(PreGameRollState {
+ host_die: self.pre_game_dice[0],
+ guest_die: self.pre_game_dice[1],
+ tie_count: self.tie_count,
+ });
+ // Both players roll independently; no single "active" player.
+ vs.active_mp_player = None;
+ }
+ self.view_state = vs;
+ }
+
+ fn broadcast_state(&mut self) {
+ self.sync_view_state();
+ let delta = GameDelta {
+ state: self.view_state.clone(),
+ };
+ 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();
+ let player_id = self.game.active_player_id;
+ let _ = self.game.consume(&GameEvent::Roll { player_id });
+ let _ = self
+ .game
+ .consume(&GameEvent::RollResult { player_id, dice });
+
+ // Drive automatic stages that require no player input.
+ self.drive_automatic_stages();
+ }
+
+ /// Advance through stages that can be resolved without player input
+ /// (MarkPoints, MarkAdvPoints).
+ fn drive_automatic_stages(&mut self) {
+ loop {
+ // Stop if the game has already ended (stage transitions to Ended but
+ // turn_stage may still be MarkPoints when schools_enabled=false, which
+ // makes consume(Mark) a no-op and would cause an infinite loop).
+ if self.game.stage == trictrac_store::Stage::Ended {
+ break;
+ }
+ let player_id = self.game.active_player_id;
+ match self.game.turn_stage {
+ TurnStage::MarkPoints | TurnStage::MarkAdvPoints => {
+ let _ = self.game.consume(&GameEvent::Mark {
+ player_id,
+ points: self.game.dice_points.0.max(self.game.dice_points.1),
+ });
+ }
+ _ => break,
+ }
+ }
+ }
+}
+
+impl TrictracBackend {
+ pub fn get_game(&self) -> &GameState {
+ &self.game
+ }
+}
+
+impl BackEndArchitecture for TrictracBackend {
+ fn new(_rule_variation: u16) -> Self {
+ let mut game = GameState::new(false);
+ game.init_player("Blancs");
+ game.init_player("Noirs");
+
+ let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
+
+ TrictracBackend {
+ game,
+ dice_roller: DiceRoller::default(),
+ commands: Vec::new(),
+ view_state,
+ arrived: [false; 2],
+ pre_game_dice: [None; 2],
+ tie_count: 0,
+ ceremony_started: false,
+ }
+ }
+
+ fn from_bytes(_rule_variation: u16, bytes: &[u8]) -> Option {
+ let view_state: ViewState = serde_json::from_slice(bytes).ok()?;
+ // Reconstruct a fresh game; full state restore is not yet implemented.
+ let mut backend = Self::new(_rule_variation);
+ backend.view_state = view_state;
+ Some(backend)
+ }
+
+ fn player_arrival(&mut self, mp_player: u16) {
+ if mp_player > 1 {
+ self.commands
+ .push(BackendCommand::KickPlayer { player: mp_player });
+ return;
+ }
+ self.arrived[mp_player as usize] = true;
+
+ // Cancel any reconnect timer for this player.
+ self.commands.push(BackendCommand::CancelTimer {
+ timer_id: mp_player,
+ });
+
+ // Start the ceremony once both players have arrived.
+ if self.arrived[0]
+ && self.arrived[1]
+ && self.game.stage == trictrac_store::Stage::PreGame
+ && !self.ceremony_started
+ {
+ self.ceremony_started = true;
+ self.pre_game_dice = [None; 2];
+ self.tie_count = 0;
+ self.sync_view_state();
+ self.commands.push(BackendCommand::ResetViewState);
+ } else {
+ self.broadcast_state();
+ }
+ }
+
+ fn player_departure(&mut self, mp_player: u16) {
+ if mp_player > 1 {
+ return;
+ }
+ self.arrived[mp_player as usize] = false;
+ // Give 60 seconds to reconnect before terminating the room.
+ self.commands.push(BackendCommand::SetTimer {
+ timer_id: mp_player,
+ duration: 60.0,
+ });
+ }
+
+ fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
+ // 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;
+ }
+
+ let store_id = if mp_player == 0 {
+ HOST_PLAYER_ID
+ } else {
+ GUEST_PLAYER_ID
+ };
+
+ // Only the active player may act (except during Chance-like waiting stages).
+ if self.game.active_player_id != store_id {
+ return;
+ }
+
+ match action {
+ PlayerAction::Roll => {
+ if self.game.turn_stage == TurnStage::RollDice {
+ self.do_roll();
+ }
+ }
+ PlayerAction::Move(m1, m2) => {
+ if self.game.turn_stage != TurnStage::Move
+ && self.game.turn_stage != TurnStage::HoldOrGoChoice
+ {
+ return;
+ }
+ let event = GameEvent::Move {
+ player_id: store_id,
+ moves: (m1, m2),
+ };
+ if self.game.validate(&event) {
+ let _ = self.game.consume(&event);
+ self.drive_automatic_stages();
+ }
+ }
+ PlayerAction::Go => {
+ if self.game.turn_stage == TurnStage::HoldOrGoChoice {
+ let _ = self.game.consume(&GameEvent::Go {
+ player_id: store_id,
+ });
+ }
+ }
+ PlayerAction::Mark => {
+ if matches!(
+ self.game.turn_stage,
+ TurnStage::MarkPoints | TurnStage::MarkAdvPoints
+ ) {
+ self.drive_automatic_stages();
+ }
+ }
+ PlayerAction::PreGameRoll => {} // ignored outside ceremony
+ }
+
+ self.broadcast_state();
+ }
+
+ fn timer_triggered(&mut self, timer_id: u16) {
+ match timer_id {
+ 0 | 1 => {
+ // Reconnect grace period expired for host (0) or guest (1).
+ self.commands.push(BackendCommand::TerminateRoom);
+ }
+ _ => {}
+ }
+ }
+
+ fn get_view_state(&self) -> &ViewState {
+ &self.view_state
+ }
+
+ fn drain_commands(&mut self) -> Vec> {
+ std::mem::take(&mut self.commands)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use super::types::{SerStage, SerTurnStage};
+ use backbone_lib::traits::BackEndArchitecture;
+
+ fn make_backend() -> TrictracBackend {
+ TrictracBackend::new(0)
+ }
+
+ /// Helper: drain and return only Delta commands, extracting their ViewStates.
+ fn drain_deltas(b: &mut TrictracBackend) -> Vec {
+ b.drain_commands()
+ .into_iter()
+ .filter_map(|cmd| match cmd {
+ BackendCommand::Delta(d) => Some(d.state),
+ BackendCommand::ResetViewState => Some(b.view_state.clone()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ /// Drive the ceremony to completion (both players roll until one wins).
+ fn complete_ceremony(b: &mut TrictracBackend) {
+ loop {
+ if b.get_view_state().stage != SerStage::PreGameRoll {
+ break;
+ }
+ let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default();
+ let host_needs = pgr.host_die.is_none();
+ let guest_needs = pgr.guest_die.is_none();
+ if !host_needs && !guest_needs {
+ break; // both rolled but stage not yet resolved — shouldn't happen
+ }
+ if host_needs {
+ b.inform_rpc(0, PlayerAction::PreGameRoll);
+ }
+ if guest_needs {
+ b.inform_rpc(1, PlayerAction::PreGameRoll);
+ }
+ b.drain_commands();
+ }
+ }
+
+ #[test]
+ fn both_players_arrive_starts_ceremony() {
+ let mut b = make_backend();
+ b.player_arrival(0); // host
+ b.drain_commands();
+ b.player_arrival(1); // guest
+ let cmds = b.drain_commands();
+
+ // ResetViewState should have been issued to start the ceremony.
+ let has_reset = cmds
+ .iter()
+ .any(|c| matches!(c, BackendCommand::ResetViewState));
+ assert!(
+ has_reset,
+ "expected ResetViewState after both players arrive"
+ );
+
+ // Stage should now be PreGameRoll, not InGame.
+ assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll);
+ }
+
+ #[test]
+ fn ceremony_resolves_to_in_game() {
+ let mut b = make_backend();
+ b.player_arrival(0);
+ b.player_arrival(1);
+ b.drain_commands();
+
+ complete_ceremony(&mut b);
+
+ 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();
+ b.player_arrival(99);
+ let cmds = b.drain_commands();
+ assert!(cmds
+ .iter()
+ .any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 })));
+ }
+
+ #[test]
+ fn roll_advances_to_move_or_hold() {
+ let mut b = make_backend();
+ b.player_arrival(0);
+ b.player_arrival(1);
+ b.drain_commands();
+
+ // Complete ceremony before rolling.
+ complete_ceremony(&mut b);
+
+ // Roll for whoever won the ceremony (either player could go first).
+ let first_player = b
+ .get_view_state()
+ .active_mp_player
+ .expect("someone should be active");
+ b.inform_rpc(first_player, PlayerAction::Roll);
+ let states = drain_deltas(&mut b);
+ assert!(!states.is_empty(), "expected a state broadcast after roll");
+
+ let last = states.last().unwrap();
+ assert!(
+ matches!(
+ last.turn_stage,
+ SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
+ ),
+ "expected Move or HoldOrGoChoice after roll, got {:?}",
+ last.turn_stage
+ );
+ assert_eq!(last.dice, b.get_view_state().dice);
+ assert!(last.dice.0 >= 1 && last.dice.0 <= 6);
+ assert!(last.dice.1 >= 1 && last.dice.1 <= 6);
+ }
+
+ #[test]
+ fn wrong_player_roll_ignored() {
+ let mut b = make_backend();
+ b.player_arrival(0);
+ b.player_arrival(1);
+ b.drain_commands();
+ complete_ceremony(&mut b);
+
+ // Identify who goes first and have the OTHER player try to roll.
+ let active = b.get_view_state().active_mp_player;
+ let wrong_player = if active == Some(0) { 1u16 } else { 0u16 };
+ b.inform_rpc(wrong_player, PlayerAction::Roll);
+ let cmds = b.drain_commands();
+ assert!(cmds.is_empty(), "wrong player roll should be ignored");
+ }
+
+ #[test]
+ fn departure_sets_reconnect_timer() {
+ let mut b = make_backend();
+ b.player_arrival(0);
+ b.drain_commands();
+ b.player_departure(0);
+ let cmds = b.drain_commands();
+ assert!(
+ cmds.iter()
+ .any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })),
+ "expected reconnect timer after host departure"
+ );
+ }
+
+ #[test]
+ fn timer_triggers_terminate_room() {
+ let mut b = make_backend();
+ b.timer_triggered(0);
+ let cmds = b.drain_commands();
+ assert!(cmds
+ .iter()
+ .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) {}
diff --git a/clients/web/src/game/trictrac/bot_local.rs b/clients/web/src/game/trictrac/bot_local.rs
new file mode 100644
index 0000000..5543b07
--- /dev/null
+++ b/clients/web/src/game/trictrac/bot_local.rs
@@ -0,0 +1,43 @@
+use rand::prelude::IndexedRandom;
+use trictrac_store::{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 {
+ // 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![]);
+ 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,
+ }
+}
diff --git a/clients/web/src/game/trictrac/mod.rs b/clients/web/src/game/trictrac/mod.rs
new file mode 100644
index 0000000..38d05bb
--- /dev/null
+++ b/clients/web/src/game/trictrac/mod.rs
@@ -0,0 +1,3 @@
+pub mod backend;
+pub mod bot_local;
+pub mod types;
diff --git a/clients/web/src/game/trictrac/types.rs b/clients/web/src/game/trictrac/types.rs
new file mode 100644
index 0000000..3c0dfe2
--- /dev/null
+++ b/clients/web/src/game/trictrac/types.rs
@@ -0,0 +1,256 @@
+use serde::{Deserialize, Serialize};
+use trictrac_store::{CheckerMove, GameState, Jan, Stage, TurnStage};
+
+// ── Actions sent by a player to the host backend ─────────────────────────────
+
+#[derive(Clone, Serialize, Deserialize)]
+pub enum PlayerAction {
+ /// Active player requests a dice roll.
+ Roll,
+ /// Both checker moves for this turn. Use `EMPTY_MOVE` (from=0, to=0) when a die
+ /// has no valid move.
+ Move(CheckerMove, CheckerMove),
+ /// Choose to "go" (advance) during HoldOrGoChoice.
+ Go,
+ /// Acknowledge point marking (hold / advance points).
+ Mark,
+ /// Roll a single die during the pre-game ceremony to decide who goes first.
+ PreGameRoll,
+}
+
+// ── Incremental state update broadcast to all clients ────────────────────────
+
+/// Carries a full state snapshot; `apply_delta` replaces the local state.
+/// Simple and correct; can be refined to true diffs later.
+#[derive(Clone, Serialize, Deserialize)]
+pub struct GameDelta {
+ pub state: ViewState,
+}
+
+// ── 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 (1–6) rolled by the host; `None` = not yet rolled this round.
+ pub host_die: Option,
+ /// Die value (1–6) rolled by the guest; `None` = not yet rolled this round.
+ pub guest_die: Option,
+ /// 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.
+ pub board: [i8; 24],
+ pub stage: SerStage,
+ pub turn_stage: SerTurnStage,
+ /// Which multiplayer player_id (0 = host, 1 = guest) is the active player.
+ pub active_mp_player: Option,
+ /// Scores indexed by multiplayer player_id (0 = host, 1 = guest).
+ pub scores: [PlayerScore; 2],
+ /// Last rolled dice values.
+ pub dice: (u8, u8),
+ /// Jans (scoring events) triggered by the last dice roll.
+ pub dice_jans: Vec,
+ /// Last two checker moves played; default when no move has occurred yet.
+ pub dice_moves: (CheckerMove, CheckerMove),
+ /// Present while the pre-game ceremony is in progress.
+ #[serde(default)]
+ pub pre_game_roll: Option,
+}
+
+/// One scoring event from a dice roll.
+#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
+pub struct JanEntry {
+ pub jan: Jan,
+ /// True when the dice are doubles (both same value) — changes the point value.
+ /// Special case for HelplessMan: true when *both* dice are unplayable.
+ pub is_double: bool,
+ /// Number of distinct move pairs that produce this jan.
+ pub ways: usize,
+ /// Points per way (negative = scored against the active player).
+ pub points_per: i8,
+ /// Total = points_per × ways.
+ pub total: i8,
+ /// The move pairs that produce this jan (for move display).
+ pub moves: Vec<(CheckerMove, CheckerMove)>,
+}
+
+impl ViewState {
+ pub fn default_with_names(host_name: &str, guest_name: &str) -> Self {
+ ViewState {
+ board: [0i8; 24],
+ stage: SerStage::PreGame,
+ turn_stage: SerTurnStage::RollDice,
+ active_mp_player: None,
+ scores: [
+ PlayerScore {
+ name: host_name.to_string(),
+ points: 0,
+ holes: 0,
+ can_bredouille: false,
+ },
+ PlayerScore {
+ name: guest_name.to_string(),
+ points: 0,
+ holes: 0,
+ can_bredouille: false,
+ },
+ ],
+ dice: (0, 0),
+ dice_jans: Vec::new(),
+ dice_moves: (CheckerMove::default(), CheckerMove::default()),
+ pre_game_roll: None,
+ }
+ }
+
+ pub fn apply_delta(&mut self, delta: &GameDelta) {
+ *self = delta.state.clone();
+ }
+
+ /// Convert a store `GameState` to a `ViewState`.
+ /// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned
+ /// to the host (mp player 0) and guest (mp player 1) respectively.
+ pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self {
+ let board_vec = gs.board.to_vec();
+ let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields");
+
+ let stage = match gs.stage {
+ Stage::PreGame => SerStage::PreGame,
+ Stage::InGame => SerStage::InGame,
+ Stage::Ended => SerStage::Ended,
+ };
+ let turn_stage = match gs.turn_stage {
+ TurnStage::RollDice => SerTurnStage::RollDice,
+ TurnStage::RollWaiting => SerTurnStage::RollWaiting,
+ TurnStage::MarkPoints => SerTurnStage::MarkPoints,
+ TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
+ TurnStage::Move => SerTurnStage::Move,
+ TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
+ };
+
+ let active_mp_player = if gs.active_player_id == host_store_id {
+ Some(0)
+ } else if gs.active_player_id == guest_store_id {
+ Some(1)
+ } else {
+ None
+ };
+
+ let score_for = |store_id: u64| -> PlayerScore {
+ gs.players
+ .get(&store_id)
+ .map(|p| PlayerScore {
+ name: p.name.clone(),
+ points: p.points,
+ holes: p.holes,
+ can_bredouille: p.can_bredouille,
+ })
+ .unwrap_or_else(|| PlayerScore {
+ name: String::new(),
+ points: 0,
+ holes: 0,
+ can_bredouille: false,
+ })
+ };
+
+ // is_double for scoring: dice show the same value (both dice identical).
+ // Exception: HelplessMan uses a special rule (see below).
+ let dice_are_double = gs.dice.values.0 == gs.dice.values.1;
+
+ // Build JanEntry list from the PossibleJans map.
+ let empty_move = CheckerMove::new(0, 0).unwrap_or_default();
+ let mut dice_jans: Vec = gs
+ .dice_jans
+ .iter()
+ .map(|(jan, moves)| {
+ // HelplessMan: is_double = true only when *both* dice are unplayable
+ // (the moves list contains a single (empty, empty) sentinel).
+ let is_double = if *jan == Jan::HelplessMan {
+ moves
+ .first()
+ .map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
+ .unwrap_or(false)
+ } else {
+ dice_are_double
+ };
+ let points_per = jan.get_points(is_double);
+ let ways = moves.len();
+ let total = points_per.saturating_mul(ways as i8);
+ JanEntry {
+ jan: jan.clone(),
+ is_double,
+ ways,
+ points_per,
+ total,
+ moves: moves.clone(),
+ }
+ })
+ .collect();
+ // Sort: highest total first, most-negative last.
+ dice_jans.sort_by_key(|e| std::cmp::Reverse(e.total));
+
+ ViewState {
+ board,
+ stage,
+ turn_stage,
+ active_mp_player,
+ scores: [score_for(host_store_id), score_for(guest_store_id)],
+ dice: (gs.dice.values.0, gs.dice.values.1),
+ dice_jans,
+ dice_moves: gs.dice_moves,
+ pre_game_roll: None,
+ }
+ }
+}
+
+// ── Scored event (notification) ──────────────────────────────────────────
+
+/// Points scored in a single scoring event, used for the notification panel.
+#[derive(Clone, PartialEq)]
+pub struct ScoredEvent {
+ /// Raw points earned (sum of jan values; before hole wrapping).
+ pub points_earned: u8,
+ /// Number of holes gained (0 = no hole).
+ pub holes_gained: u8,
+ /// Total holes after this event.
+ pub holes_total: u8,
+ /// Was bredouille active when the hole was made (doubles hole count)?
+ pub bredouille: bool,
+ /// Contributing jans from this player's perspective (totals always positive).
+ pub jans: Vec,
+}
+
+// ── Score snapshot ────────────────────────────────────────────────────────────
+
+#[derive(Clone, PartialEq, Serialize, Deserialize)]
+pub struct PlayerScore {
+ pub name: String,
+ pub points: u8,
+ pub holes: u8,
+ pub can_bredouille: bool,
+}
+
+// ── Serialisable mirrors of store enums ──────────────────────────────────────
+
+#[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,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum SerTurnStage {
+ RollDice,
+ RollWaiting,
+ MarkPoints,
+ HoldOrGoChoice,
+ Move,
+ MarkAdvPoints,
+}
diff --git a/clients/web/src/main.rs b/clients/web/src/main.rs
new file mode 100644
index 0000000..bfd8adf
--- /dev/null
+++ b/clients/web/src/main.rs
@@ -0,0 +1,14 @@
+leptos_i18n::load_locales!();
+
+mod api;
+mod app;
+mod game;
+mod nav;
+mod portal;
+
+use app::App;
+use leptos::prelude::*;
+
+fn main() {
+ mount_to_body(|| view! { })
+}
diff --git a/clients/web/src/nav.rs b/clients/web/src/nav.rs
new file mode 100644
index 0000000..c5b59fd
--- /dev/null
+++ b/clients/web/src/nav.rs
@@ -0,0 +1,51 @@
+use leptos::prelude::*;
+use leptos::task::spawn_local;
+use leptos_router::components::A;
+
+use crate::api;
+use crate::app::Screen;
+use crate::i18n::*;
+
+#[component]
+pub fn SiteNav() -> impl IntoView {
+ let i18n = use_i18n();
+ let screen = use_context::>().expect("Screen context not found");
+ let auth_username =
+ use_context::>>().expect("auth_username context not found");
+
+ let is_game_active =
+ move || !matches!(screen.get(), Screen::Login { .. });
+
+ let logout = move |_| {
+ spawn_local(async move {
+ let _ = api::post_logout().await;
+ auth_username.set(None);
+ });
+ };
+
+ view! {
+
+ "Trictrac"
+
+
+ "EN"
+ "FR"
+
+ {move || match auth_username.get() {
+ Some(u) => view! {
+ { u.clone() }
+ {t!(i18n, sign_out)}
+ }.into_any(),
+ None => view! {
+ {t!(i18n, sign_in)}
+ }.into_any(),
+ }}
+
+ }
+}
diff --git a/clients/web/src/portal/account.rs b/clients/web/src/portal/account.rs
new file mode 100644
index 0000000..7552a7c
--- /dev/null
+++ b/clients/web/src/portal/account.rs
@@ -0,0 +1,166 @@
+use leptos::prelude::*;
+use leptos_router::hooks::use_navigate;
+
+use crate::api;
+use crate::i18n::*;
+
+#[component]
+pub fn AccountPage() -> impl IntoView {
+ let i18n = use_i18n();
+ let auth_username =
+ use_context::>>().expect("auth_username context not found");
+ let navigate = use_navigate();
+
+ Effect::new(move |_| {
+ if let Some(u) = auth_username.get() {
+ navigate(&format!("/profile/{u}"), Default::default());
+ }
+ });
+
+ let tab = RwSignal::new("login");
+
+ view! {
+
+
+
+ {t!(i18n, account_title)}
+
+
+ {t!(i18n, sign_in)}
+ {t!(i18n, create_account)}
+
+ {move || if tab.get() == "login" {
+ view! { }.into_any()
+ } else {
+ view! { }.into_any()
+ }}
+
+
+ }
+}
+
+#[component]
+fn LoginForm() -> impl IntoView {
+ let i18n = use_i18n();
+ let auth_username =
+ use_context::>>().expect("auth_username context not found");
+ let navigate = use_navigate();
+
+ let username = 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 = username.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) => {
+ let dest = format!("/profile/{}", me.username);
+ auth_username.set(Some(me.username));
+ navigate(&dest, Default::default());
+ }
+ Err(e) => {
+ error.set(e);
+ pending.set(false);
+ }
+ }
+ });
+ };
+
+ view! {
+
+ }
+}
+
+#[component]
+fn RegisterForm() -> impl IntoView {
+ let i18n = use_i18n();
+ let auth_username =
+ use_context::>>().expect("auth_username context not found");
+ let navigate = use_navigate();
+
+ let username = RwSignal::new(String::new());
+ let email = 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 = username.get();
+ let e = email.get();
+ let p = password.get();
+ let navigate = navigate.clone();
+ wasm_bindgen_futures::spawn_local(async move {
+ match api::post_register(&u, &e, &p).await {
+ Ok(me) => {
+ let dest = format!("/profile/{}", me.username);
+ auth_username.set(Some(me.username));
+ navigate(&dest, Default::default());
+ }
+ Err(err) => {
+ error.set(err);
+ pending.set(false);
+ }
+ }
+ });
+ };
+
+ view! {
+
+ }
+}
diff --git a/clients/web/src/portal/game_detail.rs b/clients/web/src/portal/game_detail.rs
new file mode 100644
index 0000000..adc3643
--- /dev/null
+++ b/clients/web/src/portal/game_detail.rs
@@ -0,0 +1,109 @@
+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! {
+
+ {move || match detail.get().map(|sw| sw.take()) {
+ None => view! { {t!(i18n, loading)} }.into_any(),
+ Some(Err(e)) => view! { { e } }.into_any(),
+ Some(Ok(g)) => view! { }.into_any(),
+ }}
+
+ }
+}
+
+#[component]
+fn GameDetailView(game: GameDetail) -> impl IntoView {
+ let i18n = use_i18n();
+ let started = api::format_ts(game.started_at);
+ let ended = game.ended_at.map(api::format_ts)
+ .unwrap_or_else(|| t_string!(i18n, game_ongoing).to_string());
+
+ view! {
+
+ {t!(i18n, room_detail_title)} " " { game.room_code.clone() }
+
+ {t!(i18n, started_label)} ": " { started.clone() }
+ " · "
+ {t!(i18n, ended_label)} ": " { ended }
+
+
+ {t!(i18n, players_header)}
+
+
+
+ {t!(i18n, col_player)}
+ {t!(i18n, label_username)}
+ {t!(i18n, col_outcome)}
+
+
+
+ {game.participants.iter().map(|p| {
+ view! { }
+ }).collect_view()}
+
+
+
+ {game.result.as_ref().map(|r| view! {
+
+ {t!(i18n, score_header)}
+
+ { r.clone() }
+
+
+ })}
+
+ }
+}
+
+#[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! {
+
+ {t!(i18n, col_player)} " " { participant.player_id }
+
+ {match name {
+ Some(u) => view! {
+ { u }
+ }.into_any(),
+ None => view! {
+ {t!(i18n, anonymous_player)}
+ }.into_any(),
+ }}
+
+ { outcome_text }
+
+ }
+}
diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs
new file mode 100644
index 0000000..ef66281
--- /dev/null
+++ b/clients/web/src/portal/lobby.rs
@@ -0,0 +1,88 @@
+use futures::channel::mpsc::UnboundedSender;
+use leptos::prelude::*;
+
+use crate::app::{NetCommand, Screen};
+use crate::i18n::*;
+
+#[component]
+pub fn LobbyPage() -> impl IntoView {
+ let i18n = use_i18n();
+ let (room_name, set_room_name) = signal(String::new());
+
+ let screen = use_context::>().expect("Screen context not found");
+ let cmd_tx = use_context::>()
+ .expect("UnboundedSender not found in context");
+
+ let cmd_tx_create = cmd_tx.clone();
+ let cmd_tx_join = cmd_tx.clone();
+ let cmd_tx_bot = cmd_tx;
+
+ // Extract connection error from screen state.
+ let error = move || match screen.get() {
+ Screen::Login { error } => error,
+ _ => None,
+ };
+
+ view! {
+
+
+
+
+ "Trictrac"
+
+ "Une interprétation numérique"
+
+
+ "✦"
+
+ {move || error().map(|err| view! { {err} })}
+
+
+
+
+
+ {t!(i18n, create_room)}
+
+
+
+ {t!(i18n, join_room)}
+
+
+
+ {t!(i18n, play_vs_bot)}
+
+
+
+
+
+ }
+}
diff --git a/clients/web/src/portal/mod.rs b/clients/web/src/portal/mod.rs
new file mode 100644
index 0000000..722c9e1
--- /dev/null
+++ b/clients/web/src/portal/mod.rs
@@ -0,0 +1,4 @@
+pub mod account;
+pub mod game_detail;
+pub mod lobby;
+pub mod profile;
diff --git a/clients/web/src/portal/profile.rs b/clients/web/src/portal/profile.rs
new file mode 100644
index 0000000..9a94b3f
--- /dev/null
+++ b/clients/web/src/portal/profile.rs
@@ -0,0 +1,153 @@
+use leptos::prelude::*;
+use leptos_router::{components::A, hooks::use_params_map};
+
+use crate::api::{self, GameSummary, UserProfile};
+use crate::i18n::*;
+
+#[component]
+pub fn ProfilePage() -> impl IntoView {
+ let params = use_params_map();
+ let username = move || params.read().get("username").unwrap_or_default();
+
+ let profile = LocalResource::new(move || {
+ let u = username();
+ async move { api::get_user_profile(&u).await }
+ });
+
+ let i18n = use_i18n();
+
+ view! {
+
+ {move || match profile.get().map(|sw| sw.take()) {
+ None => view! { {t!(i18n, loading)} }.into_any(),
+ Some(Err(e)) => view! { { e } }.into_any(),
+ Some(Ok(p)) => view! { }.into_any(),
+ }}
+
+ }
+}
+
+#[component]
+fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
+ let i18n = use_i18n();
+ let page = RwSignal::new(0i64);
+ let games = LocalResource::new(move || {
+ let u = username.clone();
+ let p = page.get();
+ async move { api::get_user_games(&u, p).await }
+ });
+
+ let joined = api::format_ts(profile.created_at);
+
+ view! {
+
+ { profile.username.clone() }
+ {t!(i18n, member_since)} " " { joined }
+
+
+
+ { profile.total_games }
+ {t!(i18n, stat_games)}
+
+
+ { profile.wins }
+ {t!(i18n, stat_wins)}
+
+
+ { profile.losses }
+ {t!(i18n, stat_losses)}
+
+
+ { profile.draws }
+ {t!(i18n, stat_draws)}
+
+
+
+
+
+ {t!(i18n, game_history_title)}
+ {move || match games.get().map(|sw| sw.take()) {
+ None => view! { {t!(i18n, loading)} }.into_any(),
+ Some(Err(e)) => view! { { e } }.into_any(),
+ Some(Ok(r)) => {
+ if r.games.is_empty() {
+ view! { {t!(i18n, no_games)} }.into_any()
+ } else {
+ view! { }.into_any()
+ }
+ }
+ }}
+
+ }
+}
+
+#[component]
+fn GamesTable(games: Vec, page: RwSignal) -> impl IntoView {
+ let i18n = use_i18n();
+ let rows = games.clone();
+ let has_next = games.len() == 20;
+
+ view! {
+
+
+
+ {t!(i18n, col_room)}
+ {t!(i18n, col_started)}
+ {t!(i18n, col_ended)}
+ {t!(i18n, col_outcome)}
+ {t!(i18n, col_detail)}
+
+
+
+ {rows.into_iter().map(|g| {
+ let started = api::format_ts(g.started_at);
+ let ended = g.ended_at.map(api::format_ts).unwrap_or_else(|| "—".into());
+ let outcome_class = match g.outcome.as_deref() {
+ Some("win") => "outcome-win",
+ Some("loss") => "outcome-loss",
+ Some("draw") => "outcome-draw",
+ _ => "",
+ };
+ let outcome_text = move || match g.outcome.as_deref() {
+ Some("win") => t_string!(i18n, outcome_win),
+ Some("loss") => t_string!(i18n, outcome_loss),
+ Some("draw") => t_string!(i18n, outcome_draw),
+ _ => "—",
+ };
+ view! {
+
+ { g.room_code.clone() }
+ { started }
+ { ended }
+ { outcome_text }
+
+ {t!(i18n, view_link)}
+
+
+ }
+ }).collect_view()}
+
+
+
+ {move || if page.get() > 0 {
+ view! {
+ {t!(i18n, prev_page)}
+ }.into_any()
+ } else {
+ view! { }.into_any()
+ }}
+ {t!(i18n, page_label)} " " { move || page.get() + 1 }
+ {if has_next {
+ view! {
+ {t!(i18n, next_page)}
+ }.into_any()
+ } else {
+ view! { }.into_any()
+ }}
+
+ }
+}
diff --git a/justfile b/justfile
index 060f5ce..507fe00 100644
--- a/justfile
+++ b/justfile
@@ -9,6 +9,23 @@ shell:
runcli:
RUST_LOG=info cargo run --bin=client_cli
+[working-directory: 'clients/web']
+dev:
+ trunk serve
+
+[working-directory: 'clients/web']
+build:
+ trunk build --release
+ cp dist/index.html ../../deploy/index.html
+ cp dist/*.wasm ../../deploy/
+ cp dist/*.js ../../deploy/
+ cp dist/*.css ../../deploy/
+
+[working-directory: 'deploy']
+run-relay:
+ ./relay-server
+
+# Legacy targets kept for reference during transition
[working-directory: 'clients/web-game']
dev-game:
trunk serve
@@ -21,10 +38,6 @@ build-game:
cp dist/*.js ../../deploy/
cp dist/*.css ../../deploy/
-[working-directory: 'deploy']
-run-relay:
- ./relay-server
-
[working-directory: 'clients/web-user-portal']
dev-portal:
trunk serve
diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs
index 2c11b44..70fde5e 100644
--- a/server/relay-server/src/main.rs
+++ b/server/relay-server/src/main.rs
@@ -81,8 +81,7 @@ async fn main() {
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([
- "http://localhost:9091".parse().unwrap(), // game dev server
- "http://localhost:9092".parse().unwrap(), // portal dev server
+ "http://localhost:9091".parse().unwrap(), // unified web dev server
]))
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([
@@ -96,10 +95,6 @@ async fn main() {
.route("/enlist", get(enlist_handler))
.route("/ws", get(websocket_handler))
.merge(http::router())
- .nest_service(
- "/portal",
- ServeDir::new("portal").not_found_service(ServeFile::new("portal/index.html")),
- )
.with_state(app_state)
.layer(auth_layer)
.layer(cors)
| |