From 557f0249f8560df9bc3397e1531ea3d669458ff3 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 25 Apr 2026 16:49:25 +0200 Subject: [PATCH] feat: merge web-user-portal & web-game --- Cargo.lock | 23 + Cargo.toml | 1 + clients/web/Cargo.toml | 41 + clients/web/Trunk.toml | 2 + clients/web/assets/diceroll.mp3 | Bin 0 -> 32896 bytes clients/web/assets/style.css | 1396 +++++++++++++++++ clients/web/index.html | 12 + clients/web/locales/en.json | 96 ++ clients/web/locales/fr.json | 96 ++ clients/web/src/api.rs | 191 +++ clients/web/src/app.rs | 459 ++++++ clients/web/src/game/components/board.rs | 594 +++++++ .../src/game/components/connecting_screen.rs | 9 + clients/web/src/game/components/die.rs | 53 + .../web/src/game/components/game_screen.rs | 470 ++++++ clients/web/src/game/components/mod.rs | 9 + .../web/src/game/components/score_panel.rs | 70 + clients/web/src/game/components/scoring.rs | 209 +++ clients/web/src/game/mod.rs | 4 + clients/web/src/game/session.rs | 253 +++ clients/web/src/game/sound.rs | 182 +++ clients/web/src/game/trictrac/backend.rs | 487 ++++++ clients/web/src/game/trictrac/bot_local.rs | 43 + clients/web/src/game/trictrac/mod.rs | 3 + clients/web/src/game/trictrac/types.rs | 256 +++ clients/web/src/main.rs | 14 + clients/web/src/nav.rs | 51 + clients/web/src/portal/account.rs | 166 ++ clients/web/src/portal/game_detail.rs | 109 ++ clients/web/src/portal/lobby.rs | 88 ++ clients/web/src/portal/mod.rs | 4 + clients/web/src/portal/profile.rs | 153 ++ justfile | 21 +- server/relay-server/src/main.rs | 7 +- 34 files changed, 5562 insertions(+), 10 deletions(-) create mode 100644 clients/web/Cargo.toml create mode 100644 clients/web/Trunk.toml create mode 100644 clients/web/assets/diceroll.mp3 create mode 100644 clients/web/assets/style.css create mode 100644 clients/web/index.html create mode 100644 clients/web/locales/en.json create mode 100644 clients/web/locales/fr.json create mode 100644 clients/web/src/api.rs create mode 100644 clients/web/src/app.rs create mode 100644 clients/web/src/game/components/board.rs create mode 100644 clients/web/src/game/components/connecting_screen.rs create mode 100644 clients/web/src/game/components/die.rs create mode 100644 clients/web/src/game/components/game_screen.rs create mode 100644 clients/web/src/game/components/mod.rs create mode 100644 clients/web/src/game/components/score_panel.rs create mode 100644 clients/web/src/game/components/scoring.rs create mode 100644 clients/web/src/game/mod.rs create mode 100644 clients/web/src/game/session.rs create mode 100644 clients/web/src/game/sound.rs create mode 100644 clients/web/src/game/trictrac/backend.rs create mode 100644 clients/web/src/game/trictrac/bot_local.rs create mode 100644 clients/web/src/game/trictrac/mod.rs create mode 100644 clients/web/src/game/trictrac/types.rs create mode 100644 clients/web/src/main.rs create mode 100644 clients/web/src/nav.rs create mode 100644 clients/web/src/portal/account.rs create mode 100644 clients/web/src/portal/game_detail.rs create mode 100644 clients/web/src/portal/lobby.rs create mode 100644 clients/web/src/portal/mod.rs create mode 100644 clients/web/src/portal/profile.rs diff --git a/Cargo.lock b/Cargo.lock index fcb2626..8ce19af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8700,6 +8700,29 @@ dependencies = [ "transpose", ] +[[package]] +name = "trictrac-web" +version = "0.1.0" +dependencies = [ + "backbone-lib", + "futures", + "getrandom 0.3.4", + "gloo-net 0.5.0", + "gloo-storage", + "gloo-timers", + "js-sys", + "leptos", + "leptos_i18n", + "leptos_router", + "rand 0.9.3", + "serde", + "serde_json", + "trictrac-store", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index c0c930c..94a1c6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "store", "clients/cli", "clients/backbone-lib", + "clients/web", "clients/web-game", "clients/web-user-portal", "server/protocol", diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml new file mode 100644 index 0000000..04857a6 --- /dev/null +++ b/clients/web/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "trictrac-web" +version = "0.1.0" +edition = "2021" + +[package.metadata.leptos-i18n] +default = "en" +locales = ["en", "fr"] + +[dependencies] +leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] } +leptos_router = { version = "0.7" } +trictrac-store = { path = "../../store" } +backbone-lib = { path = "../backbone-lib" } +leptos = { version = "0.7", features = ["csr"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +futures = "0.3" +rand = "0.9" +gloo-storage = "0.3" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +gloo-net = { version = "0.5", features = ["http"] } +gloo-timers = { version = "0.3", features = ["futures"] } +getrandom = { version = "0.3", features = ["wasm_js"] } +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "RequestCredentials", + "AudioContext", + "AudioParam", + "AudioNode", + "AudioDestinationNode", + "AudioScheduledSourceNode", + "GainNode", + "OscillatorNode", + "OscillatorType", + "BaseAudioContext", + "HtmlAudioElement", +] } diff --git a/clients/web/Trunk.toml b/clients/web/Trunk.toml new file mode 100644 index 0000000..bae5297 --- /dev/null +++ b/clients/web/Trunk.toml @@ -0,0 +1,2 @@ +[serve] +port = 9091 diff --git a/clients/web/assets/diceroll.mp3 b/clients/web/assets/diceroll.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b16adff42da3003b35d59566b353a5db2fa0c7b7 GIT binary patch literal 32896 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 literal 0 HcmV?d00001 diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css new file mode 100644 index 0000000..b2d89a4 --- /dev/null +++ b/clients/web/assets/style.css @@ -0,0 +1,1396 @@ +/* ── Google Fonts ───────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* ── Site navigation ─────────────────────────────────────────────── */ +.site-nav { + background: var(--board-rail); + border-bottom: 2px solid var(--ui-gold-dark); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + flex-shrink: 0; +} + +.site-nav-brand { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-gold); + text-decoration: none; + letter-spacing: 0.1em; +} +.site-nav-brand:hover { color: #e0b840; } + +.site-nav-spacer { flex: 1; } + +.site-nav a { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s, color 0.15s; +} +.site-nav a:hover { opacity: 1; } + +.site-nav-btn { + padding: 0.3rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.03em; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 4px; + background: transparent; + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.site-nav-btn:hover { + background: rgba(200,164,72,0.12); + border-color: var(--ui-gold); +} + +/* ── Portal main content area ────────────────────────────────────── */ +.portal-main { + flex: 1; + max-width: 900px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.portal-card { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.3); + border-top: 3px solid var(--ui-gold-dark); + border-radius: 6px; + padding: 1.75rem 2rem; + box-shadow: 0 4px 16px rgba(0,0,0,0.18); + margin-bottom: 1.5rem; +} + +.portal-card h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.25rem; +} +.portal-card h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + color: var(--ui-ink); + margin-bottom: 0.75rem; +} + +.portal-meta { + font-size: 0.85rem; + color: #665544; + margin-bottom: 1.5rem; + font-family: var(--font-ui); +} + +/* ── Stats grid ──────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} +.stat-box { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.28); + border-radius: 6px; + padding: 1rem; + text-align: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} +.stat-box .value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + color: var(--ui-gold-dark); +} +.stat-box .label { + font-size: 0.78rem; + color: #665544; + margin-top: 0.2rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Tables ──────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-family: var(--font-ui); +} +th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid rgba(200,164,72,0.4); + color: #665544; + font-weight: 500; + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + color: var(--ui-ink); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(200,164,72,0.05); } + +a { color: var(--ui-gold-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Outcome classes ─────────────────────────────────────────────── */ +.outcome-win { color: var(--ui-green-accent); font-weight: 600; } +.outcome-loss { color: var(--ui-red-accent); font-weight: 600; } +.outcome-draw { color: #c07020; font-weight: 600; } + +/* ── Portal tabs ─────────────────────────────────────────────────── */ +.portal-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(200,164,72,0.3); +} +.portal-tab-btn { + padding: 0.55rem 1.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: transparent; + border: none; + cursor: pointer; + color: #665544; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} +.portal-tab-btn.active { + color: var(--ui-ink); + border-bottom-color: var(--ui-gold-dark); + font-weight: 500; +} + +/* ── Portal form ─────────────────────────────────────────────────── */ +.portal-label { + display: block; + font-size: 0.82rem; + color: #665544; + margin-bottom: 0.3rem; + letter-spacing: 0.03em; +} +.portal-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.35); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + margin-bottom: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.portal-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.18); +} +.portal-submit-btn { + padding: 0.6rem 2rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + border: none; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.25); + transition: opacity 0.15s; +} +.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.portal-submit-btn:not(:disabled):hover { opacity: 0.9; } + +.portal-page-btn { + padding: 0.35rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + background: var(--board-rail); + color: var(--ui-parchment); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} +.portal-page-btn:hover { opacity: 1; } + +.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; } +.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; } +.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; } +.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; } + +/* ── Game overlay (full-screen, covers portal during play) ───────── */ +.game-overlay { + position: fixed; + inset: 0; + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + z-index: 200; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1.5rem; + overflow-y: auto; +} + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + width: 340px; + margin-top: 5vh; + border-radius: 8px; + overflow: hidden; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + background: var(--ui-parchment); +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +.playing-as { + font-size: 0.75rem; + color: rgba(242,232,208,0.7); + font-family: var(--font-ui); +} +.playing-as strong { color: rgba(242,232,208,0.9); } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; +} + +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +.board-and-panel { + position: relative; +} + +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94); + transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); + filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38)); +} + +.scoring-panel-wrapper.peeked { + transform: translateX(100%); +} + +.scoring-panel-wrapper.revealed { + transform: translateX(0); +} + +.scoring-panel-wrapper.peeked:not(.revealed) { + cursor: pointer; +} + +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); + } + .scoring-panel-wrapper.peeked, + .scoring-panel-wrapper.revealed { + transform: none; + cursor: default; + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) ──────────────────────────────────────────────── */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +.field { + --fc: var(--field-ivory); + width: 60px; + height: 180px; + background: transparent; + isolation: isolate; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours (§2b) ────────────────────────────────── */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; } +.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; } + +.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; } +.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; } + +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +.field.clickable { + cursor: pointer; + --fc: #8fc840 !important; +} +.field.clickable:hover { --fc: #74aa28 !important; } +.field.selected { + --fc: #5a8a18 !important; + outline: 2px solid rgba(255,255,255,0.3); + outline-offset: -2px; +} + +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; } +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker slide animation (§4a) ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + min-height: 2rem; +} + +/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ +.ceremony-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} diff --git a/clients/web/index.html b/clients/web/index.html new file mode 100644 index 0000000..7399dbc --- /dev/null +++ b/clients/web/index.html @@ -0,0 +1,12 @@ + + + + + + Trictrac + + + + + + diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json new file mode 100644 index 0000000..3b32f20 --- /dev/null +++ b/clients/web/locales/en.json @@ -0,0 +1,96 @@ +{ + "room_name_placeholder": "Room name", + "create_room": "Create Room", + "join_room": "Join Room", + "connecting": "Connecting…", + "game_over": "Game over", + "waiting_for_opponent": "Waiting for opponent…", + "your_turn_roll": "Your turn — roll the dice", + "hold_or_go": "Hold or Go?", + "select_move": "Move a checker ({{ n }} of 2)", + "your_turn": "Your turn", + "opponent_turn": "Opponent's turn", + "room_label": "Room: {{ id }}", + "quit": "Quit", + "roll_dice": "Roll dice", + "go": "Go", + "empty_move": "Empty move", + "you_suffix": " (you)", + "points_label": "Points", + "holes_label": "Holes", + "bredouille_title": "Can bredouille", + "jan_double": "double", + "jan_simple": "simple", + "jan_filled_quarter": "Quarter filled", + "jan_true_hit_small": "True hit (small jan)", + "jan_true_hit_big": "True hit (big jan)", + "jan_true_hit_corner": "True hit (opp. corner)", + "jan_first_exit": "First to exit", + "jan_six_tables": "Six tables", + "jan_two_tables": "Two tables", + "jan_mezeas": "Mezeas", + "jan_false_hit_small": "False hit (small jan)", + "jan_false_hit_big": "False hit (big jan)", + "jan_contre_two": "Contre two tables", + "jan_contre_mezeas": "Contre mezeas", + "jan_helpless_man": "Helpless man", + "play_vs_bot": "Play vs Bot", + "vs_bot_label": "vs Bot", + "you_win": "You win!", + "opp_wins": "{{ name }} wins!", + "play_again": "Play again", + "after_opponent_roll": "Opponent rolled", + "after_opponent_go": "Opponent chose to continue", + "after_opponent_move": "Opponent moved — your turn", + "after_opponent_pre_game_roll": "Opponent rolled — your turn", + "pre_game_roll_title": "Who goes first?", + "pre_game_roll_btn": "Roll", + "pre_game_roll_tie": "Tie! Roll again", + "pre_game_roll_your_die": "Your die", + "pre_game_roll_opp_die": "Opponent's die", + "continue_btn": "Continue", + "scored_pts": "+{{ n }} pts", + "hole_made": "Hole! {{ holes }}/12", + "bredouille_applied": "Bredouille!", + "hold": "Hold", + "opp_scored_pts": "Opponent +{{ n }} pts", + "opp_hole_made": "Opponent hole! {{ holes }}/12", + "hint_move": "Click a highlighted field to move a checker", + "hint_hold_or_go": "Hold to keep points — Go to reset the setting", + "hint_continue": "Click Continue when ready", + "sign_in": "Sign in", + "sign_out": "Sign out", + "create_account": "Create account", + "account_title": "Account", + "label_username": "Username", + "label_password": "Password", + "label_email": "Email", + "loading": "Loading…", + "member_since": "Member since", + "stat_games": "Games", + "stat_wins": "Wins", + "stat_losses": "Losses", + "stat_draws": "Draws", + "game_history_title": "Game History", + "no_games": "No games recorded yet.", + "col_room": "Room", + "col_started": "Started", + "col_ended": "Ended", + "col_outcome": "Outcome", + "col_detail": "Detail", + "prev_page": "← Prev", + "next_page": "Next →", + "page_label": "Page", + "view_link": "View", + "outcome_win": "win", + "outcome_loss": "loss", + "outcome_draw": "draw", + "players_header": "Players", + "col_player": "Player", + "score_header": "Score", + "game_ongoing": "ongoing", + "anonymous_player": "anonymous", + "started_label": "Started", + "ended_label": "Ended", + "room_detail_title": "Room" +} diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json new file mode 100644 index 0000000..9af6f10 --- /dev/null +++ b/clients/web/locales/fr.json @@ -0,0 +1,96 @@ +{ + "room_name_placeholder": "Nom de la salle", + "create_room": "Créer une salle", + "join_room": "Rejoindre", + "connecting": "Connexion en cours…", + "game_over": "Partie terminée", + "waiting_for_opponent": "En attente de l'adversaire…", + "your_turn_roll": "À votre tour — lancez les dés", + "hold_or_go": "Tenir ou s'en aller ?", + "select_move": "Déplacez une dame ({{ n }} sur 2)", + "your_turn": "Votre tour", + "opponent_turn": "Tour de l'adversaire", + "room_label": "Salle : {{ id }}", + "quit": "Quitter", + "roll_dice": "Lancer les dés", + "go": "S'en aller", + "empty_move": "Mouvement impossible", + "you_suffix": " (vous)", + "points_label": "Points", + "holes_label": "Trous", + "bredouille_title": "Peut faire bredouille", + "jan_double": "double", + "jan_simple": "simple", + "jan_filled_quarter": "Remplissage", + "jan_true_hit_small": "Battage à vrai (petit jan)", + "jan_true_hit_big": "Battage à vrai (grand jan)", + "jan_true_hit_corner": "Battage coin adverse", + "jan_first_exit": "Premier sorti", + "jan_six_tables": "Jan de six tables", + "jan_two_tables": "Jan de deux tables", + "jan_mezeas": "Jan de mézéas", + "jan_false_hit_small": "Battage à faux (petit jan)", + "jan_false_hit_big": "Battage à faux (grand jan)", + "jan_contre_two": "Contre jan de deux tables", + "jan_contre_mezeas": "Contre jan de mezeas", + "jan_helpless_man": "Dame impuissante", + "play_vs_bot": "Jouer contre le bot", + "vs_bot_label": "contre le bot", + "you_win": "Vous avez gagné !", + "opp_wins": "{{ name }} gagne !", + "play_again": "Rejouer", + "after_opponent_roll": "L'adversaire a lancé les dés", + "after_opponent_go": "L'adversaire s'en va", + "after_opponent_move": "L'adversaire a joué — à vous", + "after_opponent_pre_game_roll": "L'adversaire a lancé — à vous", + "pre_game_roll_title": "Qui joue en premier ?", + "pre_game_roll_btn": "Lancer", + "pre_game_roll_tie": "Égalité ! Relancez", + "pre_game_roll_your_die": "Votre dé", + "pre_game_roll_opp_die": "Dé adverse", + "continue_btn": "Continuer", + "scored_pts": "+{{ n }} pts", + "hole_made": "Trou ! {{ holes }}/12", + "bredouille_applied": "Bredouille !", + "hold": "Tenir", + "opp_scored_pts": "Adversaire +{{ n }} pts", + "opp_hole_made": "Trou adverse ! {{ holes }}/12", + "hint_move": "Cliquez un champ surligné pour déplacer", + "hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir", + "hint_continue": "Cliquez Continuer quand vous êtes prêt", + "sign_in": "Se connecter", + "sign_out": "Se déconnecter", + "create_account": "Créer un compte", + "account_title": "Compte", + "label_username": "Nom d'utilisateur", + "label_password": "Mot de passe", + "label_email": "Email", + "loading": "Chargement…", + "member_since": "Membre depuis", + "stat_games": "Parties", + "stat_wins": "Victoires", + "stat_losses": "Défaites", + "stat_draws": "Nuls", + "game_history_title": "Historique", + "no_games": "Aucune partie enregistrée.", + "col_room": "Salle", + "col_started": "Début", + "col_ended": "Fin", + "col_outcome": "Résultat", + "col_detail": "Détail", + "prev_page": "← Précédent", + "next_page": "Suivant →", + "page_label": "Page", + "view_link": "Voir", + "outcome_win": "victoire", + "outcome_loss": "défaite", + "outcome_draw": "nul", + "players_header": "Joueurs", + "col_player": "Joueur", + "score_header": "Score", + "game_ongoing": "en cours", + "anonymous_player": "anonyme", + "started_label": "Début", + "ended_label": "Fin", + "room_detail_title": "Salle" +} diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs new file mode 100644 index 0000000..29032c0 --- /dev/null +++ b/clients/web/src/api.rs @@ -0,0 +1,191 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(debug_assertions)] +pub const HTTP_BASE: &str = "http://localhost:8080"; +#[cfg(not(debug_assertions))] +pub const HTTP_BASE: &str = ""; + +fn url(path: &str) -> String { + format!("{HTTP_BASE}{path}") +} + +// ── Response types ──────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Deserialize)] +pub struct MeResponse { + pub id: i64, + pub username: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UserProfile { + pub id: i64, + pub username: String, + pub created_at: i64, + pub total_games: i64, + pub wins: i64, + pub losses: i64, + pub draws: i64, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GameSummary { + pub id: i64, + pub game_id: String, + pub room_code: String, + pub started_at: i64, + pub ended_at: Option, + pub result: Option, + pub outcome: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GamesResponse { + pub games: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Participant { + pub player_id: i64, + pub outcome: Option, + pub username: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GameDetail { + pub id: i64, + pub game_id: String, + pub room_code: String, + pub started_at: i64, + pub ended_at: Option, + pub result: Option, + pub participants: Vec, +} + +// ── Request bodies ──────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct RegisterBody<'a> { + pub username: &'a str, + pub email: &'a str, + pub password: &'a str, +} + +#[derive(Serialize)] +pub struct LoginBody<'a> { + pub username: &'a str, + pub password: &'a str, +} + +// ── Fetch helpers ───────────────────────────────────────────────────────────── + +pub async fn get_me() -> Result { + let resp = gloo_net::http::Request::get(&url("/auth/me")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn post_login(username: &str, password: &str) -> Result { + let body = LoginBody { username, password }; + let resp = gloo_net::http::Request::post(&url("/auth/login")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn post_register(username: &str, email: &str, password: &str) -> Result { + let body = RegisterBody { username, email, password }; + let resp = gloo_net::http::Request::post(&url("/auth/register")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 201 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn post_logout() -> Result<(), String> { + let resp = gloo_net::http::Request::post(&url("/auth/logout")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 204 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_user_profile(username: &str) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_user_games(username: &str, page: i64) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!( + "/users/{username}/games?page={page}&per_page=20" + ))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_game_detail(id: i64) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + +pub fn format_ts(ts: i64) -> String { + let ms = (ts * 1000) as f64; + let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms)); + date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED) + .as_string() + .unwrap_or_default() +} diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs new file mode 100644 index 0000000..8d604b6 --- /dev/null +++ b/clients/web/src/app.rs @@ -0,0 +1,459 @@ +use futures::channel::mpsc; +use futures::{FutureExt, StreamExt}; +use gloo_storage::{LocalStorage, Storage}; +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos_router::components::{Route, Router, Routes}; +use leptos_router::path; +use serde::{Deserialize, Serialize}; + +use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; +use backbone_lib::traits::ViewStateUpdate; + +use crate::api; +use crate::game::components::{ConnectingScreen, GameScreen}; +use crate::game::session::{ + compute_last_moves, push_or_show, run_local_bot_game, +}; +use crate::game::trictrac::backend::TrictracBackend; +use crate::game::trictrac::types::{ + GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState, +}; +use crate::i18n::I18nContextProvider; +use crate::nav::SiteNav; +use crate::portal::{account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage}; +use trictrac_store::CheckerMove; + +use std::collections::VecDeque; + +const RELAY_URL: &str = "ws://localhost:8080/ws"; +const GAME_ID: &str = "trictrac"; +const STORAGE_KEY: &str = "trictrac_session"; + +/// The state the UI needs to render the game screen. +#[derive(Clone, PartialEq)] +pub struct GameUiState { + pub view_state: ViewState, + /// 0 = host, 1 = guest + pub player_id: u16, + pub room_id: String, + pub is_bot_game: bool, + pub waiting_for_confirm: bool, + pub pause_reason: Option, + pub my_scored_event: Option, + pub opp_scored_event: Option, + pub last_moves: Option<(CheckerMove, CheckerMove)>, +} + +/// Reason the UI is paused waiting for the player to click Continue. +#[derive(Clone, Debug, PartialEq)] +pub enum PauseReason { + AfterOpponentRoll, + AfterOpponentGo, + AfterOpponentMove, + AfterOpponentPreGameRoll, +} + +/// Which screen is currently shown (used to toggle game overlay). +#[derive(Clone, PartialEq)] +pub enum Screen { + Login { error: Option }, + Connecting, + Playing(GameUiState), +} + +/// Commands sent from UI event handlers into the network task. +pub enum NetCommand { + CreateRoom { room: String }, + JoinRoom { room: String }, + Reconnect { + relay_url: String, + game_id: String, + room_id: String, + token: u64, + host_state: Option>, + }, + PlayVsBot, + Action(PlayerAction), + Disconnect, +} + +#[derive(Serialize, Deserialize)] +struct StoredSession { + relay_url: String, + game_id: String, + room_id: String, + token: u64, + #[serde(default)] + is_host: bool, + #[serde(default)] + view_state: Option, +} + +fn save_session(session: &StoredSession) { + LocalStorage::set(STORAGE_KEY, session).ok(); +} + +fn load_session() -> Option { + LocalStorage::get::(STORAGE_KEY).ok() +} + +fn clear_session() { + LocalStorage::delete(STORAGE_KEY); +} + +async fn submit_game_result(room_code: String, game_state: ViewState) { + let [score_pl1, score_pl2] = game_state.scores; + let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes); + let outcomes = if score_pl1.holes < score_pl2.holes { + [("0", "loss"), ("1", "win")] + } else if score_pl2.holes < score_pl1.holes { + [("0", "win"), ("1", "loss")] + } else { + [("0", "draw"), ("1", "draw")] + }; + let body = serde_json::json!({ + "room_code": room_code, + "game_id": GAME_ID, + "result": result_str, + "outcomes": std::collections::HashMap::from(outcomes), + }); + let _ = gloo_net::http::Request::post(&format!("{}/games/result", api::HTTP_BASE)) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .unwrap() + .send() + .await; +} + +#[component] +pub fn App() -> impl IntoView { + let stored = load_session(); + let initial_screen = if stored.is_some() { + Screen::Connecting + } else { + Screen::Login { error: None } + }; + let screen: RwSignal = RwSignal::new(initial_screen); + provide_context(screen); + + // Auth: fetch once on load; shared by nav + game + portal components. + let auth_username: RwSignal> = RwSignal::new(None); + provide_context(auth_username); + spawn_local(async move { + if let Ok(me) = api::get_me().await { + auth_username.set(Some(me.username)); + } + }); + + let (cmd_tx, mut cmd_rx) = mpsc::unbounded::(); + let pending: RwSignal> = RwSignal::new(VecDeque::new()); + provide_context(pending); + provide_context(cmd_tx.clone()); + + if let Some(s) = stored { + let host_state = s + .view_state + .as_ref() + .and_then(|vs| serde_json::to_vec(vs).ok()); + cmd_tx + .unbounded_send(NetCommand::Reconnect { + relay_url: s.relay_url, + game_id: s.game_id, + room_id: s.room_id, + token: s.token, + host_state, + }) + .ok(); + } + + spawn_local(async move { + loop { + let remote_config: Option<(RoomConfig, bool)> = loop { + match cmd_rx.next().await { + Some(NetCommand::PlayVsBot) => break None, + Some(NetCommand::CreateRoom { room }) => { + break Some(( + RoomConfig { + relay_url: RELAY_URL.to_string(), + game_id: GAME_ID.to_string(), + room_id: room, + rule_variation: 0, + role: RoomRole::Create, + reconnect_token: None, + host_state: None, + }, + false, + )); + } + Some(NetCommand::JoinRoom { room }) => { + break Some(( + RoomConfig { + relay_url: RELAY_URL.to_string(), + game_id: GAME_ID.to_string(), + room_id: room, + rule_variation: 0, + role: RoomRole::Join, + reconnect_token: None, + host_state: None, + }, + false, + )); + } + Some(NetCommand::Reconnect { + relay_url, + game_id, + room_id, + token, + host_state, + }) => { + break Some(( + RoomConfig { + relay_url, + game_id, + room_id, + rule_variation: 0, + role: RoomRole::Join, + reconnect_token: Some(token), + host_state, + }, + true, + )); + } + _ => {} + } + }; + + if remote_config.is_none() { + loop { + let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await; + if !restart { + break; + } + } + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: None }); + continue; + } + let (config, is_reconnect) = remote_config.unwrap(); + + screen.set(Screen::Connecting); + + let room_id_for_storage = config.room_id.clone(); + let mut session: GameSession = + match GameSession::connect::(config).await { + Ok(s) => s, + Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => { + if is_reconnect { + clear_session(); + } + screen.set(Screen::Login { error: Some(e) }); + continue; + } + }; + + if !session.is_host { + save_session(&StoredSession { + relay_url: RELAY_URL.to_string(), + game_id: GAME_ID.to_string(), + room_id: room_id_for_storage.clone(), + token: session.reconnect_token, + is_host: false, + view_state: None, + }); + } + + let is_host = session.is_host; + let player_id = session.player_id; + let reconnect_token = session.reconnect_token; + let mut vs = ViewState::default_with_names("Blancs", "Noirs"); + let mut result_submitted = false; + + loop { + futures::select! { + cmd = cmd_rx.next().fuse() => match cmd { + Some(NetCommand::Action(action)) => { + session.send_action(action); + } + _ => { + clear_session(); + session.disconnect(); + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: None }); + break; + } + }, + event = session.next_event().fuse() => match event { + Some(SessionEvent::Update(u)) => { + let prev_vs = vs.clone(); + match u { + ViewStateUpdate::Full(state) => vs = state, + ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta), + } + + if is_host && !result_submitted && vs.stage == SerStage::Ended { + result_submitted = true; + let room = room_id_for_storage.clone(); + let gs = vs.clone(); + spawn_local(submit_game_result(room, gs)); + } + + if is_host { + save_session(&StoredSession { + relay_url: RELAY_URL.to_string(), + game_id: GAME_ID.to_string(), + room_id: room_id_for_storage.clone(), + token: reconnect_token, + is_host: true, + view_state: Some(vs.clone()), + }); + } + let is_own_move = prev_vs.active_mp_player == Some(player_id); + push_or_show( + &prev_vs, + GameUiState { + view_state: vs.clone(), + player_id, + room_id: room_id_for_storage.clone(), + is_bot_game: false, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: None, + opp_scored_event: None, + last_moves: compute_last_moves(&prev_vs, &vs, is_own_move), + }, + pending, + screen, + ); + } + Some(SessionEvent::Disconnected(reason)) => { + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: reason }); + break; + } + None => { + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: None }); + break; + } + } + } + } + } + }); + + view! { + + + // Nav: hidden while game overlay is active + + + // Portal pages — always mounted for router stability +
+ "Page not found."

}> + + + + +
+
+ + // Game overlay: fixed, covers portal during play + {move || { + let q = pending.get(); + let front = q.front().cloned(); + if let Some(state) = front { + return view! { +
+ }.into_any(); + } + match screen.get() { + Screen::Playing(state) => view! { +
+ }.into_any(), + Screen::Connecting => view! { +
+ }.into_any(), + _ => view! { }.into_any(), + } + }} +
+
+ } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::session::infer_pause_reason; + use crate::game::trictrac::types::{PlayerScore, SerStage, SerTurnStage}; + + fn score() -> PlayerScore { + PlayerScore { + name: String::new(), + points: 0, + holes: 0, + can_bredouille: false, + } + } + + fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option) -> ViewState { + ViewState { + board: [0i8; 24], + stage: SerStage::InGame, + turn_stage, + active_mp_player: active, + scores: [score(), score()], + dice, + dice_jans: Vec::new(), + dice_moves: (CheckerMove::default(), CheckerMove::default()), + pre_game_roll: None, + } + } + + #[test] + fn dice_change_is_after_roll() { + let prev = vs((0, 0), SerTurnStage::RollDice, Some(1)); + let next = vs((3, 5), SerTurnStage::Move, Some(1)); + assert_eq!( + infer_pause_reason(&prev, &next, 0), + Some(PauseReason::AfterOpponentRoll) + ); + } + + #[test] + fn hold_to_move_is_after_go() { + let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1)); + let next = vs((3, 5), SerTurnStage::Move, Some(1)); + assert_eq!( + infer_pause_reason(&prev, &next, 0), + Some(PauseReason::AfterOpponentGo) + ); + } + + #[test] + fn turn_switch_is_after_move() { + let prev = vs((3, 5), SerTurnStage::Move, Some(1)); + let next = vs((3, 5), SerTurnStage::RollDice, Some(0)); + assert_eq!( + infer_pause_reason(&prev, &next, 0), + Some(PauseReason::AfterOpponentMove) + ); + } + + #[test] + fn own_action_returns_none() { + let prev = vs((0, 0), SerTurnStage::RollDice, Some(0)); + let next = vs((2, 4), SerTurnStage::Move, Some(0)); + assert_eq!(infer_pause_reason(&prev, &next, 0), None); + } + + #[test] + fn no_active_player_returns_none() { + let mut prev = vs((0, 0), SerTurnStage::RollDice, None); + prev.stage = SerStage::PreGame; + let mut next = prev.clone(); + next.active_mp_player = Some(0); + assert_eq!(infer_pause_reason(&prev, &next, 0), None); + } +} diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs new file mode 100644 index 0000000..1e4a5a3 --- /dev/null +++ b/clients/web/src/game/components/board.rs @@ -0,0 +1,594 @@ +use leptos::prelude::*; +use trictrac_store::CheckerMove; + +use super::die::Die; +use crate::game::trictrac::types::{SerTurnStage, ViewState}; + +/// Field numbers in visual display order (left-to-right for each quarter), white's perspective. +const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; +const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24]; +const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7]; +const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1]; + +/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom. +const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6]; +const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12]; +const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19]; +const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; + +/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system. +/// Returns true when `field_num` is the rest corner for this perspective. +#[allow(dead_code)] +fn is_rest_corner(field_num: u8, is_white: bool) -> bool { + if is_white { + field_num == 12 + } else { + field_num == 13 + } +} + +/// Zone CSS class for a field number (field coordinates are always White's 1-24). +fn field_zone_class(field_num: u8) -> &'static str { + match field_num { + 1..=6 => "zone-petit", + 7..=12 => "zone-grand", + 13..=18 => "zone-opponent", + 19..=24 => "zone-retour", + _ => "", + } +} + +/// Returns (d0_used, d1_used) for the bar dice display. +fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { + let mut d0 = false; + let mut d1 = false; + for &(from, to) in staged { + let dist = if from < to { + to.saturating_sub(from) + } else { + from.saturating_sub(to) + }; + if !d0 && dist == dice.0 { + d0 = true; + } else if !d1 && dist == dice.1 { + d1 = true; + } else if !d0 { + d0 = true; + } else { + d1 = true; + } + } + (d0, d1) +} + +/// Returns the displayed board value for `field_num` after applying `staged_moves`. +/// Field numbers are always in white's coordinate system (1–24). +fn displayed_value( + base_board: [i8; 24], + staged_moves: &[(u8, u8)], + is_white: bool, + field_num: u8, +) -> i8 { + let mut val = base_board[(field_num - 1) as usize]; + let delta: i8 = if is_white { 1 } else { -1 }; + for &(from, to) in staged_moves { + if from == field_num { + val -= delta; + } + if to == field_num { + val += delta; + } + } + val +} + +/// Fields whose checkers may be selected as the next origin given already-staged moves. +fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec { + let mut v: Vec = match staged.len() { + 0 => seqs + .iter() + .map(|(m1, _)| m1.get_from() as u8) + .filter(|&f| f != 0) + .collect(), + 1 => { + let (f0, t0) = staged[0]; + seqs.iter() + .filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0) + .map(|(_, m2)| m2.get_from() as u8) + .filter(|&f| f != 0) + .collect() + } + _ => vec![], + }; + v.sort_unstable(); + v.dedup(); + v +} + +/// Pixel center of a board field in the SVG overlay coordinate space. +/// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px. +/// With triangular flèches, arrows target the WIDE BASE of each triangle — +/// that is where the checker stack actually sits. +fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { + if f == 0 || f > 24 { + return None; + } + let (qi, right, top): (usize, bool, bool) = if is_white { + match f { + 13..=18 => (f - 13, false, true), + 19..=24 => (f - 19, true, true), + 7..=12 => (12 - f, false, false), + 1..=6 => (6 - f, true, false), + _ => return None, + } + } else { + match f { + 1..=6 => (f - 1, false, true), + 7..=12 => (f - 7, true, true), + 19..=24 => (24 - f, false, false), + 13..=18 => (18 - f, true, false), + _ => return None, + } + }; + // Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i + // Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i + let x = if right { + 480.0 + qi as f32 * 62.0 + } else { + 34.0 + qi as f32 * 62.0 + }; + // Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358. + // (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360) + let y = if top { 30.0 } else { 358.0 }; + Some((x, y)) +} + +/// SVG `` element drawing one arrow (shadow + gold) from `fp` to `tp`. +fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { + let (x1, y1) = fp; + let (x2, y2) = tp; + let dx = x2 - x1; + let dy = y2 - y1; + let len = (dx * dx + dy * dy).sqrt(); + if len < 10.0 { + return view! { }.into_any(); + } + let nx = dx / len; + let ny = dy / len; + let px = -ny; + let py = nx; + + // Shrink line ends so arrows don't overlap the checker stack + let lx1 = x1 + nx * 20.0; + let ly1 = y1 + ny * 20.0; + let lx2 = x2 - nx * 15.0; + let ly2 = y2 - ny * 15.0; + + // Arrowhead triangle at (x2, y2) + let ah = 15.0_f32; + let aw = 7.0_f32; + let bx = x2 - nx * ah; + let bary = y2 - ny * ah; + let pts = format!( + "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", + x2, + y2, + bx + px * aw, + bary + py * aw, + bx - px * aw, + bary - py * aw, + ); + let shadow_pts = format!( + "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", + x2, + y2, + bx + px * (aw + 1.5), + bary + py * (aw + 1.5), + bx - px * (aw + 1.5), + bary - py * (aw + 1.5), + ); + + view! { + + // Drop-shadow for readability on coloured fields + + + // Gold arrow + + + + } + .into_any() +} + +/// Valid destinations for a selected origin given already-staged moves. +/// May include 0 (exit); callers handle that case. +fn valid_dests_for( + seqs: &[(CheckerMove, CheckerMove)], + staged: &[(u8, u8)], + origin: u8, +) -> Vec { + let mut v: Vec = match staged.len() { + 0 => seqs + .iter() + .filter(|(m1, _)| m1.get_from() as u8 == origin) + .map(|(m1, _)| m1.get_to() as u8) + .collect(), + 1 => { + let (f0, t0) = staged[0]; + seqs.iter() + .filter(|(m1, m2)| { + m1.get_from() as u8 == f0 + && m1.get_to() as u8 == t0 + && m2.get_from() as u8 == origin + }) + .map(|(_, m2)| m2.get_to() as u8) + .collect() + } + _ => vec![], + }; + v.sort_unstable(); + v.dedup(); + v +} + +#[component] +pub fn Board( + view_state: ViewState, + player_id: u16, + /// Pending origin selection (first click of a move pair). + selected_origin: RwSignal>, + /// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move. + staged_moves: RwSignal>, + /// All valid two-move sequences for this turn (empty when not in move stage). + valid_sequences: Vec<(CheckerMove, CheckerMove)>, + /// Dice to display in the center bars; None means dice not yet rolled (cups shown upright). + #[prop(default = None)] + bar_dice: Option<(u8, u8)>, + /// Whether we're in the move stage (determines used/unused die appearance). + #[prop(default = false)] + bar_is_move: bool, + #[prop(default = false)] is_my_turn: bool, + /// Whether the dice are a double (golden glow). + #[prop(default = false)] + bar_is_double: bool, + /// Checker moves to animate on mount (None when board unchanged). + #[prop(default = None)] + last_moves: Option<(CheckerMove, CheckerMove)>, + /// Fields where a hit (battue) was scored this turn — show ripple animation. + #[prop(default = vec![])] + hit_fields: Vec, +) -> impl IntoView { + let board = view_state.board; + let is_move_stage = view_state.active_mp_player == Some(player_id) + && matches!( + view_state.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ); + let is_white = player_id == 0; + let hovered_moves = use_context::>>(); + + // Exit-eligible (§8c): all the player's checkers are in their last jan. + // White last jan = fields 19-24 (board indices 18-23, positive values). + // Black last jan = fields 1-6 (board indices 0-5, negative values). + let board_snapshot = view_state.board; + let all_in_exit: bool; + let exit_field_test: fn(u8) -> bool; + if is_white { + let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum(); + all_in_exit = total > 0 && in_exit == total; + exit_field_test = |f| matches!(f, 19..=24); + } else { + let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum(); + let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum(); + all_in_exit = total > 0 && in_exit == total; + exit_field_test = |f| matches!(f, 1..=6); + } + + // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc). + let fields_from = |nums: &[u8], is_top_row: bool| -> Vec { + nums.iter() + .map(|&field_num| { + // Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)> + // is Send, which Leptos requires for reactive attribute functions. + let seqs_c = valid_sequences.clone(); + let seqs_k = valid_sequences.clone(); + let corner_title = if is_rest_corner(field_num, is_white) { + Some("Coin de repos — must enter and leave with 2 checkers") + } else { + None + }; + // §4a — slide delta for the arriving checker at this field. + // Computed once per field at render time; Option<(f32,f32)> is Copy. + let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| { + [m1, m2].iter().find_map(|m| { + if m.get_to() != field_num as usize || m.get_from() == m.get_to() { + return None; + } + let (fx, fy) = field_center(m.get_from(), is_white)?; + let (tx, ty) = field_center(m.get_to(), is_white)?; + let dx = fx - tx; + let dy = fy - ty; + (dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy)) + }) + }); + // §6e — ripple on hit fields (battue). + let is_hit_field = hit_fields.contains(&field_num); + view! { +
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/src/game/components/connecting_screen.rs b/clients/web/src/game/components/connecting_screen.rs new file mode 100644 index 0000000..6f40da5 --- /dev/null +++ b/clients/web/src/game/components/connecting_screen.rs @@ -0,0 +1,9 @@ +use leptos::prelude::*; + +use crate::i18n::*; + +#[component] +pub fn ConnectingScreen() -> impl IntoView { + let i18n = use_i18n(); + view! {

