Compare commits
6 commits
ba9c9f6a7a
...
84e0d127c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e0d127c0 | |||
| 1cdb1380b4 | |||
| 813cc3448a | |||
| 7a760980ba | |||
| cf289fa779 | |||
| 33d2163bab |
18 changed files with 3174 additions and 411 deletions
118
Cargo.lock
generated
118
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace.package]
|
||||
version = "0.2.17"
|
||||
version = "0.2.18"
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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 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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
62
devenv.lock
62
devenv.lock
|
|
@ -17,62 +17,6 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778507602,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779102034,
|
||||
|
|
@ -108,12 +52,8 @@
|
|||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-cmake3": "nixpkgs-cmake3",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
"nixpkgs-cmake3": "nixpkgs-cmake3"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
13
doc/design/snapshots/2026-06-20.html
Normal file
13
doc/design/snapshots/2026-06-20.html
Normal file
File diff suppressed because one or more lines are too long
2518
doc/design/snapshots/2026-06-20_files/style-5f3b2c8fd952901a.css
Normal file
2518
doc/design/snapshots/2026-06-20_files/style-5f3b2c8fd952901a.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 ];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
456
store/src/bin/weight_tuner.rs
Normal file
456
store/src/bin/weight_tuner.rs
Normal 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());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue