diff --git a/Cargo.lock b/Cargo.lock index b9533db..72f1a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.1", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -157,7 +157,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "backbone-lib" -version = "0.2.16" +version = "0.2.15" dependencies = [ "bytes", "ewebsock", @@ -222,9 +222,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blake2" @@ -295,9 +295,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.63" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -322,9 +322,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.4" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "cobs" @@ -651,9 +651,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", @@ -1007,7 +1007,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http 1.4.1", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -1162,9 +1162,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -1177,7 +1177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.0", ] [[package]] @@ -1188,7 +1188,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.0", "http-body", "pin-project-lite", ] @@ -1236,15 +1236,15 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "http 1.4.1", + "http 1.4.0", "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.1", + "http 1.4.0", "http-body", "hyper", "pin-project-lite", @@ -1313,7 +1313,7 @@ dependencies = [ "displaydoc", "potential_utf", "utf8_iter", - "yoke 0.8.3", + "yoke 0.8.2", "zerofrom", "zerovec 0.11.6", ] @@ -1613,7 +1613,7 @@ dependencies = [ "displaydoc", "icu_locale_core", "writeable 0.6.3", - "yoke 0.8.3", + "yoke 0.8.2", "zerofrom", "zerotrie 0.2.4", "zerovec 0.11.6", @@ -2069,9 +2069,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -2112,9 +2112,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "manyhow" @@ -2166,9 +2166,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "merge" @@ -2220,9 +2220,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2658,7 +2658,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.16" +version = "0.2.15" dependencies = [ "serde", ] @@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay-server" -version = "0.2.16" +version = "0.2.15" dependencies = [ "argon2", "axum", @@ -3175,7 +3175,7 @@ dependencies = [ "dashmap", "futures", "gloo-net 0.6.0", - "http 1.4.1", + "http 1.4.0", "js-sys", "once_cell", "pin-project-lite", @@ -3262,9 +3262,9 @@ dependencies = [ [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -3305,9 +3305,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3752,7 +3752,7 @@ dependencies = [ "axum-core", "cookie", "futures-util", - "http 1.4.1", + "http 1.4.0", "parking_lot", "pin-project-lite", "tower-layer", @@ -3769,7 +3769,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.0", "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.1", + "http 1.4.0", "time", "tokio", "tower-cookies", @@ -3824,7 +3824,7 @@ dependencies = [ "axum-core", "base64 0.22.1", "futures", - "http 1.4.1", + "http 1.4.0", "parking_lot", "rand 0.8.6", "serde", @@ -3921,7 +3921,7 @@ dependencies = [ [[package]] name = "trictrac-store" -version = "0.2.16" +version = "0.2.15" dependencies = [ "anyhow", "base64 0.21.7", @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "trictrac-web" -version = "0.2.16" +version = "0.2.15" dependencies = [ "backbone-lib", "futures", @@ -3967,7 +3967,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.0", "httparse", "log", "rand 0.8.6", @@ -3984,7 +3984,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -4014,9 +4014,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -4059,9 +4059,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -4131,9 +4131,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4643,9 +4643,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive 0.8.2", @@ -4678,18 +4678,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" 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.3", + "yoke 0.8.2", "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.3", + "yoke 0.8.2", "zerofrom", "zerovec-derive 0.11.3", ] diff --git a/Cargo.toml b/Cargo.toml index f8efdb7..7897d03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.2.18" +version = "0.2.17" [workspace] resolver = "2" diff --git a/clients/backbone-lib/src/lib.rs b/clients/backbone-lib/src/lib.rs index 48f478f..d67a96c 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 665aac9..86e7cb8 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -783,8 +783,7 @@ a:hover { text-decoration: underline; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - min-width: 4em; - text-align: left; + min-width: 0; } .score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } @@ -839,8 +838,8 @@ a:hover { text-decoration: underline; } } .peg-hole { - width: 14px; - height: 14px; + width: 10px; + height: 10px; border-radius: 50%; border: 1.5px solid rgba(138,106,40,0.45); background: rgba(0,0,0,0.06); @@ -848,6 +847,12 @@ 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; @@ -859,10 +864,22 @@ 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; } -/* ── scoreboard (both players, above board) ──────────────────── */ +/* ── 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; @@ -923,7 +940,6 @@ a:hover { text-decoration: underline; } display: flex; align-items: baseline; gap: 0.1rem; - direction: ltr; } .pts-counter { @@ -945,6 +961,33 @@ 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; } @@ -953,6 +996,10 @@ 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; @@ -960,6 +1007,31 @@ 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); @@ -2250,8 +2322,9 @@ a:hover { text-decoration: underline; } gap: 0.5rem; } -.strip-player { display: flex; justify-content: flex-end; align-items: center; flex: 1; min-width: 0; } -.strip-player-left { } +.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; @@ -2289,7 +2362,8 @@ a:hover { text-decoration: underline; } } /* Strip peg overrides */ -.players-strip .peg-track { gap: 3px; direction: ltr; } +.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); @@ -2330,23 +2404,16 @@ a:hover { text-decoration: underline; } } /* ── Controls column (sidebar on wide, row on narrow) ────────────────── */ -/* left-control displayed only on wide screen to center board */ -.controls, .left-controls { +.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 { @@ -2393,6 +2460,7 @@ a:hover { text-decoration: underline; } justify-content: space-around; align-items: center; gap: 0.4rem; + flex: 1; min-width: 0; } .ctrl-status .game-status { @@ -2435,11 +2503,6 @@ 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 { @@ -2451,7 +2514,6 @@ a:hover { text-decoration: underline; } width: 100%; } .ctrl-status { flex: 1; } - .strip-center { display: none; } - /* move second player below first player */ - .players-strip { flex-flow: column; } + /* Hide pegs on small screens to save space in the strip */ + .players-strip .peg-track { display: none; } } diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 144a374..ebc5130 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 move mode", + "free_mode_label": "Free play 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 f61290a..5889556 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": "Déplacement libre", + "free_mode_label": "Mode jeu 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 d490e4c..ba90a54 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 eb3689c..46f9deb 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -126,13 +126,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { } let dice = vs.dice; - // 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 - ); + let show_dice = dice != (0, 0); // ── Button senders ───────────────────────────────────────────────────────── let cmd_tx_go = cmd_tx.clone(); @@ -351,7 +345,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Board + controls (sidebar on wide, footer on narrow) ─────────
-
{my_name} + {t!(i18n, you_suffix)}
+ {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(|| { + let label = if my_bredouille { + format!("Trou {} · ×2 bredouille", my_holes) + } else { + format!("Trou {}", my_holes) + }; + view! { +
+ {label} +
+ } + })}
@@ -194,23 +208,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/clients/web/src/game/session.rs b/clients/web/src/game/session.rs index aa6e86c..f544235 100644 --- a/clients/web/src/game/session.rs +++ b/clients/web/src/game/session.rs @@ -6,8 +6,9 @@ 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 backbone_lib::platform::sleep_ms; +use crate::game::trictrac::types::{ + JanEntry, ScoredEvent, SerStage, SerTurnStage, ViewState, +}; use trictrac_store::CheckerMove; use std::collections::VecDeque; @@ -123,7 +124,6 @@ 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,11 +189,7 @@ 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]; @@ -279,11 +275,7 @@ 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 { @@ -305,8 +297,7 @@ pub fn infer_pause_reason( 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); } } diff --git a/clients/web/src/game/trictrac/bot_local.rs b/clients/web/src/game/trictrac/bot_local.rs index 8a817fe..6161fe2 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, Dice, GameState, MoveRules, Stage, TurnStage}; +use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; use super::types::{PlayerAction, PreGameRollState}; @@ -45,42 +45,13 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option f32 { - // 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 + let mut b = board.mirror(); + let _ = b.move_checker(&Color::White, *m1); + let _ = b.move_checker(&Color::White, *m2); + evaluate(&b) } /// Evaluate a board position from White's perspective (call after mirroring for Black). @@ -90,19 +61,11 @@ 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 += 5.5; + score += 8.0; } else { let missing = board.get_quarter_filling_candidate(Color::White); score += (6 - missing.len().min(6)) as f32 * 0.3; @@ -118,8 +81,12 @@ fn evaluate(board: &Board) -> f32 { } } - // 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) + // 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; + } + } score } diff --git a/devenv.lock b/devenv.lock index e6e8ef6..3f0905b 100644 --- a/devenv.lock +++ b/devenv.lock @@ -17,6 +17,62 @@ "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, @@ -52,11 +108,15 @@ "root": { "inputs": { "devenv": "devenv", + "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "nixpkgs-cmake3": "nixpkgs-cmake3" + "nixpkgs-cmake3": "nixpkgs-cmake3", + "pre-commit-hooks": [ + "git-hooks" + ] } } }, "root": "root", "version": 7 -} \ No newline at end of file +} diff --git a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html index a561045..4ac9d36 100644 --- a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html +++ b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html @@ -5,8 +5,149 @@ 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 deleted file mode 100644 index 5290f8a..0000000 --- a/doc/design/snapshots/2026-06-20.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - 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 deleted file mode 100644 index aeba0a0..0000000 --- a/doc/design/snapshots/2026-06-20_files/style-5f3b2c8fd952901a.css +++ /dev/null @@ -1,2518 +0,0 @@ -/* ── Google Fonts ───────────────────────────────────────────────── */ -@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); - -/* ── Design tokens ──────────────────────────────────────────────────── */ -:root { - --board-felt: #1d3d28; - --board-rail: #2a1508; - --field-ivory: #f0e6c8; - --field-burgundy: #7a1e2a; - --field-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; } -} diff --git a/flake.nix b/flake.nix index cce29d4..89d3586 100644 --- a/flake.nix +++ b/flake.nix @@ -39,7 +39,7 @@ frontendCargoDeps = rustPlatform.fetchCargoVendor { src = ./.; name = "trictrac-frontend-vendor"; - hash = "sha256-HJ9iUzsaMKapKb9DH0aoX7keuShq5fnHk1TSvNlw2CQ="; + hash = "sha256-XBxdRT/f69GDfVc18/DnnAiY1vjMGMWfcYot0K0jevg="; }; # 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.17"; # trictrac-version src = ./.; nativeBuildInputs = [ pkg-config ]; diff --git a/store/Cargo.toml b/store/Cargo.toml index 863cda3..92b1b84 100644 --- a/store/Cargo.toml +++ b/store/Cargo.toml @@ -22,7 +22,3 @@ 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 deleted file mode 100644 index 43229b5..0000000 --- a/store/src/bin/weight_tuner.rs +++ /dev/null @@ -1,456 +0,0 @@ -//! 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()); -}