{t!(i18n, connecting)}

} +} diff --git a/clients/web/src/game/components/die.rs b/clients/web/src/game/components/die.rs new file mode 100644 index 0000000..7576280 --- /dev/null +++ b/clients/web/src/game/components/die.rs @@ -0,0 +1,53 @@ +use leptos::prelude::*; + +/// (cx, cy) positions for dots on a 48×48 die face. +fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] { + match value { + 1 => &[("24", "24")], + 2 => &[("35", "13"), ("13", "35")], + 3 => &[("35", "13"), ("24", "24"), ("13", "35")], + 4 => &[("13", "13"), ("35", "13"), ("13", "35"), ("35", "35")], + 5 => &[("13", "13"), ("35", "13"), ("24", "24"), ("13", "35"), ("35", "35")], + 6 => &[("13", "13"), ("35", "13"), ("13", "24"), ("35", "24"), ("13", "35"), ("35", "35")], + _ => &[], + } +} + +/// A single die face rendered as SVG. +/// `value` 1–6 shows dots; 0 shows an empty face (not-yet-rolled). +/// `used` dims the die. +/// `is_double` applies a golden glow (both dice same value). +#[component] +pub fn Die( + value: u8, + used: bool, + #[prop(default = false)] is_double: bool, +) -> AnyView { + let mut cls = if used { + "die-face die-used".to_string() + } else { + "die-face".to_string() + }; + if is_double && !used { + cls.push_str(" die-double"); + } + if value == 0 { + return view! { + + + {"?"} + + }.into_any(); + } + let dots: Vec = dot_positions(value) + .iter() + .map(|&(cx, cy)| view! { }.into_any()) + .collect(); + view! { + + + {dots} + + }.into_any() +} diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs new file mode 100644 index 0000000..3806a41 --- /dev/null +++ b/clients/web/src/game/components/game_screen.rs @@ -0,0 +1,470 @@ +use std::cell::Cell; +use std::collections::VecDeque; + +use futures::channel::mpsc::UnboundedSender; +use leptos::prelude::*; +use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; + +use super::die::Die; +use crate::app::{GameUiState, NetCommand, PauseReason}; +use crate::i18n::*; +use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage}; + +use super::board::Board; +use super::score_panel::PlayerScorePanel; +use super::scoring::ScoringPanel; + +#[component] +pub fn GameScreen(state: GameUiState) -> impl IntoView { + let i18n = use_i18n(); + + let auth_username = + use_context::>>().unwrap_or_else(|| RwSignal::new(None)); + let vs = state.view_state.clone(); + let player_id = state.player_id; + let is_my_turn = vs.active_mp_player == Some(player_id); + let is_move_stage = is_my_turn + && matches!( + vs.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ); + let waiting_for_confirm = state.waiting_for_confirm; + let pause_reason = state.pause_reason.clone(); + + // ── Hovered jan moves (shown as arrows on the board) ────────────────────── + let hovered_jan_moves: RwSignal> = RwSignal::new(vec![]); + provide_context(hovered_jan_moves); + + // ── Staged move state ────────────────────────────────────────────────────── + let selected_origin: RwSignal> = RwSignal::new(None); + let staged_moves: RwSignal> = RwSignal::new(Vec::new()); + + let cmd_tx = use_context::>() + .expect("UnboundedSender not found in context"); + let pending = + use_context::>>().expect("pending not found in context"); + let cmd_tx_effect = cmd_tx.clone(); + // Non-reactive counter so we can detect when staged_moves grows without + // returning a value from the Effect (which causes a Leptos reactive loop + // when the Effect also writes to the same signal it reads). + let prev_staged_len = Cell::new(0usize); + + Effect::new(move |_| { + let moves = staged_moves.get(); + let n = moves.len(); + // Play checker sound whenever a move is added (own moves, immediate feedback). + if n > prev_staged_len.get() { + crate::game::sound::play_checker_move(); + } + prev_staged_len.set(n); + if n == 2 { + let to_cm = |&(from, to): &(u8, u8)| { + CheckerMove::new(from as usize, to as usize).unwrap_or_default() + }; + cmd_tx_effect + .unbounded_send(NetCommand::Action(PlayerAction::Move( + to_cm(&moves[0]), + to_cm(&moves[1]), + ))) + .ok(); + staged_moves.set(vec![]); + selected_origin.set(None); + // Reset the counter so the next turn starts clean. + prev_staged_len.set(0); + } + }); + + // ── Auto-roll effect ───────────────────────────────────────────────────── + // GameScreen is fully re-mounted on every ViewState update (state is a + // plain prop, not a signal), so this effect fires exactly once per + // RollDice phase entry and will not double-send. + // Guard: suppressed while waiting_for_confirm — the AfterOpponentMove + // buffered state shows the human's RollDice turn but the auto-roll must + // wait until the buffer is drained and the live screen state is shown. + // Guard: never auto-roll during the pre-game ceremony (the ceremony overlay + // has its own Roll button for PlayerAction::PreGameRoll). + let show_roll = + is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll; + if show_roll && !waiting_for_confirm { + let cmd_tx_auto = cmd_tx.clone(); + Effect::new(move |_| { + cmd_tx_auto + .unbounded_send(NetCommand::Action(PlayerAction::Roll)) + .ok(); + }); + } + + let dice = vs.dice; + let show_dice = dice != (0, 0); + + // ── Button senders ───────────────────────────────────────────────────────── + let cmd_tx_go = cmd_tx.clone(); + let cmd_tx_quit = cmd_tx.clone(); + let cmd_tx_end_quit = cmd_tx.clone(); + let cmd_tx_end_replay = cmd_tx.clone(); + // Only show the fallback Go button when there is no ScoringPanel showing it. + let show_hold_go = is_my_turn + && vs.turn_stage == SerTurnStage::HoldOrGoChoice + && state.my_scored_event.is_none(); + + // ── Valid move sequences for this turn ───────────────────────────────────── + // Computed once per ViewState snapshot; used by Board (highlighting) and the + // empty-move button (visibility). + let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) { + let mut store_board = StoreBoard::new(); + store_board.set_positions(&Color::White, vs.board); + let store_dice = StoreDice { values: dice }; + let color = if player_id == 0 { + Color::White + } else { + Color::Black + }; + let rules = MoveRules::new(&color, &store_board, store_dice); + let raw = rules.get_possible_moves_sequences(true, vec![]); + if player_id == 0 { + raw + } else { + raw.into_iter() + .map(|(m1, m2)| (m1.mirror(), m2.mirror())) + .collect() + } + } else { + vec![] + }; + // Clone for the empty-move button reactive closure (Board consumes the original). + let valid_seqs_empty = valid_sequences.clone(); + + // ── Scores ───────────────────────────────────────────────────────────────── + let my_score = vs.scores[player_id as usize].clone(); + let opp_score = vs.scores[1 - player_id as usize].clone(); + + // ── Ceremony state (extracted before vs is moved into Board) ──────────────── + let is_ceremony = vs.stage == SerStage::PreGameRoll; + let pre_game_roll_data: Option = vs.pre_game_roll.clone(); + let my_name_ceremony = my_score.name.clone(); + let opp_name_ceremony = opp_score.name.clone(); + let cmd_tx_ceremony = cmd_tx.clone(); + + // ── Scoring notifications ────────────────────────────────────────────────── + let my_scored_event = state.my_scored_event.clone(); + let opp_scored_event = state.opp_scored_event.clone(); + let hole_toast_info = my_scored_event + .as_ref() + .filter(|e| e.holes_gained > 0) + .map(|e| (e.holes_total, e.bredouille)); + + let is_double_dice = dice.0 == dice.1 && dice.0 != 0; + + let last_moves = state.last_moves; + + // §6e — fields where a battue (hit) was scored; ripple animation shown there. + let hit_fields: Vec = { + let is_hit_jan = |jan: &Jan| { + matches!( + jan, + Jan::TrueHitSmallJan + | Jan::TrueHitBigJan + | Jan::TrueHitOpponentCorner + | Jan::FalseHitSmallJan + | Jan::FalseHitBigJan + ) + }; + let mut fields: Vec = vec![]; + for event_opt in [&my_scored_event, &opp_scored_event] { + if let Some(event) = event_opt { + for entry in &event.jans { + if is_hit_jan(&entry.jan) { + for (m1, m2) in &entry.moves { + for m in [m1, m2] { + let to = m.get_to() as u8; + if to != 0 && !fields.contains(&to) { + fields.push(to); + } + } + } + } + } + } + } + fields + }; + + // ── Sound effects (fire once on mount = once per state snapshot) ────────── + // Dice roll: dice just appeared (no preceding moves in this snapshot). + if show_dice && last_moves.is_none() { + crate::game::sound::play_dice_roll(); + } + // Checker move: moves were committed in the preceding action. + if last_moves.is_some() { + crate::game::sound::play_checker_move(); + } + // Scoring: hole takes priority over plain points. + if let Some(ref ev) = my_scored_event { + if ev.holes_gained > 0 { + crate::game::sound::play_hole_scored(); + } else { + crate::game::sound::play_points_scored(); + } + } + + // ── Capture for closures ─────────────────────────────────────────────────── + let stage = vs.stage.clone(); + let turn_stage = vs.turn_stage.clone(); + let turn_stage_for_panel = turn_stage.clone(); + let turn_stage_for_sub = turn_stage.clone(); + let room_id = state.room_id.clone(); + let is_bot_game = state.is_bot_game; + + // ── Game-over info ───────────────────────────────────────────────────────── + let stage_is_ended = stage == SerStage::Ended; + let winner_is_me = my_score.holes >= 12; + let my_name_end = my_score.name.clone(); + let my_holes_end = my_score.holes; + let opp_name_end = opp_score.name.clone(); + let opp_holes_end = opp_score.holes; + + view! { +
+ // ── Top bar ────────────────────────────────────────────────────── +
+ {move || if is_bot_game { + t_string!(i18n, vs_bot_label).to_owned() + } else { + t_string!(i18n, room_label, id = room_id.as_str()) + }} +
+ + +
+ + {move || auth_username.get().map(|u| view! { + "▶ " {u} + })} + + {t!(i18n, quit)} +
+ + // ── Opponent score (above board) ───────────────────────────────── + + + // ── Status bar — full width, above board (§10b) ────────────────── +
+ {move || { + if let Some(ref reason) = pause_reason { + return String::from(match reason { + PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), + PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), + PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), + PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll), + }); + } + let n = staged_moves.get().len(); + if is_move_stage { + t_string!(i18n, select_move, n = n + 1) + } else { + String::from(match (&stage, is_my_turn, &turn_stage) { + (SerStage::Ended, _, _) => t_string!(i18n, game_over), + (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent), + (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), + (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), + (SerStage::InGame, true, _) => t_string!(i18n, your_turn), + (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), + }) + } + }} +
+ + // ── Contextual sub-prompt (§8a) ────────────────────────────────── + {move || { + let hint: String = if waiting_for_confirm { + t_string!(i18n, hint_continue).to_owned() + } else if is_move_stage { + t_string!(i18n, hint_move).to_owned() + } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice { + t_string!(i18n, hint_hold_or_go).to_owned() + } else { + String::new() + }; + (!hint.is_empty()).then(|| view! {

