From 20134ce46823c6bb953946e763e71624911a641a Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 2 May 2026 11:11:39 +0200 Subject: [PATCH] chore: remove old web-game & web-user-portal crates --- Cargo.lock | 35 - Cargo.toml | 2 - README.md | 4 +- clients/web-game/Cargo.toml | 40 - clients/web-game/Trunk.toml | 2 - clients/web-game/assets/diceroll.mp3 | Bin 32896 -> 0 bytes clients/web-game/assets/style.css | 1213 ----------------- clients/web-game/index.html | 12 - clients/web-game/locales/en.json | 61 - clients/web-game/locales/fr.json | 61 - clients/web-game/src/app.rs | 726 ---------- clients/web-game/src/components/board.rs | 594 -------- .../src/components/connecting_screen.rs | 9 - clients/web-game/src/components/die.rs | 53 - .../web-game/src/components/game_screen.rs | 470 ------- .../web-game/src/components/login_screen.rs | 115 -- clients/web-game/src/components/mod.rs | 11 - .../web-game/src/components/score_panel.rs | 70 - clients/web-game/src/components/scoring.rs | 209 --- clients/web-game/src/main.rs | 13 - clients/web-game/src/sound.rs | 182 --- clients/web-game/src/trictrac/backend.rs | 487 ------- clients/web-game/src/trictrac/bot_local.rs | 43 - clients/web-game/src/trictrac/mod.rs | 3 - clients/web-game/src/trictrac/types.rs | 256 ---- clients/web-user-portal/Cargo.toml | 17 - clients/web-user-portal/Trunk.toml | 2 - clients/web-user-portal/assets/style.css | 103 -- clients/web-user-portal/index.html | 11 - clients/web-user-portal/src/api.rs | 191 --- clients/web-user-portal/src/app.rs | 67 - clients/web-user-portal/src/main.rs | 7 - clients/web-user-portal/src/pages/game.rs | 95 -- clients/web-user-portal/src/pages/home.rs | 152 --- clients/web-user-portal/src/pages/mod.rs | 3 - clients/web-user-portal/src/pages/profile.rs | 137 -- 36 files changed, 2 insertions(+), 5454 deletions(-) delete mode 100644 clients/web-game/Cargo.toml delete mode 100644 clients/web-game/Trunk.toml delete mode 100644 clients/web-game/assets/diceroll.mp3 delete mode 100644 clients/web-game/assets/style.css delete mode 100644 clients/web-game/index.html delete mode 100644 clients/web-game/locales/en.json delete mode 100644 clients/web-game/locales/fr.json delete mode 100644 clients/web-game/src/app.rs delete mode 100644 clients/web-game/src/components/board.rs delete mode 100644 clients/web-game/src/components/connecting_screen.rs delete mode 100644 clients/web-game/src/components/die.rs delete mode 100644 clients/web-game/src/components/game_screen.rs delete mode 100644 clients/web-game/src/components/login_screen.rs delete mode 100644 clients/web-game/src/components/mod.rs delete mode 100644 clients/web-game/src/components/score_panel.rs delete mode 100644 clients/web-game/src/components/scoring.rs delete mode 100644 clients/web-game/src/main.rs delete mode 100644 clients/web-game/src/sound.rs delete mode 100644 clients/web-game/src/trictrac/backend.rs delete mode 100644 clients/web-game/src/trictrac/bot_local.rs delete mode 100644 clients/web-game/src/trictrac/mod.rs delete mode 100644 clients/web-game/src/trictrac/types.rs delete mode 100644 clients/web-user-portal/Cargo.toml delete mode 100644 clients/web-user-portal/Trunk.toml delete mode 100644 clients/web-user-portal/assets/style.css delete mode 100644 clients/web-user-portal/index.html delete mode 100644 clients/web-user-portal/src/api.rs delete mode 100644 clients/web-user-portal/src/app.rs delete mode 100644 clients/web-user-portal/src/main.rs delete mode 100644 clients/web-user-portal/src/pages/game.rs delete mode 100644 clients/web-user-portal/src/pages/home.rs delete mode 100644 clients/web-user-portal/src/pages/mod.rs delete mode 100644 clients/web-user-portal/src/pages/profile.rs 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 b16adff42da3003b35d59566b353a5db2fa0c7b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32896 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 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> = RwSignal::new(None); - provide_context(auth_username); - spawn_local(async move { - if let Ok(resp) = gloo_net::http::Request::get(&format!("{HTTP_BASE}/auth/me")) - .credentials(web_sys::RequestCredentials::Include) - .send() - .await - { - if resp.status() == 200 { - if let Ok(me) = resp.json::().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 { - // Wait for a connect/reconnect command (or PlayVsBot). - // None means "play vs bot"; Some((config, is_reconnect)) means "connect to relay". - let remote_config: Option<(RoomConfig, bool)> = loop { - match cmd_rx.next().await { - Some(NetCommand::PlayVsBot) => break None, - Some(NetCommand::CreateRoom { room }) => { - break Some(( - RoomConfig { - relay_url: RELAY_URL.to_string(), - game_id: GAME_ID.to_string(), - room_id: room, - rule_variation: 0, - role: RoomRole::Create, - reconnect_token: None, - host_state: None, - }, - false, - )); - } - Some(NetCommand::JoinRoom { room }) => { - break Some(( - RoomConfig { - relay_url: RELAY_URL.to_string(), - game_id: GAME_ID.to_string(), - room_id: room, - rule_variation: 0, - role: RoomRole::Join, - reconnect_token: None, - host_state: None, - }, - false, - )); - } - Some(NetCommand::Reconnect { - relay_url, - game_id, - room_id, - token, - host_state, - }) => { - break Some(( - RoomConfig { - relay_url, - game_id, - room_id, - rule_variation: 0, - role: RoomRole::Join, - reconnect_token: Some(token), - host_state, - }, - true, - )); - } - _ => {} // Ignore game commands while disconnected. - } - }; - - if remote_config.is_none() { - loop { - let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await; - if !restart { - break; - } - } - pending.update(|q| q.clear()); - screen.set(Screen::Login { error: None }); - continue; - } - let (config, is_reconnect) = remote_config.unwrap(); - - screen.set(Screen::Connecting); - - let room_id_for_storage = config.room_id.clone(); - let mut session: GameSession = - 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), - } - - // Host reports outcomes once per terminal game state. - 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! { - - {move || { - let q = pending.get(); - if let Some(front) = q.front() { - view! { }.into_any() - } else { - match screen.get() { - Screen::Login { error } => view! { }.into_any(), - Screen::Connecting => view! { }.into_any(), - Screen::Playing(state) => view! { }.into_any(), - } - } - }} - - } -} - -/// Runs one local bot game. Returns `true` if the player wants to play again. -async fn run_local_bot_game( - screen: RwSignal, - cmd_rx: &mut futures::channel::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, - })); - - 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); - // Process each delta individually so intermediate ceremony - // states (both dice shown) can trigger a pause via push_or_show. - 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. -/// Returns `None` when the board is unchanged or no real moves were recorded. -/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2. -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() { - // Relies on the engine invariant: dice_moves is updated atomically with the board - // change in the Move event handler. Any future engine path that mutates the board - // without setting dice_moves would bypass this guard and replay stale animation. - return None; - } - if own_move { - // m1 was already shown via the staged-moves overlay; only animate m2. - 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. Returns `None` when no points changed for that player. -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; - - // Determine which dice_jans are "mine" depending on who was the active roller. - let my_jans: Vec = if next.active_mp_player == Some(player_id) - && prev.active_mp_player == Some(player_id) - { - // My own roll: positive totals are mine. - next.dice_jans - .iter() - .filter(|e| e.total > 0) - .cloned() - .collect() - } else if next.active_mp_player == Some(player_id) && prev.active_mp_player != Some(player_id) { - // Opponent just moved: negative totals (their penalty) are scored for me. - next.dice_jans - .iter() - .filter(|e| e.total < 0) - .map(|e| JanEntry { - total: -e.total, - points_per: -e.points_per, - ..e.clone() - }) - .collect() - } else { - return None; - }; - - let points_earned: u8 = my_jans - .iter() - .fold(0u8, |acc, e| acc.saturating_add(e.total.unsigned_abs())); - - if points_earned == 0 && holes_gained == 0 { - return None; - } - - Some(ScoredEvent { - points_earned, - holes_gained, - holes_total: next_score.holes, - bredouille, - jans: my_jans, - }) -} - -/// Either queues the state as a buffered confirmation step (when the transition -/// warrants a pause) or shows it immediately. Always updates `screen` to the -/// live state so the UI falls through to the right content once pending drains. -fn push_or_show( - prev_vs: &ViewState, - new_state: GameUiState, - pending: RwSignal>, - 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) { - // Scoring notifications go on the buffered (paused) state only. - pending.update(|q| { - q.push_back(GameUiState { - waiting_for_confirm: true, - pause_reason: Some(reason), - my_scored_event: scored, - opp_scored_event: opp_scored, - ..new_state.clone() - }); - }); - // Animation belongs to the buffered confirmation step; clear it on the - // fallback live state so it doesn't fire again after the queue drains. - screen.set(Screen::Playing(GameUiState { - last_moves: None, - ..new_state - })); - } else { - // No pause: show scoring directly on the live state. - screen.set(Screen::Playing(GameUiState { - my_scored_event: scored, - opp_scored_event: opp_scored, - ..new_state - })); - } -} - -/// Compares the previous and next ViewState to decide whether the transition -/// warrants a confirmation pause. Returns None when it is the local player's -/// own action (no pause needed). -fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option { - let opponent_id = 1 - player_id; - - // Pre-game ceremony: pause when both dice are revealed simultaneously - // (i.e. the second die was just rolled). Both players see this pause. - 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; - } - - // Don't fire normal pause rules on the PreGameRoll → InGame transition. - if prev.stage == SerStage::PreGameRoll { - return None; - } - - if next.active_mp_player == Some(opponent_id) { - // Dice changed → opponent just rolled. - if next.dice != prev.dice { - return Some(PauseReason::AfterOpponentRoll); - } - // Was at HoldOrGoChoice, now Move, opponent still active → opponent went. - if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move - { - return Some(PauseReason::AfterOpponentGo); - } - } - - // Turn switched to us → opponent moved. - if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) { - return Some(PauseReason::AfterOpponentMove); - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::trictrac::types::{PlayerScore, SerStage, SerTurnStage}; - - fn score() -> PlayerScore { - PlayerScore { - name: String::new(), - points: 0, - holes: 0, - can_bredouille: false, - } - } - - fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option) -> 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-game/src/components/board.rs b/clients/web-game/src/components/board.rs deleted file mode 100644 index 0610c86..0000000 --- a/clients/web-game/src/components/board.rs +++ /dev/null @@ -1,594 +0,0 @@ -use leptos::prelude::*; -use trictrac_store::CheckerMove; - -use super::die::Die; -use crate::trictrac::types::{SerTurnStage, ViewState}; - -/// Field numbers in visual display order (left-to-right for each quarter), white's perspective. -const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; -const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24]; -const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7]; -const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1]; - -/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom. -const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6]; -const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12]; -const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19]; -const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; - -/// 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! { -
0 } else { val < 0 }; - let can_stage = is_move_stage && staged.len() < 2; - let sel = selected_origin.get(); - - let mut cls = format!("field {}", field_zone_class(field_num)); - if is_rest_corner(field_num, is_white) { - cls.push_str(" corner"); - // Pulse when the corner can be reached this turn - if !seqs_c.is_empty() && seqs_c.iter().any(|(m1, m2)| { - m1.get_to() as u8 == field_num - || m2.get_to() as u8 == field_num - }) { - cls.push_str(" corner-available"); - } - } - if is_rest_corner(field_num, !is_white) { - cls.push_str(" corner"); - } - if all_in_exit && exit_field_test(field_num) { - cls.push_str(" exit-eligible"); - } - - if seqs_c.is_empty() { - // No restriction (dice not rolled or not move stage) - if can_stage && (sel.is_some() || is_mine) { - cls.push_str(" clickable"); - } - if sel == Some(field_num) { cls.push_str(" selected"); } - if can_stage && sel.is_some() && sel != Some(field_num) { - cls.push_str(" dest"); - } - } else if can_stage { - if let Some(origin) = sel { - if origin == field_num { - cls.push_str(" selected clickable"); - } else { - let dests = valid_dests_for(&seqs_c, &staged, origin); - // Only highlight non-exit destinations (field 0 = exit has no tile) - if dests.iter().any(|&d| d == field_num && d != 0) { - cls.push_str(" clickable dest"); - } - } - } else { - let origins = valid_origins_for(&seqs_c, &staged); - if origins.iter().any(|&o| o == field_num) { - cls.push_str(" clickable"); - } - } - } - - // §6c: highlight fields touched by the hovered jan - if let Some(hm) = hovered_moves { - let pairs = hm.get(); - let f = field_num as usize; - let highlighted = pairs.iter().any(|(m1, m2)| { - (m1.get_from() != 0 && m1.get_from() == f) - || (m1.get_to() != 0 && m1.get_to() == f) - || (m2.get_from() != 0 && m2.get_from() == f) - || (m2.get_to() != 0 && m2.get_to() == f) - }); - if highlighted { - cls.push_str(" jan-hovered"); - } - } - - cls - } - on:click=move |_| { - if !is_move_stage { return; } - let staged = staged_moves.get_untracked(); - if staged.len() >= 2 { return; } - - match selected_origin.get_untracked() { - Some(origin) if origin == field_num => { - selected_origin.set(None); - } - Some(origin) => { - let valid = if seqs_k.is_empty() { - true - } else { - valid_dests_for(&seqs_k, &staged, origin) - .iter() - .any(|&d| d == field_num) - }; - if valid { - staged_moves.update(|v| v.push((origin, field_num))); - selected_origin.set(None); - } - } - None => { - if seqs_k.is_empty() { - let val = displayed_value(board, &staged, is_white, field_num); - if is_white && val > 0 || !is_white && val < 0 { - selected_origin.set(Some(field_num)); - } - } else { - let origins = valid_origins_for(&seqs_k, &staged); - if origins.iter().any(|&o| o == field_num) { - let dests = valid_dests_for(&seqs_k, &staged, field_num); - if !dests.is_empty() && dests.iter().all(|&d| d == 0) { - // All destinations are exits: auto-stage - staged_moves.update(|v| v.push((field_num, 0))); - } else { - selected_origin.set(Some(field_num)); - } - } - } - } - } - } - > - {field_num} - {move || { - let moves = staged_moves.get(); - let val = displayed_value(board, &moves, is_white, field_num); - let count = val.unsigned_abs(); - // §6e — ripple on hit (battue) fields; must be inside the - // reactive closure so Leptos uses the same direct rendering - // path as .arriving (avoids node-move that resets animation). - let ripple = is_hit_field.then(|| { - let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" }; - view! {
}.into_any() - }); - let stack = (count > 0).then(|| { - let color = if val > 0 { "white" } else { "black" }; - let display_n = (count as usize).min(4); - // outermost index: last for top rows, first for bottom rows. - let outer_idx = if is_top_row { display_n - 1 } else { 0 }; - let chips: Vec = (0..display_n).map(|i| { - let label = if i == outer_idx && count >= 5 { - count.to_string() - } else { - String::new() - }; - if i == outer_idx { - if let Some((dx, dy)) = slide_delta { - return view! { -
{label}
- }.into_any(); - } - } - view! { -
{label}
- }.into_any() - }).collect(); - view! {
{chips}
}.into_any() - }); - (ripple, stack) - }} -
- } - .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-game/src/components/connecting_screen.rs b/clients/web-game/src/components/connecting_screen.rs deleted file mode 100644 index 6f40da5..0000000 --- a/clients/web-game/src/components/connecting_screen.rs +++ /dev/null @@ -1,9 +0,0 @@ -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-game/src/components/die.rs b/clients/web-game/src/components/die.rs deleted file mode 100644 index 7576280..0000000 --- a/clients/web-game/src/components/die.rs +++ /dev/null @@ -1,53 +0,0 @@ -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-game/src/components/game_screen.rs b/clients/web-game/src/components/game_screen.rs deleted file mode 100644 index 2493680..0000000 --- a/clients/web-game/src/components/game_screen.rs +++ /dev/null @@ -1,470 +0,0 @@ -use std::cell::Cell; -use std::collections::VecDeque; - -use futures::channel::mpsc::UnboundedSender; -use leptos::prelude::*; -use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; - -use super::die::Die; -use crate::app::{GameUiState, NetCommand, PauseReason}; -use crate::i18n::*; -use crate::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::>>().expect("auth_username not found in context"); - 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::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::sound::play_dice_roll(); - } - // Checker move: moves were committed in the preceding action. - if last_moves.is_some() { - crate::sound::play_checker_move(); - } - // Scoring: hole takes priority over plain points. - if let Some(ref ev) = my_scored_event { - if ev.holes_gained > 0 { - crate::sound::play_hole_scored(); - } else { - crate::sound::play_points_scored(); - } - } - - // ── Capture for closures ─────────────────────────────────────────────────── - let stage = vs.stage.clone(); - let turn_stage = vs.turn_stage.clone(); - let turn_stage_for_panel = turn_stage.clone(); - let turn_stage_for_sub = turn_stage.clone(); - let room_id = state.room_id.clone(); - let is_bot_game = state.is_bot_game; - - // ── Game-over info ───────────────────────────────────────────────────────── - let stage_is_ended = stage == SerStage::Ended; - let winner_is_me = my_score.holes >= 12; - let my_name_end = my_score.name.clone(); - let my_holes_end = my_score.holes; - let opp_name_end = opp_score.name.clone(); - let opp_holes_end = opp_score.holes; - - view! { -
- // ── 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()) - }} -
- - -
- - {move || auth_username.get().map(|u| view! { -

"Playing as " {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! { - - })} - // Fallback Go button when no scoring panel (e.g. after reconnect) - {show_hold_go.then(|| view! { - - })} - {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! { - - }) - }} -
- - // ── 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! { - - } - })} - {can_roll.then(|| { - let cmd_tx_c = cmd_tx_ceremony.clone(); - view! { - - } - })} -
-
- } - })} - - // ── 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()} -
-
- - {is_bot_game.then(|| view! { - - })} -
-
-
- } - })} - - // ── 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-game/src/components/login_screen.rs b/clients/web-game/src/components/login_screen.rs deleted file mode 100644 index 1328b03..0000000 --- a/clients/web-game/src/components/login_screen.rs +++ /dev/null @@ -1,115 +0,0 @@ -use futures::channel::mpsc::UnboundedSender; -use leptos::prelude::*; - -use crate::app::NetCommand; -use crate::i18n::*; - -#[cfg(debug_assertions)] -const PORTAL_URL: &str = "http://localhost:9092"; -#[cfg(not(debug_assertions))] -const PORTAL_URL: &str = "/portal"; - -#[component] -pub fn LoginScreen(error: Option) -> impl IntoView { - let i18n = use_i18n(); - let (room_name, set_room_name) = signal(String::new()); - - let cmd_tx = use_context::>() - .expect("UnboundedSender not found in context"); - let auth_username = - use_context::>>().expect("auth_username not found in context"); - - let cmd_tx_create = cmd_tx.clone(); - let cmd_tx_join = cmd_tx.clone(); - let cmd_tx_bot = cmd_tx; - - view! { - - } -} diff --git a/clients/web-game/src/components/mod.rs b/clients/web-game/src/components/mod.rs deleted file mode 100644 index 7ae2fcb..0000000 --- a/clients/web-game/src/components/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod board; -mod connecting_screen; -mod die; -mod game_screen; -mod login_screen; -mod score_panel; -mod scoring; - -pub use connecting_screen::ConnectingScreen; -pub use game_screen::GameScreen; -pub use login_screen::LoginScreen; diff --git a/clients/web-game/src/components/score_panel.rs b/clients/web-game/src/components/score_panel.rs deleted file mode 100644 index ab97a39..0000000 --- a/clients/web-game/src/components/score_panel.rs +++ /dev/null @@ -1,70 +0,0 @@ -use leptos::prelude::*; -use trictrac_store::Jan; - -use crate::i18n::*; -use crate::trictrac::types::PlayerScore; - -pub fn jan_label(jan: &Jan) -> String { - let i18n = use_i18n(); - match jan { - Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(), - Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(), - Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(), - Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(), - Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(), - Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(), - Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(), - Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(), - Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(), - Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(), - Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(), - Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(), - Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(), - } -} - -#[component] -pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView { - let i18n = use_i18n(); - - let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100)); - let points_val = format!("{}/12", score.points); - let holes = score.holes; - let can_bredouille = score.can_bredouille; - - // 12 peg holes; filled up to `holes` - let pegs: Vec = (1u8..=12) - .map(|i| { - let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" }; - view! {
}.into_any() - }) - .collect(); - - view! { -
-
- - {score.name} - {is_you.then(|| t!(i18n, you_suffix))} - -
-
-
- {t!(i18n, points_label)} -
-
-
- {points_val} - {can_bredouille.then(|| view! { - "B" - })} -
-
- {t!(i18n, holes_label)} -
{pegs}
- {format!("{holes}/12")} -
-
-
- } -} diff --git a/clients/web-game/src/components/scoring.rs b/clients/web-game/src/components/scoring.rs deleted file mode 100644 index 4a19a81..0000000 --- a/clients/web-game/src/components/scoring.rs +++ /dev/null @@ -1,209 +0,0 @@ -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::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 - - -
- } - })} -
-
- } -} diff --git a/clients/web-game/src/main.rs b/clients/web-game/src/main.rs deleted file mode 100644 index f0952a0..0000000 --- a/clients/web-game/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -leptos_i18n::load_locales!(); - -mod app; -mod components; -mod sound; -mod trictrac; - -use app::App; -use leptos::prelude::*; - -fn main() { - mount_to_body(|| view! { }) -} diff --git a/clients/web-game/src/sound.rs b/clients/web-game/src/sound.rs deleted file mode 100644 index 5637ccd..0000000 --- a/clients/web-game/src/sound.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Synthesised sound effects using the Web Audio API. -//! -//! All public functions are no-ops on non-WASM targets so callers need no -//! `#[cfg]` guards themselves. - -#[cfg(target_arch = "wasm32")] -mod inner { - use std::cell::RefCell; - use web_sys::{AudioContext, OscillatorType}; - - thread_local! { - static CTX: RefCell> = 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-game/src/trictrac/backend.rs b/clients/web-game/src/trictrac/backend.rs deleted file mode 100644 index 04b2a36..0000000 --- a/clients/web-game/src/trictrac/backend.rs +++ /dev/null @@ -1,487 +0,0 @@ -use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; -use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage}; - -use crate::trictrac::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 crate::trictrac::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-game/src/trictrac/bot_local.rs b/clients/web-game/src/trictrac/bot_local.rs deleted file mode 100644 index f94bfc9..0000000 --- a/clients/web-game/src/trictrac/bot_local.rs +++ /dev/null @@ -1,43 +0,0 @@ -use rand::prelude::IndexedRandom; -use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; - -use crate::trictrac::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-game/src/trictrac/mod.rs b/clients/web-game/src/trictrac/mod.rs deleted file mode 100644 index 38d05bb..0000000 --- a/clients/web-game/src/trictrac/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod backend; -pub mod bot_local; -pub mod types; diff --git a/clients/web-game/src/trictrac/types.rs b/clients/web-game/src/trictrac/types.rs deleted file mode 100644 index 3c0dfe2..0000000 --- a/clients/web-game/src/trictrac/types.rs +++ /dev/null @@ -1,256 +0,0 @@ -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-user-portal/Cargo.toml b/clients/web-user-portal/Cargo.toml deleted file mode 100644 index 6afa767..0000000 --- a/clients/web-user-portal/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "web-user-portal" -version = "0.1.0" -edition = "2024" - -[dependencies] -leptos = { version = "0.7", features = ["csr"] } -leptos_router = { version = "0.7" } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1" - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" -gloo-net = { version = "0.5", features = ["http"] } -js-sys = "0.3" -web-sys = { version = "0.3", features = ["RequestCredentials"] } diff --git a/clients/web-user-portal/Trunk.toml b/clients/web-user-portal/Trunk.toml deleted file mode 100644 index 57a2aaa..0000000 --- a/clients/web-user-portal/Trunk.toml +++ /dev/null @@ -1,2 +0,0 @@ -[serve] -port = 9092 diff --git a/clients/web-user-portal/assets/style.css b/clients/web-user-portal/assets/style.css deleted file mode 100644 index 3e7462a..0000000 --- a/clients/web-user-portal/assets/style.css +++ /dev/null @@ -1,103 +0,0 @@ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -body { - font-family: system-ui, sans-serif; - background: #f5f5f5; - color: #1a1a1a; - min-height: 100vh; -} - -nav { - background: #1a1a2e; - color: #fff; - padding: 0.75rem 1.5rem; - display: flex; - align-items: center; - gap: 1.5rem; -} -nav a { color: #ccc; text-decoration: none; } -nav a:hover { color: #fff; } -nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; } -nav .spacer { flex: 1; } - -main { max-width: 800px; margin: 2rem auto; padding: 0 1rem; } - -h1 { font-size: 1.6rem; margin-bottom: 1rem; } -h2 { font-size: 1.2rem; margin-bottom: 0.75rem; } - -.card { - background: #fff; - border-radius: 8px; - padding: 1.5rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - margin-bottom: 1.5rem; -} - -.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; } -.tab-btn { - padding: 0.5rem 1.25rem; - border: 1px solid #ddd; - background: #f5f5f5; - cursor: pointer; - font-size: 0.95rem; -} -.tab-btn:first-child { border-radius: 6px 0 0 6px; } -.tab-btn:last-child { border-radius: 0 6px 6px 0; border-left: none; } -.tab-btn.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; } - -label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: #555; } -input[type=text], input[type=email], input[type=password] { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid #ddd; - border-radius: 5px; - font-size: 0.95rem; - margin-bottom: 0.75rem; -} -input:focus { outline: none; border-color: #1a1a2e; } - -button[type=submit], .btn { - padding: 0.5rem 1.25rem; - background: #1a1a2e; - color: #fff; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 0.95rem; -} -button[type=submit]:hover, .btn:hover { background: #2d2d5e; } -button[type=submit]:disabled { opacity: 0.6; cursor: not-allowed; } - -.error { color: #c0392b; font-size: 0.875rem; margin-top: 0.5rem; } -.success { color: #27ae60; font-size: 0.875rem; margin-top: 0.5rem; } - -.stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - margin-bottom: 1.5rem; -} -.stat-box { - background: #fff; - border-radius: 8px; - padding: 1rem; - text-align: center; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); -} -.stat-box .value { font-size: 2rem; font-weight: 700; } -.stat-box .label { font-size: 0.8rem; color: #777; margin-top: 0.25rem; } - -table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } -th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #eee; color: #555; } -td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; } -tr:last-child td { border-bottom: none; } -tr:hover td { background: #fafafa; } -a { color: #2c5cc5; text-decoration: none; } -a:hover { text-decoration: underline; } - -.outcome-win { color: #27ae60; font-weight: 600; } -.outcome-loss { color: #c0392b; font-weight: 600; } -.outcome-draw { color: #e67e22; font-weight: 600; } - -.loading { color: #777; padding: 1rem 0; } -.empty { color: #aaa; font-style: italic; padding: 1rem 0; } diff --git a/clients/web-user-portal/index.html b/clients/web-user-portal/index.html deleted file mode 100644 index 135091c..0000000 --- a/clients/web-user-portal/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Player Portal - - - - - diff --git a/clients/web-user-portal/src/api.rs b/clients/web-user-portal/src/api.rs deleted file mode 100644 index b6dced9..0000000 --- a/clients/web-user-portal/src/api.rs +++ /dev/null @@ -1,191 +0,0 @@ -use serde::{Deserialize, Serialize}; - -// In debug builds trunk serves on 9092 while the relay is on 8080 — use full URL. -// In release builds the portal is served by the relay itself — use relative paths. -#[cfg(debug_assertions)] -const BASE: &str = "http://localhost:8080"; -#[cfg(not(debug_assertions))] -const BASE: &str = ""; - -fn url(path: &str) -> String { - format!("{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-user-portal/src/app.rs b/clients/web-user-portal/src/app.rs deleted file mode 100644 index 92a121a..0000000 --- a/clients/web-user-portal/src/app.rs +++ /dev/null @@ -1,67 +0,0 @@ -use leptos::prelude::*; -use leptos_router::{components::{Route, Router, Routes, A}, path}; - -use crate::api::{self, MeResponse}; -use crate::pages::{home::HomePage, profile::ProfilePage, game::GamePage}; - -#[derive(Clone, Debug)] -pub struct AuthState { - pub user: RwSignal>, -} - -#[component] -pub fn App() -> impl IntoView { - let user = RwSignal::new(None::); - provide_context(AuthState { user }); - - // Probe session on load. - let auth = use_context::().unwrap(); - let _ = LocalResource::new(move || async move { - if let Ok(me) = api::get_me().await { - auth.user.set(Some(me)); - } - }); - - view! { - -