diff --git a/.gitignore b/.gitignore index fa83e0e..3736e04 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ profile.json bot/models client_web/dist var + +deploy +clients/**/dist diff --git a/Cargo.lock b/Cargo.lock index 94e366c..72f1a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,54 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -59,95 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "any_spawner" version = "0.2.0" @@ -161,88 +24,20 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "approx" -version = "0.5.1" +name = "argon2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ - "num-traits", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "arimaa_engine_step" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c6726d7896a539a62e157b05fa4b7308ffb7872f2b4a2a592d5adb19837861" -dependencies = [ - "anyhow", - "itertools 0.10.5", - "log", - "regex", -] - -[[package]] -name = "arrayvec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "ash" -version = "0.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", ] [[package]] @@ -264,7 +59,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -282,12 +77,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atomic_float" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" - [[package]] name = "attribute-derive" version = "0.10.5" @@ -299,7 +88,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -315,70 +104,27 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec 0.7.6", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec 0.7.6", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" -dependencies = [ - "arrayvec 0.7.6", -] +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -411,7 +157,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -422,9 +168,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-login" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7" +dependencies = [ + "axum", + "form_urlencoded", + "serde", + "subtle", + "thiserror 2.0.18", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions", + "tracing", + "urlencoding", +] + [[package]] name = "backbone-lib" -version = "0.1.0" +version = "0.2.15" dependencies = [ "bytes", "ewebsock", @@ -437,21 +202,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] - [[package]] name = "base64" version = "0.21.7" @@ -470,96 +220,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "serde", - "unty", -] - -[[package]] -name = "bindgen" -version = "0.71.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.10.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.114", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" -version = "1.3.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] -name = "bitflags" -version = "2.10.0" +name = "blake2" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "serde_core", + "digest 0.10.7", ] -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -570,553 +245,19 @@ dependencies = [ ] [[package]] -name = "board-game" -version = "0.8.2" +name = "block-buffer" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "647fc8459363368aae04df3d21da37094430c57dd993d09be2792133d5365e3e" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ - "arimaa_engine_step", - "cast_trait", - "chess", - "decorum", - "internal-iterator", - "itertools 0.10.5", - "lazy_static", - "nohash-hasher", - "nom 7.1.3", - "num-traits", - "once_cell", - "rand 0.8.5", - "rand_xoshiro", - "rayon", - "static_assertions", + "hybrid-array", ] -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata 0.4.14", - "serde", -] - -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "burn" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b78ff10ed98b73e1d477ea6e6e1ec1b9cf9f71a17afc3fea9f4dca482d43dcd4" -dependencies = [ - "burn-autodiff", - "burn-candle", - "burn-collective", - "burn-core", - "burn-cpu", - "burn-cuda", - "burn-ndarray", - "burn-nn", - "burn-optim", - "burn-remote", - "burn-rocm", - "burn-router", - "burn-store", - "burn-tch", - "burn-train", - "burn-wgpu", -] - -[[package]] -name = "burn-autodiff" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f04955e9b4acd5e6a6229f80217dae79742975a97dc2253003f226333ad307" -dependencies = [ - "burn-backend", - "burn-std", - "derive-new", - "hashbrown 0.16.1", - "log", - "num-traits", - "parking_lot", - "portable-atomic", - "spin 0.10.0", - "tracing", -] - -[[package]] -name = "burn-backend" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a724a5d8d5865a1f6b304f629eb19f51489760689501c583b3e1f4209f067357" -dependencies = [ - "burn-std", - "bytemuck", - "cubecl", - "derive-new", - "hashbrown 0.16.1", - "num-traits", - "rand 0.9.2", - "rand_distr", - "serde", - "thiserror 2.0.18", -] - -[[package]] -name = "burn-candle" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21c752d5008923eb9299783da5edae3242b94afdb956e88d2b37b025244b5071" -dependencies = [ - "burn-backend", - "burn-std", - "candle-core", - "derive-new", -] - -[[package]] -name = "burn-collective" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b2199984291a0f3828d5ac04039fb864c56c3bdd9ee4172a63e457b668e4c" -dependencies = [ - "burn-communication", - "burn-std", - "burn-tensor", - "bytes", - "futures", - "log", - "rmp-serde", - "serde", - "tokio", - "tokio-util", -] - -[[package]] -name = "burn-communication" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585526cd672adc35918d8aea9bcb50669067904f36090caaffa9573d22fc7ade" -dependencies = [ - "axum", - "burn-std", - "burn-tensor", - "bytes", - "derive-new", - "futures", - "futures-util", - "log", - "rmp-serde", - "serde", - "serde_bytes", - "tokio", - "tokio-tungstenite", - "tokio-util", - "tracing", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "burn-core" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3634c3ba84397bcf2977ce746954d7e0a40e2d862e92362dd694c29e18df62" -dependencies = [ - "ahash", - "bincode 2.0.1", - "burn-dataset", - "burn-derive", - "burn-std", - "burn-tensor", - "data-encoding", - "derive-new", - "flate2", - "half", - "hashbrown 0.16.1", - "log", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rand 0.9.2", - "regex", - "rmp-serde", - "serde", - "serde_json", - "spin 0.10.0", - "uuid", -] - -[[package]] -name = "burn-cpu" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60aa53c4536719f1c91c250d4b4348daca473c44cf0c45b81096785f5510c192" -dependencies = [ - "burn-backend", - "burn-cubecl", - "burn-fusion", - "cubecl", -] - -[[package]] -name = "burn-cubecl" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6d13aff03fec966da4300459688883f8a1d741dddbf19d1bfc2562656a9a9b" -dependencies = [ - "burn-backend", - "burn-cubecl-fusion", - "burn-fusion", - "burn-ir", - "burn-std", - "cubecl", - "cubek", - "derive-new", - "futures-lite", - "log", - "serde", - "text_placeholder", -] - -[[package]] -name = "burn-cubecl-fusion" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d25b2e9fb805931401f79782aabd92462d65e60bc207035a3e554de8d7cd9f" -dependencies = [ - "burn-backend", - "burn-fusion", - "burn-ir", - "burn-std", - "cubecl", - "cubek", - "derive-new", - "serde", -] - -[[package]] -name = "burn-cuda" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d0c68cf653eb9c27dcbe046bb7b04cc18c6b33afda4c09317c102e6f4ae7cb6" -dependencies = [ - "burn-backend", - "burn-cubecl", - "burn-fusion", - "cubecl", -] - -[[package]] -name = "burn-dataset" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e87741e2ff9015845ed2b41b47f9e82795cf274bf2328a29619a2e6f662495c" -dependencies = [ - "csv", - "derive-new", - "dirs", - "gix-tempfile", - "image", - "r2d2", - "r2d2_sqlite", - "rand 0.9.2", - "rmp-serde", - "rusqlite", - "sanitize-filename", - "serde", - "serde_json", - "serde_rusqlite", - "strum 0.27.2", - "tempfile", - "thiserror 2.0.18", -] - -[[package]] -name = "burn-derive" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "102d7e2f705b0cda2f89dd0e55e9bbfc6184029929d53487beb606c3303b29a5" -dependencies = [ - "derive-new", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "burn-fusion" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea83d7f8574bcc07967291c5bb679ddc0a655c8db0642eca62755e2fffc8047" -dependencies = [ - "burn-backend", - "burn-ir", - "derive-new", - "hashbrown 0.16.1", - "log", - "serde", - "spin 0.10.0", - "tracing", -] - -[[package]] -name = "burn-ir" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd2b1b37a7289bd85438800deaaebde50507336429b80f96a71730794db5bc31" -dependencies = [ - "burn-backend", - "hashbrown 0.16.1", - "serde", -] - -[[package]] -name = "burn-ndarray" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96be578991cecef163e41a73bf985d8d7eb7fb8ef7bececf8d48523c481ecddf" -dependencies = [ - "atomic_float", - "burn-autodiff", - "burn-backend", - "burn-ir", - "burn-std", - "bytemuck", - "const-random", - "itertools 0.14.0", - "libm", - "macerator", - "matrixmultiply", - "ndarray 0.17.2", - "num-traits", - "paste", - "portable-atomic", - "portable-atomic-util", - "rand 0.9.2", - "rayon", - "seq-macro", -] - -[[package]] -name = "burn-nn" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b8c6c14b94e5b1dddd68f8e6d669f20bac8f99fcb2e4f1a480212d1b598133" -dependencies = [ - "burn-core", - "num-traits", -] - -[[package]] -name = "burn-optim" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a8c376d835d92ea363c05c6f48ac19bb687b683c7958c310a716ef8d5d77ba3" -dependencies = [ - "burn-core", - "derive-new", - "hashbrown 0.16.1", - "log", - "num-traits", - "serde", -] - -[[package]] -name = "burn-remote" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7238df1d59dcbb6880fe92ca0aa7b02b64d02394815e618da3d7933950bdaf39" -dependencies = [ - "async-channel", - "axum", - "burn-communication", - "burn-ir", - "burn-router", - "burn-std", - "burn-tensor", - "bytes", - "derive-new", - "futures-util", - "log", - "rmp-serde", - "serde", - "serde_bytes", - "tokio", - "tokio-tungstenite", - "tokio-util", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "burn-rl" -version = "0.1.0" -source = "git+https://github.com/yunjhongwu/burn-rl-examples.git#bf76ba61fdc89837cbf259a6f554a1fcec238690" -dependencies = [ - "burn", - "gym-rs", - "rand 0.8.5", - "ringbuffer", - "serde", -] - -[[package]] -name = "burn-rocm" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2abda6ee63bdcb730f1a335349a9ff83f03048130d405b6ecdccd2df3ff23" -dependencies = [ - "burn-backend", - "burn-cubecl", - "burn-fusion", - "cubecl", -] - -[[package]] -name = "burn-router" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "823ccb88484736a2861d53dc7f67db375ef050b0446bb02dd7cb8783ac6b69a2" -dependencies = [ - "burn-backend", - "burn-ir", - "burn-std", - "hashbrown 0.16.1", - "log", - "spin 0.10.0", -] - -[[package]] -name = "burn-std" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a9ed8e34a4a49d3754586f306075d6b55a5e08343ac75c06f47e7d9f825271" -dependencies = [ - "bytemuck", - "bytes", - "cubecl", - "cubecl-common", - "half", - "num-traits", - "serde", -] - -[[package]] -name = "burn-store" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be80a7b084a19901dc1d0a2e9b77e226c5c575879fe66de891c67062db41a6d" -dependencies = [ - "burn-core", - "burn-nn", - "burn-tensor", - "byteorder", - "bytes", - "half", - "hashbrown 0.16.1", - "memmap2", - "regex", - "safetensors 0.7.0", - "textdistance", -] - -[[package]] -name = "burn-tch" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061cd3df7b4126949561185454b9b67623f6885657361cad2a07a2340b3b3108" -dependencies = [ - "burn-backend", - "cc", - "libc", - "log", - "tch", - "torch-sys", -] - -[[package]] -name = "burn-tensor" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3720e52e00ed0155ced4f8681d0e8a362e699cee36494ec5b97ad44fcc5194c0" -dependencies = [ - "burn-backend", - "burn-std", - "colored", - "derive-new", - "num-traits", - "serde", -] - -[[package]] -name = "burn-train" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3128c7571992c382a5ad057c72654c1048ea4dcf138d1f394313047e69803a" -dependencies = [ - "async-channel", - "burn-core", - "burn-ndarray", - "burn-optim", - "derive-new", - "log", - "nvml-wrapper", - "ratatui", - "rstest", - "serde", - "sysinfo 0.37.2", - "systemstat", - "thiserror 2.0.18", - "tracing-appender", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "burn-wgpu" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df78d62afc9b9fbb8ee4e49b72006485bb64f778a790e185a2d919479bcfc008" -dependencies = [ - "burn-backend", - "burn-cubecl", - "burn-fusion", - "cubecl", -] - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -1124,52 +265,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "bytesize" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" - -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "c_vec" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd7a427adc0135366d99db65b36dae9237130997e560ed61118041fb72be6e8" [[package]] name = "calendrical_calculations" @@ -1187,234 +287,44 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -[[package]] -name = "candle-core" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091" -dependencies = [ - "byteorder", - "float8 0.6.1", - "gemm", - "half", - "libm", - "memmap2", - "num-traits", - "num_cpus", - "rand 0.9.2", - "rand_distr", - "rayon", - "safetensors 0.7.0", - "thiserror 2.0.18", - "yoke 0.8.1", - "zip 7.4.0", -] - -[[package]] -name = "caseless" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" -dependencies = [ - "unicode-normalization", -] - -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" -[[package]] -name = "cast_trait" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4f8d981c476baadf74cd52897866a1d279d3e14e2d5e2d9af045210e0ae6128" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cc" -version = "1.2.55" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chacha20" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "chacha20poly1305" -version = "0.10.1" +name = "cmov" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "chess" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ed299b171ec34f372945ad6726f7bc1d2afd5f59fb8380f64f48e2bab2f0ec8" -dependencies = [ - "arrayvec 0.5.2", - "failure", - "nodrop", - "rand 0.7.3", -] - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] - -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "clap" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.5.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" -dependencies = [ - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_lex" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" - -[[package]] -name = "client_web" -version = "0.1.0" -dependencies = [ - "backbone-lib", - "futures", - "getrandom 0.3.4", - "gloo-storage", - "leptos", - "leptos_i18n", - "rand 0.9.2", - "serde", - "serde_json", - "trictrac-store", - "wasm-bindgen-futures", -] - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "cobs" @@ -1436,83 +346,12 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "codespan-reporting" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" -dependencies = [ - "serde", - "termcolor", - "unicode-width 0.2.0", -] - -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "serde", - "termcolor", - "unicode-width 0.2.0", -] - [[package]] name = "collection_literals" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "comrak" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5" -dependencies = [ - "caseless", - "entities", - "memchr", - "slug", - "typed-arena", - "unicode_categories", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1524,56 +363,31 @@ dependencies = [ [[package]] name = "config" -version = "0.15.19" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "convert_case 0.6.0", "pathdiff", "serde_core", - "toml 0.9.11+spec-1.1.0", - "winnow", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.3", ] [[package]] -name = "confy" -version = "1.0.0" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29222b549d4e3ded127989d523da9e928918d0d0d7f7c1690b439d0d538bae9" -dependencies = [ - "directories", - "serde", - "thiserror 2.0.18", - "toml 0.8.23", -] - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "const_format" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] @@ -1593,18 +407,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - -[[package]] -name = "constcat" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" - [[package]] name = "convert_case" version = "0.6.0" @@ -1623,24 +425,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "convert_case" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cookie" version = "0.18.1" @@ -1652,42 +436,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.10.0", - "core-foundation", - "libc", -] - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "core_maths" version = "0.1.1" @@ -1707,48 +455,12 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "cfg-if", -] - -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", + "libc", ] [[package]] @@ -1757,71 +469,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.10.0", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook 0.3.18", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -1829,515 +482,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] [[package]] -name = "csv" -version = "1.4.0" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", + "hybrid-array", ] [[package]] -name = "csv-core" -version = "0.1.13" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "memchr", -] - -[[package]] -name = "cubecl" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053856efd5436224775b9423d43d86f53d5b1d3af9a6b9983d9a313a0922638f" -dependencies = [ - "cubecl-core", - "cubecl-cpu", - "cubecl-cuda", - "cubecl-hip", - "cubecl-ir", - "cubecl-runtime", - "cubecl-std", - "cubecl-wgpu", - "half", -] - -[[package]] -name = "cubecl-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60bf8aaeb572c8cf2f2ffd07fa9bb1a2cf9336d1aa11ecd4d9a2f2e30c4be706" -dependencies = [ - "backtrace", - "bytemuck", - "bytes", - "cfg-if", - "cfg_aliases", - "derive-new", - "derive_more", - "dirs", - "embassy-futures", - "embassy-time", - "float4", - "float8 0.4.2", - "futures-lite", - "half", - "hashbrown 0.15.5", - "log", - "num-traits", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "rand 0.9.2", - "sanitize-filename", - "serde", - "serde_bytes", - "serde_json", - "spin 0.10.0", - "tracing", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "cubecl-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98374a31d2b68b55709891169832ccf205408c201c5e023964482441f213d0b9" -dependencies = [ - "bitflags 2.10.0", - "bytemuck", - "cubecl-common", - "cubecl-ir", - "cubecl-macros", - "cubecl-runtime", - "derive-new", - "derive_more", - "enumset", - "float-ord", - "half", - "hashbrown 0.15.5", - "log", - "num-traits", - "paste", - "serde", - "serde_json", - "tracing", - "variadics_please", -] - -[[package]] -name = "cubecl-cpp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb24d96c1ff84ab4def0a529e384311a15cb771310aaf2b640c312384c3bca23" -dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-opt", - "cubecl-runtime", - "derive-new", - "half", - "itertools 0.14.0", - "log", -] - -[[package]] -name = "cubecl-cpu" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "152588a6e16b6bda5e8216af7a6fad3d7de4697294b6ce0f6acbe3a9029ff674" -dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-opt", - "cubecl-runtime", - "cubecl-std", - "derive-new", - "half", - "log", - "paste", - "serde", - "sysinfo 0.36.1", - "tracel-llvm", - "tracel-llvm-bundler", -] - -[[package]] -name = "cubecl-cuda" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f74a5750c45090d1fc5ddf6a19fea9a099aa1f6800b78f1167a2d60182d1d96" -dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-cpp", - "cubecl-runtime", - "cubecl-zspace", - "cudarc", - "derive-new", - "half", - "log", - "serde", - "tracing", -] - -[[package]] -name = "cubecl-hip" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbae9bc7ee6093d0d7a549c05873dff3478f9087b59eb09b223a97d642c849aa" -dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-cpp", - "cubecl-hip-sys", - "cubecl-runtime", - "cubecl-zspace", - "derive-new", - "half", - "log", - "paste", - "serde", - "tracing", -] - -[[package]] -name = "cubecl-hip-sys" -version = "7.0.5183101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ef087b59445fda47d2177370886351eb923ad1a541086d4919268574cd9558" -dependencies = [ - "libc", - "regex", -] - -[[package]] -name = "cubecl-ir" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361b608ff9f05024c7a7e381852689acd95b6af5af956d68734692b27d5f75ef" -dependencies = [ - "cubecl-common", - "cubecl-macros-internal", - "derive-new", - "derive_more", - "enumset", - "float-ord", - "fnv", - "half", - "hashbrown 0.15.5", - "num-traits", - "portable-atomic", - "serde", - "variadics_please", -] - -[[package]] -name = "cubecl-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9a872d16207c6a27ed45942fd311a281394dd384b14a21f72131db1556a977" -dependencies = [ - "cubecl-common", - "darling 0.21.3", - "derive-new", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "cubecl-macros-internal" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa3fa0626cdf28b9c49084c2bb51493bfde44378e22d90624aacaafb81da3588" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "cubecl-opt" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdcff25fdcbd82ea4277c30a81e162722859f57c6ae105c0a3c53f8bb91154f6" -dependencies = [ - "cubecl-common", - "cubecl-core", - "cubecl-ir", - "float-ord", - "log", - "num", - "petgraph", - "smallvec", - "stable-vec", - "type-map", -] - -[[package]] -name = "cubecl-runtime" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b02e28997a8d75311afae4d2cea7b593eb125312f845874118a59d78c7a6b34c" -dependencies = [ - "async-channel", - "bytemuck", - "cfg-if", - "cfg_aliases", - "cubecl-common", - "cubecl-ir", - "derive-new", - "derive_more", - "dirs", - "enumset", - "foldhash 0.1.5", - "hashbrown 0.15.5", - "log", - "md5", - "serde", - "serde_json", - "spin 0.10.0", - "thiserror 2.0.18", - "toml 0.9.11+spec-1.1.0", - "tracing", - "variadics_please", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "cubecl-std" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8ff5741c98b7a7a5944b4afb0b67dd7f5e0be41ce7f303b587f8b0d6430b29b" -dependencies = [ - "cubecl-common", - "cubecl-core", - "cubecl-runtime", - "foldhash 0.1.5", - "half", - "num-traits", - "paste", - "serde", - "spin 0.10.0", - "variadics_please", -] - -[[package]] -name = "cubecl-wgpu" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29787364632fc7ec6a11cf3d95187f82f6fcce17d6bb4f0fb0dde580b837631d" -dependencies = [ - "async-channel", - "bytemuck", - "cfg-if", - "cfg_aliases", - "cubecl-common", - "cubecl-core", - "cubecl-ir", - "cubecl-runtime", - "derive-new", - "derive_more", - "half", - "hashbrown 0.15.5", - "log", - "sanitize-filename", - "tracing", - "wgpu", -] - -[[package]] -name = "cubecl-zspace" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0f819071413b19a00b7105497e0f6d2cf3e7e9d65cbb8d4ecf1ddb29c61dc2" - -[[package]] -name = "cubek" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb1cce47db02017925301bedec92ae84628493df3f9761ea7ac42a60c6146f8" -dependencies = [ - "cubecl", - "cubek-attention", - "cubek-convolution", - "cubek-matmul", - "cubek-quant", - "cubek-random", - "cubek-reduce", -] - -[[package]] -name = "cubek-attention" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7278bd122b2428af479f9af05285160613733c33c93b63ab3c6d25cd0460c18b" -dependencies = [ - "bytemuck", - "cubecl", - "cubecl-common", - "cubek-matmul", - "cubek-random", - "half", - "serde", -] - -[[package]] -name = "cubek-convolution" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18eb04bca4ae104d62a56def04b04f3d079c42fe49aac62202c96876f90fa28b" -dependencies = [ - "bytemuck", - "cubecl", - "cubecl-common", - "cubek-matmul", - "derive-new", - "enumset", - "half", - "serde", -] - -[[package]] -name = "cubek-matmul" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28f3b04b113760e97c65a8a4dca9afc220744031eeecd5ad6cd0e3be91ba3a9" -dependencies = [ - "bytemuck", - "cubecl", - "cubecl-common", - "half", - "serde", -] - -[[package]] -name = "cubek-quant" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96ec3ae04af324df2d615c2b394e270d58d6f08cb833d67633e2ba794de75916" -dependencies = [ - "cubecl", - "cubecl-common", - "half", - "serde", -] - -[[package]] -name = "cubek-random" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65a34844d8b7f739185c1d24896137dcb73f458830444103b45f678585ad983e" -dependencies = [ - "cubecl", - "cubecl-common", - "half", - "num-traits", - "rand 0.9.2", - "serde", -] - -[[package]] -name = "cubek-reduce" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42397d9ed85bb3084dfb56ed26de75690b5b07caf42a32f4006b57eb23d5b6d6" -dependencies = [ - "cubecl", - "half", - "num-traits", - "serde", - "thiserror 2.0.18", -] - -[[package]] -name = "cudarc" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa12038120eb13347a6ae2ffab1d34efe78150125108627fd85044dd4d6ff1e" -dependencies = [ - "libloading", -] - -[[package]] -name = "cxx" -version = "1.0.194" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e" -dependencies = [ - "cc", - "cxx-build", - "cxxbridge-cmd", - "cxxbridge-flags", - "cxxbridge-macro", - "foldhash 0.2.0", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.194" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" -dependencies = [ - "cc", - "codespan-reporting 0.13.1", - "indexmap", - "proc-macro2", - "quote", - "scratch", - "syn 2.0.114", -] - -[[package]] -name = "cxxbridge-cmd" -version = "1.0.194" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" -dependencies = [ - "clap", - "codespan-reporting 0.13.1", - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.194" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.194" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" -dependencies = [ - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.114", + "cmov", ] [[package]] @@ -2346,28 +509,8 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] @@ -2381,34 +524,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.114", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2417,38 +533,16 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.11", + "darling_core", "quote", - "syn 2.0.114", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -2460,17 +554,43 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "decorum" -version = "0.3.1" +name = "deadpool" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "num-traits", + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", ] [[package]] @@ -2479,41 +599,20 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", -] - -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "derive-new" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "serde_core", ] [[package]] @@ -2524,83 +623,30 @@ checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.114", - "unicode-xid", -] - -[[package]] -name = "deunicode" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] [[package]] -name = "directories" -version = "6.0.0" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2611,7 +657,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2635,93 +681,37 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "dyn-stack" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" -dependencies = [ - "bytemuck", - "dyn-stack-macros", -] - -[[package]] -name = "dyn-stack-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" - [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "either_of" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f7f86eef3a7e4b9c2107583dbbbe3d9535c4b800796faf1774b82ba22033da" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" dependencies = [ "paste", "pin-project-lite", ] [[package]] -name = "embassy-futures" -version = "0.1.2" +name = "email-encoding" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" - -[[package]] -name = "embassy-time" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f820157f198ada183ad62e0a66f554c610cdcd1a9f27d4b316358103ced7a1f8" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "cfg-if", - "critical-section", - "document-features", - "embassy-time-driver", - "embedded-hal 0.2.7", - "embedded-hal 1.0.0", - "embedded-hal-async", - "futures-util", + "base64 0.22.1", + "memchr", ] [[package]] -name = "embassy-time-driver" -version = "0.2.1" +name = "email_address" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a244c7dc22c8d0289379c8d8830cae06bb93d8f990194d0de5efb3b5ae7ba6" -dependencies = [ - "document-features", -] - -[[package]] -name = "embedded-hal" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" -dependencies = [ - "nb 0.1.3", - "void", -] - -[[package]] -name = "embedded-hal" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" - -[[package]] -name = "embedded-hal-async" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" -dependencies = [ - "embedded-hal 1.0.0", -] +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" [[package]] name = "embedded-io" @@ -2735,102 +725,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" -[[package]] -name = "entities" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" - -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "enumset" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" -dependencies = [ - "enumset_derive", - "serde", -] - -[[package]] -name = "enumset_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" -dependencies = [ - "humantime", - "is-terminal", - "log", - "regex", - "termcolor", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -2883,100 +777,17 @@ dependencies = [ "web-sys", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "failure" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" -dependencies = [ - "backtrace", - "failure_derive", -] - -[[package]] -name = "failure_derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "synstructure 0.12.6", -] - [[package]] name = "fallible-iterator" -version = "0.3.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -2996,55 +807,6 @@ dependencies = [ "writeable 0.5.5", ] -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "float-ord" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" - -[[package]] -name = "float4" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5939bac0ef2ad7c83a53e4fb889c1d81f007b07061d648cd271071984d86f257" - -[[package]] -name = "float8" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4203231de188ebbdfb85c11f3c20ca2b063945710de04e7b59268731e728b462" -dependencies = [ - "half", -] - -[[package]] -name = "float8" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" -dependencies = [ - "half", - "num-traits", - "rand 0.9.2", - "rand_distr", -] - [[package]] name = "fnv" version = "1.0.7" @@ -3057,39 +819,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3101,9 +830,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -3116,9 +845,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -3126,75 +855,55 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", "futures-util", - "num_cpus", ] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -3204,129 +913,9 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] -[[package]] -name = "gemm" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" -dependencies = [ - "dyn-stack", - "gemm-c32", - "gemm-c64", - "gemm-common", - "gemm-f16", - "gemm-f32", - "gemm-f64", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", -] - -[[package]] -name = "gemm-c32" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" -dependencies = [ - "dyn-stack", - "gemm-common", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", -] - -[[package]] -name = "gemm-c64" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" -dependencies = [ - "dyn-stack", - "gemm-common", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", -] - -[[package]] -name = "gemm-common" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" -dependencies = [ - "bytemuck", - "dyn-stack", - "half", - "libm", - "num-complex", - "num-traits", - "once_cell", - "paste", - "pulp", - "raw-cpuid", - "rayon", - "seq-macro", - "sysctl", -] - -[[package]] -name = "gemm-f16" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" -dependencies = [ - "dyn-stack", - "gemm-common", - "gemm-f32", - "half", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "rayon", - "seq-macro", -] - -[[package]] -name = "gemm-f32" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" -dependencies = [ - "dyn-stack", - "gemm-common", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", -] - -[[package]] -name = "gemm-f64" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" -dependencies = [ - "dyn-stack", - "gemm-common", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -3337,6 +926,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -3346,7 +944,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -3359,122 +957,46 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] -name = "gif" -version = "0.14.1" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "gix-features" -version = "0.45.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56aad357ae016449434705033df644ac6253dfcf1281aad3af3af9e907560d1" -dependencies = [ - "gix-trace", - "gix-utils", + "cfg-if", "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", ] [[package]] -name = "gix-fs" -version = "0.18.2" +name = "gloo-net" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "785b9c499e46bc78d7b81c148c21b3fca18655379ee729a856ed19ce50d359ec" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" dependencies = [ - "bstr", - "fastrand", - "gix-features", - "gix-path", - "gix-utils", - "thiserror 2.0.18", + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] -[[package]] -name = "gix-path" -version = "0.10.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cb06c3e4f8eed6e24fd915fa93145e28a511f4ea0e768bae16673e05ed3f366" -dependencies = [ - "bstr", - "gix-trace", - "gix-validate", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-tempfile" -version = "20.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad89218e74850f42d364ed3877c7291f0474c8533502df91bb877ecc5cb0dd40" -dependencies = [ - "dashmap", - "gix-fs", - "libc", - "parking_lot", - "signal-hook 0.4.3", - "signal-hook-registry", - "tempfile", -] - -[[package]] -name = "gix-trace" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e42a4c2583357721ba2d887916e78df504980f22f1182df06997ce197b89504" - -[[package]] -name = "gix-utils" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5" -dependencies = [ - "fastrand", - "unicode-normalization", -] - -[[package]] -name = "gix-validate" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1e63a5b516e970a594f870ed4571a8fdcb8a344e7bd407a20db8bd61dbfde4" -dependencies = [ - "bstr", - "thiserror 2.0.18", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "gloo-net" version = "0.6.0" @@ -3485,7 +1007,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -3536,136 +1058,12 @@ dependencies = [ "web-sys", ] -[[package]] -name = "glow" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.10.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "gpu-allocator" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" -dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "windows 0.58.0", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.10.0", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "guardian" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" -[[package]] -name = "gym-rs" -version = "0.3.1" -source = "git+https://github.com/MathisWellmann/gym-rs.git#5283afaa86a3a7c45c46c882cfad459f02539b62" -dependencies = [ - "derivative", - "derive-new", - "log", - "nalgebra", - "num-traits", - "ordered-float 5.1.0", - "rand 0.8.5", - "rand_pcg 0.3.1", - "sdl2", - "serde", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "bytemuck", - "cfg-if", - "crunchy", - "num-traits", - "rand 0.9.2", - "rand_distr", - "serde", - "zerocopy", -] - [[package]] name = "hash32" version = "0.2.1" @@ -3675,15 +1073,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -3696,33 +1085,14 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", - "serde", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", - "serde", - "serde_core", -] - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heapless" @@ -3734,7 +1104,7 @@ dependencies = [ "hash32", "rustc_version", "serde", - "spin 0.9.8", + "spin", "stable_deref_trait", ] @@ -3751,18 +1121,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "hexf-parse" -version = "0.2.1" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] [[package]] -name = "hmac" -version = "0.12.1" +name = "hostname" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ - "digest", + "cfg-if", + "libc", + "windows-link", ] [[package]] @@ -3774,6 +1149,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -3791,7 +1177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3802,11 +1188,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3820,10 +1212,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "humantime" -version = "2.3.0" +name = "hybrid-array" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] [[package]] name = "hydration_context" @@ -3841,42 +1236,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2", - "http", + "http 1.4.0", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.6", ] [[package]] @@ -3885,21 +1260,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", "bytes", - "futures-channel", - "futures-util", - "http", + "http 1.4.0", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", "tokio", "tower-service", - "tracing", ] [[package]] @@ -3939,15 +1306,16 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", - "yoke 0.8.1", + "utf8_iter", + "yoke 0.8.2", "zerofrom", - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] @@ -4057,15 +1425,15 @@ checksum = "52b1a7fbdbf3958f1be8354cb59ac73f165b7b7082d447ff2090355c9a069120" [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", - "litemap 0.8.1", - "tinystr 0.8.2", - "writeable 0.6.2", - "zerovec 0.11.5", + "litemap 0.8.2", + "tinystr 0.8.3", + "writeable 0.6.3", + "zerovec 0.11.6", ] [[package]] @@ -4121,16 +1489,16 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "icu_collections 2.1.1", - "icu_normalizer_data 2.1.1", - "icu_properties 2.1.2", - "icu_provider 2.1.1", + "icu_collections 2.2.0", + "icu_normalizer_data 2.2.0", + "icu_properties 2.2.0", + "icu_provider 2.2.0", "smallvec", - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] @@ -4141,9 +1509,9 @@ checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_pattern" @@ -4195,16 +1563,16 @@ dependencies = [ [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "icu_collections 2.1.1", + "icu_collections 2.2.0", "icu_locale_core", - "icu_properties_data 2.1.2", - "icu_provider 2.1.1", - "zerotrie 0.2.3", - "zerovec 0.11.5", + "icu_properties_data 2.2.0", + "icu_provider 2.2.0", + "zerotrie 0.2.4", + "zerovec 0.11.6", ] [[package]] @@ -4215,9 +1583,9 @@ checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" @@ -4238,17 +1606,17 @@ dependencies = [ [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "writeable 0.6.2", - "yoke 0.8.1", + "writeable 0.6.3", + "yoke 0.8.2", "zerofrom", - "zerotrie 0.2.3", - "zerovec 0.11.5", + "zerotrie 0.2.4", + "zerovec 0.11.6", ] [[package]] @@ -4259,7 +1627,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4283,6 +1651,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1adcf7b613a268af025bc2a2532b4b9ee294e6051c5c0832d8bff20ac0232e68" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -4302,110 +1676,24 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "icu_normalizer 2.1.1", - "icu_properties 2.1.2", + "icu_normalizer 2.2.0", + "icu_properties 2.2.0", ] -[[package]] -name = "image" -version = "0.25.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "instability" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" -dependencies = [ - "darling 0.23.0", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "internal-iterator" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969ee3fc68ec2e88eb21434ce4d9b7e1600d1ce92ff974560a6c4a304f5124b9" - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -4414,57 +1702,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -4476,56 +1713,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jiff" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -4542,21 +1741,19 @@ dependencies = [ ] [[package]] -name = "khronos-egl" -version = "6.0.0" +name = "konst" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" dependencies = [ - "libc", - "libloading", - "pkg-config", + "konst_macro_rules", ] [[package]] -name = "khronos_api" -version = "3.1.0" +name = "konst_macro_rules" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] name = "lazy_static" @@ -4565,10 +1762,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "lebe" -version = "0.5.3" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "leptos" @@ -4590,7 +1787,7 @@ dependencies = [ "or_poisoned", "paste", "reactive_graph", - "rustc-hash 2.1.1", + "rustc-hash", "send_wrapper", "serde", "serde_qs", @@ -4668,7 +1865,7 @@ dependencies = [ "quote", "rstml", "serde", - "syn 2.0.114", + "syn 2.0.117", "walkdir", ] @@ -4711,7 +1908,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.117", "tinystr 0.7.6", "toml 0.8.23", ] @@ -4731,7 +1928,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "syn 2.0.114", + "syn 2.0.117", "tinystr 0.7.6", "toml 0.8.23", ] @@ -4746,7 +1943,7 @@ dependencies = [ "cfg-if", "convert_case 0.7.1", "html-escape", - "itertools 0.14.0", + "itertools", "leptos_hot_reload", "prettyplease", "proc-macro-error2", @@ -4754,7 +1951,7 @@ dependencies = [ "quote", "rstml", "server_fn_macro", - "syn 2.0.114", + "syn 2.0.117", "uuid", ] @@ -4774,6 +1971,42 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos_router" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4168ead6a9715daba953aa842795cb2ad81b6e011a15745bd3d1baf86f76de95" +dependencies = [ + "any_spawner", + "either_of", + "futures", + "gloo-net 0.6.0", + "js-sys", + "leptos", + "leptos_router_macro", + "once_cell", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "thiserror 2.0.18", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_router_macro" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31197af38d209ffc5d9f89715381c415a1570176f8d23455fbe00d148e79640" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "leptos_server" version = "0.7.8" @@ -4794,52 +2027,39 @@ dependencies = [ "tachys", ] +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - -[[package]] -name = "liblzma" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73c36d08cad03a3fbe2c4e7bb3a9e84c57e4ee4135ed0b065cade3d98480c648" -dependencies = [ - "liblzma-sys", - "num_cpus", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f2db66f3268487b5033077f266da6777d057949b8f93c8ad82e441df25e6186" -dependencies = [ - "cc", - "libc", - "pkg-config", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -4849,24 +2069,11 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.10.0", "libc", - "redox_syscall 0.7.0", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", ] [[package]] @@ -4875,27 +2082,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" -[[package]] -name = "link-cplusplus" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" -dependencies = [ - "cc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - [[package]] name = "litemap" version = "0.7.5" @@ -4904,9 +2090,9 @@ checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -4921,74 +2107,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", + "serde", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "macerator" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b0b2dbe8b22f9e96ba12e29964889010117f92e6bd006010887320ae58e2f0" -dependencies = [ - "bytemuck", - "cfg_aliases", - "half", - "macerator-macros", - "moddef", - "num-traits", - "paste", - "rustc_version", -] - -[[package]] -name = "macerator-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd48b535b9b37a25a2589ab8d4f997886a2c68f59960ce06588525f38dd4944" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "manyhow" @@ -4999,7 +2125,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5029,59 +2155,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "matrixmultiply" -version = "0.3.10" +name = "md-5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "num_cpus", - "once_cell", - "rawpointer", - "thread-tree", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "rayon", + "digest 0.11.3", ] -[[package]] -name = "md5" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmap2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" -dependencies = [ - "libc", - "stable_deref_trait", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "merge" version = "0.1.0" @@ -5104,21 +2192,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "metal" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" -dependencies = [ - "bitflags 2.10.0", - "block", - "core-graphics-types", - "foreign-types", - "log", - "objc", - "paste", -] - [[package]] name = "mime" version = "0.3.17" @@ -5126,197 +2199,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "mime_guess" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "minicov" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" dependencies = [ - "adler2", - "simd-adler32", + "cc", + "walkdir", ] [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] -[[package]] -name = "moddef" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0b3262dc837d2513fe2ef31ff8461352ef932dcca31ba0c0abe33547cf6b9b" - -[[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "naga" -version = "26.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c" -dependencies = [ - "arrayvec 0.7.6", - "bit-set", - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "codespan-reporting 0.12.0", - "half", - "hashbrown 0.15.5", - "hexf-parse", - "indexmap", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "thiserror 2.0.18", - "unicode-ident", -] - -[[package]] -name = "nalgebra" -version = "0.33.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b" -dependencies = [ - "approx", - "matrixmultiply", - "nalgebra-macros", - "num-complex", - "num-rational", - "num-traits", - "simba", - "typenum", -] - -[[package]] -name = "nalgebra-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "nb" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" -dependencies = [ - "nb 1.1.0", -] - -[[package]] -name = "nb" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" - -[[package]] -name = "ndarray" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", -] - -[[package]] -name = "ndarray" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", - "rayon", -] - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "next_tuple" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" -[[package]] -name = "no-std-compat" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df270209a7f04d62459240d890ecb792714d5db12c92937823574a09930276b4" - -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -5326,21 +2244,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "ntapi" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" -dependencies = [ - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5350,20 +2253,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -5374,32 +2263,11 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "bytemuck", - "num-traits", -] - [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -5410,17 +2278,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-rational" version = "0.4.2" @@ -5452,75 +2309,24 @@ dependencies = [ "libc", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "nvml-wrapper" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d5c6c0ef9702176a570f06ad94f3198bc29c524c8b498f1b9346e1b1bdcbb3a" -dependencies = [ - "bitflags 2.10.0", - "libloading", - "nvml-wrapper-sys", - "static_assertions", - "thiserror 1.0.69", - "wrapcenum-derive", -] - -[[package]] -name = "nvml-wrapper-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd23dbe2eb8d8335d2bce0299e0a07d6a63c089243d626ca75b770a962ff49e6" -dependencies = [ - "libloading", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] -name = "objc2-io-kit" +name = "objc2-system-configuration" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "libc", "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "oco_ref" version = "0.2.1" @@ -5531,23 +2337,11 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "octets" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a74f2cda724d43a0a63140af89836d4e7db6138ef67c9f96d3a0f0150d05000" - [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oorandom" @@ -5555,44 +2349,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "or_poisoned" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ordered-float" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" -dependencies = [ - "num-traits", - "rand 0.8.5", - "serde", -] - [[package]] name = "parking" version = "2.2.1" @@ -5617,16 +2379,16 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "password-hash" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", "rand_core 0.6.4", @@ -5639,30 +2401,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -5699,7 +2443,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5709,132 +2453,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] -name = "petgraph" -version = "0.6.5" +name = "phf" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "fixedbitset", - "indexmap", + "phf_shared", + "serde", ] [[package]] -name = "pico-args" -version = "0.5.0" +name = "phf_shared" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.10.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -dependencies = [ - "serde", -] - -[[package]] -name = "portable-atomic-util" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" -dependencies = [ - "portable-atomic", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "postcard" @@ -5850,12 +2515,41 @@ dependencies = [ ] [[package]] -name = "potential_utf" -version = "0.1.4" +name = "postgres-protocol" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" dependencies = [ - "zerovec 0.11.5", + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec 0.11.6", ] [[package]] @@ -5873,22 +2567,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -5896,16 +2574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "syn 2.0.117", ] [[package]] @@ -5951,7 +2620,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5982,207 +2651,48 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "version_check", "yansi", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.114", -] - [[package]] name = "protocol" -version = "0.1.0" +version = "0.2.15" dependencies = [ "serde", ] [[package]] -name = "pulp" -version = "0.22.2" +name = "pulldown-cmark" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bytemuck", - "cfg-if", - "libm", - "num-complex", - "paste", - "pulp-wasm-simd-flag", - "raw-cpuid", - "reborrow", - "version_check", + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", ] [[package]] -name = "pulp-wasm-simd-flag" -version = "0.1.0" +name = "pulldown-cmark-escape" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] -name = "pxfm" -version = "0.1.27" +name = "qrcodegen" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "pyo3" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" -dependencies = [ - "cfg-if", - "indoc", - "libc", - "memoffset", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", - "unindent", -] - -[[package]] -name = "pyo3-build-config" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" -dependencies = [ - "once_cell", - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" -dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.1", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash 2.1.1", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -6206,9 +2716,15 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -6216,69 +2732,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "r2d2" -version = "0.8.10" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - -[[package]] -name = "r2d2_sqlite" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63417e83dc891797eea3ad379f52a5986da4bca0d6ef28baf4d14034dd111b0c" -dependencies = [ - "r2d2", - "rusqlite", - "uuid", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.7.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg 0.2.1", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", - "serde", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.2.2" +name = "rand" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -6301,12 +2789,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" - [[package]] name = "rand_core" version = "0.6.4" @@ -6314,7 +2796,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom 0.2.17", - "serde", ] [[package]] @@ -6327,169 +2808,10 @@ dependencies = [ ] [[package]] -name = "rand_distr" -version = "0.5.1" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" -dependencies = [ - "num-traits", - "rand 0.9.2", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "range-alloc" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" - -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags 2.10.0", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools 0.13.0", - "lru", - "paste", - "strum 0.26.3", - "time", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec 0.7.6", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "reactive_graph" @@ -6504,7 +2826,7 @@ dependencies = [ "hydration_context", "or_poisoned", "pin-project-lite", - "rustc-hash 2.1.1", + "rustc-hash", "send_wrapper", "serde", "slotmap", @@ -6519,12 +2841,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aadc7c19e3a360bf19cd595d2dc8b58ce67b9240b95a103fbc1317a8ff194237" dependencies = [ "guardian", - "itertools 0.14.0", + "itertools", "or_poisoned", "paste", "reactive_graph", "reactive_stores_macro", - "rustc-hash 2.1.1", + "rustc-hash", ] [[package]] @@ -6537,42 +2859,16 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "reborrow" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", + "bitflags", ] [[package]] @@ -6609,91 +2905,36 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "renet" -version = "0.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751424a2b0a8640bc41ca8b969250491b1a29e9b872dee4c7c56bcc56575b76e" +name = "relay-server" +version = "0.2.15" dependencies = [ + "argon2", + "axum", + "axum-login", "bytes", - "log", - "octets", - "renetcode", -] - -[[package]] -name = "renetcode" -version = "0.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed06c64c06cd7d80c61d6049c7dea9cc39bbf685b73ef9a6c5d01fada276094f" -dependencies = [ - "chacha20poly1305", - "log", -] - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-core", + "deadpool-postgres", "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", + "lettre", + "postcard", + "protocol", + "rand 0.8.6", "serde", "serde_json", - "serde_urlencoded", - "sync_wrapper", + "thiserror 1.0.69", + "time", "tokio", - "tokio-rustls", - "tower", + "tokio-postgres", "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.6", + "tower-sessions", + "tracing", + "tracing-subscriber", ] -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" - [[package]] name = "ring" version = "0.17.14" @@ -6708,60 +2949,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "ringbuffer" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df6368f71f205ff9c33c076d170dd56ebf68e8161c733c0caa07a7a5509ed53" - -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.114", - "unicode-ident", -] - [[package]] name = "rstml" version = "0.12.1" @@ -6772,42 +2959,16 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.114", + "syn 2.0.117", "syn_derive", "thiserror 2.0.18", ] -[[package]] -name = "rusqlite" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" -dependencies = [ - "bitflags 2.10.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -6818,37 +2979,11 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -6861,19 +2996,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -6888,39 +3022,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" - -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "safetensors" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93279b86b3de76f820a8854dd06cbc33cfa57a417b19c47f6a25280112fb1df" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "safetensors" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" -dependencies = [ - "hashbrown 0.16.1", - "serde", - "serde_json", -] +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -6931,66 +3035,17 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "sanitize-filename" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" -dependencies = [ - "regex", -] - -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scratch" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" - -[[package]] -name = "sdl2" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380" -dependencies = [ - "bitflags 1.3.2", - "c_vec", - "lazy_static", - "libc", - "sdl2-sys", -] - -[[package]] -name = "sdl2-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3" -dependencies = [ - "cfg-if", - "cmake", - "libc", - "version-compare", -] - [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "send_wrapper" @@ -7001,12 +3056,6 @@ dependencies = [ "futures-core", ] -[[package]] -name = "seq-macro" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" - [[package]] name = "serde" version = "1.0.228" @@ -7017,16 +3066,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -7044,14 +3083,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -7082,16 +3121,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "serde_rusqlite" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8bd74f47e124e760475a7e863b5820dcef09cae50782d03d65961f5ca1e6d9" -dependencies = [ - "rusqlite", - "serde_core", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -7103,9 +3132,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -7145,8 +3174,8 @@ dependencies = [ "const_format", "dashmap", "futures", - "gloo-net", - "http", + "gloo-net 0.6.0", + "http 1.4.0", "js-sys", "once_cell", "pin-project-lite", @@ -7175,7 +3204,7 @@ dependencies = [ "convert_case 0.6.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "xxhash-rust", ] @@ -7186,7 +3215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb8b274f568c94226a8045668554aace8142a59b8bca5414ac5a79627c825568" dependencies = [ "server_fn_macro", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7196,8 +3225,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -7207,8 +3236,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -7226,37 +3266,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b57709da74f9ff9f4a27dce9526eec25ca8407c45a7887243b031a58935fb8e" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook 0.3.18", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7268,32 +3277,10 @@ dependencies = [ ] [[package]] -name = "simba" -version = "0.9.1" +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" -dependencies = [ - "approx", - "num-complex", - "num-traits", - "paste", - "wide", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -7310,16 +3297,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "slug" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" -dependencies = [ - "deunicode", - "wasm-bindgen", -] - [[package]] name = "smallvec" version = "1.15.1" @@ -7328,25 +3305,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "spiel_bot" -version = "0.1.0" -dependencies = [ - "anyhow", - "burn", - "criterion", - "rand 0.9.2", - "rand_distr", - "trictrac-bot", - "trictrac-store", + "windows-sys 0.61.2", ] [[package]] @@ -7358,101 +3322,35 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" -dependencies = [ - "lock_api", - "portable-atomic", -] - -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "stable-vec" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1dff32a2ce087283bec878419027cebd888760d8760b2941ad0843531dc9ec8" -dependencies = [ - "no-std-compat", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strength_reduce" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "subtle" version = "2.6.1" @@ -7472,9 +3370,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -7490,7 +3388,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7498,21 +3396,6 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-xid", -] [[package]] name = "synstructure" @@ -7522,63 +3405,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "sysctl" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" -dependencies = [ - "bitflags 2.10.0", - "byteorder", - "enum-as-inner", - "libc", - "thiserror 1.0.69", - "walkdir", -] - -[[package]] -name = "sysinfo" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - -[[package]] -name = "sysinfo" -version = "0.37.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - -[[package]] -name = "systemstat" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5021f5184d44b26fb184acd689671bbe1e4bbd24bbdaa6bc7ec383fad32d2033" -dependencies = [ - "bytesize", - "lazy_static", - "libc", - "nom 7.1.3", - "time", - "winapi", + "syn 2.0.117", ] [[package]] @@ -7596,7 +3423,7 @@ dependencies = [ "futures", "html-escape", "indexmap", - "itertools 0.14.0", + "itertools", "js-sys", "linear-map", "next_tuple", @@ -7607,7 +3434,7 @@ dependencies = [ "paste", "reactive_graph", "reactive_stores", - "rustc-hash 2.1.1", + "rustc-hash", "send_wrapper", "slotmap", "throw_error", @@ -7615,79 +3442,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "tar" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tch" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e09b91610202dc4820c21eb474a42b386ef69f323b1c0902b5472ba7456ebb5" -dependencies = [ - "half", - "lazy_static", - "libc", - "ndarray 0.16.1", - "rand 0.8.5", - "safetensors 0.3.3", - "thiserror 1.0.69", - "torch-sys", - "zip 0.6.6", -] - -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "text_placeholder" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5008f74a09742486ef0047596cf35df2b914e2a8dca5727fcb6ba6842a766b" -dependencies = [ - "hashbrown 0.13.2", - "serde", - "serde_json", -] - -[[package]] -name = "textdistance" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa672c55ab69f787dbc9126cc387dbe57fdd595f585e4524cf89018fa44ab819" - [[package]] name = "thiserror" version = "1.0.69" @@ -7714,7 +3468,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7725,16 +3479,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "thread-tree" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbd370cb847953a25954d9f63e14824a36113f8c72eecf6eccef5dc4b45d630" -dependencies = [ - "crossbeam-channel", + "syn 2.0.117", ] [[package]] @@ -7755,20 +3500,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "tiff" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg 0.4.21", -] - [[package]] name = "time" version = "0.3.47" @@ -7777,9 +3508,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -7802,15 +3531,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.7.6" @@ -7823,29 +3543,19 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", - "zerovec 0.11.5", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", + "zerovec 0.11.6", ] [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -7858,30 +3568,56 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", ] [[package]] @@ -7896,14 +3632,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.28.0", + "tungstenite 0.29.0", ] [[package]] @@ -7928,22 +3664,20 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_edit", ] [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "toml_writer", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -7957,9 +3691,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -7975,28 +3709,16 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -8005,27 +3727,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - -[[package]] -name = "torch-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef40c585e342df95b66a1fa7c923188623999c2b657227befb481dfb03a6a42" -dependencies = [ - "anyhow", - "cc", - "libc", - "serde", - "serde_json", - "ureq", - "zip 0.6.6", -] - [[package]] name = "tower" version = "0.5.3" @@ -8043,19 +3744,42 @@ dependencies = [ ] [[package]] -name = "tower-http" -version = "0.6.8" +name = "tower-cookies" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" dependencies = [ - "bitflags 2.10.0", - "bytes", + "axum-core", + "cookie", "futures-util", - "http", - "http-body", - "iri-string", + "http 1.4.0", + "parking_lot", "pin-project-lite", - "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", ] @@ -8073,82 +3797,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "tracel-llvm" -version = "20.1.4-7" +name = "tower-sessions" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "982535db9eb1a30ac0f2c50239a0eec3e5cf50993a88e92b04747bd2f4d365b2" +checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" dependencies = [ - "tracel-mlir-rs", - "tracel-mlir-sys", + "async-trait", + "http 1.4.0", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", ] [[package]] -name = "tracel-llvm-bundler" -version = "20.1.4-7" +name = "tower-sessions-core" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c75b8e477cb8d49d907afab029ca74d48459f5b88c27bdb4c6cd6acb5e61977" +checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" dependencies = [ - "anyhow", - "bytes", - "constcat", - "dirs", - "liblzma", - "regex", - "reqwest", + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http 1.4.0", + "parking_lot", + "rand 0.8.6", "serde", "serde_json", - "sha2", - "tar", - "walkdir", -] - -[[package]] -name = "tracel-mlir-rs" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a478a35efd68d0ba73f747adfb7923b121c64e7f5be9cd8364ca1dcb772d5c" -dependencies = [ - "tracel-mlir-rs-macros", - "tracel-mlir-sys", -] - -[[package]] -name = "tracel-mlir-rs-macros" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a94f36868c3b10b1825945223d99d106c73f4d249f063caa4651deeb9379344" -dependencies = [ - "comrak", - "convert_case 0.8.0", - "proc-macro2", - "quote", - "regex", - "syn 2.0.114", - "tracel-llvm-bundler", - "tracel-tblgen-rs", - "unindent", -] - -[[package]] -name = "tracel-mlir-sys" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02f26d31af0c225a6d2e3d65d012fd6de848c9fc776897b152ee83b7d1bd15c4" -dependencies = [ - "tracel-llvm-bundler", -] - -[[package]] -name = "tracel-tblgen-rs" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d2581070380418ccc33b500f3739e4d4869421fdb477fcea51ff97c6253a52" -dependencies = [ - "bindgen", - "cc", - "paste", "thiserror 2.0.18", - "tracel-llvm-bundler", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", ] [[package]] @@ -8163,18 +3859,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-appender" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" -dependencies = [ - "crossbeam-channel", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - [[package]] name = "tracing-attributes" version = "0.1.31" @@ -8183,7 +3867,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8209,9 +3893,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -8235,62 +3919,44 @@ dependencies = [ "strength_reduce", ] -[[package]] -name = "trictrac-bot" -version = "0.1.0" -dependencies = [ - "board-game", - "burn", - "burn-rl", - "confy", - "env_logger 0.10.2", - "internal-iterator", - "log", - "pretty_assertions", - "rand 0.9.2", - "serde", - "serde_json", - "trictrac-store", -] - -[[package]] -name = "trictrac-client_cli" -version = "0.1.0" -dependencies = [ - "anyhow", - "bincode 1.3.3", - "env_logger 0.11.8", - "itertools 0.13.0", - "log", - "pico-args", - "pretty_assertions", - "renet", - "spiel_bot", - "trictrac-bot", - "trictrac-store", -] - [[package]] name = "trictrac-store" -version = "0.1.0" +version = "0.2.15" dependencies = [ "anyhow", "base64 0.21.7", - "cxx", - "cxx-build", "log", "merge", - "pyo3", - "rand 0.9.2", + "rand 0.9.4", "serde", "transpose", ] [[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +name = "trictrac-web" +version = "0.2.15" +dependencies = [ + "backbone-lib", + "futures", + "getrandom 0.3.4", + "gloo-net 0.5.0", + "gloo-storage", + "gloo-timers", + "js-sys", + "leptos", + "leptos_i18n", + "leptos_router", + "pulldown-cmark", + "qrcodegen", + "rand 0.9.4", + "serde", + "serde_json", + "trictrac-store", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] [[package]] name = "tungstenite" @@ -8301,10 +3967,10 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", - "rand 0.8.5", + "rand 0.8.6", "sha1", "thiserror 1.0.69", "utf-8", @@ -8312,36 +3978,20 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "sha1", "thiserror 2.0.18", - "utf-8", ] -[[package]] -name = "type-map" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" -dependencies = [ - "rustc-hash 2.1.1", -] - -[[package]] -name = "typed-arena" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" - [[package]] name = "typed-builder" version = "0.20.1" @@ -8359,20 +4009,14 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "typed-path" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" - [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -8381,10 +4025,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] -name = "unicode-ident" -version = "1.0.22" +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -8395,34 +4051,23 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -8430,28 +4075,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -8464,30 +4087,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" @@ -8500,6 +4099,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -8524,32 +4129,14 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" -version = "1.20.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "rand 0.9.2", - "wasm-bindgen", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", "wasm-bindgen", ] @@ -8559,41 +4146,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "variadics_please" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version-compare" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - [[package]] name = "walkdir" version = "2.5.0" @@ -8604,15 +4162,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -8620,19 +4169,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "wasi" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -8643,23 +4219,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8667,26 +4239,87 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -8701,10 +4334,22 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.85" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -8722,203 +4367,26 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.11" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] [[package]] -name = "weezl" -version = "0.1.12" +name = "whoami" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "wgpu" -version = "26.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" dependencies = [ - "arrayvec 0.7.6", - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown 0.15.5", - "js-sys", - "log", - "naga", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "26.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9" -dependencies = [ - "arrayvec 0.7.6", - "bit-set", - "bit-vec", - "bitflags 2.10.0", - "cfg_aliases", - "document-features", - "hashbrown 0.15.5", - "indexmap", - "log", - "naga", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.18", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "26.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18ae5fbde6a4cbebae38358aa73fcd6e0f15c6144b67ef5dc91ded0db125dbdf" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "26.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7670e390f416006f746b4600fdd9136455e3627f5bd763abf9a65daa216dd2d" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "26.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "26.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd" -dependencies = [ - "android_system_properties", - "arrayvec 0.7.6", - "ash", - "bit-set", - "bitflags 2.10.0", - "block", - "bytemuck", - "cfg-if", - "cfg_aliases", - "core-graphics-types", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.15.5", - "js-sys", - "khronos-egl", "libc", - "libloading", - "log", - "metal", - "naga", - "ndk-sys", - "objc", - "ordered-float 4.6.0", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "smallvec", - "thiserror 2.0.18", - "wasm-bindgen", - "web-sys", - "wgpu-types", - "windows 0.58.0", - "windows-core 0.58.0", -] - -[[package]] -name = "wgpu-types" -version = "26.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" -dependencies = [ - "bitflags 2.10.0", - "bytemuck", - "js-sys", - "log", - "thiserror 2.0.18", + "libredox", + "objc2-system-configuration", + "wasite", "web-sys", ] -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -8928,209 +4396,19 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -9139,7 +4417,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -9148,40 +4426,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -9190,84 +4442,42 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -9275,16 +4485,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -9294,17 +4507,93 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "wrapcenum-derive" -version = "0.4.1" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "darling 0.20.11", + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] @@ -9324,25 +4613,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.3", -] - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xxhash-rust" @@ -9350,12 +4623,6 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yansi" version = "1.0.1" @@ -9376,12 +4643,12 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", - "yoke-derive 0.8.1", + "yoke-derive 0.8.2", "zerofrom", ] @@ -9393,61 +4660,61 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", - "synstructure 0.13.2", + "syn 2.0.117", + "synstructure", ] [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", - "synstructure 0.13.2", + "syn 2.0.117", + "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", - "synstructure 0.13.2", + "syn 2.0.117", + "synstructure", ] [[package]] @@ -9469,12 +4736,12 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke 0.8.1", + "yoke 0.8.2", "zerofrom", ] @@ -9491,13 +4758,13 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "yoke 0.8.1", + "yoke 0.8.2", "zerofrom", - "zerovec-derive 0.11.2", + "zerovec-derive 0.11.3", ] [[package]] @@ -9508,122 +4775,22 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2", - "sha1", - "time", - "zstd", -] - -[[package]] -name = "zip" -version = "7.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" -dependencies = [ - "crc32fast", - "indexmap", - "memchr", - "typed-path", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core 0.5.1", -] +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 72e3f08..7897d03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,26 @@ +[workspace.package] +version = "0.2.17" + [workspace] resolver = "2" -members = ["client_cli", "bot", "store", "spiel_bot", "client_web"] +members = [ + "store", + "clients/backbone-lib", + "clients/web", + "server/protocol", + "server/relay-server", +] + +default-members = [ + "store", + "clients/backbone-lib", + "server/protocol", + "server/relay-server", +] + +# For the server we will need opt-level='3' +[profile.release] +opt-level = 'z' # Minimum space +lto = "fat" # Aggressive Link Time Optimization +codegen-units = 1 diff --git a/README.md b/README.md index e74fb69..2094edb 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,31 @@ This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation. -The project is on its early stages. -Rules (without "schools") are implemented, as well as a rudimentary terminal interface which allow you to play against a bot which plays randomly. - -Training of AI bots is the work in progress. - ## Usage -`cargo run --bin=client_cli -- --bot random` +Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands. -## Roadmap +```bash +# Run the relay server +just build-relay +just run-relay # listens on :8080 -- [x] rules -- [x] command line interface -- [x] basic bot (random play) -- [ ] AI bot -- [ ] network game -- [ ] web client +# Run the game (separate terminal) +just dev +``` + +Open a browser window at `http://127.0.0.1:9091`. You can play against a very basic bot, or invite an other player to connect at the same address. ## Code structure - game rules and game state are implemented in the _store/_ folder. -- the command-line application is implemented in _client_cli/_; it allows you to play against a bot, or to have two bots play against each other -- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. +- a server for the network game is implemented in _server/relay-server_, which uses _server/protocol_ +- the web client is in _clients/web_, it connects to the server using the _clients/backbone-lib_ library +- the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other +- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. This is a work in progress, they are not performant at all. -### _store_ package +## Inspirations -The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented. +The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_ and _server/relay-server_ is a [Leptos](https://leptos.dev/)-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. -### _client_cli_ package - -`client_cli/src/game_runner.rs` contains the logic to make two bots play against each other. - -### _bot_ package - -- `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list. -- `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (). -- `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC). +The web client UX/UI is inspired by https://playtiao.com. diff --git a/bot/Cargo.toml b/bot/Cargo.toml index d24adcc..de957df 100644 --- a/bot/Cargo.toml +++ b/bot/Cargo.toml @@ -13,7 +13,7 @@ path = "src/burnrl/main.rs" pretty_assertions = "1.4.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -trictrac-store = { path = "../store", features = ["python"] } +trictrac-store = { path = "../store" } rand = "0.9" env_logger = "0.10" burn = { version = "0.20", features = ["ndarray", "autodiff"] } diff --git a/bot/python/test.py b/bot/python/test.py deleted file mode 100644 index 8c4f16b..0000000 --- a/bot/python/test.py +++ /dev/null @@ -1,5 +0,0 @@ -import trictrac_store - -game = trictrac_store.TricTrac() -print(game.current_player_idx()) -print(game.get_legal_actions(game.current_player_idx())) diff --git a/client_web/Cargo.toml b/client_web/Cargo.toml deleted file mode 100644 index 3e648ea..0000000 --- a/client_web/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "client_web" -version = "0.1.0" -edition = "2021" - -[package.metadata.leptos-i18n] -default = "en" -locales = ["en", "fr"] - -[dependencies] -leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] } -trictrac-store = { path = "../store" } -backbone-lib = { path = "../../forks/multiplayer/backbone-lib" } -leptos = { version = "0.7", features = ["csr"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1" -futures = "0.3" -rand = "0.9" -gloo-storage = "0.3" - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen-futures = "0.4" -# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. -# Must be a direct dependency (not just transitive) for the feature to take effect. -getrandom = { version = "0.3", features = ["wasm_js"] } diff --git a/client_web/Trunk.toml b/client_web/Trunk.toml deleted file mode 100644 index 57a2aaa..0000000 --- a/client_web/Trunk.toml +++ /dev/null @@ -1,2 +0,0 @@ -[serve] -port = 9092 diff --git a/client_web/assets/style.css b/client_web/assets/style.css deleted file mode 100644 index 61d8cec..0000000 --- a/client_web/assets/style.css +++ /dev/null @@ -1,418 +0,0 @@ -/* ── Reset & base ──────────────────────────────────────────────────── */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -body { - font-family: sans-serif; - background: #c8b084; - display: flex; - justify-content: center; - padding: 1.5rem; - min-height: 100vh; -} - -/* ── Login / Connecting screens ────────────────────────────────────── */ -.login-container { - display: flex; - flex-direction: column; - gap: 0.75rem; - max-width: 320px; - margin-top: 4rem; -} - -.login-container h1 { font-size: 2rem; text-align: center; margin-bottom: 0.5rem; } - -input[type="text"] { - padding: 0.5rem 0.75rem; - font-size: 1rem; - border: 1px solid #aaa; - border-radius: 4px; -} - -.error-msg { color: #c00; font-size: 0.9rem; } - -.connecting { font-size: 1.2rem; margin-top: 4rem; text-align: center; } - -/* ── Buttons ────────────────────────────────────────────────────────── */ -.btn { - padding: 0.5rem 1.25rem; - font-size: 1rem; - border: none; - border-radius: 4px; - cursor: pointer; - transition: opacity 0.15s; -} -.btn:disabled { opacity: 0.4; cursor: default; } -.btn-primary { background: #3a6b3a; color: #fff; } -.btn-secondary { background: #5a4a2a; color: #fff; } -.btn-bot { background: #2a5a7a; color: #fff; } -.btn:not(:disabled):hover { opacity: 0.85; } - -/* ── Game container ─────────────────────────────────────────────────── */ -.game-container { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - width: 100%; -} - -/* ── Language switcher ──────────────────────────────────────────────── */ -.lang-switcher { - display: flex; - gap: 0.25rem; -} - -.lang-switcher button { - font-size: 0.75rem; - padding: 0.15rem 0.4rem; - border: 1px solid rgba(0,0,0,0.3); - border-radius: 3px; - background: transparent; - cursor: pointer; - color: inherit; - opacity: 0.6; -} - -.lang-switcher button.lang-active { - opacity: 1; - font-weight: bold; - background: rgba(0,0,0,0.12); -} - -.login-container .lang-switcher { - justify-content: flex-end; - margin-bottom: 1rem; -} - -/* ── Top bar ─────────────────────────────────────────────────────────── */ -.top-bar { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; -} - -.quit-link { - font-size: 0.85rem; - color: #5a4a2a; - text-decoration: underline; - cursor: pointer; -} - -/* ── Player score panel ─────────────────────────────────────────────── */ -.player-score-panel { - background: #f5edd8; - border-radius: 6px; - padding: 0.5rem 1rem; - font-size: 0.9rem; - box-shadow: 0 1px 4px rgba(0,0,0,0.2); - width: 100%; -} - -.player-score-header { - margin-bottom: 0.3rem; -} - -.player-name { - font-weight: bold; - font-size: 1rem; -} - -.score-bars { - display: flex; - flex-direction: column; - gap: 4px; -} - -.score-bar-row { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.score-bar-label { - font-size: 0.8rem; - color: #555; - width: 3.5rem; - text-align: right; - flex-shrink: 0; -} - -.score-bar { - width: 140px; - height: 10px; - background: rgba(0,0,0,0.12); - border-radius: 5px; - overflow: hidden; - flex-shrink: 0; -} - -.score-bar-fill { - height: 100%; - border-radius: 5px; - transition: width 0.3s; -} - -.score-bar-points { background: #4a7a3a; } -.score-bar-holes { background: #7a4a2a; } - -.score-bar-value { - font-size: 0.8rem; - color: #444; - min-width: 2.5rem; -} - -.bredouille-badge { - font-size: 0.7rem; - font-weight: bold; - color: #fff; - background: #c07800; - border-radius: 3px; - padding: 0.05em 0.35em; - cursor: default; -} - -.player-jans { - margin-top: 0.35rem; - border-top: 1px solid rgba(0,0,0,0.1); - padding-top: 0.25rem; -} - -/* ── Board + side panel ─────────────────────────────────────────────── */ -.board-and-panel { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 1rem; -} - -.side-panel { - display: flex; - flex-direction: column; - gap: 0.75rem; - min-width: 160px; - padding-top: 0.25rem; -} - -.action-buttons { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* ── Status bar ─────────────────────────────────────────────────────── */ -.status-bar { - display: flex; - gap: 1rem; - align-items: center; - font-size: 1.05rem; - font-weight: 500; -} - -/* ── Dice bar ───────────────────────────────────────────────────────── */ -.dice-bar { - display: flex; - align-items: center; - gap: 0.75rem; -} - -/* ── Die face (SVG) ─────────────────────────────────────────────────── */ -.die-face rect { - fill: #fffff0; - stroke: #2a1a00; - stroke-width: 2; -} -.die-face circle { - fill: #1a0a00; -} -.die-face.die-used rect { - fill: #d8d4c8; - stroke: #8a7a60; -} -.die-face.die-used circle { - fill: #8a7a60; -} - -/* ── Jan panel ──────────────────────────────────────────────────────── */ -.jan-panel { - display: flex; - flex-direction: column; - gap: 2px; - background: #f5edd8; - border-radius: 6px; - padding: 0.4rem 1rem; - font-size: 0.9rem; - box-shadow: 0 1px 4px rgba(0,0,0,0.15); - min-width: 260px; -} - -.jan-row { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 2px 4px; - border-radius: 3px; -} -.jan-expandable { cursor: pointer; } -.jan-expandable:hover { background: rgba(0,0,0,0.06); } - -.jan-positive { color: #1a5c1a; } -.jan-negative { color: #8b1a1a; } - -.jan-label { flex: 1; } -.jan-tag { - font-size: 0.75rem; - padding: 0.1em 0.4em; - border-radius: 3px; - background: rgba(0,0,0,0.08); - color: #555; - white-space: nowrap; -} -.jan-pts { font-weight: bold; text-align: right; min-width: 3rem; } - -.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } -.jan-moves.hidden { display: none; } -.jan-move-line { font-family: monospace; font-size: 0.8rem; color: #444; } - -/* ── Game-over overlay ──────────────────────────────────────────────── */ -.game-over-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; -} - -.game-over-box { - background: #f5edd8; - border-radius: 8px; - padding: 2rem 2.5rem; - text-align: center; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - display: flex; - flex-direction: column; - gap: 1.25rem; - min-width: 260px; -} - -.game-over-box h2 { - font-size: 1.75rem; -} - -.game-over-winner { - font-size: 1.25rem; - font-weight: bold; - color: #3a6b3a; -} - -.game-over-actions { - display: flex; - gap: 0.75rem; - justify-content: center; -} - -/* ── Board ──────────────────────────────────────────────────────────── */ -.board { - background: #2e6b2e; - border: 4px solid #1a3d1a; - border-radius: 8px; - padding: 4px; - display: flex; - flex-direction: column; - gap: 4px; - user-select: none; - box-shadow: 0 4px 12px rgba(0,0,0,0.4); - position: relative; -} - -.board-row { - display: flex; - gap: 4px; -} - -.board-quarter { - display: flex; - gap: 2px; -} - -.board-bar { - width: 20px; - background: #1a3d1a; - border-radius: 3px; -} - -.board-center-bar { - height: 12px; - background: #1a3d1a; - border-radius: 3px; -} - -/* ── Fields ─────────────────────────────────────────────────────────── */ -.field { - width: 60px; - height: 180px; - background: #d4a843; - border-radius: 4px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-end; - padding: 4px 2px; - position: relative; - transition: background 0.1s; -} - -/* Alternating field colours */ -.board-quarter .field:nth-child(odd) { background: #c49030; } -.board-quarter .field:nth-child(even) { background: #d4a843; } - -.top-row .field { justify-content: flex-start; } - -.field.clickable { cursor: pointer; } -.field.clickable:hover { background: #e8c060 !important; } -.field.selected { background: #88bb44 !important; outline: 2px solid #446622; } -.field.dest { background: #aad060 !important; } - -.field-num { - font-size: 0.65rem; - color: rgba(0,0,0,0.45); - position: absolute; - bottom: 2px; -} - -.top-row .field-num { bottom: auto; top: 2px; } - -/* ── Checkers ───────────────────────────────────────────────────────── */ -.checker-stack { - display: flex; - flex-direction: column; - align-items: center; -} - -.checker { - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.8rem; - font-weight: bold; - border: 2px solid rgba(0,0,0,0.3); - box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 1px 3px rgba(0,0,0,0.3); - flex-shrink: 0; -} - -.checker + .checker { margin-top: 2px; } - -.checker.white { - background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc); - color: #333; -} - -.checker.black { - background: radial-gradient(circle at 35% 35%, #555555, #111111); - color: #eee; -} diff --git a/client_web/locales/en.json b/client_web/locales/en.json deleted file mode 100644 index 799dbbb..0000000 --- a/client_web/locales/en.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "room_name_placeholder": "Room name", - "create_room": "Create Room", - "join_room": "Join Room", - "connecting": "Connecting…", - "game_over": "Game over", - "waiting_for_opponent": "Waiting for opponent…", - "your_turn_roll": "Your turn — roll the dice", - "hold_or_go": "Hold or Go?", - "select_move": "Select move {{ n }} of 2", - "your_turn": "Your turn", - "opponent_turn": "Opponent's turn", - "room_label": "Room: {{ id }}", - "quit": "Quit", - "roll_dice": "Roll dice", - "go": "Go", - "empty_move": "Empty move", - "you_suffix": " (you)", - "points_label": "Points", - "holes_label": "Holes", - "bredouille_title": "Can bredouille", - "jan_double": "double", - "jan_simple": "simple", - "jan_filled_quarter": "Quarter filled", - "jan_true_hit_small": "True hit (small jan)", - "jan_true_hit_big": "True hit (big jan)", - "jan_true_hit_corner": "True hit (opp. corner)", - "jan_first_exit": "First to exit", - "jan_six_tables": "Six tables", - "jan_two_tables": "Two tables", - "jan_mezeas": "Mezeas", - "jan_false_hit_small": "False hit (small jan)", - "jan_false_hit_big": "False hit (big jan)", - "jan_contre_two": "Contre two tables", - "jan_contre_mezeas": "Contre mezeas", - "jan_helpless_man": "Helpless man", - "play_vs_bot": "Play vs Bot", - "vs_bot_label": "vs Bot", - "you_win": "You win!", - "opp_wins": "{{ name }} wins!", - "play_again": "Play again" -} diff --git a/client_web/locales/fr.json b/client_web/locales/fr.json deleted file mode 100644 index df6a2b5..0000000 --- a/client_web/locales/fr.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "room_name_placeholder": "Nom de la salle", - "create_room": "Créer une salle", - "join_room": "Rejoindre", - "connecting": "Connexion en cours…", - "game_over": "Partie terminée", - "waiting_for_opponent": "En attente de l'adversaire…", - "your_turn_roll": "À votre tour — lancez les dés", - "hold_or_go": "Tenir ou s'en aller ?", - "select_move": "Sélectionner le coup {{ n }} sur 2", - "your_turn": "Votre tour", - "opponent_turn": "Tour de l'adversaire", - "room_label": "Salle : {{ id }}", - "quit": "Quitter", - "roll_dice": "Lancer les dés", - "go": "S'en aller", - "empty_move": "Mouvement impossible", - "you_suffix": " (vous)", - "points_label": "Points", - "holes_label": "Trous", - "bredouille_title": "Peut faire bredouille", - "jan_double": "double", - "jan_simple": "simple", - "jan_filled_quarter": "Remplissage", - "jan_true_hit_small": "Battage à vrai (petit jan)", - "jan_true_hit_big": "Battage à vrai (grand jan)", - "jan_true_hit_corner": "Battage coin adverse", - "jan_first_exit": "Premier sorti", - "jan_six_tables": "Jan de six tables", - "jan_two_tables": "Jan de deux tables", - "jan_mezeas": "Jan de mézéas", - "jan_false_hit_small": "Battage à faux (petit jan)", - "jan_false_hit_big": "Battage à faux (grand jan)", - "jan_contre_two": "Contre jan de deux tables", - "jan_contre_mezeas": "Contre jan de mezeas", - "jan_helpless_man": "Dame impuissante", - "play_vs_bot": "Jouer contre le bot", - "vs_bot_label": "contre le bot", - "you_win": "Vous avez gagné !", - "opp_wins": "{{ name }} gagne !", - "play_again": "Rejouer" -} diff --git a/client_web/src/app.rs b/client_web/src/app.rs deleted file mode 100644 index ae4c22f..0000000 --- a/client_web/src/app.rs +++ /dev/null @@ -1,331 +0,0 @@ -use futures::channel::mpsc; -use futures::{FutureExt, StreamExt}; -use gloo_storage::{LocalStorage, Storage}; -use leptos::prelude::*; -use leptos::task::spawn_local; -use serde::{Deserialize, Serialize}; - -use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; -use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate}; - -use crate::components::{ConnectingScreen, GameScreen, LoginScreen}; -use crate::i18n::I18nContextProvider; -use crate::trictrac::backend::TrictracBackend; -use crate::trictrac::bot_local::bot_decide; -use crate::trictrac::types::{GameDelta, PlayerAction, ViewState}; - -const RELAY_URL: &str = "ws://127.0.0.1:8080/ws"; -const GAME_ID: &str = "trictrac"; -const STORAGE_KEY: &str = "trictrac_session"; - -/// The state the UI needs to render the game screen. -#[derive(Clone, PartialEq)] -pub struct GameUiState { - pub view_state: ViewState, - /// 0 = host, 1 = guest - pub player_id: u16, - pub room_id: String, - pub is_bot_game: bool, -} - -/// Which screen is currently shown. -#[derive(Clone, PartialEq)] -pub enum Screen { - Login { error: Option }, - Connecting, - Playing(GameUiState), -} - -/// Commands sent from UI event handlers into the network task. -pub enum NetCommand { - CreateRoom { - room: String, - }, - JoinRoom { - room: String, - }, - Reconnect { - relay_url: String, - game_id: String, - room_id: String, - token: u64, - host_state: Option>, - }, - PlayVsBot, - Action(PlayerAction), - Disconnect, -} - -/// Stored in localStorage to reconnect after a page refresh. -#[derive(Serialize, Deserialize)] -struct StoredSession { - relay_url: String, - game_id: String, - room_id: String, - token: u64, - #[serde(default)] - is_host: bool, - #[serde(default)] - view_state: Option, -} - -fn save_session(session: &StoredSession) { - LocalStorage::set(STORAGE_KEY, session).ok(); -} - -fn load_session() -> Option { - LocalStorage::get::(STORAGE_KEY).ok() -} - -fn clear_session() { - LocalStorage::delete(STORAGE_KEY); -} - -#[component] -pub fn App() -> impl IntoView { - let stored = load_session(); - let initial_screen = if stored.is_some() { - Screen::Connecting - } else { - Screen::Login { error: None } - }; - let screen = RwSignal::new(initial_screen); - - let (cmd_tx, mut cmd_rx) = mpsc::unbounded::(); - provide_context(cmd_tx.clone()); - - if let Some(s) = stored { - let host_state = s - .view_state - .as_ref() - .and_then(|vs| serde_json::to_vec(vs).ok()); - cmd_tx - .unbounded_send(NetCommand::Reconnect { - relay_url: s.relay_url, - game_id: s.game_id, - room_id: s.room_id, - token: s.token, - host_state, - }) - .ok(); - } - - spawn_local(async move { - loop { - // Wait for a connect/reconnect command (or PlayVsBot). - // None means "play vs bot"; Some((config, is_reconnect)) means "connect to relay". - let remote_config: Option<(RoomConfig, bool)> = loop { - match cmd_rx.next().await { - Some(NetCommand::PlayVsBot) => break None, - Some(NetCommand::CreateRoom { room }) => { - break Some(( - RoomConfig { - relay_url: RELAY_URL.to_string(), - game_id: GAME_ID.to_string(), - room_id: room, - rule_variation: 0, - role: RoomRole::Create, - reconnect_token: None, - host_state: None, - }, - false, - )); - } - Some(NetCommand::JoinRoom { room }) => { - break Some(( - RoomConfig { - relay_url: RELAY_URL.to_string(), - game_id: GAME_ID.to_string(), - room_id: room, - rule_variation: 0, - role: RoomRole::Join, - reconnect_token: None, - host_state: None, - }, - false, - )); - } - Some(NetCommand::Reconnect { - relay_url, - game_id, - room_id, - token, - host_state, - }) => { - break Some(( - RoomConfig { - relay_url, - game_id, - room_id, - rule_variation: 0, - role: RoomRole::Join, - reconnect_token: Some(token), - host_state, - }, - true, - )); - } - _ => {} // Ignore game commands while disconnected. - } - }; - - if remote_config.is_none() { - loop { - let restart = run_local_bot_game(screen, &mut cmd_rx).await; - if !restart { break; } - } - screen.set(Screen::Login { error: None }); - continue; - } - let (config, is_reconnect) = remote_config.unwrap(); - - screen.set(Screen::Connecting); - - let room_id_for_storage = config.room_id.clone(); - let mut session: GameSession = - match GameSession::connect::(config).await { - Ok(s) => s, - Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => { - if is_reconnect { - clear_session(); - } - screen.set(Screen::Login { error: Some(e) }); - continue; - } - }; - - if !session.is_host { - save_session(&StoredSession { - relay_url: RELAY_URL.to_string(), - game_id: GAME_ID.to_string(), - room_id: room_id_for_storage.clone(), - token: session.reconnect_token, - is_host: false, - view_state: None, - }); - } - - let is_host = session.is_host; - let player_id = session.player_id; - let reconnect_token = session.reconnect_token; - let mut vs = ViewState::default_with_names("Host", "Guest"); - - loop { - futures::select! { - cmd = cmd_rx.next().fuse() => match cmd { - Some(NetCommand::Action(action)) => { - session.send_action(action); - } - _ => { - clear_session(); - session.disconnect(); - screen.set(Screen::Login { error: None }); - break; - } - }, - event = session.next_event().fuse() => match event { - Some(SessionEvent::Update(u)) => { - match u { - ViewStateUpdate::Full(state) => vs = state, - ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta), - } - if is_host { - save_session(&StoredSession { - relay_url: RELAY_URL.to_string(), - game_id: GAME_ID.to_string(), - room_id: room_id_for_storage.clone(), - token: reconnect_token, - is_host: true, - view_state: Some(vs.clone()), - }); - } - screen.set(Screen::Playing(GameUiState { - view_state: vs.clone(), - player_id, - room_id: room_id_for_storage.clone(), - is_bot_game: false, - })); - } - Some(SessionEvent::Disconnected(reason)) => { - screen.set(Screen::Login { error: reason }); - break; - } - None => { - screen.set(Screen::Login { error: None }); - break; - } - } - } - } - } - }); - - view! { - - {move || match screen.get() { - Screen::Login { error } => view! { }.into_any(), - Screen::Connecting => view! { }.into_any(), - Screen::Playing(state) => view! { }.into_any(), - }} - - } -} - -/// Runs one local bot game. Returns `true` if the player wants to play again. -async fn run_local_bot_game( - screen: RwSignal, - cmd_rx: &mut futures::channel::mpsc::UnboundedReceiver, -) -> bool { - let mut backend = TrictracBackend::new(0); - backend.player_arrival(0); - backend.player_arrival(1); - - let mut vs = ViewState::default_with_names("You", "Bot"); - drain_and_update(&mut backend, &mut vs, screen); - - loop { - match cmd_rx.next().await { - Some(NetCommand::Action(action)) => { - backend.inform_rpc(0, action); - } - Some(NetCommand::PlayVsBot) => return true, - _ => return false, - } - - drain_and_update(&mut backend, &mut vs, screen); - - loop { - match bot_decide(backend.get_game()) { - None => break, - Some(action) => { - backend.inform_rpc(1, action); - drain_and_update(&mut backend, &mut vs, screen); - } - } - } - } -} - -fn drain_and_update( - backend: &mut TrictracBackend, - vs: &mut ViewState, - screen: RwSignal, -) { - for cmd in backend.drain_commands() { - match cmd { - BackendCommand::ResetViewState => { - *vs = backend.get_view_state().clone(); - } - BackendCommand::Delta(delta) => { - vs.apply_delta(&delta); - } - _ => {} - } - } - screen.set(Screen::Playing(GameUiState { - view_state: vs.clone(), - player_id: 0, - room_id: String::new(), - is_bot_game: true, - })); -} diff --git a/client_web/src/components/board.rs b/client_web/src/components/board.rs deleted file mode 100644 index a60b99e..0000000 --- a/client_web/src/components/board.rs +++ /dev/null @@ -1,371 +0,0 @@ -use leptos::prelude::*; -use trictrac_store::CheckerMove; - -use crate::trictrac::types::{SerTurnStage, ViewState}; - -/// Field numbers in visual display order (left-to-right for each quarter), white's perspective. -const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18]; -const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24]; -const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7]; -const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1]; - -/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom. -const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6]; -const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12]; -const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19]; -const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13]; - -/// Returns the displayed board value for `field_num` after applying `staged_moves`. -/// Field numbers are always in white's coordinate system (1–24). -fn displayed_value( - base_board: [i8; 24], - staged_moves: &[(u8, u8)], - is_white: bool, - field_num: u8, -) -> i8 { - let mut val = base_board[(field_num - 1) as usize]; - let delta: i8 = if is_white { 1 } else { -1 }; - for &(from, to) in staged_moves { - if from == field_num { - val -= delta; - } - if to == field_num { - val += delta; - } - } - val -} - -/// Fields whose checkers may be selected as the next origin given already-staged moves. -fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec { - let mut v: Vec = match staged.len() { - 0 => seqs.iter() - .map(|(m1, _)| m1.get_from() as u8) - .filter(|&f| f != 0) - .collect(), - 1 => { - let (f0, t0) = staged[0]; - seqs.iter() - .filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0) - .map(|(_, m2)| m2.get_from() as u8) - .filter(|&f| f != 0) - .collect() - } - _ => vec![], - }; - v.sort_unstable(); - v.dedup(); - v -} - -/// Pixel center of a board field in the SVG overlay coordinate space. -/// Geometry is derived from CSS: field 60px wide, 180px tall, board padding 4px, -/// board-row gap 4px, board-bar 20px, board-center-bar 12px. -fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { - if f == 0 || f > 24 { - return None; - } - let (qi, right, top): (usize, bool, bool) = if is_white { - match f { - 13..=18 => (f - 13, false, true), - 19..=24 => (f - 19, true, true), - 7..=12 => (12 - f, false, false), - 1..=6 => (6 - f, true, false), - _ => return None, - } - } else { - match f { - 1..=6 => (f - 1, false, true), - 7..=12 => (f - 7, true, true), - 19..=24 => (24 - f, false, false), - 13..=18 => (18 - f, true, false), - _ => return None, - } - }; - // Left-quarter field i center x: 4 + i*62 + 30 = 34 + 62i - // Right-quarter field i center x: 4 + 370 + 4 + 20 + 4 + i*62 + 30 = 432 + 62i - let x = if right { 432.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 }; - // Top row center y: 4 + 90 = 94; bot row: 4 + 180 + 4 + 12 + 4 + 90 = 294 - let y = if top { 94.0 } else { 294.0 }; - Some((x, y)) -} - -/// SVG `` element drawing one arrow (shadow + gold) from `fp` to `tp`. -fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { - let (x1, y1) = fp; - let (x2, y2) = tp; - let dx = x2 - x1; - let dy = y2 - y1; - let len = (dx * dx + dy * dy).sqrt(); - if len < 10.0 { - return view! { }.into_any(); - } - let nx = dx / len; - let ny = dy / len; - let px = -ny; - let py = nx; - - // Shrink line ends so arrows don't overlap the checker stack - let lx1 = x1 + nx * 20.0; - let ly1 = y1 + ny * 20.0; - let lx2 = x2 - nx * 15.0; - let ly2 = y2 - ny * 15.0; - - // Arrowhead triangle at (x2, y2) - let ah = 15.0_f32; - let aw = 7.0_f32; - let bx = x2 - nx * ah; - let bary = y2 - ny * ah; - let pts = format!( - "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * aw, bary + py * aw, - bx - px * aw, bary - py * aw, - ); - let shadow_pts = format!( - "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", - x2, y2, - bx + px * (aw + 1.5), bary + py * (aw + 1.5), - bx - px * (aw + 1.5), bary - py * (aw + 1.5), - ); - - view! { - - // Drop-shadow for readability on coloured fields - - - // Gold arrow - - - - } - .into_any() -} - -/// Valid destinations for a selected origin given already-staged moves. -/// May include 0 (exit); callers handle that case. -fn valid_dests_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)], origin: u8) -> Vec { - let mut v: Vec = match staged.len() { - 0 => seqs.iter() - .filter(|(m1, _)| m1.get_from() as u8 == origin) - .map(|(m1, _)| m1.get_to() as u8) - .collect(), - 1 => { - let (f0, t0) = staged[0]; - seqs.iter() - .filter(|(m1, m2)| { - m1.get_from() as u8 == f0 - && m1.get_to() as u8 == t0 - && m2.get_from() as u8 == origin - }) - .map(|(_, m2)| m2.get_to() as u8) - .collect() - } - _ => vec![], - }; - v.sort_unstable(); - v.dedup(); - v -} - -#[component] -pub fn Board( - view_state: ViewState, - player_id: u16, - /// Pending origin selection (first click of a move pair). - selected_origin: RwSignal>, - /// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move. - staged_moves: RwSignal>, - /// All valid two-move sequences for this turn (empty when not in move stage). - valid_sequences: Vec<(CheckerMove, CheckerMove)>, -) -> impl IntoView { - let board = view_state.board; - let is_move_stage = view_state.active_mp_player == Some(player_id) - && matches!( - view_state.turn_stage, - SerTurnStage::Move | SerTurnStage::HoldOrGoChoice - ); - let is_white = player_id == 0; - let hovered_moves = use_context::>>(); - - // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc). - let fields_from = |nums: &[u8], is_top_row: bool| -> Vec { - nums.iter() - .map(|&field_num| { - // Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)> - // is Send, which Leptos requires for reactive attribute functions. - let seqs_c = valid_sequences.clone(); - let seqs_k = valid_sequences.clone(); - view! { -
0 } else { val < 0 }; - let can_stage = is_move_stage && staged.len() < 2; - let sel = selected_origin.get(); - - let mut cls = "field".to_string(); - - if seqs_c.is_empty() { - // No restriction (dice not rolled or not move stage) - if can_stage && (sel.is_some() || is_mine) { - cls.push_str(" clickable"); - } - if sel == Some(field_num) { cls.push_str(" selected"); } - if can_stage && sel.is_some() && sel != Some(field_num) { - cls.push_str(" dest"); - } - } else if can_stage { - if let Some(origin) = sel { - if origin == field_num { - cls.push_str(" selected clickable"); - } else { - let dests = valid_dests_for(&seqs_c, &staged, origin); - // Only highlight non-exit destinations (field 0 = exit has no tile) - if dests.iter().any(|&d| d == field_num && d != 0) { - cls.push_str(" clickable dest"); - } - } - } else { - let origins = valid_origins_for(&seqs_c, &staged); - if origins.iter().any(|&o| o == field_num) { - cls.push_str(" clickable"); - } - } - } - - cls - } - on:click=move |_| { - if !is_move_stage { return; } - let staged = staged_moves.get_untracked(); - if staged.len() >= 2 { return; } - - match selected_origin.get_untracked() { - Some(origin) if origin == field_num => { - selected_origin.set(None); - } - Some(origin) => { - let valid = if seqs_k.is_empty() { - true - } else { - valid_dests_for(&seqs_k, &staged, origin) - .iter() - .any(|&d| d == field_num) - }; - if valid { - staged_moves.update(|v| v.push((origin, field_num))); - selected_origin.set(None); - } - } - None => { - if seqs_k.is_empty() { - let val = displayed_value(board, &staged, is_white, field_num); - if is_white && val > 0 || !is_white && val < 0 { - selected_origin.set(Some(field_num)); - } - } else { - let origins = valid_origins_for(&seqs_k, &staged); - if origins.iter().any(|&o| o == field_num) { - let dests = valid_dests_for(&seqs_k, &staged, field_num); - if !dests.is_empty() && dests.iter().all(|&d| d == 0) { - // All destinations are exits: auto-stage - staged_moves.update(|v| v.push((field_num, 0))); - } else { - selected_origin.set(Some(field_num)); - } - } - } - } - } - } - > - {field_num} - {move || { - let moves = staged_moves.get(); - let val = displayed_value(board, &moves, is_white, field_num); - let count = val.unsigned_abs(); - (count > 0).then(|| { - let color = if val > 0 { "white" } else { "black" }; - let display_n = (count as usize).min(4); - // outermost index: last for top rows, first for bottom rows. - let outer_idx = if is_top_row { display_n - 1 } else { 0 }; - let chips: Vec = (0..display_n).map(|i| { - let label = if i == outer_idx && count >= 5 { - count.to_string() - } else { - String::new() - }; - view! { -
{label}
- }.into_any() - }).collect(); - view! {
{chips}
} - }) - }} -
- } - .into_any() - }) - .collect() - }; - - let (tl, tr, bl, br) = if is_white { - (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W) - } else { - (&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B) - }; - - view! { -
-
-
{fields_from(tl, true)}
-
-
{fields_from(tr, true)}
-
-
-
-
{fields_from(bl, false)}
-
-
{fields_from(br, false)}
-
- // SVG overlay: arrows for hovered jan moves - - {move || { - let Some(hm) = hovered_moves else { return vec![]; }; - let pairs = hm.get(); - if pairs.is_empty() { return vec![]; } - // Collect unique individual (from, to) moves; skip empty/exit. - let mut moves: Vec<(usize, usize)> = pairs.iter() - .flat_map(|(m1, m2)| [ - (m1.get_from(), m1.get_to()), - (m2.get_from(), m2.get_to()), - ]) - .filter(|&(f, t)| f != 0 && t != 0) - .collect(); - moves.sort_unstable(); - moves.dedup(); - moves.into_iter() - .filter_map(|(from, to)| { - let p1 = field_center(from, is_white)?; - let p2 = field_center(to, is_white)?; - Some(arrow_svg(p1, p2)) - }) - .collect() - }} - -
- } -} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs deleted file mode 100644 index 8a43399..0000000 --- a/client_web/src/components/game_screen.rs +++ /dev/null @@ -1,309 +0,0 @@ -use futures::channel::mpsc::UnboundedSender; -use leptos::prelude::*; -use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules}; - -use crate::app::{GameUiState, NetCommand}; -use crate::i18n::*; -use crate::trictrac::types::{JanEntry, PlayerAction, SerStage, SerTurnStage}; - -use super::board::Board; -use super::die::Die; -use super::score_panel::PlayerScorePanel; - -#[allow(dead_code)] -/// Returns (d0_used, d1_used) by matching each staged move's distance to a die. -fn matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { - let mut d0 = false; - let mut d1 = false; - for &(from, to) in staged { - let dist = if from < to { - to.saturating_sub(from) - } else { - from.saturating_sub(to) - }; - if !d0 && dist == dice.0 { - d0 = true; - } else if !d1 && dist == dice.1 { - d1 = true; - } else if !d0 { - d0 = true; - } else { - d1 = true; - } - } - (d0, d1) -} - -/// Split `dice_jans` into (viewer_jans, opponent_jans). -fn split_jans(dice_jans: &[JanEntry], viewer_is_active: bool) -> (Vec, Vec) { - let mut mine = Vec::new(); - let mut theirs = Vec::new(); - for e in dice_jans { - if viewer_is_active { - if e.total >= 0 { - mine.push(e.clone()); - } else { - theirs.push(JanEntry { - total: -e.total, - points_per: -e.points_per, - ..e.clone() - }); - } - } else if e.total >= 0 { - theirs.push(e.clone()); - } else { - mine.push(JanEntry { - total: -e.total, - points_per: -e.points_per, - ..e.clone() - }); - } - } - (mine, theirs) -} - -#[component] -pub fn GameScreen(state: GameUiState) -> impl IntoView { - let i18n = use_i18n(); - - let vs = state.view_state.clone(); - let player_id = state.player_id; - let is_my_turn = vs.active_mp_player == Some(player_id); - let is_move_stage = is_my_turn - && matches!( - vs.turn_stage, - SerTurnStage::Move | SerTurnStage::HoldOrGoChoice - ); - - // ── Hovered jan moves (shown as arrows on the board) ────────────────────── - let hovered_jan_moves: RwSignal> = RwSignal::new(vec![]); - provide_context(hovered_jan_moves); - - // ── Staged move state ────────────────────────────────────────────────────── - let selected_origin: RwSignal> = RwSignal::new(None); - let staged_moves: RwSignal> = RwSignal::new(Vec::new()); - - let cmd_tx = use_context::>() - .expect("UnboundedSender not found in context"); - let cmd_tx_effect = cmd_tx.clone(); - Effect::new(move |_| { - let moves = staged_moves.get(); - if moves.len() == 2 { - let to_cm = |&(from, to): &(u8, u8)| { - CheckerMove::new(from as usize, to as usize).unwrap_or_default() - }; - cmd_tx_effect - .unbounded_send(NetCommand::Action(PlayerAction::Move( - to_cm(&moves[0]), - to_cm(&moves[1]), - ))) - .ok(); - staged_moves.set(vec![]); - selected_origin.set(None); - } - }); - - let dice = vs.dice; - let show_dice = dice != (0, 0); - - // ── Button senders ───────────────────────────────────────────────────────── - let cmd_tx_roll = cmd_tx.clone(); - let cmd_tx_go = cmd_tx.clone(); - let cmd_tx_quit = cmd_tx.clone(); - let cmd_tx_end_quit = cmd_tx.clone(); - let cmd_tx_end_replay = cmd_tx.clone(); - let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; - let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice; - - // ── Valid move sequences for this turn ───────────────────────────────────── - // Computed once per ViewState snapshot; used by Board (highlighting) and the - // empty-move button (visibility). - let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) { - let mut store_board = StoreBoard::new(); - store_board.set_positions(&Color::White, vs.board); - let store_dice = StoreDice { values: dice }; - let color = if player_id == 0 { Color::White } else { Color::Black }; - let rules = MoveRules::new(&color, &store_board, store_dice); - let raw = rules.get_possible_moves_sequences(true, vec![]); - if player_id == 0 { - raw - } else { - raw.into_iter().map(|(m1, m2)| (m1.mirror(), m2.mirror())).collect() - } - } else { - vec![] - }; - // Clone for the empty-move button reactive closure (Board consumes the original). - let valid_seqs_empty = valid_sequences.clone(); - - // ── Jan split: viewer_jans / opponent_jans ───────────────────────────────── - let (my_jans, opp_jans) = split_jans(&vs.dice_jans, is_my_turn && !show_roll); - - // ── Scores ───────────────────────────────────────────────────────────────── - let my_score = vs.scores[player_id as usize].clone(); - let opp_score = vs.scores[1 - player_id as usize].clone(); - - // ── Capture for closures ─────────────────────────────────────────────────── - let stage = vs.stage.clone(); - let turn_stage = vs.turn_stage.clone(); - let room_id = state.room_id.clone(); - let is_bot_game = state.is_bot_game; - - // ── Game-over info ───────────────────────────────────────────────────────── - let stage_is_ended = stage == SerStage::Ended; - let winner_is_me = my_score.holes >= 12; - let opp_name_end = opp_score.name.clone(); - - view! { -
- // ── Top bar ────────────────────────────────────────────────────── -
- {move || if is_bot_game { - t_string!(i18n, vs_bot_label).to_owned() - } else { - t_string!(i18n, room_label, id = room_id.as_str()) - }} -
- - -
- {t!(i18n, quit)} -
- - // ── Opponent score (above board) ───────────────────────────────── - - - // ── Board + side panel ─────────────────────────────────────────── -
- - - // ── Side panel ─────────────────────────────────────────────── -
- // Status message -
- {move || { - let n = staged_moves.get().len(); - if is_move_stage { - t_string!(i18n, select_move, n = n + 1) - } else { - String::from(match (&stage, is_my_turn, &turn_stage) { - (SerStage::Ended, _, _) => t_string!(i18n, game_over), - (SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent), - (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), - (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), - (SerStage::InGame, true, _) => t_string!(i18n, your_turn), - (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), - }) - } - }} -
- - // Dice (always shown when rolled, used state depends on whose turn) - {show_dice.then(|| view! { -
- {move || { - let (d0, d1) = if is_move_stage { - matched_dice_used(&staged_moves.get(), dice) - } else { - (true, true) - }; - view! { - - - } - }} -
- })} - - // Action buttons -
- {show_roll.then(|| view! { - - })} - {show_hold_go.then(|| view! { - - })} - {move || { - // Show the empty-move button only when (0,0) is a valid - // first or second move given what has already been staged. - let staged = staged_moves.get(); - let show = is_move_stage && staged.len() < 2 && ( - valid_seqs_empty.is_empty() || match staged.len() { - 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), - 1 => { - let (f0, t0) = staged[0]; - valid_seqs_empty.iter() - .filter(|(m1, _)| { - m1.get_from() as u8 == f0 - && m1.get_to() as u8 == t0 - }) - .any(|(_, m2)| m2.get_from() == 0) - } - _ => false, - } - ); - show.then(|| view! { - - }) - }} -
-
-
- - // ── Player score (below board) ──────────────────────────────────── - - - // ── Game-over overlay ───────────────────────────────────────────── - {stage_is_ended.then(|| { - let winner_text = if winner_is_me { - t_string!(i18n, you_win).to_owned() - } else { - t_string!(i18n, opp_wins, name = opp_name_end.as_str()) - }; - view! { -
-
-

{t!(i18n, game_over)}

-

{winner_text}

-
- - {is_bot_game.then(|| view! { - - })} -
-
-
- } - })} -
- } -} diff --git a/client_web/src/components/login_screen.rs b/client_web/src/components/login_screen.rs deleted file mode 100644 index 91e6d1b..0000000 --- a/client_web/src/components/login_screen.rs +++ /dev/null @@ -1,77 +0,0 @@ -use futures::channel::mpsc::UnboundedSender; -use leptos::prelude::*; - -use crate::app::NetCommand; -use crate::i18n::*; - -#[component] -pub fn LoginScreen(error: Option) -> impl IntoView { - let i18n = use_i18n(); - let (room_name, set_room_name) = signal(String::new()); - - let cmd_tx = use_context::>() - .expect("UnboundedSender not found in context"); - - let cmd_tx_create = cmd_tx.clone(); - let cmd_tx_join = cmd_tx.clone(); - let cmd_tx_bot = cmd_tx; - - view! { - - } -} diff --git a/client_web/src/components/score_panel.rs b/client_web/src/components/score_panel.rs deleted file mode 100644 index 9045008..0000000 --- a/client_web/src/components/score_panel.rs +++ /dev/null @@ -1,156 +0,0 @@ -use leptos::prelude::*; -use trictrac_store::{CheckerMove, Jan}; - -use crate::i18n::*; -use crate::trictrac::types::{JanEntry, PlayerScore}; - -fn jan_label(jan: &Jan) -> String { - let i18n = use_i18n(); - match jan { - Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(), - Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(), - Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(), - Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(), - Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(), - Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(), - Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(), - Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(), - Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(), - Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(), - Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(), - Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(), - Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(), - } -} - -fn format_move_pair(m1: CheckerMove, m2: CheckerMove) -> String { - let fmt = |m: CheckerMove| -> String { - let (f, t) = (m.get_from(), m.get_to()); - if f == 0 && t == 0 { - "—".to_string() - } else if t == 0 { - format!("{f}↑") - } else { - format!("{f}→{t}") - } - }; - format!("{} & {}", fmt(m1), fmt(m2)) -} - -fn jan_row(idx: usize, entry: JanEntry, expanded: RwSignal>) -> impl IntoView { - let i18n = use_i18n(); - let row_class = if entry.total >= 0 { - "jan-row jan-expandable jan-positive" - } else { - "jan-row jan-expandable jan-negative" - }; - let label = jan_label(&entry.jan); - let double_tag = if entry.is_double { - t_string!(i18n, jan_double).to_owned() - } else { - t_string!(i18n, jan_simple).to_owned() - }; - let ways_tag = format!("×{}", entry.ways); - let pts_str = if entry.total >= 0 { - format!("+{}", entry.total) - } else { - format!("{}", entry.total) - }; - - let moves = entry.moves.clone(); - let moves_hover = entry.moves.clone(); - // RwSignal is Copy so it can be captured by both closures independently. - let hovered = use_context::>>(); - - view! { -
-
- {label} - {double_tag} - {ways_tag} - {pts_str} -
- { - let move_lines: Vec<_> = moves.iter() - .map(|&(m1, m2)| { - let text = format_move_pair(m1, m2); - view! {
{text}
} - }) - .collect(); - view! { -
- {move_lines} -
- } - } -
- } -} - -#[component] -pub fn PlayerScorePanel(score: PlayerScore, jans: Vec, is_you: bool) -> impl IntoView { - let i18n = use_i18n(); - - let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100)); - let holes_pct = format!("{}%", (score.holes as u32 * 100 / 12).min(100)); - let points_val = format!("{}/12", score.points); - let holes_val = format!("{}/12", score.holes); - let can_bredouille = score.can_bredouille; - - let expanded: RwSignal> = RwSignal::new(None); - let jan_rows: Vec<_> = jans - .into_iter() - .enumerate() - .map(|(i, entry)| jan_row(i, entry, expanded)) - .collect(); - - view! { -
-
- - {score.name} - {is_you.then(|| t!(i18n, you_suffix))} - -
-
-
- {t!(i18n, points_label)} -
-
-
- {points_val} - {can_bredouille.then(|| view! { - "B" - })} -
-
- {t!(i18n, holes_label)} -
-
-
- {holes_val} -
-
- {(!jan_rows.is_empty()).then(|| view! { -
{jan_rows}
- })} -
- } -} diff --git a/client_web/src/main.rs b/client_web/src/main.rs deleted file mode 100644 index 209ae60..0000000 --- a/client_web/src/main.rs +++ /dev/null @@ -1,12 +0,0 @@ -leptos_i18n::load_locales!(); - -mod app; -mod components; -mod trictrac; - -use app::App; -use leptos::prelude::*; - -fn main() { - mount_to_body(|| view! { }) -} diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs deleted file mode 100644 index 8941a09..0000000 --- a/client_web/src/trictrac/bot_local.rs +++ /dev/null @@ -1,33 +0,0 @@ -use rand::prelude::IndexedRandom; -use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; - -use crate::trictrac::types::PlayerAction; - -const GUEST_PLAYER_ID: u64 = 2; - -/// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn. -pub fn bot_decide(game: &GameState) -> Option { - if game.stage == Stage::Ended { - return None; - } - if game.active_player_id != GUEST_PLAYER_ID { - return None; - } - match game.turn_stage { - TurnStage::RollDice => Some(PlayerAction::Roll), - TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), - TurnStage::Move => { - let rules = MoveRules::new(&Color::Black, &game.board, game.dice); - let sequences = rules.get_possible_moves_sequences(true, vec![]); - let mut rng = rand::rng(); - let (m1, m2) = sequences - .choose(&mut rng) - .cloned() - .unwrap_or((CheckerMove::default(), CheckerMove::default())); - // MoveRules with Color::Black mirrors the board internally, so - // returned move coordinates are in mirrored (White) space — mirror back. - Some(PlayerAction::Move(m1.mirror(), m2.mirror())) - } - _ => None, - } -} diff --git a/clients/backbone-lib/Cargo.toml b/clients/backbone-lib/Cargo.toml new file mode 100644 index 0000000..d6ae5c9 --- /dev/null +++ b/clients/backbone-lib/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "backbone-lib" +version.workspace = true +edition = "2024" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +postcard = { version = "1.1", features = ["use-std"] } +bytes = "1.11" +ewebsock = "0.8" +protocol = { path = "../../server/protocol" } +futures = "0.3" +web-time = "1.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = "0.4" +gloo-timers = { version = "0.3", features = ["futures"] } diff --git a/clients/backbone-lib/src/client.rs b/clients/backbone-lib/src/client.rs new file mode 100644 index 0000000..65c7fdb --- /dev/null +++ b/clients/backbone-lib/src/client.rs @@ -0,0 +1,84 @@ +//! Background task for the client (non-host) side of a session. + +use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender}; +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; + +use crate::platform::sleep_ms; +use crate::protocol::{parse_client_update, send_disconnect, send_rpc}; +use crate::session::{BackendMsg, SessionEvent}; +use crate::traits::SerializationCap; + +pub(crate) async fn client_loop( + mut ws_sender: WsSender, + ws_receiver: WsReceiver, + mut action_rx: UnboundedReceiver>, + event_tx: UnboundedSender>, +) where + A: SerializationCap, + D: SerializationCap, + VS: SerializationCap, +{ + loop { + // 1. Drain outbound actions. + loop { + match action_rx.try_next() { + Ok(Some(BackendMsg::Action(action))) => { + send_rpc(&mut ws_sender, &action); + } + Ok(Some(BackendMsg::Disconnect)) => { + send_disconnect(&mut ws_sender, false); + event_tx + .unbounded_send(SessionEvent::Disconnected(None)) + .ok(); + return; + } + Ok(None) => { + send_disconnect(&mut ws_sender, false); + return; + } + Err(_) => break, + } + } + + // 2. Drain inbound state updates. + loop { + match ws_receiver.try_recv() { + Some(WsEvent::Message(WsMessage::Binary(data))) => { + match parse_client_update::(data) { + Ok(updates) => { + for u in updates { + event_tx + .unbounded_send(SessionEvent::Update(u)) + .ok(); + } + } + Err(e) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + } + } + Some(WsEvent::Closed) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some( + "Connection closed".to_string(), + ))) + .ok(); + return; + } + Some(WsEvent::Error(e)) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + Some(_) => continue, + None => break, + } + } + + sleep_ms(2).await; + } +} diff --git a/clients/backbone-lib/src/host.rs b/clients/backbone-lib/src/host.rs new file mode 100644 index 0000000..c78e228 --- /dev/null +++ b/clients/backbone-lib/src/host.rs @@ -0,0 +1,211 @@ +//! Background task for the host (game server) side of a session. + +use std::collections::HashSet; + +use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender}; +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; +use web_time::{Duration, Instant}; + +use crate::platform::sleep_ms; +use crate::protocol::{ + ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state, + send_kick, send_reset, +}; +use crate::session::{BackendMsg, SessionEvent}; +use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate}; + +struct Timer { + id: u16, + fire_at: Instant, +} + +pub(crate) async fn host_loop( + mut ws_sender: WsSender, + ws_receiver: WsReceiver, + mut action_rx: UnboundedReceiver>, + event_tx: UnboundedSender>, + rule_variation: u16, + host_state: Option>, +) where + A: SerializationCap, + D: SerializationCap + Clone, + VS: SerializationCap + Clone, + Backend: BackEndArchitecture, +{ + let mut backend = host_state + .as_deref() + .and_then(|b| Backend::from_bytes(rule_variation, b)) + .unwrap_or_else(|| Backend::new(rule_variation)); + backend.player_arrival(0); + + // Push initial state to UI immediately. + let initial = backend.get_view_state().clone(); + event_tx + .unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial))) + .ok(); + + let mut timers: Vec = Vec::new(); + let mut cancelled_timers: HashSet = HashSet::new(); + let mut remote_player_count: u16 = 0; + + loop { + let mut client_joined = false; + + // 1. Drain local actions / detect session drop or disconnect request. + loop { + match action_rx.try_next() { + Ok(Some(BackendMsg::Action(action))) => { + backend.inform_rpc(0, action); + } + Ok(Some(BackendMsg::Disconnect)) => { + send_disconnect(&mut ws_sender, true); + event_tx + .unbounded_send(SessionEvent::Disconnected(None)) + .ok(); + return; + } + Ok(None) => { + // All senders dropped — session was dropped without calling disconnect(). + send_disconnect(&mut ws_sender, true); + return; + } + Err(_) => break, // Channel empty; nothing pending. + } + } + + // 2. Drain WebSocket events from the relay. + loop { + match ws_receiver.try_recv() { + Some(WsEvent::Message(WsMessage::Binary(data))) => { + match parse_server_command::(data) { + ToServerCommand::ClientJoin(id) => { + backend.player_arrival(id); + remote_player_count += 1; + client_joined = true; + } + ToServerCommand::ClientLeft(id) => { + backend.player_departure(id); + remote_player_count = remote_player_count.saturating_sub(1); + } + ToServerCommand::Rpc(id, payload) => { + backend.inform_rpc(id, payload); + } + ToServerCommand::Error(e) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + } + } + Some(WsEvent::Closed) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some( + "Connection closed".to_string(), + ))) + .ok(); + return; + } + Some(WsEvent::Error(e)) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + Some(_) => continue, // Ignore Opened / text messages. + None => break, // No more events this iteration. + } + } + + // 3. Fire elapsed timers. + let now = Instant::now(); + let mut fired = Vec::new(); + timers.retain(|t| { + if t.fire_at <= now { + fired.push(t.id); + false + } else { + true + } + }); + for id in fired { + if !cancelled_timers.remove(&id) { + backend.timer_triggered(id); + } + } + + // 4. Drain and process backend commands. + let commands = backend.drain_commands(); + + if commands.is_empty() && !client_joined { + sleep_ms(2).await; + continue; + } + + let mut delta_batch: Vec = Vec::new(); + let mut reset = false; + + for cmd in commands { + match cmd { + BackendCommand::TerminateRoom => { + send_disconnect(&mut ws_sender, true); + event_tx + .unbounded_send(SessionEvent::Disconnected(None)) + .ok(); + return; + } + BackendCommand::SetTimer { timer_id, duration } => { + // Cancel any existing timer with the same id, then re-arm. + timers.retain(|t| t.id != timer_id); + cancelled_timers.remove(&timer_id); + timers.push(Timer { + id: timer_id, + fire_at: Instant::now() + Duration::from_secs_f32(duration), + }); + } + BackendCommand::CancelTimer { timer_id } => { + cancelled_timers.insert(timer_id); + } + BackendCommand::KickPlayer { player } => { + if remote_player_count > 0 { + send_kick(&mut ws_sender, player); + } + } + BackendCommand::ResetViewState => { + reset = true; + } + BackendCommand::Delta(d) => { + delta_batch.push(d); + } + } + } + + if reset { + // Reset supersedes all pending deltas: send fresh full state. + let state = backend.get_view_state().clone(); + if remote_player_count > 0 { + send_reset(&mut ws_sender, &state); + } + event_tx + .unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state))) + .ok(); + } else { + // Broadcast deltas, then notify local UI. + if remote_player_count > 0 && !delta_batch.is_empty() { + send_delta(&mut ws_sender, &delta_batch); + } + for d in delta_batch { + event_tx + .unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d))) + .ok(); + } + } + + // Send full state to clients that joined this iteration. + if client_joined { + send_full_state(&mut ws_sender, backend.get_view_state()); + } + + sleep_ms(2).await; + } +} diff --git a/clients/backbone-lib/src/lib.rs b/clients/backbone-lib/src/lib.rs new file mode 100644 index 0000000..d67a96c --- /dev/null +++ b/clients/backbone-lib/src/lib.rs @@ -0,0 +1,10 @@ +pub mod session; +pub mod traits; + +mod client; +mod host; +mod platform; +mod protocol; + +pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; +pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate}; diff --git a/clients/backbone-lib/src/platform.rs b/clients/backbone-lib/src/platform.rs new file mode 100644 index 0000000..92f2414 --- /dev/null +++ b/clients/backbone-lib/src/platform.rs @@ -0,0 +1,48 @@ +use std::future::Future; + +/// Spawns a background task. +/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required) +/// - Native: spawns an OS thread running `futures::executor::block_on` +#[cfg(target_arch = "wasm32")] +pub fn spawn_task(fut: F) +where + F: Future + 'static, +{ + wasm_bindgen_futures::spawn_local(fut); +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn_task(fut: F) +where + F: Future + Send + 'static, +{ + std::thread::spawn(move || { + futures::executor::block_on(fut); + }); +} + +/// Yields for approximately `ms` milliseconds. +/// - WASM: non-blocking yield via browser timer +/// - Native: blocks the current thread (safe on a dedicated background thread) +#[cfg(target_arch = "wasm32")] +pub async fn sleep_ms(ms: u32) { + gloo_timers::future::TimeoutFuture::new(ms).await; +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn sleep_ms(ms: u32) { + std::thread::sleep(std::time::Duration::from_millis(ms as u64)); +} + +/// Platform-agnostic bound for types that can be moved into a background task. +/// - WASM: only requires `'static` (single-threaded, no Send needed) +/// - Native: requires `Send + 'static` +#[cfg(target_arch = "wasm32")] +pub trait TaskBound: 'static {} +#[cfg(target_arch = "wasm32")] +impl TaskBound for T {} + +#[cfg(not(target_arch = "wasm32"))] +pub trait TaskBound: Send + 'static {} +#[cfg(not(target_arch = "wasm32"))] +impl TaskBound for T {} diff --git a/clients/backbone-lib/src/protocol.rs b/clients/backbone-lib/src/protocol.rs new file mode 100644 index 0000000..65f972a --- /dev/null +++ b/clients/backbone-lib/src/protocol.rs @@ -0,0 +1,159 @@ +//! Wire protocol encoding/decoding helpers. +//! +//! Translates between raw WebSocket binary frames and typed Rust values using +//! postcard serialization and the message-type constants from the `protocol` crate. + +use crate::traits::{SerializationCap, ViewStateUpdate}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use ewebsock::{WsMessage, WsSender}; +use postcard::{from_bytes, take_from_bytes, to_stdvec}; +use protocol::{ + CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE, + FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS, + SERVER_ERROR, SERVER_RPC, +}; + +// --------------------------------------------------------------------------- +// Inbound command types (relay → host) +// --------------------------------------------------------------------------- + +pub enum ToServerCommand { + ClientJoin(u16), + ClientLeft(u16), + Rpc(u16, A), + Error(String), +} + +// --------------------------------------------------------------------------- +// Send helpers +// --------------------------------------------------------------------------- + +fn send_binary(sender: &mut WsSender, data: &[u8]) { + sender.send(WsMessage::Binary(data.to_vec())); +} + +pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> { + let bytes = to_stdvec(req).map_err(|e| e.to_string())?; + send_binary(sender, &bytes); + Ok(()) +} + +pub fn send_rpc(sender: &mut WsSender, action: &A) { + let raw = to_stdvec(action).expect("Failed to serialize RPC"); + let mut buf = BytesMut::with_capacity(1 + raw.len()); + buf.put_u8(SERVER_RPC); + buf.put_slice(&raw); + send_binary(sender, &buf); +} + +pub fn send_delta(sender: &mut WsSender, deltas: &[D]) { + let serialized: Vec = deltas + .iter() + .flat_map(|d| to_stdvec(d).expect("Failed to serialize delta")) + .collect(); + let mut buf = BytesMut::with_capacity(1 + serialized.len()); + buf.put_u8(DELTA_UPDATE); + buf.put_slice(&serialized); + send_binary(sender, &buf); +} + +pub fn send_full_state(sender: &mut WsSender, state: &VS) { + let serialized = to_stdvec(state).expect("Failed to serialize full state"); + let mut buf = BytesMut::with_capacity(1 + serialized.len()); + buf.put_u8(FULL_UPDATE); + buf.put_slice(&serialized); + send_binary(sender, &buf); +} + +pub fn send_reset(sender: &mut WsSender, state: &VS) { + let serialized = to_stdvec(state).expect("Failed to serialize reset state"); + let mut buf = BytesMut::with_capacity(1 + serialized.len()); + buf.put_u8(RESET); + buf.put_slice(&serialized); + send_binary(sender, &buf); +} + +pub fn send_kick(sender: &mut WsSender, player_id: u16) { + let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE); + buf.put_u8(CLIENT_GETS_KICKED); + buf.put_u16(player_id); + send_binary(sender, &buf); +} + +pub fn send_disconnect(sender: &mut WsSender, as_host: bool) { + let msg = if as_host { + SERVER_DISCONNECTS + } else { + CLIENT_DISCONNECTS_SELF + }; + send_binary(sender, &[msg]); +} + +// --------------------------------------------------------------------------- +// Receive / parse helpers +// --------------------------------------------------------------------------- + +/// Parses the relay's handshake response. +/// +/// Returns `(player_id, rule_variation, reconnect_token)`. +pub fn parse_handshake_response(data: Vec) -> Result<(u16, u16, u64), String> { + let mut bytes = Bytes::from(data); + let msg = bytes.get_u8(); + match msg { + SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()), + HAND_SHAKE_RESPONSE => { + let player_id = bytes.get_u16(); + let rule_variation = bytes.get_u16(); + let token = bytes.get_u64(); + Ok((player_id, rule_variation, token)) + } + other => Err(format!("Unexpected handshake message id: {other}")), + } +} + +pub fn parse_server_command(data: Vec) -> ToServerCommand { + let mut bytes = Bytes::from(data); + let msg = bytes.get_u8(); + match msg { + SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()), + NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()), + CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()), + SERVER_RPC => { + let client_id = bytes.get_u16(); + let payload: A = + from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload"); + ToServerCommand::Rpc(client_id, payload) + } + other => ToServerCommand::Error(format!("Unknown server message id: {other}")), + } +} + +pub fn parse_client_update( + data: Vec, +) -> Result>, String> +where + VS: SerializationCap, + D: SerializationCap, +{ + let mut bytes = Bytes::from(data); + let msg = bytes.get_u8(); + match msg { + SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()), + DELTA_UPDATE => { + let mut result = Vec::new(); + let mut remaining: &[u8] = &bytes; + while !remaining.is_empty() { + let (delta, rest): (D, &[u8]) = + take_from_bytes(remaining).map_err(|e| e.to_string())?; + remaining = rest; + result.push(ViewStateUpdate::Incremental(delta)); + } + Ok(result) + } + FULL_UPDATE | RESET => { + let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?; + Ok(vec![ViewStateUpdate::Full(state)]) + } + other => Err(format!("Unknown client message id: {other}")), + } +} diff --git a/clients/backbone-lib/src/session.rs b/clients/backbone-lib/src/session.rs new file mode 100644 index 0000000..24314f7 --- /dev/null +++ b/clients/backbone-lib/src/session.rs @@ -0,0 +1,266 @@ +//! The public-facing session API. +//! +//! # Usage +//! +//! ```ignore +//! // Connect (async, returns after handshake completes) +//! let mut session: GameSession = +//! GameSession::connect::(RoomConfig { +//! relay_url: "ws://localhost:8080/ws".to_string(), +//! game_id: "my-game".to_string(), +//! room_id: "room-42".to_string(), +//! rule_variation: 0, +//! role: RoomRole::Create, +//! reconnect_token: None, +//! }) +//! .await?; +//! +//! // In a loop (e.g. Dioxus coroutine with futures::select!): +//! loop { +//! futures::select! { +//! cmd = ui_rx.next().fuse() => session.send_action(cmd), +//! event = session.next_event().fuse() => match event { +//! Some(SessionEvent::Update(u)) => view_state.apply(u), +//! Some(SessionEvent::Disconnected(reason)) | None => break, +//! } +//! } +//! } +//! ``` + +use ewebsock::{WsEvent, WsMessage}; +use futures::StreamExt; +use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use protocol::JoinRequest; + +use crate::client::client_loop; +use crate::host::host_loop; +use crate::platform::{TaskBound, sleep_ms, spawn_task}; +use crate::protocol::{parse_handshake_response, send_join_request}; +use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate}; + +// --------------------------------------------------------------------------- +// Public configuration types +// --------------------------------------------------------------------------- + +/// Whether to create a new room (host) or join an existing one (client). +pub enum RoomRole { + Create, + Join, +} + +/// Configuration required to connect to a game session. +pub struct RoomConfig { + /// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`). + pub relay_url: String, + /// Game identifier registered on the relay (e.g. `"tic-tac-toe"`). + pub game_id: String, + /// Room identifier shared between host and clients. + pub room_id: String, + /// Game mode/variant. Only used when `role` is `Create`. + pub rule_variation: u16, + pub role: RoomRole, + /// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh. + /// The value is the token returned by a previous successful handshake. + pub reconnect_token: Option, + /// Serialized backend state for host reconnect. + /// + /// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in + /// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host + /// reconnects so the game can resume from the last known state. + /// Ignored for non-host reconnects and normal connections. + pub host_state: Option>, +} + +/// Error returned by [`GameSession::connect`]. +#[derive(Debug)] +pub enum ConnectError { + WebSocket(String), + Handshake(String), +} + +impl std::fmt::Display for ConnectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"), + ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"), + } + } +} + +// --------------------------------------------------------------------------- +// Internal message type (UI → background task) +// --------------------------------------------------------------------------- + +pub(crate) enum BackendMsg { + Action(A), + Disconnect, +} + +// --------------------------------------------------------------------------- +// Session event (background task → UI) +// --------------------------------------------------------------------------- + +/// Events emitted by the session to the UI. +pub enum SessionEvent { + /// A state update arrived from the host backend. + Update(ViewStateUpdate), + /// The session ended. `None` = clean disconnect, `Some(reason)` = error. + Disconnected(Option), +} + +// --------------------------------------------------------------------------- +// GameSession +// --------------------------------------------------------------------------- + +/// A connected game session. +/// +/// Created by [`GameSession::connect`]. Holds channels to the background task +/// that owns the WebSocket connection and (on host) the game backend. +pub struct GameSession { + /// The player ID assigned by the relay server. Always `0` for the host. + pub player_id: u16, + /// The game mode/variant selected by the host. + pub rule_variation: u16, + /// `true` if this client is hosting the game (runs the backend). + pub is_host: bool, + /// Token to persist in localStorage for reconnect on page refresh. + /// Only meaningful for non-host players (player_id > 0). + pub reconnect_token: u64, + action_tx: UnboundedSender>, + event_rx: UnboundedReceiver>, +} + +impl GameSession +where + A: SerializationCap + TaskBound, + D: SerializationCap + Clone + TaskBound, + VS: SerializationCap + Clone + TaskBound, +{ + /// Connects to the relay server and performs the handshake. + /// + /// Returns after the relay confirms the player ID and rule variation. + /// Spawns a background task that drives the WebSocket connection for the + /// lifetime of the session. + /// + /// # Errors + /// Returns `Err` if the WebSocket cannot be opened or the handshake fails. + pub async fn connect(config: RoomConfig) -> Result + where + Backend: BackEndArchitecture + TaskBound, + { + let create_room = matches!(config.role, RoomRole::Create); + + // 1. Open WebSocket. + let (mut ws_sender, ws_receiver) = + ewebsock::connect(&config.relay_url, ewebsock::Options::default()) + .map_err(|e| ConnectError::WebSocket(e.to_string()))?; + + // 2. Wait for the Opened event (WASM WebSocket is async). + loop { + match ws_receiver.try_recv() { + Some(WsEvent::Opened) => break, + Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)), + Some(WsEvent::Closed) => { + return Err(ConnectError::WebSocket("Connection closed".to_string())); + } + Some(_) => continue, + None => sleep_ms(1).await, + } + } + + // 3. Send the join request. + let req = JoinRequest { + game_id: config.game_id, + room_id: config.room_id, + rule_variation: config.rule_variation, + create_room, + reconnect_token: config.reconnect_token, + }; + send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?; + + // 4. Wait for the handshake response. + let (player_id, rule_variation, reconnect_token) = loop { + match ws_receiver.try_recv() { + Some(WsEvent::Message(WsMessage::Binary(data))) => { + break parse_handshake_response(data).map_err(ConnectError::Handshake)?; + } + Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)), + Some(WsEvent::Closed) => { + // The relay may have sent a binary error frame just before + // closing. ewebsock can deliver Closed before that frame, + // so drain one more message to catch it. + if let Some(WsEvent::Message(WsMessage::Binary(data))) = + ws_receiver.try_recv() + { + break parse_handshake_response(data) + .map_err(ConnectError::Handshake)?; + } + return Err(ConnectError::Handshake( + "Connection closed during handshake".to_string(), + )); + } + Some(_) => continue, + None => sleep_ms(1).await, + } + }; + + // The relay assigns player_id == 0 exclusively to the host. + let is_host = player_id == 0; + + // 5. Set up channels between the UI and the background task. + let (action_tx, action_rx) = mpsc::unbounded::>(); + let (event_tx, event_rx) = mpsc::unbounded::>(); + + // 6. Spawn the background event loop. + if is_host { + spawn_task(host_loop::( + ws_sender, + ws_receiver, + action_rx, + event_tx, + rule_variation, + config.host_state, + )); + } else { + spawn_task(client_loop::( + ws_sender, + ws_receiver, + action_rx, + event_tx, + )); + } + + Ok(GameSession { + player_id, + rule_variation, + is_host, + reconnect_token, + action_tx, + event_rx, + }) + } + + /// Sends a game action to the backend (fire-and-forget). + pub fn send_action(&self, action: A) { + self.action_tx + .unbounded_send(BackendMsg::Action(action)) + .ok(); + } + + /// Awaits the next session event. + /// + /// Returns `None` if the background task has exited (i.e. the session is + /// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))` + /// before the channel closes. + pub async fn next_event(&mut self) -> Option> { + self.event_rx.next().await + } + + /// Signals the background task to send a graceful disconnect message and + /// shut down. Consumes the session. + pub fn disconnect(self) { + self.action_tx + .unbounded_send(BackendMsg::Disconnect) + .ok(); + } +} diff --git a/clients/backbone-lib/src/traits.rs b/clients/backbone-lib/src/traits.rs new file mode 100644 index 0000000..1ec50f7 --- /dev/null +++ b/clients/backbone-lib/src/traits.rs @@ -0,0 +1,97 @@ +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Marker trait for types that can be serialized with postcard. +pub trait SerializationCap: Serialize + DeserializeOwned {} +impl SerializationCap for T where T: Serialize + DeserializeOwned {} + +/// State updates delivered to the frontend for rendering. +/// +/// - [`Full`](Self::Full): Immediately set all visual state, no animation. +/// - [`Incremental`](Self::Incremental): Apply with animation/transition. +pub enum ViewStateUpdate { + /// Complete game state snapshot. Received on join or after a reset. + Full(ViewState), + /// Incremental state change for animated transitions. + Incremental(DeltaInformation), +} + +/// Commands emitted by the game backend to control the session. +pub enum BackendCommand +where + DeltaInformation: SerializationCap, +{ + /// Incremental state change to be broadcast to all frontends. + Delta(DeltaInformation), + + /// Signals a complete reset: discard queued deltas, broadcast fresh full state. + ResetViewState, + + /// Forcibly removes a player from the session. + KickPlayer { player: u16 }, + + /// Schedules a callback after `duration` seconds. Overwrites any existing + /// timer with the same `timer_id`. + SetTimer { timer_id: u16, duration: f32 }, + + /// Cancels a previously scheduled timer. No-op if already fired or not set. + CancelTimer { timer_id: u16 }, + + /// Shuts down the entire room and disconnects all players. + TerminateRoom, +} + +/// The contract for game-specific server logic. +/// +/// Implement this on the host side. The session calls these methods in response +/// to network events and drives `drain_commands` to collect outbound messages. +/// +/// # Type Parameters +/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`) +/// * `DeltaInformation` — Incremental state changes for animations +/// * `ViewState` — Complete game snapshot for syncing new clients +pub trait BackEndArchitecture +where + ServerRpcPayload: SerializationCap, + DeltaInformation: SerializationCap, + ViewState: SerializationCap + Clone, +{ + /// Creates a new game instance. `rule_variation` selects the game mode. + fn new(rule_variation: u16) -> Self; + + /// Attempt to restore a previously running game from serialized bytes. + /// + /// Called when the host reconnects after a page refresh. The bytes are the + /// game-specific snapshot produced by the app layer (via `serde_json` or + /// similar) and stored in localStorage. + /// + /// Return `None` if restoration is not supported or the bytes are invalid — + /// the caller falls back to `new(rule_variation)`. + fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option + where + Self: Sized, + { + None + } + + /// Called when a player connects. Player will receive a full state snapshot + /// automatically after this returns. + fn player_arrival(&mut self, player: u16); + + /// Called when a player disconnects. + fn player_departure(&mut self, player: u16); + + /// Called when a player sends a game action. + fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload); + + /// Called when a previously scheduled timer fires. + fn timer_triggered(&mut self, timer_id: u16); + + /// Returns the complete current game state. + fn get_view_state(&self) -> &ViewState; + + /// Collects and clears all pending commands since the last drain. + /// + /// Implement with `std::mem::take(&mut self.command_list)`. + fn drain_commands(&mut self) -> Vec>; +} diff --git a/client_cli/Cargo.toml b/clients/cli/Cargo.toml similarity index 67% rename from client_cli/Cargo.toml rename to clients/cli/Cargo.toml index d85dd8b..0149b1b 100644 --- a/client_cli/Cargo.toml +++ b/clients/cli/Cargo.toml @@ -13,9 +13,9 @@ bincode = "1.3.3" pico-args = "0.5.0" pretty_assertions = "1.4.0" renet = "0.0.13" -trictrac-store = { path = "../store", features = ["python"] } -trictrac-bot = { path = "../bot" } -spiel_bot = { path = "../spiel_bot" } +trictrac-store = { path = "../../store" } +trictrac-bot = { path = "../../bot" } +spiel_bot = { path = "../../spiel_bot" } itertools = "0.13.0" env_logger = "0.11.6" log = "0.4.20" diff --git a/client_cli/src/app.rs b/clients/cli/src/app.rs similarity index 100% rename from client_cli/src/app.rs rename to clients/cli/src/app.rs diff --git a/client_cli/src/game_runner.rs b/clients/cli/src/game_runner.rs similarity index 100% rename from client_cli/src/game_runner.rs rename to clients/cli/src/game_runner.rs diff --git a/client_cli/src/main.rs b/clients/cli/src/main.rs similarity index 100% rename from client_cli/src/main.rs rename to clients/cli/src/main.rs diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml new file mode 100644 index 0000000..a290f74 --- /dev/null +++ b/clients/web/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "trictrac-web" +version.workspace = true +edition = "2021" + +[package.metadata.leptos-i18n] +default = "en" +locales = ["en", "fr"] + +[dependencies] +leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] } +leptos_router = { version = "0.7" } +trictrac-store = { path = "../../store" } +backbone-lib = { path = "../backbone-lib" } +leptos = { version = "0.7", features = ["csr"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +futures = "0.3" +rand = "0.9" +gloo-storage = "0.3" +qrcodegen = "1.8" +pulldown-cmark = "0.13" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "=0.2.118" +wasm-bindgen-futures = "0.4" +gloo-net = { version = "0.5", features = ["http"] } +gloo-timers = { version = "0.3", features = ["futures"] } +getrandom = { version = "0.3", features = ["wasm_js"] } +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "RequestCredentials", + "AudioContext", + "AudioParam", + "AudioNode", + "AudioDestinationNode", + "AudioScheduledSourceNode", + "GainNode", + "OscillatorNode", + "OscillatorType", + "BaseAudioContext", + "HtmlAudioElement", + "Clipboard", + "Navigator", + "Location", +] } + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/clients/web/Trunk.toml b/clients/web/Trunk.toml new file mode 100644 index 0000000..bae5297 --- /dev/null +++ b/clients/web/Trunk.toml @@ -0,0 +1,2 @@ +[serve] +port = 9091 diff --git a/clients/web/assets/diceroll.mp3 b/clients/web/assets/diceroll.mp3 new file mode 100644 index 0000000..b16adff Binary files /dev/null and b/clients/web/assets/diceroll.mp3 differ diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css new file mode 100644 index 0000000..86e7cb8 --- /dev/null +++ b/clients/web/assets/style.css @@ -0,0 +1,2519 @@ +/* ── Google Fonts ───────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-blue: #e5eadc; + --field-blue-light: #1a4f72; + --field-brown: #f2dfa0; + --field-brown-light: #6a2810; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* -- svg icons -- */ +.icon { + width: 1.2em; + height: 1.2em; + color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; +} + +/* ── Site navigation ─────────────────────────────────────────────── */ +.site-nav { + background: var(--board-rail); + border-bottom: 2px solid var(--ui-gold-dark); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + flex-shrink: 0; +} + +.site-nav-brand { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-gold); + text-decoration: none; + letter-spacing: 0.1em; +} +.site-nav-brand:hover { color: #e0b840; } + +.site-nav-spacer { flex: 1; } + +.site-nav a { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s, color 0.15s; +} +.site-nav a:hover { opacity: 1; } + +.site-nav-btn { + padding: 0.3rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.03em; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 4px; + background: transparent; + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.site-nav-btn:hover { + background: rgba(200,164,72,0.12); + border-color: var(--ui-gold); +} + +/* ── Portal main content area ────────────────────────────────────── */ +.portal-main { + flex: 1; + max-width: 900px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.portal-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */ + /* border: 1px solid rgba(200,164,72,0.3); */ + /* border-top: 3px solid var(--ui-gold-dark); */ + padding: 1.75rem 2rem; + margin-bottom: 1.5rem; +} + +.portal-card h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.25rem; +} +.portal-card h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + color: var(--ui-ink); + margin-bottom: 0.75rem; +} + +.portal-meta { + font-size: 0.85rem; + color: #665544; + margin-bottom: 1.5rem; + font-family: var(--font-ui); +} + +/* ── Stats grid ──────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} +.stat-box { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.28); + border-radius: 6px; + padding: 1rem; + text-align: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} +.stat-box .value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + color: var(--ui-gold-dark); +} +.stat-box .label { + font-size: 0.78rem; + color: #665544; + margin-top: 0.2rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Tables ──────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-family: var(--font-ui); +} +th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid rgba(200,164,72,0.4); + color: #665544; + font-weight: 500; + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + color: var(--ui-ink); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(200,164,72,0.05); } + +a { color: var(--ui-gold-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Outcome classes ─────────────────────────────────────────────── */ +.outcome-win { color: var(--ui-green-accent); font-weight: 600; } +.outcome-loss { color: var(--ui-red-accent); font-weight: 600; } +.outcome-draw { color: #c07020; font-weight: 600; } + +/* ── Portal tabs ─────────────────────────────────────────────────── */ +.portal-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(200,164,72,0.3); +} +.portal-tab-btn { + padding: 0.55rem 1.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: transparent; + border: none; + cursor: pointer; + color: #665544; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} +.portal-tab-btn.active { + color: var(--ui-ink); + border-bottom-color: var(--ui-gold-dark); + font-weight: 500; +} + +/* ── Portal form ─────────────────────────────────────────────────── */ +.portal-label { + display: block; + font-size: 0.82rem; + color: #665544; + margin-bottom: 0.3rem; + letter-spacing: 0.03em; +} +.portal-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.35); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + margin-bottom: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.portal-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.18); +} +.portal-submit-btn { + padding: 0.6rem 2rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + border: none; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.25); + transition: opacity 0.15s; +} +.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.portal-submit-btn:not(:disabled):hover { opacity: 0.9; } + +.portal-page-btn { + padding: 0.35rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + background: var(--board-rail); + color: var(--ui-parchment); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} +.portal-page-btn:hover { opacity: 1; } + +.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; } +.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; } +.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; } +.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; } + +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.portal-link { + color: var(--ui-gold); + text-decoration: none; + font-size: 0.875rem; +} +.portal-link:hover { text-decoration: underline; } + +.portal-verification-banner { + background: rgba(200,164,72,0.08); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.portal-verification-banner p { + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +/* ── Share URL row (lobby waiting card + game top bar) ──────────── */ +.share-url-row { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0,0,0,0.18); + border: 1px solid rgba(200,164,72,0.25); + border-radius: 5px; + padding: 0.4rem 0.6rem; +} +.share-url-text { + flex: 1; + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(242,232,208,0.75); + word-break: break-all; + user-select: all; +} +.share-copy-btn { + flex-shrink: 0; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.2rem 0.6rem; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 3px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} +.share-copy-btn:hover { background: rgba(200,164,72,0.22); } + +/* ── QR code container ───────────────────────────────────────────── */ +.qr-container { + width: 160px; + height: 160px; + margin: 0 auto; + border-radius: 4px; + overflow: hidden; +} +.qr-container svg { width: 100%; height: 100%; display: block; } + +/* ── Share popover (in-game top bar) ─────────────────────────────── */ +.share-popover { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(200,164,72,0.2); + border-radius: 6px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.share-popover .qr-container { width: 120px; height: 120px; } +.share-popover-label { + font-size: 0.75rem; + color: rgba(242,232,208,0.6); + text-align: center; + margin: 0; +} + +/* ── Game overlay (full-screen, covers portal during play) ───────── */ +.game-overlay { + position: fixed; + inset: 0; + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + z-index: 200; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1.5rem; + overflow-y: auto; +} + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + */ + /* border-top: 3px solid var(--ui-gold-dark); */ + width: 340px; + margin-top: 5vh; + overflow: hidden; +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +.playing-as { + font-size: 0.75rem; + color: rgba(242,232,208,0.7); + font-family: var(--font-ui); +} +.playing-as strong { color: rgba(242,232,208,0.9); } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +.board-and-panel { + position: relative; +} + +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; +} + +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; +} + +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; +} + +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; + cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; +} +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } + +/* ── Inner scoring card ─────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 320px; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) ──────────────────────────────────────────────── */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +.field { + --fc: var(--field-ivory); + width: 60px; + height: 180px; + background: transparent; + isolation: isolate; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours ────────────────────────────────── */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +.board-quarter .field.zone-opponent:nth-child(odd), +.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-opponent:nth-child(even), +.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); } + +/* ── Point indicator: first N fields reflect each player's score & bredouille */ +.board-quarter .field.zone-petit.point-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ +.exit-btn { + pointer-events: none; + opacity: 0.3; + transition: opacity 0.2s, transform 0.15s; +} +.exit-btn.exit-active { + pointer-events: auto; + cursor: pointer; + opacity: 1; + animation: exit-btn-pulse 1.4s ease-in-out infinite; +} +.exit-btn.exit-active:hover { + transform: scale(1.1); +} +@keyframes exit-btn-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } + 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } +} + +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +.field.clickable { + cursor: pointer; +} +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} +.field.selected { + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); +} + +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; } +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker slide animation (§4a) ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + min-height: 2rem; +} + +/* ── Free-play mode ─────────────────────────────────────────────────────── */ +.free-mode-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-ui); + font-size: 0.78rem; + color: #887766; + cursor: pointer; + user-select: none; + padding-top: 0.1rem; +} +.free-mode-toggle input[type="checkbox"] { + accent-color: var(--ui-gold); + cursor: pointer; + width: 0.85rem; + height: 0.85rem; +} +.free-mode-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 1px solid #a89880; + font-size: 0.65rem; + font-style: normal; + color: #a89880; + cursor: help; + flex-shrink: 0; +} + +.free-mode-error { + text-align: center; + gap: 0.75rem; + background: rgba(180, 60, 30, 0.12); + border: 1px solid rgba(180, 60, 30, 0.4); + border-radius: 4px; + padding: 0.4rem 0.75rem; + width: 100%; + box-sizing: border-box; +} +.free-mode-error-msg { + font-family: var(--font-ui); + font-size: 0.85rem; + color: #8b2000; + font-style: italic; +} + +/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ +.ceremony-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} + +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: row; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; + cursor: pointer; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} + +/* Prevent horizontal scrollbar from the full-bleed strip */ +.game-overlay { overflow-x: hidden !important; } + +/* Board bar: hide die slots, keep the rail as a thin divider */ +.bar-die-slot { display: none !important; } +.board-bar { width: 5px; overflow: hidden; } + +/* ── Full-width in-flow player strip ─────────────────────────────────── */ +.players-strip { + width: 100vw; + margin-top: -1.5rem; /* undo game-overlay top padding */ + display: flex; + align-items: center; + background: var(--ui-parchment); + border-bottom: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 8px rgba(0,0,0,0.18); + padding: 0.35rem 1.5rem; + gap: 0.5rem; +} + +.strip-player { display: flex; align-items: center; flex: 1; min-width: 0; } +.strip-player-left { justify-content: flex-end; } +.strip-player-right { justify-content: flex-start; } + +.strip-active-zone { + display: flex; + align-items: center; + gap: 0.7rem; + border-radius: 8px; + padding: 0.28rem 0.5rem; + transition: background 0.15s; +} +.strip-active-zone.active { background: rgba(58,42,10,0.15); } + +/* Checker-style circles */ +.strip-avatar { + width: 38px; height: 38px; + border-radius: 50%; + flex-shrink: 0; +} +.strip-avatar-me { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(160,130,70,0.22) 68.5%, rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(160,130,70,0.17) 43.5%, rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.35), inset 0 -1px 3px rgba(0,0,0,0.15); +} +.strip-avatar-opp { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(200,164,72,0.18) 68.5%, rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(200,164,72,0.13) 43.5%, rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 -1px 3px rgba(0,0,0,0.4); +} + +/* Strip peg overrides */ +.players-strip .peg-track { gap: 3px; } +.players-strip .peg-hole { width: 12px; height: 12px; } +.players-strip .peg-hole.filled { + background: #5aab38; border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} +.players-strip .peg-hole.peg-opp.filled { + background: #c05030; border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Strip score-row-name: remove fixed width from v01 */ +.players-strip .score-row-name { width: auto; } + +/* No ghost bar below pts-counter in the strip */ +.players-strip .pts-counter-wrap { padding-bottom: 0; } + +/* Center "Trictrac" title */ +.players-strip-center { + flex-shrink: 0; + padding: 0 1rem; + border-left: 1px solid rgba(138,106,40,0.2); + border-right: 1px solid rgba(138,106,40,0.2); +} +.strip-title { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.03em; + white-space: nowrap; + margin-left: 1rem; +} + +/* ── Body: board + controls ──────────────────────────────────────────── */ +.main-body { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +/* ── Controls column (sidebar on wide, row on narrow) ────────────────── */ +.controls { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-self: stretch; +} +@media (min-width: 920px) { + .controls { + width: 200px; + } +} + +.ctrl-dice { + background: var(--board-rail); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.6rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.ctrl-dice-row { + display: flex; + gap: 0.55rem; + align-items: center; + justify-content: center; +} + +/* Free-mode toggle: light text on dark board-rail background */ +.ctrl-dice .free-mode-toggle { + color: var(--ui-parchment); + font-size: 0.7rem; + flex-wrap: wrap; + justify-content: center; + text-align: center; + gap: 0.3rem; +} +.ctrl-dice .free-mode-help { + border-color: rgba(242,232,208,0.35); + color: rgba(242,232,208,0.5); +} + +.ctrl-status { + background: var(--ui-parchment); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.65rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; +} +.ctrl-status .game-status { + color: var(--ui-ink); + text-shadow: none; + font-size: 1rem; + padding: 0; + width: auto; + text-align: center; + line-height: 1.3; +} +.ctrl-status .board-actions { + flex-wrap: wrap; + justify-content: center; + min-height: 0; +} +.ctrl-status .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; + text-align: center; + font-size: 0.67rem; + line-height: 1.4; + margin: 0; +} + +.scoring-row .scoring-panels-container { + position: static; + top: auto; left: auto; right: auto; bottom: auto; + z-index: auto; + padding: 0; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.scoring-row .scoring-panel { + box-sizing: border-box; + margin: 0; +} + +/* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */ +@media (max-width: 919px) { + .main-body { + flex-direction: column; + align-items: stretch; + } + .controls { + flex-direction: row; + width: 100%; + } + .ctrl-status { flex: 1; } + /* Hide pegs on small screens to save space in the strip */ + .players-strip .peg-track { display: none; } +} diff --git a/client_web/index.html b/clients/web/index.html similarity index 81% rename from client_web/index.html rename to clients/web/index.html index b661d76..7399dbc 100644 --- a/client_web/index.html +++ b/clients/web/index.html @@ -6,6 +6,7 @@ Trictrac + diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json new file mode 100644 index 0000000..ebc5130 --- /dev/null +++ b/clients/web/locales/en.json @@ -0,0 +1,167 @@ +{ + "room_name_placeholder": "Room name", + "create_room": "Create Room", + "join_room": "Join Room", + "connecting": "Connecting…", + "game_over": "Game over", + "waiting_for_opponent": "Waiting for opponent…", + "your_turn_roll": "Your turn — roll the dice", + "hold_or_go": "Hold or Go?", + "select_move": "Move a checker ({{ n }} of 2)", + "your_turn": "Your turn", + "opponent_turn": "Opponent's turn", + "room_label": "Room: {{ id }}", + "quit": "Quit", + "roll_dice": "Roll dice", + "go": "Go", + "empty_move": "Empty move", + "cancel_move": "Cancel move", + "debug_section": "Debug", + "take_snapshot": "Take snapshot", + "snapshot_copied": "Copied!", + "replay_snapshot": "Replay snapshot", + "replay_paste_hint": "Paste a snapshot JSON to start a bot game from that position.", + "replay_start": "Start", + "replay_invalid_state": "Invalid snapshot — paste the JSON copied by Take snapshot.", + "cancel": "Cancel", + "you_suffix": " (you)", + "points_label": "Points", + "holes_label": "Holes", + "bredouille_title": "Can bredouille", + "jan_double": "double", + "jan_simple": "simple", + "jan_filled_quarter": "Quarter filled", + "jan_true_hit_small": "True hit (small jan)", + "jan_true_hit_big": "True hit (big jan)", + "jan_true_hit_corner": "True hit (opp. corner)", + "jan_first_exit": "First to exit", + "jan_six_tables": "Six tables", + "jan_two_tables": "Two tables", + "jan_mezeas": "Mezeas", + "jan_false_hit_small": "False hit (small jan)", + "jan_false_hit_big": "False hit (big jan)", + "jan_contre_two": "Contre two tables", + "jan_contre_mezeas": "Contre mezeas", + "jan_helpless_man": "Helpless man", + "play_vs_bot": "Play vs Bot", + "vs_bot_label": "vs Bot", + "you_win": "You win!", + "opp_wins": "{{ name }} wins!", + "play_again": "Play again", + "after_opponent_roll": "Opponent rolled", + "after_opponent_go": "Opponent chose to continue", + "after_opponent_move": "Opponent moved — your turn", + "after_opponent_pre_game_roll": "Opponent rolled — your turn", + "pre_game_roll_title": "Who goes first?", + "pre_game_roll_btn": "Roll", + "pre_game_roll_tie": "Tie! Roll again", + "toss_you_first": "You go first!", + "toss_opp_first": "{{ name }} goes first!", + "pre_game_roll_your_die": "Your die", + "pre_game_roll_opp_die": "Opponent's die", + "continue_btn": "Continue", + "scored_pts": "+{{ n }} pts", + "hole_made": "Hole! {{ holes }}/12", + "bredouille_applied": "Bredouille!", + "hold": "Hold", + "opp_scored_pts": "Opponent +{{ n }} pts", + "opp_hole_made": "Opponent hole! {{ holes }}/12", + "hint_move": "Click a highlighted field to move a checker", + "hint_hold_or_go": "Hold to keep points — Go to reset the setting", + "hint_continue": "Click Continue when ready", + "anonymous_name": "Anonymous", + "login_failed": "Invalid username or password.", + "sign_in": "Sign in", + "sign_out": "Sign out", + "create_account": "Create account", + "account_title": "Account", + "label_username": "Username", + "label_username_or_email": "Username or email", + "label_password": "Password", + "label_confirm_password": "Confirm password", + "passwords_do_not_match": "Passwords do not match.", + "label_email": "Email", + "forgot_password_link": "Forgot password?", + "forgot_password_title": "Reset password", + "forgot_password_email_label": "Email address", + "forgot_password_submit": "Send reset link", + "forgot_password_sent": "If an account with this email exists, a reset link has been sent to that address.", + "reset_password_title": "New password", + "new_password_label": "New password", + "reset_password_submit": "Reset password", + "reset_password_success": "Password reset successfully. You can now sign in.", + "reset_password_invalid": "This reset link is invalid or has expired.", + "verify_email_title": "Email verification", + "verify_email_checking": "Verifying your email…", + "verify_email_success": "Your email has been verified.", + "verify_email_invalid": "This verification link is invalid or has expired.", + "email_not_verified_banner": "Please verify your email address — check your inbox.", + "resend_verification": "Resend verification email", + "verification_email_resent": "Verification email sent.", + "loading": "Loading…", + "member_since": "Member since", + "stat_games": "Games", + "stat_wins": "Wins", + "stat_losses": "Losses", + "stat_draws": "Draws", + "game_history_title": "Game History", + "no_games": "No games recorded yet.", + "col_room": "Room", + "col_started": "Started", + "col_ended": "Ended", + "col_outcome": "Outcome", + "col_detail": "Detail", + "prev_page": "← Prev", + "next_page": "Next →", + "page_label": "Page", + "view_link": "View", + "outcome_win": "win", + "outcome_loss": "loss", + "outcome_draw": "draw", + "players_header": "Players", + "col_player": "Player", + "score_header": "Score", + "game_ongoing": "ongoing", + "anonymous_player": "anonymous", + "started_label": "Started", + "ended_label": "Ended", + "room_detail_title": "Room", + "share_link": "Share this link to invite an opponent", + "copy_link": "Copy link", + "link_copied": "Copied!", + "scan_qr": "or scan the QR code", + "join_code_label": "Join by code", + "join_code_placeholder": "Room code", + "share_btn": "Share", + "nickname_modal_title": "Choose your nickname", + "nickname_modal_hint": "You will play as:", + "nickname_modal_play": "Play", + "nickname_modal_or": "or", + "nickname_modal_sign_in": "Sign in", + "nickname_modal_register": "Create account", + "new_game": "New game", + "language": "Language", + "delete_account_title": "Danger zone", + "delete_account_btn": "Delete my account", + "delete_account_warning": "This action is irreversible. Your account will be permanently deleted.", + "delete_account_confirm_label": "Type your username to confirm:", + "delete_account_confirm_btn": "Delete permanently", + "delete_account_mismatch": "Username does not match.", + "account_deleted": "Your account has been permanently deleted.", + "about": "About", + "legal": "Legal notices", + "free_mode_label": "Free play mode", + "free_mode_tooltip": "Select any checker and try to find a valid move yourself. If your move breaks a rule, you'll see an explanation.", + "reset_move": "Try again", + "err_invalid_move": "This move is not valid with the current dice", + "err_opponent_corner": "Cannot land on the opponent's rest corner", + "err_corner_needs_two": "Must enter and leave the rest corner with 2 checkers at once", + "err_corner_by_effect": "Must take the rest corner directly (by effect), not by force", + "err_exit_needs_all_in_last_jan": "All checkers must be in the last jan before exiting", + "err_exit_by_effect": "Must exit with exact dice value when possible (no overage)", + "err_exit_not_farthest": "With overage, must exit the checker farthest from the exit", + "err_opponent_can_fill_quarter": "Cannot play in a quarter the opponent can still fill", + "err_must_fill_quarter": "Must fill (or keep) a quarter when possible", + "err_must_play_all_dice": "Must play both dice when possible", + "err_must_play_stronger_die": "Must play the stronger die when only one can be played" +} diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json new file mode 100644 index 0000000..5889556 --- /dev/null +++ b/clients/web/locales/fr.json @@ -0,0 +1,165 @@ +{ + "room_name_placeholder": "Nom de la salle", + "create_room": "Inviter un adversaire", + "join_room": "Rejoindre", + "connecting": "Connexion en cours…", + "game_over": "Partie terminée", + "waiting_for_opponent": "En attente de l'adversaire…", + "your_turn_roll": "À votre tour — lancez les dés", + "hold_or_go": "Tenir ou s'en aller ?", + "select_move": "Déplacez une dame ({{ n }} sur 2)", + "your_turn": "Votre tour", + "opponent_turn": "Tour de l'adversaire", + "room_label": "Salle : {{ id }}", + "quit": "Quitter", + "roll_dice": "Lancer les dés", + "go": "S'en aller", + "empty_move": "Mouvement impossible", + "cancel_move": "Annuler le déplacement", + "debug_section": "Debug", + "take_snapshot": "Prendre un instantané", + "snapshot_copied": "Copié !", + "replay_snapshot": "Rejouer un instantané", + "replay_paste_hint": "Collez un instantané JSON pour démarrer une partie contre le bot depuis cette position.", + "replay_start": "Démarrer", + "replay_invalid_state": "Instantané invalide — collez le JSON copié par « Prendre un instantané ».", + "cancel": "Annuler", + "you_suffix": " (vous)", + "points_label": "Points", + "holes_label": "Trous", + "bredouille_title": "Peut faire bredouille", + "jan_double": "double", + "jan_simple": "simple", + "jan_filled_quarter": "Remplissage", + "jan_true_hit_small": "Battage à vrai (petit jan)", + "jan_true_hit_big": "Battage à vrai (grand jan)", + "jan_true_hit_corner": "Battage coin adverse", + "jan_first_exit": "Premier sorti", + "jan_six_tables": "Jan de six tables", + "jan_two_tables": "Jan de deux tables", + "jan_mezeas": "Jan de mézéas", + "jan_false_hit_small": "Battage à faux (petit jan)", + "jan_false_hit_big": "Battage à faux (grand jan)", + "jan_contre_two": "Contre jan de deux tables", + "jan_contre_mezeas": "Contre jan de mezeas", + "jan_helpless_man": "Dame impuissante", + "play_vs_bot": "Jouer contre le bot", + "vs_bot_label": "contre le bot", + "you_win": "Vous avez gagné !", + "opp_wins": "{{ name }} a gagné !", + "play_again": "Rejouer", + "after_opponent_roll": "L'adversaire a lancé les dés", + "after_opponent_go": "L'adversaire s'en va", + "after_opponent_move": "L'adversaire a joué — à vous", + "after_opponent_pre_game_roll": "L'adversaire a lancé — à vous", + "pre_game_roll_title": "Qui joue en premier ?", + "pre_game_roll_btn": "Lancer", + "pre_game_roll_tie": "Égalité ! Relancez", + "toss_you_first": "Vous commencez !", + "toss_opp_first": "{{ name }} commence !", + "pre_game_roll_your_die": "Votre dé", + "pre_game_roll_opp_die": "Dé adverse", + "continue_btn": "Continuer", + "scored_pts": "+{{ n }} pts", + "hole_made": "Trou ! {{ holes }}/12", + "bredouille_applied": "Bredouille !", + "hold": "Tenir", + "opp_scored_pts": "Adversaire +{{ n }} pts", + "opp_hole_made": "Trou adverse ! {{ holes }}/12", + "hint_move": "Cliquez une flêche soulignée pour déplacer", + "hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir", + "hint_continue": "Cliquez Continuer quand vous êtes prêt", + "anonymous_name": "Anonyme", + "login_failed": "Identifiant ou mot de passe incorrect.", + "sign_in": "Se connecter", + "sign_out": "Se déconnecter", + "create_account": "Créer un compte", + "account_title": "Compte", + "label_username": "Nom d'utilisateur", + "label_username_or_email": "Nom d'utilisateur ou email", + "label_password": "Mot de passe", + "label_confirm_password": "Confirmer le mot de passe", + "passwords_do_not_match": "Les mots de passe ne correspondent pas.", + "label_email": "Email", + "forgot_password_link": "Mot de passe oublié ?", + "forgot_password_title": "Réinitialiser le mot de passe", + "forgot_password_email_label": "Adresse email", + "forgot_password_submit": "Envoyer le lien", + "forgot_password_sent": "Si un compte avec cet email existe, un lien de réinitialisation a été envoyé à cette adresse.", + "reset_password_title": "Nouveau mot de passe", + "new_password_label": "Nouveau mot de passe", + "reset_password_submit": "Réinitialiser", + "reset_password_success": "Mot de passe réinitialisé. Vous pouvez maintenant vous connecter.", + "reset_password_invalid": "Ce lien est invalide ou a expiré.", + "verify_email_title": "Vérification de l'email", + "verify_email_checking": "Vérification en cours…", + "verify_email_success": "Votre email a été vérifié.", + "verify_email_invalid": "Ce lien de vérification est invalide ou a expiré.", + "email_not_verified_banner": "Un email de vérification vous a été envoyé — veuillez consulter votre boîte de réception.", + "resend_verification": "Renvoyer l'email de vérification", + "verification_email_resent": "Email de vérification envoyé.", + "loading": "Chargement…", + "member_since": "Membre depuis le", + "stat_games": "Parties", + "stat_wins": "Victoires", + "stat_losses": "Défaites", + "stat_draws": "Nuls", + "game_history_title": "Historique", + "no_games": "Aucune partie enregistrée.", + "col_room": "Salle", + "col_started": "Début", + "col_ended": "Fin", + "col_outcome": "Résultat", + "col_detail": "Détail", + "prev_page": "← Précédent", + "next_page": "Suivant →", + "page_label": "Page", + "view_link": "Voir", + "outcome_win": "victoire", + "outcome_loss": "défaite", + "outcome_draw": "nul", + "players_header": "Joueurs", + "col_player": "Joueur", + "score_header": "Score", + "game_ongoing": "en cours", + "anonymous_player": "anonyme", + "started_label": "Début", + "ended_label": "Fin", + "room_detail_title": "Salle", + "share_link": "Partagez ce lien pour inviter un adversaire", + "copy_link": "Copier le lien", + "link_copied": "Copié !", + "scan_qr": "ou scannez le QR code", + "share_btn": "Partager", + "nickname_modal_title": "Choisissez votre pseudo", + "nickname_modal_hint": "Vous jouerez sous le nom de :", + "nickname_modal_play": "Jouer", + "nickname_modal_or": "ou", + "nickname_modal_sign_in": "connectez-vous", + "nickname_modal_register": "Créer un compte", + "new_game": "Nouvelle partie", + "language": "Langue", + "delete_account_title": "Zone de danger", + "delete_account_btn": "Supprimer mon compte", + "delete_account_warning": "Cette action est irréversible. Votre compte sera définitivement supprimé.", + "delete_account_confirm_label": "Tapez votre nom d'utilisateur pour confirmer :", + "delete_account_confirm_btn": "Supprimer définitivement", + "delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.", + "account_deleted": "Votre compte a été définitivement supprimé.", + "about": "À propos", + "legal": "Mentions légales", + "free_mode_label": "Mode jeu libre", + "free_mode_tooltip": "Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même. Si votre coup enfreint une règle, une explication s'affichera.", + "reset_move": "Réessayer", + "err_invalid_move": "Ce coup n'est pas valide avec les dés actuels", + "err_opponent_corner": "Interdit de jouer sur le coin de repos adverse", + "err_corner_needs_two": "Le coin de repos doit être pris et quitté avec 2 dames simultanément", + "err_corner_by_effect": "Doit prendre le coin de repos par effet, non par puissance", + "err_exit_needs_all_in_last_jan": "Toutes les dames doivent être dans le jan de retour avant de sortir", + "err_exit_by_effect": "Doit sortir par effet (sans excédant) si c'est possible", + "err_exit_not_farthest": "Avec excédant, doit sortir la dame la plus éloignée de la sortie", + "err_opponent_can_fill_quarter": "Interdit de jouer dans un cadran que l'adversaire peut encore remplir", + "err_must_fill_quarter": "Doit remplir (ou conserver) un cadran si c'est possible", + "err_must_play_all_dice": "Doit jouer les deux dés si c'est possible", + "err_must_play_stronger_die": "Doit jouer le dé le plus fort quand un seul peut être joué" +} diff --git a/clients/web/pages/about/en.md b/clients/web/pages/about/en.md new file mode 100644 index 0000000..27a382e --- /dev/null +++ b/clients/web/pages/about/en.md @@ -0,0 +1,12 @@ +# About + +This application allows you to play [trictrac](https://en.wikipedia.org/wiki/Trictrac) against a friend online or locally against a bot. + +The source code is available at [github.com/mmai/trictrac](https://github.com/mmai/trictrac) +The application is self-hosted and runs on a simple Raspberry Pi. + +## Contact & bug Report + +For any questions, bug reports, or feedback, you can contact me at rhumbs@rhumbs.fr. + +If you encounter an issue during gameplay, you can copy the context of a game by clicking _Take snapshot_ then paste the resulting code into your message, specifying the expected behavior and the incorrect behavior you observed. diff --git a/clients/web/pages/about/fr.md b/clients/web/pages/about/fr.md new file mode 100644 index 0000000..1c3ec74 --- /dev/null +++ b/clients/web/pages/about/fr.md @@ -0,0 +1,12 @@ +# À propos + +Cette application vous permet de jouer au [trictrac](https://fr.wikipedia.org/wiki/Trictrac) contre un ami en ligne ou localement contre un bot. + +Le code source est disponible sur [github.com/mmai/trictrac](https://github.com/mmai/trictrac). +L'application est auto hébergée et tourne sur un simple Raspberry Pi. + +## Contact et rapport de bogue + +Pour toute question, rapport de bogue ou retour d'expérience, vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr. + +Si vous constatez une anomalie en cours de jeu, vous pouvez copier le contexte d'une partie en cliquant sur _Prendre un instantané_, puis coller le code obtenu dans le message, en précisant le comportement auquel vous vous attendiez, et le comportement erroné constaté. diff --git a/clients/web/pages/legal/en.md b/clients/web/pages/legal/en.md new file mode 100644 index 0000000..8f890f2 --- /dev/null +++ b/clients/web/pages/legal/en.md @@ -0,0 +1,26 @@ +# Legal Notices + +## Data and Privacy + +This site does not use third-party analytics or advertising trackers. + +If you create an account, your username, email address, and argon2-hashed password are stored in the database. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties. + +Game records (room codes, move history, outcomes) may be stored to display game history on your profile page. + +## Cookies and Sessions + +A session cookie is stored in your browser when you sign in. It is used solely to keep you authenticated and expires after 30 days of inactivity. + +## Contact + +The website is created by + +Henri Bourcereau\ +7 rue Lugeol\ +33000 Bordeaux\ +France + +It is hosted at the same address. + +You can contact me at rhumbs@rhumbs.fr diff --git a/clients/web/pages/legal/fr.md b/clients/web/pages/legal/fr.md new file mode 100644 index 0000000..442aac4 --- /dev/null +++ b/clients/web/pages/legal/fr.md @@ -0,0 +1,28 @@ +# Mentions légales + +## Données et vie privée + +Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires. + +Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés en base de données. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers. + +Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil. + +Vous pouvez supprimer votre compte et la totalité des données associées depuis votre page de profil. + +## Cookies et sessions + +Un cookie de session est stocké dans votre navigateur lorsque vous vous connectez. Il sert uniquement à maintenir votre authentification et expire après 30 jours d'inactivité. + +## Contact + +Le site est réalisé par + +Henri Bourcereau\ +7 rue Lugeol\ +33000 Bordeaux\ +France + +Il est hébergé à la même adresse. + +Vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr diff --git a/clients/web/pages/readme.txt b/clients/web/pages/readme.txt new file mode 100644 index 0000000..ea3df35 --- /dev/null +++ b/clients/web/pages/readme.txt @@ -0,0 +1 @@ +Sync this folder to the PAGES_DIR directory of the server running `relay-server`. diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs new file mode 100644 index 0000000..2452b67 --- /dev/null +++ b/clients/web/src/api.rs @@ -0,0 +1,327 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(debug_assertions)] +pub const HTTP_BASE: &str = "http://localhost:8080"; +#[cfg(not(debug_assertions))] +pub const HTTP_BASE: &str = ""; + +fn url(path: &str) -> String { + format!("{HTTP_BASE}{path}") +} + +// ── Response types ──────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Deserialize)] +pub struct MeResponse { + pub id: i64, + pub username: String, + #[serde(default)] + pub email_verified: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UserProfile { + pub id: i64, + pub username: String, + pub created_at: i64, + pub total_games: i64, + pub wins: i64, + pub losses: i64, + pub draws: i64, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GameSummary { + pub id: i64, + pub game_id: String, + pub room_code: String, + pub started_at: i64, + pub ended_at: Option, + pub result: Option, + pub outcome: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GamesResponse { + pub games: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Participant { + pub player_id: i64, + pub outcome: Option, + pub username: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GameDetail { + pub id: i64, + pub game_id: String, + pub room_code: String, + pub started_at: i64, + pub ended_at: Option, + pub result: Option, + pub participants: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PageContent { + pub title: String, + pub content: String, +} + +// ── Request bodies ──────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct RegisterBody<'a> { + pub username: &'a str, + pub email: &'a str, + pub password: &'a str, +} + +#[derive(Serialize)] +pub struct LoginBody<'a> { + pub username: &'a str, + pub password: &'a str, +} + +// ── Fetch helpers ───────────────────────────────────────────────────────────── + +pub async fn get_me() -> Result { + let resp = gloo_net::http::Request::get(&url("/auth/me")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn post_login(username: &str, password: &str) -> Result { + let body = LoginBody { username, password }; + let resp = gloo_net::http::Request::post(&url("/auth/login")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn post_register(username: &str, email: &str, password: &str) -> Result { + let body = RegisterBody { username, email, password }; + let resp = gloo_net::http::Request::post(&url("/auth/register")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 201 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn post_logout() -> Result<(), String> { + let resp = gloo_net::http::Request::post(&url("/auth/logout")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 204 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn delete_account() -> Result<(), String> { + let resp = gloo_net::http::Request::delete(&url("/auth/account")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 204 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_user_profile(username: &str) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_user_games(username: &str, page: i64) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!( + "/users/{username}/games?page={page}&per_page=20" + ))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_game_detail(id: i64) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_verify_email(token: &str) -> Result<(), String> { + let resp = gloo_net::http::Request::get(&url(&format!("/auth/verify-email?token={token}"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + Ok(()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn post_resend_verification() -> Result<(), String> { + let resp = gloo_net::http::Request::post(&url("/auth/resend-verification")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn post_forgot_password(email: &str) -> Result<(), String> { + let body = serde_json::json!({ "email": email }); + let resp = gloo_net::http::Request::post(&url("/auth/forgot-password")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(), String> { + let body = serde_json::json!({ "token": token, "new_password": new_password }); + let resp = gloo_net::http::Request::post(&url("/auth/reset-password")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + Ok(()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn get_page(slug: &str, lang: &str) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/pages/{slug}?lang={lang}"))) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + +/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`. +/// `Default` passes no options (browser default: full date + time). +pub struct DateFormatOptions { + /// "full" | "long" | "medium" | "short" — omit to suppress date part + pub date_style: Option<&'static str>, + /// "full" | "long" | "medium" | "short" — omit to suppress time part + pub time_style: Option<&'static str>, +} + +impl Default for DateFormatOptions { + fn default() -> Self { + Self { date_style: None, time_style: None } + } +} + +impl DateFormatOptions { + pub fn date_only() -> Self { + Self { date_style: Some("short"), time_style: None } + } + + pub fn time_only() -> Self { + Self { date_style: None, time_style: Some("short") } + } + + pub fn date_time() -> Self { + Self { date_style: Some("short"), time_style: Some("short") } + } + + fn to_js_value(&self) -> wasm_bindgen::JsValue { + if self.date_style.is_none() && self.time_style.is_none() { + return wasm_bindgen::JsValue::UNDEFINED; + } + let obj = js_sys::Object::new(); + if let Some(v) = self.date_style { + let _ = js_sys::Reflect::set(&obj, &"dateStyle".into(), &v.into()); + } + if let Some(v) = self.time_style { + let _ = js_sys::Reflect::set(&obj, &"timeStyle".into(), &v.into()); + } + obj.into() + } +} + +pub fn format_ts(ts: i64, locale: &str, opts: &DateFormatOptions) -> String { + let ms = (ts * 1000) as f64; + let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms)); + date.to_locale_string(locale, &opts.to_js_value()) + .as_string() + .unwrap_or_default() +} diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs new file mode 100644 index 0000000..ba90a54 --- /dev/null +++ b/clients/web/src/app.rs @@ -0,0 +1,831 @@ +use futures::channel::mpsc; +use futures::{FutureExt, StreamExt}; +use gloo_storage::{LocalStorage, Storage}; +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos_router::components::{Route, Router, Routes, A}; +use leptos_router::hooks::use_location; +use leptos_router::path; +use serde::{Deserialize, Serialize}; + +use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; +use backbone_lib::traits::ViewStateUpdate; + +use crate::api; +use crate::game::components::{ConnectingScreen, GameScreen}; +use crate::game::session::{ + compute_last_moves, patch_player_name, push_or_show, run_local_bot_game, + run_local_bot_game_with_backend, +}; +use crate::game::trictrac::backend::TrictracBackend; +use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; +use crate::i18n::*; +use crate::portal::{ + account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage, + game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage, + reset_password::ResetPasswordPage, verify_email::VerifyEmailPage, +}; +use trictrac_store::CheckerMove; + +use std::collections::VecDeque; + +/// Newtype wrappers so context lookup can distinguish signals of the same inner type. +#[derive(Clone, Copy)] +pub(crate) struct AnonNickname(pub RwSignal>); +#[derive(Clone, Copy)] +pub(crate) struct AuthEmailVerified(pub RwSignal); +/// One-shot message shown as a top banner and auto-dismissed after a few seconds. +#[derive(Clone, Copy)] +pub(crate) struct FlashMessage(pub RwSignal>); + +fn relay_url() -> String { + #[cfg(debug_assertions)] + { + "ws://localhost:8080/ws".to_string() + } + #[cfg(not(debug_assertions))] + { + let location = web_sys::window().and_then(|w| Some(w.location())).unwrap(); + let protocol = location.protocol().unwrap_or_default(); + let host = location.host().unwrap_or_default(); + let ws_protocol = if protocol == "https:" { "wss" } else { "ws" }; + format!("{ws_protocol}://{host}/ws") + } +} +const GAME_ID: &str = "trictrac"; +const STORAGE_KEY: &str = "trictrac_session"; +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The state the UI needs to render the game screen. +#[derive(Clone, PartialEq)] +pub struct GameUiState { + pub view_state: ViewState, + /// 0 = host, 1 = guest + pub player_id: u16, + pub room_id: String, + pub is_bot_game: bool, + pub waiting_for_confirm: bool, + pub pause_reason: Option, + pub my_scored_event: Option, + pub opp_scored_event: Option, + pub last_moves: Option<(CheckerMove, CheckerMove)>, + /// True on the echo screen state set alongside a pending item — suppresses dice + /// roll animation and sound since they already played on the pending screen. + pub suppress_dice_anim: bool, +} + +/// Reason the UI is paused waiting for the player to click Continue. +#[derive(Clone, Debug, PartialEq)] +pub enum PauseReason { + AfterOpponentRoll, + AfterOpponentGo, + AfterOpponentMove, + AfterOpponentPreGameRoll, +} + +/// Which screen is currently shown (used to toggle game overlay). +#[derive(Clone, PartialEq)] +pub enum Screen { + Login { error: Option }, + Connecting, + Playing(GameUiState), +} + +/// Commands sent from UI event handlers into the network task. +pub enum NetCommand { + CreateRoom { + room: String, + }, + JoinRoom { + room: String, + }, + Reconnect { + relay_url: String, + game_id: String, + room_id: String, + token: u64, + host_state: Option>, + }, + PlayVsBot, + /// Start a bot game with the board/score position from a previously taken snapshot. + ReplaySnapshot(ViewState), + Action(PlayerAction), + Disconnect, +} + +#[derive(Serialize, Deserialize)] +struct StoredSession { + relay_url: String, + game_id: String, + room_id: String, + token: u64, + #[serde(default)] + is_host: bool, + #[serde(default)] + view_state: Option, +} + +fn save_session(session: &StoredSession) { + LocalStorage::set(STORAGE_KEY, session).ok(); +} + +fn load_session() -> Option { + LocalStorage::get::(STORAGE_KEY).ok() +} + +fn clear_session() { + LocalStorage::delete(STORAGE_KEY); +} + +async fn submit_game_result(room_code: String, game_state: ViewState) { + let [score_pl1, score_pl2] = game_state.scores; + let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes); + let outcomes = if score_pl1.holes < score_pl2.holes { + [("0", "loss"), ("1", "win")] + } else if score_pl2.holes < score_pl1.holes { + [("0", "win"), ("1", "loss")] + } else { + [("0", "draw"), ("1", "draw")] + }; + let body = serde_json::json!({ + "room_code": room_code, + "game_id": GAME_ID, + "result": result_str, + "outcomes": std::collections::HashMap::from(outcomes), + }); + let _ = gloo_net::http::Request::post(&format!("{}/games/result", api::HTTP_BASE)) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .unwrap() + .send() + .await; +} + +#[component] +pub fn App() -> impl IntoView { + let i18n = use_i18n(); + let stored = load_session(); + let initial_screen = if stored.is_some() { + Screen::Connecting + } else { + Screen::Login { error: None } + }; + let screen: RwSignal = RwSignal::new(initial_screen); + provide_context(screen); + + // Auth: fetch once on load; shared by nav + game + portal components. + let auth_username: RwSignal> = RwSignal::new(None); + let auth_email_verified: RwSignal = RwSignal::new(false); + provide_context(auth_username); + provide_context(AuthEmailVerified(auth_email_verified)); + // Set to true once get_me resolves (success or failure) so lobby can + // decide immediately whether to show the nickname modal. + let auth_loaded: RwSignal = RwSignal::new(false); + provide_context(auth_loaded); + // Nickname chosen by an anonymous player; used instead of "Anonymous". + let anon_nickname: RwSignal> = RwSignal::new(None); + provide_context(AnonNickname(anon_nickname)); + let flash: RwSignal> = RwSignal::new(None); + provide_context(FlashMessage(flash)); + spawn_local(async move { + if let Ok(me) = api::get_me().await { + auth_username.set(Some(me.username)); + auth_email_verified.set(me.email_verified); + } + auth_loaded.set(true); + }); + + let (cmd_tx, mut cmd_rx) = mpsc::unbounded::(); + let pending: RwSignal> = RwSignal::new(VecDeque::new()); + provide_context(pending); + provide_context(cmd_tx.clone()); + + if let Some(s) = stored { + let host_state = s + .view_state + .as_ref() + .and_then(|vs| serde_json::to_vec(vs).ok()); + cmd_tx + .unbounded_send(NetCommand::Reconnect { + relay_url: s.relay_url, + game_id: s.game_id, + room_id: s.room_id, + token: s.token, + host_state, + }) + .ok(); + } + + spawn_local(async move { + loop { + let mut snapshot_init: Option = None; + let remote_config: Option<(RoomConfig, bool)> = loop { + match cmd_rx.next().await { + Some(NetCommand::PlayVsBot) => break None, + Some(NetCommand::ReplaySnapshot(vs)) => { + snapshot_init = Some(vs); + break None; + } + Some(NetCommand::CreateRoom { room }) => { + break Some(( + RoomConfig { + relay_url: relay_url(), + game_id: GAME_ID.to_string(), + room_id: room, + rule_variation: 0, + role: RoomRole::Create, + reconnect_token: None, + host_state: None, + }, + false, + )); + } + Some(NetCommand::JoinRoom { room }) => { + break Some(( + RoomConfig { + relay_url: relay_url(), + game_id: GAME_ID.to_string(), + room_id: room, + rule_variation: 0, + role: RoomRole::Join, + reconnect_token: None, + host_state: None, + }, + false, + )); + } + Some(NetCommand::Reconnect { + relay_url, + game_id, + room_id, + token, + host_state, + }) => { + break Some(( + RoomConfig { + relay_url, + game_id, + room_id, + rule_variation: 0, + role: RoomRole::Join, + reconnect_token: Some(token), + host_state, + }, + true, + )); + } + _ => {} + } + }; + + if remote_config.is_none() { + let player_name = auth_username + .get_untracked() + .or_else(|| anon_nickname.get_untracked()) + .unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string())); + loop { + let restart = match snapshot_init.take() { + Some(vs) => { + let backend = TrictracBackend::from_view_state(vs, &player_name); + run_local_bot_game_with_backend( + screen, + &mut cmd_rx, + pending, + player_name.clone(), + backend, + ) + .await + } + None => { + run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()) + .await + } + }; + if !restart { + break; + } + } + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: None }); + continue; + } + let (config, is_reconnect) = remote_config.unwrap(); + + screen.set(Screen::Connecting); + + let room_id_for_storage = config.room_id.clone(); + let mut session: GameSession = + match GameSession::connect::(config).await { + Ok(s) => s, + Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => { + if is_reconnect { + clear_session(); + } + screen.set(Screen::Login { error: Some(e) }); + continue; + } + }; + + if !session.is_host { + save_session(&StoredSession { + relay_url: relay_url(), + game_id: GAME_ID.to_string(), + room_id: room_id_for_storage.clone(), + token: session.reconnect_token, + is_host: false, + view_state: None, + }); + } + + let is_host = session.is_host; + let player_id = session.player_id; + let reconnect_token = session.reconnect_token; + let my_name = auth_username + .get_untracked() + .or_else(|| anon_nickname.get_untracked()) + .unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string()); + // Announce our name to the host backend so it can broadcast it to + // the opponent. Done once immediately after connecting. + session.send_action(PlayerAction::SetName(my_name.clone())); + let mut vs = ViewState::default_with_names("", ""); + let mut result_submitted = false; + + loop { + futures::select! { + cmd = cmd_rx.next().fuse() => match cmd { + Some(NetCommand::Action(action)) => { + session.send_action(action); + } + _ => { + clear_session(); + session.disconnect(); + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: None }); + break; + } + }, + event = session.next_event().fuse() => match event { + Some(SessionEvent::Update(u)) => { + let prev_vs = vs.clone(); + match u { + ViewStateUpdate::Full(state) => vs = state, + ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta), + } + patch_player_name(&mut vs, player_id, &my_name); + + if is_host && !result_submitted && vs.stage == SerStage::Ended { + result_submitted = true; + let room = room_id_for_storage.clone(); + let gs = vs.clone(); + spawn_local(submit_game_result(room, gs)); + } + + if is_host { + save_session(&StoredSession { + relay_url: relay_url(), + game_id: GAME_ID.to_string(), + room_id: room_id_for_storage.clone(), + token: reconnect_token, + is_host: true, + view_state: Some(vs.clone()), + }); + } + let is_own_move = prev_vs.active_mp_player == Some(player_id); + push_or_show( + &prev_vs, + GameUiState { + view_state: vs.clone(), + player_id, + room_id: room_id_for_storage.clone(), + is_bot_game: false, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: None, + opp_scored_event: None, + last_moves: compute_last_moves(&prev_vs, &vs, is_own_move), + suppress_dice_anim: false, + }, + pending, + screen, + ); + } + Some(SessionEvent::Disconnected(reason)) => { + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: reason }); + break; + } + None => { + pending.update(|q| q.clear()); + screen.set(Screen::Login { error: None }); + break; + } + } + } + } + } + }); + + view! { + + + +
+ "Page not found."

}> + + + + + + + + +
+
+ + +
+ } +} + +/// Fixed top banner that shows a flash message and auto-dismisses after 5 seconds. +#[component] +fn FlashBanner() -> impl IntoView { + let flash = use_context::() + .expect("FlashMessage context not found") + .0; + + Effect::new(move |_| { + if flash.get().is_some() { + spawn_local(async move { + gloo_timers::future::TimeoutFuture::new(5_000).await; + flash.set(None); + }); + } + }); + + move || { + flash.get().map(|msg| { + view! { +
+ { msg } + +
+ } + }) + } +} + +/// Renders the full-screen game overlay, but only when the current route is "/". +/// This lets the user navigate to profile/account pages while a game is running. +#[component] +fn GameOverlay( + pending: RwSignal>, + screen: RwSignal, +) -> impl IntoView { + let location = use_location(); + + // Memoize the front of the pending queue so that pushing a new item to the back + // does not re-mount GameScreen (and replay dice animation/sound) when the displayed + // state (the front) hasn't changed. + let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned())); + + move || { + if location.pathname.get() != "/" { + return view! {}.into_any(); + } + if let Some(state) = pending_front.get() { + return view! { +
+ } + .into_any(); + } + match screen.get() { + Screen::Playing(state) => view! { +
+ } + .into_any(), + Screen::Connecting => view! { +
+ } + .into_any(), + _ => view! {}.into_any(), + } + } +} + +/// Persistent hamburger button + left sidebar — visible on every page. +#[component] +fn SiteHamburger() -> impl IntoView { + let i18n = use_i18n(); + let auth_username = + use_context::>>().unwrap_or_else(|| RwSignal::new(None)); + let screen = use_context::>().expect("Screen context not found"); + let cmd_tx = use_context::>() + .expect("cmd_tx not found in context"); + + let sidebar_open = RwSignal::new(false); + let snapshot_copied = RwSignal::new(false); + let replay_open = RwSignal::new(false); + let replay_text = RwSignal::new(String::new()); + let replay_error = RwSignal::new(false); + + let cmd_tx_newgame = cmd_tx.clone(); + let cmd_tx_snapshot = cmd_tx.clone(); + let cmd_tx_replay = cmd_tx.clone(); + + view! { + // ── Hamburger button (☰ → ✕ animation) ─────────────────────────────── + + + // ── Left sidebar ────────────────────────────────────────────────────── +
+ +
+ "Trictrac" + +
+ + +
+
+ + // Language switcher + //
+ // + // + // + // {t!(i18n, language)} + //
+ // + // + //
+ //
+ +
+ + // Auth + {move || match auth_username.get() { + Some(u) => { + let href = format!("/profile/{u}"); + view! { +
+ + + + + + {u} + +
+ + }.into_any() + }, + None => view! { + + }.into_any(), + }} + + +
+ + // ── Replay snapshot modal ───────────────────────────────────────────── +
+
+

{t!(i18n, replay_snapshot)}

+

+ {t!(i18n, replay_paste_hint)} +

+
Anonyme (vous)
6/12
Bot
2/12
+4 pts
Battage à vrai (petit jan)simple×1+4
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
11
12
11
10
9
8
7
6
5
4
3
2
1
10
grand jan
petit jan
Déplacez une dame (1 sur 2)

Cliquez une flêche soulignée pour déplacer

diff --git a/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style-e86a95086579e325.css b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style-e86a95086579e325.css new file mode 100644 index 0000000..24df8c0 --- /dev/null +++ b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style-e86a95086579e325.css @@ -0,0 +1,2305 @@ +/* ── Google Fonts ───────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-blue: #e5eadc; + --field-blue-light: #1a4f72; + --field-brown: #f2dfa0; + --field-brown-light: #6a2810; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* -- svg icons -- */ +.icon { + width: 1.2em; + height: 1.2em; + color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; +} + +/* ── Site navigation ─────────────────────────────────────────────── */ +.site-nav { + background: var(--board-rail); + border-bottom: 2px solid var(--ui-gold-dark); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + flex-shrink: 0; +} + +.site-nav-brand { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-gold); + text-decoration: none; + letter-spacing: 0.1em; +} +.site-nav-brand:hover { color: #e0b840; } + +.site-nav-spacer { flex: 1; } + +.site-nav a { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s, color 0.15s; +} +.site-nav a:hover { opacity: 1; } + +.site-nav-btn { + padding: 0.3rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.03em; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 4px; + background: transparent; + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.site-nav-btn:hover { + background: rgba(200,164,72,0.12); + border-color: var(--ui-gold); +} + +/* ── Portal main content area ────────────────────────────────────── */ +.portal-main { + flex: 1; + max-width: 900px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.portal-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */ + /* border: 1px solid rgba(200,164,72,0.3); */ + /* border-top: 3px solid var(--ui-gold-dark); */ + padding: 1.75rem 2rem; + margin-bottom: 1.5rem; +} + +.portal-card h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.25rem; +} +.portal-card h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + color: var(--ui-ink); + margin-bottom: 0.75rem; +} + +.portal-meta { + font-size: 0.85rem; + color: #665544; + margin-bottom: 1.5rem; + font-family: var(--font-ui); +} + +/* ── Stats grid ──────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} +.stat-box { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.28); + border-radius: 6px; + padding: 1rem; + text-align: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} +.stat-box .value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + color: var(--ui-gold-dark); +} +.stat-box .label { + font-size: 0.78rem; + color: #665544; + margin-top: 0.2rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Tables ──────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-family: var(--font-ui); +} +th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid rgba(200,164,72,0.4); + color: #665544; + font-weight: 500; + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + color: var(--ui-ink); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(200,164,72,0.05); } + +a { color: var(--ui-gold-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Outcome classes ─────────────────────────────────────────────── */ +.outcome-win { color: var(--ui-green-accent); font-weight: 600; } +.outcome-loss { color: var(--ui-red-accent); font-weight: 600; } +.outcome-draw { color: #c07020; font-weight: 600; } + +/* ── Portal tabs ─────────────────────────────────────────────────── */ +.portal-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(200,164,72,0.3); +} +.portal-tab-btn { + padding: 0.55rem 1.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: transparent; + border: none; + cursor: pointer; + color: #665544; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} +.portal-tab-btn.active { + color: var(--ui-ink); + border-bottom-color: var(--ui-gold-dark); + font-weight: 500; +} + +/* ── Portal form ─────────────────────────────────────────────────── */ +.portal-label { + display: block; + font-size: 0.82rem; + color: #665544; + margin-bottom: 0.3rem; + letter-spacing: 0.03em; +} +.portal-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.35); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + margin-bottom: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.portal-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.18); +} +.portal-submit-btn { + padding: 0.6rem 2rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + border: none; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.25); + transition: opacity 0.15s; +} +.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.portal-submit-btn:not(:disabled):hover { opacity: 0.9; } + +.portal-page-btn { + padding: 0.35rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + background: var(--board-rail); + color: var(--ui-parchment); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} +.portal-page-btn:hover { opacity: 1; } + +.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; } +.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; } +.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; } +.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; } + +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.portal-link { + color: var(--ui-gold); + text-decoration: none; + font-size: 0.875rem; +} +.portal-link:hover { text-decoration: underline; } + +.portal-verification-banner { + background: rgba(200,164,72,0.08); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.portal-verification-banner p { + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +/* ── Share URL row (lobby waiting card + game top bar) ──────────── */ +.share-url-row { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0,0,0,0.18); + border: 1px solid rgba(200,164,72,0.25); + border-radius: 5px; + padding: 0.4rem 0.6rem; +} +.share-url-text { + flex: 1; + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(242,232,208,0.75); + word-break: break-all; + user-select: all; +} +.share-copy-btn { + flex-shrink: 0; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.2rem 0.6rem; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 3px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} +.share-copy-btn:hover { background: rgba(200,164,72,0.22); } + +/* ── QR code container ───────────────────────────────────────────── */ +.qr-container { + width: 160px; + height: 160px; + margin: 0 auto; + border-radius: 4px; + overflow: hidden; +} +.qr-container svg { width: 100%; height: 100%; display: block; } + +/* ── Share popover (in-game top bar) ─────────────────────────────── */ +.share-popover { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(200,164,72,0.2); + border-radius: 6px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.share-popover .qr-container { width: 120px; height: 120px; } +.share-popover-label { + font-size: 0.75rem; + color: rgba(242,232,208,0.6); + text-align: center; + margin: 0; +} + +/* ── Game overlay (full-screen, covers portal during play) ───────── */ +.game-overlay { + position: fixed; + inset: 0; + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + z-index: 200; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1.5rem; + overflow-y: auto; +} + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + */ + /* border-top: 3px solid var(--ui-gold-dark); */ + width: 340px; + margin-top: 5vh; + overflow: hidden; +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +.playing-as { + font-size: 0.75rem; + color: rgba(242,232,208,0.7); + font-family: var(--font-ui); +} +.playing-as strong { color: rgba(242,232,208,0.9); } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +.board-and-panel { + position: relative; +} + +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; +} + +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; +} + +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; +} + +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; + cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; +} +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } + +/* ── Inner scoring card ─────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 320px; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) ──────────────────────────────────────────────── */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +.field { + --fc: var(--field-ivory); + width: 60px; + height: 180px; + background: transparent; + isolation: isolate; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours ────────────────────────────────── */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +.board-quarter .field.zone-opponent:nth-child(odd), +.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-opponent:nth-child(even), +.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); } + +/* ── Point indicator: first N fields reflect each player's score & bredouille */ +.board-quarter .field.zone-petit.point-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ +.exit-btn { + pointer-events: none; + opacity: 0.3; + transition: opacity 0.2s, transform 0.15s; +} +.exit-btn.exit-active { + pointer-events: auto; + cursor: pointer; + opacity: 1; + animation: exit-btn-pulse 1.4s ease-in-out infinite; +} +.exit-btn.exit-active:hover { + transform: scale(1.1); +} +@keyframes exit-btn-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } + 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } +} + +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +.field.clickable { + cursor: pointer; +} +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} +.field.selected { + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); +} + +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; } +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker slide animation (§4a) ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + min-height: 2rem; +} + +/* ── Free-play mode ─────────────────────────────────────────────────────── */ +.free-mode-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-ui); + font-size: 0.78rem; + color: #887766; + cursor: pointer; + user-select: none; + padding-top: 0.1rem; +} +.free-mode-toggle input[type="checkbox"] { + accent-color: var(--ui-gold); + cursor: pointer; + width: 0.85rem; + height: 0.85rem; +} +.free-mode-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 1px solid #a89880; + font-size: 0.65rem; + font-style: normal; + color: #a89880; + cursor: help; + flex-shrink: 0; +} + +.free-mode-error { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(180, 60, 30, 0.12); + border: 1px solid rgba(180, 60, 30, 0.4); + border-radius: 4px; + padding: 0.4rem 0.75rem; + width: 100%; + box-sizing: border-box; +} +.free-mode-error-msg { + flex: 1; + font-family: var(--font-ui); + font-size: 0.85rem; + color: #8b2000; + font-style: italic; +} + +/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ +.ceremony-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} + +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: row; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; + cursor: pointer; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} diff --git a/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css new file mode 100644 index 0000000..24df8c0 --- /dev/null +++ b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css @@ -0,0 +1,2305 @@ +/* ── Google Fonts ───────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-blue: #e5eadc; + --field-blue-light: #1a4f72; + --field-brown: #f2dfa0; + --field-brown-light: #6a2810; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* -- svg icons -- */ +.icon { + width: 1.2em; + height: 1.2em; + color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; +} + +/* ── Site navigation ─────────────────────────────────────────────── */ +.site-nav { + background: var(--board-rail); + border-bottom: 2px solid var(--ui-gold-dark); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + flex-shrink: 0; +} + +.site-nav-brand { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-gold); + text-decoration: none; + letter-spacing: 0.1em; +} +.site-nav-brand:hover { color: #e0b840; } + +.site-nav-spacer { flex: 1; } + +.site-nav a { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s, color 0.15s; +} +.site-nav a:hover { opacity: 1; } + +.site-nav-btn { + padding: 0.3rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.03em; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 4px; + background: transparent; + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.site-nav-btn:hover { + background: rgba(200,164,72,0.12); + border-color: var(--ui-gold); +} + +/* ── Portal main content area ────────────────────────────────────── */ +.portal-main { + flex: 1; + max-width: 900px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.portal-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */ + /* border: 1px solid rgba(200,164,72,0.3); */ + /* border-top: 3px solid var(--ui-gold-dark); */ + padding: 1.75rem 2rem; + margin-bottom: 1.5rem; +} + +.portal-card h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.25rem; +} +.portal-card h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + color: var(--ui-ink); + margin-bottom: 0.75rem; +} + +.portal-meta { + font-size: 0.85rem; + color: #665544; + margin-bottom: 1.5rem; + font-family: var(--font-ui); +} + +/* ── Stats grid ──────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} +.stat-box { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.28); + border-radius: 6px; + padding: 1rem; + text-align: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} +.stat-box .value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + color: var(--ui-gold-dark); +} +.stat-box .label { + font-size: 0.78rem; + color: #665544; + margin-top: 0.2rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Tables ──────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-family: var(--font-ui); +} +th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid rgba(200,164,72,0.4); + color: #665544; + font-weight: 500; + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + color: var(--ui-ink); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(200,164,72,0.05); } + +a { color: var(--ui-gold-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Outcome classes ─────────────────────────────────────────────── */ +.outcome-win { color: var(--ui-green-accent); font-weight: 600; } +.outcome-loss { color: var(--ui-red-accent); font-weight: 600; } +.outcome-draw { color: #c07020; font-weight: 600; } + +/* ── Portal tabs ─────────────────────────────────────────────────── */ +.portal-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(200,164,72,0.3); +} +.portal-tab-btn { + padding: 0.55rem 1.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: transparent; + border: none; + cursor: pointer; + color: #665544; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} +.portal-tab-btn.active { + color: var(--ui-ink); + border-bottom-color: var(--ui-gold-dark); + font-weight: 500; +} + +/* ── Portal form ─────────────────────────────────────────────────── */ +.portal-label { + display: block; + font-size: 0.82rem; + color: #665544; + margin-bottom: 0.3rem; + letter-spacing: 0.03em; +} +.portal-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.35); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + margin-bottom: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.portal-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.18); +} +.portal-submit-btn { + padding: 0.6rem 2rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + border: none; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.25); + transition: opacity 0.15s; +} +.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.portal-submit-btn:not(:disabled):hover { opacity: 0.9; } + +.portal-page-btn { + padding: 0.35rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + background: var(--board-rail); + color: var(--ui-parchment); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} +.portal-page-btn:hover { opacity: 1; } + +.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; } +.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; } +.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; } +.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; } + +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.portal-link { + color: var(--ui-gold); + text-decoration: none; + font-size: 0.875rem; +} +.portal-link:hover { text-decoration: underline; } + +.portal-verification-banner { + background: rgba(200,164,72,0.08); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.portal-verification-banner p { + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +/* ── Share URL row (lobby waiting card + game top bar) ──────────── */ +.share-url-row { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0,0,0,0.18); + border: 1px solid rgba(200,164,72,0.25); + border-radius: 5px; + padding: 0.4rem 0.6rem; +} +.share-url-text { + flex: 1; + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(242,232,208,0.75); + word-break: break-all; + user-select: all; +} +.share-copy-btn { + flex-shrink: 0; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.2rem 0.6rem; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 3px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} +.share-copy-btn:hover { background: rgba(200,164,72,0.22); } + +/* ── QR code container ───────────────────────────────────────────── */ +.qr-container { + width: 160px; + height: 160px; + margin: 0 auto; + border-radius: 4px; + overflow: hidden; +} +.qr-container svg { width: 100%; height: 100%; display: block; } + +/* ── Share popover (in-game top bar) ─────────────────────────────── */ +.share-popover { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(200,164,72,0.2); + border-radius: 6px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.share-popover .qr-container { width: 120px; height: 120px; } +.share-popover-label { + font-size: 0.75rem; + color: rgba(242,232,208,0.6); + text-align: center; + margin: 0; +} + +/* ── Game overlay (full-screen, covers portal during play) ───────── */ +.game-overlay { + position: fixed; + inset: 0; + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + z-index: 200; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1.5rem; + overflow-y: auto; +} + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + */ + /* border-top: 3px solid var(--ui-gold-dark); */ + width: 340px; + margin-top: 5vh; + overflow: hidden; +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +.playing-as { + font-size: 0.75rem; + color: rgba(242,232,208,0.7); + font-family: var(--font-ui); +} +.playing-as strong { color: rgba(242,232,208,0.9); } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +.board-and-panel { + position: relative; +} + +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; +} + +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; +} + +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; +} + +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; + cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; +} +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } + +/* ── Inner scoring card ─────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 320px; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) ──────────────────────────────────────────────── */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +.field { + --fc: var(--field-ivory); + width: 60px; + height: 180px; + background: transparent; + isolation: isolate; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours ────────────────────────────────── */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +.board-quarter .field.zone-opponent:nth-child(odd), +.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-opponent:nth-child(even), +.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); } + +/* ── Point indicator: first N fields reflect each player's score & bredouille */ +.board-quarter .field.zone-petit.point-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ +.exit-btn { + pointer-events: none; + opacity: 0.3; + transition: opacity 0.2s, transform 0.15s; +} +.exit-btn.exit-active { + pointer-events: auto; + cursor: pointer; + opacity: 1; + animation: exit-btn-pulse 1.4s ease-in-out infinite; +} +.exit-btn.exit-active:hover { + transform: scale(1.1); +} +@keyframes exit-btn-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } + 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } +} + +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +.field.clickable { + cursor: pointer; +} +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} +.field.selected { + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); +} + +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; } +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker slide animation (§4a) ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + min-height: 2rem; +} + +/* ── Free-play mode ─────────────────────────────────────────────────────── */ +.free-mode-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-ui); + font-size: 0.78rem; + color: #887766; + cursor: pointer; + user-select: none; + padding-top: 0.1rem; +} +.free-mode-toggle input[type="checkbox"] { + accent-color: var(--ui-gold); + cursor: pointer; + width: 0.85rem; + height: 0.85rem; +} +.free-mode-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 1px solid #a89880; + font-size: 0.65rem; + font-style: normal; + color: #a89880; + cursor: help; + flex-shrink: 0; +} + +.free-mode-error { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(180, 60, 30, 0.12); + border: 1px solid rgba(180, 60, 30, 0.4); + border-radius: 4px; + padding: 0.4rem 0.75rem; + width: 100%; + box-sizing: border-box; +} +.free-mode-error-msg { + flex: 1; + font-family: var(--font-ui); + font-size: 0.85rem; + color: #8b2000; + font-style: italic; +} + +/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ +.ceremony-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} + +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: row; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; + cursor: pointer; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} diff --git a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html new file mode 100644 index 0000000..4ac9d36 --- /dev/null +++ b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html @@ -0,0 +1,153 @@ + + + + + + Trictrac + + + + + + + +
Anonymous (you)
6/12
Trictrac
6/12
Bot
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
8
12
11
10
9
8
7
6
5
4
3
2
1
11
grand jan
petit jan
Move a checker (1 of 2)

Click a highlighted field to move a checker

Cannot play in a quarter the opponent can still fill
+2 pts
True hit (big jan)simple×1+2
diff --git a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169_files/style-b42680e382d603c7.css b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169_files/style-b42680e382d603c7.css new file mode 100644 index 0000000..58db762 --- /dev/null +++ b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169_files/style-b42680e382d603c7.css @@ -0,0 +1,2528 @@ +/* ── Google Fonts ───────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-blue: #e5eadc; + --field-blue-light: #1a4f72; + --field-brown: #f2dfa0; + --field-brown-light: #6a2810; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* -- svg icons -- */ +.icon { + width: 1.2em; + height: 1.2em; + color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; +} + +/* ── Site navigation ─────────────────────────────────────────────── */ +.site-nav { + background: var(--board-rail); + border-bottom: 2px solid var(--ui-gold-dark); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + flex-shrink: 0; +} + +.site-nav-brand { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-gold); + text-decoration: none; + letter-spacing: 0.1em; +} +.site-nav-brand:hover { color: #e0b840; } + +.site-nav-spacer { flex: 1; } + +.site-nav a { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s, color 0.15s; +} +.site-nav a:hover { opacity: 1; } + +.site-nav-btn { + padding: 0.3rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.03em; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 4px; + background: transparent; + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.site-nav-btn:hover { + background: rgba(200,164,72,0.12); + border-color: var(--ui-gold); +} + +/* ── Portal main content area ────────────────────────────────────── */ +.portal-main { + flex: 1; + max-width: 900px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.portal-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */ + /* border: 1px solid rgba(200,164,72,0.3); */ + /* border-top: 3px solid var(--ui-gold-dark); */ + padding: 1.75rem 2rem; + margin-bottom: 1.5rem; +} + +.portal-card h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.25rem; +} +.portal-card h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + color: var(--ui-ink); + margin-bottom: 0.75rem; +} + +.portal-meta { + font-size: 0.85rem; + color: #665544; + margin-bottom: 1.5rem; + font-family: var(--font-ui); +} + +/* ── Stats grid ──────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} +.stat-box { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.28); + border-radius: 6px; + padding: 1rem; + text-align: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} +.stat-box .value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + color: var(--ui-gold-dark); +} +.stat-box .label { + font-size: 0.78rem; + color: #665544; + margin-top: 0.2rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Tables ──────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-family: var(--font-ui); +} +th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid rgba(200,164,72,0.4); + color: #665544; + font-weight: 500; + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + color: var(--ui-ink); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(200,164,72,0.05); } + +a { color: var(--ui-gold-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Outcome classes ─────────────────────────────────────────────── */ +.outcome-win { color: var(--ui-green-accent); font-weight: 600; } +.outcome-loss { color: var(--ui-red-accent); font-weight: 600; } +.outcome-draw { color: #c07020; font-weight: 600; } + +/* ── Portal tabs ─────────────────────────────────────────────────── */ +.portal-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(200,164,72,0.3); +} +.portal-tab-btn { + padding: 0.55rem 1.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: transparent; + border: none; + cursor: pointer; + color: #665544; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} +.portal-tab-btn.active { + color: var(--ui-ink); + border-bottom-color: var(--ui-gold-dark); + font-weight: 500; +} + +/* ── Portal form ─────────────────────────────────────────────────── */ +.portal-label { + display: block; + font-size: 0.82rem; + color: #665544; + margin-bottom: 0.3rem; + letter-spacing: 0.03em; +} +.portal-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.35); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + margin-bottom: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.portal-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.18); +} +.portal-submit-btn { + padding: 0.6rem 2rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + border: none; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.25); + transition: opacity 0.15s; +} +.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.portal-submit-btn:not(:disabled):hover { opacity: 0.9; } + +.portal-page-btn { + padding: 0.35rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + background: var(--board-rail); + color: var(--ui-parchment); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} +.portal-page-btn:hover { opacity: 1; } + +.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; } +.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; } +.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; } +.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; } + +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.portal-link { + color: var(--ui-gold); + text-decoration: none; + font-size: 0.875rem; +} +.portal-link:hover { text-decoration: underline; } + +.portal-verification-banner { + background: rgba(200,164,72,0.08); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.portal-verification-banner p { + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +/* ── Share URL row (lobby waiting card + game top bar) ──────────── */ +.share-url-row { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(0,0,0,0.18); + border: 1px solid rgba(200,164,72,0.25); + border-radius: 5px; + padding: 0.4rem 0.6rem; +} +.share-url-text { + flex: 1; + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(242,232,208,0.75); + word-break: break-all; + user-select: all; +} +.share-copy-btn { + flex-shrink: 0; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.2rem 0.6rem; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 3px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} +.share-copy-btn:hover { background: rgba(200,164,72,0.22); } + +/* ── QR code container ───────────────────────────────────────────── */ +.qr-container { + width: 160px; + height: 160px; + margin: 0 auto; + border-radius: 4px; + overflow: hidden; +} +.qr-container svg { width: 100%; height: 100%; display: block; } + +/* ── Share popover (in-game top bar) ─────────────────────────────── */ +.share-popover { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(200,164,72,0.2); + border-radius: 6px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.share-popover .qr-container { width: 120px; height: 120px; } +.share-popover-label { + font-size: 0.75rem; + color: rgba(242,232,208,0.6); + text-align: center; + margin: 0; +} + +/* ── Game overlay (full-screen, covers portal during play) ───────── */ +.game-overlay { + position: fixed; + inset: 0; + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + z-index: 200; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1.5rem; + overflow-y: auto; +} + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + */ + /* border-top: 3px solid var(--ui-gold-dark); */ + width: 340px; + margin-top: 5vh; + overflow: hidden; +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +.playing-as { + font-size: 0.75rem; + color: rgba(242,232,208,0.7); + font-family: var(--font-ui); +} +.playing-as strong { color: rgba(242,232,208,0.9); } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +.board-and-panel { + position: relative; +} + +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; +} + +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; +} + +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; +} + +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; + cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; +} +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } + +/* ── Inner scoring card ─────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 320px; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) ──────────────────────────────────────────────── */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +.field { + --fc: var(--field-ivory); + width: 60px; + height: 180px; + background: transparent; + isolation: isolate; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours ────────────────────────────────── */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +.board-quarter .field.zone-opponent:nth-child(odd), +.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-opponent:nth-child(even), +.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); } + +/* ── Point indicator: first N fields reflect each player's score & bredouille */ +.board-quarter .field.zone-petit.point-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ +.exit-btn { + pointer-events: none; + opacity: 0.3; + transition: opacity 0.2s, transform 0.15s; +} +.exit-btn.exit-active { + pointer-events: auto; + cursor: pointer; + opacity: 1; + animation: exit-btn-pulse 1.4s ease-in-out infinite; +} +.exit-btn.exit-active:hover { + transform: scale(1.1); +} +@keyframes exit-btn-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } + 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } +} + +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +.field.clickable { + cursor: pointer; +} +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} +.field.selected { + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); +} + +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; } +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker slide animation (§4a) ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + min-height: 2rem; +} + +/* ── Free-play mode ─────────────────────────────────────────────────────── */ +.free-mode-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-ui); + font-size: 0.78rem; + color: #887766; + cursor: pointer; + user-select: none; + padding-top: 0.1rem; +} +.free-mode-toggle input[type="checkbox"] { + accent-color: var(--ui-gold); + cursor: pointer; + width: 0.85rem; + height: 0.85rem; +} +.free-mode-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 1px solid #a89880; + font-size: 0.65rem; + font-style: normal; + color: #a89880; + cursor: help; + flex-shrink: 0; +} + +.free-mode-error { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(180, 60, 30, 0.12); + border: 1px solid rgba(180, 60, 30, 0.4); + border-radius: 4px; + padding: 0.4rem 0.75rem; + width: 100%; + box-sizing: border-box; +} +.free-mode-error-msg { + flex: 1; + font-family: var(--font-ui); + font-size: 0.85rem; + color: #8b2000; + font-style: italic; +} + +/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ +.ceremony-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} + +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: row; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; + cursor: pointer; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} + +/* ══════════════════════════════════════════════════════════════════════ + Layout variation 07 — scrolling strip + sidebar controls + ══════════════════════════════════════════════════════════════════════ */ + +/* Prevent horizontal scrollbar from the full-bleed strip */ +.game-overlay { overflow-x: hidden !important; } + +/* Board bar: hide die slots, keep the rail as a thin divider */ +.bar-die-slot { display: none !important; } +.board-bar { width: 5px; overflow: hidden; } + +/* ── Full-width in-flow player strip ─────────────────────────────────── */ +.v07-strip { + width: 100vw; + margin-top: -1.5rem; /* undo game-overlay top padding */ + margin-left: calc(50% - 50vw); /* align to viewport left */ + display: flex; + align-items: center; + background: var(--ui-parchment); + border-bottom: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 8px rgba(0,0,0,0.18); + padding: 0.35rem 1.5rem; + gap: 0.5rem; +} + +.v07-player { display: flex; align-items: center; flex: 1; min-width: 0; } +.v07-player-left { justify-content: flex-end; } +.v07-player-right { justify-content: flex-start; } + +.v07-active-zone { + display: flex; + align-items: center; + gap: 0.7rem; + border-radius: 8px; + padding: 0.28rem 0.5rem; + transition: background 0.15s; +} +.v07-active-zone.active { background: rgba(58,42,10,0.08); } + +/* Checker-style circles */ +.v07-avatar { + width: 38px; height: 38px; + border-radius: 50%; + flex-shrink: 0; +} +.v07-avatar-me { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(160,130,70,0.22) 68.5%, rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(160,130,70,0.17) 43.5%, rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.35), inset 0 -1px 3px rgba(0,0,0,0.15); +} +.v07-avatar-opp { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(200,164,72,0.18) 68.5%, rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(200,164,72,0.13) 43.5%, rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 -1px 3px rgba(0,0,0,0.4); +} + +/* Strip peg overrides */ +.v07-strip .peg-track { gap: 3px; } +.v07-strip .peg-hole { width: 12px; height: 12px; } +.v07-strip .peg-hole.filled { + background: #5aab38; border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} +.v07-strip .peg-hole.peg-opp.filled { + background: #c05030; border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Strip score-row-name: remove fixed width from v01 */ +.v07-strip .score-row-name { width: auto; } + +/* No ghost bar below pts-counter in the strip */ +.v07-strip .pts-counter-wrap { padding-bottom: 0; } + +/* Center "Trictrac" title */ +.v07-strip-center { + flex-shrink: 0; + padding: 0 1rem; + border-left: 1px solid rgba(138,106,40,0.2); + border-right: 1px solid rgba(138,106,40,0.2); +} +.v07-title { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + font-style: italic; + color: var(--ui-ink); + letter-spacing: 0.03em; + white-space: nowrap; +} + +/* ── Body: board + controls ──────────────────────────────────────────── */ +.v07-body { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; +} + +/* ── Controls column (sidebar on wide, row on narrow) ────────────────── */ +.v07-controls { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 152px; + flex-shrink: 0; + align-self: stretch; +} + +.v07-ctrl-dice { + background: var(--board-rail); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.6rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.v07-ctrl-dice-row { + display: flex; + gap: 0.55rem; + align-items: center; + justify-content: center; +} + +/* Free-mode toggle: light text on dark board-rail background */ +.v07-ctrl-dice .free-mode-toggle { + color: var(--ui-parchment); + font-size: 0.7rem; + flex-wrap: wrap; + justify-content: center; + text-align: center; + gap: 0.3rem; +} +.v07-ctrl-dice .free-mode-help { + border-color: rgba(242,232,208,0.35); + color: rgba(242,232,208,0.5); +} + +.v07-ctrl-status { + background: var(--ui-parchment); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.65rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; +} +.v07-ctrl-status .game-status { + color: var(--ui-ink); + text-shadow: none; + font-size: 1rem; + padding: 0; + width: auto; + text-align: center; + line-height: 1.3; +} +.v07-ctrl-status .board-actions { + flex-wrap: wrap; + justify-content: center; + min-height: 0; +} +.v07-ctrl-status .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; + text-align: center; + font-size: 0.67rem; + line-height: 1.4; + margin: 0; +} + +/* ── Scoring panels row (below board+controls, in-flow) ──────────────── */ +.v07-scoring-row { width: 100%; } + +/* Reset absolute positioning from the old score-area context */ +.v07-scoring-row .scoring-panels-container { + position: static; + top: auto; left: auto; right: auto; bottom: auto; + z-index: auto; + padding: 0; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.v07-scoring-row .scoring-panel { + width: 100%; + box-sizing: border-box; + margin: 0; +} + +/* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */ +@media (max-width: 919px) { + .v07-body { + flex-direction: column; + align-items: stretch; + } + .v07-controls { + flex-direction: row; + width: 100%; + } + .v07-ctrl-status { flex: 1; } + /* Hide pegs on small screens to save space in the strip */ + .v07-strip .peg-track { display: none; } +} diff --git a/doc/design/variations/01-dice-sidebar.html b/doc/design/variations/01-dice-sidebar.html new file mode 100644 index 0000000..7d0222d --- /dev/null +++ b/doc/design/variations/01-dice-sidebar.html @@ -0,0 +1,418 @@ + + + + + + Variation 01 — Dés en sidebar droite + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ Anonyme + (vous) +
+
+
+
+
+
+ 6 + /12 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bot +
+
+
+
+
+
+ 2 + /12 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ + +
+ + +
+ +
+ + +
+ +
+
+ 13 +
+
+ 14 +
+
+ 15 +
+
+
+
+
+
+
+ 16 +
+
+ 17 +
+
+ 18 +
+
+ + +
+ +
+
+ 19 +
+
+
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
+
+
11
+
+
+
+ +
+ +
+ + +
+ +
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+
+
+
+ 7 +
+
+
+
+
+
+ + +
+ +
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+
+ +
+ + + +
+ +
+ + +
+ + + + + + + +
+ +
+ +
+
+ + + diff --git a/doc/design/variations/02-horizontal-header.html b/doc/design/variations/02-horizontal-header.html new file mode 100644 index 0000000..09502a5 --- /dev/null +++ b/doc/design/variations/02-horizontal-header.html @@ -0,0 +1,628 @@ + + + + + + Variation 02 — En-tête horizontal · Scoring latéral + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
+ Anonyme + (vous) +
+
+
+
+
+
+
+
+
+
+ +
+ 6 + /12 +
+
+ + +
+ VS + jeu en 12 trous + à vous de jouer +
+ + +
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+ + +
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+ +
Aucun événement de marque
+
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + + + + +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + +
+
+
Déplacez une dame (1 sur 2)
+
+
+
+ +
+
+ + + diff --git a/doc/design/variations/03-dark-modern.html b/doc/design/variations/03-dark-modern.html new file mode 100644 index 0000000..ce480c0 --- /dev/null +++ b/doc/design/variations/03-dark-modern.html @@ -0,0 +1,655 @@ + + + + + + Variation 03 — Dark Modern · Dock de contrôle + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
A
+
+ Anonyme + (vous) +
+
+
+
+
+
+
+
+
+
+ +
+ 6 + /12 +
+
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+
B
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+ + +
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ + +
+ +
+ +
+ +
+
+ + + diff --git a/doc/design/variations/04-warm-bottom-scoring.html b/doc/design/variations/04-warm-bottom-scoring.html new file mode 100644 index 0000000..d92a2d3 --- /dev/null +++ b/doc/design/variations/04-warm-bottom-scoring.html @@ -0,0 +1,620 @@ + + + + + + Variation 04 — Warm Classic · Scoring en bas + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
A
+
+ Anonyme + (vous) +
+
+
+
+
+
+
+
+
+
+ +
+ 6 + /12 +
+
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+
B
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+ + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ + +
+ +
+ +
+ +
+
+ + + diff --git a/doc/design/variations/05-warm-sticky-header.html b/doc/design/variations/05-warm-sticky-header.html new file mode 100644 index 0000000..6d661a7 --- /dev/null +++ b/doc/design/variations/05-warm-sticky-header.html @@ -0,0 +1,582 @@ + + + + + + Variation 05 — Warm · Sticky header · Scoring below dock + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
+ 6 + /12 +
+ +
+
+
+
+
+
+
+
+
+
+ Anonyme + (vous) +
+
A
+
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+
B
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ +
+
+ + + diff --git a/doc/design/variations/06-wide-header.html b/doc/design/variations/06-wide-header.html new file mode 100644 index 0000000..9a9ed8f --- /dev/null +++ b/doc/design/variations/06-wide-header.html @@ -0,0 +1,571 @@ + + + + + + Variation 06 — Wide fixed header · Scoring below dock + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+ + +
A
+ + +
+ Anonyme + (vous) +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ 6 + /12 +
+
+ +
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+ + +
+
+
+
+
+ 2 + /12 +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ Bot +
+ + +
B
+ +
+ +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ +
+
+ + + diff --git a/doc/design/variations/07-scrolling-header.html b/doc/design/variations/07-scrolling-header.html new file mode 100644 index 0000000..122e772 --- /dev/null +++ b/doc/design/variations/07-scrolling-header.html @@ -0,0 +1,711 @@ + + + + + + Variation 07 — Scrolling header · Responsive sidebar/footer + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
+ +
A
+ +
+ Anonyme + (vous) +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ 6 + /12 +
+
+
+
+ + +
+ Trictrac +
+ + +
+
+ +
+
+ 2 + /12 +
+
+ +
+
+
+
+
+
+
+
+
+ +
+ Bot +
+ +
B
+
+
+ +
+ + +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + +
+ +
+ +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + +
+ +
+ +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ +
+
+ + + diff --git a/doc/python.md b/doc/python.md deleted file mode 100644 index 60fa378..0000000 --- a/doc/python.md +++ /dev/null @@ -1,31 +0,0 @@ -# Python bindings - -## Génération bindings - -```sh -# Generate trictrac python lib as a wheel -maturin build -m store/Cargo.toml --release -# Install wheel in local python env -pip install --no-deps --force-reinstall --prefix .devenv/state/venv target/wheels/*.whl -``` - -## Usage - -Pour vérifier l'accès à la lib : lancer le shell interactif `python` - -```python -Python 3.13.11 (main, Dec 5 2025, 16:06:33) [GCC 15.2.0] on linux -Type "help", "copyright", "credits" or "license" for more information. ->>> import trictrac_store ->>> game = trictrac_store.TricTrac() ->>> game.get_active_player_id() -1 -``` - -### Appels depuis python - -`python bot/python/test.py` - -## Interfaces - -## Entraînement diff --git a/doc/python_research.md b/doc/python_research.md deleted file mode 100644 index 90c568f..0000000 --- a/doc/python_research.md +++ /dev/null @@ -1,536 +0,0 @@ -# Trictrac — Research Notes: Engine & OpenSpiel Integration - -> Generated from a deep read of `trictrac/store/src/` and `forks/open_spiel/open_spiel/python/games/trictrac.py`. - ---- - -## 1. Architecture Overview - -The project connects two codebases through a compiled Python extension: - -``` -┌─────────────────────────────────────┐ -│ trictrac/store/ (Rust crate) │ -│ - full game rules engine │ -│ - pyengine.rs → PyO3 bindings │ -│ compiled by maturin → .whl │ -└──────────────┬──────────────────────┘ - │ import trictrac_store -┌──────────────▼──────────────────────┐ -│ forks/open_spiel/.../trictrac.py │ -│ - TrictracGame (pyspiel.Game) │ -│ - TrictracState (pyspiel.State) │ -│ registered as "python_trictrac" │ -└─────────────────────────────────────┘ -``` - -Build pipeline: -- `just pythonlib` (in `trictrac/`) → `maturin build -m store/Cargo.toml --release` → `.whl` into `target/wheels/` -- `just installtrictrac` (in `forks/open_spiel/`) → `pip install --force-reinstall` the wheel into the devenv venv - -The Rust crate is named `trictrac-store` (package) but produces a lib named `trictrac_store` (the Python module name, set in `Cargo.toml` `[lib] name`). - ---- - -## 2. Rust Engine: Module Map - -| Module | Responsibility | -|---|---| -| `board.rs` | Board representation, checker manipulation, quarter analysis | -| `dice.rs` | `Dice` struct, `DiceRoller`, bit encoding | -| `player.rs` | `Player` struct (score, bredouille), `Color`, `PlayerId`, `CurrentPlayer` | -| `game.rs` | `GameState` state machine, `GameEvent` enum, `Stage`/`TurnStage` | -| `game_rules_moves.rs` | `MoveRules`: move validation and generation | -| `game_rules_points.rs` | `PointsRules`: jan detection and scoring | -| `training_common.rs` | `TrictracAction` enum, action-space encoding (size 514) | -| `pyengine.rs` | PyO3 Python module exposing `TricTrac` class | -| `lib.rs` | Crate root, re-exports | - ---- - -## 3. Board Representation - -```rust -pub struct Board { - positions: [i8; 24], -} -``` - -- 24 fields indexed 0–23 internally, 1–24 externally. -- Positive values = White checkers on that field; negative = Black. -- Initial state: `[15, 0, ..., 0, -15]` — all 15 white pieces on field 1, all 15 black pieces on field 24. -- Field 0 is a sentinel for "exited the board" (never stored in the array). - -**Mirroring** is the central symmetry operation used throughout: - -```rust -pub fn mirror(&self) -> Self { - let mut positions = self.positions.map(|c| 0 - c); - positions.reverse(); - Board { positions } -} -``` - -This negates all values (swapping who owns each checker) and reverses the array (swapping directions). The entire engine always reasons from White's perspective; Black's moves are handled by mirroring the board first. - -**Quarter structure**: fields 1–6, 7–12, 13–18, 19–24. This maps to the four tables of Trictrac: -- 1–6: White's "petit jan" (own table) -- 7–12: White's "grand jan" -- 13–18: Black's "grand jan" (= White's opponent territory) -- 19–24: Black's "petit jan" / White's "jan de retour" - -The "coin de repos" (rest corner) is field 12 for White, field 13 for Black. - ---- - -## 4. Dice - -```rust -pub struct Dice { - pub values: (u8, u8), -} -``` - -Dice are always a pair (never quadrupled for doubles, unlike Backgammon). The `DiceRoller` uses `StdRng` seeded from OS entropy (or an optional fixed seed for tests). Bit encoding: `"{d1:0>3b}{d2:0>3b}"` — 3 bits each, 6 bits total. - ---- - -## 5. Player State - -```rust -pub struct Player { - pub name: String, - pub color: Color, // White or Black - pub points: u8, // 0–11 (points within current hole) - pub holes: u8, // holes won (game ends at >12) - pub can_bredouille: bool, - pub can_big_bredouille: bool, - pub dice_roll_count: u8, // rolls since last new_pick_up() -} -``` - -`PlayerId` is a `u64` alias. Player 1 = White, Player 2 = Black (set at init time; this is fixed for the session in pyengine). - ---- - -## 6. Game State Machine - -### Stages - -```rust -pub enum Stage { PreGame, InGame, Ended } - -pub enum TurnStage { - RollDice, // 1 — player must request a roll - RollWaiting, // 0 — waiting for dice result from outside - MarkPoints, // 2 — points are being marked (schools mode only) - HoldOrGoChoice, // 3 — player won a hole; choose to Go or Hold - Move, // 4 — player must move checkers - MarkAdvPoints, // 5 — mark opponent's points after the move (schools mode) -} -``` - -### Turn lifecycle (schools disabled — the default in pyengine) - -``` -RollWaiting - │ RollResult → auto-mark points - ├─[no hole]──→ Move - │ │ Move → mark opponent's points → switch player - │ └───────────────────────────────→ RollDice (next player) - └─[hole won]─→ HoldOrGoChoice - ├─ Go ──→ new_pick_up() → RollDice (same player) - └─ Move ──→ mark opponent's points → switch player → RollDice -``` - -In schools mode (`schools_enabled = true`), the player explicitly marks their own points (`Mark` event) and then the opponent's points after moving (`MarkAdvPoints` stage). - -### Key events - -```rust -pub enum GameEvent { - BeginGame { goes_first: PlayerId }, - EndGame { reason: EndGameReason }, - PlayerJoined { player_id, name }, - PlayerDisconnected { player_id }, - Roll { player_id }, // triggers RollWaiting - RollResult { player_id, dice }, // provides dice values - Mark { player_id, points }, // explicit point marking (schools mode) - Go { player_id }, // choose to restart position after hole - Move { player_id, moves: (CheckerMove, CheckerMove) }, - PlayError, -} -``` - -### Initialization in pyengine - -```rust -fn new() -> Self { - let mut game_state = GameState::new(false); // schools_enabled = false - game_state.init_player("player1"); - game_state.init_player("player2"); - game_state.consume(&GameEvent::BeginGame { goes_first: 1 }); - TricTrac { game_state } -} -``` - -Player 1 (White) always goes first. `active_player_id` uses 1-based indexing; pyengine converts to 0-based for the Python side with `active_player_id - 1`. - ---- - -## 7. Scoring System (Jans) - -Points are awarded after each dice roll based on "jans" (scoring events) detected by `PointsRules`. All computation assumes White's perspective (board is mirrored for Black before calling). - -### Jan types - -| Jan | Points (normal / doublet) | Direction | -|---|---|---| -| `TrueHitSmallJan` | 4 / 6 | → active player | -| `TrueHitBigJan` | 2 / 4 | → active player | -| `TrueHitOpponentCorner` | 4 / 6 | → active player | -| `FilledQuarter` | 4 / 6 | → active player | -| `FirstPlayerToExit` | 4 / 6 | → active player | -| `SixTables` | 4 / 6 | → active player | -| `TwoTables` | 4 / 6 | → active player | -| `Mezeas` | 4 / 6 | → active player | -| `FalseHitSmallJan` | −4 / −6 | → opponent | -| `FalseHitBigJan` | −2 / −4 | → opponent | -| `ContreTwoTables` | −4 / −6 | → opponent | -| `ContreMezeas` | −4 / −6 | → opponent | -| `HelplessMan` | −2 / −4 | → opponent | - -A single roll can trigger multiple jans, each scored independently. The jan detection process: -1. Try both dice orderings -2. Detect "tout d'une" (combined dice move as a virtual single die) -3. Prefer true hits over false hits for the same move -4. Check quarter-filling opportunities -5. Check rare jans (SixTables at roll 3, TwoTables, Mezeas) given specific board positions and talon counts - -### Hole scoring - -```rust -fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool { - let sum_points = p.points + points; - let jeux = sum_points / 12; // number of completed holes - let holes = match (jeux, p.can_bredouille) { - (0, _) => 0, - (_, false) => 2 * jeux - 1, // no bredouille bonus - (_, true) => 2 * jeux, // bredouille doubles the holes - }; - p.points = sum_points % 12; - p.holes += holes; - ... -} -``` - -- 12 points = 1 "jeu", which yields 1 or 2 holes depending on bredouille status. -- Scoring any points clears the opponent's `can_bredouille`. -- Completing a hole resets `can_bredouille` for the scorer. -- Game ends when `holes > 12`. -- Score reported to OpenSpiel: `holes * 12 + points`. - -### Points from both rolls - -After a roll, the active player's points (`dice_points.0`) are auto-marked immediately. After the Move, the opponent's points (`dice_points.1`) are marked (they were computed at roll-time from the pre-move board). - ---- - -## 8. Move Rules - -`MoveRules` always works from White's perspective. Key constraints enforced by `moves_allowed()`: - -1. **Opponent's corner forbidden**: Cannot land on field 13 (opponent's rest corner for White). -2. **Corner needs two checkers**: The rest corner (field 12) must be taken or vacated with exactly 2 checkers simultaneously. -3. **Corner by effect vs. by power**: If the corner can be taken directly ("par effet"), you cannot take it "par puissance" (using combined dice). -4. **Exit preconditions**: All checkers must be in fields 19–24 before any exit is allowed. -5. **Exit by effect priority**: If a normal exit is possible, exceedant moves (using overflow) are forbidden. -6. **Farthest checker first**: When exiting with exceedant, must exit the checker at the highest field. -7. **Must play all dice**: If both dice can be played, playing only one is invalid. -8. **Must play strongest die**: If only one die can be played, it must be the higher value die. -9. **Must fill quarter**: If a quarter can be completed, the move must complete it. -10. **Cannot block opponent's fillable quarter**: Cannot move into a quarter the opponent can still fill. - -The board state after each die application is simulated to check two-step sequences. - ---- - -## 9. Action Space (training_common.rs) - -Total size: **514 actions**. - -| Index | Action | Description | -|---|---|---| -| 0 | `Roll` | Request dice roll (not used in OpenSpiel mode) | -| 1 | `Go` | After winning hole: reset board and continue | -| 2–257 | `Move { dice_order: true, checker1, checker2 }` | Move with die[0] first | -| 258–513 | `Move { dice_order: false, checker1, checker2 }` | Move with die[1] first | - -Move encoding: `index = 2 + (0 if dice_order else 256) + checker1 * 16 + checker2` - -`checker1` and `checker2` are **ordinal positions** (1-based) of specific checkers counted left-to-right across all White-occupied fields, not field indices. Checker 0 = "no move" (empty move). Range: 0–15 (16 values each). - -### Mirror pattern in get_legal_actions / apply_action - -For player 2 (Black): -```rust -// get_legal_actions: mirror game state before computing -let mirror = self.game_state.mirror(); -get_valid_action_indices(&mirror) - -// apply_action: convert action → event on mirrored state, then mirror the event back -a.to_event(&self.game_state.mirror()) - .map(|e| e.get_mirror(false)) -``` - -This ensures Black's actions are computed as if Black were White on a mirrored board, then translated back to real-board coordinates. - ---- - -## 10. Python Bindings (pyengine.rs) - -The `TricTrac` PyO3 class exposes: - -| Method | Signature | Description | -|---|---|---| -| `new()` | `→ TricTrac` | Create game, init 2 players, begin with player 1 | -| `needs_roll()` | `→ bool` | True when in `RollWaiting` stage | -| `is_game_ended()` | `→ bool` | True when `Stage::Ended` | -| `current_player_idx()` | `→ u64` | 0 or 1 (active_player_id − 1) | -| `get_legal_actions(player_idx)` | `→ Vec` | Action indices for player; empty if not their turn | -| `action_to_string(player_idx, action_idx)` | `→ String` | Human-readable action description | -| `apply_dice_roll(dices: (u8, u8))` | `→ PyResult<()>` | Inject dice result; errors if not in RollWaiting | -| `apply_action(action_idx)` | `→ PyResult<()>` | Apply a game action; validates before applying | -| `get_score(player_id)` | `→ i32` | `holes * 12 + points` for player (1-indexed!) | -| `get_players_scores()` | `→ [i32; 2]` | `[score_p1, score_p2]` | -| `get_tensor(player_idx)` | `→ Vec` | 36-element state vector (mirrored for player 1) | -| `get_observation_string(player_idx)` | `→ String` | Human-readable state (mirrored for player 1) | -| `__str__()` | `→ String` | Debug representation of game state | - -Note: `get_score(player_id)` takes a 1-based player ID (1 or 2), unlike `current_player_idx()` which returns 0-based. - ---- - -## 11. State Tensor Encoding (36 bytes) - -``` -[0..23] Board positions (i8): +N white / −N black checkers per field -[24] Active player: 0=White, 1=Black -[25] TurnStage: 0=RollWaiting, 1=RollDice, 2=MarkPoints, 3=HoldOrGoChoice, - 4=Move, 5=MarkAdvPoints -[26] Dice value 1 (i8) -[27] Dice value 2 (i8) -[28] White: points (0–11) -[29] White: holes (0–12) -[30] White: can_bredouille (0 or 1) -[31] White: can_big_bredouille (0 or 1) -[32] Black: points -[33] Black: holes -[34] Black: can_bredouille -[35] Black: can_big_bredouille -``` - -When called for player 1 (Black), the entire state is mirrored first (`game_state.mirror().to_vec()`). - -### State ID (base64 string for hashing) - -108 bits packed as 18 base64 characters: -- 77 bits: GNUbg-inspired board position encoding (run-length with separators) -- 1 bit: active player color -- 3 bits: turn stage -- 6 bits: dice (3 bits per die) -- 10 bits: white player (4 pts + 4 holes + 2 flags) -- 10 bits: black player -- Padded to 108 bits, grouped as 18 × 6-bit base64 chunks - ---- - -## 12. OpenSpiel Integration (trictrac.py) - -### Game registration - -```python -pyspiel.register_game(_GAME_TYPE, TrictracGame) -``` - -Key parameters: -- `short_name = "python_trictrac"` -- `dynamics = SEQUENTIAL` -- `chance_mode = EXPLICIT_STOCHASTIC` -- `information = PERFECT_INFORMATION` -- `utility = GENERAL_SUM` (both players can score positive; no zero-sum constraint) -- `reward_model = REWARDS` (intermediate rewards, not just terminal) -- `num_distinct_actions = 514` -- `max_chance_outcomes = 36` -- `min_utility = 0.0`, `max_utility = 200.0` -- `max_game_length = 3000` (rough estimate) - -### Chance node handling - -When `needs_roll()` is true, the state is a chance node. OpenSpiel samples one of 36 outcomes (uniform): - -```python -def _roll_from_chance_idx(self, action): - return [(i,j) for i in range(1,7) for j in range(1,7)][action] - -def chance_outcomes(self): - p = 1.0 / 36 - return [(i, p) for i in range(0, 36)] -``` - -Action 0 → (1,1), action 1 → (1,2), …, action 35 → (6,6). The chance action is then passed to `apply_dice_roll((d1, d2))` on the Rust side. - -### Player action handling - -When not a chance node: -```python -def _legal_actions(self, player): - return self._store.get_legal_actions(player) - -def _apply_action(self, action): - self._store.apply_action(action) -``` - -The `Roll` action (index 0) is never returned by `get_legal_actions` in this mode because the Rust side only returns Roll actions from `TurnStage::RollDice`, which is bypassed in the pyengine flow (the RollWaiting→chance node path takes over). - -### Returns - -```python -def returns(self): - return self._store.get_players_scores() -# → [holes_p1 * 12 + points_p1, holes_p2 * 12 + points_p2] -``` - -These are cumulative scores available at any point during the game (not just terminal), consistent with `reward_model = REWARDS`. - ---- - -## 13. Known Issues and Inconsistencies - -### 13.1 `observation_string` missing return (trictrac.py:156) - -```python -def observation_string(self, player): - self._store.get_observation_string(player) # result discarded, returns None -``` - -Should be `return self._store.get_observation_string(player)`. - -### 13.2 `observation_tensor` not populating buffer (trictrac.py:159) - -```python -def observation_tensor(self, player, values): - self._store.get_tensor(player) # result discarded, values not filled -``` - -OpenSpiel's API expects `values` (a mutable buffer, typically a flat numpy array) to be filled in-place. The returned `Vec` from `get_tensor()` is discarded. Should copy data into `values`. - -### 13.3 Debug print statement active (trictrac.py:140) - -```python -print("in apply action", self.is_chance_node(), action) -``` - -This fires on every action application. Should be removed or guarded. - -### 13.4 Color swap on new_pick_up disabled - -In `game.rs:new_pick_up()`: - -```rust -// XXX : switch colors -// désactivé pour le moment car la vérification des mouvements échoue, -// cf. https://code.rhumbs.fr/henri/trictrac/issues/31 -// p.color = p.color.opponent_color(); -``` - -In authentic Trictrac, players swap colors between "relevés" (pick-ups after a hole is won with Go). This is commented out, so the same player always plays White and the same always plays Black throughout the entire game. - -### 13.5 `can_big_bredouille` tracked but not implemented - -The `can_big_bredouille` flag is stored in `Player` and serialized in state encoding, but the scoring logic never reads it. Grande bredouille (a rare extra bonus) is not implemented. - -### 13.6 `Roll` action in action space but unused in OpenSpiel mode - -`TrictracAction::Roll` (index 0) exists in the 514-action space and in `get_valid_actions()` (for `TurnStage::RollDice`). However, in pyengine, the game starts at `RollWaiting` (dice have been requested but not yet rolled), so `TurnStage::RollDice` is never reached from OpenSpiel's perspective. The chance node mechanism replaces the Roll action entirely. The action space slot 0 is permanently wasted from OpenSpiel's point of view. - -### 13.7 `get_valid_actions` panics on `RollWaiting` - -```rust -TurnStage::MarkPoints | TurnStage::MarkAdvPoints | TurnStage::RollWaiting => { - panic!("get_valid_actions not implemented for turn stage {:?}", ...) -} -``` - -If `get_legal_actions` were ever called while `needs_roll()` is true, this would panic. OpenSpiel's turn logic avoids this because chance nodes are handled separately, but it is a latent danger. - -### 13.8 PPO training script uses wrong model name - -`trictrac_ppo.py` saves to `ppo_backgammon_model.ckpt` — clearly copied from a backgammon example without renaming. Also uses `tensorflow.compat.v1` despite the PyTorch PPO import. - -### 13.9 Opponent points marked at pre-move board state - -The opponent's `dice_points.1` is computed at roll time (before the active player moves), but applied to the opponent after the move. This means the opponent's scoring is evaluated on the board position that existed before the active player moved — which is per the rules of Trictrac (points are based on where pieces could be hit at the moment of the roll), but it's worth noting this subtlety. - ---- - -## 14. Data Flow: A Complete Turn - -``` -Python (OpenSpiel) → Rust (trictrac_store) -───────────────────────────────────────────────────── -is_chance_node() ← needs_roll() [TurnStage == RollWaiting] - (true at game start) - -chance_outcomes() → [(0,p)..(35,p)] - -_apply_action(chance_idx) - _roll_from_chance_idx(idx) → (d1, d2) - apply_dice_roll((d1, d2)) → consume(RollResult{dice}) - → auto-mark active player's points - → if hole: TurnStage=HoldOrGoChoice - → else: TurnStage=Move - -current_player() → 0 or 1 - -_legal_actions(player) ← get_legal_actions(player_idx) - → get_valid_actions on (possibly mirrored) state - → Vec of valid action indices - -_apply_action(action_idx) → apply_action(action_idx) - → TrictracAction::from_action_index - → to_event on (mirrored) state - → mirror event back if player==2 - → validate → consume - → mark opponent points - → switch active player - → TurnStage=RollDice (→ pyengine starts next turn) - -Wait — pyengine starts at RollWaiting, not RollDice! -The next is_chance_node() call will be true again. -``` - -Note on turn transition: After a `Move` event in `game.rs`, turn stage becomes `RollDice` (not `RollWaiting`). The pyengine `needs_roll()` checks for `RollWaiting`. So after a move, `is_chance_node()` returns false — OpenSpiel will ask for a regular player action. But `get_valid_actions` at `TurnStage::RollDice` returns only `Roll` (index 0), which is **not** the chance path. - -This reveals a subtlety: after the Move event, the active player has already been switched, so `current_player()` returns the new active player, and `get_legal_actions` returns `[0]` (Roll). OpenSpiel then applies action 0, which calls `apply_action(0)` → `TrictracAction::Roll` → `GameEvent::Roll` → TurnStage becomes `RollWaiting`. Then the next call to `is_chance_node()` returns true, and the chance mechanism kicks in again. - -So the full sequence in OpenSpiel terms is: -``` -[Chance] dice roll → [Player] move → [Player] Roll action → [Chance] dice roll → ... -``` - -The `Roll` action IS used — it is the bridge between Move completion and the next chance node. - ---- - -## 15. Summary of Design Choices - -| Choice | Rationale | -|---|---| -| All rules engine in Rust | Performance, correctness, can be used in other contexts (CLI, native bots) | -| Mirror pattern for Black | Avoids duplicating all rule logic for both colors | -| Schools disabled by default | Simpler turn structure for RL training; full protocol for human play | -| GENERAL_SUM + REWARDS | Trictrac is not strictly zero-sum; intermediate hole rewards are informative for training | -| Action index for checkers (not fields) | Reduces action space; ordinal checker numbering is compact | -| 514 action slots | 1 Roll + 1 Go + 256 × 2 move combinations (ordered by die priority × 16 × 16 checker pairs) | -| Chance node = dice roll | Standard OpenSpiel pattern for stochastic games | diff --git a/doc/refs/dqn-burn.md b/doc/refs/bot_rl/dqn-burn.md similarity index 100% rename from doc/refs/dqn-burn.md rename to doc/refs/bot_rl/dqn-burn.md diff --git a/doc/spiel_bot_research.md b/doc/refs/bot_rl/spiel_bot_research.md similarity index 100% rename from doc/spiel_bot_research.md rename to doc/refs/bot_rl/spiel_bot_research.md diff --git a/doc/tensor_research.md b/doc/refs/bot_rl/tensor_research.md similarity index 100% rename from doc/tensor_research.md rename to doc/refs/bot_rl/tensor_research.md diff --git a/doc/refs/inspirations.md b/doc/refs/inspirations.md deleted file mode 100644 index a94aa87..0000000 --- a/doc/refs/inspirations.md +++ /dev/null @@ -1,130 +0,0 @@ -# Inspirations - -tools - -- config clippy ? -- bacon : tests runner (ou loom ?) - -## Rust libs - -cf. - -nombres aléatoires avec seed : - -- cli : ( ou clap ) -- reseau async : tokio -- web serveur : axum (uses tokio) - - -- db : sqlx - -- eyre, color-eyre (Results) -- tracing (logging) -- rayon ( sync <-> parallel ) - -- front : yew + tauri - - - egui - -- - -## network games - -- -- (wasm, rooms) -- -- - -## Others - -- plugins avec - -## Backgammon existing projects - -- go : - - protocole de communication : -- ocaml : - cli example : -- lib rust backgammon - - - - -- network webtarot -- front ? - -## cli examples - -### GnuBackgammon - - (No game) new game - gnubg rolls 3, anthon rolls 1. - - GNU Backgammon Positions ID: 4HPwATDgc/ABMA - Match ID : MIEFAAAAAAAA - +12-11-10--9--8--7-------6--5--4--3--2--1-+ O: gnubg - | X O | | O X | 0 points - | X O | | O X | Rolled 31 - | X O | | O | - | X | | O | - | X | | O | - ^| |BAR| | (Cube: 1) - | O | | X | - | O | | X | - | O X | | X | - | O X | | X O | - | O X | | X O | 0 points - +13-14-15-16-17-18------19-20-21-22-23-24-+ X: anthon - - gnubg moves 8/5 6/5. - -### jacobh - -Move 11: player O rolls a 6-2. -Player O estimates that they have a 90.6111% chance of winning. - -Os borne off: none - 24 23 22 21 20 19 18 17 16 15 14 13 - ---- - -| v v v v v v | | v v v v v v | -| | | | -| X O O O | | O O O | -| X O O O | | O O | -| O | | | -| | X | | -| | | | -| | | | -| | | | -| | | | -|------------------------------| |------------------------------| -| | | | -| | | | -| | | | -| | | | -| X | | | -| X X | | X | -| X X X | | X O | -| X X X | | X O O | -| | | | -| ^ ^ ^ ^ ^ ^ | | ^ ^ ^ ^ ^ ^ | - ---- - -1 2 3 4 5 6 7 8 9 10 11 12 -Xs borne off: none - -Move 12: player X rolls a 6-3. -Your move (? for help): bar/22 -Illegal move: it is possible to move more. -Your move (? for help): ? -Enter the start and end positions, separated by a forward slash (or any non-numeric character), of each counter you want to move. -Each position should be number from 1 to 24, "bar" or "off". -Unlike in standard notation, you should enter each counter movement individually. For example: -24/18 18/13 -bar/3 13/10 13/10 8/5 -2/off 1/off -You can also enter these commands: -p - show the previous move -n - show the next move - - toggle between showing the current and last moves -help - show this help text -quit - abandon game diff --git a/doc/refs/journal.md b/doc/refs/journal.md deleted file mode 100644 index dd6d99c..0000000 --- a/doc/refs/journal.md +++ /dev/null @@ -1,61 +0,0 @@ -# Journal - -```sh -devenv init -cargo init -cargo add pico-args -``` - -Organisation store / server / client selon - -_store_ est la bibliothèque contenant le _reducer_ qui transforme l'état du jeu en fonction des évènements. Elle est utilisée par le _server_ et le _client_. Seuls les évènements sont transmis entre clients et serveur. - -## Config neovim debugger launchers - -Cela se passe dans la config neovim (lua/plugins/overrides.lua) - -## Organisation du store - -lib - -- game::GameState - - error - - dice - - board - - user - - user - -## Algorithme de détermination des coups - -- strategy::choose_move - - - GameRules.get_possible_moves_sequences(with_excedents: bool) - - get_possible_moves_sequences_by_dices(dice_max, dice_min, with_excedents, false); - - get_possible_moves_sequences_by_dices(dice_min, dice_max, with_excedents, true); - - has_checkers_outside_last_quarter() ok - - board.get_possible_moves ok - - check_corner_rules(&(first_move, second_move)) ok - -- handle_event - - state.validate (ok) - - rules.moves_follow_rules (ok) - - moves_possible ok - - moves_follows_dices ok - - moves_allowed (ok) - - check_corner_rules ok - - can_take_corner_by_effect ok - - get_possible_moves_sequences -> cf. l.15 - - check_exit_rules - - get_possible_moves_sequences(without exedents) -> cf l.15 - - get_quarter_filling_moves_sequences - - get_possible_moves_sequences -> cf l.15 - - state.consume (RollResult) (ok) - - get_rollresult_jans -> points_rules.get_result_jans (ok) - - get_jans (ok) - - get_jans_by_ordered_dice (ok) - - get_jans_by_ordered_dice ( dices.poped ) - - move_rules.get_scoring_quarter_filling_moves_sequences (ok) - - get_quarter_filling_moves_sequences cf l.8 (ok) - - board.get_quarter_filling_candidate -> is_quarter_fillable ok - - move_rules.get_possible_moves_sequence -> cf l.15 - - get_jans_points -> jan.get_points ok diff --git a/doc/refs/laws_and_rules_of_trictrac.md b/doc/refs/laws_and_rules_of_trictrac.md new file mode 100644 index 0000000..cbcf414 --- /dev/null +++ b/doc/refs/laws_and_rules_of_trictrac.md @@ -0,0 +1,410 @@ +# LAWS AND RULES OF TRICTRAC + +2013 EDITION — SUPPLEMENT TO THE REASONED DICTIONARY OF THE GAME OF TRICTRAC www.trictrac.org by Michel MALFILÂTRE (trictrac.org) + +_Translator's note: French game terms are preserved in italics on first use. See [vocabulary.md](vocabulary.md) for a complete French/English mapping._ + +There are two types of game in grand trictrac: the ordinary game and the scored game. +In both, the main laws and rules are the same; but the goal, scoring, and payments differ. + +## ARTICLE I: THE ORDINARY GAME + +It is played between two players; the goal is to be the first to score 12 holes (_trous_). One hole equals 12 points. + +## ARTICLE II: THE SCORED GAME + +It can be played by 2, 3, or 4 players in teams or in _chouette_ format. The goal is to win as many tokens as possible by playing an agreed number of rounds (_marqués_). A round always pits two players against each other. With three or four players, participants rotate at each round in a defined order. + +To win a round, a player must score at least 6 holes and then leave (_s'en aller_) (see Article XV). The maximum number of holes per round is generally unlimited, but players may agree otherwise. + +If both players are tied at or above 6 holes when one player leaves, the round is drawn and must be replayed (_refait_) immediately. + +## ARTICLE III: EQUIPMENT + +The game is played on a board called a _trictrac_, composed of two tables: the small jan table and the big jan table. The first table contains each player's small jan and the second contains each player's big jan. One player's small jan is also the other player's return jan. Each jan consists of 6 alternately coloured fields (_flèches_). + +The board has 24 triangular fields in total and 30 holes drilled into its rails and bands. + +A hole is drilled at the base of each field. These holes hold each player's peg (_fichet_) to record the holes (games) won. The three holes on each side rail serve to place pegs at the start of the game, along with the flag (_pavillon_). + +In addition to these three pegs (one of which is the flag), the game uses 30 checkers — 15 white and 15 black (or two other contrasting colours) — three tokens (_jetons_), two dice cups (_cornets_), and two six-sided dice. + +The scored game is also played with tokens used for payments, or with paper and pencil to keep a token account. + +## ARTICLE IV: STARTING POSITION + +At the start of the game, all checkers are stacked into two separate stacks (_talons_): one of white checkers, one of black. Each stack is placed facing the other on a corner field adjacent to one of the two outer side rails, called the starting rail. This rail may later become the exit rail. + +Each player uses the checkers from the stack closest to them. The corner fields against the other outer side rail are the rest corners. The twelfth field of each player — counting the stack as the first — is therefore that player's rest corner, or simply: _corner_. + +Pegs are placed in the 3 holes of the starting rail, with the flag occupying the central hole. Three tokens are placed against this rail between the two stacks. + +## ARTICLE V: FIRST-MOVE PRIVILEGE + +To determine who plays first at the start of a game, each player rolls a die with a dice cup; the player who rolls the higher number generally takes the white checkers and begins, playing both numbers rolled. + +An alternative method: one player rolls both dice; the player closest to the higher die plays first, playing both numbers rolled. + +In both cases, if the dice show the same value, they must be re-rolled. A game may therefore not begin with a double. + +After each new setting (_relevé_), first-move privilege belongs to the player who first exited all their checkers or who left first (see Articles VIII and XV). + +In the scored game with two players, first-move privilege alternates each round. With three or four players, it belongs to the player who remains to face a new opponent. + +In case of a replay, the player who had first-move privilege in the drawn round retains it for that replay and any subsequent replays. + +## ARTICLE VI: ROLLING AND PLAYING THE DICE + +Both dice must be rolled together with a dice cup. They are valid when they land flat inside the board, even if resting on a checker or token. If a die is broken, rests on a rail, or lands outside the board, both dice must be re-rolled. + +The two numbers may be played with two checkers — each playing one number — or with a single checker playing both numbers successively in a chained move (_tout d'une_) (for 6 and 1: the 6 advances a checker six fields and the 1 advances another one field; or a single checker moves seven fields total, stopping on the first or sixth field as a resting point before reaching the seventh). + +Both numbers must be played if possible. If only one can be played and there is a choice, the higher number must be played. + +Any unplayed number is penalised: this is a _jan-qui-ne-peut_ (helpless man), worth 2 helplessness points per unplayed number, credited to the opponent. + +Dice must not be picked up before the move is fully played and all points marked (including school penalties). + +## ARTICLE VII: MOVEMENT OF CHECKERS + +Checkers always move in the same direction — opposite to the opponent's — and never backwards. + +In the course of a game, checkers travel from the stack to the rest corner (the twelfth field), then back to the opponent's stack on the return. + +A checker may only be placed, or made to rest during a chained move, on an empty field or one already occupied by one or more of the player's own checkers. The rest corner is an exception to this rule (see Article IX). + +A checker may not be placed on a field occupied by the opponent's checker(s). + +## ARTICLE VIII: EXITING CHECKERS + +When all of a player's checkers are gathered in their last jan (return jan), they are exited from the board using the exit rail privilege, which grants this rail the value of one additional field. + +A checker may be exited by an exact exit number that brings it directly to the exit rail, or by an overflow number (_nombre excédant_) that would carry the farthest checker beyond the rail. Other numbers — failing numbers (_nombres défaillants_) — must be played within the jan. + +A checker may be exited in a chained move. A player may choose not to exit a checker on an exact exit number and instead play another checker within the jan as a failing number, if possible; but an overflow number must always exit a checker. + +When exiting, all non-exiting numbers must be played within the jan when possible. It is therefore not permitted to play one number in a way that forces the second to be played only as an overflow. Likewise, if a number cannot be played within the jan due to the presence of opponent checkers, it may not be played as an overflow using a checker closer to the exit rail. + +When a player has exited all their checkers, they score 4 points for the last exited checker on a normal roll, or 6 points on a double. + +The checkers of both players are then reset and returned to their respective stacks; play continues with no change to the score. By privilege, the player who exited first rolls again and plays (first-move privilege). + +Exiting can occur multiple times in a game. + +## ARTICLE IX: THE REST CORNER + +The rest corner may only be taken simultaneously (_d'emblée_): two checkers must enter it at the same time. Likewise, it may only be vacated simultaneously. It must therefore always be occupied by at least two checkers. It is forbidden to place or leave a single checker on one's own rest corner. + +Under any circumstances, it is forbidden to place one or more checkers on the opponent's rest corner. + +An empty corner may, however, serve as a resting field for any checker during a chained move. + +A player may take their corner by effect (_par effet_, naturally), or by puissance (_par puissance_) — the latter when the opponent's corner is empty and the player could take it simultaneously. By privilege, the player takes their own corner instead, as if stepping back one field. + +If a player can take their corner both by effect and by puissance, they must take it by effect. + +After vacating the corner, it may be retaken under the same conditions. + +## ARTICLE X: HITTING CHECKERS + +This _jan de récompense_ (reward jan) occurs when an opponent's checker is exposed alone on a half-field and the player rolls numbers that could cover it with one or more of their own checkers. + +The hit is always fictitious — it exists only as a potential; no checker is actually moved. + +A checker may be hit in one, two, or three ways: + +- **One way**: only one of the direct die values, or the combined sum, could cover the checker. +- **Two ways**: both direct die values could cover it, or one direct value and the combined sum. +- **Three ways**: both direct values can cover it, and the combined sum can as well. + +By its nature, a double allows at most one or two ways to hit: + +- **One way**: one direct value, or the combined sum. +- **Two ways**: one direct value and the combined sum. + +Only one way is counted on a double, even when two checkers on a field could each cover the opponent's checker. + +Multiple checkers may be hit in the same move. + +For each checker hit and for each way it is hit, this reward jan is worth: + +- **2 points** on a normal roll, **4 points** on a double — if the hit checker is in the big jan table. +- **4 points** on a normal roll, **6 points** on a double — if the hit checker is in the small jan table or return jan. + +Reward jans must be marked by the player who achieves them (under penalty of being "sent to school" — see Article XVI). + +To hit a checker using the combined sum, the player must have a resting field (_repos_): a field where one die can land so that the second can (fictitiously) reach the target checker. This resting field must be either empty, already held by the player's own checkers, or occupied by a single opponent checker — which is then also hit. + +A _helpless man_ (_jan-qui-ne-peut_) occurs when, attempting to hit using the combined sum, no free resting field exists and the player must stop on a full field held by the opponent. The hit is then a **false hit** (_à faux_), and the opponent gains as many points as the player would have scored with a true hit. + +A checker already hit with a true hit cannot also be hit with a false hit in the same move. However, multiple checkers may be hit simultaneously — some truly, others falsely. + +Points for true hits must be marked before those given to the opponent for false hits. The opponent must mark their false-hit points in due time, under penalty of school (see Article XVI). + +## ARTICLE XI: HITTING THE CORNER + +This reward jan occurs when a player holds their own rest corner, the opponent's corner is empty, and the player rolls numbers that would let them take the opponent's corner simultaneously, without unstacking their own corner (i.e., without using the two checkers already holding it). + +Since taking the opponent's corner is actually forbidden, the player hits it instead, scoring **4 points** on a normal roll and **6 points** on a double. This hit can never be false, as it uses two direct die values and never the combined sum. + +## ARTICLE XII: OPENING JANS + +There are three opening jans: the **two tables jan**, the **mezeas jan**, and the **six tables jan**. They can only be achieved at the start of a game, a round, or their respective new settings. + +### TWO TABLES JAN + +At the start of a game, round, or new setting — when only two checkers have been deployed — if the player rolls numbers that would place one checker on their own empty rest corner and one on the opponent's, a two tables jan is scored if the opponent's corner is also empty; otherwise, if the opponent has already taken their corner, a **contre two tables** is scored instead. + +In the first case, hitting both corners is worth **4 points** on a normal roll and **6 points** on a double, credited to the player. In the second case, the same point value is credited to the opponent as a false hit. + +### MEZEAS JAN + +At the start of a game, round, or new setting — having taken one's corner with only two checkers deployed — if on the very next roll one or two aces (1s) are rolled, a mezeas jan is scored if the opponent's corner is empty; otherwise, a **contre mezeas** is scored. + +In the first case, hitting the corner is worth **4 points** per ace and **6 points** for a double; in the second case, the same value is credited to the opponent. + +### SIX TABLES JAN (THREE-ROLL JAN) + +At the start of a game, round, or new setting — having placed one checker on four of the first six fields (fields 2–7) during the first two rolls — if on the third roll the player could fill the remaining two fields, they score a six tables jan. This jan is worth **4 points** in all cases, as it cannot be achieved with a double. + +The player is not obliged to actually fill those two fields; they are free to play the roll however they prefer. + +## ARTICLE XIII: SMALL JAN, BIG JAN & RETURN JAN + +### THE FULL JAN (PLEIN) + +A jan is full (_plein_) when a player occupies each of its six fields with at least two of their own checkers. + +Each player may fill their small jan, big jan, and return jan. + +A full jan may be broken and then refilled. Over the course of a game, a player may fill several different jans successively, or the same jan multiple times. + +### FILLING + +A jan is filled when the player rolls numbers that complete the full jan by bringing in the last checker. + +A jan may be filled in one, two, or three ways: + +- **One way**: the last half-field can be covered by one direct die value or by the combined sum in a chained move. +- **Two ways**: it can be covered by either direct die value, or by one direct value and the combined sum. +- **Three ways**: it can be covered by either direct value, and also by the combined sum. + +Each way of filling is worth **4 points** on a normal roll and **6 points** on a double. + +For a jan to be filled in multiple ways, exactly one checker must be missing — only the last checker brought in actually fills the jan. Therefore, a player fills in only one way when taking the last field simultaneously or covering both last half-fields in the same move. + +A double allows at most two ways to fill. + +A jan is not effectively filled when, though able to complete it with one die, the player must break it to play the other. When a player "fills in passing" this way, no points are scored and they are not obliged to perform the filling. + +The return jan may not be filled by counting either of the two checkers holding the rest corner as a way of filling — doing so would unstack the corner, which is forbidden. + +After marking points for filling a jan, the player must actually fill it with one or two checkers, under penalty of false move and school (see Article XVII). + +If the jan can be filled in multiple ways, the player fills it with the checker of their choice and is free to play the other die as they wish. + +### CONSERVING + +A full jan is conserved when the player can play both dice without breaking it — that is, without using any of the twelve checkers that compose the full jan. + +Conserving a full jan is worth **4 points** on a normal roll and **6 points** on a double. There can be at most one way to conserve. + +A player may use the privilege of conserving by helplessness (_par impuissance_) when, having a full jan, the position prevents playing one or both numbers. Only the number 6 allows this conservation, as all lower numbers can be played within the jan (even if it means breaking the full jan). + +By privilege, the full return jan may be conserved by exiting one, two, or three checkers. + +As with filling, when it is possible to play without breaking a full jan, the player must actually conserve it — under penalty of false move and school (see Article XVII). + +## ARTICLE XIV: FORBIDDEN JANS + +It is forbidden to place a checker in the opponent's small jan or big jan as long as the opponent retains the material possibility of filling that jan with the checkers available to them. + +This prohibition normally ends once the opponent has moved enough checkers beyond the fields needed to complete the jan, so that those checkers can no longer serve to fill it — making the full jan materially impossible. + +Once the opponent can no longer complete the full jan, the player has the right to place one or more of their own checkers there. + +However, when playing a chained move, it is always permitted to use the empty fields of the opponent's big jan (including the corner) as a resting field — even if it can still be filled — in order to pass a checker into the return jan, as long as the return jan is not itself forbidden. + +## ARTICLE XV: SCORING + +Points and holes won must always be marked before touching one's checkers to play, or before rolling the dice for the next move if those points come from the opponent's roll (helpless man, contre-jans, schools). + +Points are marked with tokens. For **2 points**, the token is placed at the tip of the player's second field or between the second and third fields; for **4 points**, at the fourth or between the fourth and fifth; for **6 points**, at the sixth or against the cross-rail; for **8 points**, on the other side of that rail, in the big jan; for **10 points**, against the side rail of the big jan or at the tip of the rest corner field. **12 or 0 points** are marked against the starting rail between the two stacks, as at the start of the game. + +12 points make a hole. If the 12 points of a hole were all scored consecutively from zero — that is, without the opponent having scored any points during that run — the hole is won _bredouille_ and counts as **2 holes**. This double-hole advantage applies equally to the first and second player to start marking. The first player to mark uses a single token and can win the hole bredouille as long as the opponent scores nothing. If the opponent then scores, they mark with a double token called the _bredouille_ and continue marking this way as long as the first player scores nothing. If they reach at least 12 points in this fashion, they win the hole bredouille in second. But if the first player scores again beforehand, they remove one of the opponent's two tokens (_débredouiller_), and neither player can thereafter win the hole bredouille. Once both players each have a single-token mark, the hole will necessarily be won simple by one or the other. + +Holes are marked with pegs. Each player advances their peg along the row of holes drilled at the base of the twelve fields in their small and big jans. The first hole is at the base of the stack, the twelfth and last at the base of the rest corner. + +Earned holes must be marked before touching the tokens. Any opponent token (bredouille or not) is then reset to zero at the starting rail. The player's own token is also reset if they scored exactly 12 points; otherwise the remainder — _points de reste_ — are marked normally with a token. + +If on the same move the opponent is owed points, they mark them afterwards, starting from zero, using one or two tokens depending on whether the player marked any remainder points. + +Multiple holes, both single and double, may be won in the same move. + +In the scored game, a round may be won simple, double, or quadruple depending on whether the holes were scored consecutively. If the holes were not consecutive, the round is simple. If at least 6 holes were consecutive, the round is won in small bredouille and counts double. If at least 12 holes were consecutive, it is won in big bredouille and counts quadruple. + +As with the hole bredouille, this advantage applies equally to the first and second player to score holes. The second player takes the flag and places it at their peg's starting position on the starting rail. If the first player wins new holes, they take the flag back and return it to the central hole. The round is then necessarily won simple. + +## ARTICLE XVI: STAYING OR LEAVING + +When a player wins one or more holes through their own dice roll, they may choose to stay (_tenir_) or use the privilege of leaving (_s'en aller_). If the winning points come from the opponent's roll (helpless man, schools), the player must stay. + +**Staying**: after marking the hole(s), the player resets the opponent's token if necessary, marks any remainder points, and continues playing normally. The opponent then marks any points they may have earned from this move (see Article XV). + +**Leaving**: after marking the hole(s), the player verbally announces their intention to leave, as the opponent may object in case of a fault or school. Once the opponent has verbally agreed or begun breaking their position, all tokens are reset to zero and all checkers of both players are returned to their stacks. Only the holes won remain. No remainder points may be marked and the opponent cannot mark points or holes for this move. Play resumes from the start; by privilege, the player who left has first-move privilege for this new setting — they roll and play. + +In the scored game, once a player has scored at least 6 holes, if either player leaves, the round ends. The winner is whoever has the most holes. In case of a tie, the round is drawn and replayed. + +## ARTICLE XVII: FAULTS AND SCHOOLS + +There are three types of fault in this game: + +**1. Simple faults** — of little harm to the opponent; some can be corrected normally (e.g., playing out of turn, rolling outside the board, accidentally disturbing the position, forgetting to mark a school). No penalty is incurred for these faults. + +**2. False move faults (_fausse case_)** — potentially harmful; occur when a checker is not played to the correct field given the numbers rolled, or when a rule of play is violated (laws regarding the rest corner, forbidden jans, filling, and conserving). A false move may give rise to a school when points have been marked for a jan that is not then actually performed — as the rules require (e.g., marking for filling or conserving but not doing so). In addition to the rule "checker touched, checker abandoned, checker played" (unless the player said "_j'adoube_"), the player must accept the opponent's decision regarding rectification of the fault. + +The opponent must point out the fault(s) before rolling for their own move; they may rectify the fault in their own interest, while respecting the rules, or leave the position unchanged. If a corner was taken by puissance when it could have been taken by effect, the opponent may prevent the player from taking it on that move if the fault is recognised and an alternative play exists. If a half-field was falsely covered, the opponent may also prevent the covering. + +**3. Marking faults** — always harmful; occur when points or holes are forgotten, over-marked, or marked incorrectly. The opponent may penalise the player by "sending them to school." A school is committed once dice have been rolled or checkers touched, or once a token marker has been advanced too far and released. In some cases the school is committed as soon as an intentional declaration is made (leaving, school, invitation to play). + +A school is worth to the opponent exactly as many points as were missed or over-marked in a move. Moreover, in case of over-marking, the opponent corrects the mark by removing the excess points. When marking school points, or just after, the opponent must announce it by saying: "School!" or "N points of school." + +No one is obliged to mark a school — there is no "school of school." But if it is marked, it must be marked in full, or the opponent may request rectification. The opponent may also force the faulty player to correct their mark without marking the school. + +A **false school** occurs when a player marks an incorrect school or a school that does not exist. This is itself a school that the opponent may mark in their favour. + +A **school escalation** occurs when a player who committed a school has had it marked by their opponent, believes the opponent erred, removes that school and marks it in their own favour as a false school — but the opponent, maintaining the school was valid, removes the player's mark, restores the first school, and adds further points for this second school. The dispute could continue indefinitely unless the players reach a frank resolution. + +At any time a player may ask their opponent to explain the points they are marking or removing. The opponent must explain. + +No school of holes is incurred for marking a hole won bredouille as a simple hole. But a school of points is incurred for holes forgotten due to points earned, or for holes over-marked for points not earned. + +School points are marked last. + +## ARTICLE XVIII: SEQUENCE OF PLAY + +For a move to be regular, each player must act in the following order: + +As soon as the opponent's move ends: + +1. If applicable, mark the opponent's helpless man penalties or contre-jans. +2. If applicable and desired, mark the opponent's schools and announce: "School!"; rectify any false moves and marking errors. + +Then: + +3. Roll the dice for one's own move. If applicable, the opponent then marks any schools committed in steps 1 and 2. Resolve false schools and school escalations. +4. If applicable, mark points for opening jans, reward jans (checker hits and corner hits), filled or conserved jans, or exit points. +5. If applicable, decide to stay or leave: + - **Stay**: reset tokens and mark any remainder points. + - **Leave**: announce it, then break the position after the opponent's agreement. Reset all checkers, reset all tokens, and roll again to play (unless the game or round is over). +6. In case of exit: reset checkers, do not reset either player's tokens, and roll again to play. +7. Play both numbers rolled if possible. + +The opponent then plays following the same sequence. + +8. If applicable, mark any schools the opponent committed in their steps 1 and 2, as soon as they have rolled the dice, or interrupt the roll to rectify the marking. + +Failure to respect this sequence is a fault and may be penalised by the opponent. + +## ARTICLE XIX: THE SCORED GAME + +### THREE- AND FOUR-PLAYER GAME + +The number of rounds chosen for the game must be a multiple of the number of players, so that each player faces every opponent the same number of times. + +With three players, the first round pairing is drawn by lot. The player who draws the highest number begins with white checkers and plays the two numbers rolled. + +For the second round, the winner is replaced by the third player, but first-move privilege belongs to the player who stayed at the table. + +For the following round, the third player remains but the others alternate. + +Each player thus plays two rounds in a row against different opponents. Only the winner of the first round plays just once — at the very beginning and at the very end of the game. + +In case of a replay, the same players remain and the player who had first-move privilege in the drawn round retains it. + +With four players, the game is played in teams of two. Teammates share wins and losses. + +Play proceeds as in the three-player game: each player plays two rounds in a row — one against each opponent — then gives way to their partner. Likewise, only the winner of the first round plays just once, at the start and end of the game. + +Players not currently in a round may advise those at the table (opponents in three-player, teammates in four-player) according to their interest; but they are forbidden to touch any game component. + +### PAYMENTS + +Each round is paid to the winner in as many tokens as holes scored, minus those of the loser. The loser also pays a **consolation** of two additional tokens to the winner, and to the other player in the three-player game. + +If the round is won in small bredouille, each hole won by the winner is paid double (2 tokens) and the consolation is also doubled (4 tokens). + +If the round is won in big bredouille, each hole is paid quadruple (4 tokens) and the consolation is quadrupled (8 tokens). + +Each hole won by the loser is deducted from the total and is always worth only one token. + +In case of a replay, the consolation price doubles the previous drawn round's price, doubling again at each successive replay. + +In the three-player game, the loser must additionally always pay the consolation to the non-playing player, whatever the consolation price. + +Moreover, after each defeat, the loser sets aside one token (sometimes two) to track the number of losses and allow later settlement of bets. + +All these tokens form a **queue** that is paid at the end of the game to the player who won the most tokens in rounds. In case of a tie, the queue is split equally among the winning players. + +The queue is not mandatory when scoring is kept in writing, but may be counted by convention. + +Each player then settles their outstanding bets equitably with each opponent. + +A **bet** (_pari_) is any round exceeding each player's contingent. The contingent is the average number of rounds played between two opponents. + +Thus, if two players play eight rounds, each player's contingent is four, and any round won or lost beyond four is a bet won or lost. This gain or loss is doubled since a bet won by one player is also a bet lost by the other. + +The first double bet is called the **postillon** and is paid 28 tokens (including 20 from the queue); each subsequent bet costs 8 tokens. This payment is made between each pair of players. With three players, it is possible to win or lose two postillons among other bets, one per opponent. + +Finally, the definitive settlement converts each player's winnings into chips (or another equivalent whose value was established beforehand — for example, one chip = 5 tokens). + +## ARTICLE XX: END OF GAME + +### THE ORDINARY GAME + +The ordinary game — also called the "tour" of trictrac — ends when a player wins their twelfth and final hole. This hole may be won through the player's own dice roll or the opponent's (helpless man, schools); the player need not leave to end the game. + +By prior convention, the game may be won simple or double. It is won double — in big bredouille — when a player scores all twelve holes consecutively. The second player to mark may also achieve this by taking the flag and keeping it until they score twelve holes without the first player scoring again and taking back the flag. If neither player scores twelve holes consecutively, the game is won simple. + +Another convention allows the game to be won quadruple if the winner was the only player to score. The second player to mark may win triple by achieving big bredouille as described above. The game is won double if both players' bredouilles are cancelled and the loser failed to score at least six holes; otherwise it is won simple. + +Settlement is then made according to the stakes established before the game. + +The game ends when the loser has paid their debt to the winner. + +### THE SCORED GAME + +As stated in Article II, the scored game consists of an agreed number of rounds. When those rounds have been played — including any replays — settlement takes place by performing the count and payments as described in Article XIX. + +The game ends when all debts have been settled. + +### APPENDIX: SCORING TABLE + +This table summarises the point value of all scoring events: jans and figures of the game. + +"J" = the player (who rolled the dice); "A" = the opponent (_adversaire_): they indicate who benefits. Numbers indicate points scored. + +| SCORING EVENT | Beneficiary | Per occurrence | Normal roll | Double | +| ------------------------------- | ----------- | -------------- | ----------- | ------ | +| Six tables jan (three-roll jan) | J | — | 4 | — | +| Two tables jan | J | — | 4 | 6 | +| Contre two tables | A | — | 4 | 6 | +| Mezeas jan | J | — | 4 | 6 | +| Contre mezeas | A | — | 4 | 6 | +| Small jan filled | J | Per way | 4 | 6 | +| Small jan conserved | J | — | 4 | 6 | +| Big jan filled | J | Per way | 4 | 6 | +| Big jan conserved | J | — | 4 | 6 | +| Return jan filled | J | Per way | 4 | 6 | +| Return jan conserved | J | — | 4 | 6 | +| True hit in small jan table | J | Per way | 4 | 6 | +| False hit in small jan table | A | Per way | 4 | 6 | +| True hit in big jan table | J | Per way | 2 | 4 | +| False hit in big jan table | A | Per way | 2 | 4 | +| Corner hit | J | — | 4 | 6 | +| Exit (last checker) | J | — | 4 | 6 | +| Helpless man (unplayed number) | A | Per number | 2 | 2 | +| Misery pile achieved | J | — | 4 | 6 | +| Misery pile conserved | J | — | 4 | 6 | + +School penalties are worth to the opponent exactly the number of points that were over- or under-marked on that move. diff --git a/doc/refs/lois_et_regles_du_trictrac.md b/doc/refs/lois_et_regles_du_trictrac.md new file mode 100644 index 0000000..4bda033 --- /dev/null +++ b/doc/refs/lois_et_regles_du_trictrac.md @@ -0,0 +1,394 @@ +# LOIS ET RÈGLES DU TRICTRAC + +ÉDITION 2013 COMPLÉMENT AU DICTIONNAIRE RAISONNÉ DU JEU DE TRICTRAC www.trictrac.org par Michel MALFILÂTRE (trictrac.org) + +Il y a deux sortes de partie au grand trictrac : la partie ordinaire et la partie à écrire. +A l'une comme à l'autre, les lois et les règles principales du jeu sont les mêmes ; mais le but, la marque et donc les paiements sont différents. + +## ARTICLE I : LA PARTIE ORDINAIRE + +Elle se dispute entre deux joueurs, le but est de marquer le premier 12 jeux ou trous. Un trou vaut 12 points. + +## ARTICLE II : LA PARTIE À ÉCRIRE + +Elle peut se disputer à 2, 3 ou 4 joueurs en équipe ou « à la chouette ». Le but est de gagner un maximum de jetons, en jouant un certain nombre de marqués ou manches, qui est déterminé d'un commun accord entre les joueurs. +Un marqué oppose toujours deux joueurs. A trois ou à quatre, les joueurs alternent à chaque marqué suivant un ordre défini. +Pour gagner un marqué, il faut prendre un minimum de 6 trous et qu’un joueur s'en aille, c'est-à-dire s'arrête (voir article XV). Le nombre maximum de trous pouvant être pris n’est généralement pas limité, mais les joueurs peuvent en décider différemment. +En cas d'égalité de trous, à partir des 6 requis, lorsqu'un des joueurs s'en va, le marqué est nul et il y a refait, c'est-à-dire que le marqué doit être rejoué aussitôt. + +## ARTICLE III : LE MATÉRIEL + +Pour jouer, on utilise un tablier appelé trictrac formé de deux tables : la table du PETIT JAN et la table du GRAND JAN. Dans la première table se trouve le petit jan de chaque joueur et dans la seconde le grand jan de chaque joueur. Le petit jan d’un joueur constitue aussi le jan de retour de l’autre joueur. Chaque jan est composé de 6 flèches, ou lames de couleur alternes. +Le tablier comprend au total 24 cases triangulaires et 30 trous pratiqués dans les rebords et les bandes. +Un trou est pratiqué à la base de chaque flèche. Ces trous sont destinés à recevoir le fichet de chaque joueur afin de marquer les jeux (ou trous) gagnés. Les trois trous situés sur chaque bande latérale servent à placer les fichets au début du jeu ainsi que le pavillon. +Outre ces trois fichets dont le pavillon, on se sert de 30 dames, 15 blanches et 15 noires (ou de deux autres couleurs différentes), de trois jetons, de deux cornets et de deux dés cubiques aux faces numérotées de 1 à 6. + +Par ailleurs, la partie à écrire se joue avec des jetons utilisés pour les paiements, ou bien avec un papier et un crayon permettant d'établir un compte de jetons. + +## ARTICLE IV : POSITION DE DÉPART + +Au début de la partie, on empile toutes les dames en deux talons distincts, l'un formé des dames blanches et l'autre des dames noires. +Chaque talon est constitué vis-à-vis de l'autre sur une case de coin située contre une des deux bandes latérales extérieures appelée alors bande de départ et qui deviendra éventuellement par la suite la bande de sortie. +Chaque joueur devra utiliser les dames du talon le plus proche de lui. Les coins situés contre l’autre bande latérale extérieure sont les coins de repos. La douzième case de chaque joueur, en comptant le talon pour la première, constitue donc son coin de repos ou simplement appelé : coin. +On place les fichets dans les 3 trous de la bande de départ, le pavillon occupant le trou central. On dispose les trois jetons contre cette bande entre les deux talons. + +## ARTICLE V : LA PRIMAUTÉ DU DÉ + +Pour déterminer celui qui va jouer en premier au début d'une partie, chaque joueur jette un dé avec un cornet et celui qui a amené le plus fort nombre prend généralement les dames blanches et commence en jouant les deux nombres ainsi obtenus. +Une autre méthode peut être employée : un joueur lance les deux dés avec un cornet et le joueur se trouvant placé le plus près du plus fort dé, jouera en premier les deux nombres ainsi obtenus. +Dans les deux cas, si les dés amenés sont semblables, il faut jeter à nouveau les dés. +On ne peut donc pas commencer une partie par un doublet (double). +A chaque relevé, la primauté appartient par privilège à celui qui a sorti en premier toutes ses dames ou qui s'en est allé (voir articles VIII et XV). + +A la partie à écrire, à deux joueurs, la primauté alterne à chaque marqué ; à trois ou quatre joueurs, elle appartient à celui qui reste pour affronter un nouvel adversaire. +Lorsqu'il y a refait, le joueur qui avait la primauté au marqué nul précédent, la conserve pour ce refait et pour les éventuels suivants, en cas de refaits successifs. + +## ARTICLE VI : JETER ET JOUER LES DÉS + +Les deux dés doivent être jetés ensemble avec un cornet. Ils sont bons lorsqu’ils se posent à plat dans le trictrac, même sur une dame ou un jeton. Si un dé est cassé, posé sur une bande ou hors du trictrac, il est mauvais et les deux dés doivent être relancés. +On peut jouer les deux nombres obtenus par les dés avec deux dames, chacune jouant un nombre, ou avec une seule dame jouant « tout d'une » les deux nombres successivement (pour 6 et as : le 6 permet d'avancer une dame de six cases et l’as une autre dame d’une case ; ou bien une seule et même dame de sept cases laquelle dame exécute alors deux sauts successifs en se reposant obligatoirement sur la première ou sur la sixième case afin d’atteindre la septième case d'arrivée). + +Il est obligatoire de jouer les deux nombres si cela est possible. Si on ne peut en jouer qu'un seul et qu'on ait le choix, on doit jouer le plus fort. + +Tout nombre non joué est pénalisé : c'est un JAN-QUI-NE-PEUT qui vaut en faveur de l'adversaire 2 points d'impuissance par nombre. + +Les dés ne doivent pas être relevés avant que le coup ne soit joué entièrement et tous les points marqués (écoles comprises). + +## ARTICLE VII : LE MOUVEMENT DES DAMES + +On joue ses dames toujours dans le même sens, qui est contraire à celui de son adversaire, et sans jamais rétrograder. +Dans la marche du trictrac, les dames peuvent parcourir le tablier de leur talon jusqu'à leur coin de repos (douzième case), puis passer au retour jusqu'au talon adverse. +On ne peut placer une dame, ou la faire se reposer pour jouer tout d'une, que sur une case vide ou déjà occupée par une ou plusieurs de ses propres dames. Exception est faite à cette règle concernant les coins de repos (voir article IX). +Il n'est pas possible de jouer sur une case qu'occupe l'adversaire avec une ou plusieurs de ses dames. + +## ARTICLE VIII : LA SORTIE DES DAMES + +Lorsque toutes les dames d'un joueur se trouvent rassemblées dans son dernier jan ou jan de retour, elles sont sorties hors du trictrac en usant du privilège de la bande de sortie qui attribue à cette bande la valeur d'une case. +Une dame peut être sortie par un nombre sortant qui la fait aboutir directement à cette bande et par un nombre excédant qui fait aboutir la dame la plus éloignée de la bande de sortie au delà de celle-ci. Les autres nombres ou nombres défaillants doivent être joués à l'intérieur du jan. + +Il est possible de sortir une dame en la jouant tout d'une. Il est permis de ne pas sortir une dame par un nombre sortant mais d'en jouer une autre à l'intérieur du jan comme un nombre défaillant si cela est possible ; mais un nombre excédant doit obligatoirement faire sortir une dame. + +Lors de la sortie, il faut jouer la totalité des nombres non sortants à l'intérieur du jan lorsque cela est possible. Il n'est donc pas permis de jouer l'un des nombres de telle sorte que le second ne puisse être joué autrement que comme nombre excédant. De même si un nombre ne peut être joué dans le jan à cause de la présence d'une ou plusieurs dames adverses, il n'est pas permis de jouer ce nombre comme excédant par une dame située plus près de la bande de sortie. + +Quand un joueur a fait sortir toutes ses dames, il gagne, pour la dernière sortante, 4 points par un coup simple ou 6 points par un doublet. +Ensuite les dames des deux camps sont relevées et replacées à leur talon respectif ; le jeu se poursuit ainsi sans que la marque des points ne soit autrement modifiée. Par privilège, le joueur qui avait sorti toutes ses dames en premier jette de nouveau les dés et joue (primauté). + +La sortie des dames peut se produire plusieurs fois dans une partie. + +## ARTICLE IX : LE COIN DE REPOS + +On ne peut prendre son coin de repos que d'emblée, c'est-à-dire en y portant deux dames simultanément. De même, on ne peut le quitter que d'emblée. On doit donc l'occuper avec au moins deux dames. Il est interdit de placer ou de laisser une seule dame sur son coin de repos. + +Il est interdit dans quelque circonstance que ce soit de placer une ou plusieurs dames sur le coin de repos de l'adversaire. +Un coin vide peut cependant servir de passage à une dame quelconque pour s'y reposer afin de jouer tout d'une. + +On peut prendre son coin naturellement, par effet, ou bien par puissance si celui de l'adversaire est vide et qu'on pourrait le prendre d'emblée. Ainsi, par privilège, on prend son +propre coin à la place comme si on rétrogradait d'une case. + +Si on a la possibilité de prendre son coin à la fois par effet et par puissance, on doit le prendre par effet. + +Après avoir quitté son coin, on peut le reprendre dans les mêmes conditions. + +## ARTICLE X : LA BATTERIE DES DAMES + +On fait ce JAN DE RÉCOMPENSE lorsqu'une dame adverse est découverte, seule en demi-case et qu'on amène des nombres avec lesquels on pourrait couvrir cette dame avec une ou plusieurs des siennes. +La batterie est toujours fictive, à l'état de puissance ; elle ne s'opère jamais en réalité. Aucune dame n'est déplacée. + +On peut battre d'une, de deux ou de trois façons : + +- On bat d'une façon lorsqu'on ne peut couvrir cette dame que par un des nombres directs obtenus par les dés, ou par les deux nombres réunis. +- On bat de deux façons lorsqu'on peut couvrir la dame par l'un et l'autre nombres directs, ou par un nombre direct et les deux nombres réunis. +- Enfin, on bat de trois façons lorsque pouvant couvrir la dame par l'un et l'autre nombres directs, on le peut aussi par les deux nombres réunis. + +De par sa nature, le doublet ne permet de battre une dame que d’une ou de deux façons seulement : + +- On bat d’une façon par un nombre direct ou par les deux nombres réunis. +- On bat de deux façons par un nombre direct et par les deux nombres réunis. + +On ne bat une dame que d’une seule façon par un doublet même lorsqu’on dispose sur une case de deux dames pouvant couvrir cette dame adverse. + +Plusieurs dames peuvent être battues lors un même coup. +Pour chaque dame battue et pour chaque façon de la battre, ce jan de récompense vaut 2 points par un coup simple ou 4 points par un doublet lorsque cette dame se trouve placée dans un des grands jans ; si la dame battue se trouve dans un des petits jans ou un des jans de retour, chaque façon vaut 4 points par un coup simple ou 6 points par un doublet. + +Les jans de récompense doivent être marqués par le joueur qui les réalise (sous peine d'être « envoyé à l'école », voir article XVI). + +Pour battre une dame adverse par les deux nombres réunis, on doit bénéficier d'un repos pour battre. C'est une case où doit aboutir l'un ou l'autre nombre afin de pouvoir porter, fictivement, le second sur la dame à battre. Cette case doit être vide, ou bien déjà tenue par son propre camp, ou encore être occupée en demi-case par une dame adverse, laquelle est alors également battue. + +On fait un JAN-QUI-NE-PEUT lorsque pour battre une dame adverse par les deux nombres réunis on ne trouve pas de passage libre et qu’on est alors obligé de se reposer sur une case pleine tenue par l'adversaire. La batterie s'effectue alors « à faux » et c'est l'adversaire qui gagne pour ce jan-qui-ne-peut autant de points qu'on en aurait gagné si on avait battu « à vrai ». + +Une dame battue à vrai ne peut l'être à faux dans un même coup. Mais plusieurs dames peuvent être battues en même temps : alors certaines peuvent l'être à vrai et d'autres à faux. + +Les points obtenus pour les batteries à vrai doivent se marquer avant ceux donnés pour les batteries à faux à l’adversaire qui devra les marquer en temps utile sous peine d’être envoyé à l’école (voir article XVI). + +## ARTICLE XI : LA BATTERIE DU COIN + +On fait ce jan de récompense lorsqu'on a pris son coin de repos, que celui de l'adversaire est vide et qu'on amène des nombres avec lesquels on pourrait prendre d'emblée ce dernier sans dédoubler son propre coin, c'est-à-dire sans se servir des deux dames qui le tiennent. + +Comme la faculté de prendre le coin adverse n'existe pas, on le bat, ce qui vaut 4 points par un coup simple et 6 points par un doublet. Cette batterie ne peut jamais s’effectuer à faux puisqu’elle se réalise avec les deux nombres directs et jamais par les deux nombres réunis. + +## ARTICLE XII : JANS DE DÉPART + +Il y en a trois : le JAN DE DEUX TABLES, le JAN DE MÉZÉAS et le JAN DE SIX TABLES. Ils ne peuvent être réalisés qu'au début d'une partie, d'un marqué ou d'un de leurs +relevés éventuels. + +### JAN DE DEUX TABLES + +Lorsqu'au commencement d'une partie, d'un marqué ou d'un relevé, n'ayant encore que deux dames abattues, on amène des nombres avec lesquels on pourrait placer une de ces dames sur son propre coin de repos, vide, et l'autre sur celui de l'adversaire, alors on fait un jan de deux tables si ce coin est également vide ; mais on fait un contre-jan de deux tables si l'adversaire a déjà pris son coin. + +Dans le premier cas, cette batterie des deux coins vaut pour soi 4 points par un coup simple et 6 points par un doublet ; dans le deuxième cas, cette batterie à faux vaut le même nombre de points pour l'adversaire. + +### JAN DE MÉZÉAS + +Lorsqu'au commencement d'une partie, d'un marqué ou d'un relevé, ayant pris son coin avec deux dames seulement abattues, on amène le coup suivant un ou deux as, on fait alors un jan de mézéas si le coin adverse est vide ; mais on fait un contre-jan de mézéas si l'adversaire a déjà pris son coin. + +Dans le premier cas, cette batterie du coin vaut pour soi 4 points par un as et 6 points par le doublet ; dans le deuxième cas, cette batterie à faux vaut le même nombre de points pour l'adversaire. + +### JAN DE SIX TABLES ou JAN DE TROIS COUPS + +Lorsqu'au commencement d'une partie, d'un marqué ou d'un relevé, ayant garni d'une dame quatre de ses six premières flèches (cases 2 à 7) lors des deux premiers coups, on amène au troisième des nombres avec lesquels on pourrait garnir les deux autres flèches, alors on fait un jan de six tables ou jan de trois coups. Ce jan vaut 4 points dans tous les cas puisqu'il ne peut être réalisé par un doublet. + +On n'est point obligé de garnir effectivement ces deux flèches : on a la faculté de jouer ce coup de la manière qu'on préfère. + +## ARTICLE XIII : PETIT JAN, GRAND JAN & JAN DE RETOUR + +### LE PLEIN + +Un jan est plein lorsqu'un joueur occupe chacune des six cases de ce jan avec au moins deux de ses dames. + +Chaque joueur peut faire le plein de son petit jan, de son grand jan et de son jan de retour. + +Le plein d'un jan peut être rompu puis refait. Il est possible de faire successivement au cours d'une partie le plein de plusieurs jans différents ou du même. + +### REMPLIR + +On remplit un jan lorsqu'on amène des nombres qui permettent d'achever le plein en y apportant la dernière dame. + +On peut remplir d'une, de deux ou de trois façons : + +- On remplit d'une façon lorsqu'on peut couvrir d’une dame la dernière demi-case par un nombre direct ou par les deux nombres réunis en jouant tout d'une. +- On remplit de deux façons lorsqu'on peut couvrir cette dernière demi-case par l'un et l'autre nombres directs ou par un nombre direct et les deux réunis. +- Enfin, on remplit de trois façons lorsque, pouvant la couvrir par l'un et l'autre nombres directs, on le peut aussi par les deux nombres réunis. + +Chaque façon de remplir vaut 4 points par un coup simple et 6 points par un doublet. + +Pour pouvoir remplir un jan de plusieurs façons, il faut nécessairement qu'il n'y manque qu'une seule dame puisque seule la dernière dame apportée remplit effectivement ; on ne remplit donc que d'une seule façon en faisant la dernière case d'emblée ou en couvrant dans le même coup les deux dernières demi-cases. + +Le doublet, par sa nature, ne permet tout au plus que deux façons de remplir. + +On ne remplit pas effectivement un jan lorsque, pouvant y faire le plein par un nombre, on doit le rompre pour jouer l'autre. Lorsque ainsi on « remplit en passant », on ne doit rien marquer. On n’est donc pas tenu d’effectuer ce remplissage. + +On ne peut remplir son jan de retour en utilisant ou en comptant comme façon de remplir l'une des deux dames qui tiennent son coin de repos car ce serait le dédoubler, ce qui est interdit. + +Après avoir marqué les points pour le remplissage du jan, il est obligatoire de remplir effectivement avec une ou deux dames, sous peine de fausse case et d’école (voir article XVII). + +Si on peut remplir de plusieurs façons, on remplit effectivement avec la dame de son choix et on est libre de jouer l’autre à sa guise. + +### CONSERVER + +On conserve le plein d'un jan lorsqu'on peut jouer les deux nombres amenés sans le rompre, c'est-à-dire sans se servir d'aucune des douze dames qui composent ce plein. + +La conservation d'un plein vaut 4 points par un coup simple et 6 points par un doublet. Il ne peut y avoir plus d’une seule façon de conserver. + +On peut user du privilège de conserver par impuissance lorsque, le plein réalisé, la disposition du jeu ne permet pas de jouer l'un ou les deux nombres. Seul le nombre 6 permet cette conservation puisque les nombres inférieurs peuvent tous être joués à l'intérieur du jan, quitte à rompre le plein. + +Par privilège, on peut conserver le plein de son jan de retour grâce à la sortie d’une, de deux ou de trois de ses dames. + +Comme pour le remplissage, il est obligatoire de conserver effectivement le plein d'un jan lorsque il est possible de jouer sans le rompre, sous peine de fausse case et d’école (voir article XVII). + +## ARTICLE XIV : JANS INTERDITS + +Il est interdit de placer une de ses dames dans le petit jan ou le grand jan de l'adversaire tant que celui-ci a la possibilité matérielle de remplir ce jan avec les dames dont il dispose. +Cette interdiction cesse donc normalement dès que l'adversaire a passé suffisamment de dames au delà des cases qui lui restent à faire pour remplir ce jan, de manière que ces dames ne puissent plus servir à faire ce plein qui est ainsi devenu impossible à réaliser faute de dames. +Ainsi l'adversaire ne pouvant plus réaliser le plein de ce jan, on a le droit d'y jouer en y plaçant une ou plusieurs de ses propres des dames. + +Par contre, en jouant tout d'une, il est toujours possible d'utiliser les cases vides du grand jan adverse (y compris le coin), même s'il peut encore être rempli, afin de s'y reposer pour passer une dame dans le jan de retour, dès lors que ce dernier n'est plus interdit. + +## ARTICLE XV : LA MARQUE + +On doit toujours marquer les jeux et les points qu'on gagne avant de toucher ses dames pour jouer ou avant de jeter les dés pour le coup suivant si ces points proviennent du coup de l'adversaire (jan-qui-ne-peut, contre-jans, écoles). + +La marque des points s'effectue au moyen des jetons. Pour 2 points, on place le jeton de son côté, à la pointe de sa deuxième flèche ou entre sa deuxième et sa troisième flèche ; pour 4 points, à la pointe de sa quatrième flèche ou entre sa quatrième et sa cinquième flèche ; pour 6 points, à la pointe de sa sixième flèche ou contre la bande transversale ; pour 8 points, de l'autre côté de cette bande, dans son grand jan ; et pour 10 points, contre la bande latérale de son grand jan ou à la pointe de la flèche de son coin de repos. 12 ou 0 points se marquent contre la bande de départ, entre les deux talons, comme au début de la partie. + +12 points font un jeu ou trou. Si les 12 points du jeu ont été marqués d'affilée à partir de zéro au talon, c'est-à-dire sans que l'adversaire n'ait marqué aucun point pendant cette série, le jeu est gagné bredouille et vaut 2 trous. Cet avantage du jeu double est valable aussi bien pour le premier joueur à marquer que pour le second. Le premier marque avec un seul jeton et peut gagner le jeu bredouille tant que son adversaire ne marque rien. Mais alors, si ce dernier marque, il le fait avec un double jeton nommé « bredouille » et continue de marquer ainsi tant que le premier joueur ne marque pas de nouveaux points. S'il marque ainsi au moins 12 points, il gagne le jeu bredouille en second. Mais auparavant, si le premier joueur vient à marquer à nouveau, il débredouille son adversaire en lui ôtant un des deux jetons et alors aucun des joueurs ne peut plus gagner le jeu bredouille. Par conséquent, lorsque les deux joueurs ont chacun une marque avec un simple jeton, le jeu sera nécessairement gagné simple par l'un ou l'autre joueur. + +La marque des jeux s'effectue au moyen des fichets. +Chaque joueur marque les jeux qu'il a gagnés en faisant progresser son fichet dans la série de trous pratiqués dans les bandes à la base des douze flèches de son petit jan et de son grand jan. Le premier trou se trouve à la base de son talon et le douzième et dernier à la base de son coin de repos. + +Les trous acquis doivent se marquer avant de toucher aux jetons. Le cas échéant, on démarque ensuite le jeton (bredouillé ou non) de l'adversaire en le remettant à zéro au talon. Puis, on démarque également son jeton (bredouillé ou non) si on a obtenu juste 12 points ; ou bien on marque l'excédent, appelé points de reste, avec un jeton à la manière normale. +Si, lors du même coup on donne des points à l'adversaire, celui-ci devra ensuite les marquer en partant de zéro avec un ou deux jetons selon qu'on a marqué ou non des points de reste. +On peut gagner plusieurs jeux, simples et doubles, dans un même coup. + +A la partie à écrire, le marqué peut être gagné simple, double ou quadruple selon que les trous qui le composent ont été ou non pris d'affilée (les autres marqués multiples sont rares et ne peuvent se jouer que par convention). +Si les trous n'ont pas été pris d'affilée, le marqué est simple. Si au moins 6 trous ont été pris d'affilée, le marqué est gagné en petite bredouille et compte double. Si au moins 12 trous ont été pris d'affilée, le marqué est gagné en grande bredouille et compte quadruple. +Comme pour le jeu bredouille, cet avantage est valable pour le premier ainsi que pour le second joueur à marquer des trous. Le second joueur prend alors le pavillon qu'il met à l'emplacement initial de son fichet dans la bande de départ. Si le premier joueur gagne de +nouveaux trous, il ôte le pavillon à son adversaire et le remet dans le trou central. Alors le marqué sera nécessairement gagné simple par l'un ou l'autre joueur. + +## ARTICLE XVI : LA TENUE + +Lorsqu'on gagne un ou plusieurs jeux grâce aux points obtenus par son propre coup de dés, on a le choix entre tenir (rester) ou user du privilège de s'en aller. Mais si les points qui donnent le ou les trous proviennent du coup de l'adversaire (jan-qui-ne-peut, écoles), on doit obligatoirement tenir. + +Quand on tient, après avoir marqué son ou ses trous, on démarque le cas échéant son adversaire, on marque éventuellement ses points de reste et on continue de jouer normalement. L'adversaire marque ensuite les points que pourrait lui rapporter ce coup (voir article XV). + +Quand on décide de s'en aller, après avoir marqué son ou ses trous, on prévient verbalement son adversaire de son intention, car celui-ci peut s'y opposer en cas de faute ou d'école. Quand ce dernier a agréé verbalement ou en commençant à rompre son jeu, on démarque alors tous les jetons, et les dames des deux camps sont toutes relevées et remises au talon. Seuls les trous gagnés restent acquis aux joueurs. Aucun point de reste ne peut être +marqué et l'adversaire ne peut pas marquer de points ou de trous pour ce coup. La partie reprend comme au début et, par privilège, le joueur qui s’en est allé a la primauté du dé pour ce relevé . Il rejette donc les dés et joue. + +A la partie à écrire, à partir du moment où un joueur a marqué au moins six trous, si l'un ou l'autre joueur s'en va, le marqué est terminé. +Le gagnant est alors celui qui a marqué le plus de trous. En cas d'égalité, le marqué est nul et il y a refait. +Ainsi, pour gagner une grande bredouille, il convient donc de toujours tenir à partir du sixième trou, acquis en petite bredouille, jusqu'à l'obtention du douzième et pouvoir s’en aller ; sinon on continue à jouer pour un treizième trou ou davantage si nécessaire ou si on le souhaite. Il faut aussi que l'adversaire ne marque pas un trou et s’en aille, ce qui terminerait ainsi le marqué simple ; mais dans le cas où celui-ci déciderait de tenir, il pourrait gagner aussi une grande bredouille en jouant sans jamais s’en aller jusqu’à l’obtention de douze trous, ou plus si on en a déjà soi-même marqués davantage ou simplement s’il le souhaite. Alors il doit pouvoir s’en aller pour terminer le marqué. + +## ARTICLE XVII : LES FAUTES ET LES ÉCOLES + +Il y a trois types de faute à ce jeu : + +1°) Les simples fautes, peu préjudiciables à l'adversaire et dont certaines peuvent être rectifiées normalement (exemples : jouer hors tour, lancer les dés hors de la table, bousculer accidentellement la disposition du jeu, ou bien oublier de marquer une école). On n'encourt aucune sanction pour ces fautes. + +2°) Les fautes de case ou fausse case. Elles peuvent être préjudiciables et se produisent lorsqu'on ne joue pas ses dames aux cases où l'on devrait en fonction des nombres obtenus ou si l'on viole une loi du jeu en jouant ses dames d'une manière interdite (lois relatives au coin de repos, aux jans interdits, au remplissage et à la conservation). Les fautes de case peuvent susciter une école lorsqu’on a marqué des points pour un jan et qu’on ne le réalise pas effectivement alors qu’on y est obligé par la règle (ex : marquer pour remplir ou pour conserver et ne pas le faire). Alors, en plus du respect que l'on doit à la règle : « dame touchée, dame abandonnée, dame jouée », sauf si on a prévenu en disant : « j'adoube », on doit se soumettre à la décision de l'adversaire quant à l'éventuelle rectification de la faute et à la manière de le faire. + +L’adversaire doit signaler la ou les fautes avant de jeter les dés pour son propre coup ; il a la liberté de rectifier cette ou ces fautes selon son intérêt, tout en respectant les règles, ou bien de laisser le jeu tel quel sans rectification. Lorsqu’on a pris son coin par puissance alors qu’on pouvait le prendre par effet, on peut être empêché par l’adversaire de le prendre lors de ce coup si la faute est reconnue et qu’il est possible de jouer autrement. Si par fausse case on a couvert une dame en demi-case, on peut aussi être empêché de la couvrir lors du même coup si la faute est reconnue. + +3°) Les fautes de marque sont toujours préjudiciables ; elles ont lieu lorsqu'on oublie de marquer des points ou des trous, lorsqu'on en marque trop, ou lorsqu'on les marque incorrectement. On peut alors être pénalisé et « envoyé à l'école » par l'adversaire. Cette faute ou école, est consommée lorsqu'on a jeté les dés ou touché ses dames pour jouer ou encore lorsqu'on a trop avancé son jeton de marque et qu'on l'a lâché. Dans certains cas, l’école est consommée dès qu’on a fait une déclaration intentionnelle (s’en aller, école, invitation à jouer). + +L'école rapporte autant de points à l'adversaire qu'on en a marqué en moins ou en trop lors d'un coup. De plus, dans ce dernier cas, l'adversaire rétablit correctement la marque en supprimant les points qu'on a indûment marqués. En marquant ces points d'école, ou juste après l'avoir fait, l'adversaire doit le signaler en disant: « Ecole ! » ou « tant de points d’école ». + +Nul n'est tenu de marquer une école, on ne fait donc pas « école d'école ». Mais si on la marque, elle doit l'être dans son intégralité sous peine de rectification si l'adversaire le désire. On peut aussi forcer le joueur fautif à rectifier correctement sa marque, sans marquer l'école. + +On fait une fausse école lorsqu'on marque, soit incorrectement une école, soit une école qui n'existe pas. C'est donc une école que l'adversaire joueur peut marquer normalement à son avantage. +Il y a augmentation d’école, lorsqu’un joueur a fait une école que son adversaire a marquée et que, pensant que ce dernier s’est trompé, il démarque cette école et les marque à son profit pour fausse école ; mais l’adversaire, persistant à croire l’école justifiée, démarque alors le joueur, rectifie la première école et ajoute encore à son profit les points pour cette deuxième école (des points marqués pour la soi-disant fausse école). L’affaire pourrait continuer selon le même scénario indéfiniment si les joueurs n’en venaient alors à une franche explication. + +A tout moment un joueur peut demander à son adversaire la raison des points qu’il marque (pour école ou autres cas) ou qu’il lui enlève. Ce dernier doit alors s’expliquer. + +On ne fait pas école de trou en marquant simple un jeu gagné bredouille. Mais on fait école de points pour les trous qu’on omet de marquer en raison des points gagnés ou pour les trous marqués en trop pour des points non gagnés. + +Les points d'école se marquent après tous les autres. + +## ARTICLE XVIII : LE DÉROULEMENT DES COUPS + +Pour que le coup soit régulier, chaque joueur doit obligatoirement agir dans cet ordre : + +Dès la fin du coup de l’adversaire : + +1. Le cas échéant, marquer les jan-qui-ne-peut ou les contre-jans de l'adversaire. +2. Le cas échéant et si on le souhaite, marquer les écoles de l'adversaire et le signaler en disant : « École ! » ; éventuellement rectifier les fausses cases et la marque. + +Ensuite : + +3. Jeter les dés pour son coup. Le cas échéant, l'adversaire marque alors les écoles qu'on a faites relativement aux 1° et 2°. Règlement des fausses écoles et augmentations d’école. +4. Le cas échéant, marquer pour les jans de départ, les jans de récompense (batteries des dames et du coin), les jans remplis ou conservés, ou les points pour la sortie. +5. Le cas échéant, décider de tenir ou de s'en aller : + +- Tenir : démarquer les points et marquer éventuellement ses propres points de reste. +- S'en aller : l'annoncer puis rompre son jeu après accord de l’adversaire. Ensuite, relever ses dames, démarquer tous les points et jeter de nouveau les dés pour jouer (sauf si la partie ou le marqué est terminé). + +6. En cas de sortie : relever ses dames, ne pas démarquer les points des joueurs et jeter de nouveau les dés pour jouer. +7. Jouer les deux nombres obtenus par les dés si on le peut. + +C'est alors à l'adversaire de jouer selon le même processus. + +8. Le cas échéant, marquer les écoles que l'adversaire a pu faire, relativement à son 1° et 2°, dès qu'il a jeté les dés, ou rompre ceux-ci pour procéder à la rectification de la marque. + +Le non-respect de cet ordre de jeu est une faute, qui peut éventuellement être pénalisée par l'adversaire. + +## ARTICLE XIX : LA PARTIE À ÉCRIRE + +### LE DÉROULEMENT DE LA PARTIE À TROIS OU QUATRE JOUEURS + +Le nombre de marqués choisi pour la partie doit être un multiple du nombre de joueurs participants, de sorte qu'un joueur puisse rencontrer chacun de ses adversaires autant de fois que ceux-ci se rencontrent entre eux. + +A trois joueurs, on tire au sort ceux qui vont jouer le premier marqué. + +Ensuite, c'est celui qui tire le plus fort nombre qui commence avec les dames blanches et joue les deux nombres obtenus. + +Pour le deuxième marqué, le gagnant est remplacé par le troisième joueur, mais la primauté du dé appartient à celui qui est resté à la table. + +Pour le marqué suivant, le troisième joueur reste à la table mais les autres alternent. + +Chacun joue ainsi deux marqués de suite avec des adversaires différents. Seul le gagnant du premier marqué ne joue qu'une seule fois au début de la partie et à la fin. + +En cas de refait, les mêmes joueurs restent pour rejouer et celui qui avait la primauté du dé au marqué nul, la conserve. + +A quatre joueurs, on joue en équipe de deux. Les équipiers partagent les gains et les pertes. + +On procède comme pour trois joueurs et chacun joue deux marqués de suite, un contre chaque adversaire, et cède ensuite la place à son partenaire. Pareillement, seul le gagnant du premier marqué ne joue qu'une seule fois, au début et à la fin de la partie. + +Les joueurs qui ne participent pas au marqué en cours peuvent conseiller ceux qui sont à la table (adversaires à trois joueurs ou équipiers à quatre) selon leur intérêt ; mais il leur est interdit de toucher aux éléments du jeu. + +### LES PAIEMENTS + +Chaque marqué est payé au vainqueur autant de jetons qu'il a acquis de trous, déduction faite de ceux du vaincu. Ce dernier paye en outre une consolation de deux jetons supplémentaires au vainqueur ainsi qu'à l'autre joueur si la partie se joue à trois. + +Si le marqué est gagné en petite bredouille, chaque trou acquis par le vainqueur est alors payé double (2 jetons) ainsi que la consolation (4 jetons). + +Si le marqué est gagné en grande bredouille, chaque trou acquis par le vainqueur est alors payé quadruple (4 jetons) ainsi que la consolation (8 jetons). + +Chaque trou acquis par le vaincu est déduit du compte et ne vaut toujours qu'un seul jeton. + +En cas de refait, le prix de la consolation est le double de celui du marqué nul précédent. Il double à chaque refait successif. + +A trois joueurs, le vaincu doit toujours payer de surcroît la consolation à celui qui n'a pas joué ce marqué, quel que soit le prix de cette consolation. + +De plus, à chaque défaite subie, le vaincu met un jeton (parfois deux) de côté pour en indiquer le nombre et permettre le règlement ultérieur des paris. + +L'ensemble de ces jetons forme une queue qui revient à la fin de la partie au joueur ayant gagné le plus de jetons lors des marqués. En cas d'égalité, cette queue de jetons doit être divisée équitablement entre les joueurs gagnants. + +Il n'est pas obligatoire de recourir à cette queue de jetons lorsque la marque est tenue par écrit, mais on peut la compter par convention. + +Ensuite, chaque joueur règle équitablement les paris qu'il a perdus avec chacun de ses adversaires. + +On appelle pari tout marqué qui excède le contingent de chaque joueur. Ce contingent correspond à la moyenne des marqués joués entre deux adversaires. + +Ainsi, si deux joueurs s'affrontent en huit marqués, le contingent de chacun est de quatre, et tout marqué gagné ou perdu par un joueur au delà de ces quatre est un pari gagné ou perdu. Ce gain ou cette perte est double puisqu’un pari gagné par un joueur est en plus un pari perdu pour son adversaire : si un joueur gagne cinq marqués, l’autre n’en peut gagner que trois. + +Le premier double pari s'appelle le postillon et se paie 28 jetons, dont 20 de queue ; chacun des paris suivants se paie 8 jetons. Ce paiement s'effectue entre chaque joueur. A trois joueurs, il est ainsi possible de gagner ou de perdre deux postillons entre autres paris, un par adversaire. + +Enfin, on procède au règlement définitif de la partie en convertissant les gains des joueurs en fiches, ou en tout autre équivalent, dont la valeur a été préalablement établie par les joueurs (une fiche peut, par exemple, valoir 5 jetons). + +## ARTICLE XX : FIN DE LA PARTIE + +### LA PARTIE ORDINAIRE + +La partie ordinaire, appelée aussi le « tour » de trictrac, est terminée quand un joueur obtient son douzième et dernier trou. Ce trou peut être obtenu grâce aux points gagnés par son propre coup de dé ou par celui de l’adversaire ; il n’est pas nécessaire de pouvoir s’en aller pour terminer le tour. + +Selon convention préalable, la partie peut être gagnée simple ou double. Elle est gagnée double, c’est-à-dire en grande bredouille, lorsqu’un joueur réussit à marquer les douze trous d’affilée. Le second joueur à marquer peut jouir aussi de cette faculté en prenant le pavillon et en le gardant jusqu’à parvenir à marquer les douze trous sans que le premier joueur ne marque à nouveau et ne lui enlève alors le pavillon. Au cas où aucun des joueurs ne réussit à marquer les douze trous d’affilée, la partie est gagnée simple. + +Une autre convention permet de gagner la partie quadruple quand le gagnant a été le seul à marquer. Alors la partie est gagnée triple par le second joueur à marquer s’il réalise la grande bredouille comme indiqué précédemment. La partie est gagnée double si, tous les joueurs étant débredouillés, le perdant n’a pas réussi à marquer au moins six trous, sinon elle est gagnée simple. + +On procède alors au règlement en fonction de l’enjeu établi avant le début de la partie. + +La partie est terminée lorsque le perdant s’est acquitté de sa dette envers le gagnant. + +### LA PARTIE À ÉCRIRE + +Comme indiqué à l’article II, la partie à écrire consiste en un certain nombre de marqués convenu à l’avance entre les joueurs. Lorsque ces marqués ont été joués, ainsi que les éventuels refaits, on procède au règlement de la partie en effectuant le compte et les paiements comme indiqué à l’article XIX. + +La partie est terminée lorsque toutes les dettes ont été réglées au(x) vainqueur(s). + +### ANNEXE : TARIF + +C’est le tableau récapitulatif de la valeur en points de toutes les rencontres : jans et figures de ce jeu. + +Lire « J » pour le joueur (celui qui a lancé les dés) et « A » pour l’adversaire : ils désignent le bénéficiaire. Les chiffres indiquent le nombre de points gagnés. + +| RENCONTRES | | occurrence | Par dé simple | Par doublet | +| ------------------------------------------------ | --- | ---------- | ------------- | ----------- | +| Jan de six tables (jan de trois coups) | J | | 4 | - | +| Jan de deux tables | J | | 4 | 6 | +| Contre-jan de deux tables | A | | 4 | 6 | +| Jan de mézéas | J | | 4 | 6 | +| Contre-jan de mézéas | A | | 4 | 6 | +| Petit jan rempli | J | Par façon | 4 | 6 | +| Petit jan conservé | J | | 4 | 6 | +| Grand jan rempli | J | Par façon | 4 | 6 | +| Grand jan conservé | J | | 4 | 6 | +| Jan de retour rempli | J | Par façon | 4 | 6 | +| Jan de retour conservé | J | | 4 | 6 | +| Dame battue à vrai dans la table des petits jans | J | Par façon | 4 | 6 | +| Dame battue à faux dans la table des petits jans | A | Par façon | 4 | 6 | +| Dame battue à vrai dans la table des grands jans | J | Par façon | 2 | 4 | +| Dame battue à faux dans la table des grands jans | A | Par façon | 2 | 4 | +| Coin battu | J | | 4 | 6 | +| Sortie (de la dernière dame) | J | | 4 | 6 | +| Impuissance (nombre non joué) | A | Par nombre | 2 | 2 | +| Pile de misère réalisée | J | | 4 | 6 | +| Pile de misère conservée | J | | 4 | 6 | + +Les écoles valent à l’adversaire le nombre de points exact qu’on aurait dû marquer en plus ou en moins par rapport à ceux qu’on a réellement marqués. diff --git a/doc/refs/outputs.md b/doc/refs/outputs.md deleted file mode 100644 index 895062b..0000000 --- a/doc/refs/outputs.md +++ /dev/null @@ -1,417 +0,0 @@ -# Outputs - -## 50 episodes - 1000 steps max - desktop - -{"episode": 0, "reward": -1798.7162, "steps count": 1000, "duration": 11} -{"episode": 1, "reward": -1794.8162, "steps count": 1000, "duration": 32} -{"episode": 2, "reward": -1387.7109, "steps count": 1000, "duration": 58} -{"episode": 3, "reward": -42.5005, "steps count": 1000, "duration": 82} -{"episode": 4, "reward": -48.2005, "steps count": 1000, "duration": 109} -{"episode": 5, "reward": 1.2000, "steps count": 1000, "duration": 141} -{"episode": 6, "reward": 8.8000, "steps count": 1000, "duration": 184} -{"episode": 7, "reward": 6.9002, "steps count": 1000, "duration": 219} -{"episode": 8, "reward": 16.5001, "steps count": 1000, "duration": 248} -{"episode": 9, "reward": -2.6000, "steps count": 1000, "duration": 281} -{"episode": 10, "reward": 3.0999, "steps count": 1000, "duration": 324} -{"episode": 11, "reward": -34.7004, "steps count": 1000, "duration": 497} -{"episode": 12, "reward": -15.7998, "steps count": 1000, "duration": 466} -{"episode": 13, "reward": 6.9000, "steps count": 1000, "duration": 496} -{"episode": 14, "reward": 6.3000, "steps count": 1000, "duration": 540} -{"episode": 15, "reward": -2.6000, "steps count": 1000, "duration": 581} -{"episode": 16, "reward": -33.0003, "steps count": 1000, "duration": 641} -{"episode": 17, "reward": -36.8000, "steps count": 1000, "duration": 665} -{"episode": 18, "reward": -10.1997, "steps count": 1000, "duration": 753} -{"episode": 19, "reward": -88.1014, "steps count": 1000, "duration": 837} -{"episode": 20, "reward": -57.5002, "steps count": 1000, "duration": 881} -{"episode": 21, "reward": -17.7997, "steps count": 1000, "duration": 1159} -{"episode": 22, "reward": -25.4000, "steps count": 1000, "duration": 1235} -{"episode": 23, "reward": -104.4013, "steps count": 995, "duration": 1290} -{"episode": 24, "reward": -268.6004, "steps count": 1000, "duration": 1322} -{"episode": 25, "reward": -743.6052, "steps count": 1000, "duration": 1398} -{"episode": 26, "reward": -821.5029, "steps count": 1000, "duration": 1427} -{"episode": 27, "reward": -211.5993, "steps count": 1000, "duration": 1409} -{"episode": 28, "reward": -276.1974, "steps count": 1000, "duration": 1463} -{"episode": 29, "reward": -222.9980, "steps count": 1000, "duration": 1509} -{"episode": 30, "reward": -298.9973, "steps count": 1000, "duration": 1560} -{"episode": 31, "reward": -164.0011, "steps count": 1000, "duration": 1752} -{"episode": 32, "reward": -221.0990, "steps count": 1000, "duration": 1807} -{"episode": 33, "reward": -260.9996, "steps count": 1000, "duration": 1730} -{"episode": 34, "reward": -420.5959, "steps count": 1000, "duration": 1767} -{"episode": 35, "reward": -407.2964, "steps count": 1000, "duration": 1815} -{"episode": 36, "reward": -291.2966, "steps count": 1000, "duration": 1870} - -thread 'main' has overflowed its stack -fatal runtime error: stack overflow, aborting -error: Recipe `trainbot` was terminated on line 24 by signal 6 - -## 50 episodes - 700 steps max - desktop - -const MEMORY_SIZE: usize = 4096; -const DENSE_SIZE: usize = 128; -const EPS_DECAY: f64 = 1000.0; -const EPS_START: f64 = 0.9; -const EPS_END: f64 = 0.05; - -> Entraînement -> {"episode": 0, "reward": -862.8993, "steps count": 700, "duration": 6} -> {"episode": 1, "reward": -418.8971, "steps count": 700, "duration": 13} -> {"episode": 2, "reward": -64.9999, "steps count": 453, "duration": 14} -> {"episode": 3, "reward": -142.8002, "steps count": 700, "duration": 31} -> {"episode": 4, "reward": -74.4004, "steps count": 700, "duration": 45} -> {"episode": 5, "reward": -40.2002, "steps count": 700, "duration": 58} -> {"episode": 6, "reward": -21.1998, "steps count": 700, "duration": 70} -> {"episode": 7, "reward": 99.7000, "steps count": 642, "duration": 79} -> {"episode": 8, "reward": -5.9999, "steps count": 700, "duration": 99} -> {"episode": 9, "reward": -7.8999, "steps count": 700, "duration": 118} -> {"episode": 10, "reward": 92.5000, "steps count": 624, "duration": 117} -> {"episode": 11, "reward": -17.1998, "steps count": 700, "duration": 144} -> {"episode": 12, "reward": 1.7000, "steps count": 700, "duration": 157} -> {"episode": 13, "reward": -7.9000, "steps count": 700, "duration": 172} -> {"episode": 14, "reward": -7.9000, "steps count": 700, "duration": 196} -> {"episode": 15, "reward": -2.8000, "steps count": 700, "duration": 214} -> {"episode": 16, "reward": 16.8002, "steps count": 700, "duration": 250} -> {"episode": 17, "reward": -47.7001, "steps count": 700, "duration": 272} -> k{"episode": 18, "reward": -13.6000, "steps count": 700, "duration": 288} -> {"episode": 19, "reward": -79.9002, "steps count": 700, "duration": 304} -> {"episode": 20, "reward": -355.5985, "steps count": 700, "duration": 317} -> {"episode": 21, "reward": -205.5001, "steps count": 700, "duration": 333} -> {"episode": 22, "reward": -207.3974, "steps count": 700, "duration": 348} -> {"episode": 23, "reward": -161.7999, "steps count": 700, "duration": 367} - ---- - -const MEMORY_SIZE: usize = 8192; -const DENSE_SIZE: usize = 128; -const EPS_DECAY: f64 = 10000.0; -const EPS_START: f64 = 0.9; -const EPS_END: f64 = 0.05; - -> Entraînement -> {"episode": 0, "reward": -1119.9921, "steps count": 700, "duration": 6} -> {"episode": 1, "reward": -928.6963, "steps count": 700, "duration": 13} -> {"episode": 2, "reward": -364.5009, "steps count": 380, "duration": 11} -> {"episode": 3, "reward": -797.5981, "steps count": 700, "duration": 28} -> {"episode": 4, "reward": -577.5994, "steps count": 599, "duration": 34} -> {"episode": 5, "reward": -725.2992, "steps count": 700, "duration": 49} -> {"episode": 6, "reward": -638.8995, "steps count": 700, "duration": 59} -> {"episode": 7, "reward": -1039.1932, "steps count": 700, "duration": 73} -> field invalid : White, 3, Board { positions: [13, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -2, 0, -11] } - -thread 'main' panicked at store/src/game.rs:556:65: -called `Result::unwrap()` on an `Err` value: FieldInvalid -note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -error: Recipe `trainbot` failed on line 27 with exit code 101 - ---- - -# [allow(unused)] - -const MEMORY_SIZE: usize = 8192; -const DENSE_SIZE: usize = 256; -const EPS_DECAY: f64 = 10000.0; -const EPS_START: f64 = 0.9; -const EPS_END: f64 = 0.05; - -> Entraînement -> {"episode": 0, "reward": -1102.6925, "steps count": 700, "duration": 9} -> field invalid : White, 6, Board { positions: [14, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, 0, 0, -13] } - -thread 'main' panicked at store/src/game.rs:556:65: -called `Result::unwrap()` on an `Err` value: FieldInvalid -note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -error: Recipe `trainbot` failed on line 27 with exit code 101 - ---- - -const MEMORY_SIZE: usize = 8192; -const DENSE_SIZE: usize = 256; -const EPS_DECAY: f64 = 1000.0; -const EPS_START: f64 = 0.9; -const EPS_END: f64 = 0.05; - -> Entraînement -> {"episode": 0, "reward": -1116.2921, "steps count": 700, "duration": 9} -> {"episode": 1, "reward": -1116.2922, "steps count": 700, "duration": 18} -> {"episode": 2, "reward": -1119.9921, "steps count": 700, "duration": 29} -> {"episode": 3, "reward": -1089.1927, "steps count": 700, "duration": 41} -> {"episode": 4, "reward": -1116.2921, "steps count": 700, "duration": 53} -> {"episode": 5, "reward": -684.8043, "steps count": 700, "duration": 66} -> {"episode": 6, "reward": 0.3000, "steps count": 700, "duration": 80} -> {"episode": 7, "reward": 2.0000, "steps count": 700, "duration": 96} -> {"episode": 8, "reward": 30.9001, "steps count": 700, "duration": 112} -> {"episode": 9, "reward": 0.3000, "steps count": 700, "duration": 128} -> {"episode": 10, "reward": 0.3000, "steps count": 700, "duration": 141} -> {"episode": 11, "reward": 8.8000, "steps count": 700, "duration": 155} -> {"episode": 12, "reward": 7.1000, "steps count": 700, "duration": 169} -> {"episode": 13, "reward": 17.3001, "steps count": 700, "duration": 190} -> {"episode": 14, "reward": -107.9005, "steps count": 700, "duration": 210} -> {"episode": 15, "reward": 7.1001, "steps count": 700, "duration": 236} -> {"episode": 16, "reward": 17.3001, "steps count": 700, "duration": 268} -> {"episode": 17, "reward": 7.1000, "steps count": 700, "duration": 283} -> {"episode": 18, "reward": -5.9000, "steps count": 700, "duration": 300} -> {"episode": 19, "reward": -36.8009, "steps count": 700, "duration": 316} -> {"episode": 20, "reward": 19.0001, "steps count": 700, "duration": 332} -> {"episode": 21, "reward": 113.3000, "steps count": 461, "duration": 227} -> field invalid : White, 1, Board { positions: [0, 2, 2, 0, 2, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -3, -7, -2, -1, 0, -1, -1] } - -thread 'main' panicked at store/src/game.rs:556:65: -called `Result::unwrap()` on an `Err` value: FieldInvalid -note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -error: Recipe `trainbot` failed on line 27 with exit code 101 - ---- - -num_episodes: 50, -// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant -// max_steps: 700, // must be set in environment.rs with the MAX_STEPS constant -dense_size: 256, // neural network complexity -eps_start: 0.9, // epsilon initial value (0.9 => more exploration) -eps_end: 0.05, -eps_decay: 1000.0, - -> Entraînement -> {"episode": 0, "reward": -1118.8921, "steps count": 700, "duration": 9} -> {"episode": 1, "reward": -1119.9921, "steps count": 700, "duration": 17} -> {"episode": 2, "reward": -1118.8921, "steps count": 700, "duration": 28} -> {"episode": 3, "reward": -283.5977, "steps count": 700, "duration": 41} -> {"episode": 4, "reward": -23.4998, "steps count": 700, "duration": 54} -> {"episode": 5, "reward": -31.9999, "steps count": 700, "duration": 68} -> {"episode": 6, "reward": 2.0000, "steps count": 700, "duration": 82} -> {"episode": 7, "reward": 109.3000, "steps count": 192, "duration": 26} -> {"episode": 8, "reward": -4.8000, "steps count": 700, "duration": 102} -> {"episode": 9, "reward": 15.6001, "steps count": 700, "duration": 124} -> {"episode": 10, "reward": 15.6002, "steps count": 700, "duration": 144} -> {"episode": 11, "reward": -65.7008, "steps count": 700, "duration": 162} -> {"episode": 12, "reward": 19.0002, "steps count": 700, "duration": 182} -> {"episode": 13, "reward": 20.7001, "steps count": 700, "duration": 197} -> {"episode": 14, "reward": 12.2002, "steps count": 700, "duration": 229} -> {"episode": 15, "reward": -32.0007, "steps count": 700, "duration": 242} -> {"episode": 16, "reward": 10.5000, "steps count": 700, "duration": 287} -> {"episode": 17, "reward": 24.1001, "steps count": 700, "duration": 318} -> {"episode": 18, "reward": 25.8002, "steps count": 700, "duration": 335} -> {"episode": 19, "reward": 29.2001, "steps count": 700, "duration": 367} -> {"episode": 20, "reward": 9.1000, "steps count": 700, "duration": 366} -> {"episode": 21, "reward": 3.7001, "steps count": 700, "duration": 398} -> {"episode": 22, "reward": 10.5000, "steps count": 700, "duration": 417} -> {"episode": 23, "reward": 10.5000, "steps count": 700, "duration": 438} -> {"episode": 24, "reward": 13.9000, "steps count": 700, "duration": 444} -> {"episode": 25, "reward": 7.1000, "steps count": 700, "duration": 486} -> {"episode": 26, "reward": 12.2001, "steps count": 700, "duration": 499} -> {"episode": 27, "reward": 8.8001, "steps count": 700, "duration": 554} -> {"episode": 28, "reward": -6.5000, "steps count": 700, "duration": 608} -> {"episode": 29, "reward": -3.1000, "steps count": 700, "duration": 633} -> {"episode": 30, "reward": -32.0001, "steps count": 700, "duration": 696} -> {"episode": 31, "reward": 22.4002, "steps count": 700, "duration": 843} -> {"episode": 32, "reward": -77.9004, "steps count": 700, "duration": 817} -> {"episode": 33, "reward": -368.5993, "steps count": 700, "duration": 827} -> {"episode": 34, "reward": -254.6986, "steps count": 700, "duration": 852} -> {"episode": 35, "reward": -433.1992, "steps count": 700, "duration": 884} -> {"episode": 36, "reward": -521.6010, "steps count": 700, "duration": 905} -> {"episode": 37, "reward": -71.1004, "steps count": 700, "duration": 930} -> {"episode": 38, "reward": -251.0004, "steps count": 700, "duration": 956} -> {"episode": 39, "reward": -594.7045, "steps count": 700, "duration": 982} -> {"episode": 40, "reward": -154.4001, "steps count": 700, "duration": 1008} -> {"episode": 41, "reward": -171.3994, "steps count": 700, "duration": 1033} -> {"episode": 42, "reward": -118.7004, "steps count": 700, "duration": 1059} -> {"episode": 43, "reward": -137.4003, "steps count": 700, "duration": 1087} - -thread 'main' has overflowed its stack -fatal runtime error: stack overflow, aborting -error: Recipe `trainbot` was terminated on line 27 by signal 6 - ---- - -num_episodes: 40, -// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant -// max_steps: 1500, // must be set in environment.rs with the MAX_STEPS constant -dense_size: 256, // neural network complexity -eps_start: 0.9, // epsilon initial value (0.9 => more exploration) -eps_end: 0.05, -eps_decay: 1000.0, - -> Entraînement -> {"episode": 0, "reward": -2399.9993, "steps count": 1500, "duration": 31} -> {"episode": 1, "reward": -2061.6736, "steps count": 1500, "duration": 81} -> {"episode": 2, "reward": -48.9010, "steps count": 1500, "duration": 145} -> {"episode": 3, "reward": 3.8000, "steps count": 1500, "duration": 215} -> {"episode": 4, "reward": -6.3999, "steps count": 1500, "duration": 302} -> {"episode": 5, "reward": 20.8004, "steps count": 1500, "duration": 374} -> {"episode": 6, "reward": 49.6992, "steps count": 1500, "duration": 469} -> {"episode": 7, "reward": 29.3002, "steps count": 1500, "duration": 597} -> {"episode": 8, "reward": 34.3999, "steps count": 1500, "duration": 710} -> {"episode": 9, "reward": 115.3003, "steps count": 966, "duration": 515} -> {"episode": 10, "reward": 25.9004, "steps count": 1500, "duration": 852} -> {"episode": 11, "reward": -122.0007, "steps count": 1500, "duration": 1017} -> {"episode": 12, "reward": -274.9966, "steps count": 1500, "duration": 1073} -> {"episode": 13, "reward": 54.8994, "steps count": 651, "duration": 518} -> {"episode": 14, "reward": -439.8978, "steps count": 1500, "duration": 1244} -> {"episode": 15, "reward": -506.1997, "steps count": 1500, "duration": 1676} -> {"episode": 16, "reward": -829.5031, "steps count": 1500, "duration": 1855} -> {"episode": 17, "reward": -545.2961, "steps count": 1500, "duration": 1892} -> {"episode": 18, "reward": -795.2026, "steps count": 1500, "duration": 2008} -> {"episode": 19, "reward": -637.1031, "steps count": 1500, "duration": 2124} -> {"episode": 20, "reward": -989.6997, "steps count": 1500, "duration": 2241} - -thread 'main' has overflowed its stack -fatal runtime error: stack overflow, aborting -error: Recipe `trainbot` was terminated on line 27 by signal 6 - ---- - -num_episodes: 40, -// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant -// max_steps: 1000, // must be set in environment.rs with the MAX_STEPS constant -dense_size: 256, // neural network complexity -eps_start: 0.9, // epsilon initial value (0.9 => more exploration) -eps_end: 0.05, -eps_decay: 10000.0, - -> Entraînement -> {"episode": 0, "reward": -1598.8848, "steps count": 1000, "duration": 16} -> {"episode": 1, "reward": -1531.9866, "steps count": 1000, "duration": 34} -> {"episode": 2, "reward": -515.6000, "steps count": 530, "duration": 25} -> {"episode": 3, "reward": -396.1008, "steps count": 441, "duration": 27} -> {"episode": 4, "reward": -540.6996, "steps count": 605, "duration": 43} -> {"episode": 5, "reward": -976.0975, "steps count": 1000, "duration": 89} -> {"episode": 6, "reward": -1014.2944, "steps count": 1000, "duration": 117} -> {"episode": 7, "reward": -806.7012, "steps count": 1000, "duration": 140} -> {"episode": 8, "reward": -1276.6891, "steps count": 1000, "duration": 166} -> {"episode": 9, "reward": -1554.3855, "steps count": 1000, "duration": 197} -> {"episode": 10, "reward": -1178.3925, "steps count": 1000, "duration": 219} -> {"episode": 11, "reward": -1457.4869, "steps count": 1000, "duration": 258} -> {"episode": 12, "reward": -1475.8882, "steps count": 1000, "duration": 291} - ---- - -num_episodes: 40, -// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant -// max_steps: 1000, // must be set in environment.rs with the MAX_STEPS constant -dense_size: 256, // neural network complexity -eps_start: 0.9, // epsilon initial value (0.9 => more exploration) -eps_end: 0.05, -eps_decay: 3000.0, - -> Entraînement -> {"episode": 0, "reward": -1598.8848, "steps count": 1000, "duration": 15} -> {"episode": 1, "reward": -1599.9847, "steps count": 1000, "duration": 33} -> {"episode": 2, "reward": -751.7018, "steps count": 1000, "duration": 57} -> {"episode": 3, "reward": -402.8979, "steps count": 1000, "duration": 81} -> {"episode": 4, "reward": -289.2985, "steps count": 1000, "duration": 108} -> {"episode": 5, "reward": -231.4988, "steps count": 1000, "duration": 140} -> {"episode": 6, "reward": -138.0006, "steps count": 1000, "duration": 165} -> {"episode": 7, "reward": -145.0998, "steps count": 1000, "duration": 200} -> {"episode": 8, "reward": -60.4005, "steps count": 1000, "duration": 236} -> {"episode": 9, "reward": -35.7999, "steps count": 1000, "duration": 276} -> {"episode": 10, "reward": -42.2002, "steps count": 1000, "duration": 313} -> {"episode": 11, "reward": 69.0002, "steps count": 874, "duration": 300} -> {"episode": 12, "reward": 93.2000, "steps count": 421, "duration": 153} -> {"episode": 13, "reward": -324.9010, "steps count": 866, "duration": 364} -> {"episode": 14, "reward": -1331.3883, "steps count": 1000, "duration": 478} -> {"episode": 15, "reward": -1544.5859, "steps count": 1000, "duration": 514} -> {"episode": 16, "reward": -1599.9847, "steps count": 1000, "duration": 552} - ---- - -Nouveaux points... - -num_episodes: 40, -// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant -// max_steps: 1000, // must be set in environment.rs with the MAX_STEPS constant -dense_size: 256, // neural network complexity -eps_start: 0.9, // epsilon initial value (0.9 => more exploration) -eps_end: 0.05, -eps_decay: 3000.0, - -> Entraînement -> {"episode": 0, "reward": -1798.1161, "steps count": 1000, "duration": 15} -> {"episode": 1, "reward": -1800.0162, "steps count": 1000, "duration": 34} -> {"episode": 2, "reward": -1718.6151, "steps count": 1000, "duration": 57} -> {"episode": 3, "reward": -1369.5055, "steps count": 1000, "duration": 82} -> {"episode": 4, "reward": -321.5974, "steps count": 1000, "duration": 115} -> {"episode": 5, "reward": -213.2988, "steps count": 1000, "duration": 148} -> {"episode": 6, "reward": -175.4995, "steps count": 1000, "duration": 172} -> {"episode": 7, "reward": -126.1011, "steps count": 1000, "duration": 203} -> {"episode": 8, "reward": -105.1011, "steps count": 1000, "duration": 242} -> {"episode": 9, "reward": -46.3007, "steps count": 1000, "duration": 281} -> {"episode": 10, "reward": -57.7006, "steps count": 1000, "duration": 323} -> {"episode": 11, "reward": -15.7997, "steps count": 1000, "duration": 354} -> {"episode": 12, "reward": -38.6999, "steps count": 1000, "duration": 414} -> {"episode": 13, "reward": 10.7002, "steps count": 1000, "duration": 513} -> {"episode": 14, "reward": -10.1999, "steps count": 1000, "duration": 585} -> {"episode": 15, "reward": -8.3000, "steps count": 1000, "duration": 644} -> {"episode": 16, "reward": -463.4984, "steps count": 973, "duration": 588} -> {"episode": 17, "reward": -148.8951, "steps count": 1000, "duration": 646} -> {"episode": 18, "reward": 3.0999, "steps count": 1000, "duration": 676} -> {"episode": 19, "reward": -12.0999, "steps count": 1000, "duration": 753} -> {"episode": 20, "reward": 6.9000, "steps count": 1000, "duration": 801} -> {"episode": 21, "reward": 14.5001, "steps count": 1000, "duration": 850} -> {"episode": 22, "reward": -19.6999, "steps count": 1000, "duration": 937} -> {"episode": 23, "reward": 83.0000, "steps count": 456, "duration": 532} -> {"episode": 24, "reward": -13.9998, "steps count": 1000, "duration": 1236} -> {"episode": 25, "reward": 25.9003, "steps count": 1000, "duration": 1264} -> {"episode": 26, "reward": 1.2002, "steps count": 1000, "duration": 1349} -> {"episode": 27, "reward": 3.1000, "steps count": 1000, "duration": 1364} -> {"episode": 28, "reward": -6.4000, "steps count": 1000, "duration": 1392} -> {"episode": 29, "reward": -4.4998, "steps count": 1000, "duration": 1444} -> {"episode": 30, "reward": 3.1000, "steps count": 1000, "duration": 1611} - -thread 'main' has overflowed its stack -fatal runtime error: stack overflow, aborting - ---- - -num_episodes: 40, -// memory_size: 8192, // must be set in dqn_model.rs with the MEMORY_SIZE constant -// max_steps: 700, // must be set in environment.rs with the MAX_STEPS constant -dense_size: 256, // neural network complexity -eps_start: 0.9, // epsilon initial value (0.9 => more exploration) -eps_end: 0.05, -eps_decay: 3000.0, - -{"episode": 0, "reward": -1256.1014, "steps count": 700, "duration": 9} -{"episode": 1, "reward": -1256.1013, "steps count": 700, "duration": 20} -{"episode": 2, "reward": -1256.1014, "steps count": 700, "duration": 31} -{"episode": 3, "reward": -1258.7015, "steps count": 700, "duration": 44} -{"episode": 4, "reward": -1206.8009, "steps count": 700, "duration": 56} -{"episode": 5, "reward": -473.2974, "steps count": 700, "duration": 68} -{"episode": 6, "reward": -285.2984, "steps count": 700, "duration": 82} -{"episode": 7, "reward": -332.6987, "steps count": 700, "duration": 103} -{"episode": 8, "reward": -359.2984, "steps count": 700, "duration": 114} -{"episode": 9, "reward": -118.7008, "steps count": 700, "duration": 125} -{"episode": 10, "reward": -83.9004, "steps count": 700, "duration": 144} -{"episode": 11, "reward": -68.7006, "steps count": 700, "duration": 165} -{"episode": 12, "reward": -49.7002, "steps count": 700, "duration": 180} -{"episode": 13, "reward": -68.7002, "steps count": 700, "duration": 204} -{"episode": 14, "reward": -38.3001, "steps count": 700, "duration": 223} -{"episode": 15, "reward": -19.2999, "steps count": 700, "duration": 240} -{"episode": 16, "reward": -19.1998, "steps count": 700, "duration": 254} -{"episode": 17, "reward": -21.1999, "steps count": 700, "duration": 250} -{"episode": 18, "reward": -26.8998, "steps count": 700, "duration": 280} -{"episode": 19, "reward": -11.6999, "steps count": 700, "duration": 301} -{"episode": 20, "reward": -13.5998, "steps count": 700, "duration": 317} -{"episode": 21, "reward": 5.4000, "steps count": 700, "duration": 334} -{"episode": 22, "reward": 3.5000, "steps count": 700, "duration": 353} -{"episode": 23, "reward": 13.0000, "steps count": 700, "duration": 374} -{"episode": 24, "reward": 7.3001, "steps count": 700, "duration": 391} -{"episode": 25, "reward": -4.1000, "steps count": 700, "duration": 408} -{"episode": 26, "reward": -17.3998, "steps count": 700, "duration": 437} -{"episode": 27, "reward": 11.1001, "steps count": 700, "duration": 480} -{"episode": 28, "reward": -4.1000, "steps count": 700, "duration": 505} -{"episode": 29, "reward": -13.5999, "steps count": 700, "duration": 522} -{"episode": 30, "reward": -0.3000, "steps count": 700, "duration": 540} -{"episode": 31, "reward": -15.4998, "steps count": 700, "duration": 572} -{"episode": 32, "reward": 14.9001, "steps count": 700, "duration": 630} -{"episode": 33, "reward": -4.1000, "steps count": 700, "duration": 729} -{"episode": 34, "reward": 5.4000, "steps count": 700, "duration": 777} -{"episode": 35, "reward": 7.3000, "steps count": 700, "duration": 748} -{"episode": 36, "reward": 9.2001, "steps count": 700, "duration": 767} -{"episode": 37, "reward": 13.0001, "steps count": 700, "duration": 791} -{"episode": 38, "reward": -13.5999, "steps count": 700, "duration": 813} -{"episode": 39, "reward": 26.3002, "steps count": 700, "duration": 838} - -> Sauvegarde du modèle de validation -> Modèle de validation sauvegardé : models/burn_dqn_50_model.mpk -> Chargement du modèle pour test -> Chargement du modèle depuis : models/burn_dqn_50_model.mpk -> Test avec le modèle chargé -> Episode terminé. Récompense totale: 70.00, Étapes: 700 diff --git a/doc/refs/renet_echo.rs b/doc/refs/renet_echo.rs deleted file mode 100644 index 366a391..0000000 --- a/doc/refs/renet_echo.rs +++ /dev/null @@ -1,182 +0,0 @@ -use std::{ - collections::HashMap, - net::{SocketAddr, UdpSocket}, - sync::mpsc::{self, Receiver, TryRecvError}, - thread, - time::{Duration, Instant, SystemTime}, -}; - -use renet::{ - transport::{ - ClientAuthentication, NetcodeClientTransport, NetcodeServerTransport, ServerAuthentication, ServerConfig, NETCODE_USER_DATA_BYTES, - }, - ClientId, ConnectionConfig, DefaultChannel, RenetClient, RenetServer, ServerEvent, -}; - -// Helper struct to pass an username in the user data -struct Username(String); - -impl Username { - fn to_netcode_user_data(&self) -> [u8; NETCODE_USER_DATA_BYTES] { - let mut user_data = [0u8; NETCODE_USER_DATA_BYTES]; - if self.0.len() > NETCODE_USER_DATA_BYTES - 8 { - panic!("Username is too big"); - } - user_data[0..8].copy_from_slice(&(self.0.len() as u64).to_le_bytes()); - user_data[8..self.0.len() + 8].copy_from_slice(self.0.as_bytes()); - - user_data - } - - fn from_user_data(user_data: &[u8; NETCODE_USER_DATA_BYTES]) -> Self { - let mut buffer = [0u8; 8]; - buffer.copy_from_slice(&user_data[0..8]); - let mut len = u64::from_le_bytes(buffer) as usize; - len = len.min(NETCODE_USER_DATA_BYTES - 8); - let data = user_data[8..len + 8].to_vec(); - let username = String::from_utf8(data).unwrap(); - Self(username) - } -} - -fn main() { - env_logger::init(); - println!("Usage: server [SERVER_PORT] or client [SERVER_ADDR] [USER_NAME]"); - let args: Vec = std::env::args().collect(); - - let exec_type = &args[1]; - match exec_type.as_str() { - "client" => { - let server_addr: SocketAddr = args[2].parse().unwrap(); - let username = Username(args[3].clone()); - client(server_addr, username); - } - "server" => { - let server_addr: SocketAddr = format!("0.0.0.0:{}", args[2]).parse().unwrap(); - server(server_addr); - } - _ => { - println!("Invalid argument, first one must be \"client\" or \"server\"."); - } - } -} - -const PROTOCOL_ID: u64 = 7; - -fn server(public_addr: SocketAddr) { - let connection_config = ConnectionConfig::default(); - let mut server: RenetServer = RenetServer::new(connection_config); - - let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); - let server_config = ServerConfig { - current_time, - max_clients: 64, - protocol_id: PROTOCOL_ID, - public_addresses: vec![public_addr], - authentication: ServerAuthentication::Unsecure, - }; - let socket: UdpSocket = UdpSocket::bind(public_addr).unwrap(); - - let mut transport = NetcodeServerTransport::new(server_config, socket).unwrap(); - - let mut usernames: HashMap = HashMap::new(); - let mut received_messages = vec![]; - let mut last_updated = Instant::now(); - - loop { - let now = Instant::now(); - let duration = now - last_updated; - last_updated = now; - - server.update(duration); - transport.update(duration, &mut server).unwrap(); - - received_messages.clear(); - - while let Some(event) = server.get_event() { - match event { - ServerEvent::ClientConnected { client_id } => { - let user_data = transport.user_data(client_id).unwrap(); - let username = Username::from_user_data(&user_data); - usernames.insert(client_id, username.0); - println!("Client {} connected.", client_id) - } - ServerEvent::ClientDisconnected { client_id, reason } => { - println!("Client {} disconnected: {}", client_id, reason); - usernames.remove_entry(&client_id); - } - } - } - - for client_id in server.clients_id() { - while let Some(message) = server.receive_message(client_id, DefaultChannel::ReliableOrdered) { - let text = String::from_utf8(message.into()).unwrap(); - let username = usernames.get(&client_id).unwrap(); - println!("Client {} ({}) sent text: {}", username, client_id, text); - let text = format!("{}: {}", username, text); - received_messages.push(text); - } - } - - for text in received_messages.iter() { - server.broadcast_message(DefaultChannel::ReliableOrdered, text.as_bytes().to_vec()); - } - - transport.send_packets(&mut server); - thread::sleep(Duration::from_millis(50)); - } -} - -fn client(server_addr: SocketAddr, username: Username) { - let connection_config = ConnectionConfig::default(); - let mut client = RenetClient::new(connection_config); - - let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); - let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); - let client_id = current_time.as_millis() as u64; - let authentication = ClientAuthentication::Unsecure { - server_addr, - client_id, - user_data: Some(username.to_netcode_user_data()), - protocol_id: PROTOCOL_ID, - }; - - let mut transport = NetcodeClientTransport::new(current_time, authentication, socket).unwrap(); - let stdin_channel: Receiver = spawn_stdin_channel(); - - let mut last_updated = Instant::now(); - loop { - let now = Instant::now(); - let duration = now - last_updated; - last_updated = now; - - client.update(duration); - transport.update(duration, &mut client).unwrap(); - - if transport.is_connected() { - match stdin_channel.try_recv() { - Ok(text) => client.send_message(DefaultChannel::ReliableOrdered, text.as_bytes().to_vec()), - Err(TryRecvError::Empty) => {} - Err(TryRecvError::Disconnected) => panic!("Channel disconnected"), - } - - while let Some(text) = client.receive_message(DefaultChannel::ReliableOrdered) { - let text = String::from_utf8(text.into()).unwrap(); - println!("{}", text); - } - } - - transport.send_packets(&mut client).unwrap(); - thread::sleep(Duration::from_millis(50)); - } -} - -fn spawn_stdin_channel() -> Receiver { - let (tx, rx) = mpsc::channel::(); - thread::spawn(move || loop { - let mut buffer = String::new(); - std::io::stdin().read_line(&mut buffer).unwrap(); - tx.send(buffer.trim_end().to_string()).unwrap(); - }); - rx -} diff --git a/doc/refs/vocabulary.md b/doc/refs/vocabulary.md new file mode 100644 index 0000000..b4929b4 --- /dev/null +++ b/doc/refs/vocabulary.md @@ -0,0 +1,67 @@ +# Trictrac Vocabulary — French / English + +This table maps the French game terminology to the English terms used in this codebase (primarily the `store` crate). Where a code identifier exists, it is shown in `monospace`. + +| French | English (code) | Notes | +| -------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- | +| tablier | board | `Board` | +| case / flèche | field | `Field` (1–24, 0 = exit); "flèche" (arrow) and "case" both refer to a field/point | +| demi-case | half-field | A field occupied by exactly one checker | +| dame | checker | `Checker`; a playing piece | +| talon | stack | The starting pile of 15 checkers before they are deployed | +| coin de repos / coin | rest corner / corner | `corner`; field 12 (White) or 13 (Black) | +| bande de départ | starting rail | The side rail where stacks start; holds the pegs and flag | +| bande de sortie | exit rail | Same rail, used as an extra field value during exit | +| petit jan | small jan | Fields 1–6; `is_field_in_small_jan` | +| grand jan | big jan | Fields 7–12 (White's side, opponent's near zone) | +| jan de retour | return jan | Fields 19–24; same fields as opponent's small jan ; where checkers gather before exiting; `last quarter` | +| table des petits jans | small jan table | The board half containing both players' small jans (fields 1–12) | +| table des grands jans | big jan table | The board half containing both players' big jans (fields 13–24) | +| plein (d'un jan) | filled (jan) | All 6 fields of a jan hold ≥ 2 checkers | +| remplir | fill | Scoring event: completing the fill of a jan; `FilledQuarter` | +| conserver | conserve | Scoring event: maintaining a filled jan without breaking it; `FilledQuarter` | +| jan de récompense — battre à vrai | true hit | `TrueHitSmallJan`, `TrueHitBigJan`, `TrueHitOpponentCorner` | +| jan de récompense — battre à faux | false hit | `FalseHitSmallJan`, `FalseHitBigJan` | +| batterie du coin | corner hit | `TrueHitOpponentCorner`; hitting the opponent's empty rest corner | +| jan-qui-ne-peut / impuissance | helpless man | `HelplessMan`; a die value that cannot be played (penalty for opponent) | +| jan de deux tables | two tables jan | `TwoTables` | +| contre-jan de deux tables | contre two tables | `ContreTwoTables` | +| jan de mézéas | mezeas jan | `Mezeas` | +| contre-jan de mézéas | contre mezeas | `ContreMezeas` | +| jan de six tables / jan de trois coups | six tables jan | `SixTables`; also called "three-roll jan" | +| sortie (première) | first player to exit | `FirstPlayerToExit` | +| sortie (nombre sortant) | exit (exact exit) | Moving a checker off the board with an exact die value | +| nombre excédant | overflow number | Die value exceeding the checker's distance to the exit rail | +| nombre défaillant | failing number | A die value that cannot be played within the jan | +| tout d'une | chained move | `chained move`; one checker playing both dice successively | +| repos (case de repos) | rest (resting field) | An intermediate field where a checker pauses in a chained move | +| doublet | double | `is_double`; both dice show the same value | +| dé / dés | die / dice | `Dice` | +| cornet | dice cup | — | +| par puissance | by puissance | `is_move_by_puissance`; taking own corner using opponent's empty corner as virtual step | +| par effet | by effect | `can_take_corner_by_effect`; taking own corner by normal die values | +| d'emblée | simultaneously | Two checkers entering (or leaving) the corner at the same time | +| dédoubler | unstack corner | Using one of the two corner-holding checkers (forbidden for corner exits) | +| trou / jeu | hole | `holes`; 12 points = 1 hole; the primary scoring unit | +| fichet | peg | Physical marker tracking holes won along the board edge | +| jeton | token | Physical marker tracking points within a game (0–12) | +| pavillon | flag | The bredouille marker taken by the second player to score | +| bredouille | bredouille | `can_bredouille`; winning a hole while opponent scored nothing | +| petite bredouille | small bredouille | Winning a round (marqué) with ≥ 6 consecutive holes | +| grande bredouille | big bredouille | `can_big_bredouille`; winning a round with ≥ 12 consecutive holes | +| relevé | new setting | Resetting checkers to their stacks after a hole or exit | +| primauté | first-move privilege | The right to roll first, held by the player who exited or left first | +| s'en aller | leave / go | `Go` event; choosing to start a new setting after winning a hole | +| tenir | stay / hold | Choosing to continue after winning a hole instead of leaving | +| marqué | round | A scoring round in the "partie à écrire" | +| partie ordinaire | ordinary game | First to 12 holes wins | +| partie à écrire | scored game | Multi-round game played for tokens | +| à la chouette | chouette | Three- or four-player format | +| refait | replay | A drawn round (equal holes) that must be replayed | +| consolation | consolation | Bonus tokens paid to the winner and, in 3-player games, the non-playing player | +| postillon | postillon | The first "double bet" in final payment settlement | +| école | school | `schools`; a penalty for a marking error; opponent scores the missed points | +| fausse case | false move | Playing a checker to the wrong field | +| fausse école | false school | Incorrectly claiming or marking a school penalty | +| augmentation d'école | school escalation | Back-and-forth dispute over a school penalty | +| pile de misère | misery pile | A special scoring configuration (not yet implemented in the codebase) | diff --git a/doc/research.md b/doc/research.md deleted file mode 100644 index 37ad6e1..0000000 --- a/doc/research.md +++ /dev/null @@ -1,292 +0,0 @@ -# Trictrac — Research Notes - -## 1. Rust Engine: Module Map - -| Module | Responsibility | -| ---------------------- | ------------------------------------------------------------------------- | -| `board.rs` | Board representation, checker manipulation, quarter analysis | -| `dice.rs` | `Dice` struct, `DiceRoller`, bit encoding | -| `player.rs` | `Player` struct (score, bredouille), `Color`, `PlayerId`, `CurrentPlayer` | -| `game.rs` | `GameState` state machine, `GameEvent` enum, `Stage`/`TurnStage` | -| `game_rules_moves.rs` | `MoveRules`: move validation and generation | -| `game_rules_points.rs` | `PointsRules`: jan detection and scoring | -| `training_common.rs` | `TrictracAction` enum, action-space encoding (size 514) | -| `pyengine.rs` | PyO3 Python module exposing `TricTrac` class | -| `lib.rs` | Crate root, re-exports | - ---- - -## 2. Board Representation - -```rust -pub struct Board { - positions: [i8; 24], -} -``` - -- 24 fields indexed 0–23 internally, 1–24 externally. -- Positive values = White checkers on that field; negative = Black. -- Initial state: `[15, 0, ..., 0, -15]` — all 15 white pieces on field 1, all 15 black pieces on field 24. -- Field 0 is a sentinel for "exited the board" (never stored in the array). - -**Mirroring** is the central symmetry operation used throughout: - -```rust -pub fn mirror(&self) -> Self { - let mut positions = self.positions.map(|c| 0 - c); - positions.reverse(); - Board { positions } -} -``` - -This negates all values (swapping who owns each checker) and reverses the array (swapping directions). The entire engine always reasons from White's perspective; Black's moves are handled by mirroring the board first. - -**Quarter structure**: fields 1–6, 7–12, 13–18, 19–24. This maps to the four tables of Trictrac: - -- 1–6: White's "petit jan" (own table) -- 7–12: White's "grand jan" -- 13–18: Black's "grand jan" (= White's opponent territory) -- 19–24: Black's "petit jan" / White's "jan de retour" - -The "coin de repos" (rest corner) is field 12 for White, field 13 for Black. - ---- - -## 3. Dice - -```rust -pub struct Dice { - pub values: (u8, u8), -} -``` - -Dice are always a pair (never quadrupled for doubles, unlike Backgammon). The `DiceRoller` uses `StdRng` seeded from OS entropy (or an optional fixed seed for tests). Bit encoding: `"{d1:0>3b}{d2:0>3b}"` — 3 bits each, 6 bits total. - ---- - -## 4. Player State - -```rust -pub struct Player { - pub name: String, - pub color: Color, // White or Black - pub points: u8, // 0–11 (points within current hole) - pub holes: u8, // holes won (game ends at >12) - pub can_bredouille: bool, - pub can_big_bredouille: bool, - pub dice_roll_count: u8, // rolls since last new_pick_up() -} -``` - -`PlayerId` is a `u64` alias. Player 1 = White, Player 2 = Black (set at init time; this is fixed for the session in pyengine). - ---- - -## 5. Game State Machine - -### Stages - -```rust -pub enum Stage { PreGame, InGame, Ended } - -pub enum TurnStage { - RollDice, // 1 — player must request a roll - RollWaiting, // 0 — waiting for dice result from outside - MarkPoints, // 2 — points are being marked (schools mode only) - HoldOrGoChoice, // 3 — player won a hole; choose to Go or Hold - Move, // 4 — player must move checkers - MarkAdvPoints, // 5 — mark opponent's points after the move (schools mode) -} -``` - -### Turn lifecycle (schools disabled — the default) - -``` -RollWaiting - │ RollResult → auto-mark points - ├─[no hole]──→ Move - │ │ Move → mark opponent's points → switch player - │ └───────────────────────────────→ RollDice (next player) - └─[hole won]─→ HoldOrGoChoice - ├─ Go ──→ new_pick_up() → RollDice (same player) - └─ Move ──→ mark opponent's points → switch player → RollDice -``` - -In schools mode (`schools_enabled = true`), the player explicitly marks their own points (`Mark` event) and then the opponent's points after moving (`MarkAdvPoints` stage). - -### Key events - -```rust -pub enum GameEvent { - BeginGame { goes_first: PlayerId }, - EndGame { reason: EndGameReason }, - PlayerJoined { player_id, name }, - PlayerDisconnected { player_id }, - Roll { player_id }, // triggers RollWaiting - RollResult { player_id, dice }, // provides dice values - Mark { player_id, points }, // explicit point marking (schools mode) - Go { player_id }, // choose to restart position after hole - Move { player_id, moves: (CheckerMove, CheckerMove) }, - PlayError, -} -``` - -### Initialization in pyengine - -```rust -fn new() -> Self { - let mut game_state = GameState::new(false); // schools_enabled = false - game_state.init_player("player1"); - game_state.init_player("player2"); - game_state.consume(&GameEvent::BeginGame { goes_first: 1 }); - TricTrac { game_state } -} -``` - -Player 1 (White) always goes first. `active_player_id` uses 1-based indexing; pyengine converts to 0-based for the Python side with `active_player_id - 1`. - ---- - -## 6. Scoring System (Jans) - -Points are awarded after each dice roll based on "jans" (scoring events) detected by `PointsRules`. All computation assumes White's perspective (board is mirrored for Black before calling). - -### Jan types - -| Jan | Points (normal / doublet) | Direction | -| ----------------------- | ------------------------- | --------------- | -| `TrueHitSmallJan` | 4 / 6 | → active player | -| `TrueHitBigJan` | 2 / 4 | → active player | -| `TrueHitOpponentCorner` | 4 / 6 | → active player | -| `FilledQuarter` | 4 / 6 | → active player | -| `FirstPlayerToExit` | 4 / 6 | → active player | -| `SixTables` | 4 / 6 | → active player | -| `TwoTables` | 4 / 6 | → active player | -| `Mezeas` | 4 / 6 | → active player | -| `FalseHitSmallJan` | −4 / −6 | → opponent | -| `FalseHitBigJan` | −2 / −4 | → opponent | -| `ContreTwoTables` | −4 / −6 | → opponent | -| `ContreMezeas` | −4 / −6 | → opponent | -| `HelplessMan` | −2 / −4 | → opponent | - -A single roll can trigger multiple jans, each scored independently. The jan detection process: - -1. Try both dice orderings -2. Detect "tout d'une" (combined dice move as a virtual single die) -3. Prefer true hits over false hits for the same move -4. Check quarter-filling opportunities -5. Check rare jans (SixTables at roll 3, TwoTables, Mezeas) given specific board positions and talon counts - -### Hole scoring - -```rust -fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool { - let sum_points = p.points + points; - let jeux = sum_points / 12; // number of completed holes - let holes = match (jeux, p.can_bredouille) { - (0, _) => 0, - (_, false) => 2 * jeux - 1, // no bredouille bonus - (_, true) => 2 * jeux, // bredouille doubles the holes - }; - p.points = sum_points % 12; - p.holes += holes; - ... -} -``` - -- 12 points = 1 "jeu", which yields 1 or 2 holes depending on bredouille status. -- Scoring any points clears the opponent's `can_bredouille`. -- Completing a hole resets `can_bredouille` for the scorer. -- Game ends when `holes > 12`. -- Score reported to OpenSpiel: `holes * 12 + points`. - -### Points from both rolls - -After a roll, the active player's points (`dice_points.0`) are auto-marked immediately. After the Move, the opponent's points (`dice_points.1`) are marked (they were computed at roll-time from the pre-move board). - ---- - -## 7. Move Rules - -`MoveRules` always works from White's perspective. Key constraints enforced by `moves_allowed()`: - -1. **Opponent's corner forbidden**: Cannot land on field 13 (opponent's rest corner for White). -2. **Corner needs two checkers**: The rest corner (field 12) must be taken or vacated with exactly 2 checkers simultaneously. -3. **Corner by effect vs. by power**: If the corner can be taken directly ("par effet"), you cannot take it "par puissance" (using combined dice). -4. **Exit preconditions**: All checkers must be in fields 19–24 before any exit is allowed. -5. **Exit by effect priority**: If a normal exit is possible, exceedant moves (using overflow) are forbidden. -6. **Farthest checker first**: When exiting with exceedant, must exit the checker at the highest field. -7. **Must play all dice**: If both dice can be played, playing only one is invalid. -8. **Must play strongest die**: If only one die can be played, it must be the higher value die. -9. **Must fill quarter**: If a quarter can be completed, the move must complete it. -10. **Cannot block opponent's fillable quarter**: Cannot move into a quarter the opponent can still fill. - -The board state after each die application is simulated to check two-step sequences. - ---- - -## 8. Action Space (training_common.rs) - -Total size: **514 actions**. - -| Index | Action | Description | -| ------- | ------------------------------------------------ | ---------------------------------------------- | -| 0 | `Roll` | Request dice roll (not used in OpenSpiel mode) | -| 1 | `Go` | After winning hole: reset board and continue | -| 2–257 | `Move { dice_order: true, checker1, checker2 }` | Move with die[0] first | -| 258–513 | `Move { dice_order: false, checker1, checker2 }` | Move with die[1] first | - -Move encoding: `index = 2 + (0 if dice_order else 256) + checker1 * 16 + checker2` - -`checker1` and `checker2` are **ordinal positions** (1-based) of specific checkers counted left-to-right across all White-occupied fields, not field indices. Checker 0 = "no move" (empty move). Range: 0–15 (16 values each). - -### Mirror pattern in get_legal_actions / apply_action - -For player 2 (Black): - -```rust -// get_legal_actions: mirror game state before computing -let mirror = self.game_state.mirror(); -get_valid_action_indices(&mirror) - -// apply_action: convert action → event on mirrored state, then mirror the event back -a.to_event(&self.game_state.mirror()) - .map(|e| e.get_mirror(false)) -``` - -This ensures Black's actions are computed as if Black were White on a mirrored board, then translated back to real-board coordinates. - ---- - -## 9. Known Issues and Inconsistencies - -### 9.1 Color swap on new_pick_up disabled - -In `game.rs:new_pick_up()`: - -```rust -// XXX : switch colors -// désactivé pour le moment car la vérification des mouvements échoue, -// cf. https://code.rhumbs.fr/henri/trictrac/issues/31 -// p.color = p.color.opponent_color(); -``` - -In authentic Trictrac, players swap colors between "relevés" (pick-ups after a hole is won with Go). This is commented out, so the same player always plays White and the same always plays Black throughout the entire game. - -### 9.2 `can_big_bredouille` tracked but not implemented - -The `can_big_bredouille` flag is stored in `Player` and serialized in state encoding, but the scoring logic never reads it. Grande bredouille (a rare extra bonus) is not implemented. - -### 9.3 `get_valid_actions` panics on `RollWaiting` - -```rust -TurnStage::MarkPoints | TurnStage::MarkAdvPoints | TurnStage::RollWaiting => { - panic!("get_valid_actions not implemented for turn stage {:?}", ...) -} -``` - -If `get_legal_actions` were ever called while `needs_roll()` is true, this would panic. - -### 9.4 Opponent points marked at pre-move board state - -The opponent's `dice_points.1` is computed at roll time (before the active player moves), but applied to the opponent after the move. This means the opponent's scoring is evaluated on the board position that existed before the active player moved — which is per the rules of Trictrac (points are based on where pieces could be hit at the moment of the roll), but it's worth noting this subtlety. diff --git a/doc/specs/vocabulary.md b/doc/specs/vocabulary.md deleted file mode 100644 index a738230..0000000 --- a/doc/specs/vocabulary.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vocabulary - -Dames : checkers / men -cases : points -cadrant : quarter diff --git a/doc/specs/diagrammes.md b/doc/store_diagrams/diagrammes.md similarity index 100% rename from doc/specs/diagrammes.md rename to doc/store_diagrams/diagrammes.md diff --git a/doc/specs/stateEncoding.md b/doc/store_diagrams/stateEncoding.md similarity index 100% rename from doc/specs/stateEncoding.md rename to doc/store_diagrams/stateEncoding.md diff --git a/doc/specs/store.puml b/doc/store_diagrams/store.puml similarity index 100% rename from doc/specs/store.puml rename to doc/store_diagrams/store.puml diff --git a/doc/specs/workflow.md b/doc/store_diagrams/workflow.md similarity index 100% rename from doc/specs/workflow.md rename to doc/store_diagrams/workflow.md diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d3f9da1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,62 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1778003029, + "narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1778123869, + "narHash": "sha256-hV9D8ET33kXjdoMXBT2bwM/j8WQM1SP/dVKZtjQKhAQ=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "592e5dedf04f0eaff1ed0f01ce5db7407d9fc7be", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 7e82fc5..89d3586 100644 --- a/flake.nix +++ b/flake.nix @@ -1,41 +1,174 @@ - { description = "Trictrac"; - inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + rust-overlay.url = "github:oxalica/rust-overlay"; + }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: - # let pkgs = nixpkgs.legacyPackages.${system}; in - let pkgs = import nixpkgs { + outputs = { self, nixpkgs, rust-overlay }: + let + systems = [ "x86_64-linux" "i686-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); + nixpkgsFor = forAllSystems (system: + import nixpkgs { inherit system; - config = { allowUnfree = true; }; - }; in - { - # devShell = import ./shell.nix { inherit pkgs; }; - devShell = with pkgs; mkShell rec { - - nativeBuildInputs = [ - pkg-config - llvmPackages.bintools # To use lld linker - ]; - - buildInputs = [ - cargo rustc rustfmt rustPackages.clippy # rust - # pre-commit - - alsa-lib udev - vulkan-loader # needed for GPU acceleration - xlibsWrapper xorg.libXcursor xorg.libXrandr xorg.libXi # To use x11 feature - # libxkbcommon wayland # To use wayland feature - ]; - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs; - - shellHook = '' - export HOST=127.0.0.1 - export PORT=7000 - ''; - }; + overlays = [ self.overlay ]; } - ); - } + ); + in + { + overlay = final: prev: + let + # Extend final privately with rust-overlay to get rust-bin for the WASM + # toolchain without exposing rust-overlay attributes to consumers. + rustPkgs = final.extend rust-overlay.overlays.default; + in + { + + trictrac-front = + let + # WASM build needs wasm32-unknown-unknown target in the Rust toolchain + rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { + targets = [ "wasm32-unknown-unknown" ]; + }; + rustPlatform = final.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; + frontendCargoDeps = rustPlatform.fetchCargoVendor { + src = ./.; + name = "trictrac-frontend-vendor"; + hash = "sha256-XBxdRT/f69GDfVc18/DnnAiY1vjMGMWfcYot0K0jevg="; + }; + # Must match the wasm-bindgen version in Cargo.lock + wasm-bindgen-version = "0.2.118"; + # wasm-bindgen-version = "0.2.121"; + # wasm-bindgen-cli = final.buildWasmBindgenCli rec { + # version = wasm-bindgen-version; + # src = final.fetchCrate { + # pname = "wasm-bindgen-cli"; + # inherit version; + # hash = "sha256-ZOMgFNOcGkO66Jz/Z83eoIu+DIzo3Z/vq6Z5g6BDY/w="; + # }; + # cargoDeps = rustPlatform.fetchCargoVendor { + # inherit src; + # name = "wasm-bindgen-cli-vendor"; + # hash = "sha256-DPdCDPTAPBrbqLUqnCwQu1dePs9lGg85JCJOCIr9qjU="; + # }; + # }; + in + final.stdenv.mkDerivation { + name = "trictrac-front"; + src = ./.; + + nativeBuildInputs = with final; [ + rustToolchain + lld + rustPlatform.cargoSetupHook + wasm-bindgen-cli_0_2_118 + trunk + binaryen + ]; + + cargoDeps = frontendCargoDeps; + + buildPhase = '' + runHook preBuild + export HOME=$TMPDIR + + # Pin tool versions so trunk finds them in PATH instead of downloading + cat >> clients/web/Trunk.toml << 'EOF' + + [tools] + wasm-bindgen = { version = "${wasm-bindgen-version}" } + wasm-opt = { version = "version_124" } + EOF + + pushd clients/web + trunk build --release --offline + popd + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -R clients/web/dist/. $out/ + runHook postInstall + ''; + }; + + trictrac = with final; rustPlatform.buildRustPackage { + pname = "trictrac"; + version = "0.2.17"; # trictrac-version + src = ./.; + + nativeBuildInputs = [ pkg-config ]; + buildInputs = [ openssl ]; + + # Build only the relay server; skip WASM/bot crates + cargoBuildFlags = [ "-p" "relay-server" ]; + doCheck = false; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + postInstall = '' + install -m 644 ${./server/relay-server/GameConfig.json} $out/GameConfig.json + ''; + + meta = with lib; { + description = "A online game of trictrac"; + homepage = "https://github.com/mmai/trictrac"; + license = licenses.gpl3; + platforms = platforms.unix; + }; + }; + + trictrac-docker = with final; + let + port = "8080"; + entrypoint = writeScript "entrypoint.sh" '' + #!${runtimeShell} + # Populate a writable working dir with static files + config + mkdir -p /var/lib/trictrac + for f in ${trictrac-front}/*; do + ln -sf "$f" "/var/lib/trictrac/$(basename "$f")" + done + cp -n ${trictrac}/GameConfig.json /var/lib/trictrac/ 2>/dev/null || true + cd /var/lib/trictrac + echo "Starting trictrac server on port ${port}" + exec ${trictrac}/bin/relay-server + ''; + in + dockerTools.buildImage { + name = "mmai/trictrac"; + tag = "latest"; + copyToRoot = buildEnv { + name = "trictrac-env"; + paths = [ busybox ]; + }; + config = { + Entrypoint = [ entrypoint ]; + ExposedPorts = { + "${port}/tcp" = { }; + }; + }; + }; + + }; + + packages = forAllSystems (system: { + inherit (nixpkgsFor.${system}) trictrac trictrac-front trictrac-docker; + }); + + defaultPackage = forAllSystems (system: self.packages.${system}.trictrac); + + # trictrac service module + nixosModule = import ./module.nix; + + }; +} diff --git a/justfile b/justfile index 1c650fc..6f39fa5 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,48 @@ # ^ A shebang isn't required, but allows a justfile to be executed # like a script, with `./justfile test`, for example. +# Bump the project version and start a git-flow release. +# Usage: just bump 0.2.12 +# After running, finish with: git flow release finish +bump version: + git flow release start {{version}} + sed -i '/^\[workspace\.package\]/,/^\[/{s/^version = ".*"/version = "{{version}}"/}' Cargo.toml + sed -i 's/version = "[0-9.]*"; # trictrac-version/version = "{{version}}"; # trictrac-version/' flake.nix + just frontend-flake-hash + git add Cargo.toml flake.nix + git commit -m "chore: bump version to {{version}}" + @echo "Done. Finish with: `git flow release finish {{version}}`" + +# Get new trictrac front-end nix package hash and update flake.nix with it +frontend-flake-hash: + #!/usr/bin/env bash + set -euo pipefail + FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + # Match only non-commented hash lines (i.e. lines where 'hash' is the first non-space token) + CURRENT_HASH=$(grep -P '^\s+hash = "' flake.nix | grep -oP 'sha256-[^"]+' | head -1) + echo "Current hash: $CURRENT_HASH" + sed -i "s|$CURRENT_HASH|$FAKE_HASH|" flake.nix + set +e + OUTPUT=$(nix build .#trictrac-front --no-link 2>&1) + set -e + NEW_HASH=$(echo "$OUTPUT" | grep -oP 'got:\s+\Ksha256-\S+' || true) + if [ -n "$NEW_HASH" ]; then + sed -i "s|$FAKE_HASH|$NEW_HASH|" flake.nix + if [ "$NEW_HASH" = "$CURRENT_HASH" ]; then + echo "Hash already up to date: $CURRENT_HASH" + else + echo "Updated: $CURRENT_HASH → $NEW_HASH" + fi + else + sed -i "s|$FAKE_HASH|$CURRENT_HASH|" flake.nix + printf "Unexpected build output (no hash mismatch found):\n%s\n" "$OUTPUT" >&2 + exit 1 + fi + +# Sync pages content to production server +pages-deploy: + rsync -av clients/web/pages/ raspberry:/var/lib/trictrac/pages/ + doc: cargo doc --no-deps shell: @@ -9,17 +51,60 @@ shell: runcli: RUST_LOG=info cargo run --bin=client_cli -[working-directory: 'client_web/'] -dev-leptos: +# example: fix-wasm-version 0.2.118 +fix-wasm-version version: + cargo update \ + -p wasm-bindgen --precise {{version}} \ + -p wasm-bindgen-futures \ + -p wasm-bindgen-test \ + -p js-sys \ + -p web-sys + +[working-directory: 'clients/web'] +dev: trunk serve -[working-directory: 'client_web'] -build-leptos: +test-web: + wasm-pack test --node clients/web + +[working-directory: 'clients/web'] +build: trunk build --release - cp dist/index.html /home/henri/travaux/programmes/forks/multiplayer/deploy/trictrac.html - cp dist/*.wasm /home/henri/travaux/programmes/forks/multiplayer/deploy/ - cp dist/*.js /home/henri/travaux/programmes/forks/multiplayer/deploy/ - cp dist/*.css /home/henri/travaux/programmes/forks/multiplayer/deploy/ + cp dist/index.html ../../deploy/index.html + cp dist/*.wasm ../../deploy/ + cp dist/*.js ../../deploy/ + cp dist/*.css ../../deploy/ + +[working-directory: 'deploy'] +run-relay: + PAGES_DIR=../clients/web/pages ./relay-server + +build-relay: + CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release + mkdir -p deploy + cp target/release/relay-server deploy + cp -u server/relay-server/GameConfig.json deploy/ + +# generate web stats report from the current nginx logs +stats: + ssh -t raspberry sudo goaccess /var/log/nginx/trictrac_access.log --log-format=COMBINED -o html > var/stats/report.html + +# start a trictrac container with nixos-container +# `boot.enableContainers = true` must be set on local nixos system +local: + cd container && nix flake update nixpkgs trictrac && cd - + sudo nixos-container destroy trictrac + sudo nixos-container create trictrac --flake ./container/ + nixos-container start trictrac + machinectl + +docker-build: + nix build .#trictrac-docker +docker-run: docker-build + docker load < ./result + docker run mmai/trictrac -P +docker-publish: docker-build + docker push mmai/trictrac runclibots: cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk @@ -32,16 +117,7 @@ profile: echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid cargo build --profile profiling samply record ./target/profiling/client_cli --bot dummy,dummy -pythonlib: - rm -rf target/wheels - maturin build -m store/Cargo.toml --release - pip install --no-deps --force-reinstall --prefix .devenv/state/venv target/wheels/*.whl -cxxlib: - cargo build --release -p trictrac-store - @echo "Static lib: $(ls target/release/libtrictrac_store.a)" - @echo "CXX header: $(find target -name 'cxxengine.rs.h' | head -1)" trainbot algo: - #python ./store/python/trainModel.py # cargo run --bin=train_dqn # ok # ./bot/scripts/trainValid.sh ./bot/scripts/train.sh {{algo}} @@ -54,3 +130,4 @@ profiletrainbot: echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid cargo build --profile profiling --bin=train_dqn_burn LD_LIBRARY_PATH=./target/profiling samply record ./target/profiling/train_dqn_burn + diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..28bec85 --- /dev/null +++ b/module.nix @@ -0,0 +1,235 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.trictrac; +in +{ + + options = { + services.trictrac = { + enable = mkEnableOption "trictrac"; + + user = mkOption { + type = types.str; + default = "trictrac"; + description = "User under which trictrac is ran."; + }; + + group = mkOption { + type = types.str; + default = "trictrac"; + description = "Group under which trictrac is ran."; + }; + + protocol = mkOption { + type = types.enum [ "http" "https" ]; + default = "https"; + description = "Web server protocol."; + }; + + pages_dir = mkOption { + type = types.str; + default = "/var/lib/trictrac/pages"; + description = "Directory containing content pages."; + }; + + hostname = mkOption { + type = types.str; + default = "trictrac.localhost"; + description = "Public domain name of the trictrac web app."; + }; + + apiPort = mkOption { + type = types.port; + default = 8080; + description = "Port the relay server listens on."; + }; + + smtp = { + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "SMTP server hostname."; + }; + port = mkOption { + type = types.nullOr types.port; + default = null; + description = "SMTP server port. Defaults to 465 when tls = true, 1025 otherwise."; + }; + tls = mkOption { + type = types.bool; + default = false; + description = "Use TLS (port 465). Required for Resend and other cloud SMTP providers."; + }; + from = mkOption { + type = types.str; + default = "noreply@trictrac.local"; + description = "Sender address for outgoing mail."; + }; + user = mkOption { + type = types.str; + default = ""; + description = "SMTP username (leave empty to skip authentication). Use \"resend\" for Resend."; + }; + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/trictrac-smtp-password"; + description = '' + Path to a file containing a single line: SMTP_PASSWORD=. + Loaded as a systemd EnvironmentFile so the secret never appears in + the Nix store or process environment of other units. + ''; + }; + }; + + createDatabaseLocally = mkOption { + type = types.bool; + default = true; + example = false; + description = "Create a local PostgreSQL database for trictrac."; + }; + + }; + }; + + config = mkIf cfg.enable { + users.users.trictrac = mkIf (cfg.user == "trictrac") { + group = cfg.group; + isSystemUser = true; + }; + users.groups.trictrac = mkIf (cfg.group == "trictrac") { }; + + services.nginx = { + enable = true; + # map needed for WebSocket Connection header upgrade + appendHttpConfig = '' + upstream trictrac-api { + server 127.0.0.1:${toString cfg.apiPort}; + } + map $http_upgrade $connection_upgrade { + default upgrade; + "" close; + } + ''; + virtualHosts = + let + proxyConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + ''; + withSSL = cfg.protocol == "https"; + listenPort = if withSSL then 443 else 80; + in + { + "${cfg.hostname}" = { + enableACME = withSSL; + forceSSL = withSSL; + # Explicit listen so this vhost isn't shadowed by a default_server + # created by other virtual hosts with forceSSL = true. + listen = [ + { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; } + { addr = "[::]"; port = listenPort; ssl = withSSL; } + ]; + locations."/" = { + extraConfig = proxyConfig; + proxyPass = "http://trictrac-api/"; + }; + + extraConfig = '' + error_log /var/log/nginx/trictrac_error.log; + access_log /var/log/nginx/trictrac_access.log; + ''; + }; + }; + }; + + services.postgresql = mkIf cfg.createDatabaseLocally { + enable = mkDefault true; + ensureDatabases = [ "trictrac" ]; + ensureUsers = [ + { + name = cfg.user; + ensureDBOwnership = true; + } + ]; + # Allow the trictrac service user to connect via TCP without a password + authentication = mkAfter '' + host trictrac ${cfg.user} 127.0.0.1/32 trust + host trictrac ${cfg.user} ::1/128 trust + ''; + }; + + systemd.services.trictrac-server = + let + setupScript = pkgs.writeShellScript "trictrac-setup" '' + set -euo pipefail + # Symlink frontend static files into the state directory so the + # relay server can serve them from its working directory. + for f in ${pkgs.trictrac-front}/*; do + ln -sf "$f" "$STATE_DIRECTORY/$(basename "$f")" + done + # Seed a writable GameConfig.json on first run; admins may edit it later. + if [ ! -f "$STATE_DIRECTORY/GameConfig.json" ]; then + install -m 644 ${pkgs.trictrac}/GameConfig.json "$STATE_DIRECTORY/GameConfig.json" + fi + ''; + startScript = pkgs.writeShellScript "trictrac-start" ( + optionalString (cfg.smtp.passwordFile != null) '' + export SMTP_PASSWORD="$(< "$CREDENTIALS_DIRECTORY/smtp-pass")" + '' + '' + exec ${pkgs.trictrac}/bin/relay-server + '' + ); + in + { + description = "trictrac relay server"; + after = [ "network.target" ] ++ optional cfg.createDatabaseLocally "postgresql.service"; + requires = optional cfg.createDatabaseLocally "postgresql.service"; + wantedBy = [ "multi-user.target" ]; + + environment = { + DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}"; + APP_URL = "${cfg.protocol}://${cfg.hostname}"; + PAGES_DIR = cfg.pages_dir; + SMTP_HOST = cfg.smtp.host; + SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port + else if cfg.smtp.tls then 465 else 1025); + SMTP_FROM = cfg.smtp.from; + } // optionalAttrs cfg.smtp.tls { + SMTP_TLS = "true"; + } // optionalAttrs (cfg.smtp.user != "") { + SMTP_USER = cfg.smtp.user; + }; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + # systemd creates /var/lib/trictrac and sets STATE_DIRECTORY accordingly + StateDirectory = "trictrac"; + StateDirectoryMode = "0755"; + WorkingDirectory = "/var/lib/trictrac"; + LoadCredential = mkIf (cfg.smtp.passwordFile != null) "smtp-pass:${cfg.smtp.passwordFile}"; + ExecStartPre = "${setupScript}"; + ExecStart = "${startScript}"; + Restart = "on-failure"; + RestartSec = "5s"; + }; + }; + + }; + + meta = { + maintainers = with lib.maintainers; [ mmai ]; + }; +} diff --git a/server/protocol/Cargo.toml b/server/protocol/Cargo.toml new file mode 100644 index 0000000..70f4e68 --- /dev/null +++ b/server/protocol/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "protocol" +version.workspace = true +edition = "2024" + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } diff --git a/server/protocol/src/lib.rs b/server/protocol/src/lib.rs new file mode 100644 index 0000000..67b0741 --- /dev/null +++ b/server/protocol/src/lib.rs @@ -0,0 +1,72 @@ +//! The ids for messages that we use. They will be used consistent across the server and the client. +//! Also contains the protocol structure for joining a game. + +use serde::{Deserialize, Serialize}; + +/// The buffer sizes for the channels for intra VPS communication. +pub const CHANNEL_BUFFER_SIZE: usize = 256; + +// Client -> Server. + +/// The message to announce a new client (Client->Server) followed by u16 client id. +pub const NEW_CLIENT: u8 = 0; +/// The message size for a new client (Header + Client Id) (u8 + u16) +pub const NEW_CLIENT_MSG_SIZE: usize = 3; + +/// A client disconnects from the game. (Client->Server) and removes him from the room. followed by u16 client id. +pub const CLIENT_DISCONNECTS: u8 = 1; +/// The disconnect client message size (Header + Client Id) (u8 + u16) +pub const CLIENT_DISCONNECT_MSG_SIZE: usize = 3; + +/// Client -> Server RPC followed by u16 Clientid, followed by payload from postcard or other coding. (Client->Server) +pub const SERVER_RPC: u8 = 2; + +/// The disconnection message that is used for disconnecting without any arguments, that gets passed through the web socket layer. +pub const CLIENT_DISCONNECTS_SELF: u8 = 3; + +// Server -> Client + +/// The server disconnects from the game and the room gets closed. +pub const SERVER_DISCONNECTS: u8 = 0; +/// The disconnection message is just the byte itself. +pub const SERVER_DISCONNECT_MSG_SIZE: usize = 1; + +/// A client gets kicked, meant for the situation, when no more clients should get accepted. followed by u16 client id. The receiving tokio task has to act on its own. (Server -> Client) +pub const CLIENT_GETS_KICKED: u8 = 1; + +/// Delta update. Followed by payload for every delta update. May carry several delta messages in one pass. +pub const DELTA_UPDATE: u8 = 2; + +/// Flagging a full update. Followed by payload for full update. +pub const FULL_UPDATE: u8 = 3; + +/// The message to reset the game. This is also followed by a full update. Difference is, that every client will get the full update. +pub const RESET: u8 = 4; + +/// The error message we add. +pub const SERVER_ERROR: u8 = 5; + +/// The response message for the handshake. +pub const HAND_SHAKE_RESPONSE: u8 = 6; + +// Sizes of entries. +/// For the handshake we respond with player id (u16), rule variation (u16), and reconnect token (u64). +pub const HAND_SHAKE_RESPONSE_SIZE: usize = 13; + +/// The size of a new client. (u16) +pub const CLIENT_ID_SIZE: usize = 2; + +/// The join request. This struct is used on the server and on the client. +#[derive(Deserialize, Serialize)] +pub struct JoinRequest { + /// Which game do we want to join. + pub game_id: String, + /// Which room do we want to join. + pub room_id: String, + /// The rule variation that is applied, this gets only interpreted if a room gets constructed. + pub rule_variation: u16, + /// Do we want to create a room and act as a server? + pub create_room: bool, + /// Reconnect token from a previous session. `None` = fresh join/create, `Some` = reconnect. + pub reconnect_token: Option, +} diff --git a/server/relay-server/Cargo.toml b/server/relay-server/Cargo.toml new file mode 100644 index 0000000..1c0112b --- /dev/null +++ b/server/relay-server/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "relay-server" +version.workspace = true +edition = "2024" + +[dependencies] +tokio = { version = "1.48.0", features = ["full"] } +axum = { version = "0.8.7", features = ["ws"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +futures-util = "0.3.31" +postcard = "1.1.3" +bytes = "1.11.0" +tracing = "0.1.41" +tower-http = { version = "0.6.7", features = ["fs", "cors"] } +protocol = { path = "../protocol" } +rand = "0.8" + +# User management / auth +tokio-postgres = "0.7" +deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] } +tower-sessions = "0.14" +axum-login = "0.18" +argon2 = "0.5" +time = "0.3" +thiserror = "1" +lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname", "tokio1-rustls-tls"] } diff --git a/server/relay-server/GameConfig.json b/server/relay-server/GameConfig.json new file mode 100644 index 0000000..8001497 --- /dev/null +++ b/server/relay-server/GameConfig.json @@ -0,0 +1,6 @@ +[ + { + "name": "trictrac", + "max_players": 10 + } +] diff --git a/server/relay-server/migrations/001_init.sql b/server/relay-server/migrations/001_init.sql new file mode 100644 index 0000000..0f75f53 --- /dev/null +++ b/server/relay-server/migrations/001_init.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS game_records ( + id BIGSERIAL PRIMARY KEY, + game_id TEXT NOT NULL, + room_code TEXT NOT NULL, + started_at BIGINT NOT NULL, + ended_at BIGINT, + result TEXT +); + +CREATE TABLE IF NOT EXISTS game_participants ( + id BIGSERIAL PRIMARY KEY, + game_record_id BIGINT NOT NULL REFERENCES game_records(id), + user_id BIGINT REFERENCES users(id), + player_id BIGINT NOT NULL, + outcome TEXT +); diff --git a/server/relay-server/migrations/002_participants_unique.sql b/server/relay-server/migrations/002_participants_unique.sql new file mode 100644 index 0000000..52d0212 --- /dev/null +++ b/server/relay-server/migrations/002_participants_unique.sql @@ -0,0 +1,3 @@ +-- Prevent duplicate participant rows if POST /games/result is called more than once. +CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_unique + ON game_participants(game_record_id, player_id); diff --git a/server/relay-server/migrations/003_email_verification.sql b/server/relay-server/migrations/003_email_verification.sql new file mode 100644 index 0000000..bd04f92 --- /dev/null +++ b/server/relay-server/migrations/003_email_verification.sql @@ -0,0 +1,12 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS email_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token); diff --git a/server/relay-server/src/auth.rs b/server/relay-server/src/auth.rs new file mode 100644 index 0000000..0142c2c --- /dev/null +++ b/server/relay-server/src/auth.rs @@ -0,0 +1,96 @@ +//! Authentication backend for axum-login. +//! +//! Implements [`AuthUser`] on [`db::User`] and provides [`AuthBackend`] which +//! validates credentials against the database using Argon2 password hashing. + +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::password_hash::rand_core::OsRng; +use argon2::Argon2; +use axum_login::{AuthUser, AuthnBackend, UserId}; +use deadpool_postgres::Pool; + +use crate::db; + +// ── AuthUser ───────────────────────────────────────────────────────────────── + +impl AuthUser for db::User { + type Id = i64; + + fn id(&self) -> Self::Id { + self.id + } + + /// Changing the password invalidates all existing sessions for this user. + fn session_auth_hash(&self) -> &[u8] { + self.password_hash.as_bytes() + } +} + +// ── Credentials ────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct Credentials { + /// Accepts either a username or an email address. + pub login: String, + pub password: String, +} + +// ── Error ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("database error: {0}")] + Database(#[from] db::DbError), + #[error("password hashing error")] + PasswordHash, +} + +// ── Backend ─────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct AuthBackend { + pool: Pool, +} + +impl AuthBackend { + pub fn new(pool: Pool) -> Self { + Self { pool } + } +} + +impl AuthnBackend for AuthBackend { + type User = db::User; + type Credentials = Credentials; + type Error = AuthError; + + async fn authenticate( + &self, + creds: Self::Credentials, + ) -> Result, Self::Error> { + let Some(user) = db::get_user_by_username_or_email(&self.pool, &creds.login).await? else { + return Ok(None); + }; + + let parsed = PasswordHash::new(&user.password_hash).map_err(|_| AuthError::PasswordHash)?; + let valid = Argon2::default() + .verify_password(creds.password.as_bytes(), &parsed) + .is_ok(); + + Ok(valid.then_some(user)) + } + + async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { + Ok(db::get_user_by_id(&self.pool, *user_id).await?) + } +} + +// ── Password hashing helper ─────────────────────────────────────────────────── + +/// Hashes a plaintext password with Argon2id. Used by the registration endpoint. +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|_| AuthError::PasswordHash) +} diff --git a/server/relay-server/src/db.rs b/server/relay-server/src/db.rs new file mode 100644 index 0000000..0b9c878 --- /dev/null +++ b/server/relay-server/src/db.rs @@ -0,0 +1,378 @@ +//! Database access layer. +//! +//! All PostgreSQL interaction is funnelled through this module. Functions return +//! `Result<_, DbError>` so callers can handle errors uniformly. + +use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod}; +use tokio_postgres::{NoTls, error::SqlState}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// A registered user as stored in the database. +#[derive(Clone, Debug)] +pub struct User { + pub id: i64, + pub username: String, + pub email: String, + pub password_hash: String, + pub created_at: i64, + pub email_verified: bool, +} + +/// Aggregated game statistics for a user's public profile. +pub struct UserStats { + pub total: i64, + pub wins: i64, + pub losses: i64, + pub draws: i64, +} + +/// A condensed game entry returned by [`get_user_games`]. +pub struct GameSummary { + pub id: i64, + pub game_id: String, + pub room_code: String, + pub started_at: i64, + pub ended_at: Option, + pub result: Option, + pub outcome: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum DbError { + #[error("connection pool error: {0}")] + Pool(#[from] deadpool_postgres::PoolError), + #[error("database error: {0}")] + Db(#[from] tokio_postgres::Error), +} + +impl DbError { + pub fn is_unique_violation(&self) -> bool { + if let DbError::Db(e) = self { + e.code() == Some(&SqlState::UNIQUE_VIOLATION) + } else { + false + } + } +} + +pub fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +/// Connects to the PostgreSQL database at `url` and runs all pending migrations. +pub async fn init_db(url: &str) -> Pool { + let pg_config: tokio_postgres::Config = url.parse().expect("Invalid DATABASE_URL"); + let manager = Manager::from_config( + pg_config, + NoTls, + ManagerConfig { recycling_method: RecyclingMethod::Fast }, + ); + let pool = Pool::builder(manager) + .max_size(5) + .build() + .expect("Failed to build connection pool"); + + let client = pool.get().await.expect("Failed to get connection for migrations"); + client + .batch_execute(include_str!("../migrations/001_init.sql")) + .await + .expect("Migration 001 failed"); + client + .batch_execute(include_str!("../migrations/002_participants_unique.sql")) + .await + .expect("Migration 002 failed"); + client + .batch_execute(include_str!("../migrations/003_email_verification.sql")) + .await + .expect("Migration 003 failed"); + + pool +} + +// ── Users ──────────────────────────────────────────────────────────────────── + +fn user_from_row(r: &tokio_postgres::Row) -> User { + User { + id: r.get("id"), + username: r.get("username"), + email: r.get("email"), + password_hash: r.get("password_hash"), + created_at: r.get("created_at"), + email_verified: r.get("email_verified"), + } +} + +pub async fn create_user( + pool: &Pool, + username: &str, + email: &str, + password_hash: &str, +) -> Result { + let client = pool.get().await?; + let row = client + .query_one( + "INSERT INTO users (username, email, password_hash, created_at, email_verified) \ + VALUES ($1, $2, $3, $4, FALSE) RETURNING id", + &[&username, &email, &password_hash, &now_unix()], + ) + .await?; + Ok(row.get(0)) +} + +pub async fn get_user_by_id(pool: &Pool, id: i64) -> Result, DbError> { + let client = pool.get().await?; + let row = client + .query_opt( + "SELECT id, username, email, password_hash, created_at, email_verified \ + FROM users WHERE id = $1", + &[&id], + ) + .await?; + Ok(row.as_ref().map(user_from_row)) +} + +pub async fn get_user_by_username(pool: &Pool, username: &str) -> Result, DbError> { + let client = pool.get().await?; + let row = client + .query_opt( + "SELECT id, username, email, password_hash, created_at, email_verified \ + FROM users WHERE username = $1", + &[&username], + ) + .await?; + Ok(row.as_ref().map(user_from_row)) +} + +pub async fn get_user_by_email(pool: &Pool, email: &str) -> Result, DbError> { + let client = pool.get().await?; + let row = client + .query_opt( + "SELECT id, username, email, password_hash, created_at, email_verified \ + FROM users WHERE email = $1", + &[&email], + ) + .await?; + Ok(row.as_ref().map(user_from_row)) +} + +/// Looks up a user by username first; if not found, tries by email. +pub async fn get_user_by_username_or_email(pool: &Pool, login: &str) -> Result, DbError> { + if let Some(u) = get_user_by_username(pool, login).await? { + return Ok(Some(u)); + } + get_user_by_email(pool, login).await +} + +pub async fn set_email_verified(pool: &Pool, user_id: i64) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "UPDATE users SET email_verified = TRUE WHERE id = $1", + &[&user_id], + ) + .await?; + Ok(()) +} + +pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "UPDATE users SET password_hash = $1 WHERE id = $2", + &[&hash, &user_id], + ) + .await?; + Ok(()) +} + +/// Permanently deletes a user and their auth data. +/// Game history rows are kept but de-associated (user_id set to NULL). +pub async fn delete_user(pool: &Pool, user_id: i64) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "UPDATE game_participants SET user_id = NULL WHERE user_id = $1", + &[&user_id], + ) + .await?; + client + .execute("DELETE FROM email_tokens WHERE user_id = $1", &[&user_id]) + .await?; + client + .execute("DELETE FROM users WHERE id = $1", &[&user_id]) + .await?; + Ok(()) +} + +// ── Email tokens ────────────────────────────────────────────────────────────── + +pub async fn create_email_token( + pool: &Pool, + user_id: i64, + token: &str, + kind: &str, + expires_at: i64, +) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "INSERT INTO email_tokens (user_id, token, kind, expires_at, created_at) \ + VALUES ($1, $2, $3, $4, $5)", + &[&user_id, &token, &kind, &expires_at, &now_unix()], + ) + .await?; + Ok(()) +} + +/// Removes all tokens of the given kind for a user (call before creating a fresh one). +pub async fn delete_email_tokens(pool: &Pool, user_id: i64, kind: &str) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "DELETE FROM email_tokens WHERE user_id = $1 AND kind = $2", + &[&user_id, &kind], + ) + .await?; + Ok(()) +} + +/// Atomically deletes the token row and returns the `user_id` if the token +/// exists and has not expired. Returns `None` for missing or expired tokens. +pub async fn consume_email_token( + pool: &Pool, + token: &str, + kind: &str, +) -> Result, DbError> { + let client = pool.get().await?; + let row = client + .query_opt( + "DELETE FROM email_tokens WHERE token = $1 AND kind = $2 \ + RETURNING user_id, expires_at", + &[&token, &kind], + ) + .await?; + + Ok(row.and_then(|r| { + let expires_at: i64 = r.get("expires_at"); + if expires_at >= now_unix() { + Some(r.get("user_id")) + } else { + None + } + })) +} + +// ── Game records ───────────────────────────────────────────────────────────── + +/// Creates a new game record when a room opens. Returns the record id. +pub async fn insert_game_record( + pool: &Pool, + game_id: &str, + room_code: &str, +) -> Result { + let client = pool.get().await?; + let row = client + .query_one( + "INSERT INTO game_records (game_id, room_code, started_at) \ + VALUES ($1, $2, $3) RETURNING id", + &[&game_id, &room_code, &now_unix()], + ) + .await?; + Ok(row.get(0)) +} + +/// Stamps `ended_at` and stores the opaque result JSON supplied by the game. +pub async fn close_game_record( + pool: &Pool, + record_id: i64, + result_json: Option<&str>, +) -> Result<(), DbError> { + // AND ended_at IS NULL prevents overwriting a result already set by POST /games/result + let client = pool.get().await?; + client + .execute( + "UPDATE game_records SET ended_at = $1, result = $2 \ + WHERE id = $3 AND ended_at IS NULL", + &[&now_unix(), &result_json, &record_id], + ) + .await?; + Ok(()) +} + +/// Records a player's participation in a game. `user_id` is `None` for anonymous players. +pub async fn insert_participant( + pool: &Pool, + record_id: i64, + user_id: Option, + player_id: u16, + outcome: Option<&str>, +) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "INSERT INTO game_participants (game_record_id, user_id, player_id, outcome) \ + VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + &[&record_id, &user_id, &(player_id as i64), &outcome], + ) + .await?; + Ok(()) +} + +/// Returns win/loss/draw counts for a user. All values are 0 when the user has no games. +pub async fn get_user_stats(pool: &Pool, user_id: i64) -> Result { + let client = pool.get().await?; + let row = client + .query_one( + "SELECT + COUNT(*) as total, + COALESCE(SUM(CASE WHEN outcome = 'win' THEN 1 ELSE 0 END), 0::BIGINT) as wins, + COALESCE(SUM(CASE WHEN outcome = 'loss' THEN 1 ELSE 0 END), 0::BIGINT) as losses, + COALESCE(SUM(CASE WHEN outcome = 'draw' THEN 1 ELSE 0 END), 0::BIGINT) as draws + FROM game_participants + WHERE user_id = $1", + &[&user_id], + ) + .await?; + Ok(UserStats { + total: row.get("total"), + wins: row.get("wins"), + losses: row.get("losses"), + draws: row.get("draws"), + }) +} + +/// Returns a paginated list of games a user participated in, newest first. +pub async fn get_user_games( + pool: &Pool, + user_id: i64, + page: i64, + per_page: i64, +) -> Result, DbError> { + let client = pool.get().await?; + let rows = client + .query( + "SELECT gr.id, gr.game_id, gr.room_code, gr.started_at, gr.ended_at, gr.result, gp.outcome + FROM game_records gr + JOIN game_participants gp ON gp.game_record_id = gr.id + WHERE gp.user_id = $1 + ORDER BY gr.started_at DESC + LIMIT $2 OFFSET $3", + &[&user_id, &per_page, &(page * per_page)], + ) + .await?; + Ok(rows + .into_iter() + .map(|r| GameSummary { + id: r.get("id"), + game_id: r.get("game_id"), + room_code: r.get("room_code"), + started_at: r.get("started_at"), + ended_at: r.get("ended_at"), + result: r.get("result"), + outcome: r.get("outcome"), + }) + .collect()) +} diff --git a/server/relay-server/src/hand_shake.rs b/server/relay-server/src/hand_shake.rs new file mode 100644 index 0000000..f8d008a --- /dev/null +++ b/server/relay-server/src/hand_shake.rs @@ -0,0 +1,599 @@ +//! This module does the whole initialization and handshake thing. +//! The general protocol of connecting is : +//! WASM Client -> Websocket: postcard serialized join request. +//! Websocket -> WASM Client: u16 player id, u16 rule variation, u64 reconnect token. + +use crate::db; +use crate::hand_shake::ClientServerSpecificData::{Client, Server}; +use crate::hand_shake::DisconnectEndpointSpecification::{DisconnectClient, DisconnectServer}; +use crate::lobby::{AppState, Room}; +use axum::extract::ws::Message::Binary; +use axum::extract::ws::{Message, WebSocket}; +use bytes::{BufMut, Bytes, BytesMut}; +use futures_util::stream::{SplitSink, SplitStream}; +use futures_util::{sink::SinkExt, stream::StreamExt}; +use postcard::from_bytes; +use protocol::{ + CHANNEL_BUFFER_SIZE, CLIENT_DISCONNECT_MSG_SIZE, CLIENT_DISCONNECTS, HAND_SHAKE_RESPONSE, + HAND_SHAKE_RESPONSE_SIZE, JoinRequest, NEW_CLIENT, NEW_CLIENT_MSG_SIZE, + SERVER_DISCONNECT_MSG_SIZE, SERVER_DISCONNECTS, SERVER_ERROR, +}; +use rand::random; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::{broadcast, mpsc}; + +/// Is called on error, sends a text message because e-websocket can not interpret closing messages. +/// This text message is encoded as a binary message. +async fn send_closing_message(sender: &mut SplitSink, closing_message: String) { + let raw_data = closing_message.as_bytes(); + let mut msg = BytesMut::with_capacity(1 + raw_data.len()); + msg.put_u8(SERVER_ERROR); + msg.put_slice(raw_data); + + let _ = sender.send(Message::Binary(msg.into())).await; + let _ = sender.send(Message::Close(None)).await; +} + +/// The handshake result we get for the joining the room. +pub struct HandshakeResult { + /// The id of the player we play. + pub player_id: u16, + /// The complete identifier of the room as stored in the hashmap. + pub room_id: String, + /// The rule variation we apply. + pub rule_variation: u16, + /// The reconnect token for this player — sent back to the client for localStorage storage. + pub token: u64, + /// The internal connection information. + pub specific_data: ClientServerSpecificData, +} + +/// Contains all the channel information for internal communication. +pub enum ClientServerSpecificData { + /// In this case we are servicing the server. + Server(Receiver, broadcast::Sender), + /// In this case we are servicing a client. + Client(broadcast::Receiver, Sender), +} + +/// This data is data we need to keep for the disconnect handling and cleanup. +pub struct DisconnectData { + /// The id of the player we play. + pub player_id: u16, + /// The complete identifier of the room as stored in the hashmap. + pub room_id: String, + /// The sender we use. + pub sender: DisconnectEndpointSpecification, +} + +/// Contains the information where to send error data to in case of disconnection. +pub enum DisconnectEndpointSpecification { + /// If we are servicing the server, we broadcast the info to all clients. + DisconnectServer(broadcast::Sender), + /// If we are servicing the client, we send data to the server. + DisconnectClient(Sender), +} + +/// Construction of DisconnectData from Handshake result. +impl From<&HandshakeResult> for DisconnectData { + fn from(value: &HandshakeResult) -> Self { + match &value.specific_data { + Server(_, internal_sender) => DisconnectData { + player_id: value.player_id, + room_id: value.room_id.clone(), + sender: DisconnectServer(internal_sender.clone()), + }, + Client(_, internal_sender) => DisconnectData { + player_id: value.player_id, + room_id: value.room_id.clone(), + sender: DisconnectClient(internal_sender.clone()), + }, + } + } +} + +/// Gets an initial connection result, where a room is constructed +/// and game and existence / non existence of room is checked for legality. +struct InitialConnectionResult { + /// Flags, if we are a server. + is_server: bool, + /// The complete room we have for internal administration. + compound_room_id: String, + /// Which game do we want to join. + game_id: String, + /// Which room do we want to join. + room_id: String, + /// The rule variation that is applied, this gets only interpreted if a room gets constructed. + rule_variation: u16, + /// The maximum amount of players a room allows (0 = infinite). + max_players: u16, + /// Reconnect token from the client, if this is a reconnect attempt. + reconnect_token: Option, +} + +/// Reads in the join request from the web socket, verifies if game exists and generates the final room name. +async fn get_initial_query( + sender: &mut SplitSink, + receiver: &mut SplitStream, + state: Arc, +) -> Option { + // First we get a room opening and joining request. This is the first binary message we received. + let my_data = loop { + let Some(raw_data) = receiver.next().await else { + tracing::warn!("WebSocket closed before handshake completed"); + send_closing_message(sender, "Initial error during handshake.".into()).await; + return None; + }; + match raw_data { + Err(err) => { + tracing::error!(?err, "Initial error during handshake."); + send_closing_message(sender, "Initial error during handshake.".into()).await; + return None; + } + Ok(Binary(data)) => { + break data; + } + // We do not care about any other message like ping pong messages. + Ok(_) => {} + } + }; + + // Now we get some data and we try to convert it into the required format. + let working_struct = match from_bytes::(&my_data) { + Ok(req) => req, + Err(e) => { + tracing::error!(error = ?e, "Failed to parse join request"); + send_closing_message(sender, "Failed to parse join request.".into()).await; + return None; + } + }; + + // Let us take a look, if the game exists. + let games = state.configs.read().await; + let game_exists = games.contains_key(&working_struct.game_id); + let max_players = if game_exists { + games[&working_struct.game_id] + } else { + 0 + }; + drop(games); + + if !game_exists { + tracing::error!( + optional_game = working_struct.game_id, + "Requested illegal game." + ); + send_closing_message(sender, format!("Unknown game {}.", &working_struct.game_id)).await; + return None; + } + + // The final room id is the combination of game and room id. + let room_id = format!( + "{}#{}", + working_struct.room_id.as_str(), + working_struct.game_id.as_str() + ); + let is_server = working_struct.create_room; + + Some(InitialConnectionResult { + is_server, + compound_room_id: room_id, + game_id: working_struct.game_id, + room_id: working_struct.room_id, + rule_variation: working_struct.rule_variation, + max_players, + reconnect_token: working_struct.reconnect_token, + }) +} + +/// Connects and eventually establishes a room. +pub async fn init_and_connect( + sender: &mut SplitSink, + receiver: &mut SplitStream, + state: Arc, + user_id: Option, +) -> Option { + let start_result = get_initial_query(sender, receiver, state.clone()).await?; + + if let Some(token) = start_result.reconnect_token { + process_handshake_reconnect(sender, state, start_result, token, user_id).await + } else if start_result.is_server { + process_handshake_server(sender, state, start_result, user_id).await + } else { + process_handshake_client(sender, state, start_result, user_id).await + } +} + +/// Does the handshake, if we are connected to a client. +async fn process_handshake_client( + sender: &mut SplitSink, + state: Arc, + initial_result: InitialConnectionResult, + user_id: Option, +) -> Option { + let mut rooms = state.rooms.lock().await; + let Some(local_room) = rooms.get_mut(&initial_result.compound_room_id) else { + drop(rooms); + send_closing_message( + sender, + format!( + "Room {} does not exist for game {}.", + &initial_result.room_id, &initial_result.game_id + ), + ) + .await; + return None; + }; + + // Do we fit in? max_players == 0 means "infinite". + if initial_result.max_players != 0 && local_room.amount_of_players >= initial_result.max_players + { + drop(rooms); + send_closing_message( + sender, + format!( + "Room {} exceeded max amount of players {}.", + &initial_result.room_id, initial_result.max_players + ), + ) + .await; + return None; + } + + // Save guard against the case, that we have run out of client ids. + if local_room.next_client_id > u16::MAX - 100 { + drop(rooms); + send_closing_message( + sender, + format!("Room {} run out of client ids.", &initial_result.room_id), + ) + .await; + tracing::error!("Server run out of client ids."); + return None; + } + + local_room.amount_of_players += 1; + let player_id = local_room.next_client_id; + local_room.next_client_id += 1; + + let token: u64 = random(); + local_room.player_tokens.insert(player_id, token); + local_room.connected_players.push(player_id); + local_room.user_ids.insert(player_id, user_id); + + let to_server_sender = local_room.to_host_sender.clone(); + let receiver = local_room.host_to_client_broadcaster.subscribe(); + let rule_variation = local_room.rule_variation; + drop(rooms); + + // Here we send a message to the server, that a new client has joined. + let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE); + msg.put_u8(NEW_CLIENT); // Message-Type + msg.put_u16(player_id); // player id. + + let result = to_server_sender.send(msg.into()).await; + if let Err(error) = result { + // We have to leave the room again. + let mut rooms = state.rooms.lock().await; + if let Some(room) = rooms.get_mut(&initial_result.compound_room_id) { + room.amount_of_players -= 1; + room.player_tokens.remove(&player_id); + } + drop(rooms); + tracing::error!(?error, "Server unexpectedly left during handshake"); + send_closing_message(sender, "Server unexpectedly left during handshake".into()).await; + return None; + } + + Some(HandshakeResult { + room_id: initial_result.compound_room_id, + player_id, + rule_variation, + token, + specific_data: Client(receiver, to_server_sender), + }) +} + +/// Opens a new room and generates the handshake result for the server. +async fn process_handshake_server( + sender: &mut SplitSink, + state: Arc, + initial_result: InitialConnectionResult, + user_id: Option, +) -> Option { + // Insert a game record before taking the rooms lock (best-effort: failures don't abort the handshake). + let game_record_id = + match db::insert_game_record(&state.db, &initial_result.game_id, &initial_result.room_id) + .await + { + Ok(id) => Some(id), + Err(e) => { + tracing::warn!("Failed to create game record for room {}: {e}", initial_result.room_id); + None + } + }; + + let mut rooms = state.rooms.lock().await; + if rooms.contains_key(&initial_result.compound_room_id) { + drop(rooms); + send_closing_message( + sender, + format!( + "Room {} already exists for game {}.", + &initial_result.room_id, &initial_result.game_id + ), + ) + .await; + // User error no need for error tracing. + return None; + } + // Here we create a new room. + let (to_server_sender, to_server_receiver) = mpsc::channel(CHANNEL_BUFFER_SIZE); + let (to_client_sender, _) = broadcast::channel(CHANNEL_BUFFER_SIZE); + let token: u64 = random(); + let mut player_tokens = HashMap::new(); + player_tokens.insert(0u16, token); + let mut user_ids = HashMap::new(); + user_ids.insert(0u16, user_id); + let new_room = Room { + next_client_id: 1, + amount_of_players: 1, + rule_variation: initial_result.rule_variation, + to_host_sender: to_server_sender, + host_to_client_broadcaster: to_client_sender.clone(), + player_tokens, + host_connected: true, + connected_players: Vec::new(), + game_record_id, + user_ids, + }; + rooms.insert(initial_result.compound_room_id.clone(), new_room); + drop(rooms); + let hand_shake_result = HandshakeResult { + room_id: initial_result.compound_room_id, + player_id: 0, + rule_variation: initial_result.rule_variation, + token, + specific_data: Server(to_server_receiver, to_client_sender), + }; + Some(hand_shake_result) +} + +/// Reconnects a previously connected player (host or client) using their stored token. +/// +/// **Client reconnect**: resubscribes to the broadcast channel and notifies the host +/// via `NEW_CLIENT` so it delivers a fresh `FULL_UPDATE`. +/// +/// **Host reconnect**: creates a new mpsc channel (the old one died with the WebSocket), +/// replaces `room.to_host_sender`, and queues `NEW_CLIENT` / `CLIENT_DISCONNECTS` +/// messages so the host backend can reconstruct who is currently in the room. +async fn process_handshake_reconnect( + sender: &mut SplitSink, + state: Arc, + initial_result: InitialConnectionResult, + reconnect_token: u64, + user_id: Option, +) -> Option { + let mut rooms = state.rooms.lock().await; + let Some(local_room) = rooms.get_mut(&initial_result.compound_room_id) else { + drop(rooms); + send_closing_message( + sender, + format!( + "Room {} no longer exists for game {}.", + &initial_result.room_id, &initial_result.game_id + ), + ) + .await; + return None; + }; + + // Find the player whose token matches. + let player_id = match local_room + .player_tokens + .iter() + .find(|&(_, &t)| t == reconnect_token) + .map(|(&id, _)| id) + { + Some(id) => id, + None => { + drop(rooms); + tracing::warn!("Reconnect attempt with invalid token in room {}", &initial_result.room_id); + send_closing_message(sender, "Invalid reconnect token.".into()).await; + return None; + } + }; + + // ------------------------------------------------------------------ Host reconnect + if player_id == 0 { + if local_room.host_connected { + drop(rooms); + send_closing_message(sender, "Host is already connected.".into()).await; + return None; + } + + // Create a fresh mpsc channel (the previous receiver was dropped when the + // host's WebSocket closed). + let (new_sender, new_receiver) = mpsc::channel(CHANNEL_BUFFER_SIZE); + local_room.to_host_sender = new_sender.clone(); + local_room.host_connected = true; + local_room.user_ids.insert(0u16, user_id); + + let broadcaster = local_room.host_to_client_broadcaster.clone(); + let rule_variation = local_room.rule_variation; + + // Collect the players we need to notify about. + let connected = local_room.connected_players.clone(); + let all_non_host: Vec = local_room + .player_tokens + .keys() + .filter(|&&pid| pid != 0) + .copied() + .collect(); + drop(rooms); + + // Queue NEW_CLIENT for every currently connected player so the host backend + // increments remote_player_count and sends a FULL_UPDATE. + for pid in &connected { + let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE); + msg.put_u8(NEW_CLIENT); + msg.put_u16(*pid); + let _ = new_sender.send(msg.into()).await; + } + // Queue CLIENT_DISCONNECTS for players who left while the host was away so + // the backend can start their grace-period timers. + for pid in all_non_host { + if !connected.contains(&pid) { + let mut msg = BytesMut::with_capacity(CLIENT_DISCONNECT_MSG_SIZE); + msg.put_u8(CLIENT_DISCONNECTS); + msg.put_u16(pid); + let _ = new_sender.send(msg.into()).await; + } + } + + tracing::info!(room = &initial_result.room_id, "Host reconnected"); + + return Some(HandshakeResult { + room_id: initial_result.compound_room_id, + player_id: 0, + rule_variation, + token: reconnect_token, + specific_data: Server(new_receiver, broadcaster), + }); + } + + // ---------------------------------------------------------------- Client reconnect + local_room.amount_of_players += 1; + local_room.connected_players.push(player_id); + local_room.user_ids.insert(player_id, user_id); + let to_server_sender = local_room.to_host_sender.clone(); + let broadcast_receiver = local_room.host_to_client_broadcaster.subscribe(); + let rule_variation = local_room.rule_variation; + drop(rooms); + + // Notify the host that this player has rejoined so it sends a FULL_UPDATE. + let mut msg = BytesMut::with_capacity(NEW_CLIENT_MSG_SIZE); + msg.put_u8(NEW_CLIENT); + msg.put_u16(player_id); + + if let Err(error) = to_server_sender.send(msg.into()).await { + let mut rooms = state.rooms.lock().await; + if let Some(room) = rooms.get_mut(&initial_result.compound_room_id) { + room.amount_of_players -= 1; + room.connected_players.retain(|&p| p != player_id); + } + drop(rooms); + tracing::error!(?error, "Host unavailable during reconnect handshake"); + send_closing_message(sender, "Host is no longer available.".into()).await; + return None; + } + + tracing::info!( + player_id, + room = &initial_result.room_id, + "Player reconnected" + ); + + Some(HandshakeResult { + room_id: initial_result.compound_room_id, + player_id, + rule_variation, + token: reconnect_token, + specific_data: Client(broadcast_receiver, to_server_sender), + }) +} + +/// Informs the partner of the connection result, returns a bool as a success flag. +pub async fn inform_client_of_connection( + sender: &mut SplitSink, + status: &HandshakeResult, +) -> bool { + let mut msg = BytesMut::with_capacity(HAND_SHAKE_RESPONSE_SIZE); + msg.put_u8(HAND_SHAKE_RESPONSE); + msg.put_u16(status.player_id); + msg.put_u16(status.rule_variation); + msg.put_u64(status.token); + + let result = sender.send(Message::Binary(msg.into())).await; + result.is_ok() +} + +/// Performs the shutdown of the system and sends a last message. +pub async fn shutdown_connection( + wrapped_sender: Arc>>, + disconnect_data: DisconnectData, + app_state: Arc, + error_message: &'static str, +) { + match disconnect_data.sender { + DisconnectServer(broadcaster) => { + // Mark the host as disconnected and start a 30-second grace period. + // If the host reconnects within that window the grace task does nothing; + // otherwise it broadcasts SERVER_DISCONNECTS and removes the room. + { + let mut rooms = app_state.rooms.lock().await; + if let Some(room) = rooms.get_mut(&disconnect_data.room_id) { + room.host_connected = false; + } + } + + let state_clone = app_state.clone(); + let room_id = disconnect_data.room_id.clone(); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + + let game_record_id = { + let mut rooms = state_clone.rooms.lock().await; + if let Some(room) = rooms.get(&room_id) { + if !room.host_connected { + let record_id = room.game_record_id; + rooms.remove(&room_id); + record_id + } else { + return; // host reconnected + } + } else { + return; // room already removed + } + }; + + // Room lock released — broadcast and close the DB record. + let mut msg = BytesMut::with_capacity(SERVER_DISCONNECT_MSG_SIZE); + msg.put_u8(SERVER_DISCONNECTS); + let _ = broadcaster.send(msg.into()); + tracing::info!(room_id, "Host grace period expired — room removed"); + + if let Some(record_id) = game_record_id { + if let Err(e) = db::close_game_record(&state_clone.db, record_id, None).await { + tracing::warn!("Failed to close game record {record_id}: {e}"); + } + } + }); + } + DisconnectClient(sender) => { + // Inform server first. + let mut msg = BytesMut::with_capacity(CLIENT_DISCONNECT_MSG_SIZE); + msg.put_u8(CLIENT_DISCONNECTS); + msg.put_u16(disconnect_data.player_id); + let _ = sender.send(msg.into()).await; + // Subtract one client from the room. + let mut rooms = app_state.rooms.lock().await; + // Check if the room still exists. + if let Some(room) = rooms.get_mut(&disconnect_data.room_id) { + room.amount_of_players -= 1; + room.connected_players.retain(|&p| p != disconnect_data.player_id); + // Note: we intentionally keep the token in player_tokens so the + // client can use it to reconnect as long as the room exists. + } + drop(rooms); + } + } + + let mut sender = wrapped_sender.lock().await; + + // Send the message to the WASM point. + send_closing_message(&mut sender, error_message.into()).await; +} diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs new file mode 100644 index 0000000..0104c76 --- /dev/null +++ b/server/relay-server/src/http.rs @@ -0,0 +1,612 @@ +//! HTTP endpoints for user management. +//! +//! Routes: +//! POST /auth/register +//! POST /auth/login +//! POST /auth/logout +//! GET /auth/me +//! GET /auth/verify-email?token=… +//! POST /auth/resend-verification +//! POST /auth/forgot-password +//! POST /auth/reset-password +//! GET /users/:username +//! GET /users/:username/games?page=0&per_page=20 +//! GET /games/:id +//! POST /games/result + +use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post}, +}; +use axum_login::AuthSession; +use rand::distributions::Alphanumeric; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::auth::{AuthBackend, Credentials, hash_password}; +use crate::db::{self, now_unix}; +use crate::lobby::AppState; + +const VERIFY_TOKEN_EXPIRY: i64 = 86_400; // 24 hours +const RESET_TOKEN_EXPIRY: i64 = 3_600; // 1 hour + +// ── Router ──────────────────────────────────────────────────────────────────── + +pub fn router() -> Router> { + Router::new() + .route("/auth/register", post(register)) + .route("/auth/login", post(login)) + .route("/auth/logout", post(logout)) + .route("/auth/me", get(me)) + .route("/auth/verify-email", get(verify_email)) + .route("/auth/resend-verification", post(resend_verification)) + .route("/auth/forgot-password", post(forgot_password)) + .route("/auth/reset-password", post(reset_password)) + .route("/auth/account", delete(delete_account)) + .route("/users/{username}", get(user_profile)) + .route("/users/{username}/games", get(user_games)) + .route("/games/result", post(game_result)) + .route("/games/{id}", get(game_detail)) + .route("/pages/{slug}", get(get_page)) +} + +// ── Token generation ────────────────────────────────────────────────────────── + +fn generate_token() -> String { + rand::thread_rng() + .sample_iter(Alphanumeric) + .take(64) + .map(char::from) + .collect() +} + +// ── Error type ──────────────────────────────────────────────────────────────── + +enum AppError { + Database(db::DbError), + NotFound, + Conflict(&'static str), + BadRequest(&'static str), + Unauthorized, + Internal, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + match self { + AppError::Database(e) => { + tracing::error!("database error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() + } + AppError::NotFound => StatusCode::NOT_FOUND.into_response(), + AppError::Conflict(msg) => (StatusCode::CONFLICT, msg).into_response(), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(), + AppError::Unauthorized => StatusCode::UNAUTHORIZED.into_response(), + AppError::Internal => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } +} + +impl From for AppError { + fn from(e: db::DbError) -> Self { + AppError::Database(e) + } +} + +// ── Request / response bodies ───────────────────────────────────────────────── + +#[derive(Deserialize)] +struct RegisterBody { + username: String, + email: String, + password: String, +} + +#[derive(Deserialize)] +struct LoginBody { + username: String, + password: String, +} + +#[derive(Deserialize)] +struct TokenQuery { + token: String, +} + +#[derive(Deserialize)] +struct ForgotPasswordBody { + email: String, +} + +#[derive(Deserialize)] +struct ResetPasswordBody { + token: String, + new_password: String, +} + +#[derive(Serialize)] +struct MeResponse { + id: i64, + username: String, + email_verified: bool, +} + +#[derive(Serialize)] +struct UserProfileResponse { + id: i64, + username: String, + created_at: i64, + total_games: i64, + wins: i64, + losses: i64, + draws: i64, +} + +#[derive(Deserialize)] +struct GamesQuery { + #[serde(default)] + page: i64, + #[serde(default = "default_per_page")] + per_page: i64, +} + +fn default_per_page() -> i64 { + 20 +} + +#[derive(Serialize)] +struct GamesResponse { + games: Vec, +} + +#[derive(Serialize)] +struct GameSummaryResponse { + id: i64, + game_id: String, + room_code: String, + started_at: i64, + ended_at: Option, + result: Option, + outcome: Option, +} + +impl From for GameSummaryResponse { + fn from(g: db::GameSummary) -> Self { + Self { + id: g.id, + game_id: g.game_id, + room_code: g.room_code, + started_at: g.started_at, + ended_at: g.ended_at, + result: g.result, + outcome: g.outcome, + } + } +} + +// ── Auth handlers ───────────────────────────────────────────────────────────── + +async fn register( + mut auth_session: AuthSession, + State(state): State>, + Json(body): Json, +) -> Result { + if body.username.len() < 3 || body.username.len() > 30 { + return Err(AppError::BadRequest("username must be 3–30 characters")); + } + if body.password.len() < 8 { + return Err(AppError::BadRequest("password must be at least 8 characters")); + } + if !body.email.contains('@') { + return Err(AppError::BadRequest("invalid email address")); + } + + let hash = hash_password(&body.password).map_err(|_| AppError::Internal)?; + + let user_id = db::create_user(&state.db, &body.username, &body.email, &hash) + .await + .map_err(|e| { + if e.is_unique_violation() { + AppError::Conflict("username or email already taken") + } else { + AppError::Database(e) + } + })?; + + let user = db::get_user_by_id(&state.db, user_id) + .await? + .ok_or(AppError::Internal)?; + + // Send verification email (best-effort). + let token = generate_token(); + let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY; + if db::create_email_token(&state.db, user_id, &token, "verify", expires_at) + .await + .is_ok() + { + state.mailer.send_verification(&body.email, &token).await; + } + + auth_session.login(&user).await.map_err(|_| AppError::Internal)?; + + Ok(( + StatusCode::CREATED, + Json(MeResponse { + id: user.id, + username: user.username, + email_verified: user.email_verified, + }), + )) +} + +async fn login( + mut auth_session: AuthSession, + State(state): State>, + Json(body): Json, +) -> Result { + let creds = Credentials { + login: body.username, + password: body.password, + }; + + let user = match auth_session.authenticate(creds).await { + Ok(Some(u)) => u, + Ok(None) => return Err(AppError::Unauthorized), + Err(_) => return Err(AppError::Internal), + }; + + auth_session.login(&user).await.map_err(|_| AppError::Internal)?; + + if !user.email_verified { + let _ = db::delete_email_tokens(&state.db, user.id, "verify").await; + let token = generate_token(); + let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY; + if db::create_email_token(&state.db, user.id, &token, "verify", expires_at) + .await + .is_ok() + { + state.mailer.send_verification(&user.email, &token).await; + } + } + + Ok(Json(MeResponse { + id: user.id, + username: user.username, + email_verified: user.email_verified, + })) +} + +async fn logout(mut auth_session: AuthSession) -> Result { + auth_session.logout().await.map_err(|_| AppError::Internal)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn delete_account( + mut auth_session: AuthSession, + State(state): State>, +) -> Result { + let user = auth_session.user.clone().ok_or(AppError::Unauthorized)?; + auth_session.logout().await.map_err(|_| AppError::Internal)?; + db::delete_user(&state.db, user.id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn me(auth_session: AuthSession) -> Result { + match auth_session.user { + Some(user) => Ok(Json(MeResponse { + id: user.id, + username: user.username, + email_verified: user.email_verified, + }) + .into_response()), + None => Ok(StatusCode::UNAUTHORIZED.into_response()), + } +} + +async fn verify_email( + State(state): State>, + Query(params): Query, +) -> Result { + let user_id = db::consume_email_token(&state.db, ¶ms.token, "verify") + .await? + .ok_or(AppError::BadRequest("invalid or expired token"))?; + + db::set_email_verified(&state.db, user_id).await?; + + Ok(StatusCode::OK) +} + +async fn resend_verification( + auth_session: AuthSession, + State(state): State>, +) -> Result { + let user = auth_session.user.ok_or(AppError::Unauthorized)?; + + if user.email_verified { + return Ok(StatusCode::OK); + } + + db::delete_email_tokens(&state.db, user.id, "verify").await?; + + let token = generate_token(); + let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY; + db::create_email_token(&state.db, user.id, &token, "verify", expires_at).await?; + + state.mailer.send_verification(&user.email, &token).await; + + Ok(StatusCode::OK) +} + +async fn forgot_password( + State(state): State>, + Json(body): Json, +) -> StatusCode { + // Always return 200 to avoid leaking which email addresses are registered. + if let Ok(Some(user)) = db::get_user_by_email(&state.db, &body.email).await { + let _ = db::delete_email_tokens(&state.db, user.id, "reset").await; + let token = generate_token(); + let expires_at = now_unix() + RESET_TOKEN_EXPIRY; + if db::create_email_token(&state.db, user.id, &token, "reset", expires_at) + .await + .is_ok() + { + state.mailer.send_password_reset(&body.email, &token).await; + } + } + StatusCode::OK +} + +async fn reset_password( + State(state): State>, + Json(body): Json, +) -> Result { + if body.new_password.len() < 8 { + return Err(AppError::BadRequest("password must be at least 8 characters")); + } + + let user_id = db::consume_email_token(&state.db, &body.token, "reset") + .await? + .ok_or(AppError::BadRequest("invalid or expired token"))?; + + let hash = hash_password(&body.new_password).map_err(|_| AppError::Internal)?; + db::update_password_hash(&state.db, user_id, &hash).await?; + + Ok(StatusCode::OK) +} + +// ── Profile handlers ────────────────────────────────────────────────────────── + +async fn user_profile( + Path(username): Path, + State(state): State>, +) -> Result { + let user = db::get_user_by_username(&state.db, &username) + .await? + .ok_or(AppError::NotFound)?; + + let stats = db::get_user_stats(&state.db, user.id).await?; + + Ok(Json(UserProfileResponse { + id: user.id, + username: user.username, + created_at: user.created_at, + total_games: stats.total, + wins: stats.wins, + losses: stats.losses, + draws: stats.draws, + })) +} + +async fn user_games( + Path(username): Path, + Query(query): Query, + State(state): State>, +) -> Result { + let per_page = query.per_page.clamp(1, 100); + let page = query.page.max(0); + + let user = db::get_user_by_username(&state.db, &username) + .await? + .ok_or(AppError::NotFound)?; + + let summaries = db::get_user_games(&state.db, user.id, page, per_page).await?; + + Ok(Json(GamesResponse { + games: summaries.into_iter().map(Into::into).collect(), + })) +} + +// ── Game detail ─────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct ParticipantWithUsername { + player_id: i64, + outcome: Option, + username: Option, +} + +#[derive(Serialize)] +struct GameDetailResponse { + id: i64, + game_id: String, + room_code: String, + started_at: i64, + ended_at: Option, + result: Option, + participants: Vec, +} + +async fn game_detail( + Path(id): Path, + State(state): State>, +) -> Result { + let client = state.db.get().await.map_err(db::DbError::from)?; + + let record = client + .query_opt( + "SELECT id, game_id, room_code, started_at, ended_at, result + FROM game_records WHERE id = $1", + &[&id], + ) + .await + .map_err(db::DbError::from)? + .ok_or(AppError::NotFound)?; + + let rows = client + .query( + "SELECT gp.player_id, gp.outcome, u.username + FROM game_participants gp + LEFT JOIN users u ON u.id = gp.user_id + WHERE gp.game_record_id = $1 + ORDER BY gp.player_id", + &[&id], + ) + .await + .map_err(db::DbError::from)?; + + let participants = rows + .into_iter() + .map(|r| ParticipantWithUsername { + player_id: r.get("player_id"), + outcome: r.get("outcome"), + username: r.get("username"), + }) + .collect(); + + Ok(Json(GameDetailResponse { + id: record.get("id"), + game_id: record.get("game_id"), + room_code: record.get("room_code"), + started_at: record.get("started_at"), + ended_at: record.get("ended_at"), + result: record.get("result"), + participants, + })) +} + +// ── Game result recording ───────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct GameResultBody { + room_code: String, + game_id: String, + /// Opaque game-specific result, stored verbatim as JSON. + result: JsonValue, + /// Per-player outcomes keyed by player_id as a string ("0", "1", …). + /// Accepted values: "win", "loss", "draw". Missing keys → NULL outcome. + #[serde(default)] + outcomes: HashMap, +} + +#[derive(Serialize)] +struct GameResultResponse { + game_record_id: i64, +} + +/// Called by the WASM host when a game ends. +/// +/// The room code + game ID act as the shared secret (same trust level as WS join). +/// `close_game_record` is idempotent (no-op if already closed), and participant +/// inserts use `ON CONFLICT DO NOTHING`, so safe retries are supported. +async fn game_result( + State(state): State>, + Json(body): Json, +) -> Result { + let compound_id = format!("{}#{}", body.room_code, body.game_id); + + let (game_record_id, user_ids) = { + let rooms = state.rooms.lock().await; + let room = rooms.get(&compound_id).ok_or(AppError::NotFound)?; + let record_id = room + .game_record_id + .ok_or(AppError::NotFound)?; + (record_id, room.user_ids.clone()) + }; + + let result_json = serde_json::to_string(&body.result) + .map_err(|_| AppError::BadRequest("could not serialise result"))?; + + db::close_game_record(&state.db, game_record_id, Some(&result_json)).await?; + + for (player_id, user_id) in &user_ids { + let outcome = body.outcomes.get(&player_id.to_string()).map(String::as_str); + db::insert_participant(&state.db, game_record_id, *user_id, *player_id, outcome).await?; + } + + tracing::info!( + game_record_id, + room = body.room_code, + "Game result recorded" + ); + + Ok(Json(GameResultResponse { game_record_id })) +} + +// ── Static content pages ────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct LangQuery { + #[serde(default = "default_lang")] + lang: String, +} + +fn default_lang() -> String { + "en".to_string() +} + +#[derive(Serialize)] +struct PageResponse { + title: String, + content: String, +} + +async fn get_page( + Path(slug): Path, + Query(query): Query, + State(state): State>, +) -> Result { + // Reject slugs with path-traversal characters or unusual lengths. + if slug.is_empty() + || slug.len() > 64 + || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(AppError::NotFound); + } + // Normalise lang to a safe identifier. + let lang = if !query.lang.is_empty() + && query.lang.len() <= 5 + && query.lang.chars().all(|c| c.is_ascii_alphabetic()) + { + query.lang.to_ascii_lowercase() + } else { + "en".to_string() + }; + + let base = std::path::Path::new(&state.pages_dir); + let primary = base.join(&slug).join(format!("{lang}.md")); + + let content = match tokio::fs::read_to_string(&primary).await { + Ok(c) => c, + Err(_) if lang != "en" => { + let fallback = base.join(&slug).join("en.md"); + tokio::fs::read_to_string(&fallback) + .await + .map_err(|_| AppError::NotFound)? + } + Err(_) => return Err(AppError::NotFound), + }; + + let title = content + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| l[2..].trim().to_string()) + .unwrap_or_default(); + + Ok(Json(PageResponse { title, content })) +} diff --git a/server/relay-server/src/lobby.rs b/server/relay-server/src/lobby.rs new file mode 100644 index 0000000..db8f57c --- /dev/null +++ b/server/relay-server/src/lobby.rs @@ -0,0 +1,99 @@ +//! This module handles game rooms where players connect and exchange messages. +//! It provides: +//! - [`Room`]: A game session with host-to-client broadcast channels +//! - [`AppState`]: Global state holding all active rooms and game configurations +//! - [`reload_config`]: Hot-reloading of game settings from `GameConfig.json` + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use deadpool_postgres::Pool; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::fs; +use tokio::sync::{Mutex, RwLock}; +use tokio::sync::{broadcast, mpsc}; + +use crate::smtp::Mailer; + +/// The game entry we have for one game. +#[derive(Serialize, Deserialize)] +pub struct GameEntry { + /// The name of the game. + pub name: String, + /// The maximum amount of players (0 = no limit) + pub max_players: u16, +} + +type EntryList = Vec; + +/// The description of the room, the players play in +pub struct Room { + /// The next id a client gets, this is consecutively counted. + pub next_client_id: u16, // Needs Mutex + /// The amount of players currently in the room. + pub amount_of_players: u16, // Needs mutex. + /// This is a status counter for rule variation in a game (like coop vs semi-coop). + pub rule_variation: u16, + /// The sender to send messages to the host. + pub to_host_sender: mpsc::Sender, // Clone-able no Mutex! + /// The broad case sender needed to subscribe for the clients. + pub host_to_client_broadcaster: broadcast::Sender, // Clone-able -> no Mutex! + /// Reconnect tokens keyed by player id. Used to authenticate reconnect attempts. + pub player_tokens: HashMap, + /// Whether the host WebSocket is currently active. False during the grace period + /// after host disconnect — the grace-period task will clean up the room if the + /// host does not reconnect in time. + pub host_connected: bool, + /// IDs of non-host players whose WebSocket is currently active. + /// Used to replay NEW_CLIENT / CLIENT_DISCONNECTS when the host reconnects. + pub connected_players: Vec, + /// Row id in `game_records` for this session. None when no authenticated player created the room. + pub game_record_id: Option, + /// Maps in-game player_id → database user_id. None means the player is anonymous. + pub user_ids: HashMap>, +} + +/// The application state. +pub struct AppState { + /// The rooms we associate with several sessions. + pub rooms: Mutex>, + /// Contains a mapping from game name to the maximum amount of players allowed. + pub configs: RwLock>, + /// PostgreSQL connection pool — shared across all request handlers. + pub db: Pool, + /// SMTP mailer for email verification and password reset. + pub mailer: Mailer, + /// Directory containing static content pages as `{slug}/{lang}.md` files. + pub pages_dir: String, +} + +impl AppState { + pub fn new(db: Pool, mailer: Mailer, pages_dir: String) -> Self { + Self { + rooms: Mutex::new(HashMap::new()), + configs: RwLock::new(HashMap::new()), + db, + mailer, + pages_dir, + } + } +} + +/// Reloads the configuration file, that lists the games with the maximum number of players per room. +pub async fn reload_config(state: &Arc) -> Result<(), String> { + let json_content = fs::read_to_string("GameConfig.json") + .await + .map_err(|e| format!("Failed to read file: {}", e))?; + let raw_data: EntryList = + serde_json::from_str(&json_content).map_err(|e| format!("Failed to parse JSON: {}", e))?; + let new_configs: HashMap = raw_data + .into_iter() + .map(|entry| (entry.name, entry.max_players)) + .collect(); + + { + let mut configs = state.configs.write().await; + *configs = new_configs; // Replace all. + } + Ok(()) +} diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs new file mode 100644 index 0000000..367ef98 --- /dev/null +++ b/server/relay-server/src/main.rs @@ -0,0 +1,238 @@ +mod auth; +mod db; +mod hand_shake; +mod http; +mod lobby; +mod message_relay; +mod smtp; + +use crate::auth::AuthBackend; +use crate::hand_shake::{ + ClientServerSpecificData, DisconnectData, inform_client_of_connection, init_and_connect, + shutdown_connection, +}; +use crate::lobby::{AppState, reload_config}; +use crate::message_relay::{handle_client_logic, handle_server_logic}; +use axum::Router; +use axum::extract::ws::{Message, WebSocket}; +use axum::extract::{State, WebSocketUpgrade}; +use axum::http::{HeaderName, Method}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum_login::{AuthManagerLayerBuilder, AuthSession}; +use bytes::Bytes; +use futures_util::SinkExt; +use futures_util::stream::StreamExt; +use std::sync::Arc; +use std::time::Duration; +use time::Duration as TimeDuration; +use tokio::sync::Mutex; +use tower_http::cors::{AllowOrigin, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; +use tower_sessions::MemoryStore; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +/// Activates error tracing, spawns a watch dog task to eliminate eventual dead rooms, then it sets up the roting system to serve the +/// web sockets and listen for the pages enlist and reload. The server listens on port 8080. +async fn main() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| format!("{}=trace", env!("CARGO_CRATE_NAME")).into()), + ) + .with( + tracing_subscriber::fmt::layer() + .with_file(true) + .with_line_number(true) + .with_target(true) // Modul-Path (e.g. relay_server::processing_module) + .with_thread_ids(true) // Thread-ID (helpful for Tokio) + .with_thread_names(true), // Thread-Name + ) + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgresql://trictrac:trictrac@127.0.0.1:5432/trictrac".to_string()); + let pool = db::init_db(&database_url).await; + + let mailer = smtp::Mailer::from_env(); + + let session_store = MemoryStore::default(); + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false) + .with_expiry(Expiry::OnInactivity(TimeDuration::days(30))); + + let auth_backend = AuthBackend::new(pool.clone()); + let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build(); + + let pages_dir = std::env::var("PAGES_DIR").unwrap_or_else(|_| "pages".to_string()); + let app_state = Arc::new(AppState::new(pool, mailer, pages_dir)); + let watchdog_state = app_state.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min + loop { + interval.tick().await; + cleanup_dead_rooms(&watchdog_state).await; + } + }); + + let initial = reload_config(&app_state).await; + if let Err(message) = initial { + tracing::error!(message, "Initial load error."); + panic!("Initial load error: {}", message); + } + + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::list([ + "http://localhost:9091".parse().unwrap(), // unified web dev server + ])) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_headers([ + HeaderName::from_static("content-type"), + HeaderName::from_static("cookie"), + ]) + .allow_credentials(true); + + let app = Router::new() + .route("/reload", get(reload_handler)) + .route("/enlist", get(enlist_handler)) + .route("/ws", get(websocket_handler)) + .merge(http::router()) + .with_state(app_state) + .fallback_service(ServeDir::new(".").not_found_service(ServeFile::new("index.html"))) + .layer(auth_layer) + .layer(cors); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") + .await + .unwrap(); + + axum::serve(listener, app).await.unwrap(); +} + +/// Runs over all rooms and checks if they are diconnected from the server. +/// If so, it cleans them up. This is a fallback solution things should be handled internally otherwise. +async fn cleanup_dead_rooms(state: &Arc) { + let mut rooms = state.rooms.lock().await; + rooms.retain(|room_id, room| { + // Keep rooms where the host is actively connected. + // Rooms with host_connected = false are in the grace period — the + // grace-period task spawned by shutdown_connection owns their cleanup. + let is_alive = room.host_connected && !room.to_host_sender.is_closed(); + if !is_alive { + tracing::info!("Removing dead room: {}", room_id); + } + is_alive + }); +} + +/// Generates a list with the current rooms, the amount of players and info if this is a dead room. +async fn enlist_handler(State(state): State>) -> String { + let rooms = state.rooms.lock().await; + rooms + .iter() + .map(|(name, room)| { + format!( + "Room: {:<30} Variation: {:03} Players: {:03} is alive: {}", + name, + room.rule_variation, + room.amount_of_players, + !room.to_host_sender.is_closed() + ) + }) + .collect::>() + .join("\n") +} + +/// Forces the reload of the config file and lists the content. This enables the adding of new games +/// without restarting the service. +async fn reload_handler(State(state): State>) -> String { + let res = reload_config(&state).await; + match res { + Ok(_) => state + .configs + .read() + .await + .iter() + .map(|(key, players)| { + format!("Game: {:<40} Maximum Amount of Players: {}", key, players) + }) + .collect::>() + .join("\n"), + Err(e) => { + format!("Config reload failed: {}", e) + } + } +} + +/// This function gets immediately called and upgrades the web response to a web socket. +async fn websocket_handler( + ws: WebSocketUpgrade, + auth_session: AuthSession, + State(state): State>, +) -> impl IntoResponse { + let user_id = auth_session.user.map(|u| u.id); + ws.on_upgrade(move |socket| websocket(socket, state, user_id)) +} + +/// Does the whole handling from start to finish: Handshake -> Handling of logic depending on if we are connected to +/// the server or client -> Shut down processing. +async fn websocket(stream: WebSocket, state: Arc, user_id: Option) { + // By splitting, we can send and receive at the same time. + let (mut sender, mut receiver) = stream.split(); + + let handshake_result = + init_and_connect(&mut sender, &mut receiver, state.clone(), user_id).await; + if handshake_result.is_none() { + // We quit here, as the handshake did not work out. + return; + } + let base_data = handshake_result.unwrap(); + + let disconnect_data = DisconnectData::from(&base_data); + let success = inform_client_of_connection(&mut sender, &base_data).await; + let wrapped_sender = Arc::new(Mutex::new(sender)); + + // Ping-Task to keep alive. + let ping_sender = wrapped_sender.clone(); + let ping_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; // Skip first tick. + loop { + interval.tick().await; + let mut s = ping_sender.lock().await; + if s.send(Message::Ping(Bytes::new())).await.is_err() { + break; + } + } + }); + + let mut error_message = "Connection to server lost"; + if success { + match base_data.specific_data { + ClientServerSpecificData::Server(internal_receiver, internal_sender) => { + error_message = handle_server_logic( + wrapped_sender.clone(), + receiver, + internal_receiver, + internal_sender, + ) + .await; + } + ClientServerSpecificData::Client(internal_receiver, internal_sender) => { + error_message = handle_client_logic( + wrapped_sender.clone(), + receiver, + internal_receiver, + internal_sender, + base_data.player_id, + ) + .await; + } + } + } + + ping_task.abort(); + shutdown_connection(wrapped_sender, disconnect_data, state, error_message).await; +} diff --git a/server/relay-server/src/message_relay.rs b/server/relay-server/src/message_relay.rs new file mode 100644 index 0000000..a1db1e0 --- /dev/null +++ b/server/relay-server/src/message_relay.rs @@ -0,0 +1,354 @@ +//! WebSocket message routing for the relay server. +//! +//! This module handles bidirectional communication between game hosts and clients. +//! It spawns paired Tokio tasks for each connection that: +//! - Validate and filter messages by type (preventing illegal commands) +//! - Route host broadcasts to subscribed clients +//! - Forward client RPCs to the host with injected player IDs +//! - Manage sync state so clients only receive deltas after a full update +//! +//! The relay server never interprets game logic — it only validates message types +//! and routes bytes between endpoints. + +use axum::extract::ws::{Message, WebSocket}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use futures_util::stream::{SplitSink, SplitStream}; +use futures_util::{SinkExt, StreamExt}; +use protocol::*; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::broadcast; +use tokio::sync::broadcast::Sender; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::mpsc::Receiver; + +/// Spawns bidirectional message handlers for a game host connection. +/// +/// Creates two concurrent tasks: +/// - **Send task**: Forwards client messages (joins, disconnects, RPCs) to the host +/// - **Receive task**: Broadcasts host messages (updates, kicks) to all clients +/// +/// When either task completes (connection lost, protocol error, intentional disconnect), +/// the other is aborted and the room should be cleaned up by the caller. +/// +/// # Returns +/// A static string describing why the connection ended (for logging/debugging). +pub async fn handle_server_logic( + sender: Arc>>, + receiver: SplitStream, + internal_receiver: Receiver, + internal_sender: broadcast::Sender, +) -> &'static str { + let mut send_task = + tokio::spawn(async move { send_logic_server(sender, internal_receiver).await }); + + let mut receive_task = + tokio::spawn(async move { receive_logic_server(receiver, internal_sender).await }); + + // If any one of the tasks run to completion, we abort the other. + let result = tokio::select! { + res_a = &mut send_task => {receive_task.abort(); res_a}, + res_b = &mut receive_task => {send_task.abort(); res_b}, + }; + + result.unwrap_or_else(|err| { + tracing::error!(?err, "Error while handling server logic."); + "Internal panic in server side logic." + }) +} + +/// Receives messages from the game host and broadcasts them to all clients. +/// +/// Allowed message types from host: +/// - [`CLIENT_GETS_KICKED`]: Remove a specific player +/// - [`DELTA_UPDATE`]: Incremental game state change +/// - [`FULL_UPDATE`]: Complete game state (for new/desynced clients) +/// - [`RESET`]: Game restart signal +/// - [`SERVER_DISCONNECTS`]: Graceful shutdown (triggers cleanup) +/// +/// Any other message type is rejected as a protocol violation. +async fn receive_logic_server( + mut receiver: SplitStream, + internal_sender: Sender, +) -> &'static str { + while let Some(state) = receiver.next().await { + match state { + Ok(Message::Binary(bytes)) => { + if bytes.is_empty() { + tracing::error!("Illegal empty message in receive logic server."); + return "Illegal empty message received."; + } + + if bytes[0] == SERVER_DISCONNECTS { + // This something normal to be expected. + return "Server disconnected intentionally"; + } + + if !matches!( + bytes[0], + CLIENT_GETS_KICKED | DELTA_UPDATE | FULL_UPDATE | RESET + ) { + tracing::error!( + message_type = bytes[0], + "Illegal message type Server->Client." + ); + return "Illegal Server -> Client command."; + } + + // All messages are simply passed through. + let res = internal_sender.send(bytes); + // An error may occur, if there are no further clients available. + // As a rule of a thumb the server should not send any messages, if he does not know of any clients. + // Currently logged as a warning, as it is unclear, if this is strictly avoidable. + if let Err(error) = res { + tracing::warn!(?error, "Sending to no clients."); + } + } + Ok(_) => {} // Ignore other messages (ping/pong handled by axum) + Err(_) => { + return "Connection lost."; + } + } + } + "Connection lost." +} + +/// Forwards aggregated client messages to the game host. +/// +/// Allowed message types to host: +/// - [`NEW_CLIENT`]: Player joined notification +/// - [`CLIENT_DISCONNECTS`]: Player left notification +/// - [`SERVER_RPC`]: Game action from a client (with player ID prepended) +/// +/// This task owns the WebSocket sender lock for its lifetime to ensure +/// sequential message delivery to the host. +async fn send_logic_server( + sender: Arc>>, + mut internal_receiver: Receiver, +) -> &'static str { + while let Some(bytes) = internal_receiver.recv().await { + if bytes.is_empty() { + tracing::error!("Illegal internal empty message in send logic server."); + return "Illegal empty message received."; + } + if !matches!(bytes[0], NEW_CLIENT | CLIENT_DISCONNECTS | SERVER_RPC) { + tracing::error!( + message_type = bytes[0], + "Unknown internal Client->Server command" + ); + return "Unknown internal Client->Server command"; + } + // Simply pass on the message. + let res = sender.lock().await.send(Message::Binary(bytes)).await; + if let Err(err) = res { + tracing::error!(?err, "Error in communication with server endpoint."); + return "Error in communication with server endpoint."; + } + } + // In normal shutdown procedure that should not happen, because we are responsible for closing the channel. + tracing::error!("Internal channel on server was unexpectedly closed."); + "Internal channel closed." +} + +/// Spawns bidirectional message handlers for a game client connection. +/// +/// Creates two concurrent tasks: +/// - **Send task**: Delivers host broadcasts to this client (with sync state filtering) +/// - **Receive task**: Forwards client RPCs to the host (with player ID injection) +/// +/// # Arguments +/// * `player_id` - Unique identifier assigned to this client for the session +/// +/// # Returns +/// A static string describing why the connection ended. +pub async fn handle_client_logic( + sender: Arc>>, + receiver: SplitStream, + internal_receiver: tokio::sync::broadcast::Receiver, + internal_sender: tokio::sync::mpsc::Sender, + player_id: u16, +) -> &'static str { + let mut send_task = + tokio::spawn(async move { send_logic_client(sender, internal_receiver, player_id).await }); + + let mut receive_task = + tokio::spawn( + async move { receive_logic_client(receiver, internal_sender, player_id).await }, + ); + + // If any one of the tasks run to completion, we abort the other. + let result = tokio::select! { + res_a = &mut send_task => {receive_task.abort(); res_a}, + res_b = &mut receive_task => {send_task.abort(); res_b}, + }; + + result.unwrap_or_else(|err| { + tracing::error!(?err, "Internal panic in client side logic."); + "Internal panic in client side logic." + }) +} + +/// Receives messages from a client and forwards them to the host. +/// +/// Allowed message types from client: +/// - [`SERVER_RPC`]: Game action — gets player ID injected before forwarding +/// - [`CLIENT_DISCONNECTS_SELF`]: Graceful disconnect (triggers cleanup) +/// +/// # Player ID Injection +/// RPC messages are transformed from `[SERVER_RPC, payload...]` to +/// `[SERVER_RPC, player_id_high, player_id_low, payload...]` so the host +/// knows which player sent the action. +async fn receive_logic_client( + mut receiver: SplitStream, + internal_sender: tokio::sync::mpsc::Sender, + player_id: u16, +) -> &'static str { + while let Some(state) = receiver.next().await { + match state { + Ok(Message::Binary(bytes)) => { + if bytes.is_empty() { + tracing::error!("Illegal empty message received in receive logic client."); + return "Illegal empty message received."; + } + match bytes[0] { + SERVER_RPC => { + // Inject player ID after command byte + let mut msg = BytesMut::with_capacity(bytes.len() + CLIENT_ID_SIZE); + msg.put_u8(SERVER_RPC); + msg.put_u16(player_id); + msg.put_slice(&bytes[1..]); + + let res = internal_sender.send(msg.into()).await; + if let Err(error) = res { + tracing::error!(?error, "Error in internal broadcast."); + return "Error in internal broadcast."; + } + } + CLIENT_DISCONNECTS_SELF => { + return "Client disconnected intentionally"; + } + _ => { + tracing::error!(command = ?bytes[0], "Illegal command from client."); + return "Illegal Command from client"; + } + } + } + Ok(_) => {} // Ignore other messages + Err(_) => { + return "Connection lost."; + } + } + } + "Connection lost." +} + +/// Delivers host broadcasts to a specific client with sync state management. +/// +/// # Sync State Machine +/// Clients start unsynced and must receive a [`FULL_UPDATE`] or [`RESET`] before +/// processing [`DELTA_UPDATE`] messages. This prevents clients from applying +/// deltas to an unknown base state. +/// +/// ```text +/// [Unsynced] --FULL_UPDATE--> [Synced] --DELTA_UPDATE--> [Synced] +/// [Unsynced] --RESET-------> [Synced] +/// [Synced] --DELTA_UPDATE--> [Synced] (forwarded) +/// [Unsynced] --DELTA_UPDATE--> [Unsynced] (dropped) +/// ``` +/// +/// # Filtered Messages +/// - [`CLIENT_GETS_KICKED`]: Only terminates if `player_id` matches +/// - [`SERVER_DISCONNECTS`]: Always terminates +/// +/// # Error Handling +/// Returns immediately if the broadcast channel lags (buffer overflow), +/// as the client cannot recover from missed messages. +async fn send_logic_client( + sender: Arc>>, + mut internal_receiver: tokio::sync::broadcast::Receiver, + player_id: u16, +) -> &'static str { + let mut is_synced = false; + loop { + let state = internal_receiver.recv().await; + match state { + Err(RecvError::Closed) => { + tracing::error!("Internal channel closed."); + return "Internal channel closed."; + } + Err(RecvError::Lagged(skipped)) => { + tracing::warn!( + skipped_messages = skipped, + "Lagging started on internal channel." + ); + return "Lagging on internal channel - Computer too slow."; + } + Ok(mut bytes) => { + if bytes.is_empty() { + tracing::error!("Illegal empty message received."); + return "Illegal empty message received."; + } + match bytes[0] { + SERVER_DISCONNECTS => { + return "Server has left the game."; + } + CLIENT_GETS_KICKED => { + if bytes.len() < 3 { + tracing::error!("Malformed CLIENT_GETS_KICKED message"); + return "Malformed message received."; + } + bytes.get_u8(); // Skip command byte + let meant_client = bytes.get_u16(); + // We have to see if we are meant. + if meant_client == player_id { + return "We got rejected by server."; + } + } + DELTA_UPDATE => { + if is_synced { + let res = sender.lock().await.send(Message::Binary(bytes)).await; + if let Err(error) = res { + tracing::error!( + ?error, + "Error in communication with client endpoint." + ); + return "Error in communication with client endpoint."; + } + } + // Silently drop deltas for unsynced clients + } + FULL_UPDATE => { + if !is_synced { + is_synced = true; + let res = sender.lock().await.send(Message::Binary(bytes)).await; + if let Err(error) = res { + tracing::error!( + ?error, + "Error in communication with client endpoint." + ); + return "Error in communication with client endpoint."; + } + } + // Drop redundant full updates for already synced clients + } + RESET => { + // We simply forward the message and are definitively synced here. + is_synced = true; + let res = sender.lock().await.send(Message::Binary(bytes)).await; + if let Err(error) = res { + tracing::error!(?error, "Error in communication with client endpoint."); + return "Error in communication with client endpoint."; + } + } + _ => { + tracing::error!( + message = bytes[0], + "Illegal message on client side received." + ); + return "Illegal message on client side received."; + } + } + } + } + } +} diff --git a/server/relay-server/src/smtp.rs b/server/relay-server/src/smtp.rs new file mode 100644 index 0000000..bbcfa6e --- /dev/null +++ b/server/relay-server/src/smtp.rs @@ -0,0 +1,128 @@ +//! SMTP mailer. +//! +//! Configured via environment variables: +//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev) +//! SMTP_PORT — default: 1025 (mailpit) / 465 when SMTP_TLS=true +//! SMTP_TLS — set to "true" to use TLS (required for Resend and other cloud SMTP) +//! SMTP_FROM — default: noreply@trictrac.local +//! SMTP_USER — optional SMTP credentials (use "resend" for Resend) +//! SMTP_PASSWORD — optional SMTP credentials (use Resend API key) +//! APP_URL — default: http://localhost:9091 (frontend base URL for email links) +//! +//! Production (Resend): +//! SMTP_HOST=smtp.resend.com SMTP_TLS=true +//! SMTP_USER=resend SMTP_PASSWORD=re_xxxx +//! SMTP_FROM=noreply@yourdomain.com + +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, + message::Mailbox, + transport::smtp::authentication::Credentials as SmtpCredentials, +}; + +pub struct Mailer { + transport: AsyncSmtpTransport, + from: Mailbox, + app_url: String, +} + +impl Mailer { + pub fn from_env() -> Self { + let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let tls = std::env::var("SMTP_TLS").map(|v| v == "true").unwrap_or(false); + let from_str = std::env::var("SMTP_FROM") + .unwrap_or_else(|_| "noreply@trictrac.local".to_string()); + let app_url = std::env::var("APP_URL") + .unwrap_or_else(|_| "http://localhost:9091".to_string()); + + let credentials = if let (Ok(user), Ok(pass)) = + (std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD")) + { + Some(SmtpCredentials::new(user, pass)) + } else { + None + }; + + let transport = if tls { + // TLS on port 465 (Resend, SendGrid, etc.) + let default_port = 465u16; + let port: u16 = std::env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(default_port); + let mut builder = AsyncSmtpTransport::::relay(&host) + .expect("invalid SMTP_HOST for TLS relay") + .port(port); + if let Some(creds) = credentials { + builder = builder.credentials(creds); + } + builder.build() + } else { + // Plain SMTP (Mailpit dev, or local relay) + let port: u16 = std::env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(1025); + let mut builder = + AsyncSmtpTransport::::builder_dangerous(&host).port(port); + if let Some(creds) = credentials { + builder = builder.credentials(creds); + } + builder.build() + }; + + let from = from_str + .parse() + .unwrap_or_else(|_| "noreply@trictrac.local".parse().unwrap()); + + Self { transport, from, app_url } + } + + pub async fn send_verification(&self, to_email: &str, token: &str) { + let link = format!("{}/verify-email?token={}", self.app_url, token); + let body = format!( + "Welcome to Trictrac!\n\n\ + Please verify your email address by clicking the link below:\n\n\ + {link}\n\n\ + This link expires in 24 hours.\n" + ); + self.send(to_email, "Verify your Trictrac account", body).await; + } + + pub async fn send_password_reset(&self, to_email: &str, token: &str) { + let link = format!("{}/reset-password?token={}", self.app_url, token); + let body = format!( + "You requested a password reset for your Trictrac account.\n\n\ + Click the link below to choose a new password:\n\n\ + {link}\n\n\ + This link expires in 1 hour.\n\ + If you did not request this, you can safely ignore this email.\n" + ); + self.send(to_email, "Reset your Trictrac password", body).await; + } + + async fn send(&self, to_email: &str, subject: &str, body: String) { + let to: Mailbox = match to_email.parse() { + Ok(m) => m, + Err(e) => { + tracing::warn!("SMTP: invalid recipient address {to_email:?}: {e}"); + return; + } + }; + let email = match Message::builder() + .from(self.from.clone()) + .to(to) + .subject(subject) + .body(body) + { + Ok(e) => e, + Err(e) => { + tracing::warn!("SMTP: failed to build message: {e}"); + return; + } + }; + if let Err(e) = self.transport.send(email).await { + tracing::warn!("SMTP: send failed: {e}"); + } + } +} diff --git a/spiel_bot/Cargo.toml b/spiel_bot/Cargo.toml index 1458d66..b541adc 100644 --- a/spiel_bot/Cargo.toml +++ b/spiel_bot/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -trictrac-store = { path = "../store", features = ["python"] } +trictrac-store = { path = "../store" } trictrac-bot = { path = "../bot" } anyhow = "1" rand = "0.9" diff --git a/store/Cargo.toml b/store/Cargo.toml index 58e9e32..92b1b84 100644 --- a/store/Cargo.toml +++ b/store/Cargo.toml @@ -1,32 +1,20 @@ [package] name = "trictrac-store" -version = "0.1.0" +version.workspace = true edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] name = "trictrac_store" -# "cdylib" → Python .so built by maturin (pyengine) -# "rlib" → used by other workspace crates (bot, client_cli) -# "staticlib" → used by the C++ OpenSpiel game (cxxengine) -crate-type = ["cdylib", "rlib", "staticlib"] - -[features] -# Enable Python bindings (required for maturin / AI training). Not available on wasm32. -python = ["pyo3"] -# Enable C++ bridge for OpenSpiel integration. Not available on wasm32. -cpp = ["dep:cxx"] +crate-type = ["rlib"] [dependencies] anyhow = "1.0" base64 = "0.21.7" -cxx = { version = "1.0", optional = true } # provides macros for creating log messages to be used by a logger (for example env_logger) log = "0.4.20" merge = "0.1.0" -# generate python lib (with maturin) to be used in AI training -pyo3 = { version = "0.23", features = ["extension-module", "abi3-py38"], optional = true } rand = "0.9" serde = { version = "1.0", features = ["derive"] } transpose = "0.2.2" @@ -34,6 +22,3 @@ transpose = "0.2.2" [[bin]] name = "random_game" path = "src/bin/random_game.rs" - -[build-dependencies] -cxx-build = "1.0" diff --git a/store/build.rs b/store/build.rs deleted file mode 100644 index 88d743f..0000000 --- a/store/build.rs +++ /dev/null @@ -1,9 +0,0 @@ -fn main() { - if std::env::var("CARGO_FEATURE_CPP").is_ok() { - cxx_build::bridge("src/cxxengine.rs") - .std("c++17") - .compile("trictrac-cxx"); - - println!("cargo:rerun-if-changed=src/cxxengine.rs"); - } -} diff --git a/store/pyproject.toml b/store/pyproject.toml deleted file mode 100644 index f50d478..0000000 --- a/store/pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[build-system] -requires = ["maturin>=1.0,<2.0"] -build-backend = "maturin" - -[tool.maturin] -# "extension-module" tells pyo3 we want to build an extension module (skips linking against libpython.so) -features = ["pyo3/extension-module"] -# python-source = "python" diff --git a/store/src/cxxengine.rs b/store/src/cxxengine.rs deleted file mode 100644 index 55d348c..0000000 --- a/store/src/cxxengine.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! C++ bindings for the TricTrac game engine via cxx.rs. -//! -//! Exposes an opaque `TricTracEngine` type to C++. The C++ side -//! (open_spiel/games/trictrac/trictrac.cc) holds it via -//! `rust::Box`. -//! -//! The Rust engine always reasons from White's (player 1's) perspective. -//! For Black (player 2), the board is mirrored before computing actions -//! and events are mirrored back before being applied — exactly as in -//! pyengine.rs. - -use std::panic::{self, AssertUnwindSafe}; - -use crate::dice::Dice; -use crate::game::{GameEvent, GameState, Stage, TurnStage}; -use crate::training_common::{get_valid_action_indices, TrictracAction}; - -/// Catch any Rust panic and convert it to anyhow::Error so it never -/// crosses the C FFI boundary as undefined behaviour. -fn catch_panics(f: F) -> anyhow::Result -where - F: FnOnce() -> anyhow::Result + panic::UnwindSafe, -{ - panic::catch_unwind(f).unwrap_or_else(|e| { - let msg = e - .downcast_ref::() - .map(|s| s.as_str()) - .or_else(|| e.downcast_ref::<&str>().copied()) - .unwrap_or("unknown panic payload"); - Err(anyhow::anyhow!("Rust panic in FFI: {}", msg)) - }) -} - -// ── cxx bridge declaration ──────────────────────────────────────────────────── - -#[cxx::bridge(namespace = "trictrac_engine")] -pub mod ffi { - // ── Shared types (transparent to both Rust and C++) ─────────────────────── - - /// Two dice values passed from C++ when applying a chance outcome. - struct DicePair { - die1: u8, - die2: u8, - } - - /// Both players' cumulative scores: holes * 12 + points. - struct PlayerScores { - score_p1: i32, - score_p2: i32, - } - - // ── Opaque Rust type and its free-function constructor ──────────────────── - - extern "Rust" { - /// Opaque handle to a running TricTrac game. - /// C++ accesses this only through `rust::Box`. - type TricTracEngine; - - /// Construct a fresh engine with two players; player 1 (White) goes first. - fn new_trictrac_engine() -> Box; - - /// Deep-copy the engine — required by OpenSpiel's State::Clone(). - fn clone_engine(self: &TricTracEngine) -> Box; - - // ── Queries ─────────────────────────────────────────────────────────── - - /// True when the game is in TurnStage::RollWaiting (OpenSpiel chance node). - fn needs_roll(self: &TricTracEngine) -> bool; - - /// True when Stage::Ended. - fn is_game_ended(self: &TricTracEngine) -> bool; - - /// Active player index: 0 = player 1 (White), 1 = player 2 (Black). - fn current_player_idx(self: &TricTracEngine) -> u64; - - /// Legal action indices for `player_idx` in [0, 513]. - /// Returns an empty vector when it is not that player's turn. - fn get_legal_actions(self: &TricTracEngine, player_idx: u64) -> Result>; - - /// Human-readable description of an action index. - fn action_to_string(self: &TricTracEngine, player_idx: u64, action_idx: u64) -> String; - - /// Both players' scores. - fn get_players_scores(self: &TricTracEngine) -> PlayerScores; - - /// 217-element state tensor (f32), normalized to [0,1]. Mirrored for player_idx == 1. - fn get_tensor(self: &TricTracEngine, player_idx: u64) -> Vec; - - /// Human-readable state description for `player_idx`. - fn get_observation_string(self: &TricTracEngine, player_idx: u64) -> String; - - /// Full debug representation of the current state. - fn to_debug_string(self: &TricTracEngine) -> String; - - // ── Mutations ───────────────────────────────────────────────────────── - - /// Apply a dice-roll result. Returns Err (C++ exception) if not in - /// the RollWaiting stage. - fn apply_dice_roll(self: &mut TricTracEngine, dice: DicePair) -> Result<()>; - - /// Apply a player action. Returns Err (C++ exception) if the action - /// is not legal in the current state. - fn apply_action(self: &mut TricTracEngine, action_idx: u64) -> Result<()>; - } -} - -// ── Opaque type ─────────────────────────────────────────────────────────────── - -pub struct TricTracEngine { - game_state: GameState, -} - -// ── Free-function constructor (declared in the bridge as a plain function) ──── - -pub fn new_trictrac_engine() -> Box { - let mut game_state = GameState::new(false); // schools_enabled = false - game_state.init_player("player1"); - game_state.init_player("player2"); - game_state - .consume(&GameEvent::BeginGame { goes_first: 1 }) - .expect("BeginGame failed during engine initialization"); - Box::new(TricTracEngine { game_state }) -} - -// ── Method implementations ──────────────────────────────────────────────────── - -impl TricTracEngine { - fn clone_engine(&self) -> Box { - Box::new(TricTracEngine { - game_state: self.game_state.clone(), - }) - } - - fn needs_roll(&self) -> bool { - self.game_state.turn_stage == TurnStage::RollWaiting - } - - fn is_game_ended(&self) -> bool { - self.game_state.stage == Stage::Ended - } - - fn current_player_idx(&self) -> u64 { - self.game_state.active_player_id - 1 - } - - fn get_legal_actions(&self, player_idx: u64) -> anyhow::Result> { - if player_idx != self.current_player_idx() { - return Ok(vec![]); - } - catch_panics(AssertUnwindSafe(|| { - if player_idx == 0 { - get_valid_action_indices(&self.game_state) - .map(|v| v.into_iter().map(|i| i as u64).collect()) - } else { - let mirror = self.game_state.mirror(); - get_valid_action_indices(&mirror).map(|v| v.into_iter().map(|i| i as u64).collect()) - } - })) - } - - fn action_to_string(&self, player_idx: u64, action_idx: u64) -> String { - TrictracAction::from_action_index(action_idx as usize) - .map(|a| format!("{}:{}", player_idx, a)) - .unwrap_or_else(|| "unknown action".into()) - } - - fn get_players_scores(&self) -> ffi::PlayerScores { - ffi::PlayerScores { - score_p1: self.score_for(1), - score_p2: self.score_for(2), - } - } - - fn score_for(&self, player_id: u64) -> i32 { - self.game_state - .players - .get(&player_id) - .map(|p| p.holes as i32 * 12 + p.points as i32) - .unwrap_or(-1) - } - - fn get_tensor(&self, player_idx: u64) -> Vec { - if player_idx == 0 { - self.game_state.to_tensor() - } else { - self.game_state.mirror().to_tensor() - } - } - - fn get_observation_string(&self, player_idx: u64) -> String { - if player_idx == 0 { - format!("{}", self.game_state) - } else { - format!("{}", self.game_state.mirror()) - } - } - - fn to_debug_string(&self) -> String { - format!("{}", self.game_state) - } - - fn apply_dice_roll(&mut self, dice: ffi::DicePair) -> anyhow::Result<()> { - if self.game_state.turn_stage != TurnStage::RollWaiting { - anyhow::bail!( - "apply_dice_roll: not in RollWaiting stage (currently {:?})", - self.game_state.turn_stage - ); - } - let player_id = self.game_state.active_player_id; - let dice = Dice { - values: (dice.die1, dice.die2), - }; - catch_panics(AssertUnwindSafe(|| { - self.game_state - .consume(&GameEvent::RollResult { player_id, dice }) - .map_err(|e| anyhow::anyhow!(e)) - })) - } - - fn apply_action(&mut self, action_idx: u64) -> anyhow::Result<()> { - catch_panics(AssertUnwindSafe(|| { - let needs_mirror = self.game_state.active_player_id == 2; - - let event = TrictracAction::from_action_index(action_idx as usize).and_then(|a| { - let state = if needs_mirror { - &self.game_state.mirror() - } else { - &self.game_state - }; - a.to_event(state) - .map(|e| if needs_mirror { e.get_mirror(false) } else { e }) - }); - - match event { - Some(evt) if self.game_state.validate(&evt) => self - .game_state - .consume(&evt) - .map_err(|e| anyhow::anyhow!(e)), - Some(evt) => anyhow::bail!( - "apply_action: event {:?} is not valid in current state {}", - evt, - self.game_state - ), - None => anyhow::bail!( - "apply_action: could not build event from action index {} in state {}", - action_idx, - self.game_state - ), - } - })) - } -} diff --git a/store/src/game.rs b/store/src/game.rs index 9c5233f..f3ee103 100644 --- a/store/src/game.rs +++ b/store/src/game.rs @@ -80,6 +80,7 @@ pub struct GameState { roll_first: bool, // NOTE: add to a Setting struct if other fields needed pub schools_enabled: bool, + pub debug_message: String, } // implement Display trait @@ -119,6 +120,7 @@ impl Default for GameState { dice_jans: PossibleJans::default(), roll_first: true, schools_enabled: false, + debug_message: "".into(), } } } @@ -147,6 +149,11 @@ impl GameState { game } + pub fn get_debug_message(&self) -> String { + // format!("{:?}", self.history.last()) + format!("{:?}", self.debug_message) + } + pub fn mirror(&self) -> GameState { let mirrored_active_player = if self.active_player_id == 1 { 2 } else { 1 }; let mut mirrored_players = HashMap::new(); @@ -171,6 +178,7 @@ impl GameState { dice_jans: self.dice_jans.mirror(), roll_first: self.roll_first, schools_enabled: self.schools_enabled, + debug_message: self.debug_message.clone(), } } @@ -594,8 +602,9 @@ impl GameState { dice_points: (0, 0), dice_moves: (CheckerMove::default(), CheckerMove::default()), dice_jans: PossibleJans::default(), - roll_first: false, // Assume not first roll - schools_enabled: false, // Assume disabled + roll_first: false, // Assume not first roll + schools_enabled: false, // Assume disabled + debug_message: "".into(), // Assume disabled }) } @@ -748,7 +757,7 @@ impl GameState { error!("Player not active : {}", self.active_player_id); return false; } - // Check the player can leave (ie the game is in the KeepOrLeaveChoice stage) + // Check the player can leave (ie the game is in the HoldOrGoChoice stage) if self.turn_stage != TurnStage::HoldOrGoChoice { error!("bad stage {:?}", self.turn_stage); error!( @@ -925,7 +934,7 @@ impl GameState { } } } - Go { player_id: _ } => self.new_pick_up(), + Go { player_id: _ } => self.new_pick_up(true), Move { player_id, moves } => { let Some(player) = self.players.get(player_id) else { return Err("unknown player {player_id}".into()); @@ -937,20 +946,37 @@ impl GameState { .move_checker(&player.color, moves.1) .map_err(|e| e.to_string())?; self.dice_moves = *moves; - let Some(active_player_id) = self.players.keys().find(|id| *id != player_id) else { - return Err("Can't find player id {id}".into()); - }; - self.active_player_id = *active_player_id; - self.turn_stage = if self.schools_enabled { - TurnStage::MarkAdvPoints + // Check if all current player's checkers have exited + let checkers = self.board.get_color_fields(player.color); + let checkers_count = checkers.iter().fold(0, |acc, (_f, count)| acc + count); + if checkers_count == 0 { + // all checkers have exited, we reset the board + // mark opp. points + let Some(opponent_player_id) = self.players.keys().find(|id| *id != player_id) + else { + return Err("Can't find player id {id}".into()); + }; + let _ = self.mark_points(*opponent_player_id, self.dice_points.1); + // reset checkers, keep points + self.new_pick_up(false); } else { - // The player has moved, we can mark its opponent's points (which is now the current player) - let new_hole = self.mark_points(self.active_player_id, self.dice_points.1); - if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) >= 12 { - self.stage = Stage::Ended; - } - TurnStage::RollDice - }; + let Some(active_player_id) = self.players.keys().find(|id| *id != player_id) + else { + return Err("Can't find player id {id}".into()); + }; + self.active_player_id = *active_player_id; + self.turn_stage = if self.schools_enabled { + TurnStage::MarkAdvPoints + } else { + // The player has moved, we can mark its opponent's points (which is now the current player) + let new_hole = self.mark_points(self.active_player_id, self.dice_points.1); + if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) >= 12 + { + self.stage = Stage::Ended; + } + TurnStage::RollDice + }; + } } PlayError => {} } @@ -960,10 +986,13 @@ impl GameState { /// Set a new pick up ('relevé') after a player won a hole and choose to 'go', /// or after a player has bore off (took of his men off the board) - fn new_pick_up(&mut self) { + fn new_pick_up(&mut self, reset_points: bool) { self.players.iter_mut().for_each(|(_id, p)| { - // reset points - p.points = 0; + // reset points only after "go", not after checkers exit + if reset_points { + // reset points + p.points = 0; + } // reset dice_roll_count p.dice_roll_count = 0; // reset bredouille @@ -1281,4 +1310,34 @@ mod tests { assert_eq!(game_state.get_active_player().unwrap().points, 0); assert_eq!(game_state.turn_stage, TurnStage::MarkAdvPoints); } + + #[test] + fn last_checker_exit() { + let mut game_state = init_test_gamestate(TurnStage::Move); + game_state.board.set_positions( + &crate::Color::White, + [ + -5, -2, -2, -4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ], + ); + game_state.schools_enabled = true; + let _ = game_state.consume(&GameEvent::Mark { + player_id: game_state.active_player_id, + points: 4, + }); + let player = game_state.get_active_player().unwrap(); + assert_eq!(player.points, 4); + game_state.dice.values = (4, 5); + let _ = game_state.consume(&GameEvent::Move { + player_id: game_state.active_player_id, + moves: ( + CheckerMove::new(24, 0).unwrap(), + CheckerMove::new(0, 0).unwrap(), + ), + }); + let player = game_state.get_active_player().unwrap(); + assert_eq!(game_state.turn_stage, TurnStage::RollDice); + assert_eq!(game_state.board, Board::default()); + assert_eq!(player.points, 4); + } } diff --git a/store/src/game_rules_moves.rs b/store/src/game_rules_moves.rs index d695d84..8a2f741 100644 --- a/store/src/game_rules_moves.rs +++ b/store/src/game_rules_moves.rs @@ -4,10 +4,11 @@ use crate::dice::Dice; use crate::game::GameState; use crate::player::Color; use log::info; +use rand::seq::IndexedRandom; use std::cmp; use std::collections::HashSet; -#[derive(std::cmp::PartialEq, Debug)] +#[derive(std::cmp::PartialEq, Debug, Clone, Copy)] pub enum MoveError { // Opponent corner is forbidden OpponentCorner, @@ -257,11 +258,23 @@ impl MoveRules { &self, moves: &(CheckerMove, CheckerMove), ) -> Result<(), MoveError> { - let farthest = cmp::max(moves.0.get_to(), moves.1.get_to()); - let in_opponent_side = farthest > 12; - if in_opponent_side && self.board.is_quarter_fillable(Color::Black, farthest) { + // A chained move (tout d'une): the first destination is a resting field. + // Exception: a resting field in the opponent's big jan (13-18) is allowed + // during a chained move to pass into the return jan. + let is_chained = moves.1.get_from() != 0 && moves.0.get_to() == moves.1.get_from(); + + if !is_chained { + let to0 = moves.0.get_to(); + if to0 > 12 && self.board.is_quarter_fillable(Color::Black, to0) { + return Err(MoveError::OpponentCanFillQuarter); + } + } + + let to1 = moves.1.get_to(); + if to1 > 12 && self.board.is_quarter_fillable(Color::Black, to1) { return Err(MoveError::OpponentCanFillQuarter); } + Ok(()) } @@ -315,16 +328,45 @@ impl MoveRules { Ok(()) } - fn has_checkers_outside_last_quarter(&self) -> bool { + // check if there is still a checker left outside the last quarter after the allowed_move + fn has_checkers_outside_last_quarter(&self, allowed_move: Option) -> bool { + // Get the unique field allowed outside the last quarter, when the firt move origin is + // outside and the destination is inside the last quarter + let one_allowed = allowed_move + .filter(|m| m.get_to() > 18) + .map(|m| m.get_from()); + !self .board .get_color_fields(Color::White) .iter() - .filter(|(field, _count)| *field < 19) + .filter(|(field, count)| *field < 19 && !(Some(*field) == one_allowed && *count == 1)) .collect::>() .is_empty() } + fn forbid_exits(&self) -> bool { + let filtered = self + .board + .get_color_fields(Color::White) + .into_iter() + .filter(|(field, _count)| *field < 19) + .collect::>(); + let max_dice = if self.dice.values.0 > self.dice.values.1 { + self.dice.values.0 + } else { + self.dice.values.1 + }; + match filtered[..] { + // all checkers in the last jan, exits are possible + [] => false, + // if there is only one checker outside the last jan, and it can go to the last jan with + // one of the dice, an exit is possible with the other dice. + [(field, 1)] if field + (max_dice as usize) > 18 => false, + _ => true, + } + } + fn check_exit_rules( &self, moves: &(CheckerMove, CheckerMove), @@ -333,8 +375,8 @@ impl MoveRules { if !moves.0.is_exit() && !moves.1.is_exit() { return Ok(()); } - // toutes les dames doivent être dans le jan de retour - if self.has_checkers_outside_last_quarter() { + // all checkers must be in the return jan + if self.has_checkers_outside_last_quarter(Some(moves.0)) { return Err(MoveError::ExitNeedsAllCheckersOnLastQuarter); } @@ -572,7 +614,7 @@ impl MoveRules { ) -> Vec<(CheckerMove, CheckerMove)> { let mut moves_seqs = Vec::new(); let color = &Color::White; - let forbid_exits = self.has_checkers_outside_last_quarter(); + let forbid_exits = self.forbid_exits(); // Precompute non-excedant sequences once so check_exit_rules need not repeat // the full move generation for every exit-move candidate. // Only needed when Exit is not already ignored and exits are actually reachable. @@ -660,6 +702,23 @@ impl MoveRules { } board.unmove_checker(color, first_move); } + + // ── Par puissance (corner taken by force) ──────────────────────────── + // Neither corner is taken via the normal loop above because the die + // would land on field 13 (opponent corner), which is always rejected + // by check_corner_rules. Generate the canonical par-puissance pair + // once here; the deduplication step in get_possible_moves_sequences + // removes any duplicate produced by the swapped-dice second pass. + if !self.can_take_corner_by_effect() { + if let Some(seq) = self.try_puissance_corner_seq(dice1, dice2) { + if filling_seqs.map_or(true, |seqs| seqs.is_empty() || seqs.contains(&seq)) + && !moves_seqs.contains(&seq) + { + moves_seqs.push(seq); + } + } + } + moves_seqs } @@ -739,10 +798,66 @@ impl MoveRules { let (count2, opt_color2) = res2.unwrap(); count1 > 0 && count2 > 0 && opt_color1 == Some(color) && opt_color2 == Some(color) } + + /// Returns the par-puissance corner move pair if the conditions are met: + /// both corners empty, each die has an own checker exactly one field before + /// the opponent's corner (field 13). The move with the lower source field + /// is returned first (canonical ordering so both dice-order calls produce + /// the same pair and the outer deduplication collapses them to one entry). + fn try_puissance_corner_seq(&self, dice1: u8, dice2: u8) -> Option<(CheckerMove, CheckerMove)> { + let own_corner: Field = 12; // MoveRules always works from White's perspective + let opp_corner: Field = 13; + + let (count_own, _) = self.board.get_field_checkers(own_corner).ok()?; + let (count_opp, _) = self.board.get_field_checkers(opp_corner).ok()?; + if count_own > 0 || count_opp > 0 { + return None; + } + + // Source field for each die: the field whose checker would reach the + // opponent's corner with a normal move. + let f1 = opp_corner.checked_sub(dice1 as usize)?; + let f2 = opp_corner.checked_sub(dice2 as usize)?; + if f1 == 0 || f2 == 0 { + return None; + } + + let has_white = |f: Field| -> bool { + self.board + .get_field_checkers(f) + .map(|(c, col)| c >= 1 && col == Some(&Color::White)) + .unwrap_or(false) + }; + + if dice1 == dice2 { + // Doublet: both moves from the same field, need ≥ 2 own checkers. + let ok = self + .board + .get_field_checkers(f1) + .map(|(c, col)| c >= 2 && col == Some(&Color::White)) + .unwrap_or(false); + if !ok { + return None; + } + let m = CheckerMove::new(f1, own_corner).ok()?; + Some((m, m)) + } else { + if !has_white(f1) || !has_white(f2) { + return None; + } + // Canonical: lower source field first. + let (fa, fb) = if f1 <= f2 { (f1, f2) } else { (f2, f1) }; + let ma = CheckerMove::new(fa, own_corner).ok()?; + let mb = CheckerMove::new(fb, own_corner).ok()?; + Some((ma, mb)) + } + } } #[cfg(test)] mod tests { + use anyhow::Ok; + use super::*; #[test] @@ -874,6 +989,20 @@ mod tests { state.moves_allowed(&moves) ); + // on peut sortir une dame avec un nombre exact, même si on peut en jouer une avec un nombre défaillant + state.board.set_positions( + &Color::White, + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 0, 2, 0, + ], + ); + state.dice.values = (2, 5); + let moves = ( + CheckerMove::new(20, 0).unwrap(), + CheckerMove::new(23, 0).unwrap(), + ); + assert!(state.moves_allowed(&moves).is_ok()); + // on doit jouer le nombre excédant le plus éloigné state.board.set_positions( &Color::White, @@ -954,6 +1083,38 @@ mod tests { state.moves_allowed(&moves) ); + state.board.set_positions( + &Color::Black, + [ + 10, 0, 0, 0, -1, 0, 2, 0, 0, 0, 1, 2, 0, -1, -1, 0, 2, 0, 0, 0, 0, 0, 0, -10, + ], + ); + state.dice.values = (4, 1); + let moves = ( + CheckerMove::new(15, 14).unwrap().mirror(), + CheckerMove::new(14, 10).unwrap().mirror(), + ); + assert_eq!( + Err(MoveError::OpponentCanFillQuarter), + state.moves_allowed(&moves) + ); + + state.board.set_positions( + &Color::Black, + [ + 0, 0, 0, 0, -1, 1, 3, 0, 3, 4, 1, 3, 0, -2, -5, -2, -1, -4, 0, 0, 0, 0, 0, 0, + ], + ); + state.dice.values = (6, 2); + let moves = ( + CheckerMove::new(14, 8).unwrap().mirror(), + CheckerMove::new(5, 3).unwrap().mirror(), + ); + assert_eq!( + Err(MoveError::OpponentCanFillQuarter), + state.moves_allowed(&moves) + ); + state.board.set_positions( &Color::White, [ @@ -1261,6 +1422,20 @@ mod tests { ); assert!(!state.moves_possible(&moves)); + // Chaned moves: can't rest on a field occupied by one opponent's checker + state.board.set_positions( + &Color::White, + [ + 0, 0, 0, 0, 0, 0, 6, 2, 2, 2, 2, 2, -2, -6, -1, -3, -1, 0, -2, 0, 0, 0, 0, 0, + ], + ); + state.dice.values = (5, 5); + let moves = ( + CheckerMove::new(10, 15).unwrap(), + CheckerMove::new(15, 20).unwrap(), + ); + assert!(!state.moves_possible(&moves)); + // black moves let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default()); let moves = ( @@ -1430,22 +1605,6 @@ mod tests { state.get_possible_moves_sequences(true, vec![]) ); - state.board.set_positions( - &Color::White, - [ - -8, -4, -1, 0, 0, 0, 0, -1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 3, 2, 2, 2, - ], - ); - state.dice.values = (1, 4); - let moves = ( - CheckerMove::new(21, 22).unwrap(), - CheckerMove::new(22, 0).unwrap(), - ); - assert_eq!( - vec![moves], - state.get_possible_moves_sequences(true, vec![]) - ); - state.dice.values = (5, 3); state.board.set_positions( &crate::Color::White, @@ -1500,6 +1659,21 @@ mod tests { ), ]; assert_eq!(moves, state.get_possible_moves_sequences(true, vec![])); + + // Prise de coin par puissance + let mut board = Board::new(); + board.set_positions( + &crate::Color::White, + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, -13, + ], + ); + let state = MoveRules::new(&Color::White, &board, Dice { values: (3, 2) }); + let moves = vec![( + CheckerMove::new(10, 12).unwrap(), + CheckerMove::new(11, 12).unwrap(), + )]; + assert_eq!(moves, state.get_possible_moves_sequences(true, vec![])); } #[test] @@ -1629,6 +1803,46 @@ mod tests { CheckerMove::new(23, 0).unwrap(), ); assert!(state.check_exit_rules(&moves, None).is_ok()); + + state.dice.values = (2, 6); + state.board.set_positions( + &crate::Color::White, + [ + -9, -1, 0, 0, -2, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, -2, 1, 0, 0, 1, 0, 1, 10, 2, + ], + ); + let moves = ( + CheckerMove::new(17, 23).unwrap(), + CheckerMove::new(23, 0).unwrap(), + ); + assert!(state.check_exit_rules(&moves, None).is_ok()); + + state.dice.values = (3, 1); + state.board.set_positions( + &crate::Color::White, + [ + -10, -2, 0, 0, 0, 0, -1, 0, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 3, 2, 1, + ], + ); + let moves = ( + CheckerMove::new(22, 0).unwrap(), + CheckerMove::new(24, 0).unwrap(), + ); + assert!(state.check_exit_rules(&moves, None).is_ok()); + + // Bad exit order: the first move must be with the checker furthest from the exit + state.dice.values = (3, 1); + state.board.set_positions( + &crate::Color::White, + [ + -10, -2, 0, 0, 0, 0, -1, 0, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 3, 2, 1, + ], + ); + let moves = ( + CheckerMove::new(24, 0).unwrap(), + CheckerMove::new(22, 0).unwrap(), + ); + assert!(state.check_exit_rules(&moves, None).is_err()); } #[test] diff --git a/store/src/game_rules_points.rs b/store/src/game_rules_points.rs index 062bf06..cdc1e9c 100644 --- a/store/src/game_rules_points.rs +++ b/store/src/game_rules_points.rs @@ -205,24 +205,26 @@ impl PointsRules { let from0 = adv_corner_field - self.dice.values.0 as usize; let from1 = adv_corner_field - self.dice.values.1 as usize; - let (from0_count, _from0_color) = board_ini.get_field_checkers(from0).unwrap(); - let (from1_count, _from1_color) = board_ini.get_field_checkers(from1).unwrap(); + let (from0_count, from0_color) = board_ini.get_field_checkers(from0).unwrap(); + let (from1_count, from1_color) = board_ini.get_field_checkers(from1).unwrap(); let hit_moves = vec![( CheckerMove::new(from0, adv_corner_field).unwrap(), CheckerMove::new(from1, adv_corner_field).unwrap(), )]; - if from0 == from1 { - // doublet - if from0_count > if from0 == corner_field { 3 } else { 1 } { - jans.insert(Jan::TrueHitOpponentCorner, hit_moves); - } - } else { - // simple - if from0_count > if from0 == corner_field { 2 } else { 0 } - && from1_count > if from1 == corner_field { 2 } else { 0 } - { - jans.insert(Jan::TrueHitOpponentCorner, hit_moves); + if from0_color == Some(&Color::White) && from1_color == Some(&Color::White) { + if from0 == from1 { + // doublet + if from0_count > if from0 == corner_field { 3 } else { 1 } { + jans.insert(Jan::TrueHitOpponentCorner, hit_moves); + } + } else { + // simple + if from0_count > if from0 == corner_field { 2 } else { 0 } + && from1_count > if from1 == corner_field { 2 } else { 0 } + { + jans.insert(Jan::TrueHitOpponentCorner, hit_moves); + } } } } @@ -699,6 +701,16 @@ mod tests { rules.set_dice(Dice { values: (1, 1) }); assert_eq!(0, rules.get_points(5).0); + // Battage du coin de repos adverse: check if we do it with our own checkers! + rules.update_positions( + &Color::White, + [ + -4, 0, 0, -1, 0, 0, 0, 0, -1, 3, 2, 2, 0, -2, -2, 2, 1, 0, 4, -3, 1, 0, 0, 2, + ], + ); + rules.set_dice(Dice { values: (3, 4) }); + assert_eq!(0, rules.get_points(5).0); + // Cas de battage du coin de repos adverse impossible // car son propre coin de repos n'est pas rempli rules.update_positions( diff --git a/store/src/lib.rs b/store/src/lib.rs index 90fbbc0..7f62f53 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -1,6 +1,6 @@ mod game; mod game_rules_moves; -pub use game_rules_moves::MoveRules; +pub use game_rules_moves::{MoveError, MoveRules}; mod game_rules_points; pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage}; pub use game_rules_points::{Jan, PointsRules}; @@ -18,11 +18,3 @@ mod dice; pub use dice::{Dice, DiceRoller}; pub mod training_common; - -// python interface "trictrac_engine" (for AI training..) -#[cfg(feature = "python")] -mod pyengine; - -// C++ interface via cxx.rs (for OpenSpiel C++ integration) -#[cfg(feature = "cpp")] -pub mod cxxengine; diff --git a/store/src/player.rs b/store/src/player.rs index cca02b5..d609ce7 100644 --- a/store/src/player.rs +++ b/store/src/player.rs @@ -1,12 +1,9 @@ -#[cfg(feature = "python")] -use pyo3::prelude::*; use serde::{Deserialize, Serialize}; use std::fmt; // This just makes it easier to dissern between a player id and any ol' u64 pub type PlayerId = u64; -#[cfg_attr(feature = "python", pyclass(eq, eq_int))] #[derive(Copy, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Color { White, diff --git a/store/src/pyengine.rs b/store/src/pyengine.rs deleted file mode 100644 index 43b5713..0000000 --- a/store/src/pyengine.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! # Expose trictrac game state and rules in a python module -use pyo3::prelude::*; - -use crate::dice::Dice; -use crate::game::{GameEvent, GameState, Stage, TurnStage}; -use crate::player::PlayerId; -use crate::training_common::{get_valid_action_indices, TrictracAction}; - -#[pyclass] -struct TricTrac { - game_state: GameState, -} - -#[pymethods] -impl TricTrac { - #[new] - fn new() -> Self { - let mut game_state = GameState::new(false); // schools_enabled = false - - // Initialiser 2 joueurs - game_state.init_player("player1"); - game_state.init_player("player2"); - - // Commencer la partie avec le joueur 1 - let _ = game_state.consume(&GameEvent::BeginGame { goes_first: 1 }); - - TricTrac { game_state } - } - - fn needs_roll(&self) -> bool { - self.game_state.turn_stage == TurnStage::RollWaiting - } - - fn is_game_ended(&self) -> bool { - self.game_state.stage == Stage::Ended - } - - // 0 or 1 - fn current_player_idx(&self) -> u64 { - self.game_state.active_player_id - 1 - } - - fn get_legal_actions(&self, player_idx: u64) -> Vec { - if player_idx == self.current_player_idx() { - if player_idx == 0 { - get_valid_action_indices(&self.game_state).unwrap() - } else { - let mirror = self.game_state.mirror(); - get_valid_action_indices(&mirror).unwrap() - } - } else { - vec![] - } - } - - fn action_to_string(&self, player_idx: u64, action_idx: usize) -> String { - TrictracAction::from_action_index(action_idx) - .map(|a| format!("{}:{}", player_idx, a)) - .unwrap_or("unknown action".into()) - } - - fn apply_dice_roll(&mut self, dices: (u8, u8)) -> PyResult<()> { - let player_id = self.game_state.active_player_id; - - if self.game_state.turn_stage != TurnStage::RollWaiting { - return Err(pyo3::exceptions::PyRuntimeError::new_err( - "Not in RollWaiting stage", - )); - } - - let dice = Dice { values: dices }; - let _ = self - .game_state - .consume(&GameEvent::RollResult { player_id, dice }); - Ok(()) - } - - fn apply_action(&mut self, action_idx: usize) -> PyResult<()> { - if let Some(event) = TrictracAction::from_action_index(action_idx).and_then(|a| { - let needs_mirror = self.game_state.active_player_id == 2; - let game_state = if needs_mirror { - &self.game_state.mirror() - } else { - &self.game_state - }; - a.to_event(game_state) - .map(|e| if needs_mirror { e.get_mirror(false) } else { e }) - }) { - if self.game_state.validate(&event) { - let _ = self.game_state.consume(&event); - return Ok(()); - } else { - return Err(pyo3::exceptions::PyRuntimeError::new_err( - "Action is invalid", - )); - } - } - Err(pyo3::exceptions::PyRuntimeError::new_err( - "Could not apply action", - )) - } - - /// Get a player total score (holes & points) - fn get_score(&self, player_id: PlayerId) -> i32 { - if let Some(player) = self.game_state.players.get(&player_id) { - player.holes as i32 * 12 + player.points as i32 - } else { - -1 - } - } - - fn get_players_scores(&self) -> [i32; 2] { - [self.get_score(1), self.get_score(2)] - } - - fn get_tensor(&self, player_idx: u64) -> Vec { - if player_idx == 0 { - self.game_state.to_tensor() - } else { - self.game_state.mirror().to_tensor() - } - } - - fn get_observation_string(&self, player_idx: u64) -> String { - if player_idx == 0 { - format!("{}", self.game_state) - } else { - format!("{}", self.game_state.mirror()) - } - } - - /// Afficher l'état du jeu (pour le débogage) - fn __str__(&self) -> String { - format!("{}", self.game_state) - } -} - -/// A Python module implemented in Rust. The name of this function must match -/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to -/// import the module. -#[pymodule] -fn trictrac_store(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - - Ok(()) -}