{hint}

}) + }} + + // ── Board + side panel ─────────────────────────────────────────── +
+ + + // ── Side panel (scoring panels only) ───────────────────────── +
+ {my_scored_event.map(|event| view! { + + })} + {opp_scored_event.map(|event| view! { + + })} +
+
+ + // ── Action buttons below board (§10c) ──────────────────────────── +
+ {waiting_for_confirm.then(|| view! { + + })} + // 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/src/game/components/mod.rs b/clients/web/src/game/components/mod.rs new file mode 100644 index 0000000..4c48cbd --- /dev/null +++ b/clients/web/src/game/components/mod.rs @@ -0,0 +1,9 @@ +mod board; +mod connecting_screen; +mod die; +mod game_screen; +mod score_panel; +mod scoring; + +pub use connecting_screen::ConnectingScreen; +pub use game_screen::GameScreen; diff --git a/clients/web/src/game/components/score_panel.rs b/clients/web/src/game/components/score_panel.rs new file mode 100644 index 0000000..2ce14ef --- /dev/null +++ b/clients/web/src/game/components/score_panel.rs @@ -0,0 +1,70 @@ +use leptos::prelude::*; +use trictrac_store::Jan; + +use crate::i18n::*; +use crate::game::trictrac::types::PlayerScore; + +pub fn jan_label(jan: &Jan) -> String { + let i18n = use_i18n(); + match jan { + Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(), + Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(), + Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(), + Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(), + Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(), + Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(), + Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(), + Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(), + Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(), + Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(), + Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(), + Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(), + Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(), + } +} + +#[component] +pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView { + let i18n = use_i18n(); + + let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100)); + let points_val = format!("{}/12", score.points); + let holes = score.holes; + let can_bredouille = score.can_bredouille; + + // 12 peg holes; filled up to `holes` + let pegs: Vec = (1u8..=12) + .map(|i| { + let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" }; + view! {
}.into_any() + }) + .collect(); + + view! { +
+
+ + {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/src/game/components/scoring.rs b/clients/web/src/game/components/scoring.rs new file mode 100644 index 0000000..d1966be --- /dev/null +++ b/clients/web/src/game/components/scoring.rs @@ -0,0 +1,209 @@ +use futures::channel::mpsc::UnboundedSender; +#[cfg(target_arch = "wasm32")] +use gloo_timers::future::TimeoutFuture; +use leptos::prelude::*; +use trictrac_store::CheckerMove; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::spawn_local; +#[cfg(target_arch = "wasm32")] +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; + +use crate::app::NetCommand; +use crate::i18n::*; +use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage}; + +use super::score_panel::jan_label; + +/// One row in the scoring panel. Sets the hovered-moves context on enter +/// (so board shows arrows for that jan's moves), but does NOT clear on +/// leave — clearing is handled by the outer wrapper's mouseleave so that +/// arrows persist while the pointer moves between rows. +fn scoring_jan_row(entry: JanEntry) -> impl IntoView { + let i18n = use_i18n(); + let hovered = use_context::>>(); + let jan = entry.jan; + let is_double = entry.is_double; + let ways_tag = format!("×{}", entry.ways); + let pts_str = format!("+{}", entry.total); + let moves_hover = entry.moves.clone(); + + view! { +
+ {move || jan_label(&jan)} + {move || if is_double { + t_string!(i18n, jan_double).to_owned() + } else { + t_string!(i18n, jan_simple).to_owned() + }} + {ways_tag} + {pts_str} +
+ } +} + +#[component] +pub fn ScoringPanel( + event: ScoredEvent, + turn_stage: SerTurnStage, + #[prop(default = false)] is_opponent: bool, +) -> impl IntoView { + let i18n = use_i18n(); + let cmd_tx = use_context::>() + .expect("UnboundedSender not found in context"); + + let points_earned = event.points_earned; + let holes_gained = event.holes_gained; + let holes_total = event.holes_total; + let bredouille = event.bredouille; + let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice; + let panel_class = if is_opponent { + "scoring-panel scoring-panel-opp" + } else { + "scoring-panel" + }; + + // ── Lifecycle signals ────────────────────────────────────────────────── + // peeked: added after 3.4 s (slide to peek strip) + // revealed: added on first hover of the peek strip (stay open permanently) + let peeked = RwSignal::new(false); + let revealed = RwSignal::new(false); + + // ── Collect all moves from all jans for automatic arrow display ──────── + let all_moves: Vec<(CheckerMove, CheckerMove)> = event + .jans + .iter() + .flat_map(|e| e.moves.iter().cloned()) + .collect(); + let all_moves_click = all_moves.clone(); + let all_moves_enter = all_moves.clone(); + + let hovered_ctx = use_context::>>(); + + // On mount: show all this event's moves as board arrows immediately, + // then after 3.4 s slide to peek and clear the arrows. + // + // Two important constraints: + // 1. The initial hm.set() must be deferred (spawn_local, not sync in body) + // to avoid writing a reactive signal mid-render while Board reads it — + // that triggers Leptos's cycle guard → `unreachable` WASM panic. + // 2. The cancellation flag must be Rc>, NOT RwSignal. + // RwSignal is a NodeId into Leptos's arena; the arena slot is freed + // when ScoringPanel's owner drops (on every GameScreen remount). If the + // 3.4 s future outlives the component and calls is_alive.get_untracked() + // on a freed slot, that also panics with `unreachable`. Rc> + // is reference-counted outside the arena and stays valid for as long as + // the future holds onto it. + #[cfg(target_arch = "wasm32")] + if let Some(hm) = hovered_ctx { + let is_alive = Arc::new(AtomicBool::new(true)); + let is_alive_cleanup = is_alive.clone(); + // on_cleanup requires Send + Sync; Arc satisfies both. + on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed)); + + spawn_local(async move { + // Show arrows (runs in the next microtask, after render settles). + hm.set(all_moves); + + TimeoutFuture::new(3_400).await; + // Guard: component may have been destroyed while we were waiting. + // is_alive was set to false by on_cleanup, which runs before Leptos + // frees the signal arena slots — so peeked is still valid iff this + // returns true. + if !is_alive.load(Ordering::Relaxed) { + return; + } + hm.set(vec![]); + peeked.set(true); + }); + } + + let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect(); + + view! { + // ── Outer wrapper: owns the slide / peek / reveal animation ─────── + // pointer-events are on by default (parent .side-panel sets none, + // and .scoring-panel-wrapper overrides back to auto in CSS). +
+
+
+ {move || if is_opponent { + t_string!(i18n, opp_scored_pts, n = points_earned) + } else { + t_string!(i18n, scored_pts, n = points_earned) + }} +
+ {jan_rows} + {(holes_gained > 0).then(|| view! { +
+ {move || if is_opponent { + t_string!(i18n, opp_hole_made, holes = holes_total) + } else { + t_string!(i18n, hole_made, holes = holes_total) + }} + {bredouille.then(|| view! { + + {move || t_string!(i18n, bredouille_applied)} + + })} +
+ })} + {show_hold_go.then(|| { + let dismissed = RwSignal::new(false); + view! { +
+ // stop_propagation so these buttons don't also toggle the panel + + +
+ } + })} +
+
+ } +} diff --git a/clients/web/src/game/mod.rs b/clients/web/src/game/mod.rs new file mode 100644 index 0000000..c92b6f2 --- /dev/null +++ b/clients/web/src/game/mod.rs @@ -0,0 +1,4 @@ +pub mod components; +pub mod session; +pub mod sound; +pub mod trictrac; diff --git a/clients/web/src/game/session.rs b/clients/web/src/game/session.rs new file mode 100644 index 0000000..e648d01 --- /dev/null +++ b/clients/web/src/game/session.rs @@ -0,0 +1,253 @@ +use futures::channel::mpsc; +use leptos::prelude::*; + +use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; + +use crate::app::{GameUiState, NetCommand, PauseReason, Screen}; +use crate::game::trictrac::backend::TrictracBackend; +use crate::game::trictrac::bot_local::bot_decide; +use crate::game::trictrac::types::{ + JanEntry, ScoredEvent, SerStage, SerTurnStage, ViewState, +}; +use trictrac_store::CheckerMove; + +use std::collections::VecDeque; + +/// Runs one local bot game. Returns `true` if the player wants to play again. +pub async fn run_local_bot_game( + screen: RwSignal, + cmd_rx: &mut mpsc::UnboundedReceiver, + pending: RwSignal>, +) -> bool { + let mut backend = TrictracBackend::new(0); + backend.player_arrival(0); + backend.player_arrival(1); + + let mut vs = ViewState::default_with_names("You", "Bot"); + for cmd in backend.drain_commands() { + match cmd { + BackendCommand::ResetViewState => { + vs = backend.get_view_state().clone(); + } + BackendCommand::Delta(delta) => { + vs.apply_delta(&delta); + } + _ => {} + } + } + screen.set(Screen::Playing(GameUiState { + view_state: vs.clone(), + player_id: 0, + room_id: String::new(), + is_bot_game: true, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: None, + opp_scored_event: None, + last_moves: None, + })); + + use futures::StreamExt; + loop { + match cmd_rx.next().await { + Some(NetCommand::Action(action)) => { + let prev_vs = vs.clone(); + backend.inform_rpc(0, action); + for cmd in backend.drain_commands() { + if let BackendCommand::Delta(delta) = cmd { + vs.apply_delta(&delta); + } + } + let scored = compute_scored_event(&prev_vs, &vs, 0); + let opp_scored = compute_scored_event(&prev_vs, &vs, 1); + screen.set(Screen::Playing(GameUiState { + view_state: vs.clone(), + player_id: 0, + room_id: String::new(), + is_bot_game: true, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: scored, + opp_scored_event: opp_scored, + last_moves: compute_last_moves(&prev_vs, &vs, true), + })); + } + Some(NetCommand::PlayVsBot) => return true, + _ => return false, + } + + loop { + let pgr = backend.get_view_state().pre_game_roll.clone(); + match bot_decide(backend.get_game(), pgr.as_ref()) { + None => break, + Some(action) => { + backend.inform_rpc(1, action); + for cmd in backend.drain_commands() { + if let BackendCommand::Delta(delta) = cmd { + let delta_prev_vs = vs.clone(); + vs.apply_delta(&delta); + push_or_show( + &delta_prev_vs, + GameUiState { + view_state: vs.clone(), + player_id: 0, + room_id: String::new(), + is_bot_game: true, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: None, + opp_scored_event: None, + last_moves: compute_last_moves(&delta_prev_vs, &vs, false), + }, + pending, + screen, + ); + } + } + } + } + } + } +} + +/// Returns the checker moves to animate when the board changed between two ViewStates. +pub fn compute_last_moves( + prev: &ViewState, + next: &ViewState, + own_move: bool, +) -> Option<(CheckerMove, CheckerMove)> { + if prev.board == next.board { + return None; + } + let (m1, m2) = next.dice_moves; + if m1 == CheckerMove::default() && m2 == CheckerMove::default() { + return None; + } + if own_move { + if m2 == CheckerMove::default() { + return None; + } + return Some((m2, CheckerMove::default())); + } + Some((m1, m2)) +} + +/// Computes a scoring event for `player_id` by comparing the previous and next ViewState. +pub fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option { + let prev_score = &prev.scores[player_id as usize]; + let next_score = &next.scores[player_id as usize]; + + let holes_gained = next_score.holes.saturating_sub(prev_score.holes); + if holes_gained == 0 && prev_score.points == next_score.points { + return None; + } + + let bredouille = holes_gained > 0 && prev_score.can_bredouille; + + let my_jans: Vec = if next.active_mp_player == Some(player_id) + && prev.active_mp_player == Some(player_id) + { + next.dice_jans + .iter() + .filter(|e| e.total > 0) + .cloned() + .collect() + } else if next.active_mp_player == Some(player_id) && prev.active_mp_player != Some(player_id) { + next.dice_jans + .iter() + .filter(|e| e.total < 0) + .map(|e| JanEntry { + total: -e.total, + points_per: -e.points_per, + ..e.clone() + }) + .collect() + } else { + return None; + }; + + let points_earned: u8 = my_jans + .iter() + .fold(0u8, |acc, e| acc.saturating_add(e.total.unsigned_abs())); + + if points_earned == 0 && holes_gained == 0 { + return None; + } + + Some(ScoredEvent { + points_earned, + holes_gained, + holes_total: next_score.holes, + bredouille, + jans: my_jans, + }) +} + +/// Either queues the state as a confirmation step or shows it immediately. +pub fn push_or_show( + prev_vs: &ViewState, + new_state: GameUiState, + pending: RwSignal>, + screen: RwSignal, +) { + let scored = compute_scored_event(prev_vs, &new_state.view_state, new_state.player_id); + let opp_scored = compute_scored_event(prev_vs, &new_state.view_state, 1 - new_state.player_id); + + if let Some(reason) = infer_pause_reason(prev_vs, &new_state.view_state, new_state.player_id) { + pending.update(|q| { + q.push_back(GameUiState { + waiting_for_confirm: true, + pause_reason: Some(reason), + my_scored_event: scored, + opp_scored_event: opp_scored, + ..new_state.clone() + }); + }); + screen.set(Screen::Playing(GameUiState { + last_moves: None, + ..new_state + })); + } else { + screen.set(Screen::Playing(GameUiState { + my_scored_event: scored, + opp_scored_event: opp_scored, + ..new_state + })); + } +} + +/// Compares the previous and next ViewState to decide whether the transition +/// warrants a confirmation pause. +pub fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option { + let opponent_id = 1 - player_id; + + if next.stage == SerStage::PreGameRoll { + if let (Some(prev_pgr), Some(next_pgr)) = (&prev.pre_game_roll, &next.pre_game_roll) { + let both_now = next_pgr.host_die.is_some() && next_pgr.guest_die.is_some(); + let both_before = prev_pgr.host_die.is_some() && prev_pgr.guest_die.is_some(); + if both_now && !both_before { + return Some(PauseReason::AfterOpponentPreGameRoll); + } + } + return None; + } + + if prev.stage == SerStage::PreGameRoll { + return None; + } + + if next.active_mp_player == Some(opponent_id) { + if next.dice != prev.dice { + return Some(PauseReason::AfterOpponentRoll); + } + if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move { + return Some(PauseReason::AfterOpponentGo); + } + } + + if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) { + return Some(PauseReason::AfterOpponentMove); + } + + None +} diff --git a/clients/web/src/game/sound.rs b/clients/web/src/game/sound.rs new file mode 100644 index 0000000..5637ccd --- /dev/null +++ b/clients/web/src/game/sound.rs @@ -0,0 +1,182 @@ +//! Synthesised sound effects using the Web Audio API. +//! +//! All public functions are no-ops on non-WASM targets so callers need no +//! `#[cfg]` guards themselves. + +#[cfg(target_arch = "wasm32")] +mod inner { + use std::cell::RefCell; + use web_sys::{AudioContext, OscillatorType}; + + thread_local! { + static CTX: RefCell> = const { RefCell::new(None) }; + } + + fn with_ctx(f: F) { + CTX.with(|cell| { + let mut opt = cell.borrow_mut(); + if opt.is_none() { + *opt = AudioContext::new().ok(); + } + if let Some(ctx) = opt.as_ref() { + f(ctx); + } + }); + } + + /// Schedule a single oscillator tone with an exponential gain decay. + /// + /// - `start_offset`: seconds from `ctx.current_time()` when the tone starts + /// - `duration`: how long (in seconds) until gain reaches ~0 + fn play_tone( + ctx: &AudioContext, + freq: f32, + gain: f32, + duration: f64, + start_offset: f64, + wave: OscillatorType, + ) { + let t0 = ctx.current_time() + start_offset; + let t1 = t0 + duration; + + let Ok(osc) = ctx.create_oscillator() else { + return; + }; + let Ok(gain_node) = ctx.create_gain() else { + return; + }; + + osc.set_type(wave); + osc.frequency().set_value(freq); + + let gain_param = gain_node.gain(); + let _ = gain_param.set_value_at_time(gain, t0); + // exponential_ramp requires a positive target; 0.001 is inaudible + let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1); + + let dest = ctx.destination(); + let _ = osc.connect_with_audio_node(&gain_node); + let _ = gain_node.connect_with_audio_node(&dest); + + let _ = osc.start_with_when(t0); + let _ = osc.stop_with_when(t1); + } + + /// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms. + pub fn play_checker_move() { + with_ctx(|ctx| { + // Sine at 300 Hz for the clean attack click + play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine); + // Triangle at 150 Hz for the woody body resonance + play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle); + // Sub at 80 Hz for weight + play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle); + }); + } + + /// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients. + /// + /// Two layers: + /// - A dense series of detuned sawtooth bursts that thin out over time, + /// modelling the continuous scrape/rattle of dice tumbling. + /// - Five percussive impacts (square clicks + triangle thuds) whose + /// inter-arrival gap shrinks as the dice decelerate and settle. + pub fn play_dice_roll_cinematic() { + with_ctx(|ctx| { + // ── Continuous rolling texture ───────────────────────────────── + // 16 steps over 440 ms; each step is two detuned sawtooth waves + // (the interference between them produces a noise-like texture). + // Gain fades by ~55 % from first to last step. + const N: u32 = 16; + for i in 0..N { + let t = i as f64 * 0.028; + let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55); + // Quasi-random frequencies so each step sounds different. + let f1 = 310.0 + (i as f32 * 29.3 % 280.0); + let f2 = 480.0 + (i as f32 * 43.7 % 220.0); + play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth); + play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth); + } + + // ── Impact transients ────────────────────────────────────────── + // Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s), + // mimicking dice decelerating and settling. + let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)]; + for &(t_off, amp) in impacts { + // Hard click: bright square partials → percussive attack + for &freq in &[700.0f32, 1_050.0, 1_500.0] { + play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square); + } + // Woody body thud: two low triangle partials + play_tone( + ctx, + 130.0, + amp * 0.05, + 0.070, + t_off, + OscillatorType::Triangle, + ); + play_tone( + ctx, + 68.0, + amp * 0.07, + 0.090, + t_off, + OscillatorType::Triangle, + ); + } + }); + } + + /// Play the pre-recorded dice-roll MP3 asset. + pub fn play_dice_roll() { + if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") { + audio.set_volume(0.2); + let _ = audio.play(); + } + } + + /// Ascending three-note chime (C5 – E5 – G5). + pub fn play_points_scored() { + with_ctx(|ctx| { + let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)]; + for (freq, offset) in notes { + play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine); + } + }); + } + + /// Triumphant four-note fanfare (C5 – E5 – G5 – C6). + pub fn play_hole_scored() { + with_ctx(|ctx| { + let notes: [(f32, f64, f64); 4] = [ + (523.25, 0.0, 0.35), + (659.25, 0.17, 0.35), + (783.99, 0.34, 0.35), + (1046.5, 0.51, 0.55), + ]; + for (freq, offset, dur) in notes { + play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine); + } + }); + } +} + +// ── Public API: WASM delegates to `inner`, other targets are no-ops ─────────── + +#[cfg(target_arch = "wasm32")] +pub use inner::{ + play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored, + play_points_scored, +}; + +#[cfg(not(target_arch = "wasm32"))] +pub fn play_checker_move() {} +#[cfg(not(target_arch = "wasm32"))] +pub fn play_dice_roll() {} +#[cfg(not(target_arch = "wasm32"))] +pub fn play_dice_roll_cinematic() {} +#[cfg(not(target_arch = "wasm32"))] +pub fn play_points_scored() {} +#[cfg(not(target_arch = "wasm32"))] +pub fn play_hole_scored() {} diff --git a/clients/web/src/game/trictrac/backend.rs b/clients/web/src/game/trictrac/backend.rs new file mode 100644 index 0000000..ca28204 --- /dev/null +++ b/clients/web/src/game/trictrac/backend.rs @@ -0,0 +1,487 @@ +use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; +use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage}; + +use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState}; + +// Store PlayerId (u64) values used for the two players. +const HOST_PLAYER_ID: u64 = 1; +const GUEST_PLAYER_ID: u64 = 2; + +pub struct TrictracBackend { + game: GameState, + dice_roller: DiceRoller, + commands: Vec>, + view_state: ViewState, + /// Arrival flags: have host (index 0) and guest (index 1) joined? + arrived: [bool; 2], + /// Die rolled by each player during the ceremony ([host, guest]). + pre_game_dice: [Option; 2], + /// Number of tied rounds so far. + tie_count: u8, + /// True while the first-player ceremony is running. + ceremony_started: bool, +} + +impl TrictracBackend { + fn sync_view_state(&mut self) { + let mut vs = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID); + if self.ceremony_started { + vs.stage = SerStage::PreGameRoll; + vs.pre_game_roll = Some(PreGameRollState { + host_die: self.pre_game_dice[0], + guest_die: self.pre_game_dice[1], + tie_count: self.tie_count, + }); + // Both players roll independently; no single "active" player. + vs.active_mp_player = None; + } + self.view_state = vs; + } + + fn broadcast_state(&mut self) { + self.sync_view_state(); + let delta = GameDelta { + state: self.view_state.clone(), + }; + self.commands.push(BackendCommand::Delta(delta)); + } + + /// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest). + fn handle_pre_game_roll(&mut self, mp_player: u16) { + let idx = mp_player as usize; + // Ignore if this player already rolled. + if self.pre_game_dice[idx].is_some() { + return; + } + let single = self.dice_roller.roll().values.0; + self.pre_game_dice[idx] = Some(single); + + if let [Some(h), Some(g)] = self.pre_game_dice { + // Both have rolled — broadcast both dice before resolving. + self.broadcast_state(); + if h == g { + // Tie: reset for another round. + self.tie_count += 1; + self.pre_game_dice = [None; 2]; + self.broadcast_state(); + } else { + // Highest die goes first. + let goes_first = if h > g { + HOST_PLAYER_ID + } else { + GUEST_PLAYER_ID + }; + self.ceremony_started = false; + let _ = self.game.consume(&GameEvent::BeginGame { goes_first }); + // Use pre-game dice roll for the first move + let _ = self.game.consume(&GameEvent::Roll { + player_id: goes_first, + }); + let _ = self.game.consume(&GameEvent::RollResult { + player_id: goes_first, + dice: Dice { values: (g, h) }, + }); + self.broadcast_state(); + } + } else { + // Only one die rolled so far — broadcast the partial result. + self.broadcast_state(); + } + } + + /// Roll dice using the store's DiceRoller and fire Roll + RollResult events. + fn do_roll(&mut self) { + let dice = self.dice_roller.roll(); + let player_id = self.game.active_player_id; + let _ = self.game.consume(&GameEvent::Roll { player_id }); + let _ = self + .game + .consume(&GameEvent::RollResult { player_id, dice }); + + // Drive automatic stages that require no player input. + self.drive_automatic_stages(); + } + + /// Advance through stages that can be resolved without player input + /// (MarkPoints, MarkAdvPoints). + fn drive_automatic_stages(&mut self) { + loop { + // Stop if the game has already ended (stage transitions to Ended but + // turn_stage may still be MarkPoints when schools_enabled=false, which + // makes consume(Mark) a no-op and would cause an infinite loop). + if self.game.stage == trictrac_store::Stage::Ended { + break; + } + let player_id = self.game.active_player_id; + match self.game.turn_stage { + TurnStage::MarkPoints | TurnStage::MarkAdvPoints => { + let _ = self.game.consume(&GameEvent::Mark { + player_id, + points: self.game.dice_points.0.max(self.game.dice_points.1), + }); + } + _ => break, + } + } + } +} + +impl TrictracBackend { + pub fn get_game(&self) -> &GameState { + &self.game + } +} + +impl BackEndArchitecture for TrictracBackend { + fn new(_rule_variation: u16) -> Self { + let mut game = GameState::new(false); + game.init_player("Blancs"); + game.init_player("Noirs"); + + let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID); + + TrictracBackend { + game, + dice_roller: DiceRoller::default(), + commands: Vec::new(), + view_state, + arrived: [false; 2], + pre_game_dice: [None; 2], + tie_count: 0, + ceremony_started: false, + } + } + + fn from_bytes(_rule_variation: u16, bytes: &[u8]) -> Option { + let view_state: ViewState = serde_json::from_slice(bytes).ok()?; + // Reconstruct a fresh game; full state restore is not yet implemented. + let mut backend = Self::new(_rule_variation); + backend.view_state = view_state; + Some(backend) + } + + fn player_arrival(&mut self, mp_player: u16) { + if mp_player > 1 { + self.commands + .push(BackendCommand::KickPlayer { player: mp_player }); + return; + } + self.arrived[mp_player as usize] = true; + + // Cancel any reconnect timer for this player. + self.commands.push(BackendCommand::CancelTimer { + timer_id: mp_player, + }); + + // Start the ceremony once both players have arrived. + if self.arrived[0] + && self.arrived[1] + && self.game.stage == trictrac_store::Stage::PreGame + && !self.ceremony_started + { + self.ceremony_started = true; + self.pre_game_dice = [None; 2]; + self.tie_count = 0; + self.sync_view_state(); + self.commands.push(BackendCommand::ResetViewState); + } else { + self.broadcast_state(); + } + } + + fn player_departure(&mut self, mp_player: u16) { + if mp_player > 1 { + return; + } + self.arrived[mp_player as usize] = false; + // Give 60 seconds to reconnect before terminating the room. + self.commands.push(BackendCommand::SetTimer { + timer_id: mp_player, + duration: 60.0, + }); + } + + fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) { + // During the first-player ceremony only PreGameRoll actions are accepted. + if self.ceremony_started { + if matches!(action, PlayerAction::PreGameRoll) { + self.handle_pre_game_roll(mp_player); + } + return; + } + + if self.game.stage == trictrac_store::Stage::Ended { + return; + } + + let store_id = if mp_player == 0 { + HOST_PLAYER_ID + } else { + GUEST_PLAYER_ID + }; + + // Only the active player may act (except during Chance-like waiting stages). + if self.game.active_player_id != store_id { + return; + } + + match action { + PlayerAction::Roll => { + if self.game.turn_stage == TurnStage::RollDice { + self.do_roll(); + } + } + PlayerAction::Move(m1, m2) => { + if self.game.turn_stage != TurnStage::Move + && self.game.turn_stage != TurnStage::HoldOrGoChoice + { + return; + } + let event = GameEvent::Move { + player_id: store_id, + moves: (m1, m2), + }; + if self.game.validate(&event) { + let _ = self.game.consume(&event); + self.drive_automatic_stages(); + } + } + PlayerAction::Go => { + if self.game.turn_stage == TurnStage::HoldOrGoChoice { + let _ = self.game.consume(&GameEvent::Go { + player_id: store_id, + }); + } + } + PlayerAction::Mark => { + if matches!( + self.game.turn_stage, + TurnStage::MarkPoints | TurnStage::MarkAdvPoints + ) { + self.drive_automatic_stages(); + } + } + PlayerAction::PreGameRoll => {} // ignored outside ceremony + } + + self.broadcast_state(); + } + + fn timer_triggered(&mut self, timer_id: u16) { + match timer_id { + 0 | 1 => { + // Reconnect grace period expired for host (0) or guest (1). + self.commands.push(BackendCommand::TerminateRoom); + } + _ => {} + } + } + + fn get_view_state(&self) -> &ViewState { + &self.view_state + } + + fn drain_commands(&mut self) -> Vec> { + std::mem::take(&mut self.commands) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::types::{SerStage, SerTurnStage}; + use backbone_lib::traits::BackEndArchitecture; + + fn make_backend() -> TrictracBackend { + TrictracBackend::new(0) + } + + /// Helper: drain and return only Delta commands, extracting their ViewStates. + fn drain_deltas(b: &mut TrictracBackend) -> Vec { + b.drain_commands() + .into_iter() + .filter_map(|cmd| match cmd { + BackendCommand::Delta(d) => Some(d.state), + BackendCommand::ResetViewState => Some(b.view_state.clone()), + _ => None, + }) + .collect() + } + + /// Drive the ceremony to completion (both players roll until one wins). + fn complete_ceremony(b: &mut TrictracBackend) { + loop { + if b.get_view_state().stage != SerStage::PreGameRoll { + break; + } + let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default(); + let host_needs = pgr.host_die.is_none(); + let guest_needs = pgr.guest_die.is_none(); + if !host_needs && !guest_needs { + break; // both rolled but stage not yet resolved — shouldn't happen + } + if host_needs { + b.inform_rpc(0, PlayerAction::PreGameRoll); + } + if guest_needs { + b.inform_rpc(1, PlayerAction::PreGameRoll); + } + b.drain_commands(); + } + } + + #[test] + fn both_players_arrive_starts_ceremony() { + let mut b = make_backend(); + b.player_arrival(0); // host + b.drain_commands(); + b.player_arrival(1); // guest + let cmds = b.drain_commands(); + + // ResetViewState should have been issued to start the ceremony. + let has_reset = cmds + .iter() + .any(|c| matches!(c, BackendCommand::ResetViewState)); + assert!( + has_reset, + "expected ResetViewState after both players arrive" + ); + + // Stage should now be PreGameRoll, not InGame. + assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll); + } + + #[test] + fn ceremony_resolves_to_in_game() { + let mut b = make_backend(); + b.player_arrival(0); + b.player_arrival(1); + b.drain_commands(); + + complete_ceremony(&mut b); + + assert_eq!(b.get_view_state().stage, SerStage::InGame); + } + + #[test] + fn ceremony_any_order_allowed() { + let mut b = make_backend(); + b.player_arrival(0); + b.player_arrival(1); + b.drain_commands(); + + // Guest may roll before host. + b.inform_rpc(1, PlayerAction::PreGameRoll); + let states = drain_deltas(&mut b); + assert!( + !states.is_empty(), + "guest PreGameRoll should broadcast a state" + ); + let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap(); + assert!( + pgr.guest_die.is_some(), + "guest die should be set after guest rolls" + ); + assert!(pgr.host_die.is_none(), "host die should still be blank"); + } + + #[test] + fn unknown_player_kicked() { + let mut b = make_backend(); + b.player_arrival(99); + let cmds = b.drain_commands(); + assert!(cmds + .iter() + .any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 }))); + } + + #[test] + fn roll_advances_to_move_or_hold() { + let mut b = make_backend(); + b.player_arrival(0); + b.player_arrival(1); + b.drain_commands(); + + // Complete ceremony before rolling. + complete_ceremony(&mut b); + + // Roll for whoever won the ceremony (either player could go first). + let first_player = b + .get_view_state() + .active_mp_player + .expect("someone should be active"); + b.inform_rpc(first_player, PlayerAction::Roll); + let states = drain_deltas(&mut b); + assert!(!states.is_empty(), "expected a state broadcast after roll"); + + let last = states.last().unwrap(); + assert!( + matches!( + last.turn_stage, + SerTurnStage::Move | SerTurnStage::HoldOrGoChoice + ), + "expected Move or HoldOrGoChoice after roll, got {:?}", + last.turn_stage + ); + assert_eq!(last.dice, b.get_view_state().dice); + assert!(last.dice.0 >= 1 && last.dice.0 <= 6); + assert!(last.dice.1 >= 1 && last.dice.1 <= 6); + } + + #[test] + fn wrong_player_roll_ignored() { + let mut b = make_backend(); + b.player_arrival(0); + b.player_arrival(1); + b.drain_commands(); + complete_ceremony(&mut b); + + // Identify who goes first and have the OTHER player try to roll. + let active = b.get_view_state().active_mp_player; + let wrong_player = if active == Some(0) { 1u16 } else { 0u16 }; + b.inform_rpc(wrong_player, PlayerAction::Roll); + let cmds = b.drain_commands(); + assert!(cmds.is_empty(), "wrong player roll should be ignored"); + } + + #[test] + fn departure_sets_reconnect_timer() { + let mut b = make_backend(); + b.player_arrival(0); + b.drain_commands(); + b.player_departure(0); + let cmds = b.drain_commands(); + assert!( + cmds.iter() + .any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })), + "expected reconnect timer after host departure" + ); + } + + #[test] + fn timer_triggers_terminate_room() { + let mut b = make_backend(); + b.timer_triggered(0); + let cmds = b.drain_commands(); + assert!(cmds + .iter() + .any(|c| matches!(c, BackendCommand::TerminateRoom))); + } +} + +// ── Public API: WASM delegates to `inner`, other targets are no-ops ─────────── + +#[cfg(target_arch = "wasm32")] +mod inner { + use web_sys::console; + + pub fn console_log(message: String) { + console::log_1(&message.into()); + } +} + +#[cfg(target_arch = "wasm32")] +pub use inner::console_log; + +#[cfg(not(target_arch = "wasm32"))] +pub fn console_log(message: String) {} diff --git a/clients/web/src/game/trictrac/bot_local.rs b/clients/web/src/game/trictrac/bot_local.rs new file mode 100644 index 0000000..5543b07 --- /dev/null +++ b/clients/web/src/game/trictrac/bot_local.rs @@ -0,0 +1,43 @@ +use rand::prelude::IndexedRandom; +use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; + +use super::types::{PlayerAction, PreGameRollState}; + +const GUEST_PLAYER_ID: u64 = 2; + +/// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn. +/// `pgr` is the current pre-game ceremony state if the ceremony is in progress. +pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option { + // During the ceremony, the bot (guest) rolls when its die is missing. + if game.stage == Stage::PreGame { + if let Some(pgr) = pgr { + if pgr.guest_die.is_none() { + return Some(PlayerAction::PreGameRoll); + } + } + return None; + } + if game.stage == Stage::Ended { + return None; + } + if game.active_player_id != GUEST_PLAYER_ID { + return None; + } + match game.turn_stage { + TurnStage::RollDice => Some(PlayerAction::Roll), + // TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), + TurnStage::Move | TurnStage::HoldOrGoChoice => { + let rules = MoveRules::new(&Color::Black, &game.board, game.dice); + let sequences = rules.get_possible_moves_sequences(true, vec![]); + let mut rng = rand::rng(); + let (m1, m2) = sequences + .choose(&mut rng) + .cloned() + .unwrap_or((CheckerMove::default(), CheckerMove::default())); + // MoveRules with Color::Black mirrors the board internally, so + // returned move coordinates are in mirrored (White) space — mirror back. + Some(PlayerAction::Move(m1.mirror(), m2.mirror())) + } + _ => None, + } +} diff --git a/clients/web/src/game/trictrac/mod.rs b/clients/web/src/game/trictrac/mod.rs new file mode 100644 index 0000000..38d05bb --- /dev/null +++ b/clients/web/src/game/trictrac/mod.rs @@ -0,0 +1,3 @@ +pub mod backend; +pub mod bot_local; +pub mod types; diff --git a/clients/web/src/game/trictrac/types.rs b/clients/web/src/game/trictrac/types.rs new file mode 100644 index 0000000..3c0dfe2 --- /dev/null +++ b/clients/web/src/game/trictrac/types.rs @@ -0,0 +1,256 @@ +use serde::{Deserialize, Serialize}; +use trictrac_store::{CheckerMove, GameState, Jan, Stage, TurnStage}; + +// ── Actions sent by a player to the host backend ───────────────────────────── + +#[derive(Clone, Serialize, Deserialize)] +pub enum PlayerAction { + /// Active player requests a dice roll. + Roll, + /// Both checker moves for this turn. Use `EMPTY_MOVE` (from=0, to=0) when a die + /// has no valid move. + Move(CheckerMove, CheckerMove), + /// Choose to "go" (advance) during HoldOrGoChoice. + Go, + /// Acknowledge point marking (hold / advance points). + Mark, + /// Roll a single die during the pre-game ceremony to decide who goes first. + PreGameRoll, +} + +// ── Incremental state update broadcast to all clients ──────────────────────── + +/// Carries a full state snapshot; `apply_delta` replaces the local state. +/// Simple and correct; can be refined to true diffs later. +#[derive(Clone, Serialize, Deserialize)] +pub struct GameDelta { + pub state: ViewState, +} + +// ── Full game snapshot ──────────────────────────────────────────────────────── + +/// State of the pre-game ceremony where each player rolls one die to decide +/// who goes first. Present only when `stage == SerStage::PreGameRoll`. +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct PreGameRollState { + /// Die value (1–6) rolled by the host; `None` = not yet rolled this round. + pub host_die: Option, + /// Die value (1–6) rolled by the guest; `None` = not yet rolled this round. + pub guest_die: Option, + /// Number of tied rounds so far (0 on the first round). + pub tie_count: u8, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct ViewState { + /// Board positions: index i = field i+1. Positive = white, negative = black. + pub board: [i8; 24], + pub stage: SerStage, + pub turn_stage: SerTurnStage, + /// Which multiplayer player_id (0 = host, 1 = guest) is the active player. + pub active_mp_player: Option, + /// Scores indexed by multiplayer player_id (0 = host, 1 = guest). + pub scores: [PlayerScore; 2], + /// Last rolled dice values. + pub dice: (u8, u8), + /// Jans (scoring events) triggered by the last dice roll. + pub dice_jans: Vec, + /// Last two checker moves played; default when no move has occurred yet. + pub dice_moves: (CheckerMove, CheckerMove), + /// Present while the pre-game ceremony is in progress. + #[serde(default)] + pub pre_game_roll: Option, +} + +/// One scoring event from a dice roll. +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +pub struct JanEntry { + pub jan: Jan, + /// True when the dice are doubles (both same value) — changes the point value. + /// Special case for HelplessMan: true when *both* dice are unplayable. + pub is_double: bool, + /// Number of distinct move pairs that produce this jan. + pub ways: usize, + /// Points per way (negative = scored against the active player). + pub points_per: i8, + /// Total = points_per × ways. + pub total: i8, + /// The move pairs that produce this jan (for move display). + pub moves: Vec<(CheckerMove, CheckerMove)>, +} + +impl ViewState { + pub fn default_with_names(host_name: &str, guest_name: &str) -> Self { + ViewState { + board: [0i8; 24], + stage: SerStage::PreGame, + turn_stage: SerTurnStage::RollDice, + active_mp_player: None, + scores: [ + PlayerScore { + name: host_name.to_string(), + points: 0, + holes: 0, + can_bredouille: false, + }, + PlayerScore { + name: guest_name.to_string(), + points: 0, + holes: 0, + can_bredouille: false, + }, + ], + dice: (0, 0), + dice_jans: Vec::new(), + dice_moves: (CheckerMove::default(), CheckerMove::default()), + pre_game_roll: None, + } + } + + pub fn apply_delta(&mut self, delta: &GameDelta) { + *self = delta.state.clone(); + } + + /// Convert a store `GameState` to a `ViewState`. + /// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned + /// to the host (mp player 0) and guest (mp player 1) respectively. + pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self { + let board_vec = gs.board.to_vec(); + let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields"); + + let stage = match gs.stage { + Stage::PreGame => SerStage::PreGame, + Stage::InGame => SerStage::InGame, + Stage::Ended => SerStage::Ended, + }; + let turn_stage = match gs.turn_stage { + TurnStage::RollDice => SerTurnStage::RollDice, + TurnStage::RollWaiting => SerTurnStage::RollWaiting, + TurnStage::MarkPoints => SerTurnStage::MarkPoints, + TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice, + TurnStage::Move => SerTurnStage::Move, + TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints, + }; + + let active_mp_player = if gs.active_player_id == host_store_id { + Some(0) + } else if gs.active_player_id == guest_store_id { + Some(1) + } else { + None + }; + + let score_for = |store_id: u64| -> PlayerScore { + gs.players + .get(&store_id) + .map(|p| PlayerScore { + name: p.name.clone(), + points: p.points, + holes: p.holes, + can_bredouille: p.can_bredouille, + }) + .unwrap_or_else(|| PlayerScore { + name: String::new(), + points: 0, + holes: 0, + can_bredouille: false, + }) + }; + + // is_double for scoring: dice show the same value (both dice identical). + // Exception: HelplessMan uses a special rule (see below). + let dice_are_double = gs.dice.values.0 == gs.dice.values.1; + + // Build JanEntry list from the PossibleJans map. + let empty_move = CheckerMove::new(0, 0).unwrap_or_default(); + let mut dice_jans: Vec = gs + .dice_jans + .iter() + .map(|(jan, moves)| { + // HelplessMan: is_double = true only when *both* dice are unplayable + // (the moves list contains a single (empty, empty) sentinel). + let is_double = if *jan == Jan::HelplessMan { + moves + .first() + .map(|&(m1, m2)| m1 == empty_move && m2 == empty_move) + .unwrap_or(false) + } else { + dice_are_double + }; + let points_per = jan.get_points(is_double); + let ways = moves.len(); + let total = points_per.saturating_mul(ways as i8); + JanEntry { + jan: jan.clone(), + is_double, + ways, + points_per, + total, + moves: moves.clone(), + } + }) + .collect(); + // Sort: highest total first, most-negative last. + dice_jans.sort_by_key(|e| std::cmp::Reverse(e.total)); + + ViewState { + board, + stage, + turn_stage, + active_mp_player, + scores: [score_for(host_store_id), score_for(guest_store_id)], + dice: (gs.dice.values.0, gs.dice.values.1), + dice_jans, + dice_moves: gs.dice_moves, + pre_game_roll: None, + } + } +} + +// ── Scored event (notification) ────────────────────────────────────────── + +/// Points scored in a single scoring event, used for the notification panel. +#[derive(Clone, PartialEq)] +pub struct ScoredEvent { + /// Raw points earned (sum of jan values; before hole wrapping). + pub points_earned: u8, + /// Number of holes gained (0 = no hole). + pub holes_gained: u8, + /// Total holes after this event. + pub holes_total: u8, + /// Was bredouille active when the hole was made (doubles hole count)? + pub bredouille: bool, + /// Contributing jans from this player's perspective (totals always positive). + pub jans: Vec, +} + +// ── Score snapshot ──────────────────────────────────────────────────────────── + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct PlayerScore { + pub name: String, + pub points: u8, + pub holes: u8, + pub can_bredouille: bool, +} + +// ── Serialisable mirrors of store enums ────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SerStage { + PreGame, + /// Both players have arrived; ceremony in progress to decide who goes first. + PreGameRoll, + InGame, + Ended, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SerTurnStage { + RollDice, + RollWaiting, + MarkPoints, + HoldOrGoChoice, + Move, + MarkAdvPoints, +} diff --git a/clients/web/src/main.rs b/clients/web/src/main.rs new file mode 100644 index 0000000..bfd8adf --- /dev/null +++ b/clients/web/src/main.rs @@ -0,0 +1,14 @@ +leptos_i18n::load_locales!(); + +mod api; +mod app; +mod game; +mod nav; +mod portal; + +use app::App; +use leptos::prelude::*; + +fn main() { + mount_to_body(|| view! { }) +} diff --git a/clients/web/src/nav.rs b/clients/web/src/nav.rs new file mode 100644 index 0000000..c5b59fd --- /dev/null +++ b/clients/web/src/nav.rs @@ -0,0 +1,51 @@ +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos_router::components::A; + +use crate::api; +use crate::app::Screen; +use crate::i18n::*; + +#[component] +pub fn SiteNav() -> impl IntoView { + let i18n = use_i18n(); + let screen = use_context::>().expect("Screen context not found"); + let auth_username = + use_context::>>().expect("auth_username context not found"); + + let is_game_active = + move || !matches!(screen.get(), Screen::Login { .. }); + + let logout = move |_| { + spawn_local(async move { + let _ = api::post_logout().await; + auth_username.set(None); + }); + }; + + view! { + + } +} diff --git a/clients/web/src/portal/account.rs b/clients/web/src/portal/account.rs new file mode 100644 index 0000000..7552a7c --- /dev/null +++ b/clients/web/src/portal/account.rs @@ -0,0 +1,166 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; + +use crate::api; +use crate::i18n::*; + +#[component] +pub fn AccountPage() -> impl IntoView { + let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username context not found"); + let navigate = use_navigate(); + + Effect::new(move |_| { + if let Some(u) = auth_username.get() { + navigate(&format!("/profile/{u}"), Default::default()); + } + }); + + let tab = RwSignal::new("login"); + + view! { +
+
+

