From 20134ce46823c6bb953946e763e71624911a641a Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 2 May 2026 11:11:39 +0200
Subject: [PATCH 1/7] chore: remove old web-game & web-user-portal crates
---
Cargo.lock | 35 -
Cargo.toml | 2 -
README.md | 4 +-
clients/web-game/Cargo.toml | 40 -
clients/web-game/Trunk.toml | 2 -
clients/web-game/assets/diceroll.mp3 | Bin 32896 -> 0 bytes
clients/web-game/assets/style.css | 1213 -----------------
clients/web-game/index.html | 12 -
clients/web-game/locales/en.json | 61 -
clients/web-game/locales/fr.json | 61 -
clients/web-game/src/app.rs | 726 ----------
clients/web-game/src/components/board.rs | 594 --------
.../src/components/connecting_screen.rs | 9 -
clients/web-game/src/components/die.rs | 53 -
.../web-game/src/components/game_screen.rs | 470 -------
.../web-game/src/components/login_screen.rs | 115 --
clients/web-game/src/components/mod.rs | 11 -
.../web-game/src/components/score_panel.rs | 70 -
clients/web-game/src/components/scoring.rs | 209 ---
clients/web-game/src/main.rs | 13 -
clients/web-game/src/sound.rs | 182 ---
clients/web-game/src/trictrac/backend.rs | 487 -------
clients/web-game/src/trictrac/bot_local.rs | 43 -
clients/web-game/src/trictrac/mod.rs | 3 -
clients/web-game/src/trictrac/types.rs | 256 ----
clients/web-user-portal/Cargo.toml | 17 -
clients/web-user-portal/Trunk.toml | 2 -
clients/web-user-portal/assets/style.css | 103 --
clients/web-user-portal/index.html | 11 -
clients/web-user-portal/src/api.rs | 191 ---
clients/web-user-portal/src/app.rs | 67 -
clients/web-user-portal/src/main.rs | 7 -
clients/web-user-portal/src/pages/game.rs | 95 --
clients/web-user-portal/src/pages/home.rs | 152 ---
clients/web-user-portal/src/pages/mod.rs | 3 -
clients/web-user-portal/src/pages/profile.rs | 137 --
36 files changed, 2 insertions(+), 5454 deletions(-)
delete mode 100644 clients/web-game/Cargo.toml
delete mode 100644 clients/web-game/Trunk.toml
delete mode 100644 clients/web-game/assets/diceroll.mp3
delete mode 100644 clients/web-game/assets/style.css
delete mode 100644 clients/web-game/index.html
delete mode 100644 clients/web-game/locales/en.json
delete mode 100644 clients/web-game/locales/fr.json
delete mode 100644 clients/web-game/src/app.rs
delete mode 100644 clients/web-game/src/components/board.rs
delete mode 100644 clients/web-game/src/components/connecting_screen.rs
delete mode 100644 clients/web-game/src/components/die.rs
delete mode 100644 clients/web-game/src/components/game_screen.rs
delete mode 100644 clients/web-game/src/components/login_screen.rs
delete mode 100644 clients/web-game/src/components/mod.rs
delete mode 100644 clients/web-game/src/components/score_panel.rs
delete mode 100644 clients/web-game/src/components/scoring.rs
delete mode 100644 clients/web-game/src/main.rs
delete mode 100644 clients/web-game/src/sound.rs
delete mode 100644 clients/web-game/src/trictrac/backend.rs
delete mode 100644 clients/web-game/src/trictrac/bot_local.rs
delete mode 100644 clients/web-game/src/trictrac/mod.rs
delete mode 100644 clients/web-game/src/trictrac/types.rs
delete mode 100644 clients/web-user-portal/Cargo.toml
delete mode 100644 clients/web-user-portal/Trunk.toml
delete mode 100644 clients/web-user-portal/assets/style.css
delete mode 100644 clients/web-user-portal/index.html
delete mode 100644 clients/web-user-portal/src/api.rs
delete mode 100644 clients/web-user-portal/src/app.rs
delete mode 100644 clients/web-user-portal/src/main.rs
delete mode 100644 clients/web-user-portal/src/pages/game.rs
delete mode 100644 clients/web-user-portal/src/pages/home.rs
delete mode 100644 clients/web-user-portal/src/pages/mod.rs
delete mode 100644 clients/web-user-portal/src/pages/profile.rs
diff --git a/Cargo.lock b/Cargo.lock
index e557059..517e14f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1449,26 +1449,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
-[[package]]
-name = "client_web"
-version = "0.1.0"
-dependencies = [
- "backbone-lib",
- "futures",
- "getrandom 0.3.4",
- "gloo-net 0.5.0",
- "gloo-storage",
- "gloo-timers",
- "leptos",
- "leptos_i18n",
- "rand 0.9.3",
- "serde",
- "serde_json",
- "trictrac-store",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
[[package]]
name = "cmake"
version = "0.1.58"
@@ -9245,21 +9225,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "web-user-portal"
-version = "0.1.0"
-dependencies = [
- "gloo-net 0.5.0",
- "js-sys",
- "leptos",
- "leptos_router",
- "serde",
- "serde_json",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
[[package]]
name = "webpki-roots"
version = "0.26.11"
diff --git a/Cargo.toml b/Cargo.toml
index 94a1c6b..3c70d45 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,8 +6,6 @@ members = [
"clients/cli",
"clients/backbone-lib",
"clients/web",
- "clients/web-game",
- "clients/web-user-portal",
"server/protocol",
"server/relay-server",
"bot",
diff --git a/README.md b/README.md
index 4e7789f..ca4c0de 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ just build-relay
just run-relay # listens on :8080
# Run the game (separate terminal)
-just dev-game
+just dev
```
Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name.
@@ -52,7 +52,7 @@ The game state is defined by the `GameState` struct in _store/src/game.rs_. The
### multiplayer game
-Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
+Packages "clients/backbone-lib", "clients/web/game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
The system consists of:
diff --git a/clients/web-game/Cargo.toml b/clients/web-game/Cargo.toml
deleted file mode 100644
index 578be7c..0000000
--- a/clients/web-game/Cargo.toml
+++ /dev/null
@@ -1,40 +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 = "../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"
-gloo-net = { version = "0.5", features = ["http"] }
-gloo-timers = { version = "0.3", features = ["futures"] }
-# 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"] }
-web-sys = { version = "0.3", features = [
- "RequestCredentials",
- "AudioContext",
- "AudioParam",
- "AudioNode",
- "AudioDestinationNode",
- "AudioScheduledSourceNode",
- "GainNode",
- "OscillatorNode",
- "OscillatorType",
- "BaseAudioContext",
- "HtmlAudioElement",
-] }
diff --git a/clients/web-game/Trunk.toml b/clients/web-game/Trunk.toml
deleted file mode 100644
index bae5297..0000000
--- a/clients/web-game/Trunk.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[serve]
-port = 9091
diff --git a/clients/web-game/assets/diceroll.mp3 b/clients/web-game/assets/diceroll.mp3
deleted file mode 100644
index b16adff42da3003b35d59566b353a5db2fa0c7b7..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 32896
zcmeZtF=k-^0l$y{S3?E{1_1^J=HUF&ycDO*q?}Z}Q*%8FV?9Fy1CTjT{Qq|Vl!cq}%uCCM7{lPlz|f$Ej<992OaqkdT~~nVDNqTwY#P+tAX|-qqVX
zamw^rv*s;avSj({bsINs*|B@~zC%Y(oH%{{(xt06?%cos=;_OsZ{C0U_U-4N{~*`+
zB$g$cn(G-NyO%+Q#R4P;a!1?QJZT6ULH_?eA%%g3*~$L!WF4mmri?3{4BHrZPcn+~
z?)k{@r#0nej|v9^!{=+CZIUk^?qEGJLwIMRcG08vC%%cU&-lTinQ?f1pTWuh+xFH7
zN*Mh)q}cf5i-p04Fm+bOhw3lQ{W%YE$#(DFy+}z(NipHh5C8ulu+D;yap9Dvj!wU)
z?F|P1fAZabRQ~_ztLF##9%gX!pK4^`a8WNW^O;pHD3HjaAl!NILybULjGcN#{gl##
z|EHTD{IHXkkhuI`@Bjb*PY$Rkn+g{@?lE#;HW5yHBw%#SG@~{;!TntC4(;g&t9S4_
zi7wJ&-=Q6z*)q!^XI9i#?}W8TIZ|ss8Qp&rp8Qx7rK8Onpj*MHe32V_X#+r`TnbQ(Szfvr<&*d|2-*1l7WTY
z&_%bHfkChN#>BN-7=$gYt9Q0<5_FsQcDvjnrdG?MgyYJ$l~@(M-s*2o&sn{RuV}$>
zJ-#BD9qtGBOU*2ajsL%}_t?Mt|0L4qeLlbYy!DO5pr!tQUK(sX`#j!Y=Rf}IcQ3Bq
z{&&8vdwzHRl>kMy_t^hG;VcireirOYi?{#u{A>M!XV0(2
z>+k>j>aWo4tWDc>dQY27_s;mkpv~U-b<4j-cm49G6B~-o_AFVjuzQ9C+e7t~eGJCD
z(MEeu^-CPgb9TsYs&!sd<dR)|BRo|N?_y3n->e;Xbm1_?y|NsC0zxv1b
z{{8=M`|?+6DT9#Q8wPz-T3FrRad&P!8Za8aw~IKG8}zvXT4j_(Z&Ke<*m%S~V~
z%i~jtSn2#ZNk}FraqsG7(-pni76mJ`vuju}Y`S34I?r7rZ{C?P^tHPPvnK!sy48Ig;rs<(Akf1UXKqUn#28
z|F3z;%t_(@uWbp4^}he#Rp?dx1x5y@NCt-FJa6T--42Wgisuy^op{2&cXChJTfb=M
zj&!jPR=t-xRGt>xS83_EU9oPDNMb~uf%fxraYvV}%Io*m|ES=+HSg5=;?RvoCiZJ)
zweo#P`)Dku_TcC1@7dQrZu$13v-^C!usK=CAt3HFORZ<(j84CM$({C2!gj5k*Dhqp<{Laz;s5K>
zv)A^|mgoO}uUbA;*{Wo*_GAlQ$)dO&&65f{D$kq{^bDH*0u)2M49tHVud^j3FuAnr
z@E)JS(C_fwCAPhRleP0@`n(AYIssjsKa2zeK5ROiBgJ$~pK()n>vKKvjz$Hx8U`lE
zjS~;qop_w$KZTc1m`!7fPv1?M3p+BJ7j#O+x@wg@Yx~7|*jZUrd}7kq`OjDn&NiQZ
z`q}e{hb|p6F1m+@Rhpa?leBLw(AM8FWl2yD3#-&kmmLWV3|vYLp5hbAXHAs2k+^8W
z=^s3yC(os4-stQ6`~Oc@kLZt`k`Izg*ZK9{R?@+B5G}WJ8
zRK1by9ScuvXwMQIiwzGv#9zcPT?}h{65(i3yRfVEW1@L;#+@uT366s=T$hEcpYSrR
z-umt%*3G7?S0DdX%k=Gv^tJs#f1iE1mOba$eTlvLd+vQ*o9*-J-hKgHF4Jsw|8nlj
zmTQwP@RXdHZ&6cg{h8lGfP-lXkKmMu8B^D|P24ljBwu=qzV@uSY8;&c%A$h8-O3J6
zWQEL^%$b=uKT5ZI=8Q>Qc6{wxY>n$h%MXV#c1+9Ay5Xp_a$DNf$biE3Jx7;@
zDessX3W^~f7Uv^g=eaq}7@U=VNhUt>L
zQoa5erG!dpty(QT>D*(UN2U@r^PhRYf2=yWe$Gl!&7IEXMr;>v_~ty%*AKtDJ^#kp
zM`ql5DJ!+_)}1xJ=qo+*?6qt63RCtjjQqQ3dt27-yPx;pmYMj0fr)`}4Tr$F`5C+8
zqIC16FJFCSYxwiSY@gNc@5BDBoayy1yl9iDO_WTy52*%WjGSnjwC_u0BXGU~q7DR9VXMAihy*LU|=S
z%dg^9|E$&jAG)J#oFLvKtKc#5+iB0R=Tqly^5XcP7q@iD?kb0-B{N0qf2zKdm?RLt
z^8Ek5PaoH2Is_@EEIAS8qH=-f6FX{EHw+*a^9tujC#9wnd
z@LDz6uxUp2l$*8dH}7c5+*>Lma*9=3@);ixVBKxPQA>tC<|Bf461hr4!35tz{-V=ERz4Bp(cU6}sci^s6~5
zyI%iso|Tbr`0Vgn-^{&+HJ4Nu1zHPD*{aWfIy&ioeE6@Q`-;6@|Nno_@c;e)`}aP3
z5SifD@LXns=G5mwzA>lvY-*ja_4QVSB-7zQ7G~#%In(F*E^GO?abslRbAwrOC!fvD
z^>>S}`7OCkR`|^K|C|45m){M$+4F8*)=jg04IAn@7?>X@L@+DQwD>BJx|w-FsA2u0
zb7$BpigcLoOg`D@z;uJDz_cMOIzXsk;>6afJxLql4jkdnJh7xTX07_wV?}cc(hJ(z
zgl0!w|9|e^tq-qXudo0AzkK4vod^FGuZX|>|NpBtvkIN>=U#GMD4g}@`k(**KR)nf
z4xgR*NatpPBm-MZ0z&`;153dqr8V(>@k`fg#vMorPt$9>^`L^?@Uq0_`j_u_{Q9rR
zzb|h~?EmKinP1K`HZIgZw*L$J;lB%3y<%R##3KLy9|MEoU$bduqLs~D-iA~9fP{K0|qChogdEZVGwY&4z>8%z{ta?yIt-A(^-`>AKKO$9P|;=J6$G_XzaBh
zpXra`83opZ%VZUsV!JB~lKB>Yu{v{jYuE8#zgNj?J`P?S^_F+$vk!Nrqo>|Bkucak
z?{WN!d3;~{f19PWeRfifFvpF)YTdSZHRi(CW`KOSB$*X5B
zyplNKOX>_$&BtwAsVk%IoICbh?re{aRc_ese-Z7D4XI^3CiDNUU9o*%=%0748-6-W
z34DI9@&VTrU4utg46gYvzH?7-g9IDbQ}9wf{lT{!@+Zp>Lj}+
zr&C!TVJp;oy?9fXJ=ht1*-`YnsYk$RW)7nV83oKQod16)`BA%n*W+Hz{||rte#*eW
zfBC0IgW#IoD_BcXKN_7{S;7L2AqM7)&f(`0A22v4z219#0mFocoK~_k8#peouG&5$
zf#K2C9W~cj^P{`BHpB@i{bu)l>#n3Cv-V=Hn>1ri<_wTQ`X?JbJw{4laX8-^H|7PAT7rJ^!JKZ4eP+I=8
zc80Uw<$Y>T3K~_LHb{stFjfULOv>OcZQAl$B;kMB#F(;$TP8K!5Vc`D()4h`v%O)j
zLhJvZEOCvN75U>M#MdSFkL!;6zex{f9g{IoI$$7lX=PE#@*u6vBFs%ehO0$<1T0jQ
z*FLcHVVcFr&h`)lMK~55WimhZ;GXoMCjtyQ0uc&13yUT-y_hDlQNv5<@f@p@m-KDo
z^<*q!OP6&kbY1dP*{c%ry*y}2m#5W5SCOrYJ=IH9rmrns)U6el*ELrpG_tc)(BXLf
z*9`#y+1uI}{QfrZh_W9))4p7&v+-??t?Z&_YZ!H1TA0)V61#+590fSr6wUwd+RVx;
z|Ccx0;uoXz1h!=*(=N((o5Wr<3it5uv+%mXCD@{ow-OXXyba7zwr6`2A28S}pS-q4
zgF*14wLhqoQ|{0zmpi~P;U*uO#jIwIIg=+h-k3kj#-HVf?@7y)cFq;E<+(2RUfS`v
z#EqOLN5et3K|1QZadl)eJ
z$RUSUEsdwS{{M;85_4iQ2vT)<60oA9hhf!(Uy1wK`1qe5YM(!&;N3=vt4_fhyEqPb
z$Shva|A6UK>gCd_JSz@7W`42V;M%%}E@%0!GA6LwNbvuabzip0$@5{k`m*Q8>c4dE
z30`?SPW#U3Hq-d|YqKBCK2*E6vA4HL{{Lh7|6jj9`)^Wz&-+Mj@lmrtorUT86GZr*3Fx-uqB%ZzKUY(nD@Wu&83ax%S^EFH5OkW@O
zl()xwvXXLLl7^0*{Fw<`LJAUJI{xS|%luV0DSugmqlTeL%#yCC-q!Vd&xEda;4M6x
zlmdz&ZU$y;?-29E1SZ>uU7^osFz_VmyQfIn
zTzgpRSUJz~ees2V3JN=B-}%D8VCOK)U*@&j#78S)wKdL^3YE;7+B$*FhlMApZOtZL
z36T`L)Ciy8{J;YayZ<#FY^eE>-g#^*H-Dkc|6dD47&sXiKk;9XZHX}IeyO;jpX+s`
z0dvMY7L}!n`jHRyr(Mcgn7Xacm&@cz09&Kaz2q4aZf<$E^s2B_?(amGr|*_Z83!dV
zP+QgO5T@yNWoGBBRntx%yD}r3?`;44S9^jILs%L*9x!k))n-UDO6YNIIyB4Zijo*}
zjf#%eDM7tWT_TgeEa{FBiJaPHVD%uw`lG0QMyhd)j=Zdg`R3
zmo9xQco?j2aFMjg6}`j=WKuCX+ksjMxLW
zjT8M(X|6aNd2plX-MwPvS`vLNk%2RwyG^_mwEq9Ux8D?7#Q!wk{IAv^puoUtxc*s~^kyppaWXhIv-T@BFoYL0HYPt6a
zURt*JrNIL!Mm7e)J{d_DgKUnr^jp;nxGd6+tY0j{x$Cm{({lY2pMO2_Wa2!~vuAtn
z-Dk(9nd_{)^|@#^U(Mx&xV~C3W8*0|g426y1MOvg)J2=mo_>4w%+0zDyTj(?iR;Xq
z`>&d3N!yL*|F$w2JQG=VUjP4{`btp#`7aDA4F11sk22w6R`a;X`<%Iefq{|pm8)=?
zu+|KR^x4zaK8T$*y{+)4kIJq^>u=9|a*8i{%8Bx|h50)Q{~!PV?_uoy`e$M89z7WvBH6QiP+GCV3Yt
zt6G_Txm^A8$;yRm>;JN7Iqs`Hqrt$y!2GV7g@HlB{bN(Y^ru=P>%&g-9og`x-c8-v
zU-5vRP}Dr`M#q8|3}Ta1_@}QB?orlgnfhPYMd$xQ#s>BOZjQCCb`2~S6-*|#dlzkJ
z5q_q2^<)MphPW7*|GS5-vwgs1->fZnhJ%4GBN`;t$ia^LvdBS=``Ni+Hj;^4!ri`%J`H&PdLfF=zkz=hD?XW}oKMUjP69
zFTc-Tt+&>TuF*+t|NsBx*X@&^{(74m7$&h_$M#M9|NsA{S;`0;Uu7=w|JyC?HAh6k
ze}+1xsHmwhu&66HFfcIP^bL@lesRaD;Iq&BEZ%eWY=1PPFY{Dsy<-BC(Rb~3G1(I>
z=N?X;FeCd4v+$=qJGop~j!QY*U^rm3!kce%dh`{>;?`v+FVDQZg}Jg?^hI(^;}W}M
zg^TG?r&`^LrTPTsX@^`~l4O7F&g4%~m8k_se!uV%xv`=D$MOHS|9o@5KFKW6af!6!
z`LAm=XC{CD)!%#j|HJFz$CvF}-LdXbRL19u@0S-mhqxcx7c}*k|jd>AsgNPCqsf
ziMl4Z{S7OBA0MZ(sjSpWCXR@O$AyIE9QL*AI(6#Ug-1W`?0oV6|K9q)-P;!b>*rlH
z**v<)GOgcz@uh6L1#AUJ!;N?F9f&-)=gu-2J*@|z7~*GODe;Sbm-v9i>PWZmF%AZv
zC!G$q?F|yoIyKejB`~xcUN2jEYDdpfA=f$0?aw2hPvmw{Y~CFTbyT7jyL04gQs{tD>Lhd~#W~
zf8VUdj=%ov%Y5bg{r~^{{qtm|{``M%Qu&Q)rby$Nf;A>#Ci`Aam^|bIqq4F+Mql`UsWil&yl|3#wr`7!Y
z^8c)j`uW9qyRIf3oGt3w#r^ev?emmx`#x=Jz5niSm0W2-u%qwMHE$0tc*Vfr#+>m>
z>YcCe>z1P3{2?os9LoLtvSVVPLelipI$H%-&NTnGA#}?nRaVCZ24*2DdHqcXF9(E3
zG0ggrIO-md-n|I-8pCX)ig*hf5T
z6P?Xfv
zjNnyQgG;~qoT!=^t*x_W{hywo8ytrXCN*`m-Sy7@-y(gCYpQRDJIewkCN{?9hpn1&
zTfWG$WispVEz!R1Cp3{!LClBU+ezr-gGG)$4?i#WV{&|$f7JcI_rK1&FH3CIn=7S6
z7qW8o$8BEPztt(ui-D7s&xXxVhL!I`Goz3Oi;&m8RR!k+e#%c!d*L>p%T&BBf{P<3
z$XQdkWrG20=fRa8-a-K`Q#ibi&R|*bi+^kK@qgK|n@!WSvsYCZ9&D_Cy<$PJf{!$x
zO2l*t4r`riwtkE4I!p{~OnJznY1)<
z$&?-Zn>*M9O8FUUuGP=ux|pW!@p)=6Bae=+YKv>rk{g)^w^%;Bz@2>YO|53fA@l!J
z#7^~l^Ls1&Tao_t|ED{XI9uihDzH5bc-Eru!0A%!fo^UORmpr|3yTdOT<=Ufv$IUW
zcXOu*(}pjz*-m?AMNTSfZvVEpr|e6XUP@3>-JVqytAh83R$9#rRlaI-dEd-8YlWJp
z%*oo?n{Zs$Mban8!c^h=%Y_XyB{VnoWeVOg)!gjzx8(gS_E&qOT9)k+zWO$E
zc~-pr+&BI6{r-LpzcML)$HV*o|Nnk@@9dLLdu#uH*m`evnMHrJn<^PI2i99O^mpZCw_%)a#Q<@ZI-ziL4-6=_hS~dHsvYK!6Oarr@i!|A~68YntCmgu0@^seEw2Qw!$z6VyQMan<
zr>Ao9+MQ*4FJ8O#(k|2UzYZ_6Ys0#!BK7}Y{eN}w>-W2Vv^-dDo{f0$@61NO=-K;T
zS?UF?xba6oq{~m1cMeZiYx>Uf=eq30_?hCr7EQh0TlDLNabA9Bf!^gM52k7vojp|7
zIhRq*@d1Ofm#QMuas4F0C0<(0I%Y@1r4$pgx9mH>IxV%FGqPOooz#S@{E2U7`IH<=
zHOrK8+GsF?-C{}SEB$5XU(VZ8^(p@9{MDjePm}jQ`=oe&+yDPp1UOjEYFvm}X*RE9
zn)_0ReWte_?eUT0SngkM#=c~}b=art|Ic^%7e72#^6_%hywb2=XQnDGmH7X~qeJGO
zPw9;hA`D^M-}QEUV`7}p`XQX>?ao!^Wqi|5h1c+z=~$S=Ox>s@dM-hObNjBKaucQ$
z-KSYQ@7{1xx*8X81QbKr43qAwxH3mnUa)`SaWMC#o|jIg#v7Hh4$pa$t}dPx!0MQM
z%B}xjIYMUQ6RyhL0yrI~LKOQM3Nu@{MNm!fn^@nshqy
z@VVPvPb>urwEuB6h_l~WpdEAmS(DWf7nkMrZ?8^$EyD8Hg)!NZT^?LX87S_pupprz>##_
z&Ef6;-#0XxUNVdIPK+*6+5dmv0tI>D1y(9+{{No;Z-Pa*wc^!n2l%2I7}GifjXyuR
zSGo7B2dBEbs
z<{tXYUdgOb#Hj7{Uk(vAZXV@cijfBN5D(71=!)Iy>uaNLc
z;<=g})VNTuV7LDB^XdHc=jYheJbb*IZ@T-$#`2#H?>(Ao6?>{b+URsIst6H%(y&5+
zfnR~~(E-Eri+z_`{QYw2kUke%tId;-b1tM@UU&KLrdUu6O>1CwarVFX&}WS`o0V^(
zx_d7px5*pc%^Z(rJd^n-m+^nsqDd{5bGrV2n#*NT%=`a;=YfJP;-|I591Rl=_pljF
zxlp*Xb6R5?Pl;&YjDn&iOiZTEaf}8GjE+wx=>=VN619ptxFUuEhN}OG@gp?7xW3)~;h_FWB&akCkZ(
zqm%R&l^K#C0bSLczKrlf8Wws+z(*JAx9%WOKmWzz!H
zQ1$;i88-QQ9#}nb(OfQ_j<5G;@)jj!);WvlY&!2`{X5Wo-D&H{@a1{uukO45f6o(E
zb*WQ^M@1DEPEYJ`Oc4)xX6QLpYl-Q}$-hHoq@=Y9+}`SsdJA;!G(n;j110!;kEz%|97(g
z_trY=Li53=(e-{VI#An3{2@jpO1f?TS5*>*khJ@Bh#`
z$F?|U{pr&G>*}9}*Z;O)jrV2gd$*G@w)pFjGqTY?|9_nQaaZR5{z+yYKMQ~5G%6@R
z|0x%6a|&+*-|m{N$G+GX<$v{BnQ|$9OHawg)t;`u
zHazHc**m!lZH?A(o2(t%(~Z9ieJljU(2b5s{xTlcP9_`7lOiVOa5f1D6msxBO+Crb
zvhZf_1BRpR=BcLk*X3+xtW2r<`}N+}+N(D2wrzdA{|$rAvR9#!k~iEpd@S00pEXB`
zvFyjOD|^)VmP(tf=w7g4g^O!c?T0|!{Oj@M|L^UMkCp!)weQEuS*BANgbeq6ky+!o
zT(xKJyjk<6H{_+I89Y#SWM+EjyLKPbajrfNnTR8kI=1F_+N!v-x<}1>`~TcOExQ{L
zLbpzPc%RzBl9J)FxPP`-wwYj0$)N`pTD6Dz9hYu=#k*C^v}I1=)`cD2oSiW%IfBk?
z;+@AFw&W!P7bDMAua#FPrOPWWdr|PF;UG)rOh!Ilh3i`ruPh4X-@C?fW`ESf!_)77
zOsjtp8#i0?=ewxJ+}!ewch{D`d~M;?Ve})e?w8N_Jfb-$gLZTcOneDPSN^G$fnlnityjtw9
z@=2afx#OXl9?v54YHvx3b49)8ouanWJAB^~*PTIEgAPwj6fAo^n|bynPz-(RVR6%S
z;+`3lWO^`SWwJUO--Ubu->0e#e^`?WCvG`0MMLI2o;=&)SjQ
z9IZQyQch0T)Ai-_#J>9-{(qJf|MuzGd)BEefvxeN@A9b0%J~PlRX9VLqz>j^+-Vs8
zVcsl81zoTFeO=QMm-R373@o4Hw0-A$gDrd^PnQPz?^~cQ=cEC6msp2a!YJMW!2wp|5_2qcWPxIzguDcGYsIPNBx~*l`
z)p^T&W(iBl$*5@@Wt?^InqH~XhKS!RiE7uhrW$O%api{#v;-aYfW7U`*qWnIuK{dR3*R4b>!3%`kFt4wd!97r%;-`=ApXLFKAs78{n
zn~$f%g^lsd11XaU(v=c2N;blV5^D||THRoP9O&~#nfg$~%
zzS0qyI7STy8-}b;3x6H^(3F&;#8}F3;E|qVSW#Db)HQQf1`UQhL&v>Rvz9wvF$}ES
zm0|TNDq~TzwW7<>2_}0*CN6np=F9xLRC8hw+uyCb^l}ou2VF|Pne%$t$|sXHFL|!B
zWXnylU@4_pW}c=?mT%4SI%XvjnC2Y*|Np<dnwymtJsy=X5^mE83~Cl`i~S{ygOXz%jS3wT
zKPEeKt23%iW166ny@K_Zi!&EjJfj1XLb>lGEjKqAEgQX+?TwcWSGRL7;M{Gr{8rDQ
zrgn$ngFha4yU?DJV}Cp61>Oz{pf!^dX0*PMzh5!;CddeG6Nd-&q~{$kQQi$9tZc
zk%1vXEofb4I2Vtq(KWB?HX81eU5G`TELK&yyC^#7YsZB=?b-b7#KcxEaBhY
zz>r{WYiGhq4=W@35E##hZ
z-Ro0c<=Ha_Pb6@BJEJU+BXROh`0V$0JCE9Fs!Y0k^r^^~O|Q4?D1Y(SKVy4=jz*aEso0gHqrQkZ@A!N=T$shj}wmEnx(lOl!cUf4(CfAom^G%!T->W
zN%_8V{5tItxldCMig7P;-99(ra;(uTlU=zc>%z6?A1rt`p=#^yD!sOxa%1O`|2L29
z?Y_J{wY)T#^?Qf}17qW|mwa`L*%bIvcxDP5lQ|iYUa?CKk_b{5t@Eo6*LC*u0^Im>yc65X&X4Y&}T`U;xzk$Q&tS-;vkQRZ@Do3PW9(cn2
z@WkfYDshD}Rr`NxS-&$L$s$`;*2@3j|D%EA3sk9n={iv1)T!|b7sge7>Q3gA)>8d?c8ucb9Kmq
z$)FfgW?-q7bm}cvVDLziXk44XAgHQqymMs(qvfPm=5hy^W!MC+OI6l0GFkJAUSC+g
zIQtVv$h$3fh08jO#pcH`DQPzO^(AeI>s&3+=B{aNA~R#>34Zp5!sZL>eu;h2%H8u=
zvwFgJ)30WUrx-VRCBK!wmma_J{xrckPKLSG%NUFdlzvD^spwBr0g{-^^Iu*6zl0%xW0{#$&yfZOCI$vx-o%Fr
ziH;X|JRUw@+E9@F<*dUIo)Z?er{}gBD>N|v=({*ctI36V39H8r^R`-p`wTV=cNSPi
zEZM|UQzg1xGUuVuTI(fLEA|dKP)&@R?oiRIFi+(&-YVY-WR4%|U
zTPFD+DC_=zrPMonBHRD}|6**I@^WS5`LAD21Ff>=J>DClwP$ixG_GB;+%xG?PB
zoxU`c^Ig#Qq_RJCf3Gt!DoofXsQde=rt7nJLdT7TYFRdCF121#F8^Xq=M?*|S&SDL
z5*QMg8W@b?O&b+sm97yq0eIxs=NUb|NyM?5lUNj1?*uJ@J
z<4(R{mpQk7oZ))*|KI5uEAk=^D1GJ#^sR|7vFteiq}Z_Ar^DXRGw1BF4~sYsKVjw<
z=mihDD8PEHZ197&shclsUK#tz>R0Xy{;L&~jT9!L%%^Yv$@z
zWou*%9=7wkh`6fEywqXm^U!SO?WZ3*jz~Q@X21Gg!bZUf587%(W>|0~G$>Drd-^V>
z<8Ye(swCC}r^;W|-{Le>R%w~ZqtmICX{MRhv1GQ}#VtAiX1ee3Ie&lIvq{d5Z~pJ8
zWiMsmIK*&J(xl6;H7K}E!g8WTf=7#jk_l4?Q&78dPVoUy3<>Z!->^SEJ5uYDhmz)U
zTV)xqfMOx_je3od-HWESsdgTaO7U6uMORT`e{wT}yZ)i}rCFg0u0kuMn?g6ReD$Ax
zt@GPgp97JTzx^@JIQ-dFdclI;W6Fi=C79XzW&hrYaaf{xVe(Fyo^sXLSg9BD|Nmd?
zm*~h)!STRhezX7s^R@$WOLr)&+Q>0s^>n{riI`u^YYh6jEhk63>f=5QO0z%z|NqNl
z(3zy#($FtpUpAj(;sf^s$|fC`x43K+Qtgpbarh`aDXn8Z&j*9fi+rk^ZG-}Tuzp~?
zaD(^ZOSegjCVdQ;#^bo@r=hcleRV>HRlB=t)R_1apDV>mwiB8?zGDSy)Vj^;z`c(?oNohPR_BPSky8mDK
z+{gw7hJ9;~KANN{{w9CQjLfx9OeTarWX-QL)7fkO>`&LGqZ>=x{vZ8+TC#fG&-(|K
z?63L%dy@ktnq!Z
zV8P<1SyJ5p|6g)0s!?k@YTnOvVND>beWU-+iUPg;jS-JDGW363bm#5TUNf2XuWS7?
zxB3>B)Mu_gCu-@3g!=#g61QSSd;V4X^)nVWHY?t~b85cJFD<2iM_pefBwY9#7;2@Z
zdGlu%hv13-|91a$W@YD7;^3H&kWp5)=|5{Qv(iojax_qM_VYr=_7{!YV4}=>q9&+gaz`&5n
zz`(%4z`(1(z`)4Bz_^5gfkA?F3_jjy3=O_G
zL3Pn+455a{;Ny+P(BO*`R2PlL5Ndb~KHg{y4Zb)*bcm#!lmdiQvkIu28L<(^b
diff --git a/clients/web-game/assets/style.css b/clients/web-game/assets/style.css
deleted file mode 100644
index 341be19..0000000
--- a/clients/web-game/assets/style.css
+++ /dev/null
@@ -1,1213 +0,0 @@
-/* ── Google Fonts ───────────────────────────────────────────────────── */
-@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap');
-
-/* ── Design tokens ──────────────────────────────────────────────────── */
-:root {
- --board-felt: #1d3d28;
- --board-rail: #2a1508;
- --field-ivory: #f0e6c8;
- --field-burgundy: #7a1e2a;
- --field-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;
- justify-content: center;
- padding: 1.5rem;
- min-height: 100vh;
-}
-
-.hidden { display: none !important; }
-
-/* ── Login card (§11) ───────────────────────────────────────────────── */
-.login-card {
- width: 340px;
- margin-top: 5vh;
- border-radius: 8px;
- overflow: hidden;
- 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);
- background: var(--ui-parchment);
-}
-
-/* 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;
- /* Alternating burgundy/ivory triangles pointing down from the top */
- 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 into downward-pointing triangles */
- 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 ─────────────────────────────────────────────────── */
-/* No width: 100% — let it size to content (the board wrapper, ~832px).
- This keeps the board pinned at the same horizontal position whether or
- not the side panel is visible, and aligns the status bar / score panels
- with the board rather than with the viewport edge. */
-.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; }
-
-/* ── 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 ─────────────────────────────────────────────── */
-/* Horizontal banner: name on the left, score bars expanding to fill the
- board width — no more empty right half on large screens. */
-.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;
-}
-
-/* Bars sit side-by-side (points | holes) filling remaining width */
-.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);
-}
-
-/* ── Board + side panel ─────────────────────────────────────────────── */
-/* .board-and-panel is sized to the board wrapper only; the side panel is
- positioned absolutely so it floats to the right without pushing the
- board and breaking its horizontal alignment. */
-.board-and-panel {
- position: relative;
-}
-
-/* The side panel is anchored to the board's RIGHT edge. Scoring panel
- wrappers inside it initially overlap the board; they slide to a peek
- strip after a few seconds, and reveal fully on hover. */
-.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; /* pass board clicks through the empty area */
-}
-
-.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) ─────────────────────────────────────────────────── */
-
-/* §5a — vigorous tumble: die bounces in from a random rotation */
-@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 — centered in the board bar */
-.bar-die-slot {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-/* Double glow (§5c) */
-.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;
-}
-
-/* Final score ledger */
-.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; }
-
-/* ── Scoring notification panel (§6b) ───────────────────────────────── */
-
-/* ── Wrapper: handles slide-in → peek → reveal lifecycle ──────────────
- The wrapper starts off-screen right (translateX(100%)), slides in on
- mount via animation, then Leptos adds .peeked after 3.4s to slide it
- back to a 28px peek strip. */
-@keyframes scoring-panel-enter {
- from { transform: translateX(100%); }
- to { transform: translateX(0); }
-}
-
-.scoring-panel-wrapper {
- /* width: 290px; */
- pointer-events: auto;
- animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
- transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
- filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38));
-}
-
-/* Peeked: slide right by the full panel width so the board is 100% clear.
- The panel's left portion stays visible in whatever free space exists to
- the right of the board. */
-.scoring-panel-wrapper.peeked {
- transform: translateX(100%);
-}
-
-/* Click on the visible left strip → .revealed slides it back over the board.
- A second click removes .revealed and returns to the peeked position. */
-.scoring-panel-wrapper.revealed {
- transform: translateX(0);
-}
-
-/* Pointer cursor on the peeked (clickable) strip */
-.scoring-panel-wrapper.peeked:not(.revealed) {
- cursor: pointer;
-}
-
-/* ── Inner panel card ─────────────────────────────────────────────────── */
-.scoring-panel {
- background: var(--ui-parchment);
- border-radius: 5px;
- padding: 0.45rem 0.85rem;
- font-size: 0.84rem;
- box-shadow: 0 1px 4px rgba(0,0,0,0.15);
- border-left: 3px solid var(--ui-green-accent);
- display: flex;
- flex-direction: column;
- gap: 4px;
- width: 100%;
-}
-
-.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; }
-
-/* ── Large-screen layout: panel in free space, no peek needed ─────────
- Threshold: board (832) + body-padding (48) + panel-gap (16) + panel (290)
- + symmetric left margin = 1492 px.
- At this width the panel fits entirely to the right of the board. */
-@media (min-width: 1492px) {
- .side-panel {
- right: auto;
- left: calc(100% + 1rem); /* outside board, no overlap */
- }
- /* Already fully visible in free space — peeked/revealed are no-ops. */
- .scoring-panel-wrapper.peeked,
- .scoring-panel-wrapper.revealed {
- transform: none;
- cursor: default;
- }
-}
-
-/* ── Board wrapper ──────────────────────────────────────────────────── */
-.board-wrapper {
- display: flex;
- flex-direction: column;
- gap: 3px;
-}
-
-/* ── Zone labels (§2a) — aligned with board quarters ───────────────── */
-/* Board border(4) + padding(4) = 8px inset each side */
-.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) ────────────────────────────────────────────────────── */
-/*
- * Each field is a transparent rectangle over the felt.
- * The triangular flèche is drawn by ::before using clip-path.
- * --fc controls the triangle colour; z-index:-1 keeps the triangle
- * behind checkers; isolation:isolate confines the negative z-index.
- */
-.field {
- --fc: var(--field-ivory); /* default triangle colour */
- width: 60px;
- height: 180px;
- background: transparent; /* felt shows through between triangle tips */
- isolation: isolate; /* stacking context for z-index:-1 ::before */
- border-radius: 3px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-end;
- padding: 4px 2px;
- position: relative;
-}
-
-/* Bot-row triangle: wide base at bottom, tip at top */
-.field::before {
- content: '';
- position: absolute;
- inset: 0;
- z-index: -1; /* behind checkers & corner crown */
- background: var(--fc);
- clip-path: polygon(0% 100%, 50% 0%, 100% 100%);
- transition: background 0.12s;
-}
-
-/* Top-row triangle: wide base at top, tip at bottom */
-.top-row .field::before {
- clip-path: polygon(0% 0%, 100% 0%, 50% 100%);
-}
-
-.top-row .field { justify-content: flex-start; }
-
-/* ── Zone alternating colours (§2b) ────────────────────────────────── */
-/* petit-jan and grand-jan: burgundy / ivory */
-.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); }
-
-/* Opponent's grand-jan — deep slate-blue / silvery-green ivory.
- Previously #1e3d32 was nearly identical to the felt (#1d3d28); now using
- a clearly distinguishable cool blue that reads well against the green. */
-.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; }
-.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; }
-
-/* Jan de retour — warmer: amber-brown / warm amber ivory */
-.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
-.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
-
-/* ── Rest corner — before .clickable so green wins when interactive ── */
-/* .field.corner { --fc: var(--field-corner) !important; } */
-
-/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */
-.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; }
-
-/* Corner pulse (§8d) — filter respects the triangle shape */
-@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;
-}
-
-/* ── Exit-eligible highlight (§8c) — filter glow on triangle ───────── */
-@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;
-}
-
-/* ── §6c — Jan hover field highlight ────────────────────────────────── */
-.field.jan-hovered {
- --fc: rgba(190, 140, 35, 0.8) !important;
-}
-
-/* ── §6e — Hit (battue) ripple ──────────────────────────────────────── */
-@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; }
-
-/* ── Interactive states — after .corner to take visual priority ─────── */
-.field.clickable {
- cursor: pointer;
- --fc: #8fc840 !important;
-}
-.field.clickable:hover { --fc: #74aa28 !important; }
-.field.selected {
- --fc: #5a8a18 !important;
- outline: 2px solid rgba(255,255,255,0.3);
- outline-offset: -2px;
-}
-
-/* ── Field numbers ──────────────────────────────────────────────────── */
-.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;
-}
-
-/* ── Bredouille toast variant (§6d) — gold shimmer, larger entrance ─── */
-@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;
-}
-
-/* ── §4a — Checker slide animation ─────────────────────────────────── */
-@keyframes checker-slide-in {
- from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); }
- to { transform: none; }
-}
-/* Only the arriving (outermost) checker animates; --slide-dx/dy are set
- as inline styles on that element at render time, so no flash occurs. */
-.checker.arriving {
- animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94);
-}
-/* Lift the field that owns an arriving checker above its siblings so the
- checker doesn't slide under adjacent fields (isolation:isolate traps
- z-index within each field's stacking context). */
-.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; /* reserve height so layout doesn't shift when buttons appear */
-}
-
-/* ── 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;
-}
-
-
-.auth-badge {
- font-size: 0.8rem;
- text-align: center;
- padding: 0.35rem 0.6rem;
- border-radius: 5px;
-}
-.auth-badge--in { background: rgba(96,165,250,0.15); color: #93c5fd; }
-.auth-badge--out { background: rgba(148,163,184,0.1); color: #64748b; }
-.auth-badge a { color: #60a5fa; }
-
-.playing-as {
- font-size: 0.8rem;
- color: #64748b;
- text-align: center;
-}
diff --git a/clients/web-game/index.html b/clients/web-game/index.html
deleted file mode 100644
index 7399dbc..0000000
--- a/clients/web-game/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- Trictrac
-
-
-
-
-
-
diff --git a/clients/web-game/locales/en.json b/clients/web-game/locales/en.json
deleted file mode 100644
index c29121d..0000000
--- a/clients/web-game/locales/en.json
+++ /dev/null
@@ -1,61 +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": "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",
- "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",
- "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"
-}
diff --git a/clients/web-game/locales/fr.json b/clients/web-game/locales/fr.json
deleted file mode 100644
index 93f76e5..0000000
--- a/clients/web-game/locales/fr.json
+++ /dev/null
@@ -1,61 +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": "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",
- "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",
- "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",
- "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 un champ surligné 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"
-}
diff --git a/clients/web-game/src/app.rs b/clients/web-game/src/app.rs
deleted file mode 100644
index ce355f4..0000000
--- a/clients/web-game/src/app.rs
+++ /dev/null
@@ -1,726 +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, JanEntry, PlayerAction, ScoredEvent, SerStage, SerTurnStage, ViewState,
-};
-use trictrac_store::CheckerMove;
-
-use std::collections::VecDeque;
-
-const RELAY_URL: &str = "ws://localhost:8080/ws";
-const GAME_ID: &str = "trictrac";
-const STORAGE_KEY: &str = "trictrac_session";
-
-// In debug builds trunk serves on 9091, relay is on 8080.
-// In release the game is served by the relay itself — use relative paths.
-#[cfg(debug_assertions)]
-const HTTP_BASE: &str = "http://localhost:8080";
-#[cfg(not(debug_assertions))]
-const HTTP_BASE: &str = "";
-
-/// 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,
- /// True when this state is a buffered snapshot awaiting player confirmation.
- pub waiting_for_confirm: bool,
- /// Why we are paused — drives the status-bar message in GameScreen.
- pub pause_reason: Option,
- /// Points scored by this player in the transition to this state (if any).
- pub my_scored_event: Option,
- pub opp_scored_event: Option,
- /// Checker moves to animate on this render. None when board is unchanged.
- pub last_moves: Option<(CheckerMove, CheckerMove)>,
-}
-
-/// Reason the UI is paused waiting for the player to click Continue.
-#[derive(Clone, Debug, PartialEq)]
-pub enum PauseReason {
- AfterOpponentRoll,
- AfterOpponentGo,
- AfterOpponentMove,
- /// Opponent rolled their die in the pre-game ceremony.
- AfterOpponentPreGameRoll,
-}
-
-/// 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,
-}
-
-#[derive(Deserialize)]
-struct MeResponse {
- username: String,
-}
-
-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);
-}
-
-/// Fire-and-forget: tell the relay server who won. Only called by the host.
-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!("{HTTP_BASE}/games/result"))
- .credentials(web_sys::RequestCredentials::Include)
- .json(&body)
- .unwrap()
- .send()
- .await;
-}
-
-#[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);
-
- // Auth: fetch once and expose to all child components via context.
- let auth_username: RwSignal> = RwSignal::new(None);
- provide_context(auth_username);
- spawn_local(async move {
- if let Ok(resp) = gloo_net::http::Request::get(&format!("{HTTP_BASE}/auth/me"))
- .credentials(web_sys::RequestCredentials::Include)
- .send()
- .await
- {
- if resp.status() == 200 {
- if let Ok(me) = resp.json::().await {
- auth_username.set(Some(me.username));
- }
- }
- }
- });
-
- 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 {
- // 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, pending).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.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("Blancs", "Noirs");
- 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),
- }
-
- // Host reports outcomes once per terminal game state.
- 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.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()),
- });
- }
- 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),
- },
- 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! {
-
- {move || {
- let q = pending.get();
- if let Some(front) = q.front() {
- view! { }.into_any()
- } else {
- 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,
- pending: RwSignal>,
-) -> bool {
- let mut backend = TrictracBackend::new(0);
- backend.player_arrival(0);
- backend.player_arrival(1);
-
- let mut vs = ViewState::default_with_names("You", "Bot");
- 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,
- waiting_for_confirm: false,
- pause_reason: None,
- my_scored_event: None,
- opp_scored_event: None,
- last_moves: None,
- }));
-
- loop {
- match cmd_rx.next().await {
- Some(NetCommand::Action(action)) => {
- let prev_vs = vs.clone();
- backend.inform_rpc(0, action);
- for cmd in backend.drain_commands() {
- if let BackendCommand::Delta(delta) = cmd {
- vs.apply_delta(&delta);
- }
- }
- let scored = compute_scored_event(&prev_vs, &vs, 0);
- let opp_scored = compute_scored_event(&prev_vs, &vs, 1);
- screen.set(Screen::Playing(GameUiState {
- view_state: vs.clone(),
- player_id: 0,
- room_id: String::new(),
- is_bot_game: true,
- waiting_for_confirm: false,
- pause_reason: None,
- my_scored_event: scored,
- opp_scored_event: opp_scored,
- last_moves: compute_last_moves(&prev_vs, &vs, true),
- }));
- }
- Some(NetCommand::PlayVsBot) => return true,
- _ => return false,
- }
-
- loop {
- let pgr = backend.get_view_state().pre_game_roll.clone();
- match bot_decide(backend.get_game(), pgr.as_ref()) {
- None => break,
- Some(action) => {
- backend.inform_rpc(1, action);
- // Process each delta individually so intermediate ceremony
- // states (both dice shown) can trigger a pause via push_or_show.
- for cmd in backend.drain_commands() {
- if let BackendCommand::Delta(delta) = cmd {
- let delta_prev_vs = vs.clone();
- vs.apply_delta(&delta);
- push_or_show(
- &delta_prev_vs,
- GameUiState {
- view_state: vs.clone(),
- player_id: 0,
- room_id: String::new(),
- is_bot_game: true,
- waiting_for_confirm: false,
- pause_reason: None,
- my_scored_event: None,
- opp_scored_event: None,
- last_moves: compute_last_moves(&delta_prev_vs, &vs, false),
- },
- pending,
- screen,
- );
- }
- }
- }
- }
- }
- }
-}
-
-/// Returns the checker moves to animate when the board changed between two ViewStates.
-/// Returns `None` when the board is unchanged or no real moves were recorded.
-/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2.
-fn compute_last_moves(
- prev: &ViewState,
- next: &ViewState,
- own_move: bool,
-) -> Option<(CheckerMove, CheckerMove)> {
- if prev.board == next.board {
- return None;
- }
- let (m1, m2) = next.dice_moves;
- if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
- // Relies on the engine invariant: dice_moves is updated atomically with the board
- // change in the Move event handler. Any future engine path that mutates the board
- // without setting dice_moves would bypass this guard and replay stale animation.
- return None;
- }
- if own_move {
- // m1 was already shown via the staged-moves overlay; only animate m2.
- if m2 == CheckerMove::default() {
- return None;
- }
- return Some((m2, CheckerMove::default()));
- }
- Some((m1, m2))
-}
-
-/// Computes a scoring event for `player_id` by comparing the previous and next
-/// ViewState. Returns `None` when no points changed for that player.
-fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option {
- let prev_score = &prev.scores[player_id as usize];
- let next_score = &next.scores[player_id as usize];
-
- let holes_gained = next_score.holes.saturating_sub(prev_score.holes);
- if holes_gained == 0 && prev_score.points == next_score.points {
- return None;
- }
-
- let bredouille = holes_gained > 0 && prev_score.can_bredouille;
-
- // Determine which dice_jans are "mine" depending on who was the active roller.
- let my_jans: Vec = if next.active_mp_player == Some(player_id)
- && prev.active_mp_player == Some(player_id)
- {
- // My own roll: positive totals are mine.
- next.dice_jans
- .iter()
- .filter(|e| e.total > 0)
- .cloned()
- .collect()
- } else if next.active_mp_player == Some(player_id) && prev.active_mp_player != Some(player_id) {
- // Opponent just moved: negative totals (their penalty) are scored for me.
- next.dice_jans
- .iter()
- .filter(|e| e.total < 0)
- .map(|e| JanEntry {
- total: -e.total,
- points_per: -e.points_per,
- ..e.clone()
- })
- .collect()
- } else {
- return None;
- };
-
- let points_earned: u8 = my_jans
- .iter()
- .fold(0u8, |acc, e| acc.saturating_add(e.total.unsigned_abs()));
-
- if points_earned == 0 && holes_gained == 0 {
- return None;
- }
-
- Some(ScoredEvent {
- points_earned,
- holes_gained,
- holes_total: next_score.holes,
- bredouille,
- jans: my_jans,
- })
-}
-
-/// Either queues the state as a buffered confirmation step (when the transition
-/// warrants a pause) or shows it immediately. Always updates `screen` to the
-/// live state so the UI falls through to the right content once pending drains.
-fn push_or_show(
- prev_vs: &ViewState,
- new_state: GameUiState,
- pending: RwSignal>,
- screen: RwSignal,
-) {
- let scored = compute_scored_event(prev_vs, &new_state.view_state, new_state.player_id);
- let opp_scored = compute_scored_event(prev_vs, &new_state.view_state, 1 - new_state.player_id);
-
- if let Some(reason) = infer_pause_reason(prev_vs, &new_state.view_state, new_state.player_id) {
- // Scoring notifications go on the buffered (paused) state only.
- pending.update(|q| {
- q.push_back(GameUiState {
- waiting_for_confirm: true,
- pause_reason: Some(reason),
- my_scored_event: scored,
- opp_scored_event: opp_scored,
- ..new_state.clone()
- });
- });
- // Animation belongs to the buffered confirmation step; clear it on the
- // fallback live state so it doesn't fire again after the queue drains.
- screen.set(Screen::Playing(GameUiState {
- last_moves: None,
- ..new_state
- }));
- } else {
- // No pause: show scoring directly on the live state.
- screen.set(Screen::Playing(GameUiState {
- my_scored_event: scored,
- opp_scored_event: opp_scored,
- ..new_state
- }));
- }
-}
-
-/// Compares the previous and next ViewState to decide whether the transition
-/// warrants a confirmation pause. Returns None when it is the local player's
-/// own action (no pause needed).
-fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option {
- let opponent_id = 1 - player_id;
-
- // Pre-game ceremony: pause when both dice are revealed simultaneously
- // (i.e. the second die was just rolled). Both players see this pause.
- if next.stage == SerStage::PreGameRoll {
- if let (Some(prev_pgr), Some(next_pgr)) = (&prev.pre_game_roll, &next.pre_game_roll) {
- let both_now = next_pgr.host_die.is_some() && next_pgr.guest_die.is_some();
- let both_before = prev_pgr.host_die.is_some() && prev_pgr.guest_die.is_some();
- if both_now && !both_before {
- return Some(PauseReason::AfterOpponentPreGameRoll);
- }
- }
- return None;
- }
-
- // Don't fire normal pause rules on the PreGameRoll → InGame transition.
- if prev.stage == SerStage::PreGameRoll {
- return None;
- }
-
- if next.active_mp_player == Some(opponent_id) {
- // Dice changed → opponent just rolled.
- if next.dice != prev.dice {
- return Some(PauseReason::AfterOpponentRoll);
- }
- // Was at HoldOrGoChoice, now Move, opponent still active → opponent went.
- if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move
- {
- return Some(PauseReason::AfterOpponentGo);
- }
- }
-
- // Turn switched to us → opponent moved.
- if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) {
- return Some(PauseReason::AfterOpponentMove);
- }
-
- None
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::trictrac::types::{PlayerScore, SerStage, SerTurnStage};
-
- fn score() -> PlayerScore {
- PlayerScore {
- name: String::new(),
- points: 0,
- holes: 0,
- can_bredouille: false,
- }
- }
-
- fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option) -> ViewState {
- ViewState {
- board: [0i8; 24],
- stage: SerStage::InGame,
- turn_stage,
- active_mp_player: active,
- scores: [score(), score()],
- dice,
- dice_jans: Vec::new(),
- dice_moves: (CheckerMove::default(), CheckerMove::default()),
- pre_game_roll: None,
- }
- }
-
- #[test]
- fn dice_change_is_after_roll() {
- let prev = vs((0, 0), SerTurnStage::RollDice, Some(1));
- let next = vs((3, 5), SerTurnStage::Move, Some(1));
- assert_eq!(
- infer_pause_reason(&prev, &next, 0),
- Some(PauseReason::AfterOpponentRoll)
- );
- }
-
- #[test]
- fn hold_to_move_is_after_go() {
- let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1));
- let next = vs((3, 5), SerTurnStage::Move, Some(1));
- assert_eq!(
- infer_pause_reason(&prev, &next, 0),
- Some(PauseReason::AfterOpponentGo)
- );
- }
-
- #[test]
- fn turn_switch_is_after_move() {
- let prev = vs((3, 5), SerTurnStage::Move, Some(1));
- let next = vs((3, 5), SerTurnStage::RollDice, Some(0));
- assert_eq!(
- infer_pause_reason(&prev, &next, 0),
- Some(PauseReason::AfterOpponentMove)
- );
- }
-
- #[test]
- fn own_action_returns_none() {
- let prev = vs((0, 0), SerTurnStage::RollDice, Some(0));
- let next = vs((2, 4), SerTurnStage::Move, Some(0));
- assert_eq!(infer_pause_reason(&prev, &next, 0), None);
- }
-
- #[test]
- fn no_active_player_returns_none() {
- let mut prev = vs((0, 0), SerTurnStage::RollDice, None);
- prev.stage = SerStage::PreGame;
- let mut next = prev.clone();
- next.active_mp_player = Some(0);
- assert_eq!(infer_pause_reason(&prev, &next, 0), None);
- }
-}
diff --git a/clients/web-game/src/components/board.rs b/clients/web-game/src/components/board.rs
deleted file mode 100644
index 0610c86..0000000
--- a/clients/web-game/src/components/board.rs
+++ /dev/null
@@ -1,594 +0,0 @@
-use leptos::prelude::*;
-use trictrac_store::CheckerMove;
-
-use super::die::Die;
-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];
-
-/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system.
-/// Returns true when `field_num` is the rest corner for this perspective.
-#[allow(dead_code)]
-fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
- if is_white {
- field_num == 12
- } else {
- field_num == 13
- }
-}
-
-/// Zone CSS class for a field number (field coordinates are always White's 1-24).
-fn field_zone_class(field_num: u8) -> &'static str {
- match field_num {
- 1..=6 => "zone-petit",
- 7..=12 => "zone-grand",
- 13..=18 => "zone-opponent",
- 19..=24 => "zone-retour",
- _ => "",
- }
-}
-
-/// Returns (d0_used, d1_used) for the bar dice display.
-fn bar_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)
-}
-
-/// 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: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px.
-/// With triangular flèches, arrows target the WIDE BASE of each triangle —
-/// that is where the checker stack actually sits.
-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(pad) + i*62 + 30(half field) = 34 + 62i
- // Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
- let x = if right {
- 480.0 + qi as f32 * 62.0
- } else {
- 34.0 + qi as f32 * 62.0
- };
- // Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
- // (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 − 4pad − 4field-pad − 20 ≈ 360)
- let y = if top { 30.0 } else { 358.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)>,
- /// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
- #[prop(default = None)]
- bar_dice: Option<(u8, u8)>,
- /// Whether we're in the move stage (determines used/unused die appearance).
- #[prop(default = false)]
- bar_is_move: bool,
- #[prop(default = false)] is_my_turn: bool,
- /// Whether the dice are a double (golden glow).
- #[prop(default = false)]
- bar_is_double: bool,
- /// Checker moves to animate on mount (None when board unchanged).
- #[prop(default = None)]
- last_moves: Option<(CheckerMove, CheckerMove)>,
- /// Fields where a hit (battue) was scored this turn — show ripple animation.
- #[prop(default = vec![])]
- hit_fields: Vec,
-) -> 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::>>();
-
- // Exit-eligible (§8c): all the player's checkers are in their last jan.
- // White last jan = fields 19-24 (board indices 18-23, positive values).
- // Black last jan = fields 1-6 (board indices 0-5, negative values).
- let board_snapshot = view_state.board;
- let all_in_exit: bool;
- let exit_field_test: fn(u8) -> bool;
- if is_white {
- let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum();
- let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum();
- all_in_exit = total > 0 && in_exit == total;
- exit_field_test = |f| matches!(f, 19..=24);
- } else {
- let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum();
- let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum();
- all_in_exit = total > 0 && in_exit == total;
- exit_field_test = |f| matches!(f, 1..=6);
- }
-
- // `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();
- let corner_title = if is_rest_corner(field_num, is_white) {
- Some("Coin de repos — must enter and leave with 2 checkers")
- } else {
- None
- };
- // §4a — slide delta for the arriving checker at this field.
- // Computed once per field at render time; Option<(f32,f32)> is Copy.
- let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| {
- [m1, m2].iter().find_map(|m| {
- if m.get_to() != field_num as usize || m.get_from() == m.get_to() {
- return None;
- }
- let (fx, fy) = field_center(m.get_from(), is_white)?;
- let (tx, ty) = field_center(m.get_to(), is_white)?;
- let dx = fx - tx;
- let dy = fy - ty;
- (dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
- })
- });
- // §6e — ripple on hit fields (battue).
- let is_hit_field = hit_fields.contains(&field_num);
- view! {
-
- }
- .into_any()
- })
- .collect()
- };
-
- // ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
- let bar_content = move |die_idx: u8| -> AnyView {
- match bar_dice {
- None => view! {
}.into_any(),
- Some(dice_vals) => {
- let die_val = if die_idx == 0 {
- dice_vals.0
- } else {
- dice_vals.1
- };
- view! {
-
- {move || {
- let staged = staged_moves.get();
- let (u0, u1) = if bar_is_move {
- bar_matched_dice_used(&staged, dice_vals)
- } else if is_my_turn {
- (true, true)
- } else {
- (false, false)
- };
- let used = if die_idx == 0 { u0 } else { u1 };
- view! { }
- }}
-
- }
- .into_any()
- }
- }
- };
-
- 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)
- };
-
- // Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
- let (label_tl, label_tr, label_bl, label_br) = if is_white {
- ("", "jan de retour", "grand jan", "petit jan")
- } else {
- ("petit jan", "grand jan", "jan de retour", "")
- };
-
- view! {
- // board-wrapper keeps zone labels outside .board so the SVG overlay
- // inside .board stays correctly positioned (position:absolute top:0 left:0
- // is relative to .board, not the wrapper).
-
-
-
{label_tl}
-
-
{label_tr}
-
-
-
-
{fields_from(tl, true)}
-
{bar_content(0)}
-
{fields_from(tr, true)}
-
-
-
-
{fields_from(bl, false)}
-
{bar_content(1)}
-
{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()
- }}
-
-
-
-
{label_bl}
-
-
{label_br}
-
-
- }
-}
diff --git a/clients/web-game/src/components/connecting_screen.rs b/clients/web-game/src/components/connecting_screen.rs
deleted file mode 100644
index 6f40da5..0000000
--- a/clients/web-game/src/components/connecting_screen.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-use leptos::prelude::*;
-
-use crate::i18n::*;
-
-#[component]
-pub fn ConnectingScreen() -> impl IntoView {
- let i18n = use_i18n();
- view! { {t!(i18n, connecting)}
}
-}
diff --git a/clients/web-game/src/components/die.rs b/clients/web-game/src/components/die.rs
deleted file mode 100644
index 7576280..0000000
--- a/clients/web-game/src/components/die.rs
+++ /dev/null
@@ -1,53 +0,0 @@
-use leptos::prelude::*;
-
-/// (cx, cy) positions for dots on a 48×48 die face.
-fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] {
- match value {
- 1 => &[("24", "24")],
- 2 => &[("35", "13"), ("13", "35")],
- 3 => &[("35", "13"), ("24", "24"), ("13", "35")],
- 4 => &[("13", "13"), ("35", "13"), ("13", "35"), ("35", "35")],
- 5 => &[("13", "13"), ("35", "13"), ("24", "24"), ("13", "35"), ("35", "35")],
- 6 => &[("13", "13"), ("35", "13"), ("13", "24"), ("35", "24"), ("13", "35"), ("35", "35")],
- _ => &[],
- }
-}
-
-/// A single die face rendered as SVG.
-/// `value` 1–6 shows dots; 0 shows an empty face (not-yet-rolled).
-/// `used` dims the die.
-/// `is_double` applies a golden glow (both dice same value).
-#[component]
-pub fn Die(
- value: u8,
- used: bool,
- #[prop(default = false)] is_double: bool,
-) -> AnyView {
- let mut cls = if used {
- "die-face die-used".to_string()
- } else {
- "die-face".to_string()
- };
- if is_double && !used {
- cls.push_str(" die-double");
- }
- if value == 0 {
- return view! {
-
-
- {"?"}
-
- }.into_any();
- }
- let dots: Vec = dot_positions(value)
- .iter()
- .map(|&(cx, cy)| view! { }.into_any())
- .collect();
- view! {
-
-
- {dots}
-
- }.into_any()
-}
diff --git a/clients/web-game/src/components/game_screen.rs b/clients/web-game/src/components/game_screen.rs
deleted file mode 100644
index 2493680..0000000
--- a/clients/web-game/src/components/game_screen.rs
+++ /dev/null
@@ -1,470 +0,0 @@
-use std::cell::Cell;
-use std::collections::VecDeque;
-
-use futures::channel::mpsc::UnboundedSender;
-use leptos::prelude::*;
-use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
-
-use super::die::Die;
-use crate::app::{GameUiState, NetCommand, PauseReason};
-use crate::i18n::*;
-use crate::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
-
-use super::board::Board;
-use super::score_panel::PlayerScorePanel;
-use super::scoring::ScoringPanel;
-
-#[component]
-pub fn GameScreen(state: GameUiState) -> impl IntoView {
- let i18n = use_i18n();
-
- let auth_username =
- use_context::>>().expect("auth_username not found in context");
- 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
- );
- let waiting_for_confirm = state.waiting_for_confirm;
- let pause_reason = state.pause_reason.clone();
-
- // ── 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 pending =
- use_context::>>().expect("pending not found in context");
- let cmd_tx_effect = cmd_tx.clone();
- // Non-reactive counter so we can detect when staged_moves grows without
- // returning a value from the Effect (which causes a Leptos reactive loop
- // when the Effect also writes to the same signal it reads).
- let prev_staged_len = Cell::new(0usize);
-
- Effect::new(move |_| {
- let moves = staged_moves.get();
- let n = moves.len();
- // Play checker sound whenever a move is added (own moves, immediate feedback).
- if n > prev_staged_len.get() {
- crate::sound::play_checker_move();
- }
- prev_staged_len.set(n);
- if n == 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);
- // Reset the counter so the next turn starts clean.
- prev_staged_len.set(0);
- }
- });
-
- // ── Auto-roll effect ─────────────────────────────────────────────────────
- // GameScreen is fully re-mounted on every ViewState update (state is a
- // plain prop, not a signal), so this effect fires exactly once per
- // RollDice phase entry and will not double-send.
- // Guard: suppressed while waiting_for_confirm — the AfterOpponentMove
- // buffered state shows the human's RollDice turn but the auto-roll must
- // wait until the buffer is drained and the live screen state is shown.
- // Guard: never auto-roll during the pre-game ceremony (the ceremony overlay
- // has its own Roll button for PlayerAction::PreGameRoll).
- let show_roll =
- is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll;
- if show_roll && !waiting_for_confirm {
- let cmd_tx_auto = cmd_tx.clone();
- Effect::new(move |_| {
- cmd_tx_auto
- .unbounded_send(NetCommand::Action(PlayerAction::Roll))
- .ok();
- });
- }
-
- let dice = vs.dice;
- let show_dice = dice != (0, 0);
-
- // ── Button senders ─────────────────────────────────────────────────────────
- 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();
- // Only show the fallback Go button when there is no ScoringPanel showing it.
- let show_hold_go = is_my_turn
- && vs.turn_stage == SerTurnStage::HoldOrGoChoice
- && state.my_scored_event.is_none();
-
- // ── 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();
-
- // ── Scores ─────────────────────────────────────────────────────────────────
- let my_score = vs.scores[player_id as usize].clone();
- let opp_score = vs.scores[1 - player_id as usize].clone();
-
- // ── Ceremony state (extracted before vs is moved into Board) ────────────────
- let is_ceremony = vs.stage == SerStage::PreGameRoll;
- let pre_game_roll_data: Option = vs.pre_game_roll.clone();
- let my_name_ceremony = my_score.name.clone();
- let opp_name_ceremony = opp_score.name.clone();
- let cmd_tx_ceremony = cmd_tx.clone();
-
- // ── Scoring notifications ──────────────────────────────────────────────────
- let my_scored_event = state.my_scored_event.clone();
- let opp_scored_event = state.opp_scored_event.clone();
- let hole_toast_info = my_scored_event
- .as_ref()
- .filter(|e| e.holes_gained > 0)
- .map(|e| (e.holes_total, e.bredouille));
-
- let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
-
- let last_moves = state.last_moves;
-
- // §6e — fields where a battue (hit) was scored; ripple animation shown there.
- let hit_fields: Vec = {
- let is_hit_jan = |jan: &Jan| {
- matches!(
- jan,
- Jan::TrueHitSmallJan
- | Jan::TrueHitBigJan
- | Jan::TrueHitOpponentCorner
- | Jan::FalseHitSmallJan
- | Jan::FalseHitBigJan
- )
- };
- let mut fields: Vec = vec![];
- for event_opt in [&my_scored_event, &opp_scored_event] {
- if let Some(event) = event_opt {
- for entry in &event.jans {
- if is_hit_jan(&entry.jan) {
- for (m1, m2) in &entry.moves {
- for m in [m1, m2] {
- let to = m.get_to() as u8;
- if to != 0 && !fields.contains(&to) {
- fields.push(to);
- }
- }
- }
- }
- }
- }
- }
- fields
- };
-
- // ── Sound effects (fire once on mount = once per state snapshot) ──────────
- // Dice roll: dice just appeared (no preceding moves in this snapshot).
- if show_dice && last_moves.is_none() {
- crate::sound::play_dice_roll();
- }
- // Checker move: moves were committed in the preceding action.
- if last_moves.is_some() {
- crate::sound::play_checker_move();
- }
- // Scoring: hole takes priority over plain points.
- if let Some(ref ev) = my_scored_event {
- if ev.holes_gained > 0 {
- crate::sound::play_hole_scored();
- } else {
- crate::sound::play_points_scored();
- }
- }
-
- // ── Capture for closures ───────────────────────────────────────────────────
- let stage = vs.stage.clone();
- let turn_stage = vs.turn_stage.clone();
- let turn_stage_for_panel = turn_stage.clone();
- let turn_stage_for_sub = 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 my_name_end = my_score.name.clone();
- let my_holes_end = my_score.holes;
- let opp_name_end = opp_score.name.clone();
- let opp_holes_end = opp_score.holes;
-
- 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())
- }}
-
- "EN"
- "FR"
-
-
- {move || auth_username.get().map(|u| view! {
-
"Playing as " {u}
- })}
-
-
{t!(i18n, quit)}
-
-
- // ── Opponent score (above board) ─────────────────────────────────
-
-
- // ── Status bar — full width, above board (§10b) ──────────────────
-
- {move || {
- if let Some(ref reason) = pause_reason {
- return String::from(match reason {
- PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
- PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
- PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
- PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
- });
- }
- 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, _, _) | (SerStage::PreGameRoll, _, _) => 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),
- })
- }
- }}
-
-
- // ── Contextual sub-prompt (§8a) ──────────────────────────────────
- {move || {
- let hint: String = if waiting_for_confirm {
- t_string!(i18n, hint_continue).to_owned()
- } else if is_move_stage {
- t_string!(i18n, hint_move).to_owned()
- } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
- t_string!(i18n, hint_hold_or_go).to_owned()
- } else {
- String::new()
- };
- (!hint.is_empty()).then(|| view! {
{hint}
})
- }}
-
- // ── Board + side panel ───────────────────────────────────────────
-
-
-
- // ── Side panel (scoring panels only) ─────────────────────────
-
- {my_scored_event.map(|event| view! {
-
- })}
- {opp_scored_event.map(|event| view! {
-
- })}
-
-
-
- // ── Action buttons below board (§10c) ────────────────────────────
-
- {waiting_for_confirm.then(|| view! {
- {t!(i18n, continue_btn)}
- })}
- // Fallback Go button when no scoring panel (e.g. after reconnect)
- {show_hold_go.then(|| view! {
- {t!(i18n, go)}
- })}
- {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! {
- {t!(i18n, empty_move)}
- })
- }}
-
-
- // ── Player score (below board) ────────────────────────────────────
-
-
- // ── Pre-game ceremony overlay ─────────────────────────────────────
- {is_ceremony.then(|| {
- let pgr = pre_game_roll_data.unwrap_or(PreGameRollState {
- host_die: None,
- guest_die: None,
- tie_count: 0,
- });
- let my_die = if player_id == 0 { pgr.host_die } else { pgr.guest_die };
- let opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die };
- let can_roll = my_die.is_none() && !waiting_for_confirm;
- let show_tie = pgr.tie_count > 0;
- view! {
-
-
-
{t!(i18n, pre_game_roll_title)}
- {show_tie.then(|| view! {
-
{t!(i18n, pre_game_roll_tie)}
- })}
-
-
- {my_name_ceremony}{t!(i18n, you_suffix)}
-
-
-
- {opp_name_ceremony}
-
-
-
- {waiting_for_confirm.then(|| {
- let pending_c = pending;
- view! {
-
{t!(i18n, continue_btn)}
- }
- })}
- {can_roll.then(|| {
- let cmd_tx_c = cmd_tx_ceremony.clone();
- view! {
-
{t!(i18n, pre_game_roll_btn)}
- }
- })}
-
-
- }
- })}
-
- // ── Game-over overlay ─────────────────────────────────────────────
- {stage_is_ended.then(|| {
- let opp_name_end_clone = opp_name_end.clone();
- let winner_text = move || if winner_is_me {
- t_string!(i18n, you_win).to_owned()
- } else {
- t_string!(i18n, opp_wins, name = opp_name_end_clone.as_str())
- };
- view! {
-
-
-
{t!(i18n, game_over)}
-
{winner_text}
-
- {my_name_end}
-
- {format!("{my_holes_end} — {opp_holes_end}")}
-
- {opp_name_end.clone()}
-
-
- {t!(i18n, quit)}
- {is_bot_game.then(|| view! {
- {t!(i18n, play_again)}
- })}
-
-
-
- }
- })}
-
- // ── Hole toast (§6a) — board-centered overlay when a hole is won ──
- {hole_toast_info.map(|(holes_total, bredouille)| view! {
-
-
"Trou !"
-
{format!("{holes_total} / 12")}
- {bredouille.then(|| view! {
-
"× 2 bredouille"
- })}
-
- })}
-
- }
-}
diff --git a/clients/web-game/src/components/login_screen.rs b/clients/web-game/src/components/login_screen.rs
deleted file mode 100644
index 1328b03..0000000
--- a/clients/web-game/src/components/login_screen.rs
+++ /dev/null
@@ -1,115 +0,0 @@
-use futures::channel::mpsc::UnboundedSender;
-use leptos::prelude::*;
-
-use crate::app::NetCommand;
-use crate::i18n::*;
-
-#[cfg(debug_assertions)]
-const PORTAL_URL: &str = "http://localhost:9092";
-#[cfg(not(debug_assertions))]
-const PORTAL_URL: &str = "/portal";
-
-#[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 auth_username =
- use_context::>>().expect("auth_username 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! {
-
- // ── Decorative board header ─────────────────────────────────────
-
-
- // ── Card body ──────────────────────────────────────────────────
-
-
-
-
"Trictrac"
-
- "Une interprétation numérique"
-
-
-
"✦"
-
- {error.map(|err| view! {
{err}
})}
-
- // Auth status badge
- {move || match auth_username.get() {
- Some(u) => view! {
-
"✓ Logged in as " {u}
- }.into_any(),
- None => view! {
-
- "Not logged in — games won't be tracked. "
- "Create account"
-
- }.into_any(),
- }}
-
-
-
-
-
- {t!(i18n, create_room)}
-
-
-
- {t!(i18n, join_room)}
-
-
-
- {t!(i18n, play_vs_bot)}
-
-
-
-
- }
-}
diff --git a/clients/web-game/src/components/mod.rs b/clients/web-game/src/components/mod.rs
deleted file mode 100644
index 7ae2fcb..0000000
--- a/clients/web-game/src/components/mod.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-mod board;
-mod connecting_screen;
-mod die;
-mod game_screen;
-mod login_screen;
-mod score_panel;
-mod scoring;
-
-pub use connecting_screen::ConnectingScreen;
-pub use game_screen::GameScreen;
-pub use login_screen::LoginScreen;
diff --git a/clients/web-game/src/components/score_panel.rs b/clients/web-game/src/components/score_panel.rs
deleted file mode 100644
index ab97a39..0000000
--- a/clients/web-game/src/components/score_panel.rs
+++ /dev/null
@@ -1,70 +0,0 @@
-use leptos::prelude::*;
-use trictrac_store::Jan;
-
-use crate::i18n::*;
-use crate::trictrac::types::PlayerScore;
-
-pub 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(),
- }
-}
-
-#[component]
-pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
- let i18n = use_i18n();
-
- let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
- let points_val = format!("{}/12", score.points);
- let holes = score.holes;
- let can_bredouille = score.can_bredouille;
-
- // 12 peg holes; filled up to `holes`
- let pegs: Vec = (1u8..=12)
- .map(|i| {
- let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" };
- view! {
}.into_any()
- })
- .collect();
-
- view! {
-
-
-
-
-
{t!(i18n, points_label)}
-
-
{points_val}
- {can_bredouille.then(|| view! {
-
"B"
- })}
-
-
-
{t!(i18n, holes_label)}
-
{pegs}
-
{format!("{holes}/12")}
-
-
-
- }
-}
diff --git a/clients/web-game/src/components/scoring.rs b/clients/web-game/src/components/scoring.rs
deleted file mode 100644
index 4a19a81..0000000
--- a/clients/web-game/src/components/scoring.rs
+++ /dev/null
@@ -1,209 +0,0 @@
-use futures::channel::mpsc::UnboundedSender;
-#[cfg(target_arch = "wasm32")]
-use gloo_timers::future::TimeoutFuture;
-use leptos::prelude::*;
-use trictrac_store::CheckerMove;
-#[cfg(target_arch = "wasm32")]
-use wasm_bindgen_futures::spawn_local;
-#[cfg(target_arch = "wasm32")]
-use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
-
-use crate::app::NetCommand;
-use crate::i18n::*;
-use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
-
-use super::score_panel::jan_label;
-
-/// One row in the scoring panel. Sets the hovered-moves context on enter
-/// (so board shows arrows for that jan's moves), but does NOT clear on
-/// leave — clearing is handled by the outer wrapper's mouseleave so that
-/// arrows persist while the pointer moves between rows.
-fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
- let i18n = use_i18n();
- let hovered = use_context::>>();
- let jan = entry.jan;
- let is_double = entry.is_double;
- let ways_tag = format!("×{}", entry.ways);
- let pts_str = format!("+{}", entry.total);
- let moves_hover = entry.moves.clone();
-
- view! {
-
- {move || jan_label(&jan)}
- {move || if is_double {
- t_string!(i18n, jan_double).to_owned()
- } else {
- t_string!(i18n, jan_simple).to_owned()
- }}
- {ways_tag}
- {pts_str}
-
- }
-}
-
-#[component]
-pub fn ScoringPanel(
- event: ScoredEvent,
- turn_stage: SerTurnStage,
- #[prop(default = false)] is_opponent: bool,
-) -> impl IntoView {
- let i18n = use_i18n();
- let cmd_tx = use_context::>()
- .expect("UnboundedSender not found in context");
-
- let points_earned = event.points_earned;
- let holes_gained = event.holes_gained;
- let holes_total = event.holes_total;
- let bredouille = event.bredouille;
- let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
- let panel_class = if is_opponent {
- "scoring-panel scoring-panel-opp"
- } else {
- "scoring-panel"
- };
-
- // ── Lifecycle signals ──────────────────────────────────────────────────
- // peeked: added after 3.4 s (slide to peek strip)
- // revealed: added on first hover of the peek strip (stay open permanently)
- let peeked = RwSignal::new(false);
- let revealed = RwSignal::new(false);
-
- // ── Collect all moves from all jans for automatic arrow display ────────
- let all_moves: Vec<(CheckerMove, CheckerMove)> = event
- .jans
- .iter()
- .flat_map(|e| e.moves.iter().cloned())
- .collect();
- let all_moves_click = all_moves.clone();
- let all_moves_enter = all_moves.clone();
-
- let hovered_ctx = use_context::>>();
-
- // On mount: show all this event's moves as board arrows immediately,
- // then after 3.4 s slide to peek and clear the arrows.
- //
- // Two important constraints:
- // 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
- // to avoid writing a reactive signal mid-render while Board reads it —
- // that triggers Leptos's cycle guard → `unreachable` WASM panic.
- // 2. The cancellation flag must be Rc>, NOT RwSignal.
- // RwSignal is a NodeId into Leptos's arena; the arena slot is freed
- // when ScoringPanel's owner drops (on every GameScreen remount). If the
- // 3.4 s future outlives the component and calls is_alive.get_untracked()
- // on a freed slot, that also panics with `unreachable`. Rc>
- // is reference-counted outside the arena and stays valid for as long as
- // the future holds onto it.
- #[cfg(target_arch = "wasm32")]
- if let Some(hm) = hovered_ctx {
- let is_alive = Arc::new(AtomicBool::new(true));
- let is_alive_cleanup = is_alive.clone();
- // on_cleanup requires Send + Sync; Arc satisfies both.
- on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
-
- spawn_local(async move {
- // Show arrows (runs in the next microtask, after render settles).
- hm.set(all_moves);
-
- TimeoutFuture::new(3_400).await;
- // Guard: component may have been destroyed while we were waiting.
- // is_alive was set to false by on_cleanup, which runs before Leptos
- // frees the signal arena slots — so peeked is still valid iff this
- // returns true.
- if !is_alive.load(Ordering::Relaxed) {
- return;
- }
- hm.set(vec![]);
- peeked.set(true);
- });
- }
-
- let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
-
- view! {
- // ── Outer wrapper: owns the slide / peek / reveal animation ───────
- // pointer-events are on by default (parent .side-panel sets none,
- // and .scoring-panel-wrapper overrides back to auto in CSS).
-
-
-
- {move || if is_opponent {
- t_string!(i18n, opp_scored_pts, n = points_earned)
- } else {
- t_string!(i18n, scored_pts, n = points_earned)
- }}
-
- {jan_rows}
- {(holes_gained > 0).then(|| view! {
-
- {move || if is_opponent {
- t_string!(i18n, opp_hole_made, holes = holes_total)
- } else {
- t_string!(i18n, hole_made, holes = holes_total)
- }}
- {bredouille.then(|| view! {
-
- {move || t_string!(i18n, bredouille_applied)}
-
- })}
-
- })}
- {show_hold_go.then(|| {
- let dismissed = RwSignal::new(false);
- view! {
-
- // stop_propagation so these buttons don't also toggle the panel
-
- {t!(i18n, hold)}
-
-
- {t!(i18n, go)}
-
-
- }
- })}
-
-
- }
-}
diff --git a/clients/web-game/src/main.rs b/clients/web-game/src/main.rs
deleted file mode 100644
index f0952a0..0000000
--- a/clients/web-game/src/main.rs
+++ /dev/null
@@ -1,13 +0,0 @@
-leptos_i18n::load_locales!();
-
-mod app;
-mod components;
-mod sound;
-mod trictrac;
-
-use app::App;
-use leptos::prelude::*;
-
-fn main() {
- mount_to_body(|| view! { })
-}
diff --git a/clients/web-game/src/sound.rs b/clients/web-game/src/sound.rs
deleted file mode 100644
index 5637ccd..0000000
--- a/clients/web-game/src/sound.rs
+++ /dev/null
@@ -1,182 +0,0 @@
-//! Synthesised sound effects using the Web Audio API.
-//!
-//! All public functions are no-ops on non-WASM targets so callers need no
-//! `#[cfg]` guards themselves.
-
-#[cfg(target_arch = "wasm32")]
-mod inner {
- use std::cell::RefCell;
- use web_sys::{AudioContext, OscillatorType};
-
- thread_local! {
- static CTX: RefCell> = const { RefCell::new(None) };
- }
-
- fn with_ctx(f: F) {
- CTX.with(|cell| {
- let mut opt = cell.borrow_mut();
- if opt.is_none() {
- *opt = AudioContext::new().ok();
- }
- if let Some(ctx) = opt.as_ref() {
- f(ctx);
- }
- });
- }
-
- /// Schedule a single oscillator tone with an exponential gain decay.
- ///
- /// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
- /// - `duration`: how long (in seconds) until gain reaches ~0
- fn play_tone(
- ctx: &AudioContext,
- freq: f32,
- gain: f32,
- duration: f64,
- start_offset: f64,
- wave: OscillatorType,
- ) {
- let t0 = ctx.current_time() + start_offset;
- let t1 = t0 + duration;
-
- let Ok(osc) = ctx.create_oscillator() else {
- return;
- };
- let Ok(gain_node) = ctx.create_gain() else {
- return;
- };
-
- osc.set_type(wave);
- osc.frequency().set_value(freq);
-
- let gain_param = gain_node.gain();
- let _ = gain_param.set_value_at_time(gain, t0);
- // exponential_ramp requires a positive target; 0.001 is inaudible
- let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
-
- let dest = ctx.destination();
- let _ = osc.connect_with_audio_node(&gain_node);
- let _ = gain_node.connect_with_audio_node(&dest);
-
- let _ = osc.start_with_when(t0);
- let _ = osc.stop_with_when(t1);
- }
-
- /// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
- pub fn play_checker_move() {
- with_ctx(|ctx| {
- // Sine at 300 Hz for the clean attack click
- play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
- // Triangle at 150 Hz for the woody body resonance
- play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
- // Sub at 80 Hz for weight
- play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
- });
- }
-
- /// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
- ///
- /// Two layers:
- /// - A dense series of detuned sawtooth bursts that thin out over time,
- /// modelling the continuous scrape/rattle of dice tumbling.
- /// - Five percussive impacts (square clicks + triangle thuds) whose
- /// inter-arrival gap shrinks as the dice decelerate and settle.
- pub fn play_dice_roll_cinematic() {
- with_ctx(|ctx| {
- // ── Continuous rolling texture ─────────────────────────────────
- // 16 steps over 440 ms; each step is two detuned sawtooth waves
- // (the interference between them produces a noise-like texture).
- // Gain fades by ~55 % from first to last step.
- const N: u32 = 16;
- for i in 0..N {
- let t = i as f64 * 0.028;
- let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
- // Quasi-random frequencies so each step sounds different.
- let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
- let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
- play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
- play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
- }
-
- // ── Impact transients ──────────────────────────────────────────
- // Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
- // mimicking dice decelerating and settling.
- let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
- for &(t_off, amp) in impacts {
- // Hard click: bright square partials → percussive attack
- for &freq in &[700.0f32, 1_050.0, 1_500.0] {
- play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
- }
- // Woody body thud: two low triangle partials
- play_tone(
- ctx,
- 130.0,
- amp * 0.05,
- 0.070,
- t_off,
- OscillatorType::Triangle,
- );
- play_tone(
- ctx,
- 68.0,
- amp * 0.07,
- 0.090,
- t_off,
- OscillatorType::Triangle,
- );
- }
- });
- }
-
- /// Play the pre-recorded dice-roll MP3 asset.
- pub fn play_dice_roll() {
- if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
- audio.set_volume(0.2);
- let _ = audio.play();
- }
- }
-
- /// Ascending three-note chime (C5 – E5 – G5).
- pub fn play_points_scored() {
- with_ctx(|ctx| {
- let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
- for (freq, offset) in notes {
- play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
- }
- });
- }
-
- /// Triumphant four-note fanfare (C5 – E5 – G5 – C6).
- pub fn play_hole_scored() {
- with_ctx(|ctx| {
- let notes: [(f32, f64, f64); 4] = [
- (523.25, 0.0, 0.35),
- (659.25, 0.17, 0.35),
- (783.99, 0.34, 0.35),
- (1046.5, 0.51, 0.55),
- ];
- for (freq, offset, dur) in notes {
- play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
- }
- });
- }
-}
-
-// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
-
-#[cfg(target_arch = "wasm32")]
-pub use inner::{
- play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
- play_points_scored,
-};
-
-#[cfg(not(target_arch = "wasm32"))]
-pub fn play_checker_move() {}
-#[cfg(not(target_arch = "wasm32"))]
-pub fn play_dice_roll() {}
-#[cfg(not(target_arch = "wasm32"))]
-pub fn play_dice_roll_cinematic() {}
-#[cfg(not(target_arch = "wasm32"))]
-pub fn play_points_scored() {}
-#[cfg(not(target_arch = "wasm32"))]
-pub fn play_hole_scored() {}
diff --git a/clients/web-game/src/trictrac/backend.rs b/clients/web-game/src/trictrac/backend.rs
deleted file mode 100644
index 04b2a36..0000000
--- a/clients/web-game/src/trictrac/backend.rs
+++ /dev/null
@@ -1,487 +0,0 @@
-use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
-use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
-
-use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
-
-// Store PlayerId (u64) values used for the two players.
-const HOST_PLAYER_ID: u64 = 1;
-const GUEST_PLAYER_ID: u64 = 2;
-
-pub struct TrictracBackend {
- game: GameState,
- dice_roller: DiceRoller,
- commands: Vec>,
- view_state: ViewState,
- /// Arrival flags: have host (index 0) and guest (index 1) joined?
- arrived: [bool; 2],
- /// Die rolled by each player during the ceremony ([host, guest]).
- pre_game_dice: [Option; 2],
- /// Number of tied rounds so far.
- tie_count: u8,
- /// True while the first-player ceremony is running.
- ceremony_started: bool,
-}
-
-impl TrictracBackend {
- fn sync_view_state(&mut self) {
- let mut vs = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
- if self.ceremony_started {
- vs.stage = SerStage::PreGameRoll;
- vs.pre_game_roll = Some(PreGameRollState {
- host_die: self.pre_game_dice[0],
- guest_die: self.pre_game_dice[1],
- tie_count: self.tie_count,
- });
- // Both players roll independently; no single "active" player.
- vs.active_mp_player = None;
- }
- self.view_state = vs;
- }
-
- fn broadcast_state(&mut self) {
- self.sync_view_state();
- let delta = GameDelta {
- state: self.view_state.clone(),
- };
- self.commands.push(BackendCommand::Delta(delta));
- }
-
- /// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest).
- fn handle_pre_game_roll(&mut self, mp_player: u16) {
- let idx = mp_player as usize;
- // Ignore if this player already rolled.
- if self.pre_game_dice[idx].is_some() {
- return;
- }
- let single = self.dice_roller.roll().values.0;
- self.pre_game_dice[idx] = Some(single);
-
- if let [Some(h), Some(g)] = self.pre_game_dice {
- // Both have rolled — broadcast both dice before resolving.
- self.broadcast_state();
- if h == g {
- // Tie: reset for another round.
- self.tie_count += 1;
- self.pre_game_dice = [None; 2];
- self.broadcast_state();
- } else {
- // Highest die goes first.
- let goes_first = if h > g {
- HOST_PLAYER_ID
- } else {
- GUEST_PLAYER_ID
- };
- self.ceremony_started = false;
- let _ = self.game.consume(&GameEvent::BeginGame { goes_first });
- // Use pre-game dice roll for the first move
- let _ = self.game.consume(&GameEvent::Roll {
- player_id: goes_first,
- });
- let _ = self.game.consume(&GameEvent::RollResult {
- player_id: goes_first,
- dice: Dice { values: (g, h) },
- });
- self.broadcast_state();
- }
- } else {
- // Only one die rolled so far — broadcast the partial result.
- self.broadcast_state();
- }
- }
-
- /// Roll dice using the store's DiceRoller and fire Roll + RollResult events.
- fn do_roll(&mut self) {
- let dice = self.dice_roller.roll();
- let player_id = self.game.active_player_id;
- let _ = self.game.consume(&GameEvent::Roll { player_id });
- let _ = self
- .game
- .consume(&GameEvent::RollResult { player_id, dice });
-
- // Drive automatic stages that require no player input.
- self.drive_automatic_stages();
- }
-
- /// Advance through stages that can be resolved without player input
- /// (MarkPoints, MarkAdvPoints).
- fn drive_automatic_stages(&mut self) {
- loop {
- // Stop if the game has already ended (stage transitions to Ended but
- // turn_stage may still be MarkPoints when schools_enabled=false, which
- // makes consume(Mark) a no-op and would cause an infinite loop).
- if self.game.stage == trictrac_store::Stage::Ended {
- break;
- }
- let player_id = self.game.active_player_id;
- match self.game.turn_stage {
- TurnStage::MarkPoints | TurnStage::MarkAdvPoints => {
- let _ = self.game.consume(&GameEvent::Mark {
- player_id,
- points: self.game.dice_points.0.max(self.game.dice_points.1),
- });
- }
- _ => break,
- }
- }
- }
-}
-
-impl TrictracBackend {
- pub fn get_game(&self) -> &GameState {
- &self.game
- }
-}
-
-impl BackEndArchitecture for TrictracBackend {
- fn new(_rule_variation: u16) -> Self {
- let mut game = GameState::new(false);
- game.init_player("Blancs");
- game.init_player("Noirs");
-
- let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
-
- TrictracBackend {
- game,
- dice_roller: DiceRoller::default(),
- commands: Vec::new(),
- view_state,
- arrived: [false; 2],
- pre_game_dice: [None; 2],
- tie_count: 0,
- ceremony_started: false,
- }
- }
-
- fn from_bytes(_rule_variation: u16, bytes: &[u8]) -> Option {
- let view_state: ViewState = serde_json::from_slice(bytes).ok()?;
- // Reconstruct a fresh game; full state restore is not yet implemented.
- let mut backend = Self::new(_rule_variation);
- backend.view_state = view_state;
- Some(backend)
- }
-
- fn player_arrival(&mut self, mp_player: u16) {
- if mp_player > 1 {
- self.commands
- .push(BackendCommand::KickPlayer { player: mp_player });
- return;
- }
- self.arrived[mp_player as usize] = true;
-
- // Cancel any reconnect timer for this player.
- self.commands.push(BackendCommand::CancelTimer {
- timer_id: mp_player,
- });
-
- // Start the ceremony once both players have arrived.
- if self.arrived[0]
- && self.arrived[1]
- && self.game.stage == trictrac_store::Stage::PreGame
- && !self.ceremony_started
- {
- self.ceremony_started = true;
- self.pre_game_dice = [None; 2];
- self.tie_count = 0;
- self.sync_view_state();
- self.commands.push(BackendCommand::ResetViewState);
- } else {
- self.broadcast_state();
- }
- }
-
- fn player_departure(&mut self, mp_player: u16) {
- if mp_player > 1 {
- return;
- }
- self.arrived[mp_player as usize] = false;
- // Give 60 seconds to reconnect before terminating the room.
- self.commands.push(BackendCommand::SetTimer {
- timer_id: mp_player,
- duration: 60.0,
- });
- }
-
- fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) {
- // During the first-player ceremony only PreGameRoll actions are accepted.
- if self.ceremony_started {
- if matches!(action, PlayerAction::PreGameRoll) {
- self.handle_pre_game_roll(mp_player);
- }
- return;
- }
-
- if self.game.stage == trictrac_store::Stage::Ended {
- return;
- }
-
- let store_id = if mp_player == 0 {
- HOST_PLAYER_ID
- } else {
- GUEST_PLAYER_ID
- };
-
- // Only the active player may act (except during Chance-like waiting stages).
- if self.game.active_player_id != store_id {
- return;
- }
-
- match action {
- PlayerAction::Roll => {
- if self.game.turn_stage == TurnStage::RollDice {
- self.do_roll();
- }
- }
- PlayerAction::Move(m1, m2) => {
- if self.game.turn_stage != TurnStage::Move
- && self.game.turn_stage != TurnStage::HoldOrGoChoice
- {
- return;
- }
- let event = GameEvent::Move {
- player_id: store_id,
- moves: (m1, m2),
- };
- if self.game.validate(&event) {
- let _ = self.game.consume(&event);
- self.drive_automatic_stages();
- }
- }
- PlayerAction::Go => {
- if self.game.turn_stage == TurnStage::HoldOrGoChoice {
- let _ = self.game.consume(&GameEvent::Go {
- player_id: store_id,
- });
- }
- }
- PlayerAction::Mark => {
- if matches!(
- self.game.turn_stage,
- TurnStage::MarkPoints | TurnStage::MarkAdvPoints
- ) {
- self.drive_automatic_stages();
- }
- }
- PlayerAction::PreGameRoll => {} // ignored outside ceremony
- }
-
- self.broadcast_state();
- }
-
- fn timer_triggered(&mut self, timer_id: u16) {
- match timer_id {
- 0 | 1 => {
- // Reconnect grace period expired for host (0) or guest (1).
- self.commands.push(BackendCommand::TerminateRoom);
- }
- _ => {}
- }
- }
-
- fn get_view_state(&self) -> &ViewState {
- &self.view_state
- }
-
- fn drain_commands(&mut self) -> Vec> {
- std::mem::take(&mut self.commands)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::trictrac::types::{SerStage, SerTurnStage};
- use backbone_lib::traits::BackEndArchitecture;
-
- fn make_backend() -> TrictracBackend {
- TrictracBackend::new(0)
- }
-
- /// Helper: drain and return only Delta commands, extracting their ViewStates.
- fn drain_deltas(b: &mut TrictracBackend) -> Vec {
- b.drain_commands()
- .into_iter()
- .filter_map(|cmd| match cmd {
- BackendCommand::Delta(d) => Some(d.state),
- BackendCommand::ResetViewState => Some(b.view_state.clone()),
- _ => None,
- })
- .collect()
- }
-
- /// Drive the ceremony to completion (both players roll until one wins).
- fn complete_ceremony(b: &mut TrictracBackend) {
- loop {
- if b.get_view_state().stage != SerStage::PreGameRoll {
- break;
- }
- let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default();
- let host_needs = pgr.host_die.is_none();
- let guest_needs = pgr.guest_die.is_none();
- if !host_needs && !guest_needs {
- break; // both rolled but stage not yet resolved — shouldn't happen
- }
- if host_needs {
- b.inform_rpc(0, PlayerAction::PreGameRoll);
- }
- if guest_needs {
- b.inform_rpc(1, PlayerAction::PreGameRoll);
- }
- b.drain_commands();
- }
- }
-
- #[test]
- fn both_players_arrive_starts_ceremony() {
- let mut b = make_backend();
- b.player_arrival(0); // host
- b.drain_commands();
- b.player_arrival(1); // guest
- let cmds = b.drain_commands();
-
- // ResetViewState should have been issued to start the ceremony.
- let has_reset = cmds
- .iter()
- .any(|c| matches!(c, BackendCommand::ResetViewState));
- assert!(
- has_reset,
- "expected ResetViewState after both players arrive"
- );
-
- // Stage should now be PreGameRoll, not InGame.
- assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll);
- }
-
- #[test]
- fn ceremony_resolves_to_in_game() {
- let mut b = make_backend();
- b.player_arrival(0);
- b.player_arrival(1);
- b.drain_commands();
-
- complete_ceremony(&mut b);
-
- assert_eq!(b.get_view_state().stage, SerStage::InGame);
- }
-
- #[test]
- fn ceremony_any_order_allowed() {
- let mut b = make_backend();
- b.player_arrival(0);
- b.player_arrival(1);
- b.drain_commands();
-
- // Guest may roll before host.
- b.inform_rpc(1, PlayerAction::PreGameRoll);
- let states = drain_deltas(&mut b);
- assert!(
- !states.is_empty(),
- "guest PreGameRoll should broadcast a state"
- );
- let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap();
- assert!(
- pgr.guest_die.is_some(),
- "guest die should be set after guest rolls"
- );
- assert!(pgr.host_die.is_none(), "host die should still be blank");
- }
-
- #[test]
- fn unknown_player_kicked() {
- let mut b = make_backend();
- b.player_arrival(99);
- let cmds = b.drain_commands();
- assert!(cmds
- .iter()
- .any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 })));
- }
-
- #[test]
- fn roll_advances_to_move_or_hold() {
- let mut b = make_backend();
- b.player_arrival(0);
- b.player_arrival(1);
- b.drain_commands();
-
- // Complete ceremony before rolling.
- complete_ceremony(&mut b);
-
- // Roll for whoever won the ceremony (either player could go first).
- let first_player = b
- .get_view_state()
- .active_mp_player
- .expect("someone should be active");
- b.inform_rpc(first_player, PlayerAction::Roll);
- let states = drain_deltas(&mut b);
- assert!(!states.is_empty(), "expected a state broadcast after roll");
-
- let last = states.last().unwrap();
- assert!(
- matches!(
- last.turn_stage,
- SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
- ),
- "expected Move or HoldOrGoChoice after roll, got {:?}",
- last.turn_stage
- );
- assert_eq!(last.dice, b.get_view_state().dice);
- assert!(last.dice.0 >= 1 && last.dice.0 <= 6);
- assert!(last.dice.1 >= 1 && last.dice.1 <= 6);
- }
-
- #[test]
- fn wrong_player_roll_ignored() {
- let mut b = make_backend();
- b.player_arrival(0);
- b.player_arrival(1);
- b.drain_commands();
- complete_ceremony(&mut b);
-
- // Identify who goes first and have the OTHER player try to roll.
- let active = b.get_view_state().active_mp_player;
- let wrong_player = if active == Some(0) { 1u16 } else { 0u16 };
- b.inform_rpc(wrong_player, PlayerAction::Roll);
- let cmds = b.drain_commands();
- assert!(cmds.is_empty(), "wrong player roll should be ignored");
- }
-
- #[test]
- fn departure_sets_reconnect_timer() {
- let mut b = make_backend();
- b.player_arrival(0);
- b.drain_commands();
- b.player_departure(0);
- let cmds = b.drain_commands();
- assert!(
- cmds.iter()
- .any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })),
- "expected reconnect timer after host departure"
- );
- }
-
- #[test]
- fn timer_triggers_terminate_room() {
- let mut b = make_backend();
- b.timer_triggered(0);
- let cmds = b.drain_commands();
- assert!(cmds
- .iter()
- .any(|c| matches!(c, BackendCommand::TerminateRoom)));
- }
-}
-
-// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
-
-#[cfg(target_arch = "wasm32")]
-mod inner {
- use web_sys::console;
-
- pub fn console_log(message: String) {
- console::log_1(&message.into());
- }
-}
-
-#[cfg(target_arch = "wasm32")]
-pub use inner::console_log;
-
-#[cfg(not(target_arch = "wasm32"))]
-pub fn console_log(message: String) {}
diff --git a/clients/web-game/src/trictrac/bot_local.rs b/clients/web-game/src/trictrac/bot_local.rs
deleted file mode 100644
index f94bfc9..0000000
--- a/clients/web-game/src/trictrac/bot_local.rs
+++ /dev/null
@@ -1,43 +0,0 @@
-use rand::prelude::IndexedRandom;
-use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
-
-use crate::trictrac::types::{PlayerAction, PreGameRollState};
-
-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.
-/// `pgr` is the current pre-game ceremony state if the ceremony is in progress.
-pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option {
- // During the ceremony, the bot (guest) rolls when its die is missing.
- if game.stage == Stage::PreGame {
- if let Some(pgr) = pgr {
- if pgr.guest_die.is_none() {
- return Some(PlayerAction::PreGameRoll);
- }
- }
- return None;
- }
- 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 | TurnStage::HoldOrGoChoice => {
- 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/web-game/src/trictrac/mod.rs b/clients/web-game/src/trictrac/mod.rs
deleted file mode 100644
index 38d05bb..0000000
--- a/clients/web-game/src/trictrac/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub mod backend;
-pub mod bot_local;
-pub mod types;
diff --git a/clients/web-game/src/trictrac/types.rs b/clients/web-game/src/trictrac/types.rs
deleted file mode 100644
index 3c0dfe2..0000000
--- a/clients/web-game/src/trictrac/types.rs
+++ /dev/null
@@ -1,256 +0,0 @@
-use serde::{Deserialize, Serialize};
-use trictrac_store::{CheckerMove, GameState, Jan, Stage, TurnStage};
-
-// ── Actions sent by a player to the host backend ─────────────────────────────
-
-#[derive(Clone, Serialize, Deserialize)]
-pub enum PlayerAction {
- /// Active player requests a dice roll.
- Roll,
- /// Both checker moves for this turn. Use `EMPTY_MOVE` (from=0, to=0) when a die
- /// has no valid move.
- Move(CheckerMove, CheckerMove),
- /// Choose to "go" (advance) during HoldOrGoChoice.
- Go,
- /// Acknowledge point marking (hold / advance points).
- Mark,
- /// Roll a single die during the pre-game ceremony to decide who goes first.
- PreGameRoll,
-}
-
-// ── Incremental state update broadcast to all clients ────────────────────────
-
-/// Carries a full state snapshot; `apply_delta` replaces the local state.
-/// Simple and correct; can be refined to true diffs later.
-#[derive(Clone, Serialize, Deserialize)]
-pub struct GameDelta {
- pub state: ViewState,
-}
-
-// ── Full game snapshot ────────────────────────────────────────────────────────
-
-/// State of the pre-game ceremony where each player rolls one die to decide
-/// who goes first. Present only when `stage == SerStage::PreGameRoll`.
-#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
-pub struct PreGameRollState {
- /// Die value (1–6) rolled by the host; `None` = not yet rolled this round.
- pub host_die: Option,
- /// Die value (1–6) rolled by the guest; `None` = not yet rolled this round.
- pub guest_die: Option,
- /// Number of tied rounds so far (0 on the first round).
- pub tie_count: u8,
-}
-
-#[derive(Clone, PartialEq, Serialize, Deserialize)]
-pub struct ViewState {
- /// Board positions: index i = field i+1. Positive = white, negative = black.
- pub board: [i8; 24],
- pub stage: SerStage,
- pub turn_stage: SerTurnStage,
- /// Which multiplayer player_id (0 = host, 1 = guest) is the active player.
- pub active_mp_player: Option,
- /// Scores indexed by multiplayer player_id (0 = host, 1 = guest).
- pub scores: [PlayerScore; 2],
- /// Last rolled dice values.
- pub dice: (u8, u8),
- /// Jans (scoring events) triggered by the last dice roll.
- pub dice_jans: Vec,
- /// Last two checker moves played; default when no move has occurred yet.
- pub dice_moves: (CheckerMove, CheckerMove),
- /// Present while the pre-game ceremony is in progress.
- #[serde(default)]
- pub pre_game_roll: Option,
-}
-
-/// One scoring event from a dice roll.
-#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
-pub struct JanEntry {
- pub jan: Jan,
- /// True when the dice are doubles (both same value) — changes the point value.
- /// Special case for HelplessMan: true when *both* dice are unplayable.
- pub is_double: bool,
- /// Number of distinct move pairs that produce this jan.
- pub ways: usize,
- /// Points per way (negative = scored against the active player).
- pub points_per: i8,
- /// Total = points_per × ways.
- pub total: i8,
- /// The move pairs that produce this jan (for move display).
- pub moves: Vec<(CheckerMove, CheckerMove)>,
-}
-
-impl ViewState {
- pub fn default_with_names(host_name: &str, guest_name: &str) -> Self {
- ViewState {
- board: [0i8; 24],
- stage: SerStage::PreGame,
- turn_stage: SerTurnStage::RollDice,
- active_mp_player: None,
- scores: [
- PlayerScore {
- name: host_name.to_string(),
- points: 0,
- holes: 0,
- can_bredouille: false,
- },
- PlayerScore {
- name: guest_name.to_string(),
- points: 0,
- holes: 0,
- can_bredouille: false,
- },
- ],
- dice: (0, 0),
- dice_jans: Vec::new(),
- dice_moves: (CheckerMove::default(), CheckerMove::default()),
- pre_game_roll: None,
- }
- }
-
- pub fn apply_delta(&mut self, delta: &GameDelta) {
- *self = delta.state.clone();
- }
-
- /// Convert a store `GameState` to a `ViewState`.
- /// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned
- /// to the host (mp player 0) and guest (mp player 1) respectively.
- pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self {
- let board_vec = gs.board.to_vec();
- let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields");
-
- let stage = match gs.stage {
- Stage::PreGame => SerStage::PreGame,
- Stage::InGame => SerStage::InGame,
- Stage::Ended => SerStage::Ended,
- };
- let turn_stage = match gs.turn_stage {
- TurnStage::RollDice => SerTurnStage::RollDice,
- TurnStage::RollWaiting => SerTurnStage::RollWaiting,
- TurnStage::MarkPoints => SerTurnStage::MarkPoints,
- TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
- TurnStage::Move => SerTurnStage::Move,
- TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
- };
-
- let active_mp_player = if gs.active_player_id == host_store_id {
- Some(0)
- } else if gs.active_player_id == guest_store_id {
- Some(1)
- } else {
- None
- };
-
- let score_for = |store_id: u64| -> PlayerScore {
- gs.players
- .get(&store_id)
- .map(|p| PlayerScore {
- name: p.name.clone(),
- points: p.points,
- holes: p.holes,
- can_bredouille: p.can_bredouille,
- })
- .unwrap_or_else(|| PlayerScore {
- name: String::new(),
- points: 0,
- holes: 0,
- can_bredouille: false,
- })
- };
-
- // is_double for scoring: dice show the same value (both dice identical).
- // Exception: HelplessMan uses a special rule (see below).
- let dice_are_double = gs.dice.values.0 == gs.dice.values.1;
-
- // Build JanEntry list from the PossibleJans map.
- let empty_move = CheckerMove::new(0, 0).unwrap_or_default();
- let mut dice_jans: Vec = gs
- .dice_jans
- .iter()
- .map(|(jan, moves)| {
- // HelplessMan: is_double = true only when *both* dice are unplayable
- // (the moves list contains a single (empty, empty) sentinel).
- let is_double = if *jan == Jan::HelplessMan {
- moves
- .first()
- .map(|&(m1, m2)| m1 == empty_move && m2 == empty_move)
- .unwrap_or(false)
- } else {
- dice_are_double
- };
- let points_per = jan.get_points(is_double);
- let ways = moves.len();
- let total = points_per.saturating_mul(ways as i8);
- JanEntry {
- jan: jan.clone(),
- is_double,
- ways,
- points_per,
- total,
- moves: moves.clone(),
- }
- })
- .collect();
- // Sort: highest total first, most-negative last.
- dice_jans.sort_by_key(|e| std::cmp::Reverse(e.total));
-
- ViewState {
- board,
- stage,
- turn_stage,
- active_mp_player,
- scores: [score_for(host_store_id), score_for(guest_store_id)],
- dice: (gs.dice.values.0, gs.dice.values.1),
- dice_jans,
- dice_moves: gs.dice_moves,
- pre_game_roll: None,
- }
- }
-}
-
-// ── Scored event (notification) ──────────────────────────────────────────
-
-/// Points scored in a single scoring event, used for the notification panel.
-#[derive(Clone, PartialEq)]
-pub struct ScoredEvent {
- /// Raw points earned (sum of jan values; before hole wrapping).
- pub points_earned: u8,
- /// Number of holes gained (0 = no hole).
- pub holes_gained: u8,
- /// Total holes after this event.
- pub holes_total: u8,
- /// Was bredouille active when the hole was made (doubles hole count)?
- pub bredouille: bool,
- /// Contributing jans from this player's perspective (totals always positive).
- pub jans: Vec,
-}
-
-// ── Score snapshot ────────────────────────────────────────────────────────────
-
-#[derive(Clone, PartialEq, Serialize, Deserialize)]
-pub struct PlayerScore {
- pub name: String,
- pub points: u8,
- pub holes: u8,
- pub can_bredouille: bool,
-}
-
-// ── Serialisable mirrors of store enums ──────────────────────────────────────
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub enum SerStage {
- PreGame,
- /// Both players have arrived; ceremony in progress to decide who goes first.
- PreGameRoll,
- InGame,
- Ended,
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub enum SerTurnStage {
- RollDice,
- RollWaiting,
- MarkPoints,
- HoldOrGoChoice,
- Move,
- MarkAdvPoints,
-}
diff --git a/clients/web-user-portal/Cargo.toml b/clients/web-user-portal/Cargo.toml
deleted file mode 100644
index 6afa767..0000000
--- a/clients/web-user-portal/Cargo.toml
+++ /dev/null
@@ -1,17 +0,0 @@
-[package]
-name = "web-user-portal"
-version = "0.1.0"
-edition = "2024"
-
-[dependencies]
-leptos = { version = "0.7", features = ["csr"] }
-leptos_router = { version = "0.7" }
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1"
-
-[target.'cfg(target_arch = "wasm32")'.dependencies]
-wasm-bindgen = "0.2"
-wasm-bindgen-futures = "0.4"
-gloo-net = { version = "0.5", features = ["http"] }
-js-sys = "0.3"
-web-sys = { version = "0.3", features = ["RequestCredentials"] }
diff --git a/clients/web-user-portal/Trunk.toml b/clients/web-user-portal/Trunk.toml
deleted file mode 100644
index 57a2aaa..0000000
--- a/clients/web-user-portal/Trunk.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[serve]
-port = 9092
diff --git a/clients/web-user-portal/assets/style.css b/clients/web-user-portal/assets/style.css
deleted file mode 100644
index 3e7462a..0000000
--- a/clients/web-user-portal/assets/style.css
+++ /dev/null
@@ -1,103 +0,0 @@
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-body {
- font-family: system-ui, sans-serif;
- background: #f5f5f5;
- color: #1a1a1a;
- min-height: 100vh;
-}
-
-nav {
- background: #1a1a2e;
- color: #fff;
- padding: 0.75rem 1.5rem;
- display: flex;
- align-items: center;
- gap: 1.5rem;
-}
-nav a { color: #ccc; text-decoration: none; }
-nav a:hover { color: #fff; }
-nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; }
-nav .spacer { flex: 1; }
-
-main { max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
-
-h1 { font-size: 1.6rem; margin-bottom: 1rem; }
-h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
-
-.card {
- background: #fff;
- border-radius: 8px;
- padding: 1.5rem;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
- margin-bottom: 1.5rem;
-}
-
-.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; }
-.tab-btn {
- padding: 0.5rem 1.25rem;
- border: 1px solid #ddd;
- background: #f5f5f5;
- cursor: pointer;
- font-size: 0.95rem;
-}
-.tab-btn:first-child { border-radius: 6px 0 0 6px; }
-.tab-btn:last-child { border-radius: 0 6px 6px 0; border-left: none; }
-.tab-btn.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
-
-label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: #555; }
-input[type=text], input[type=email], input[type=password] {
- width: 100%;
- padding: 0.5rem 0.75rem;
- border: 1px solid #ddd;
- border-radius: 5px;
- font-size: 0.95rem;
- margin-bottom: 0.75rem;
-}
-input:focus { outline: none; border-color: #1a1a2e; }
-
-button[type=submit], .btn {
- padding: 0.5rem 1.25rem;
- background: #1a1a2e;
- color: #fff;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- font-size: 0.95rem;
-}
-button[type=submit]:hover, .btn:hover { background: #2d2d5e; }
-button[type=submit]:disabled { opacity: 0.6; cursor: not-allowed; }
-
-.error { color: #c0392b; font-size: 0.875rem; margin-top: 0.5rem; }
-.success { color: #27ae60; font-size: 0.875rem; margin-top: 0.5rem; }
-
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 1rem;
- margin-bottom: 1.5rem;
-}
-.stat-box {
- background: #fff;
- border-radius: 8px;
- padding: 1rem;
- text-align: center;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
-}
-.stat-box .value { font-size: 2rem; font-weight: 700; }
-.stat-box .label { font-size: 0.8rem; color: #777; margin-top: 0.25rem; }
-
-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
-th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #eee; color: #555; }
-td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
-tr:last-child td { border-bottom: none; }
-tr:hover td { background: #fafafa; }
-a { color: #2c5cc5; text-decoration: none; }
-a:hover { text-decoration: underline; }
-
-.outcome-win { color: #27ae60; font-weight: 600; }
-.outcome-loss { color: #c0392b; font-weight: 600; }
-.outcome-draw { color: #e67e22; font-weight: 600; }
-
-.loading { color: #777; padding: 1rem 0; }
-.empty { color: #aaa; font-style: italic; padding: 1rem 0; }
diff --git a/clients/web-user-portal/index.html b/clients/web-user-portal/index.html
deleted file mode 100644
index 135091c..0000000
--- a/clients/web-user-portal/index.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
- Player Portal
-
-
-
-
-
diff --git a/clients/web-user-portal/src/api.rs b/clients/web-user-portal/src/api.rs
deleted file mode 100644
index b6dced9..0000000
--- a/clients/web-user-portal/src/api.rs
+++ /dev/null
@@ -1,191 +0,0 @@
-use serde::{Deserialize, Serialize};
-
-// In debug builds trunk serves on 9092 while the relay is on 8080 — use full URL.
-// In release builds the portal is served by the relay itself — use relative paths.
-#[cfg(debug_assertions)]
-const BASE: &str = "http://localhost:8080";
-#[cfg(not(debug_assertions))]
-const BASE: &str = "";
-
-fn url(path: &str) -> String {
- format!("{BASE}{path}")
-}
-
-// ── Response types ────────────────────────────────────────────────────────────
-
-#[derive(Clone, Debug, Deserialize)]
-pub struct MeResponse {
- pub id: i64,
- pub username: String,
-}
-
-#[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,
-}
-
-// ── 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 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()))
- }
-}
-
-// ── Utilities ─────────────────────────────────────────────────────────────────
-
-pub fn format_ts(ts: i64) -> String {
- let ms = (ts * 1000) as f64;
- let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
- date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED)
- .as_string()
- .unwrap_or_default()
-}
diff --git a/clients/web-user-portal/src/app.rs b/clients/web-user-portal/src/app.rs
deleted file mode 100644
index 92a121a..0000000
--- a/clients/web-user-portal/src/app.rs
+++ /dev/null
@@ -1,67 +0,0 @@
-use leptos::prelude::*;
-use leptos_router::{components::{Route, Router, Routes, A}, path};
-
-use crate::api::{self, MeResponse};
-use crate::pages::{home::HomePage, profile::ProfilePage, game::GamePage};
-
-#[derive(Clone, Debug)]
-pub struct AuthState {
- pub user: RwSignal>,
-}
-
-#[component]
-pub fn App() -> impl IntoView {
- let user = RwSignal::new(None::);
- provide_context(AuthState { user });
-
- // Probe session on load.
- let auth = use_context::().unwrap();
- let _ = LocalResource::new(move || async move {
- if let Ok(me) = api::get_me().await {
- auth.user.set(Some(me));
- }
- });
-
- view! {
-
-
-
- "Page not found." | |
}>
-
-
-
-
-
-
- }
-}
-
-#[component]
-fn Nav() -> impl IntoView {
- let auth = use_context::().unwrap();
-
- let logout = move |_| {
- wasm_bindgen_futures::spawn_local(async move {
- let _ = api::post_logout().await;
- auth.user.set(None);
- });
- };
-
- view! {
-
- "Player Portal"
-
- {move || match auth.user.get() {
- Some(u) => view! {
-
- { u.username.clone() }
-
-
- "Logout"
-
- }.into_any(),
- None => view! { "Login" }.into_any(),
- }}
-
- }
-}
diff --git a/clients/web-user-portal/src/main.rs b/clients/web-user-portal/src/main.rs
deleted file mode 100644
index 64a2eb2..0000000
--- a/clients/web-user-portal/src/main.rs
+++ /dev/null
@@ -1,7 +0,0 @@
-mod api;
-mod app;
-mod pages;
-
-fn main() {
- leptos::mount::mount_to_body(app::App);
-}
diff --git a/clients/web-user-portal/src/pages/game.rs b/clients/web-user-portal/src/pages/game.rs
deleted file mode 100644
index 226be3e..0000000
--- a/clients/web-user-portal/src/pages/game.rs
+++ /dev/null
@@ -1,95 +0,0 @@
-use leptos::prelude::*;
-use leptos_router::{components::A, hooks::use_params_map};
-
-use crate::api::{self, GameDetail, Participant};
-
-#[component]
-pub fn GamePage() -> impl IntoView {
- let params = use_params_map();
- let id_str = move || params.read().get("id").unwrap_or_default();
-
- let detail = LocalResource::new(move || {
- let s = id_str();
- async move {
- let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?;
- api::get_game_detail(id).await
- }
- });
-
- view! {
-
- {move || match detail.get().map(|sw| sw.take()) {
- None => view! {
"Loading…"
}.into_any(),
- Some(Err(e)) => view! {
{ e }
}.into_any(),
- Some(Ok(g)) => view! {
}.into_any(),
- }}
-
- }
-}
-
-#[component]
-fn GameDetailView(game: GameDetail) -> impl IntoView {
- let started = api::format_ts(game.started_at);
- let ended = game.ended_at.map(api::format_ts).unwrap_or_else(|| "ongoing".into());
-
- view! {
-
-
"Game " { game.room_code.clone() }
-
- "Started: " { started.clone() } " · Ended: " { ended }
-
-
-
"Players"
-
-
-
- "Player"
- "Username"
- "Outcome"
-
-
-
- {game.participants.iter().map(|p| {
- view! { }
- }).collect_view()}
-
-
-
- {game.result.as_ref().map(|r| view! {
-
-
"Result data"
-
- { r.clone() }
-
-
- })}
-
- }
-}
-
-#[component]
-fn ParticipantRow(participant: Participant) -> impl IntoView {
- let outcome_class = match participant.outcome.as_deref() {
- Some("win") => "outcome-win",
- Some("loss") => "outcome-loss",
- Some("draw") => "outcome-draw",
- _ => "",
- };
- let outcome_text = participant.outcome.clone().unwrap_or_else(|| "—".into());
- let name = participant.username.clone();
-
- view! {
-
- "Player " { participant.player_id }
-
- {match name {
- Some(u) => view! {
- { u }
- }.into_any(),
- None => view! { "anonymous" }.into_any(),
- }}
-
- { outcome_text }
-
- }
-}
diff --git a/clients/web-user-portal/src/pages/home.rs b/clients/web-user-portal/src/pages/home.rs
deleted file mode 100644
index e7dd14f..0000000
--- a/clients/web-user-portal/src/pages/home.rs
+++ /dev/null
@@ -1,152 +0,0 @@
-use leptos::prelude::*;
-use leptos_router::hooks::use_navigate;
-
-use crate::api;
-use crate::app::AuthState;
-
-#[component]
-pub fn HomePage() -> impl IntoView {
- let auth = use_context::().unwrap();
- let navigate = use_navigate();
-
- // Redirect to own profile when already logged in.
- Effect::new(move |_| {
- if let Some(u) = auth.user.get() {
- navigate(&format!("/profile/{}", u.username), Default::default());
- }
- });
-
- let tab = RwSignal::new("login");
-
- view! {
-
-
- "Login"
- "Register"
-
- {move || if tab.get() == "login" {
- view! {
}.into_any()
- } else {
- view! {
}.into_any()
- }}
-
- }
-}
-
-#[component]
-fn LoginForm() -> impl IntoView {
- let auth = use_context::().unwrap();
- let navigate = use_navigate();
-
- let username = RwSignal::new(String::new());
- let password = RwSignal::new(String::new());
- let error = RwSignal::new(String::new());
- let pending = RwSignal::new(false);
-
- let submit = move |ev: leptos::ev::SubmitEvent| {
- ev.prevent_default();
- if pending.get() { return; }
- pending.set(true);
- error.set(String::new());
- let u = username.get();
- let p = password.get();
- let navigate = navigate.clone();
- wasm_bindgen_futures::spawn_local(async move {
- match api::post_login(&u, &p).await {
- Ok(me) => {
- let dest = format!("/profile/{}", me.username);
- auth.user.set(Some(me));
- navigate(&dest, Default::default());
- }
- Err(e) => {
- error.set(e);
- pending.set(false);
- }
- }
- });
- };
-
- view! {
-
- }
-}
-
-#[component]
-fn RegisterForm() -> impl IntoView {
- let auth = use_context::().unwrap();
- let navigate = use_navigate();
-
- let username = RwSignal::new(String::new());
- let email = RwSignal::new(String::new());
- let password = RwSignal::new(String::new());
- let error = RwSignal::new(String::new());
- let pending = RwSignal::new(false);
-
- let submit = move |ev: leptos::ev::SubmitEvent| {
- ev.prevent_default();
- if pending.get() { return; }
- pending.set(true);
- error.set(String::new());
- let u = username.get();
- let e = email.get();
- let p = password.get();
- let navigate = navigate.clone();
- wasm_bindgen_futures::spawn_local(async move {
- match api::post_register(&u, &e, &p).await {
- Ok(me) => {
- let dest = format!("/profile/{}", me.username);
- auth.user.set(Some(me));
- navigate(&dest, Default::default());
- }
- Err(err) => {
- error.set(err);
- pending.set(false);
- }
- }
- });
- };
-
- view! {
-
- }
-}
diff --git a/clients/web-user-portal/src/pages/mod.rs b/clients/web-user-portal/src/pages/mod.rs
deleted file mode 100644
index e4a454b..0000000
--- a/clients/web-user-portal/src/pages/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub mod game;
-pub mod home;
-pub mod profile;
diff --git a/clients/web-user-portal/src/pages/profile.rs b/clients/web-user-portal/src/pages/profile.rs
deleted file mode 100644
index f93059a..0000000
--- a/clients/web-user-portal/src/pages/profile.rs
+++ /dev/null
@@ -1,137 +0,0 @@
-use leptos::prelude::*;
-use leptos_router::{components::A, hooks::use_params_map};
-
-use crate::api::{self, GameSummary, UserProfile};
-
-#[component]
-pub fn ProfilePage() -> impl IntoView {
- let params = use_params_map();
- let username = move || params.read().get("username").unwrap_or_default();
-
- let profile = LocalResource::new(move || {
- let u = username();
- async move { api::get_user_profile(&u).await }
- });
-
- view! {
-
- {move || match profile.get().map(|sw| sw.take()) {
- None => view! {
"Loading…"
}.into_any(),
- Some(Err(e)) => view! {
{ e }
}.into_any(),
- Some(Ok(p)) => view! {
}.into_any(),
- }}
-
- }
-}
-
-#[component]
-fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
- let page = RwSignal::new(0i64);
- let games = LocalResource::new(move || {
- let u = username.clone();
- let p = page.get();
- async move { api::get_user_games(&u, p).await }
- });
-
- let joined = crate::api::format_ts(profile.created_at);
-
- view! {
- { profile.username.clone() }
- "Joined: " { joined }
-
-
-
-
{ profile.total_games }
-
"Games"
-
-
-
{ profile.wins }
-
"Wins"
-
-
-
{ profile.losses }
-
"Losses"
-
-
-
{ profile.draws }
-
"Draws"
-
-
-
-
-
"Game History"
- {move || match games.get().map(|sw| sw.take()) {
- None => view! {
"Loading…"
}.into_any(),
- Some(Err(e)) => view! {
{ e }
}.into_any(),
- Some(Ok(r)) => {
- if r.games.is_empty() {
- view! {
"No games recorded yet."
}.into_any()
- } else {
- view! {
}.into_any()
- }
- }
- }}
-
- }
-}
-
-#[component]
-fn GamesTable(games: Vec, page: RwSignal) -> impl IntoView {
- let rows = games.clone();
- let has_next = games.len() == 20;
-
- view! {
-
-
-
- "Room"
- "Started"
- "Ended"
- "Outcome"
- "Detail"
-
-
-
- {rows.into_iter().map(|g| {
- let started = crate::api::format_ts(g.started_at);
- let ended = g.ended_at.map(crate::api::format_ts).unwrap_or_else(|| "—".into());
- let outcome_class = match g.outcome.as_deref() {
- Some("win") => "outcome-win",
- Some("loss") => "outcome-loss",
- Some("draw") => "outcome-draw",
- _ => "",
- };
- let outcome_text = g.outcome.clone().unwrap_or_else(|| "—".into());
- view! {
-
- { g.room_code.clone() }
- { started }
- { ended }
- { outcome_text }
-
- "View"
-
-
- }
- }).collect_view()}
-
-
-
- {move || if page.get() > 0 {
- view! {
- "← Prev"
- }.into_any()
- } else {
- view! { }.into_any()
- }}
- "Page " { move || page.get() + 1 }
- {if has_next {
- view! {
- "Next →"
- }.into_any()
- } else {
- view! { }.into_any()
- }}
-
- }
-}
From e61448b6278db6221449a72350ef721bb3a00dd7 Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 2 May 2026 11:26:55 +0200
Subject: [PATCH 2/7] feat(web client): local heuristic bot
---
clients/web/src/game/trictrac/bot_local.rs | 63 +++++++++++++++++++---
1 file changed, 56 insertions(+), 7 deletions(-)
diff --git a/clients/web/src/game/trictrac/bot_local.rs b/clients/web/src/game/trictrac/bot_local.rs
index 5543b07..6161fe2 100644
--- a/clients/web/src/game/trictrac/bot_local.rs
+++ b/clients/web/src/game/trictrac/bot_local.rs
@@ -1,5 +1,4 @@
-use rand::prelude::IndexedRandom;
-use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
+use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
use super::types::{PlayerAction, PreGameRollState};
@@ -29,15 +28,65 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option {
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.
+ let (m1, m2) = sequences
+ .iter()
+ .max_by(|(m1a, m2a), (m1b, m2b)| {
+ score_seq(&game.board, m1a, m2a)
+ .partial_cmp(&score_seq(&game.board, m1b, m2b))
+ .unwrap_or(std::cmp::Ordering::Equal)
+ })
+ .cloned()
+ .unwrap_or((CheckerMove::default(), CheckerMove::default()));
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
}
_ => None,
}
}
+
+/// Score a candidate move sequence from the bot's (Black) perspective.
+/// `m1` and `m2` are in mirrored (White) space, as returned by MoveRules for Color::Black.
+fn score_seq(board: &Board, m1: &CheckerMove, m2: &CheckerMove) -> f32 {
+ let mut b = board.mirror();
+ let _ = b.move_checker(&Color::White, *m1);
+ let _ = b.move_checker(&Color::White, *m2);
+ evaluate(&b)
+}
+
+/// Evaluate a board position from White's perspective (call after mirroring for Black).
+fn evaluate(board: &Board) -> f32 {
+ let mut score = 0.0f32;
+
+ let white_fields = board.get_color_fields(Color::White);
+ let black_fields = board.get_color_fields(Color::Black);
+
+ // Quarter fill progress — quarters 1-6, 7-12, 19-24.
+ // Quarter 13-18 is skipped: field 13 is the opponent's rest corner so White can never fill it.
+ for &q in &[1usize, 7, 19] {
+ if board.is_quarter_filled(Color::White, q) {
+ score += 8.0;
+ } else {
+ let missing = board.get_quarter_filling_candidate(Color::White);
+ score += (6 - missing.len().min(6)) as f32 * 0.3;
+ }
+ }
+
+ // Singleton exposure: penalise a White singleton at field f only when there is at least
+ // one Black checker at a field g > f (opponent can potentially threaten it).
+ let max_black_field = black_fields.iter().map(|(f, _)| *f).max().unwrap_or(0);
+ for (f, count) in &white_fields {
+ if *count == 1 && *f < max_black_field {
+ score -= 0.5;
+ }
+ }
+
+ // Exit zone progress: reward checkers already in fields 19-24.
+ for (field, count) in &white_fields {
+ if *field >= 19 {
+ score += count.abs() as f32 * 0.3;
+ }
+ }
+
+ score
+}
From 5328b8e5aad23ed918ca67dbd72a3213df860698 Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 2 May 2026 12:10:02 +0200
Subject: [PATCH 3/7] fix(web client): show jans arrows
---
.../web/src/game/components/game_screen.rs | 4 +-
clients/web/src/game/components/scoring.rs | 40 ++++++++++++++++++-
2 files changed, 40 insertions(+), 4 deletions(-)
diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs
index 525b7c5..0f0a209 100644
--- a/clients/web/src/game/components/game_screen.rs
+++ b/clients/web/src/game/components/game_screen.rs
@@ -176,7 +176,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let last_moves = state.last_moves;
- // §6e — fields where a battue (hit) was scored; ripple animation shown there.
+ // fields where a battue (hit) was scored; ripple animation shown there.
let hit_fields: Vec = {
let is_hit_jan = |jan: &Jan| {
matches!(
@@ -337,7 +337,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
hit_fields=hit_fields
/>
- // ── Status, hints, and actions — cream strip below board (§10b/c) ─
+ // ── Status, hints, and actions — cream strip below board ─
{move || {
diff --git a/clients/web/src/game/components/scoring.rs b/clients/web/src/game/components/scoring.rs
index 79fd433..a3f939b 100644
--- a/clients/web/src/game/components/scoring.rs
+++ b/clients/web/src/game/components/scoring.rs
@@ -80,8 +80,7 @@ pub fn ScoringPanel(
"scoring-panel"
};
- // minimized: starts false (expanded), becomes true after 3.4 s unless
- // the Hold/Go choice still needs the player's attention.
+ // minimized: starts false (expanded)
let minimized = RwSignal::new(false);
// Collect all moves from all jans for automatic arrow display.
@@ -97,6 +96,43 @@ pub fn ScoringPanel(
let hovered_ctx = use_context::
>>();
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
+ // On mount: show all this event's moves as board arrows immediately,
+ // then after 3.4 s slide to peek and clear the arrows.
+ //
+ // Two important constraints:
+ // 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
+ // to avoid writing a reactive signal mid-render while Board reads it —
+ // that triggers Leptos's cycle guard → `unreachable` WASM panic.
+ // 2. The cancellation flag must be Rc>, NOT RwSignal.
+ // RwSignal is a NodeId into Leptos's arena; the arena slot is freed
+ // when ScoringPanel's owner drops (on every GameScreen remount). If the
+ // 3.4 s future outlives the component and calls is_alive.get_untracked()
+ // on a freed slot, that also panics with `unreachable`. Rc>
+ // is reference-counted outside the arena and stays valid for as long as
+ // the future holds onto it.
+ #[cfg(target_arch = "wasm32")]
+ if let Some(hm) = hovered_ctx {
+ let is_alive = Arc::new(AtomicBool::new(true));
+ let is_alive_cleanup = is_alive.clone();
+ // on_cleanup requires Send + Sync; Arc satisfies both.
+ on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
+
+ spawn_local(async move {
+ // Show arrows (runs in the next microtask, after render settles).
+ hm.set(all_moves);
+
+ TimeoutFuture::new(3_400).await;
+ // Guard: component may have been destroyed while we were waiting.
+ // is_alive was set to false by on_cleanup, which runs before Leptos
+ // frees the signal arena slots — so peeked is still valid iff this
+ // returns true.
+ if !is_alive.load(Ordering::Relaxed) {
+ return;
+ }
+ hm.set(vec![]);
+ });
+ }
+
view! {
Date: Sat, 2 May 2026 12:20:06 +0200
Subject: [PATCH 4/7] fix(web client): points arrow coloring
---
clients/web/assets/style.css | 29 +++++++++++++++++------------
1 file changed, 17 insertions(+), 12 deletions(-)
diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css
index 5d440dc..8031d70 100644
--- a/clients/web/assets/style.css
+++ b/clients/web/assets/style.css
@@ -7,6 +7,10 @@
--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;
@@ -22,6 +26,7 @@
--font-ui: 'Jost', system-ui, sans-serif;
}
+
/* ── Reset & base ──────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -1408,24 +1413,24 @@ a:hover { text-decoration: underline; }
/* ── 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: #1a4f72; }
+.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: #e5eadc; }
+.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); }
-.board-quarter .field.zone-petit.point-nobredouille:nth-child(odd),
-.board-quarter .field.zone-grand.point-nobredouille:nth-child(odd) { --fc: #6a2810; }
-.board-quarter .field.zone-petit.point-nobredouille:nth-child(even),
-.board-quarter .field.zone-grand.point-nobredouille:nth-child(even) { --fc: #f2dfa0; }
+.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: #1a4f72; }
+.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: #e5eadc; }
+.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); }
-.board-quarter .field.zone-opponent.point-nobredouille:nth-child(odd),
-.board-quarter .field.zone-retour.point-nobredouille:nth-child(odd) { --fc: #6a2810; }
-.board-quarter .field.zone-opponent.point-nobredouille:nth-child(even),
-.board-quarter .field.zone-retour.point-nobredouille:nth-child(even) { --fc: #f2dfa0; }
+.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: '♛';
From 60f33750eb88ad98c30f04e2e67ee0fed6490ae0 Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 2 May 2026 20:01:30 +0200
Subject: [PATCH 5/7] feat(web client): opponent sounds
---
.../web/src/game/components/game_screen.rs | 10 ++++
clients/web/src/game/sound.rs | 57 ++++++++++++++++++-
2 files changed, 65 insertions(+), 2 deletions(-)
diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs
index 0f0a209..2a1d761 100644
--- a/clients/web/src/game/components/game_screen.rs
+++ b/clients/web/src/game/components/game_screen.rs
@@ -224,6 +224,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
crate::game::sound::play_hole_scored();
}
}
+ if let Some(ref ev) = opp_scored_event {
+ if ev.holes_gained > 0 {
+ crate::game::sound::play_opp_hole_scored();
+ }
+ }
// ── Capture for closures ───────────────────────────────────────────────────
let stage = vs.stage.clone();
@@ -473,6 +478,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Game-over overlay ─────────────────────────────────────────────
{stage_is_ended.then(|| {
+ if winner_is_me {
+ crate::game::sound::play_victory();
+ } else {
+ crate::game::sound::play_defeat();
+ }
let opp_name_end_clone = opp_name_end.clone();
let winner_text = move || if winner_is_me {
t_string!(i18n, you_win).to_owned()
diff --git a/clients/web/src/game/sound.rs b/clients/web/src/game/sound.rs
index 532e94b..600aa0d 100644
--- a/clients/web/src/game/sound.rs
+++ b/clients/web/src/game/sound.rs
@@ -176,14 +176,61 @@ mod inner {
}
});
}
+
+ /// Brief descending minor phrase when the opponent scores a hole.
+ pub fn play_opp_hole_scored() {
+ with_ctx(|ctx| {
+ let notes: [(f32, f64, f64); 3] = [
+ (392.00, 0.00, 0.32), // G4
+ (349.23, 0.20, 0.32), // F4
+ (293.66, 0.40, 0.50), // D4
+ ];
+ for (freq, offset, dur) in notes {
+ play_tone(ctx, freq, 0.10, dur, offset, OscillatorType::Sine);
+ }
+ });
+ }
+
+ /// Victory fanfare: five-note ascending major (C5–E5–G5–C6–E6).
+ pub fn play_victory() {
+ with_ctx(|ctx| {
+ let notes: [(f32, f64, f64, f32); 5] = [
+ (523.25, 0.00, 0.32, 0.18), // C5
+ (659.25, 0.20, 0.32, 0.20), // E5
+ (783.99, 0.40, 0.32, 0.22), // G5
+ (1046.5, 0.60, 0.50, 0.25), // C6
+ (1318.5, 0.88, 0.80, 0.28), // E6
+ ];
+ for (freq, offset, dur, gain) in notes {
+ play_tone(ctx, freq, gain, dur, offset, OscillatorType::Sine);
+ play_tone(ctx, freq * 2.0, gain * 0.12, dur, offset, OscillatorType::Sine);
+ }
+ });
+ }
+
+ /// Defeat phrase: descending minor (E5–Eb5–D5–C5).
+ pub fn play_defeat() {
+ with_ctx(|ctx| {
+ let notes: [(f32, f64, f64); 4] = [
+ (659.25, 0.00, 0.45), // E5
+ (622.25, 0.35, 0.45), // Eb5
+ (587.33, 0.70, 0.45), // D5
+ (523.25, 1.05, 0.80), // C5
+ ];
+ for (freq, offset, dur) in notes {
+ play_tone(ctx, freq, 0.14, dur, offset, OscillatorType::Sine);
+ play_tone(ctx, freq / 2.0, 0.06, dur, offset, OscillatorType::Triangle);
+ }
+ });
+ }
}
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
#[cfg(target_arch = "wasm32")]
pub use inner::{
- play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
- play_opp_points_tick, play_points_scored, play_points_tick,
+ play_checker_move, play_defeat, play_dice_roll, play_dice_roll_cinematic, play_hole_scored,
+ play_opp_hole_scored, play_opp_points_tick, play_points_scored, play_points_tick, play_victory,
};
#[cfg(not(target_arch = "wasm32"))]
@@ -200,3 +247,9 @@ pub fn play_points_tick() {}
pub fn play_opp_points_tick() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_hole_scored() {}
+#[cfg(not(target_arch = "wasm32"))]
+pub fn play_opp_hole_scored() {}
+#[cfg(not(target_arch = "wasm32"))]
+pub fn play_victory() {}
+#[cfg(not(target_arch = "wasm32"))]
+pub fn play_defeat() {}
From 0e6edc371032875d2dc2f2f75ea7328516fb5f55 Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 2 May 2026 20:02:47 +0200
Subject: [PATCH 6/7] fix(web client): exit dice used
---
Cargo.lock | 50 ++++++++++++++++++++++++
clients/web/Cargo.toml | 3 ++
clients/web/src/game/components/board.rs | 22 ++++++++++-
clients/web/src/game/trictrac/backend.rs | 4 +-
justfile | 28 ++-----------
store/src/game_rules_moves.rs | 35 ++++++++---------
6 files changed, 95 insertions(+), 47 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 517e14f..07b7830 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5339,6 +5339,16 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minicov"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
+dependencies = [
+ "cc",
+ "walkdir",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -8707,6 +8717,7 @@ dependencies = [
"trictrac-store",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-bindgen-test",
"web-sys",
]
@@ -9158,6 +9169,45 @@ 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"
diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml
index 71af23b..f184a28 100644
--- a/clients/web/Cargo.toml
+++ b/clients/web/Cargo.toml
@@ -43,3 +43,6 @@ web-sys = { version = "0.3", features = [
"Navigator",
"Location",
] }
+
+[dev-dependencies]
+wasm-bindgen-test = "0.3"
diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs
index b058ed0..7590f25 100644
--- a/clients/web/src/game/components/board.rs
+++ b/clients/web/src/game/components/board.rs
@@ -43,7 +43,13 @@ fn bar_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 {
+ let dist = if to == 0 {
+ if from > 18 {
+ (25 as u8).saturating_sub(from)
+ } else {
+ from.saturating_sub(0)
+ }
+ } else if from < to {
to.saturating_sub(from)
} else {
from.saturating_sub(to)
@@ -52,7 +58,7 @@ fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
d0 = true;
} else if !d1 && dist == dice.1 {
d1 = true;
- } else if !d0 {
+ } else if !d0 && dist <= dice.0 && dice.0 <= dice.1 {
d0 = true;
} else {
d1 = true;
@@ -677,3 +683,15 @@ pub fn Board(
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use wasm_bindgen_test::wasm_bindgen_test;
+
+ #[wasm_bindgen_test]
+ fn test_bar_matched_dice_used() {
+ assert_eq!((true, false), bar_matched_dice_used(&[(22, 24)], (2, 3)));
+ assert_eq!((false, true), bar_matched_dice_used(&[(22, 0)], (2, 3)));
+ }
+}
diff --git a/clients/web/src/game/trictrac/backend.rs b/clients/web/src/game/trictrac/backend.rs
index ca28204..3581057 100644
--- a/clients/web/src/game/trictrac/backend.rs
+++ b/clients/web/src/game/trictrac/backend.rs
@@ -1,7 +1,7 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
-use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
+use super::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, SerTurnStage, ViewState};
// Store PlayerId (u64) values used for the two players.
const HOST_PLAYER_ID: u64 = 1;
@@ -289,7 +289,7 @@ impl BackEndArchitecture for TrictracBackend
#[cfg(test)]
mod tests {
use super::*;
- use super::types::{SerStage, SerTurnStage};
+ use super::{SerStage, SerTurnStage};
use backbone_lib::traits::BackEndArchitecture;
fn make_backend() -> TrictracBackend {
diff --git a/justfile b/justfile
index 507fe00..b308095 100644
--- a/justfile
+++ b/justfile
@@ -13,6 +13,9 @@ runcli:
dev:
trunk serve
+test-web:
+ wasm-pack test --node clients/web
+
[working-directory: 'clients/web']
build:
trunk build --release
@@ -25,31 +28,6 @@ build:
run-relay:
./relay-server
-# Legacy targets kept for reference during transition
-[working-directory: 'clients/web-game']
-dev-game:
- trunk serve
-
-[working-directory: 'clients/web-game']
-build-game:
- trunk build --release
- cp dist/index.html ../../deploy/trictrac.html
- cp dist/*.wasm ../../deploy/
- cp dist/*.js ../../deploy/
- cp dist/*.css ../../deploy/
-
-[working-directory: 'clients/web-user-portal']
-dev-portal:
- trunk serve
-
-[working-directory: 'clients/web-user-portal']
-build-portal:
- trunk build --release
- cp dist/index.html ../../deploy/portal.html
- cp dist/*.wasm ../../deploy/
- cp dist/*.js ../../deploy/
- cp dist/*.css ../../deploy/
-
build-relay:
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
mkdir -p deploy
diff --git a/store/src/game_rules_moves.rs b/store/src/game_rules_moves.rs
index 660acb7..e5af72b 100644
--- a/store/src/game_rules_moves.rs
+++ b/store/src/game_rules_moves.rs
@@ -260,8 +260,7 @@ impl MoveRules {
// 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();
+ 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();
@@ -756,6 +755,8 @@ impl MoveRules {
#[cfg(test)]
mod tests {
+ use anyhow::Ok;
+
use super::*;
#[test]
@@ -887,6 +888,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,
@@ -1489,22 +1504,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,
From acfcd505d30b4488ddc30a9c68881e07f5852ac2 Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 2 May 2026 20:34:51 +0200
Subject: [PATCH 7/7] =?UTF-8?q?fix(store):=E2=80=AFcheck=20color=20on=20op?=
=?UTF-8?q?ponent=20corner=20hit?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
store/src/game_rules_points.rs | 38 ++++++++++++++++++++++------------
1 file changed, 25 insertions(+), 13 deletions(-)
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(
| |