From 33d2163bab869137dd6fddf5ca48c41ff79e8cc8 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 8 Jun 2026 21:05:56 +0200 Subject: [PATCH 1/5] fix: css --- Cargo.lock | 118 +++++++++--------- clients/web/assets/style.css | 3 +- .../web/src/game/components/score_panel.rs | 1 - 3 files changed, 60 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72f1a21..b9533db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body", "http-body-util", "hyper", @@ -157,7 +157,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.1", "http-body", "http-body-util", "mime", @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "backbone-lib" -version = "0.2.15" +version = "0.2.16" dependencies = [ "bytes", "ewebsock", @@ -222,9 +222,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake2" @@ -295,9 +295,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -322,9 +322,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "cobs" @@ -651,9 +651,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1007,7 +1007,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http 1.4.0", + "http 1.4.1", "js-sys", "pin-project", "serde", @@ -1162,9 +1162,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1177,7 +1177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.1", ] [[package]] @@ -1188,7 +1188,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.1", "http-body", "pin-project-lite", ] @@ -1236,15 +1236,15 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "http 1.4.0", + "http 1.4.1", "http-body", "httparse", "httpdate", @@ -1261,7 +1261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.1", "http-body", "hyper", "pin-project-lite", @@ -1313,7 +1313,7 @@ dependencies = [ "displaydoc", "potential_utf", "utf8_iter", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerovec 0.11.6", ] @@ -1613,7 +1613,7 @@ dependencies = [ "displaydoc", "icu_locale_core", "writeable 0.6.3", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerotrie 0.2.4", "zerovec 0.11.6", @@ -2069,9 +2069,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -2112,9 +2112,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "manyhow" @@ -2166,9 +2166,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "merge" @@ -2220,9 +2220,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2658,7 +2658,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.15" +version = "0.2.16" dependencies = [ "serde", ] @@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay-server" -version = "0.2.15" +version = "0.2.16" dependencies = [ "argon2", "axum", @@ -3175,7 +3175,7 @@ dependencies = [ "dashmap", "futures", "gloo-net 0.6.0", - "http 1.4.0", + "http 1.4.1", "js-sys", "once_cell", "pin-project-lite", @@ -3262,9 +3262,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3305,9 +3305,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3752,7 +3752,7 @@ dependencies = [ "axum-core", "cookie", "futures-util", - "http 1.4.0", + "http 1.4.1", "parking_lot", "pin-project-lite", "tower-layer", @@ -3769,7 +3769,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body", "http-body-util", "http-range-header", @@ -3803,7 +3803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" dependencies = [ "async-trait", - "http 1.4.0", + "http 1.4.1", "time", "tokio", "tower-cookies", @@ -3824,7 +3824,7 @@ dependencies = [ "axum-core", "base64 0.22.1", "futures", - "http 1.4.0", + "http 1.4.1", "parking_lot", "rand 0.8.6", "serde", @@ -3921,7 +3921,7 @@ dependencies = [ [[package]] name = "trictrac-store" -version = "0.2.15" +version = "0.2.16" dependencies = [ "anyhow", "base64 0.21.7", @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "trictrac-web" -version = "0.2.15" +version = "0.2.16" dependencies = [ "backbone-lib", "futures", @@ -3967,7 +3967,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.1", "httparse", "log", "rand 0.8.6", @@ -3984,7 +3984,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.1", "httparse", "log", "rand 0.9.4", @@ -4014,9 +4014,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -4059,9 +4059,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4131,9 +4131,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4643,9 +4643,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive 0.8.2", @@ -4678,18 +4678,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -4741,7 +4741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", ] @@ -4762,7 +4762,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerovec-derive 0.11.3", ] diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 86e7cb8..aeba0a0 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -783,7 +783,7 @@ a:hover { text-decoration: underline; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - min-width: 0; + min-width: 4em; } .score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } @@ -2460,7 +2460,6 @@ a:hover { text-decoration: underline; } justify-content: space-around; align-items: center; gap: 0.4rem; - flex: 1; min-width: 0; } .ctrl-status .game-status { diff --git a/clients/web/src/game/components/score_panel.rs b/clients/web/src/game/components/score_panel.rs index 94e5f8a..bbb5f35 100644 --- a/clients/web/src/game/components/score_panel.rs +++ b/clients/web/src/game/components/score_panel.rs @@ -169,7 +169,6 @@ pub fn MergedScorePanel(
{my_name} - {t!(i18n, you_suffix)}
{my_can_bredouille.then(|| view! { Date: Sat, 20 Jun 2026 15:42:28 +0200 Subject: [PATCH 2/5] fix: css --- clients/web/assets/style.css | 29 +- clients/web/locales/en.json | 2 +- clients/web/locales/fr.json | 2 +- clients/web/src/app.rs | 4 +- .../web/src/game/components/game_screen.rs | 1 + .../web/src/game/components/score_panel.rs | 34 +- ...36e3ee1a8d3d686a664d74717fd23df1d3169.html | 145 +- doc/design/snapshots/2026-06-20.html | 13 + .../style-5f3b2c8fd952901a.css | 2518 +++++++++++++++++ 9 files changed, 2577 insertions(+), 171 deletions(-) create mode 100644 doc/design/snapshots/2026-06-20.html create mode 100644 doc/design/snapshots/2026-06-20_files/style-5f3b2c8fd952901a.css diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index aeba0a0..17cdf3d 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -784,6 +784,7 @@ a:hover { text-decoration: underline; } text-overflow: ellipsis; white-space: nowrap; min-width: 4em; + text-align: left; } .score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } @@ -864,6 +865,7 @@ a:hover { text-decoration: underline; } letter-spacing: 0.06em; cursor: default; box-shadow: 0 1px 3px rgba(0,0,0,0.25); + margin: 0.4em; } /* ── Merged scoreboard (both players, above board) ──────────────────── */ @@ -940,6 +942,7 @@ a:hover { text-decoration: underline; } display: flex; align-items: baseline; gap: 0.1rem; + direction: ltr; } .pts-counter { @@ -2322,9 +2325,8 @@ a:hover { text-decoration: underline; } gap: 0.5rem; } -.strip-player { display: flex; align-items: center; flex: 1; min-width: 0; } -.strip-player-left { justify-content: flex-end; } -.strip-player-right { justify-content: flex-start; } +.strip-player { display: flex; justify-content: flex-end; align-items: center; flex: 1; min-width: 0; } +.strip-player-left { } .strip-active-zone { display: flex; @@ -2362,7 +2364,7 @@ a:hover { text-decoration: underline; } } /* Strip peg overrides */ -.players-strip .peg-track { gap: 3px; } +.players-strip .peg-track { gap: 3px; direction: ltr; } .players-strip .peg-hole { width: 12px; height: 12px; } .players-strip .peg-hole.filled { background: #5aab38; border-color: #3a7828; @@ -2404,16 +2406,23 @@ a:hover { text-decoration: underline; } } /* ── Controls column (sidebar on wide, row on narrow) ────────────────── */ -.controls { +/* left-control displayed only on wide screen to center board */ +.controls, .left-controls { display: flex; flex-direction: column; gap: 0.5rem; align-self: stretch; } +@media (min-width: 1120px) { + .left-controls { + width: 200px; + } +} @media (min-width: 920px) { .controls { width: 200px; } + .strip-player-right { direction: rtl; } } .ctrl-dice { @@ -2502,6 +2511,11 @@ a:hover { text-decoration: underline; } margin: 0; } +@media (max-width: 1119px) { + .left-controls { + width: 0; + } + } /* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */ @media (max-width: 919px) { .main-body { @@ -2513,6 +2527,7 @@ a:hover { text-decoration: underline; } width: 100%; } .ctrl-status { flex: 1; } - /* Hide pegs on small screens to save space in the strip */ - .players-strip .peg-track { display: none; } + .strip-center { display: none; } + /* move second player below first player */ + .players-strip { flex-flow: column; } } diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index ebc5130..144a374 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -150,7 +150,7 @@ "account_deleted": "Your account has been permanently deleted.", "about": "About", "legal": "Legal notices", - "free_mode_label": "Free play mode", + "free_mode_label": "Free move mode", "free_mode_tooltip": "Select any checker and try to find a valid move yourself. If your move breaks a rule, you'll see an explanation.", "reset_move": "Try again", "err_invalid_move": "This move is not valid with the current dice", diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 5889556..f61290a 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -148,7 +148,7 @@ "account_deleted": "Votre compte a été définitivement supprimé.", "about": "À propos", "legal": "Mentions légales", - "free_mode_label": "Mode jeu libre", + "free_mode_label": "Déplacement libre", "free_mode_tooltip": "Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même. Si votre coup enfreint une règle, une explication s'affichera.", "reset_move": "Réessayer", "err_invalid_move": "Ce coup n'est pas valide avec les dés actuels", diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index ba90a54..d490e4c 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -696,9 +696,9 @@ fn SiteHamburger() -> impl IntoView {
diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 46f9deb..afda321 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -345,6 +345,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Board + controls (sidebar on wide, footer on narrow) ─────────
+
{my_name}
- {my_can_bredouille.then(|| view! { - - "B" - - })}
{my_pegs}
{move || my_displayed_pts.get()} "/12" + {my_can_bredouille.then(|| view! { + + "B" + + })}
{(my_holes_gained > 0).then(|| { @@ -207,23 +207,23 @@ pub fn MergedScorePanel( // ── Opponent: right side, left-aligned from center ──────────────
+
+
+ {opp_name} +
+
{opp_pegs}
{move || opp_displayed_pts.get()} "/12" + {opp_can_bredouille.then(|| view! { + + "B" + + })}
-
{opp_pegs}
- {opp_can_bredouille.then(|| view! { - - "B" - - })} -
- {opp_name} -
-
diff --git a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html index 4ac9d36..a561045 100644 --- a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html +++ b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html @@ -5,149 +5,8 @@ Trictrac - - + - - +
Anonymous (you)
6/12
Trictrac
6/12
Bot
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
8
12
11
10
9
8
7
6
5
4
3
2
1
11
grand jan
petit jan
Move a checker (1 of 2)

Click a highlighted field to move a checker

Cannot play in a quarter the opponent can still fill
+2 pts
True hit (big jan)simple×1+2
diff --git a/doc/design/snapshots/2026-06-20.html b/doc/design/snapshots/2026-06-20.html new file mode 100644 index 0000000..5290f8a --- /dev/null +++ b/doc/design/snapshots/2026-06-20.html @@ -0,0 +1,13 @@ + + + + + + Trictrac + + + + + + +
Anonyme
0/12
Trictrac
6/12
B
Bot
13
14
15
16
17
18
19
20
21
22
23
24
11
12
11
10
9
8
7
6
5
4
3
2
1
9
L'adversaire a lancé les dés

Cliquez Continuer quand vous êtes prêt

Adversaire +2 pts
Battage à vrai (grand jan)simple×1+2
diff --git a/doc/design/snapshots/2026-06-20_files/style-5f3b2c8fd952901a.css b/doc/design/snapshots/2026-06-20_files/style-5f3b2c8fd952901a.css new file mode 100644 index 0000000..aeba0a0 --- /dev/null +++ b/doc/design/snapshots/2026-06-20_files/style-5f3b2c8fd952901a.css @@ -0,0 +1,2518 @@ +/* ── 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-blue: #e5eadc; + --field-blue-light: #1a4f72; + --field-brown: #f2dfa0; + --field-brown-light: #6a2810; + --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; } + +/* -- svg icons -- */ +.icon { + width: 1.2em; + height: 1.2em; + color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; +} + +/* ── 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-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */ + /* border: 1px solid rgba(200,164,72,0.3); */ + /* border-top: 3px solid var(--ui-gold-dark); */ + padding: 1.75rem 2rem; + 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(3, 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; } + +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.portal-link { + color: var(--ui-gold); + text-decoration: none; + font-size: 0.875rem; +} +.portal-link:hover { text-decoration: underline; } + +.portal-verification-banner { + background: rgba(200,164,72,0.08); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.portal-verification-banner p { + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +/* ── Share URL row (lobby waiting card + game top bar) ──────────── */ +.share-url-row { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0,0,0,0.18); + border: 1px solid rgba(200,164,72,0.25); + border-radius: 5px; + padding: 0.4rem 0.6rem; +} +.share-url-text { + flex: 1; + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(242,232,208,0.75); + word-break: break-all; + user-select: all; +} +.share-copy-btn { + flex-shrink: 0; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.2rem 0.6rem; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 3px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} +.share-copy-btn:hover { background: rgba(200,164,72,0.22); } + +/* ── QR code container ───────────────────────────────────────────── */ +.qr-container { + width: 160px; + height: 160px; + margin: 0 auto; + border-radius: 4px; + overflow: hidden; +} +.qr-container svg { width: 100%; height: 100%; display: block; } + +/* ── Share popover (in-game top bar) ─────────────────────────────── */ +.share-popover { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(200,164,72,0.2); + border-radius: 6px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.share-popover .qr-container { width: 120px; height: 120px; } +.share-popover-label { + font-size: 0.75rem; + color: rgba(242,232,208,0.6); + text-align: center; + margin: 0; +} + +/* ── 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 { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* 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); + */ + /* border-top: 3px solid var(--ui-gold-dark); */ + width: 340px; + margin-top: 5vh; + overflow: hidden; +} + +/* 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; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 4em; +} + +.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); +} + +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + 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; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + +/* ── 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; } + +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; +} + +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; +} + +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; +} + +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; + cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; +} +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } + +/* ── Inner scoring card ─────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 320px; +} + +.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); + } +} + +/* ── 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 ────────────────────────────────── */ +.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), +.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-opponent:nth-child(even), +.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); } + +/* ── Point indicator: first N fields reflect each player's score & bredouille */ +.board-quarter .field.zone-petit.point-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.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; +} + +/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ +.exit-btn { + pointer-events: none; + opacity: 0.3; + transition: opacity 0.2s, transform 0.15s; +} +.exit-btn.exit-active { + pointer-events: auto; + cursor: pointer; + opacity: 1; + animation: exit-btn-pulse 1.4s ease-in-out infinite; +} +.exit-btn.exit-active:hover { + transform: scale(1.1); +} +@keyframes exit-btn-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } + 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } +} + +.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; +} +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} +.field.selected { + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); +} + +.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; +} + +/* ── Free-play mode ─────────────────────────────────────────────────────── */ +.free-mode-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-ui); + font-size: 0.78rem; + color: #887766; + cursor: pointer; + user-select: none; + padding-top: 0.1rem; +} +.free-mode-toggle input[type="checkbox"] { + accent-color: var(--ui-gold); + cursor: pointer; + width: 0.85rem; + height: 0.85rem; +} +.free-mode-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 1px solid #a89880; + font-size: 0.65rem; + font-style: normal; + color: #a89880; + cursor: help; + flex-shrink: 0; +} + +.free-mode-error { + text-align: center; + gap: 0.75rem; + background: rgba(180, 60, 30, 0.12); + border: 1px solid rgba(180, 60, 30, 0.4); + border-radius: 4px; + padding: 0.4rem 0.75rem; + width: 100%; + box-sizing: border-box; +} +.free-mode-error-msg { + font-family: var(--font-ui); + font-size: 0.85rem; + color: #8b2000; + font-style: italic; +} + +/* ── 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; +} + +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + 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); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: row; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; + cursor: pointer; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} + +/* Prevent horizontal scrollbar from the full-bleed strip */ +.game-overlay { overflow-x: hidden !important; } + +/* Board bar: hide die slots, keep the rail as a thin divider */ +.bar-die-slot { display: none !important; } +.board-bar { width: 5px; overflow: hidden; } + +/* ── Full-width in-flow player strip ─────────────────────────────────── */ +.players-strip { + width: 100vw; + margin-top: -1.5rem; /* undo game-overlay top padding */ + display: flex; + align-items: center; + background: var(--ui-parchment); + border-bottom: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 8px rgba(0,0,0,0.18); + padding: 0.35rem 1.5rem; + gap: 0.5rem; +} + +.strip-player { display: flex; align-items: center; flex: 1; min-width: 0; } +.strip-player-left { justify-content: flex-end; } +.strip-player-right { justify-content: flex-start; } + +.strip-active-zone { + display: flex; + align-items: center; + gap: 0.7rem; + border-radius: 8px; + padding: 0.28rem 0.5rem; + transition: background 0.15s; +} +.strip-active-zone.active { background: rgba(58,42,10,0.15); } + +/* Checker-style circles */ +.strip-avatar { + width: 38px; height: 38px; + border-radius: 50%; + flex-shrink: 0; +} +.strip-avatar-me { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(160,130,70,0.22) 68.5%, rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(160,130,70,0.17) 43.5%, rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.35), inset 0 -1px 3px rgba(0,0,0,0.15); +} +.strip-avatar-opp { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(200,164,72,0.18) 68.5%, rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(200,164,72,0.13) 43.5%, rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 -1px 3px rgba(0,0,0,0.4); +} + +/* Strip peg overrides */ +.players-strip .peg-track { gap: 3px; } +.players-strip .peg-hole { width: 12px; height: 12px; } +.players-strip .peg-hole.filled { + background: #5aab38; border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} +.players-strip .peg-hole.peg-opp.filled { + background: #c05030; border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Strip score-row-name: remove fixed width from v01 */ +.players-strip .score-row-name { width: auto; } + +/* No ghost bar below pts-counter in the strip */ +.players-strip .pts-counter-wrap { padding-bottom: 0; } + +/* Center "Trictrac" title */ +.players-strip-center { + flex-shrink: 0; + padding: 0 1rem; + border-left: 1px solid rgba(138,106,40,0.2); + border-right: 1px solid rgba(138,106,40,0.2); +} +.strip-title { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.03em; + white-space: nowrap; + margin-left: 1rem; +} + +/* ── Body: board + controls ──────────────────────────────────────────── */ +.main-body { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +/* ── Controls column (sidebar on wide, row on narrow) ────────────────── */ +.controls { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-self: stretch; +} +@media (min-width: 920px) { + .controls { + width: 200px; + } +} + +.ctrl-dice { + background: var(--board-rail); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.6rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.ctrl-dice-row { + display: flex; + gap: 0.55rem; + align-items: center; + justify-content: center; +} + +/* Free-mode toggle: light text on dark board-rail background */ +.ctrl-dice .free-mode-toggle { + color: var(--ui-parchment); + font-size: 0.7rem; + flex-wrap: wrap; + justify-content: center; + text-align: center; + gap: 0.3rem; +} +.ctrl-dice .free-mode-help { + border-color: rgba(242,232,208,0.35); + color: rgba(242,232,208,0.5); +} + +.ctrl-status { + background: var(--ui-parchment); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.65rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + gap: 0.4rem; + min-width: 0; +} +.ctrl-status .game-status { + color: var(--ui-ink); + text-shadow: none; + font-size: 1rem; + padding: 0; + width: auto; + text-align: center; + line-height: 1.3; +} +.ctrl-status .board-actions { + flex-wrap: wrap; + justify-content: center; + min-height: 0; +} +.ctrl-status .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; + text-align: center; + font-size: 0.67rem; + line-height: 1.4; + margin: 0; +} + +.scoring-row .scoring-panels-container { + position: static; + top: auto; left: auto; right: auto; bottom: auto; + z-index: auto; + padding: 0; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.scoring-row .scoring-panel { + box-sizing: border-box; + margin: 0; +} + +/* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */ +@media (max-width: 919px) { + .main-body { + flex-direction: column; + align-items: stretch; + } + .controls { + flex-direction: row; + width: 100%; + } + .ctrl-status { flex: 1; } + /* Hide pegs on small screens to save space in the strip */ + .players-strip .peg-track { display: none; } +} From 7a760980ba36ea086c82cd707296c9807c6a1171 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 20 Jun 2026 21:42:11 +0200 Subject: [PATCH 3/5] fix: die animation --- clients/backbone-lib/src/lib.rs | 2 +- clients/web/assets/style.css | 82 +------------------ .../web/src/game/components/game_screen.rs | 8 +- .../web/src/game/components/score_panel.rs | 13 --- clients/web/src/game/session.rs | 21 +++-- 5 files changed, 26 insertions(+), 100 deletions(-) diff --git a/clients/backbone-lib/src/lib.rs b/clients/backbone-lib/src/lib.rs index d67a96c..48f478f 100644 --- a/clients/backbone-lib/src/lib.rs +++ b/clients/backbone-lib/src/lib.rs @@ -1,9 +1,9 @@ +pub mod platform; pub mod session; pub mod traits; mod client; mod host; -mod platform; mod protocol; pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 17cdf3d..665aac9 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -839,8 +839,8 @@ a:hover { text-decoration: underline; } } .peg-hole { - width: 10px; - height: 10px; + width: 14px; + height: 14px; border-radius: 50%; border: 1.5px solid rgba(138,106,40,0.45); background: rgba(0,0,0,0.06); @@ -848,12 +848,6 @@ a:hover { text-decoration: underline; } 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; @@ -868,20 +862,7 @@ a:hover { text-decoration: underline; } margin: 0.4em; } -/* ── Merged scoreboard (both players, above board) ──────────────────── */ -.merged-score-panel { - background: var(--ui-parchment); - border-radius: 5px; - padding: 0.5rem 1.25rem 0.45rem; - 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; - flex-direction: column; - gap: 0.2rem; -} - +/* ── scoreboard (both players, above board) ──────────────────── */ .score-row { display: flex; align-items: center; @@ -964,33 +945,6 @@ a:hover { text-decoration: underline; } padding-bottom: 2px; } -/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ -.merged-score-panel .peg-track { - gap: 4px; -} - -.merged-score-panel .peg-hole { - width: 14px; - height: 14px; - border-radius: 50%; - border: 1.5px solid rgba(138,106,40,0.3); - background: rgba(0,0,0,0.06); - flex-shrink: 0; - transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; -} - -.merged-score-panel .peg-hole.filled { - background: #5aab38; - border-color: #3a7828; - box-shadow: 0 0 5px rgba(90,171,56,0.55); -} - -.merged-score-panel .peg-hole.peg-opp.filled { - background: #c05030; - border-color: #8a3018; - box-shadow: 0 0 5px rgba(192,80,48,0.55); -} - /* Peg pop-in animation when a new hole is scored */ @keyframes peg-pop { 0% { transform: scale(0.15); opacity: 0; } @@ -999,10 +953,6 @@ a:hover { text-decoration: underline; } 100% { transform: scale(1.0); opacity: 1; } } -.merged-score-panel .peg-hole.peg-new { - animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; -} - /* Thin separator between the two player rows */ .score-row-sep { height: 1px; @@ -1010,31 +960,6 @@ a:hover { text-decoration: underline; } margin: 0.05rem 0; } -/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ -@keyframes hole-flash-in-out { - 0% { opacity: 0; transform: translateY(-3px); } - 14% { opacity: 1; transform: translateY(0); } - 65% { opacity: 1; } - 100% { opacity: 0; transform: translateY(2px); } -} - -.hole-flash { - margin-left: auto; - flex-shrink: 0; - white-space: nowrap; - font-family: var(--font-display); - font-size: 0.88rem; - font-weight: 600; - color: var(--ui-green-accent); - letter-spacing: 0.05em; - animation: hole-flash-in-out 2.5s ease-out forwards; - pointer-events: none; -} - -.hole-flash.hole-flash-bredouille { - color: var(--ui-gold-dark); -} - /* ── Game bottom strip — status, hints, buttons on cream ────────────── */ .game-bottom-strip { background: var(--ui-parchment); @@ -2365,7 +2290,6 @@ a:hover { text-decoration: underline; } /* Strip peg overrides */ .players-strip .peg-track { gap: 3px; direction: ltr; } -.players-strip .peg-hole { width: 12px; height: 12px; } .players-strip .peg-hole.filled { background: #5aab38; border-color: #3a7828; box-shadow: 0 0 5px rgba(90,171,56,0.55); diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index afda321..eb3689c 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -126,7 +126,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { } let dice = vs.dice; - let show_dice = dice != (0, 0); + // Hide dice during RollDice/RollWaiting: the stored dice values are stale from the + // previous turn and showing them would trigger the tumble animation incorrectly. + let show_dice = dice != (0, 0) + && !matches!( + vs.turn_stage, + SerTurnStage::RollDice | SerTurnStage::RollWaiting + ); // ── Button senders ───────────────────────────────────────────────────────── let cmd_tx_go = cmd_tx.clone(); diff --git a/clients/web/src/game/components/score_panel.rs b/clients/web/src/game/components/score_panel.rs index 14468c7..47ff126 100644 --- a/clients/web/src/game/components/score_panel.rs +++ b/clients/web/src/game/components/score_panel.rs @@ -183,19 +183,6 @@ pub fn MergedScorePanel( })}
- {(my_holes_gained > 0).then(|| { - let label = if my_bredouille { - format!("Trou {} · ×2 bredouille", my_holes) - } else { - format!("Trou {}", my_holes) - }; - view! { -
- {label} -
- } - })} diff --git a/clients/web/src/game/session.rs b/clients/web/src/game/session.rs index f544235..aa6e86c 100644 --- a/clients/web/src/game/session.rs +++ b/clients/web/src/game/session.rs @@ -6,9 +6,8 @@ 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 crate::game::trictrac::types::{JanEntry, ScoredEvent, SerStage, SerTurnStage, ViewState}; +use backbone_lib::platform::sleep_ms; use trictrac_store::CheckerMove; use std::collections::VecDeque; @@ -124,6 +123,7 @@ async fn run_local_bot_game_loop( match bot_decide(backend.get_game(), pgr.as_ref()) { None => break, Some(action) => { + sleep_ms(500).await; backend.inform_rpc(1, action); for cmd in backend.drain_commands() { if let BackendCommand::Delta(delta) = cmd { @@ -189,7 +189,11 @@ pub fn compute_last_moves( } /// 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 { +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]; @@ -275,7 +279,11 @@ pub fn push_or_show( /// 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 { +pub fn infer_pause_reason( + prev: &ViewState, + next: &ViewState, + player_id: u16, +) -> Option { let opponent_id = 1 - player_id; if next.stage == SerStage::PreGameRoll { @@ -297,7 +305,8 @@ pub fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> if next.dice != prev.dice { return Some(PauseReason::AfterOpponentRoll); } - if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move { + if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move + { return Some(PauseReason::AfterOpponentGo); } } From 813cc3448a15f0a39388c9f5a994392a5dee1d31 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 21 Jun 2026 13:44:24 +0200 Subject: [PATCH 4/5] feat: web client bot tuning --- clients/web/src/game/trictrac/bot_local.rs | 59 ++- devenv.lock | 64 +-- store/Cargo.toml | 4 + store/src/bin/weight_tuner.rs | 456 +++++++++++++++++++++ 4 files changed, 508 insertions(+), 75 deletions(-) create mode 100644 store/src/bin/weight_tuner.rs diff --git a/clients/web/src/game/trictrac/bot_local.rs b/clients/web/src/game/trictrac/bot_local.rs index 6161fe2..8a817fe 100644 --- a/clients/web/src/game/trictrac/bot_local.rs +++ b/clients/web/src/game/trictrac/bot_local.rs @@ -1,4 +1,4 @@ -use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; +use trictrac_store::{Board, CheckerMove, Color, Dice, GameState, MoveRules, Stage, TurnStage}; use super::types::{PlayerAction, PreGameRollState}; @@ -45,13 +45,42 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option f32 { - let mut b = board.mirror(); - let _ = b.move_checker(&Color::White, *m1); - let _ = b.move_checker(&Color::White, *m2); - evaluate(&b) + // Apply bot's moves on the mirrored board, then restore normal coordinates → B1. + let mut b_mirror = board.mirror(); + let _ = b_mirror.move_checker(&Color::White, *m1); + let _ = b_mirror.move_checker(&Color::White, *m2); + let b1 = b_mirror.mirror(); + + // Expectiminimax: sum over all 21 distinct dice pairs, weighted by probability (out of 36). + // Non-doubles have probability 2/36 each; doubles 1/36 each. + let mut total = 0.0f32; + for d1 in 1u8..=6 { + for d2 in d1..=6 { + let weight = if d1 == d2 { 1.0f32 } else { 2.0f32 }; + let opp_rules = MoveRules::new(&Color::White, &b1, Dice { values: (d1, d2) }); + let opp_seqs = opp_rules.get_possible_moves_sequences(true, vec![]); + let min_score = if opp_seqs.is_empty() { + evaluate(&b1.mirror()) + } else { + opp_seqs + .iter() + .map(|(om1, om2)| { + let mut b2 = b1.clone(); + let _ = b2.move_checker(&Color::White, *om1); + let _ = b2.move_checker(&Color::White, *om2); + evaluate(&b2.mirror()) + }) + .fold(f32::INFINITY, f32::min) + }; + total += weight * min_score; + } + } + total // proportional to expected score; dividing by 36 doesn't affect move ordering } /// Evaluate a board position from White's perspective (call after mirroring for Black). @@ -61,11 +90,19 @@ fn evaluate(board: &Board) -> f32 { let white_fields = board.get_color_fields(Color::White); let black_fields = board.get_color_fields(Color::Black); + // Bonus if rest corner filled (tuned: 6.0) + let corner_field = board.get_color_corner(&Color::White); + let (corner_count, _color) = board.get_field_checkers(corner_field).unwrap(); + if corner_count > 0 { + score += 6.0; + } + // Quarter fill progress — quarters 1-6, 7-12, 19-24. // Quarter 13-18 is skipped: field 13 is the opponent's rest corner so White can never fill it. + // quarter_filled tuned to 5.5 (was 8.0), quarter_progress kept at 0.3. for &q in &[1usize, 7, 19] { if board.is_quarter_filled(Color::White, q) { - score += 8.0; + score += 5.5; } else { let missing = board.get_quarter_filling_candidate(Color::White); score += (6 - missing.len().min(6)) as f32 * 0.3; @@ -81,12 +118,8 @@ fn evaluate(board: &Board) -> f32 { } } - // Exit zone progress: reward checkers already in fields 19-24. - for (field, count) in &white_fields { - if *field >= 19 { - score += count.abs() as f32 * 0.3; - } - } + // Exit zone progress: tuned to 0.0 — mid-game jan-filling dominates. + // (term kept here as a reminder; re-enable when bearing-off phase is reached) score } diff --git a/devenv.lock b/devenv.lock index 3f0905b..e6e8ef6 100644 --- a/devenv.lock +++ b/devenv.lock @@ -17,62 +17,6 @@ "type": "github" } }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1767039857, - "owner": "NixOS", - "repo": "flake-compat", - "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1778507602, - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762808025, - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "locked": { "lastModified": 1779102034, @@ -108,15 +52,11 @@ "root": { "inputs": { "devenv": "devenv", - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "nixpkgs-cmake3": "nixpkgs-cmake3", - "pre-commit-hooks": [ - "git-hooks" - ] + "nixpkgs-cmake3": "nixpkgs-cmake3" } } }, "root": "root", "version": 7 -} +} \ No newline at end of file diff --git a/store/Cargo.toml b/store/Cargo.toml index 92b1b84..863cda3 100644 --- a/store/Cargo.toml +++ b/store/Cargo.toml @@ -22,3 +22,7 @@ transpose = "0.2.2" [[bin]] name = "random_game" path = "src/bin/random_game.rs" + +[[bin]] +name = "weight_tuner" +path = "src/bin/weight_tuner.rs" diff --git a/store/src/bin/weight_tuner.rs b/store/src/bin/weight_tuner.rs new file mode 100644 index 0000000..43229b5 --- /dev/null +++ b/store/src/bin/weight_tuner.rs @@ -0,0 +1,456 @@ +//! Weight tuner for the trictrac heuristic bot. +//! +//! Uses self-play (greedy heuristic with candidate weights vs current champion weights) +//! to measure win-rate signal. Since both bots are similarly capable, small weight +//! differences produce a gradient near 50%, unlike vs-random where the heuristic wins +//! ~100% regardless of weights. +//! +//! Algorithm: coordinate-descent hill-climbing. For each weight, probe +step and -step; +//! accept the change that pushes the challenger win-rate above 50%. Halve step when no +//! weight in the current pass improved. Stop when step < min_step. +//! +//! Each win-rate estimate runs `n_games` games with the challenger as White AND as Black +//! (total 2×n_games), eliminating first-move bias. +//! +//! Usage: +//! cargo run --release --bin weight_tuner -- [--games ] [--seed ] [--step ] [--min-step ] +//! +//! Prints the best weights at the end; paste them into bot_local.rs. + +use std::borrow::Cow; +use std::time::Instant; + +use trictrac_store::{ + training_common::sample_valid_action, Board, CheckerMove, Color, DiceRoller, GameEvent, + GameState, MoveRules, Stage, TurnStage, +}; + +// ── Weights ─────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +struct Weights { + corner_filled: f32, // bonus if rest corner (field 12 for White) is occupied + quarter_filled: f32, // bonus per fully filled quarter + quarter_progress: f32, // bonus per non-missing checker in the most-promising unfilled quarter + singleton_penalty: f32, // penalty per exposed singleton (opponent checker at higher field) + exit_zone: f32, // bonus per checker already in fields 19-24 +} + +const WEIGHT_NAMES: [&str; 5] = [ + "corner_filled", + "quarter_filled", + "quarter_progress", + "singleton_penalty", + "exit_zone", +]; + +impl Weights { + fn initial() -> Self { + // Current hard-coded values from bot_local.rs + Self { + corner_filled: 5.0, + quarter_filled: 8.0, + quarter_progress: 0.3, + singleton_penalty: 0.5, + exit_zone: 0.3, + } + } + + fn get(&self, i: usize) -> f32 { + match i { + 0 => self.corner_filled, + 1 => self.quarter_filled, + 2 => self.quarter_progress, + 3 => self.singleton_penalty, + 4 => self.exit_zone, + _ => panic!("weight index out of range"), + } + } + + fn with(&self, i: usize, v: f32) -> Self { + let mut w = self.clone(); + match i { + 0 => w.corner_filled = v, + 1 => w.quarter_filled = v, + 2 => w.quarter_progress = v, + 3 => w.singleton_penalty = v, + 4 => w.exit_zone = v, + _ => panic!("weight index out of range"), + } + w + } +} + +// ── Evaluation ──────────────────────────────────────────────────────────────── + +/// Evaluate a board from White's perspective. +/// Mirrors evaluate() in bot_local.rs with parameterised weights. +fn evaluate(board: &Board, w: &Weights) -> f32 { + let mut score = 0.0f32; + + let white_fields = board.get_color_fields(Color::White); + let black_fields = board.get_color_fields(Color::Black); + + let corner_field = board.get_color_corner(&Color::White); + let (corner_count, _) = board.get_field_checkers(corner_field).unwrap(); + if corner_count > 0 { + score += w.corner_filled; + } + + for &q in &[1usize, 7, 19] { + if board.is_quarter_filled(Color::White, q) { + score += w.quarter_filled; + } else { + let missing = board.get_quarter_filling_candidate(Color::White); + score += (6 - missing.len().min(6)) as f32 * w.quarter_progress; + } + } + + let max_black_field = black_fields.iter().map(|(f, _)| *f).max().unwrap_or(0); + for (f, count) in &white_fields { + if *count == 1 && *f < max_black_field { + score -= w.singleton_penalty; + } + } + + for (field, count) in &white_fields { + if *field >= 19 { + score += count.abs() as f32 * w.exit_zone; + } + } + + score +} + +/// Greedy score for a move sequence. +/// `m1`, `m2` are in the MoveRules output space for `color` (mirrored White space for Black). +fn score_seq(board: &Board, m1: &CheckerMove, m2: &CheckerMove, color: Color, w: &Weights) -> f32 { + // MoveRules for Black mirrors the board; sequences are in White space after mirror. + // Replicate: use the mirrored board for Black, original for White. + let mut b = if color == Color::White { board.clone() } else { board.mirror() }; + let _ = b.move_checker(&Color::White, *m1); + let _ = b.move_checker(&Color::White, *m2); + evaluate(&b, w) +} + +// ── Bot actions ─────────────────────────────────────────────────────────────── + +/// Pick the greedy best move for the heuristic bot with the given color and weights. +/// Returns a GameEvent::Move with moves in the game's (non-mirrored) coordinate space. +fn heuristic_action(state: &GameState, color: Color, weights: &Weights) -> GameEvent { + let rules = MoveRules::new(&color, &state.board, state.dice); + let seqs = rules.get_possible_moves_sequences(true, vec![]); + let (m1, m2) = seqs + .iter() + .max_by(|(a1, a2), (b1, b2)| { + score_seq(&state.board, a1, a2, color, weights) + .partial_cmp(&score_seq(&state.board, b1, b2, color, weights)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + .unwrap_or_default(); + // MoveRules for Black returns moves in mirrored (White) space — mirror back. + let (m1, m2) = if color == Color::Black { (m1.mirror(), m2.mirror()) } else { (m1, m2) }; + GameEvent::Move { player_id: state.active_player_id, moves: (m1, m2) } +} + +/// Pick a uniformly random move for the random bot (used only in --vs-random mode). +fn random_action(state: &GameState) -> GameEvent { + let view: Cow = Cow::Owned(state.mirror()); + if let Some(action) = sample_valid_action(&view) { + if let Some(event) = action.to_event(&view) { + return event.get_mirror(false); + } + } + GameEvent::Move { + player_id: state.active_player_id, + moves: (CheckerMove::default(), CheckerMove::default()), + } +} + +// ── Game simulation ─────────────────────────────────────────────────────────── + +const MAX_STEPS: usize = 8_000; + +/// Simulate one self-play game. +/// Player 1 (White) uses `weights_p1`, player 2 (Black) uses `weights_p2`. +/// Returns the winner's player_id, or None on truncation. +fn run_selfplay_game( + weights_p1: &Weights, + weights_p2: &Weights, + roller: &mut DiceRoller, +) -> Option { + let mut state = GameState::new_with_players("Bot1", "Bot2"); + let mut steps = 0; + + while state.stage != Stage::Ended { + steps += 1; + if steps > MAX_STEPS { + return None; + } + + match state.turn_stage { + TurnStage::RollDice => { + let _ = state.consume(&GameEvent::Roll { player_id: state.active_player_id }); + let dice = roller.roll(); + let _ = state + .consume(&GameEvent::RollResult { player_id: state.active_player_id, dice }); + } + _ => { + let event = if state.active_player_id == 1 { + heuristic_action(&state, Color::White, weights_p1) + } else { + heuristic_action(&state, Color::Black, weights_p2) + }; + if state.consume(&event).is_err() { + return None; + } + } + } + } + + state.determine_winner() +} + +/// Estimate challenger's win rate against champion via self-play. +/// Runs n_games with challenger as White and n_games with challenger as Black +/// to eliminate first-move bias. Returns fraction of games won by challenger. +fn self_play_win_rate( + challenger: &Weights, + champion: &Weights, + n_games: usize, + roller: &mut DiceRoller, +) -> f32 { + let mut challenger_wins = 0usize; + let total = n_games * 2; + + for _ in 0..n_games { + // Challenger as White (player 1) + if run_selfplay_game(challenger, champion, roller) == Some(1) { + challenger_wins += 1; + } + // Challenger as Black (player 2) + if run_selfplay_game(champion, challenger, roller) == Some(2) { + challenger_wins += 1; + } + } + + challenger_wins as f32 / total as f32 +} + +/// Win rate of the heuristic bot (player 1 / White) against the random bot. +/// Useful as a sanity check, but not suitable for hill-climbing (win rate ≈ 100%). +fn vs_random_win_rate(weights: &Weights, n_games: usize, roller: &mut DiceRoller) -> f32 { + let mut wins = 0usize; + for _ in 0..n_games { + let mut state = GameState::new_with_players("Heuristic", "Random"); + let mut steps = 0; + while state.stage != Stage::Ended { + steps += 1; + if steps > MAX_STEPS { + break; + } + match state.turn_stage { + TurnStage::RollDice => { + let _ = state.consume(&GameEvent::Roll { player_id: state.active_player_id }); + let dice = roller.roll(); + let _ = state.consume(&GameEvent::RollResult { + player_id: state.active_player_id, + dice, + }); + } + _ => { + let event = if state.active_player_id == 1 { + heuristic_action(&state, Color::White, weights) + } else { + random_action(&state) + }; + let _ = state.consume(&event); + } + } + } + if state.determine_winner() == Some(1) { + wins += 1; + } + } + wins as f32 / n_games as f32 +} + +// ── Hill-climbing ───────────────────────────────────────────────────────────── + +/// Coordinate-descent hill-climbing via self-play. +/// +/// Compares each candidate (champion ± step on one weight) against the current +/// champion. Accepts the candidate if its self-play win rate exceeds `0.5 + margin` +/// (default 0.52 ≈ 2σ at N=150 games, i.e. N=300 total trials). +/// Halves step when a full pass produces no improvement; stops when step < min_step. +fn hill_climb( + initial: Weights, + n_games: usize, + initial_step: f32, + min_step: f32, + margin: f32, + roller: &mut DiceRoller, +) -> Weights { + let threshold = 0.5 + margin; + let mut champion = initial; + let mut step = initial_step; + + println!("Initial weights: {:?}", champion); + println!("Acceptance threshold: >{:.0}% (margin={:.3})", threshold * 100.0, margin); + println!(); + + let mut iteration = 0usize; + while step >= min_step { + let mut improved = false; + iteration += 1; + + for i in 0..5 { + // Probe +step (clamped to non-negative). + let up = champion.with(i, (champion.get(i) + step).max(0.0)); + let wr_up = self_play_win_rate(&up, &champion, n_games, roller); + + // Probe -step. + let dn = champion.with(i, (champion.get(i) - step).max(0.0)); + let wr_dn = self_play_win_rate(&dn, &champion, n_games, roller); + + let best_wr = wr_up.max(wr_dn); + if best_wr >= threshold { + let (accepted, wr_accepted) = + if wr_up >= wr_dn { (up, wr_up) } else { (dn, wr_dn) }; + let dir = if wr_up >= wr_dn { '+' } else { '-' }; + println!( + " iter {:3} {} {}{:.3} self-play win {:.1}% {:?}", + iteration, + WEIGHT_NAMES[i], + dir, + step, + wr_accepted * 100.0, + accepted + ); + champion = accepted; + improved = true; + } + } + + if !improved { + step *= 0.5; + println!( + " iter {:3} no improvement at step {:.3} → halving to {:.3}", + iteration, + step * 2.0, + step + ); + } + } + + champion +} + +// ── CLI args ────────────────────────────────────────────────────────────────── + +struct Args { + n_games: usize, + seed: Option, + initial_step: f32, + min_step: f32, + margin: f32, + vs_random: bool, +} + +fn parse_args() -> Args { + let args: Vec = std::env::args().collect(); + let mut n_games = 200usize; + let mut seed: Option = None; + let mut initial_step = 2.0f32; + let mut min_step = 0.1f32; + // At N=200 games × 2 directions = 400 total trials, σ ≈ sqrt(0.25/400) ≈ 2.5%. + // margin=0.03 ≈ 1.2σ: catches real improvements while filtering most noise. + let mut margin = 0.03f32; + let mut vs_random = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--games" => { + i += 1; + if let Some(v) = args.get(i).and_then(|s| s.parse().ok()) { + n_games = v; + } + } + "--seed" => { + i += 1; + seed = args.get(i).and_then(|s| s.parse().ok()); + } + "--step" => { + i += 1; + if let Some(v) = args.get(i).and_then(|s| s.parse().ok()) { + initial_step = v; + } + } + "--min-step" => { + i += 1; + if let Some(v) = args.get(i).and_then(|s| s.parse().ok()) { + min_step = v; + } + } + "--margin" => { + i += 1; + if let Some(v) = args.get(i).and_then(|s| s.parse().ok()) { + margin = v; + } + } + "--vs-random" => vs_random = true, + _ => {} + } + i += 1; + } + + Args { n_games, seed, initial_step, min_step, margin, vs_random } +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +fn main() { + let args = parse_args(); + + println!("=== Trictrac weight tuner ==="); + println!("mode : {}", if args.vs_random { "vs-random (no hill-climbing)" } else { "self-play hill-climbing" }); + println!("games/eval : {}", args.n_games); + println!("seed : {:?}", args.seed); + if !args.vs_random { + println!("step range : {:.3} → {:.3}", args.initial_step, args.min_step); + println!("margin : >{:.0}%", (0.5 + args.margin) * 100.0); + } + println!(); + + let mut roller = DiceRoller::new(args.seed); + let t0 = Instant::now(); + + if args.vs_random { + let wr = vs_random_win_rate(&Weights::initial(), args.n_games, &mut roller); + println!("vs-random win rate: {:.1}% ({} games)", wr * 100.0, args.n_games); + println!("Elapsed: {:.1} s", t0.elapsed().as_secs_f64()); + return; + } + + let best = hill_climb( + Weights::initial(), + args.n_games, + args.initial_step, + args.min_step, + args.margin, + &mut roller, + ); + + let elapsed = t0.elapsed(); + println!(); + println!("=== Optimised weights (paste into bot_local.rs) ==="); + println!(" corner_filled: {}", best.corner_filled); + println!(" quarter_filled: {}", best.quarter_filled); + println!(" quarter_progress: {}", best.quarter_progress); + println!(" singleton_penalty: {}", best.singleton_penalty); + println!(" exit_zone: {}", best.exit_zone); + println!(); + println!("Elapsed: {:.1} s", elapsed.as_secs_f64()); +} From 1cdb1380b4f0f4932087fd9d34b6850a6624ff51 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 21 Jun 2026 13:46:48 +0200 Subject: [PATCH 5/5] chore: bump version to 0.2.18 --- Cargo.toml | 2 +- flake.nix | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 52537ac..f8efdb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.2.16" +version = "0.2.18" [workspace] resolver = "2" diff --git a/flake.nix b/flake.nix index 91e1038..6353109 100644 --- a/flake.nix +++ b/flake.nix @@ -39,7 +39,7 @@ frontendCargoDeps = rustPlatform.fetchCargoVendor { src = ./.; name = "trictrac-frontend-vendor"; - hash = "sha256-XBxdRT/f69GDfVc18/DnnAiY1vjMGMWfcYot0K0jevg="; + hash = "sha256-HJ9iUzsaMKapKb9DH0aoX7keuShq5fnHk1TSvNlw2CQ="; }; # Must match the wasm-bindgen version in Cargo.lock wasm-bindgen-version = "0.2.118"; @@ -102,7 +102,7 @@ trictrac = with final; rustPlatform.buildRustPackage { pname = "trictrac"; - version = "0.2.16"; # trictrac-version + version = "0.2.18"; # trictrac-version src = ./.; nativeBuildInputs = [ pkg-config ];