+ {t!(i18n, account_title)} +

+
+ + +
+ {move || if tab.get() == "login" { + view! { }.into_any() + } else { + view! { }.into_any() + }} +
+
+ } +} + +#[component] +fn LoginForm() -> impl IntoView { + let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username context not found"); + let navigate = use_navigate(); + + let username = RwSignal::new(String::new()); + let password = RwSignal::new(String::new()); + let error = RwSignal::new(String::new()); + let pending = RwSignal::new(false); + + let submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + if pending.get() { return; } + pending.set(true); + error.set(String::new()); + let u = username.get(); + let p = password.get(); + let navigate = navigate.clone(); + wasm_bindgen_futures::spawn_local(async move { + match api::post_login(&u, &p).await { + Ok(me) => { + let dest = format!("/profile/{}", me.username); + auth_username.set(Some(me.username)); + navigate(&dest, Default::default()); + } + Err(e) => { + error.set(e); + pending.set(false); + } + } + }); + }; + + view! { +
+ + + + + + {move || if !error.get().is_empty() { + view! {

{ error.get() }

}.into_any() + } else { + view! { }.into_any() + }} + + } +} + +#[component] +fn RegisterForm() -> impl IntoView { + let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username context not found"); + let navigate = use_navigate(); + + let username = RwSignal::new(String::new()); + let email = RwSignal::new(String::new()); + let password = RwSignal::new(String::new()); + let error = RwSignal::new(String::new()); + let pending = RwSignal::new(false); + + let submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + if pending.get() { return; } + pending.set(true); + error.set(String::new()); + let u = username.get(); + let e = email.get(); + let p = password.get(); + let navigate = navigate.clone(); + wasm_bindgen_futures::spawn_local(async move { + match api::post_register(&u, &e, &p).await { + Ok(me) => { + let dest = format!("/profile/{}", me.username); + auth_username.set(Some(me.username)); + navigate(&dest, Default::default()); + } + Err(err) => { + error.set(err); + pending.set(false); + } + } + }); + }; + + view! { +
+ + + + + + + + {move || if !error.get().is_empty() { + view! {

{ error.get() }

}.into_any() + } else { + view! { }.into_any() + }} + + } +} diff --git a/clients/web/src/portal/game_detail.rs b/clients/web/src/portal/game_detail.rs new file mode 100644 index 0000000..adc3643 --- /dev/null +++ b/clients/web/src/portal/game_detail.rs @@ -0,0 +1,109 @@ +use leptos::prelude::*; +use leptos_router::{components::A, hooks::use_params_map}; + +use crate::api::{self, GameDetail, Participant}; +use crate::i18n::*; + +#[component] +pub fn GameDetailPage() -> impl IntoView { + let i18n = use_i18n(); + let params = use_params_map(); + let id_str = move || params.read().get("id").unwrap_or_default(); + + let detail = LocalResource::new(move || { + let s = id_str(); + async move { + let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?; + api::get_game_detail(id).await + } + }); + + view! { +
+ {move || match detail.get().map(|sw| sw.take()) { + None => view! {

{t!(i18n, loading)}

}.into_any(), + Some(Err(e)) => view! {

{ e }

}.into_any(), + Some(Ok(g)) => view! { }.into_any(), + }} +
+ } +} + +#[component] +fn GameDetailView(game: GameDetail) -> impl IntoView { + let i18n = use_i18n(); + let started = api::format_ts(game.started_at); + let ended = game.ended_at.map(api::format_ts) + .unwrap_or_else(|| t_string!(i18n, game_ongoing).to_string()); + + view! { +
+

