diff --git a/Cargo.lock b/Cargo.lock
index e557059..517e14f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1449,26 +1449,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
-[[package]]
-name = "client_web"
-version = "0.1.0"
-dependencies = [
- "backbone-lib",
- "futures",
- "getrandom 0.3.4",
- "gloo-net 0.5.0",
- "gloo-storage",
- "gloo-timers",
- "leptos",
- "leptos_i18n",
- "rand 0.9.3",
- "serde",
- "serde_json",
- "trictrac-store",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
[[package]]
name = "cmake"
version = "0.1.58"
@@ -9245,21 +9225,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "web-user-portal"
-version = "0.1.0"
-dependencies = [
- "gloo-net 0.5.0",
- "js-sys",
- "leptos",
- "leptos_router",
- "serde",
- "serde_json",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
[[package]]
name = "webpki-roots"
version = "0.26.11"
diff --git a/Cargo.toml b/Cargo.toml
index 94a1c6b..3c70d45 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,8 +6,6 @@ members = [
"clients/cli",
"clients/backbone-lib",
"clients/web",
- "clients/web-game",
- "clients/web-user-portal",
"server/protocol",
"server/relay-server",
"bot",
diff --git a/README.md b/README.md
index 4e7789f..ca4c0de 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ just build-relay
just run-relay # listens on :8080
# Run the game (separate terminal)
-just dev-game
+just dev
```
Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name.
@@ -52,7 +52,7 @@ The game state is defined by the `GameState` struct in _store/src/game.rs_. The
### multiplayer game
-Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
+Packages "clients/backbone-lib", "clients/web/game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
The system consists of:
diff --git a/clients/web-game/Cargo.toml b/clients/web-game/Cargo.toml
deleted file mode 100644
index 578be7c..0000000
--- a/clients/web-game/Cargo.toml
+++ /dev/null
@@ -1,40 +0,0 @@
-[package]
-name = "client_web"
-version = "0.1.0"
-edition = "2021"
-
-[package.metadata.leptos-i18n]
-default = "en"
-locales = ["en", "fr"]
-
-[dependencies]
-leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
-trictrac-store = { path = "../../store" }
-backbone-lib = { path = "../backbone-lib" }
-leptos = { version = "0.7", features = ["csr"] }
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1"
-futures = "0.3"
-rand = "0.9"
-gloo-storage = "0.3"
-
-[target.'cfg(target_arch = "wasm32")'.dependencies]
-wasm-bindgen-futures = "0.4"
-gloo-net = { version = "0.5", features = ["http"] }
-gloo-timers = { version = "0.3", features = ["futures"] }
-# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
-# Must be a direct dependency (not just transitive) for the feature to take effect.
-getrandom = { version = "0.3", features = ["wasm_js"] }
-web-sys = { version = "0.3", features = [
- "RequestCredentials",
- "AudioContext",
- "AudioParam",
- "AudioNode",
- "AudioDestinationNode",
- "AudioScheduledSourceNode",
- "GainNode",
- "OscillatorNode",
- "OscillatorType",
- "BaseAudioContext",
- "HtmlAudioElement",
-] }
diff --git a/clients/web-game/Trunk.toml b/clients/web-game/Trunk.toml
deleted file mode 100644
index bae5297..0000000
--- a/clients/web-game/Trunk.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[serve]
-port = 9091
diff --git a/clients/web-game/assets/diceroll.mp3 b/clients/web-game/assets/diceroll.mp3
deleted file mode 100644
index b16adff..0000000
Binary files a/clients/web-game/assets/diceroll.mp3 and /dev/null differ
diff --git a/clients/web-game/assets/style.css b/clients/web-game/assets/style.css
deleted file mode 100644
index 341be19..0000000
--- a/clients/web-game/assets/style.css
+++ /dev/null
@@ -1,1213 +0,0 @@
-/* ── 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;
- justify-content: center;
- padding: 1.5rem;
- min-height: 100vh;
-}
-
-.hidden { display: none !important; }
-
-/* ── 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;
- /* Alternating burgundy/ivory triangles pointing down from the top */
- 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 into downward-pointing triangles */
- 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 ─────────────────────────────────────────────────── */
-/* No width: 100% — let it size to content (the board wrapper, ~832px).
- This keeps the board pinned at the same horizontal position whether or
- not the side panel is visible, and aligns the status bar / score panels
- with the board rather than with the viewport edge. */
-.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; }
-
-/* ── 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 ─────────────────────────────────────────────── */
-/* Horizontal banner: name on the left, score bars expanding to fill the
- board width — no more empty right half on large screens. */
-.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;
-}
-
-/* Bars sit side-by-side (points | holes) filling remaining width */
-.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 is sized to the board wrapper only; the side panel is
- positioned absolutely so it floats to the right without pushing the
- board and breaking its horizontal alignment. */
-.board-and-panel {
- position: relative;
-}
-
-/* The side panel is anchored to the board's RIGHT edge. Scoring panel
- wrappers inside it initially overlap the board; they slide to a peek
- strip after a few seconds, and reveal fully on hover. */
-.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; /* pass board clicks through the empty area */
-}
-
-.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) ─────────────────────────────────────────────────── */
-
-/* §5a — vigorous tumble: die bounces in from a random rotation */
-@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 — centered in the board bar */
-.bar-die-slot {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-/* Double glow (§5c) */
-.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;
-}
-
-/* Final score ledger */
-.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) ───────────────────────────────── */
-
-/* ── Wrapper: handles slide-in → peek → reveal lifecycle ──────────────
- The wrapper starts off-screen right (translateX(100%)), slides in on
- mount via animation, then Leptos adds .peeked after 3.4s to slide it
- back to a 28px peek strip. */
-@keyframes scoring-panel-enter {
- from { transform: translateX(100%); }
- to { transform: translateX(0); }
-}
-
-.scoring-panel-wrapper {
- /* width: 290px; */
- 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));
-}
-
-/* Peeked: slide right by the full panel width so the board is 100% clear.
- The panel's left portion stays visible in whatever free space exists to
- the right of the board. */
-.scoring-panel-wrapper.peeked {
- transform: translateX(100%);
-}
-
-/* Click on the visible left strip → .revealed slides it back over the board.
- A second click removes .revealed and returns to the peeked position. */
-.scoring-panel-wrapper.revealed {
- transform: translateX(0);
-}
-
-/* Pointer cursor on the peeked (clickable) strip */
-.scoring-panel-wrapper.peeked:not(.revealed) {
- cursor: pointer;
-}
-
-/* ── Inner panel card ─────────────────────────────────────────────────── */
-.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; }
-
-/* ── Large-screen layout: panel in free space, no peek needed ─────────
- Threshold: board (832) + body-padding (48) + panel-gap (16) + panel (290)
- + symmetric left margin = 1492 px.
- At this width the panel fits entirely to the right of the board. */
-@media (min-width: 1492px) {
- .side-panel {
- right: auto;
- left: calc(100% + 1rem); /* outside board, no overlap */
- }
- /* Already fully visible in free space — peeked/revealed are no-ops. */
- .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) — aligned with board quarters ───────────────── */
-/* Board border(4) + padding(4) = 8px inset each side */
-.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) ────────────────────────────────────────────────────── */
-/*
- * Each field is a transparent rectangle over the felt.
- * The triangular flèche is drawn by ::before using clip-path.
- * --fc controls the triangle colour; z-index:-1 keeps the triangle
- * behind checkers; isolation:isolate confines the negative z-index.
- */
-.field {
- --fc: var(--field-ivory); /* default triangle colour */
- width: 60px;
- height: 180px;
- background: transparent; /* felt shows through between triangle tips */
- isolation: isolate; /* stacking context for z-index:-1 ::before */
- border-radius: 3px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-end;
- padding: 4px 2px;
- position: relative;
-}
-
-/* Bot-row triangle: wide base at bottom, tip at top */
-.field::before {
- content: '';
- position: absolute;
- inset: 0;
- z-index: -1; /* behind checkers & corner crown */
- background: var(--fc);
- clip-path: polygon(0% 100%, 50% 0%, 100% 100%);
- transition: background 0.12s;
-}
-
-/* Top-row triangle: wide base at top, tip at bottom */
-.top-row .field::before {
- clip-path: polygon(0% 0%, 100% 0%, 50% 100%);
-}
-
-.top-row .field { justify-content: flex-start; }
-
-/* ── Zone alternating colours (§2b) ────────────────────────────────── */
-/* petit-jan and grand-jan: burgundy / ivory */
-.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); }
-
-/* Opponent's grand-jan — deep slate-blue / silvery-green ivory.
- Previously #1e3d32 was nearly identical to the felt (#1d3d28); now using
- a clearly distinguishable cool blue that reads well against the green. */
-.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; }
-.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; }
-
-/* Jan de retour — warmer: amber-brown / warm amber ivory */
-.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
-.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
-
-/* ── Rest corner — before .clickable so green wins when interactive ── */
-/* .field.corner { --fc: var(--field-corner) !important; } */
-
-/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */
-.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; }
-
-/* Corner pulse (§8d) — filter respects the triangle shape */
-@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;
-}
-
-/* ── Exit-eligible highlight (§8c) — filter glow on triangle ───────── */
-@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;
-}
-
-/* ── §6c — Jan hover field highlight ────────────────────────────────── */
-.field.jan-hovered {
- --fc: rgba(190, 140, 35, 0.8) !important;
-}
-
-/* ── §6e — Hit (battue) ripple ──────────────────────────────────────── */
-@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; }
-
-/* ── Interactive states — after .corner to take visual priority ─────── */
-.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 numbers ──────────────────────────────────────────────────── */
-.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;
-}
-
-/* ── Bredouille toast variant (§6d) — gold shimmer, larger entrance ─── */
-@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;
-}
-
-/* ── §4a — Checker slide animation ─────────────────────────────────── */
-@keyframes checker-slide-in {
- from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); }
- to { transform: none; }
-}
-/* Only the arriving (outermost) checker animates; --slide-dx/dy are set
- as inline styles on that element at render time, so no flash occurs. */
-.checker.arriving {
- animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94);
-}
-/* Lift the field that owns an arriving checker above its siblings so the
- checker doesn't slide under adjacent fields (isolation:isolate traps
- z-index within each field's stacking context). */
-.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; /* reserve height so layout doesn't shift when buttons appear */
-}
-
-/* ── 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;
-}
-
-
-.auth-badge {
- font-size: 0.8rem;
- text-align: center;
- padding: 0.35rem 0.6rem;
- border-radius: 5px;
-}
-.auth-badge--in { background: rgba(96,165,250,0.15); color: #93c5fd; }
-.auth-badge--out { background: rgba(148,163,184,0.1); color: #64748b; }
-.auth-badge a { color: #60a5fa; }
-
-.playing-as {
- font-size: 0.8rem;
- color: #64748b;
- text-align: center;
-}
diff --git a/clients/web-game/index.html b/clients/web-game/index.html
deleted file mode 100644
index 7399dbc..0000000
--- a/clients/web-game/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- Trictrac
-
-
-
-
-
-
diff --git a/clients/web-game/locales/en.json b/clients/web-game/locales/en.json
deleted file mode 100644
index c29121d..0000000
--- a/clients/web-game/locales/en.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "room_name_placeholder": "Room name",
- "create_room": "Create Room",
- "join_room": "Join Room",
- "connecting": "Connecting…",
- "game_over": "Game over",
- "waiting_for_opponent": "Waiting for opponent…",
- "your_turn_roll": "Your turn — roll the dice",
- "hold_or_go": "Hold or Go?",
- "select_move": "Move a checker ({{ n }} of 2)",
- "your_turn": "Your turn",
- "opponent_turn": "Opponent's turn",
- "room_label": "Room: {{ id }}",
- "quit": "Quit",
- "roll_dice": "Roll dice",
- "go": "Go",
- "empty_move": "Empty move",
- "you_suffix": " (you)",
- "points_label": "Points",
- "holes_label": "Holes",
- "bredouille_title": "Can bredouille",
- "jan_double": "double",
- "jan_simple": "simple",
- "jan_filled_quarter": "Quarter filled",
- "jan_true_hit_small": "True hit (small jan)",
- "jan_true_hit_big": "True hit (big jan)",
- "jan_true_hit_corner": "True hit (opp. corner)",
- "jan_first_exit": "First to exit",
- "jan_six_tables": "Six tables",
- "jan_two_tables": "Two tables",
- "jan_mezeas": "Mezeas",
- "jan_false_hit_small": "False hit (small jan)",
- "jan_false_hit_big": "False hit (big jan)",
- "jan_contre_two": "Contre two tables",
- "jan_contre_mezeas": "Contre mezeas",
- "jan_helpless_man": "Helpless man",
- "play_vs_bot": "Play vs Bot",
- "vs_bot_label": "vs Bot",
- "you_win": "You win!",
- "opp_wins": "{{ name }} wins!",
- "play_again": "Play again",
- "after_opponent_roll": "Opponent rolled",
- "after_opponent_go": "Opponent chose to continue",
- "after_opponent_move": "Opponent moved — your turn",
- "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"
-}
diff --git a/clients/web-game/locales/fr.json b/clients/web-game/locales/fr.json
deleted file mode 100644
index 93f76e5..0000000
--- a/clients/web-game/locales/fr.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "room_name_placeholder": "Nom de la salle",
- "create_room": "Créer une salle",
- "join_room": "Rejoindre",
- "connecting": "Connexion en cours…",
- "game_over": "Partie terminée",
- "waiting_for_opponent": "En attente de l'adversaire…",
- "your_turn_roll": "À votre tour — lancez les dés",
- "hold_or_go": "Tenir ou s'en aller ?",
- "select_move": "Déplacez une dame ({{ n }} sur 2)",
- "your_turn": "Votre tour",
- "opponent_turn": "Tour de l'adversaire",
- "room_label": "Salle : {{ id }}",
- "quit": "Quitter",
- "roll_dice": "Lancer les dés",
- "go": "S'en aller",
- "empty_move": "Mouvement impossible",
- "you_suffix": " (vous)",
- "points_label": "Points",
- "holes_label": "Trous",
- "bredouille_title": "Peut faire bredouille",
- "jan_double": "double",
- "jan_simple": "simple",
- "jan_filled_quarter": "Remplissage",
- "jan_true_hit_small": "Battage à vrai (petit jan)",
- "jan_true_hit_big": "Battage à vrai (grand jan)",
- "jan_true_hit_corner": "Battage coin adverse",
- "jan_first_exit": "Premier sorti",
- "jan_six_tables": "Jan de six tables",
- "jan_two_tables": "Jan de deux tables",
- "jan_mezeas": "Jan de mézéas",
- "jan_false_hit_small": "Battage à faux (petit jan)",
- "jan_false_hit_big": "Battage à faux (grand jan)",
- "jan_contre_two": "Contre jan de deux tables",
- "jan_contre_mezeas": "Contre jan de mezeas",
- "jan_helpless_man": "Dame impuissante",
- "play_vs_bot": "Jouer contre le bot",
- "vs_bot_label": "contre le bot",
- "you_win": "Vous avez gagné !",
- "opp_wins": "{{ name }} gagne !",
- "play_again": "Rejouer",
- "after_opponent_roll": "L'adversaire a lancé les dés",
- "after_opponent_go": "L'adversaire s'en va",
- "after_opponent_move": "L'adversaire a joué — à vous",
- "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"
-}
diff --git a/clients/web-game/src/app.rs b/clients/web-game/src/app.rs
deleted file mode 100644
index ce355f4..0000000
--- a/clients/web-game/src/app.rs
+++ /dev/null
@@ -1,726 +0,0 @@
-use futures::channel::mpsc;
-use futures::{FutureExt, StreamExt};
-use gloo_storage::{LocalStorage, Storage};
-use leptos::prelude::*;
-use leptos::task::spawn_local;
-use serde::{Deserialize, Serialize};
-
-use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
-use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate};
-
-use crate::components::{ConnectingScreen, GameScreen, LoginScreen};
-use crate::i18n::I18nContextProvider;
-use crate::trictrac::backend::TrictracBackend;
-use crate::trictrac::bot_local::bot_decide;
-use crate::trictrac::types::{
- GameDelta, JanEntry, PlayerAction, ScoredEvent, SerStage, SerTurnStage, ViewState,
-};
-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";
-
-// In debug builds trunk serves on 9091, relay is on 8080.
-// In release the game is served by the relay itself — use relative paths.
-#[cfg(debug_assertions)]
-const HTTP_BASE: &str = "http://localhost:8080";
-#[cfg(not(debug_assertions))]
-const HTTP_BASE: &str = "";
-
-/// The state the UI needs to render the game screen.
-#[derive(Clone, PartialEq)]
-pub struct GameUiState {
- pub view_state: ViewState,
- /// 0 = host, 1 = guest
- pub player_id: u16,
- pub room_id: String,
- pub is_bot_game: bool,
- /// True when this state is a buffered snapshot awaiting player confirmation.
- pub waiting_for_confirm: bool,
- /// Why we are paused — drives the status-bar message in GameScreen.
- pub pause_reason: Option,
- /// Points scored by this player in the transition to this state (if any).
- pub my_scored_event: Option,
- pub opp_scored_event: Option,
- /// Checker moves to animate on this render. None when board is unchanged.
- pub last_moves: Option<(CheckerMove, CheckerMove)>,
-}
-
-/// Reason the UI is paused waiting for the player to click Continue.
-#[derive(Clone, Debug, PartialEq)]
-pub enum PauseReason {
- AfterOpponentRoll,
- AfterOpponentGo,
- AfterOpponentMove,
- /// Opponent rolled their die in the pre-game ceremony.
- AfterOpponentPreGameRoll,
-}
-
-/// Which screen is currently shown.
-#[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,
-}
-
-/// Stored in localStorage to reconnect after a page refresh.
-#[derive(Serialize, Deserialize)]
-struct StoredSession {
- relay_url: String,
- game_id: String,
- room_id: String,
- token: u64,
- #[serde(default)]
- is_host: bool,
- #[serde(default)]
- view_state: Option,
-}
-
-#[derive(Deserialize)]
-struct MeResponse {
- username: String,
-}
-
-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);
-}
-
-/// Fire-and-forget: tell the relay server who won. Only called by the host.
-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!("{HTTP_BASE}/games/result"))
- .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::new(initial_screen);
-
- // Auth: fetch once and expose to all child components via context.
- let auth_username: RwSignal