Compare commits

..

6 commits

18 changed files with 3174 additions and 411 deletions

118
Cargo.lock generated
View file

@ -124,7 +124,7 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
"http 1.4.0",
"http 1.4.1",
"http-body",
"http-body-util",
"hyper",
@ -157,7 +157,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.4.0",
"http 1.4.1",
"http-body",
"http-body-util",
"mime",
@ -189,7 +189,7 @@ dependencies = [
[[package]]
name = "backbone-lib"
version = "0.2.15"
version = "0.2.16"
dependencies = [
"bytes",
"ewebsock",
@ -222,9 +222,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.11.1"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "blake2"
@ -295,9 +295,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.62"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex",
@ -322,9 +322,9 @@ dependencies = [
[[package]]
name = "cmov"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cobs"
@ -651,9 +651,9 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
@ -1007,7 +1007,7 @@ dependencies = [
"futures-core",
"futures-sink",
"gloo-utils",
"http 1.4.0",
"http 1.4.1",
"js-sys",
"pin-project",
"serde",
@ -1162,9 +1162,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@ -1177,7 +1177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http 1.4.0",
"http 1.4.1",
]
[[package]]
@ -1188,7 +1188,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http 1.4.0",
"http 1.4.1",
"http-body",
"pin-project-lite",
]
@ -1236,15 +1236,15 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http 1.4.0",
"http 1.4.1",
"http-body",
"httparse",
"httpdate",
@ -1261,7 +1261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http 1.4.0",
"http 1.4.1",
"http-body",
"hyper",
"pin-project-lite",
@ -1313,7 +1313,7 @@ dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke 0.8.2",
"yoke 0.8.3",
"zerofrom",
"zerovec 0.11.6",
]
@ -1613,7 +1613,7 @@ dependencies = [
"displaydoc",
"icu_locale_core",
"writeable 0.6.3",
"yoke 0.8.2",
"yoke 0.8.3",
"zerofrom",
"zerotrie 0.2.4",
"zerovec 0.11.6",
@ -2069,9 +2069,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.16"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
@ -2112,9 +2112,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.30"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "manyhow"
@ -2166,9 +2166,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "merge"
@ -2220,9 +2220,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
@ -2658,7 +2658,7 @@ dependencies = [
[[package]]
name = "protocol"
version = "0.2.15"
version = "0.2.16"
dependencies = [
"serde",
]
@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relay-server"
version = "0.2.15"
version = "0.2.16"
dependencies = [
"argon2",
"axum",
@ -3175,7 +3175,7 @@ dependencies = [
"dashmap",
"futures",
"gloo-net 0.6.0",
"http 1.4.0",
"http 1.4.1",
"js-sys",
"once_cell",
"pin-project-lite",
@ -3262,9 +3262,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
@ -3305,9 +3305,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
@ -3752,7 +3752,7 @@ dependencies = [
"axum-core",
"cookie",
"futures-util",
"http 1.4.0",
"http 1.4.1",
"parking_lot",
"pin-project-lite",
"tower-layer",
@ -3769,7 +3769,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
"http 1.4.0",
"http 1.4.1",
"http-body",
"http-body-util",
"http-range-header",
@ -3803,7 +3803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
dependencies = [
"async-trait",
"http 1.4.0",
"http 1.4.1",
"time",
"tokio",
"tower-cookies",
@ -3824,7 +3824,7 @@ dependencies = [
"axum-core",
"base64 0.22.1",
"futures",
"http 1.4.0",
"http 1.4.1",
"parking_lot",
"rand 0.8.6",
"serde",
@ -3921,7 +3921,7 @@ dependencies = [
[[package]]
name = "trictrac-store"
version = "0.2.15"
version = "0.2.16"
dependencies = [
"anyhow",
"base64 0.21.7",
@ -3934,7 +3934,7 @@ dependencies = [
[[package]]
name = "trictrac-web"
version = "0.2.15"
version = "0.2.16"
dependencies = [
"backbone-lib",
"futures",
@ -3967,7 +3967,7 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.4.0",
"http 1.4.1",
"httparse",
"log",
"rand 0.8.6",
@ -3984,7 +3984,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http 1.4.0",
"http 1.4.1",
"httparse",
"log",
"rand 0.9.4",
@ -4014,9 +4014,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "ucd-trie"
@ -4059,9 +4059,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
@ -4131,9 +4131,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@ -4643,9 +4643,9 @@ dependencies = [
[[package]]
name = "yoke"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive 0.8.2",
@ -4678,18 +4678,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
@ -4741,7 +4741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke 0.8.2",
"yoke 0.8.3",
"zerofrom",
]
@ -4762,7 +4762,7 @@ version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke 0.8.2",
"yoke 0.8.3",
"zerofrom",
"zerovec-derive 0.11.3",
]

View file

@ -1,5 +1,5 @@
[workspace.package]
version = "0.2.17"
version = "0.2.18"
[workspace]
resolver = "2"

View file

@ -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};

View file

@ -783,7 +783,8 @@ a:hover { text-decoration: underline; }
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
min-width: 4em;
text-align: left;
}
.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; }
@ -838,8 +839,8 @@ a:hover { text-decoration: underline; }
}
.peg-hole {
width: 10px;
height: 10px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 1.5px solid rgba(138,106,40,0.45);
background: rgba(0,0,0,0.06);
@ -847,12 +848,6 @@ a:hover { text-decoration: underline; }
transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s;
}
.peg-hole.filled {
background: var(--ui-gold);
border-color: var(--ui-gold-dark);
box-shadow: 0 0 4px rgba(200,164,72,0.6);
}
.bredouille-badge {
font-size: 0.62rem;
font-weight: 500;
@ -864,22 +859,10 @@ a:hover { text-decoration: underline; }
letter-spacing: 0.06em;
cursor: default;
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
margin: 0.4em;
}
/* ── Merged scoreboard (both players, above board) ──────────────────── */
.merged-score-panel {
background: var(--ui-parchment);
border-radius: 5px;
padding: 0.5rem 1.25rem 0.45rem;
font-size: 0.88rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
width: 100%;
border-top: 2px solid var(--ui-gold-dark);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
/* ── scoreboard (both players, above board) ──────────────────── */
.score-row {
display: flex;
align-items: center;
@ -940,6 +923,7 @@ a:hover { text-decoration: underline; }
display: flex;
align-items: baseline;
gap: 0.1rem;
direction: ltr;
}
.pts-counter {
@ -961,33 +945,6 @@ a:hover { text-decoration: underline; }
padding-bottom: 2px;
}
/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */
.merged-score-panel .peg-track {
gap: 4px;
}
.merged-score-panel .peg-hole {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1.5px solid rgba(138,106,40,0.3);
background: rgba(0,0,0,0.06);
flex-shrink: 0;
transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s;
}
.merged-score-panel .peg-hole.filled {
background: #5aab38;
border-color: #3a7828;
box-shadow: 0 0 5px rgba(90,171,56,0.55);
}
.merged-score-panel .peg-hole.peg-opp.filled {
background: #c05030;
border-color: #8a3018;
box-shadow: 0 0 5px rgba(192,80,48,0.55);
}
/* Peg pop-in animation when a new hole is scored */
@keyframes peg-pop {
0% { transform: scale(0.15); opacity: 0; }
@ -996,10 +953,6 @@ a:hover { text-decoration: underline; }
100% { transform: scale(1.0); opacity: 1; }
}
.merged-score-panel .peg-hole.peg-new {
animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
/* Thin separator between the two player rows */
.score-row-sep {
height: 1px;
@ -1007,31 +960,6 @@ a:hover { text-decoration: underline; }
margin: 0.05rem 0;
}
/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */
@keyframes hole-flash-in-out {
0% { opacity: 0; transform: translateY(-3px); }
14% { opacity: 1; transform: translateY(0); }
65% { opacity: 1; }
100% { opacity: 0; transform: translateY(2px); }
}
.hole-flash {
margin-left: auto;
flex-shrink: 0;
white-space: nowrap;
font-family: var(--font-display);
font-size: 0.88rem;
font-weight: 600;
color: var(--ui-green-accent);
letter-spacing: 0.05em;
animation: hole-flash-in-out 2.5s ease-out forwards;
pointer-events: none;
}
.hole-flash.hole-flash-bredouille {
color: var(--ui-gold-dark);
}
/* ── Game bottom strip — status, hints, buttons on cream ────────────── */
.game-bottom-strip {
background: var(--ui-parchment);
@ -2322,9 +2250,8 @@ a:hover { text-decoration: underline; }
gap: 0.5rem;
}
.strip-player { display: flex; align-items: center; flex: 1; min-width: 0; }
.strip-player-left { justify-content: flex-end; }
.strip-player-right { justify-content: flex-start; }
.strip-player { display: flex; justify-content: flex-end; align-items: center; flex: 1; min-width: 0; }
.strip-player-left { }
.strip-active-zone {
display: flex;
@ -2362,8 +2289,7 @@ a:hover { text-decoration: underline; }
}
/* Strip peg overrides */
.players-strip .peg-track { gap: 3px; }
.players-strip .peg-hole { width: 12px; height: 12px; }
.players-strip .peg-track { gap: 3px; direction: ltr; }
.players-strip .peg-hole.filled {
background: #5aab38; border-color: #3a7828;
box-shadow: 0 0 5px rgba(90,171,56,0.55);
@ -2404,16 +2330,23 @@ a:hover { text-decoration: underline; }
}
/* ── Controls column (sidebar on wide, row on narrow) ────────────────── */
.controls {
/* left-control displayed only on wide screen to center board */
.controls, .left-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: stretch;
}
@media (min-width: 1120px) {
.left-controls {
width: 200px;
}
}
@media (min-width: 920px) {
.controls {
width: 200px;
}
.strip-player-right { direction: rtl; }
}
.ctrl-dice {
@ -2460,7 +2393,6 @@ a:hover { text-decoration: underline; }
justify-content: space-around;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
}
.ctrl-status .game-status {
@ -2503,6 +2435,11 @@ a:hover { text-decoration: underline; }
margin: 0;
}
@media (max-width: 1119px) {
.left-controls {
width: 0;
}
}
/* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */
@media (max-width: 919px) {
.main-body {
@ -2514,6 +2451,7 @@ a:hover { text-decoration: underline; }
width: 100%;
}
.ctrl-status { flex: 1; }
/* Hide pegs on small screens to save space in the strip */
.players-strip .peg-track { display: none; }
.strip-center { display: none; }
/* move second player below first player */
.players-strip { flex-flow: column; }
}

View file

@ -150,7 +150,7 @@
"account_deleted": "Your account has been permanently deleted.",
"about": "About",
"legal": "Legal notices",
"free_mode_label": "Free play mode",
"free_mode_label": "Free move mode",
"free_mode_tooltip": "Select any checker and try to find a valid move yourself. If your move breaks a rule, you'll see an explanation.",
"reset_move": "Try again",
"err_invalid_move": "This move is not valid with the current dice",

View file

@ -148,7 +148,7 @@
"account_deleted": "Votre compte a été définitivement supprimé.",
"about": "À propos",
"legal": "Mentions légales",
"free_mode_label": "Mode jeu libre",
"free_mode_label": "Déplacement libre",
"free_mode_tooltip": "Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même. Si votre coup enfreint une règle, une explication s'affichera.",
"reset_move": "Réessayer",
"err_invalid_move": "Ce coup n'est pas valide avec les dés actuels",

View file

@ -696,9 +696,9 @@ fn SiteHamburger() -> impl IntoView {
</div>
<div>
<div class="site-nav-infolinks">
<a href="/page/about">{t!(i18n, about)}</a>
<a href="/page/about" on:click=move |_| { sidebar_open.set(false); } >{t!(i18n, about)}</a>
<span> - </span>
<a href="/page/legal">{t!(i18n, legal)}</a>
<a href="/page/legal" on:click=move |_| { sidebar_open.set(false); } >{t!(i18n, legal)}</a>
</div>
</div>
<div>

View file

@ -126,7 +126,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}
let dice = vs.dice;
let show_dice = dice != (0, 0);
// Hide dice during RollDice/RollWaiting: the stored dice values are stale from the
// previous turn and showing them would trigger the tumble animation incorrectly.
let show_dice = dice != (0, 0)
&& !matches!(
vs.turn_stage,
SerTurnStage::RollDice | SerTurnStage::RollWaiting
);
// ── Button senders ─────────────────────────────────────────────────────────
let cmd_tx_go = cmd_tx.clone();
@ -345,6 +351,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Board + controls (sidebar on wide, footer on narrow) ─────────
<div class="main-body">
<div class="left-controls"></div>
<Board
view_state=vs
player_id=player_id

View file

@ -169,34 +169,20 @@ pub fn MergedScorePanel(
<div class="strip-avatar strip-avatar-me"></div>
<div class="score-row-name">
<span class="player-name">{my_name}</span>
<span class="you-tag">{t!(i18n, you_suffix)}</span>
</div>
{my_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
<div class="peg-track">{my_pegs}</div>
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">{move || my_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
{my_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
</div>
</div>
{(my_holes_gained > 0).then(|| {
let label = if my_bredouille {
format!("Trou {} · ×2 bredouille", my_holes)
} else {
format!("Trou {}", my_holes)
};
view! {
<div class="hole-flash"
class:hole-flash-bredouille=my_bredouille>
{label}
</div>
}
})}
</div>
</div>
@ -208,23 +194,23 @@ pub fn MergedScorePanel(
// ── Opponent: right side, left-aligned from center ──────────────
<div class="strip-player strip-player-right">
<div class="strip-active-zone" class:active=opp_active>
<div class="strip-avatar strip-avatar-opp"></div>
<div class="score-row-name">
<span class="player-name">{opp_name}</span>
</div>
<div class="peg-track">{opp_pegs}</div>
<div class="pts-counter-wrap">
<div class="pts-counter-row">
<span class="pts-counter">{move || opp_displayed_pts.get()}</span>
<span class="pts-max">"/12"</span>
{opp_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
</div>
</div>
<div class="peg-track">{opp_pegs}</div>
{opp_can_bredouille.then(|| view! {
<span class="bredouille-badge"
title=move || t_string!(i18n, bredouille_title).to_owned()>
"B"
</span>
})}
<div class="score-row-name">
<span class="player-name">{opp_name}</span>
</div>
<div class="strip-avatar strip-avatar-opp"></div>
</div>
</div>

View file

@ -6,9 +6,8 @@ use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use crate::app::{GameUiState, NetCommand, PauseReason, Screen};
use crate::game::trictrac::backend::TrictracBackend;
use crate::game::trictrac::bot_local::bot_decide;
use crate::game::trictrac::types::{
JanEntry, ScoredEvent, SerStage, SerTurnStage, ViewState,
};
use crate::game::trictrac::types::{JanEntry, ScoredEvent, SerStage, SerTurnStage, ViewState};
use backbone_lib::platform::sleep_ms;
use trictrac_store::CheckerMove;
use std::collections::VecDeque;
@ -124,6 +123,7 @@ async fn run_local_bot_game_loop(
match bot_decide(backend.get_game(), pgr.as_ref()) {
None => break,
Some(action) => {
sleep_ms(500).await;
backend.inform_rpc(1, action);
for cmd in backend.drain_commands() {
if let BackendCommand::Delta(delta) = cmd {
@ -189,7 +189,11 @@ pub fn compute_last_moves(
}
/// Computes a scoring event for `player_id` by comparing the previous and next ViewState.
pub fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
pub fn compute_scored_event(
prev: &ViewState,
next: &ViewState,
player_id: u16,
) -> Option<ScoredEvent> {
let prev_score = &prev.scores[player_id as usize];
let next_score = &next.scores[player_id as usize];
@ -275,7 +279,11 @@ pub fn push_or_show(
/// Compares the previous and next ViewState to decide whether the transition
/// warrants a confirmation pause.
pub fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<PauseReason> {
pub fn infer_pause_reason(
prev: &ViewState,
next: &ViewState,
player_id: u16,
) -> Option<PauseReason> {
let opponent_id = 1 - player_id;
if next.stage == SerStage::PreGameRoll {
@ -297,7 +305,8 @@ pub fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) ->
if next.dice != prev.dice {
return Some(PauseReason::AfterOpponentRoll);
}
if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move {
if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move
{
return Some(PauseReason::AfterOpponentGo);
}
}

View file

@ -1,4 +1,4 @@
use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
use trictrac_store::{Board, CheckerMove, Color, Dice, GameState, MoveRules, Stage, TurnStage};
use super::types::{PlayerAction, PreGameRollState};
@ -45,13 +45,42 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<Pl
}
}
/// Score a candidate move sequence from the bot's (Black) perspective.
/// Score a candidate bot move sequence using depth-1 expectiminimax.
/// For each of the 21 possible opponent dice pairs, the opponent picks the move that
/// minimises the bot's score; we average those minima weighted by dice probability.
/// `m1` and `m2` are in mirrored (White) space, as returned by MoveRules for Color::Black.
fn score_seq(board: &Board, m1: &CheckerMove, m2: &CheckerMove) -> f32 {
let mut b = board.mirror();
let _ = b.move_checker(&Color::White, *m1);
let _ = b.move_checker(&Color::White, *m2);
evaluate(&b)
// Apply bot's moves on the mirrored board, then restore normal coordinates → B1.
let mut b_mirror = board.mirror();
let _ = b_mirror.move_checker(&Color::White, *m1);
let _ = b_mirror.move_checker(&Color::White, *m2);
let b1 = b_mirror.mirror();
// Expectiminimax: sum over all 21 distinct dice pairs, weighted by probability (out of 36).
// Non-doubles have probability 2/36 each; doubles 1/36 each.
let mut total = 0.0f32;
for d1 in 1u8..=6 {
for d2 in d1..=6 {
let weight = if d1 == d2 { 1.0f32 } else { 2.0f32 };
let opp_rules = MoveRules::new(&Color::White, &b1, Dice { values: (d1, d2) });
let opp_seqs = opp_rules.get_possible_moves_sequences(true, vec![]);
let min_score = if opp_seqs.is_empty() {
evaluate(&b1.mirror())
} else {
opp_seqs
.iter()
.map(|(om1, om2)| {
let mut b2 = b1.clone();
let _ = b2.move_checker(&Color::White, *om1);
let _ = b2.move_checker(&Color::White, *om2);
evaluate(&b2.mirror())
})
.fold(f32::INFINITY, f32::min)
};
total += weight * min_score;
}
}
total // proportional to expected score; dividing by 36 doesn't affect move ordering
}
/// Evaluate a board position from White's perspective (call after mirroring for Black).
@ -61,11 +90,19 @@ fn evaluate(board: &Board) -> f32 {
let white_fields = board.get_color_fields(Color::White);
let black_fields = board.get_color_fields(Color::Black);
// Bonus if rest corner filled (tuned: 6.0)
let corner_field = board.get_color_corner(&Color::White);
let (corner_count, _color) = board.get_field_checkers(corner_field).unwrap();
if corner_count > 0 {
score += 6.0;
}
// Quarter fill progress — quarters 1-6, 7-12, 19-24.
// Quarter 13-18 is skipped: field 13 is the opponent's rest corner so White can never fill it.
// quarter_filled tuned to 5.5 (was 8.0), quarter_progress kept at 0.3.
for &q in &[1usize, 7, 19] {
if board.is_quarter_filled(Color::White, q) {
score += 8.0;
score += 5.5;
} else {
let missing = board.get_quarter_filling_candidate(Color::White);
score += (6 - missing.len().min(6)) as f32 * 0.3;
@ -81,12 +118,8 @@ fn evaluate(board: &Board) -> f32 {
}
}
// Exit zone progress: reward checkers already in fields 19-24.
for (field, count) in &white_fields {
if *field >= 19 {
score += count.abs() as f32 * 0.3;
}
}
// Exit zone progress: tuned to 0.0 — mid-game jan-filling dominates.
// (term kept here as a reminder; re-enable when bearing-off phase is reached)
score
}

View file

@ -17,62 +17,6 @@
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1762808025,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779102034,
@ -108,15 +52,11 @@
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"nixpkgs-cmake3": "nixpkgs-cmake3",
"pre-commit-hooks": [
"git-hooks"
]
"nixpkgs-cmake3": "nixpkgs-cmake3"
}
}
},
"root": "root",
"version": 7
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,7 @@
frontendCargoDeps = rustPlatform.fetchCargoVendor {
src = ./.;
name = "trictrac-frontend-vendor";
hash = "sha256-XBxdRT/f69GDfVc18/DnnAiY1vjMGMWfcYot0K0jevg=";
hash = "sha256-HJ9iUzsaMKapKb9DH0aoX7keuShq5fnHk1TSvNlw2CQ=";
};
# Must match the wasm-bindgen version in Cargo.lock
wasm-bindgen-version = "0.2.118";
@ -102,7 +102,7 @@
trictrac = with final; rustPlatform.buildRustPackage {
pname = "trictrac";
version = "0.2.17"; # trictrac-version
version = "0.2.16"; # trictrac-version
src = ./.;
nativeBuildInputs = [ pkg-config ];

View file

@ -22,3 +22,7 @@ transpose = "0.2.2"
[[bin]]
name = "random_game"
path = "src/bin/random_game.rs"
[[bin]]
name = "weight_tuner"
path = "src/bin/weight_tuner.rs"

View file

@ -0,0 +1,456 @@
//! Weight tuner for the trictrac heuristic bot.
//!
//! Uses self-play (greedy heuristic with candidate weights vs current champion weights)
//! to measure win-rate signal. Since both bots are similarly capable, small weight
//! differences produce a gradient near 50%, unlike vs-random where the heuristic wins
//! ~100% regardless of weights.
//!
//! Algorithm: coordinate-descent hill-climbing. For each weight, probe +step and -step;
//! accept the change that pushes the challenger win-rate above 50%. Halve step when no
//! weight in the current pass improved. Stop when step < min_step.
//!
//! Each win-rate estimate runs `n_games` games with the challenger as White AND as Black
//! (total 2×n_games), eliminating first-move bias.
//!
//! Usage:
//! cargo run --release --bin weight_tuner -- [--games <N>] [--seed <u64>] [--step <f32>] [--min-step <f32>]
//!
//! 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<GameState> = 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<u64> {
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<u64>,
initial_step: f32,
min_step: f32,
margin: f32,
vs_random: bool,
}
fn parse_args() -> Args {
let args: Vec<String> = std::env::args().collect();
let mut n_games = 200usize;
let mut seed: Option<u64> = 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());
}