{t!(i18n, room_detail_title)} " " { game.room_code.clone() }

+

+ {t!(i18n, started_label)} ": " { started.clone() } + " · " + {t!(i18n, ended_label)} ": " { ended } +

+ +

{t!(i18n, players_header)}

+ + + + + + + + + + {game.participants.iter().map(|p| { + view! { } + }).collect_view()} + +
{t!(i18n, col_player)}{t!(i18n, label_username)}{t!(i18n, col_outcome)}
+ + {game.result.as_ref().map(|r| view! { +
+

{t!(i18n, score_header)}

+

+ { r.clone() } +

+
+ })} +
+ } +} + +#[component] +fn ParticipantRow(participant: Participant) -> impl IntoView { + let i18n = use_i18n(); + let outcome_class = match participant.outcome.as_deref() { + Some("win") => "outcome-win", + Some("loss") => "outcome-loss", + Some("draw") => "outcome-draw", + _ => "", + }; + let outcome_text = move || match participant.outcome.as_deref() { + Some("win") => t_string!(i18n, outcome_win), + Some("loss") => t_string!(i18n, outcome_loss), + Some("draw") => t_string!(i18n, outcome_draw), + _ => "—", + }; + let name = participant.username.clone(); + + view! { + + {t!(i18n, col_player)} " " { participant.player_id } + + {match name { + Some(u) => view! { + { u } + }.into_any(), + None => view! { + {t!(i18n, anonymous_player)} + }.into_any(), + }} + + { outcome_text } + + } +} diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs new file mode 100644 index 0000000..ef66281 --- /dev/null +++ b/clients/web/src/portal/lobby.rs @@ -0,0 +1,88 @@ +use futures::channel::mpsc::UnboundedSender; +use leptos::prelude::*; + +use crate::app::{NetCommand, Screen}; +use crate::i18n::*; + +#[component] +pub fn LobbyPage() -> impl IntoView { + let i18n = use_i18n(); + let (room_name, set_room_name) = signal(String::new()); + + let screen = use_context::>().expect("Screen context not found"); + let cmd_tx = use_context::>() + .expect("UnboundedSender not found in context"); + + let cmd_tx_create = cmd_tx.clone(); + let cmd_tx_join = cmd_tx.clone(); + let cmd_tx_bot = cmd_tx; + + // Extract connection error from screen state. + let error = move || match screen.get() { + Screen::Login { error } => error, + _ => None, + }; + + view! { +
+ +
+ } +} diff --git a/clients/web/src/portal/mod.rs b/clients/web/src/portal/mod.rs new file mode 100644 index 0000000..722c9e1 --- /dev/null +++ b/clients/web/src/portal/mod.rs @@ -0,0 +1,4 @@ +pub mod account; +pub mod game_detail; +pub mod lobby; +pub mod profile; diff --git a/clients/web/src/portal/profile.rs b/clients/web/src/portal/profile.rs new file mode 100644 index 0000000..9a94b3f --- /dev/null +++ b/clients/web/src/portal/profile.rs @@ -0,0 +1,153 @@ +use leptos::prelude::*; +use leptos_router::{components::A, hooks::use_params_map}; + +use crate::api::{self, GameSummary, UserProfile}; +use crate::i18n::*; + +#[component] +pub fn ProfilePage() -> impl IntoView { + let params = use_params_map(); + let username = move || params.read().get("username").unwrap_or_default(); + + let profile = LocalResource::new(move || { + let u = username(); + async move { api::get_user_profile(&u).await } + }); + + let i18n = use_i18n(); + + view! { +
+ {move || match profile.get().map(|sw| sw.take()) { + None => view! {

{t!(i18n, loading)}

}.into_any(), + Some(Err(e)) => view! {

{ e }

}.into_any(), + Some(Ok(p)) => view! { }.into_any(), + }} +
+ } +} + +#[component] +fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView { + let i18n = use_i18n(); + let page = RwSignal::new(0i64); + let games = LocalResource::new(move || { + let u = username.clone(); + let p = page.get(); + async move { api::get_user_games(&u, p).await } + }); + + let joined = api::format_ts(profile.created_at); + + view! { +
+

{ profile.username.clone() }

+

{t!(i18n, member_since)} " " { joined }

+ +
+
+
{ profile.total_games }
+
{t!(i18n, stat_games)}
+
+
+
{ profile.wins }
+
{t!(i18n, stat_wins)}
+
+
+
{ profile.losses }
+
{t!(i18n, stat_losses)}
+
+
+
{ profile.draws }
+
{t!(i18n, stat_draws)}
+
+
+
+ +
+

{t!(i18n, game_history_title)}

+ {move || match games.get().map(|sw| sw.take()) { + None => view! {

{t!(i18n, loading)}

}.into_any(), + Some(Err(e)) => view! {

{ e }

}.into_any(), + Some(Ok(r)) => { + if r.games.is_empty() { + view! {

{t!(i18n, no_games)}

}.into_any() + } else { + view! { }.into_any() + } + } + }} +
+ } +} + +#[component] +fn GamesTable(games: Vec, page: RwSignal) -> impl IntoView { + let i18n = use_i18n(); + let rows = games.clone(); + let has_next = games.len() == 20; + + view! { + + + + + + + + + + + + {rows.into_iter().map(|g| { + let started = api::format_ts(g.started_at); + let ended = g.ended_at.map(api::format_ts).unwrap_or_else(|| "—".into()); + let outcome_class = match g.outcome.as_deref() { + Some("win") => "outcome-win", + Some("loss") => "outcome-loss", + Some("draw") => "outcome-draw", + _ => "", + }; + let outcome_text = move || match g.outcome.as_deref() { + Some("win") => t_string!(i18n, outcome_win), + Some("loss") => t_string!(i18n, outcome_loss), + Some("draw") => t_string!(i18n, outcome_draw), + _ => "—", + }; + view! { + + + + + + + + } + }).collect_view()} + +
{t!(i18n, col_room)}{t!(i18n, col_started)}{t!(i18n, col_ended)}{t!(i18n, col_outcome)}{t!(i18n, col_detail)}
{ g.room_code.clone() }{ started }{ ended }{ outcome_text } + {t!(i18n, view_link)} +
+
+ {move || if page.get() > 0 { + view! { + + }.into_any() + } else { + view! { }.into_any() + }} + {t!(i18n, page_label)} " " { move || page.get() + 1 } + {if has_next { + view! { + + }.into_any() + } else { + view! { }.into_any() + }} +
+ } +} diff --git a/justfile b/justfile index 060f5ce..507fe00 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,23 @@ shell: runcli: RUST_LOG=info cargo run --bin=client_cli +[working-directory: 'clients/web'] +dev: + trunk serve + +[working-directory: 'clients/web'] +build: + trunk build --release + cp dist/index.html ../../deploy/index.html + cp dist/*.wasm ../../deploy/ + cp dist/*.js ../../deploy/ + cp dist/*.css ../../deploy/ + +[working-directory: 'deploy'] +run-relay: + ./relay-server + +# Legacy targets kept for reference during transition [working-directory: 'clients/web-game'] dev-game: trunk serve @@ -21,10 +38,6 @@ build-game: cp dist/*.js ../../deploy/ cp dist/*.css ../../deploy/ -[working-directory: 'deploy'] -run-relay: - ./relay-server - [working-directory: 'clients/web-user-portal'] dev-portal: trunk serve diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs index 2c11b44..70fde5e 100644 --- a/server/relay-server/src/main.rs +++ b/server/relay-server/src/main.rs @@ -81,8 +81,7 @@ async fn main() { let cors = CorsLayer::new() .allow_origin(AllowOrigin::list([ - "http://localhost:9091".parse().unwrap(), // game dev server - "http://localhost:9092".parse().unwrap(), // portal dev server + "http://localhost:9091".parse().unwrap(), // unified web dev server ])) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_headers([ @@ -96,10 +95,6 @@ async fn main() { .route("/enlist", get(enlist_handler)) .route("/ws", get(websocket_handler)) .merge(http::router()) - .nest_service( - "/portal", - ServeDir::new("portal").not_found_service(ServeFile::new("portal/index.html")), - ) .with_state(app_state) .layer(auth_layer) .layer(cors)