Compare commits
730
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eaaab876e | ||
|
|
52d932002b | ||
|
|
6524ed3bf2 | ||
|
|
0faa7923b0 | ||
|
|
3e0fa2a8a2 | ||
|
|
9cb65a21f5 | ||
|
|
37acaeb9d2 | ||
|
|
8b577f2487 | ||
|
|
bf73c41ae0 | ||
|
|
f667383565 | ||
|
|
420943b890 | ||
|
|
9cc659602c | ||
|
|
40c9651b80 | ||
|
|
6e26ceb216 | ||
|
|
2fe97ddd1f | ||
|
|
478b38f5de | ||
|
|
83ccd71821 | ||
|
|
d8d2e7207b | ||
|
|
9081249574 | ||
|
|
b89c225d5b | ||
|
|
1eeeb32566 | ||
|
|
34aa64cdef | ||
|
|
624f95dde4 | ||
|
|
4798a3579b | ||
|
|
ea3b21f92c | ||
|
|
9c6b2d884b | ||
|
|
40d567a053 | ||
|
|
8083598cef | ||
|
|
50c6201d33 | ||
|
|
6719245f40 | ||
|
|
4d769af8c7 | ||
|
|
1ba942138d | ||
|
|
0e9209531a | ||
|
|
e529cc2d7a | ||
|
|
77376c39d6 | ||
|
|
cea310b030 | ||
|
|
e896570f07 | ||
|
|
8fb9f59106 | ||
|
|
02695aea7c | ||
|
|
71009d73e3 | ||
|
|
91ce096818 | ||
|
|
6bf165a6aa | ||
|
|
7dfdf8d593 | ||
|
|
0406fc1869 | ||
|
|
d0e4c177e4 | ||
|
|
1f03ae673b | ||
|
|
850227c26d | ||
|
|
dd3c199e2e | ||
|
|
ea910e9c82 | ||
|
|
26be6fd8ee | ||
|
|
7d71647a90 | ||
|
|
bb193d8f8c | ||
|
|
974b7fd99f | ||
|
|
b64b732e50 | ||
|
|
ee3e1a0ab2 | ||
|
|
7d26929abd | ||
|
|
3edebecc89 | ||
|
|
6da6ff7f83 | ||
|
|
e133a5fe2b | ||
|
|
86c51b4aa7 | ||
|
|
c3f37a574d | ||
|
|
910e1f9b40 | ||
|
|
c6b4a165bd | ||
|
|
4dc2df2513 | ||
|
|
a244aeb5f8 | ||
|
|
b00d720e40 | ||
|
|
aa6f77372f | ||
|
|
235c2eb03f | ||
|
|
24255905ed | ||
|
|
70ef142577 | ||
|
|
fcd93ca252 | ||
|
|
a3af6b755d | ||
|
|
3c62c284a7 | ||
|
|
812657bb35 | ||
|
|
533c651b07 | ||
|
|
1f92affea7 | ||
|
|
0902289592 | ||
|
|
69bbb09dcf | ||
|
|
4e55ab9e86 | ||
|
|
31b3c1ab87 | ||
|
|
c8889e94e2 | ||
|
|
ac4302a8ed | ||
|
|
b750a3d3eb | ||
|
|
f1bef950e0 | ||
|
|
e9154a90f1 | ||
|
|
f96183f350 | ||
|
|
1059cc1682 | ||
|
|
536d334543 | ||
|
|
8006e53580 | ||
|
|
56b3aeab68 | ||
|
|
813a2bacbe | ||
|
|
fbdf11c789 | ||
|
|
a45d81aa34 | ||
|
|
a07c6d8e50 | ||
|
|
6d50829c0c | ||
|
|
69a9330810 | ||
|
|
cd1f726593 | ||
|
|
21f95bdd5b | ||
|
|
92c68ec4f6 | ||
|
|
2cd2a60a36 | ||
|
|
72ff9db5e8 | ||
|
|
6a31315c95 | ||
|
|
8e16542784 | ||
|
|
298c8245a6 | ||
|
|
4690200f59 | ||
|
|
b64c657c1d | ||
|
|
3061dc5004 | ||
|
|
090d764f6f | ||
|
|
65a1936b94 | ||
|
|
2916b4d48f | ||
|
|
474674f7fe | ||
|
|
c3576b2deb | ||
|
|
f50e436506 | ||
|
|
813a19bb1d | ||
|
|
e5c7e338e8 | ||
|
|
b620cea873 | ||
|
|
53b37adf9f | ||
|
|
1bb7a99836 | ||
|
|
b5fb6aeb64 | ||
|
|
f0ebdf9c87 | ||
|
|
2e4aba9630 | ||
|
|
97edc3eac8 | ||
|
|
7f999f4573 | ||
|
|
11e50e7f49 | ||
|
|
933a9c1379 | ||
|
|
e41abdaf2e | ||
|
|
57a59c1663 | ||
|
|
28df687bf9 | ||
|
|
e1596dbcee | ||
|
|
dff0c77a5b | ||
|
|
54787c7f7b | ||
|
|
84fdd404c9 | ||
|
|
e530d267a3 | ||
|
|
c56d9c85ef | ||
|
|
ff2826b980 | ||
|
|
6a0fba860d | ||
|
|
b02839ce2a | ||
|
|
4c4bebe85e | ||
|
|
3e959504ef | ||
|
|
3abde581db | ||
|
|
38738eabac | ||
|
|
b81823bbec | ||
|
|
00741a37d0 | ||
|
|
fd38c51ffd | ||
|
|
fd0b2f0fa5 | ||
|
|
e88f871385 | ||
|
|
09a81a9b65 | ||
|
|
c93b693137 | ||
|
|
f548039cd8 | ||
|
|
f76c8dfb93 | ||
|
|
1a79fd3131 | ||
|
|
921b4fe1e3 | ||
|
|
0d72a33ee5 | ||
|
|
8dbec05844 | ||
|
|
d4e82c74b1 | ||
|
|
13398aa76e | ||
|
|
812f77e348 | ||
|
|
05330ca7c9 | ||
|
|
0d979e4f8b | ||
|
|
957d5a172f | ||
|
|
6b2dfb84a2 | ||
|
|
b63a60da64 | ||
|
|
d524642b65 | ||
|
|
0e3c155876 | ||
|
|
c81e00e6dd | ||
|
|
f5ae61f382 | ||
|
|
a76effb932 | ||
|
|
5627512d16 | ||
|
|
335b4ce638 | ||
|
|
7fc21113e3 | ||
|
|
24653799cf | ||
|
|
bd6e5bbf7f | ||
|
|
4ab20a2838 | ||
|
|
09eb857583 | ||
|
|
396f9e0ccb | ||
|
|
7f9daa10fa | ||
|
|
0a98a92f4b | ||
|
|
68a7870e2e | ||
|
|
82370d906f | ||
|
|
011b6aa096 | ||
|
|
fe6c41ba40 | ||
|
|
ec549028ba | ||
|
|
4af90e547c | ||
|
|
90c50736f2 | ||
|
|
f6e6671ac5 | ||
|
|
2c3dde51d4 | ||
|
|
4ee51917bc | ||
|
|
8e2f2c6a95 | ||
|
|
2ea3c58f68 | ||
|
|
34dccdfd99 | ||
|
|
cab6b41c67 | ||
|
|
18bf5d4423 | ||
|
|
0c848bec74 | ||
|
|
725672364d | ||
|
|
78bcf22bca | ||
|
|
d5c5342d2d | ||
|
|
8053e1fb26 | ||
|
|
99d442fc68 | ||
|
|
617a6593a9 | ||
|
|
81fbbca309 | ||
|
|
92f2e8125b | ||
|
|
27959b4655 | ||
|
|
06b0b354d0 | ||
|
|
40c31a46e4 | ||
|
|
56d55a1565 | ||
|
|
12e3a448c6 | ||
|
|
3a01c8f1c0 | ||
|
|
b3ea9f9c97 | ||
|
|
4d09b6bffa | ||
|
|
d018a506e1 | ||
|
|
30c066726b | ||
|
|
2431370abb | ||
|
|
70c95f2686 | ||
|
|
0d868a3838 | ||
|
|
8e6602633d | ||
|
|
3bf7d26e80 | ||
|
|
cf268243f3 | ||
|
|
724d9dce1e | ||
|
|
ab69f97cfa | ||
|
|
33523d1926 | ||
|
|
96de962622 | ||
|
|
61c269123c | ||
|
|
ce676f0e54 | ||
|
|
5bcf5bab48 | ||
|
|
052737d0f9 | ||
|
|
86608f7ea9 | ||
|
|
724eabf99c | ||
|
|
2ae2b984c0 | ||
|
|
0d8481e821 | ||
|
|
b2a20226d7 | ||
|
|
c999054ae0 | ||
|
|
767bace774 | ||
|
|
438a5692a6 | ||
|
|
abf0305ddf | ||
|
|
d66b594770 | ||
|
|
9959f1b347 | ||
|
|
9bce22abf5 | ||
|
|
0b3413fcd8 | ||
|
|
d83db397c0 | ||
|
|
035be51915 | ||
|
|
d0f52c7bae | ||
|
|
3aca64412f | ||
|
|
1a505525a8 | ||
|
|
6ea691255a | ||
|
|
eff668b202 | ||
|
|
61828150b9 | ||
|
|
660b0a0c25 | ||
|
|
0e3d6a250e | ||
|
|
d63efccb55 | ||
|
|
52832dc0fd | ||
|
|
659e2a4a9a | ||
|
|
4b2f4f3fa3 | ||
|
|
7b9938219d | ||
|
|
047011e243 | ||
|
|
19e554666e | ||
|
|
49992d322f | ||
|
|
c311ed4251 | ||
|
|
f995b6f4cd | ||
|
|
49586efd2f | ||
|
|
f0b2ac82d9 | ||
|
|
b5a53da712 | ||
|
|
edbcbe28de | ||
|
|
b4a556d5d3 | ||
|
|
1f3e71fe03 | ||
|
|
152039be5b | ||
|
|
16f654071a | ||
|
|
6d30e7c5e1 | ||
|
|
f9e204c301 | ||
|
|
59aa56af2b | ||
|
|
c95e294890 | ||
|
|
6fabe6aa62 | ||
|
|
26553dd175 | ||
|
|
ae2d0371ce | ||
|
|
2939ffa900 | ||
|
|
377e9c5160 | ||
|
|
6c83148ead | ||
|
|
2d89ddb6e1 | ||
|
|
84200ebc74 | ||
|
|
cb13a4d674 | ||
|
|
561152d58e | ||
|
|
7fe84ffbbd | ||
|
|
884a09fa92 | ||
|
|
b82441c834 | ||
|
|
72e77d7f4d | ||
|
|
6ccfaa1b4d | ||
|
|
677ad7aa3c | ||
|
|
b4157aaa9b | ||
|
|
bb9d292c4c | ||
|
|
8793e23d6b | ||
|
|
8422cf1f2d | ||
|
|
999edb67e6 | ||
|
|
97c2b67650 | ||
|
|
178251ad35 | ||
|
|
716304bb9e | ||
|
|
f3eb25c17a | ||
|
|
8d5c8da6c4 | ||
|
|
ce716f3438 | ||
|
|
a7cc25ae29 | ||
|
|
366c967f8a | ||
|
|
c466107452 | ||
|
|
e70cd48172 | ||
|
|
7891517a9b | ||
|
|
06342ad34c | ||
|
|
60d1bd3b59 | ||
|
|
c265f0638e | ||
|
|
bc37cd38fe | ||
|
|
01985af36b | ||
|
|
b9330d489e | ||
|
|
17adffa6a7 | ||
|
|
fde44baab5 | ||
|
|
2ad947af54 | ||
|
|
7182502b16 | ||
|
|
7ef3377218 | ||
|
|
210aa04811 | ||
|
|
b8ad2f011f | ||
|
|
dea9b20f9b | ||
|
|
2dbbe8859e | ||
|
|
5961c75a29 | ||
|
|
5a32c1ac85 | ||
|
|
40a5e51229 | ||
|
|
3ed9856578 | ||
|
|
628f59b73a | ||
|
|
63298be1e1 | ||
|
|
d6d71c7c02 | ||
|
|
cda9338f13 | ||
|
|
f5b7ebc8da | ||
|
|
ccdcfe2c4d | ||
|
|
4a67e8c7be | ||
|
|
4313983a5c | ||
|
|
c64c08af89 | ||
|
|
05c4c8c7fb | ||
|
|
643add3f85 | ||
|
|
5509abf1e5 | ||
|
|
24c38ebccd | ||
|
|
0dfdeb9578 | ||
|
|
a58b8bc92c | ||
|
|
f50dda6078 | ||
|
|
842fd72761 | ||
|
|
be0644c0e9 | ||
|
|
feb2b03251 | ||
|
|
5d13374f7e | ||
|
|
32b139281c | ||
|
|
634ed293af | ||
|
|
7ef00e0a65 | ||
|
|
2c6bb02ccb | ||
|
|
f268a36a11 | ||
|
|
5902286db5 | ||
|
|
f587374923 | ||
|
|
8a2df97be8 | ||
|
|
44c2bf0da4 | ||
|
|
8386b41015 | ||
|
|
2b8eb77e4e | ||
|
|
8f9f980075 | ||
|
|
11adb7dbf3 | ||
|
|
a12250320c | ||
|
|
0db84c81b7 | ||
|
|
c10fe0b48c | ||
|
|
e265af3cb3 | ||
|
|
1cd7db7d0b | ||
|
|
a5a6db7868 | ||
|
|
559b42dd78 | ||
|
|
60f338e2e1 | ||
|
|
a24600ae1d | ||
|
|
172363d9d3 | ||
|
|
e2dc5f3d0d | ||
|
|
3ce2e1d32a | ||
|
|
d3e1c2f242 | ||
|
|
22948ff6e4 | ||
|
|
022040972c | ||
|
|
25996ec8a7 | ||
|
|
985cf85e12 | ||
|
|
82f1aae51b | ||
|
|
8ab827b902 | ||
|
|
72e37a8978 | ||
|
|
4bf6cfc875 | ||
|
|
1f38f12beb | ||
|
|
bcd1b1041a | ||
|
|
bc09ac3cfd | ||
|
|
388c5fa8d5 | ||
|
|
554f2bf9e9 | ||
|
|
373badd560 | ||
|
|
2efa69d10f | ||
|
|
7994026a70 | ||
|
|
cd580d27c8 | ||
|
|
a9b26fd88c | ||
|
|
e57f8ddab7 | ||
|
|
adb00358dd | ||
|
|
ea12a68e56 | ||
|
|
9fb680da09 | ||
|
|
304849f8d9 | ||
|
|
df9bbc5e04 | ||
|
|
9b83b61669 | ||
|
|
36f911cec8 | ||
|
|
ac64adaa3f | ||
|
|
b207dd0479 | ||
|
|
c3d1896673 | ||
|
|
ec574ab0f5 | ||
|
|
afe3008cae | ||
|
|
8c5b6c8b55 | ||
|
|
8bc7f7aca2 | ||
|
|
fbf77d0f80 | ||
|
|
4966b1d062 | ||
|
|
b8a66a864b | ||
|
|
7004977e75 | ||
|
|
506bf25bc6 | ||
|
|
68f7a101a3 | ||
|
|
38feaf17ba | ||
|
|
f1706ab408 | ||
|
|
a9a8ae6b75 | ||
|
|
cb4c32fc87 | ||
|
|
d14f3d1717 | ||
|
|
bbf27c4f3d | ||
|
|
a4f1cfc650 | ||
|
|
d718e380b7 | ||
|
|
adb054023d | ||
|
|
308797567e | ||
|
|
63e5f22607 | ||
|
|
7ae0a9f7ae | ||
|
|
f68c556f8a | ||
|
|
bb6835e6c3 | ||
|
|
9afc03b347 | ||
|
|
5b3729e2db | ||
|
|
90c6f0020c | ||
|
|
03e3d9bdbf | ||
|
|
bab8f3403d | ||
|
|
74768647f4 | ||
|
|
a6869b0166 | ||
|
|
a1d620d7b0 | ||
|
|
f09ddf1b18 | ||
|
|
0d545deb63 | ||
|
|
12e4bcbf45 | ||
|
|
c468dc9e44 | ||
|
|
3171afc522 | ||
|
|
11487e0cf9 | ||
|
|
d319faa80c | ||
|
|
235f763625 | ||
|
|
6709522890 | ||
|
|
066a097618 | ||
|
|
da61249592 | ||
|
|
7cb6f98332 | ||
|
|
d4a9835807 | ||
|
|
871e932319 | ||
|
|
a487f3e1b3 | ||
|
|
a985937b19 | ||
|
|
b19e6dc940 | ||
|
|
bcabaaf596 | ||
|
|
7571ffb2ef | ||
|
|
18693b3207 | ||
|
|
9efa17b2b7 | ||
|
|
d1ba8e0c7e | ||
|
|
eb63967b5c | ||
|
|
0040ed1921 | ||
|
|
24863480cb | ||
|
|
5544b69d7e | ||
|
|
a1517ec949 | ||
|
|
8e204218be | ||
|
|
f2fd274502 | ||
|
|
38b1ba3419 | ||
|
|
5bfcd2dfbf | ||
|
|
048c8e60ff | ||
|
|
504681db82 | ||
|
|
5e2a320512 | ||
|
|
b892967eb3 | ||
|
|
66e061ccca | ||
|
|
451ecc950c | ||
|
|
be8703dc51 | ||
|
|
039fe4e87f | ||
|
|
7cef1a3412 | ||
|
|
869412bebd | ||
|
|
85729f9819 | ||
|
|
4ed6beb33a | ||
|
|
0315917987 | ||
|
|
d438d923a3 | ||
|
|
59a406830c | ||
|
|
5e293f567a | ||
|
|
f29f14128f | ||
|
|
8d55b8f3f7 | ||
|
|
2ede6b55ea | ||
|
|
d3a09a95fe | ||
|
|
fbbfabb404 | ||
|
|
2d0f20b69c | ||
|
|
c28e24b5fa | ||
|
|
f36632accc | ||
|
|
7fa737f690 | ||
|
|
7e3bec2721 | ||
|
|
e0101a937b | ||
|
|
e493bcf58e | ||
|
|
8bea4c1a0c | ||
|
|
3bebc68cd5 | ||
|
|
972b781d0a | ||
|
|
0e1a442dca | ||
|
|
07c20ae806 | ||
|
|
e05a205a47 | ||
|
|
45e6084424 | ||
|
|
48b2755088 | ||
|
|
6935b8650d | ||
|
|
05ab2c92b0 | ||
|
|
0e1797d4e0 | ||
|
|
30a0bdbbb6 | ||
|
|
204d91902d | ||
|
|
185f3b37be | ||
|
|
ec3d26f663 | ||
|
|
2efa10a9d6 | ||
|
|
71bc325289 | ||
|
|
c7073dc6b6 | ||
|
|
b5f5de5c83 | ||
|
|
3fb047c93d | ||
|
|
a8758bc6b5 | ||
|
|
fa025220f8 | ||
|
|
3dd36bd9ff | ||
|
|
e2dcebca35 | ||
|
|
4a3bd8fe90 | ||
|
|
b5b3a949d3 | ||
|
|
2270c07f97 | ||
|
|
958d7b3436 | ||
|
|
c8a6d0bb3c | ||
|
|
9e8c718739 | ||
|
|
555afe4070 | ||
|
|
d51142bf9a | ||
|
|
b0267827b5 | ||
|
|
e798914aab | ||
|
|
a2a92bb408 | ||
|
|
4672b97417 | ||
|
|
c12b463439 | ||
|
|
1947e354ce | ||
|
|
67ce690064 | ||
|
|
8d17fab3de | ||
|
|
17bc4537c3 | ||
|
|
cfa0fe3952 | ||
|
|
3e79021a6e | ||
|
|
d637dbaf63 | ||
|
|
0774ae6f83 | ||
|
|
a6e80c7e23 | ||
|
|
5e902e384d | ||
|
|
84d2ba1d3d | ||
|
|
83f825513a | ||
|
|
a8c02b4a85 | ||
|
|
8b6bf38560 | ||
|
|
2d24f43721 | ||
|
|
449e27b415 | ||
|
|
03aedc14d7 | ||
|
|
fba673d215 | ||
|
|
54ce1de1df | ||
|
|
3ae5e1f994 | ||
|
|
c8482a2d96 | ||
|
|
9f254a6912 | ||
|
|
8f2dd3b1a4 | ||
|
|
8ceee7afd0 | ||
|
|
b8585cdd92 | ||
|
|
2da3c9273b | ||
|
|
7cdb9b5a62 | ||
|
|
2b72021611 | ||
|
|
a8f5a3c953 | ||
|
|
f259488a9a | ||
|
|
3c7725c3a5 | ||
|
|
219489ae04 | ||
|
|
7a6972cd1b | ||
|
|
b71f953635 | ||
|
|
21d71e8282 | ||
|
|
39e2954ab4 | ||
|
|
50808aed5e | ||
|
|
5f082696a6 | ||
|
|
63e57bd894 | ||
|
|
f17f4e703e | ||
|
|
df8f335b98 | ||
|
|
032b41aa87 | ||
|
|
10d74f8db8 | ||
|
|
d41a7e03a9 | ||
|
|
cb1f81977e | ||
|
|
d86d2088e6 | ||
|
|
d327d1943b | ||
|
|
7ee064df14 | ||
|
|
64c8dfc770 | ||
|
|
113667da55 | ||
|
|
03c77604de | ||
|
|
cd985c7c34 | ||
|
|
0ceb28bd31 | ||
|
|
b83d75d9d3 | ||
|
|
d72f3a2172 | ||
|
|
589e1b9df2 | ||
|
|
3fc070fcfa | ||
|
|
934b2ee4d7 | ||
|
|
b89e93a67f | ||
|
|
cd532222a5 | ||
|
|
85caee6c83 | ||
|
|
1057eb2719 | ||
|
|
3333432be8 | ||
|
|
e112df858a | ||
|
|
bf4122029e | ||
|
|
288870c842 | ||
|
|
05ff2697bc | ||
|
|
e9830b6ced | ||
|
|
af6d27095c | ||
|
|
6785e622a7 | ||
|
|
13c46450a9 | ||
|
|
1cf8ff72c7 | ||
|
|
0c25aad62f | ||
|
|
44d90bb9ae | ||
|
|
3b79267c29 | ||
|
|
cdfe0e5370 | ||
|
|
49c99de3e2 | ||
|
|
a2b04dbfba | ||
|
|
c43ab64872 | ||
|
|
e1056a4db0 | ||
|
|
42324b4a99 | ||
|
|
8a56299155 | ||
|
|
d3bf12fcd8 | ||
|
|
0d9d0a72d6 | ||
|
|
fc5cbcd720 | ||
|
|
9c0000e469 | ||
|
|
a921c0da9b | ||
|
|
aeae152370 | ||
|
|
3285172eec | ||
|
|
98d556a0e0 | ||
|
|
953be21d38 | ||
|
|
756af19d18 | ||
|
|
faebab9a3a | ||
|
|
b130cffe53 | ||
|
|
88bcb33342 | ||
|
|
8f67b39735 | ||
|
|
4c07e1bbd1 | ||
|
|
9bfc3ec037 | ||
|
|
8f6b1c8463 | ||
|
|
eede77a3e3 | ||
|
|
5832f4353a | ||
|
|
ddc303542a | ||
|
|
ebfae0f803 | ||
|
|
6de75a7d78 | ||
|
|
e9f9994513 | ||
|
|
d9c67ca992 | ||
|
|
32d9ef4ec9 | ||
|
|
ab5c78226a | ||
|
|
989a986d68 | ||
|
|
9872efcc1d | ||
|
|
dffbe7b9f7 | ||
|
|
f0eafc175e | ||
|
|
dd6d3a89ca | ||
|
|
e55d275014 | ||
|
|
593ca3beb4 | ||
|
|
34390bb78b | ||
|
|
b3e4f1babb | ||
|
|
eb4df0b99c | ||
|
|
836af6fb3a | ||
|
|
162f01c160 | ||
|
|
2ec0422b79 | ||
|
|
0e9945e333 | ||
|
|
9ea50a792e | ||
|
|
55b47ce039 | ||
|
|
9cf8249398 | ||
|
|
583cd97a25 | ||
|
|
3d95db88cc | ||
|
|
882d006b3c | ||
|
|
ac8742c829 | ||
|
|
c961a93f62 | ||
|
|
6ac5dabdee | ||
|
|
1083c6ccbf | ||
|
|
df71faf240 | ||
|
|
29aedf1c52 | ||
|
|
b9c8b740ca | ||
|
|
41fd6fe173 | ||
|
|
6df505f003 | ||
|
|
54cbf4d06f | ||
|
|
66a427ab55 | ||
|
|
b2603f6715 | ||
|
|
8ddd138e87 | ||
|
|
8a23b5ccd2 | ||
|
|
745e4fa8ab | ||
|
|
88829b7057 | ||
|
|
068da48734 | ||
|
|
c6784468a1 | ||
|
|
53a1ba77a1 | ||
|
|
92564f8de5 | ||
|
|
fa867efb88 | ||
|
|
350689829d | ||
|
|
f25174859e | ||
|
|
e6bb9809f7 | ||
|
|
3b118dd845 | ||
|
|
68d782ca82 | ||
|
|
1b36402dd6 | ||
|
|
1bda2ec31e | ||
|
|
23ce406872 | ||
|
|
763749f25e | ||
|
|
0c6d28a706 | ||
|
|
886a8f4478 | ||
|
|
cb98ead411 | ||
|
|
209fb18a27 | ||
|
|
c9a3625ff2 | ||
|
|
4801ce003b | ||
|
|
f64b5fd33b | ||
|
|
2e39ea00bb | ||
|
|
d77aa5472b | ||
|
|
99c6ab5863 | ||
|
|
b7da1389da | ||
|
|
2183376edc | ||
|
|
2fbc754484 | ||
|
|
0bf61c8067 | ||
|
|
90c5c7af25 | ||
|
|
6124618d42 | ||
|
|
628c536218 | ||
|
|
8045c9e3eb | ||
|
|
23b6c09ae8 | ||
|
|
51ef855d19 | ||
|
|
0d01d18ddd | ||
|
|
8956cf0be7 | ||
|
|
22a81c6f7a | ||
|
|
361a1def43 | ||
|
|
07c739fcf7 | ||
|
|
1e690b7062 | ||
|
|
5c1c944a4f | ||
|
|
066aeab207 | ||
|
|
a05e85f637 | ||
|
|
160fdc1f30 | ||
|
|
2cddf03932 | ||
|
|
137f9223f1 | ||
|
|
e918e2e0c0 | ||
|
|
98b1b3261b | ||
|
|
238de3860d | ||
|
|
c0cc4de9e1 | ||
|
|
e7d4efc3db | ||
|
|
8c922b446b | ||
|
|
f29d05620c | ||
|
|
c2d392e155 | ||
|
|
36a03bda9c | ||
|
|
f9668a577e | ||
|
|
c5661ec255 | ||
|
|
b14593fa40 | ||
|
|
55f3ae0b73 | ||
|
|
0533f09f16 | ||
|
|
64e8299aeb | ||
|
|
c5508b727a |
No files matched your search
@@ -0,0 +1,36 @@
|
||||
# Allow only build inputs. Never send saved games, credentials or Git history.
|
||||
**
|
||||
!package.json
|
||||
!package-lock.json
|
||||
!tsconfig.json
|
||||
!next.config.ts
|
||||
!vite.config.ts
|
||||
!.oxlintrc.json
|
||||
!.gitignore
|
||||
!components.json
|
||||
!drizzle.config.ts
|
||||
!README.md
|
||||
!AGENTS.md
|
||||
!THIRD_PARTY_NOTICES.md
|
||||
!docs/**
|
||||
!.openai/
|
||||
!.openai/hosting.json
|
||||
!app/**
|
||||
!components/**
|
||||
!db/**
|
||||
!drizzle/**
|
||||
!game/**
|
||||
!hooks/**
|
||||
!lib/**
|
||||
!public/**
|
||||
!tests/**
|
||||
!tools/**
|
||||
!deploy/container-entrypoint.sh
|
||||
!deploy/serve.mjs
|
||||
!deploy/bootstrap-admin.mjs
|
||||
**/*.orig
|
||||
**/*.rej
|
||||
**/.env*
|
||||
**/.dev.vars*
|
||||
**/*.sqlite*
|
||||
**/*.pem
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Verify and publish checkpoint container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'codex/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dune-checkpoint-container
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
DUNE_CONTAINER_IMAGE: ${{ vars.DUNE_CONTAINER_IMAGE || 'git.juiced.computer/admin/dune' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build container
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
BUILD_REVISION=${{ github.sha }}
|
||||
load: true
|
||||
tags: dune:verify
|
||||
- name: Verify isolated storage and HTTP behavior
|
||||
run: node tools/verify-container.mjs dune:verify
|
||||
- name: Configure container registry
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||
REGISTRY_USERNAME: ${{ vars.DUNE_REGISTRY_USERNAME || github.repository_owner }}
|
||||
run: |
|
||||
if [[ -z "$REGISTRY_TOKEN" ]]; then
|
||||
echo '::error::Set the CONTAINER_REGISTRY_TOKEN repository secret to publish the verified image.'
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$REGISTRY_USERNAME" ]]; then
|
||||
echo '::error::Set the DUNE_REGISTRY_USERNAME repository variable to a registry account.'
|
||||
exit 1
|
||||
fi
|
||||
image="${DUNE_CONTAINER_IMAGE,,}"
|
||||
if [[ "$image" != */* || "$image" == *[!a-z0-9._/:-]* || "${image#*/}" == *:* ]]; then
|
||||
echo '::error::DUNE_CONTAINER_IMAGE must be an untagged registry/owner/image name.'
|
||||
exit 1
|
||||
fi
|
||||
registry="${image%%/*}"
|
||||
if [[ -z "$registry" || -z "${image#*/}" ]]; then
|
||||
echo '::error::DUNE_CONTAINER_IMAGE must include a registry and image name.'
|
||||
exit 1
|
||||
fi
|
||||
printf 'DUNE_REGISTRY=%s\nDUNE_IMAGE=%s\n' "$registry" "$image" >> "$GITHUB_ENV"
|
||||
- name: Log in to container registry
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DUNE_REGISTRY }}
|
||||
username: ${{ vars.DUNE_REGISTRY_USERNAME || github.repository_owner }}
|
||||
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||
- name: Publish the verified image
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
REVISION: ${{ github.sha }}
|
||||
run: |
|
||||
docker tag dune:verify "$DUNE_IMAGE:sha-$REVISION"
|
||||
docker tag dune:verify "$DUNE_IMAGE:checkpoint"
|
||||
docker push "$DUNE_IMAGE:sha-$REVISION"
|
||||
docker push "$DUNE_IMAGE:checkpoint"
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Verify and publish checkpoint container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'codex/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: dune-checkpoint-container
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build container
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
BUILD_REVISION=${{ github.sha }}
|
||||
load: true
|
||||
tags: dune:verify
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Verify isolated storage and HTTP behavior
|
||||
run: node tools/verify-container.mjs dune:verify
|
||||
- uses: docker/login-action@v3
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish the verified image
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
REVISION: ${{ github.sha }}
|
||||
run: |
|
||||
image="ghcr.io/${REPOSITORY,,}"
|
||||
docker tag dune:verify "$image:sha-$REVISION"
|
||||
docker tag dune:verify "$image:checkpoint"
|
||||
docker push "$image:sha-$REVISION"
|
||||
docker push "$image:checkpoint"
|
||||
@@ -43,3 +43,5 @@ next-env.d.ts
|
||||
|
||||
# local notes (contains credentials, never commit)
|
||||
/github.md
|
||||
/GOAL.local.md
|
||||
/DEPLOYMENT.local.md
|
||||
@@ -35,6 +35,7 @@
|
||||
],
|
||||
"rules": {
|
||||
"no-array-constructor": "error",
|
||||
"nextjs/no-html-link-for-pages": "off",
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"prefer-rest-params": "error",
|
||||
|
||||
@@ -6,14 +6,21 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
||||
|
||||
## Start small
|
||||
|
||||
- Read `README.md` and this guide first. See `docs/DEVELOPMENT.md` for the
|
||||
architecture map and focused verification commands.
|
||||
- Read `docs/CURRENT_STATUS.md` for current gates and `docs/RULE_DECISIONS.md`
|
||||
for existing rulings before reopening research.
|
||||
- Start here; use `docs/DEVELOPMENT.md#context-index` to open only the task's
|
||||
canonical paths, then the relevant README instructions if needed.
|
||||
- When continuing the complete project goal in this checkout, explicitly read
|
||||
`GOAL.local.md` if present; it is local and ignored, not automatically loaded
|
||||
project context. If absent, use the tracked guidance and the current user
|
||||
request. Never recreate it from credential-bearing handovers or commit it.
|
||||
- Read the opening of `docs/CURRENT_STATUS.md` for active work; use the bounded
|
||||
lookup in `docs/DEVELOPMENT.md#canonical-feature-lookup` for feature evidence
|
||||
and `docs/RULE_DECISIONS.md` for source authority and rulings.
|
||||
- Run `git status --short` before editing; preserve other work.
|
||||
- Search the relevant feature in `game/`, `components/`, `tests/`, and `docs/`.
|
||||
Use `rg -n` and bounded reads for `game/engine.ts` and
|
||||
`components/game-table.tsx`; both are large orchestration files.
|
||||
- Locate the feature's helper and consumers from its existing evidence paths;
|
||||
search the relevant subsystem only when those paths are missing.
|
||||
Use OMP `find` for unknown behavior, `grep` for known literals and bounded
|
||||
`read` for `game/engine.ts` and `components/game-table.tsx`; both are large
|
||||
orchestration files.
|
||||
- `docs/IMPLEMENTATION_STATUS.md` is a historical evidence log, newest first.
|
||||
Read its opening checkpoint and the relevant feature document rather than
|
||||
loading the entire log. Verify historical claims against current code/tests.
|
||||
@@ -22,63 +29,100 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
||||
|
||||
## Prototype the remaining scope first
|
||||
|
||||
- The user prioritizes working first versions of all remaining functions across
|
||||
Basic, Advanced and every expansion, followed by integration, refinement and
|
||||
polish. Reuse existing capabilities; batch related functions by dependency.
|
||||
- The 8 October 2026 user amendment sets the content order: complete Basic
|
||||
rules for all twelve factions first, then Advanced rules, then refine
|
||||
interactions. Usable first versions may be imperfect; mark provisional
|
||||
interpretations honestly rather than postponing all independent content.
|
||||
Defer administration detours, broad test suites, capture-resumption sweeps and
|
||||
combination campaigns. Use only brief changed-path smoke during rule delivery;
|
||||
batch type/lint feedback rather than repeating it for each small edit.
|
||||
- The 2 October 2026 amendment supersedes the 26 September sequencing:
|
||||
implement all rules first; save/recovery, privacy and custody assurance plus
|
||||
comprehensive review follow afterward, not as per-prototype gates. Do not
|
||||
repeat seat-restoration audits, whole-database comparisons or preservation
|
||||
loops at each feature. This defers assurance, not printed card/force/payment
|
||||
mechanics, existing authorization, no-reset/no-deletion or secret protection.
|
||||
- Track missing, prototyped, integrated, verified and polished work in the
|
||||
existing checklist. Prototypes need usable controls, a legal AI path and saved
|
||||
continuation; placeholders and disconnected helpers are not completed features.
|
||||
- Keep focused checks for crashes, deadlocks, legality, custody, privacy and save
|
||||
integrity. Defer exhaustive combinations and visual polish until broader
|
||||
functional coverage, unless current failures require them.
|
||||
existing checklist. Prototypes need usable controls, connected rule behavior,
|
||||
a minimal legal AI path and changed-path smoke; placeholders and disconnected
|
||||
helpers are not completed features. Recovery certification comes later.
|
||||
- A complete administration panel is required non-AI scope: room creation,
|
||||
lifecycle/removal, participant support, backups/restoration and operations.
|
||||
Follow `docs/ADMIN_PANEL.md`; retain authorization, privacy, audit history and
|
||||
saved-game safeguards. Adding this scope does not authorize a game reset.
|
||||
- Iterate on the smallest failing case first. Keep legality, crashes, deadlocks,
|
||||
usable controls and minimal legal AI feedback; batch related rules by dependency
|
||||
before integration proof. Do not add privacy/custody/save audits to that loop.
|
||||
- Defer full AI implementation, strategic refinement and difficulty calibration
|
||||
until all non-AI game features are complete. Until then, maintain only minimal
|
||||
legal participation and fix critical playability/correctness issues. Afterward,
|
||||
target approximately 75% higher-tier wins for each adjacent pair: Medium/Easy,
|
||||
Hard/Medium and Brutal/Hard. See `docs/AI_DEVELOPMENT_PLAN.md` for evaluation.
|
||||
- Preserve required checkpoint checks, selective subagents and independent
|
||||
review for complex rules/privacy/persistence. Commit and push verified
|
||||
checkpoints under standing authorization. No user-imposed usage cutoff or
|
||||
routine account-usage check remains.
|
||||
- Keep one shared integration owner and selectively delegate disjoint substantial
|
||||
slices. Rule-specific decisions may need bounded review now; comprehensive
|
||||
rules/privacy/persistence review follows all rules. Commit and push verified
|
||||
checkpoints under standing authorization. No usage cutoff or routine
|
||||
account-usage check remains.
|
||||
- Keep material rulings explicit and prototype independent work while pending.
|
||||
Prototype evidence does not open mode or publication gates.
|
||||
The user-authorized Advanced preview permits unfinished base-faction starts
|
||||
with a visible warning; see `docs/ADVANCED_PREVIEW.md`. Prototype evidence does
|
||||
not certify complete modes or open expansion/publication gates.
|
||||
|
||||
## OMP harness workflow
|
||||
|
||||
See [the OMP harness workflow](docs/DEVELOPMENT.md#omp-harness-workflow) and
|
||||
[compact handoff](docs/DEVELOPMENT.md#compact-agent-handoff). Live tool schemas
|
||||
take precedence. Use native planning, delegation and browser tools; one
|
||||
integration owner freezes related work before running shared checks.
|
||||
|
||||
## Implement and verify
|
||||
|
||||
- Keep rule calculations/validation in focused `game/` modules; integrate them
|
||||
through the engine, player view, bots and UI as the feature requires.
|
||||
- Preserve rejected-action immutability, physical card/force custody, private
|
||||
information boundaries, and JSON save/restore behavior. Reuse shared rule
|
||||
quotes instead of independently duplicating legality in bots or components.
|
||||
- Reuse shared rule quotes rather than duplicating legality in bots or components.
|
||||
Preserve existing rejected-action immutability, physical card/force mechanics,
|
||||
private boundaries and JSON behavior; defer their assurance campaigns until
|
||||
all rules are implemented rather than making them new per-feature gates.
|
||||
- Add meaningful regressions to `tests/**/*.test.ts`; discovery is automatic.
|
||||
HTTP tests must start with `// @dune-suite integration`. Tests importing
|
||||
`node:sqlite` are classified as in-memory recovery tests automatically.
|
||||
- During iteration: `npm test -- <filename-fragment>`; multiple fragments form
|
||||
a union. Use `--list` to inspect selection and `--name <regex>` to narrow
|
||||
cases. A filename typo fails rather than silently passing an empty suite.
|
||||
- Before handing off code changes: `npm run check` (types, lint, offline tests).
|
||||
Run `npm run build` for app/build/dependency changes. Run
|
||||
`npm run test:integration` against a development server for HTTP, auth or
|
||||
persistence changes. Documentation-only edits need link/command review.
|
||||
- Extend testing when failures or changed behavior justify it. Do not run AI
|
||||
calibration or browser acceptance for unrelated maintenance changes.
|
||||
- During iteration: `npm test -- <filename-fragment> --name '<failing-case-regex>'`.
|
||||
Inspect with `--list`; verify the intended case actually ran. Once repaired,
|
||||
run the related file union once, not after every tiny fixture edit. Filename
|
||||
typos fail instead of passing an empty selection.
|
||||
- At a frozen related-rule batch, the integration owner runs `npm run check:quick`
|
||||
(types/lint), affected tests and the changed runtime path once. Build
|
||||
app/build/dependency changes;
|
||||
run relevant HTTP tests for actual boundary changes, not routine assurance.
|
||||
See `docs/DEVELOPMENT.md#feedback-and-proof-schedule` for the full schedule.
|
||||
- Full `npm run check`, comprehensive review, recovery/privacy/custody audits,
|
||||
combinations, browser/deployed acceptance and required release proof remain
|
||||
due after all rules. Earlier broad checks need a concrete integration risk or
|
||||
observed failure, not habit. Documentation-only edits need link/command review.
|
||||
Do not run AI calibration or unrelated browser acceptance for maintenance.
|
||||
- If a sandbox blocks test subprocess pipes with `EPERM`, rerun the exact check
|
||||
with approved subprocess permissions. Do not disable isolation or skip tests
|
||||
to obtain a green result; the runner self-test checks real failure propagation.
|
||||
- Report actual check results and limitations. Passing focused tests does not
|
||||
certify a complete faction, expansion or rules mode; preserve release gates.
|
||||
- Verify relevant runtime changes on `https://dune.procrastination.games` after
|
||||
deployment, matching the deployed revision to the checkpoint. Use dedicated
|
||||
QA rooms and preserve existing games; local success is not deployed evidence.
|
||||
Follow `docs/VERIFICATION_WORKFLOW.md` for production checks and limitations.
|
||||
|
||||
## Preserve development state
|
||||
|
||||
- The user-authorized 2026-09-13 reset cleared old local games once; see
|
||||
`docs/VERIFICATION_WORKFLOW.md`. Preserve all games created afterward.
|
||||
- Keep `.wrangler/state` and all saved games. Never reset a database to fix a
|
||||
- Keep `.wrangler/state` and all saved games: do not delete/reset or
|
||||
intentionally damage existing games; extensive historical-save hardening
|
||||
follows the content-first priority above. Never reset a database to fix a
|
||||
test, migration, connection or gameplay problem. Migrations are additive.
|
||||
- Reuse a running dev server. Restart only when a code/configuration change or
|
||||
observed server condition warrants it; there is no hourly restart schedule.
|
||||
Warn before interrupting human play, defer to a safe point, and verify
|
||||
restoration after a necessary restart. No recurring maintenance automation
|
||||
is installed; do not recreate it without a request.
|
||||
Warn before interrupting human play and defer to a safe point. Observe server
|
||||
health after a necessary restart; comprehensive restoration assurance follows
|
||||
all rules. No recurring maintenance automation is installed.
|
||||
- `.openai/hosting.json` identifies the existing Sites project. Preserve it.
|
||||
Follow the applicable Sites skills for site work; publication has existing
|
||||
completion and verification gates documented in `README.md`.
|
||||
@@ -87,9 +131,13 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
|
||||
|
||||
## Efficient checkpoints
|
||||
|
||||
- Use `docs/VERIFICATION_WORKFLOW.md` for compact source-bound check reports
|
||||
and private saved-game/seat checks. Keep artifacts outside the checkout.
|
||||
- Use `docs/VERIFICATION_WORKFLOW.md` for compact source-bound reports and the
|
||||
deferred final saved-game/seat assurance commands. Keep artifacts outside Git.
|
||||
- Use subagents selectively for bounded independent tasks and complex reviews;
|
||||
they remain explicitly authorized. Share concise briefs and file ownership.
|
||||
- Review, verify, commit and push completed checkpoints automatically under
|
||||
“Push all from now on.” Confirm push success; never force-push or expose secrets.
|
||||
“Push all from now on.” The intended `origin` is
|
||||
`https://git.juiced.computer/admin/Dune.git`; verify the destination and push
|
||||
success. Do not fall back to GitHub, force-push or expose secrets. Gitea
|
||||
Actions and container publication require a configured runner and credentials;
|
||||
a source push alone does not prove an image was published or deployed.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
ENV CI=true WRANGLER_SEND_METRICS=false GOMEMLIMIT=2GiB GOMAXPROCS=2
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ARG BUILD_REVISION
|
||||
ENV BUILD_REVISION=${BUILD_REVISION}
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production CI=true WRANGLER_SEND_METRICS=false \
|
||||
WRANGLER_WRITE_LOGS=false MINIFLARE_REGISTRY_PATH=/tmp/dune-registry
|
||||
LABEL org.opencontainers.image.source="https://git.juiced.computer/admin/Dune"
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/drizzle ./drizzle
|
||||
COPY package.json ./
|
||||
COPY tools/wrangler.local.json ./tools/wrangler.local.json
|
||||
COPY tools/admin-access.mjs ./tools/admin-access.mjs
|
||||
COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
|
||||
COPY deploy/bootstrap-admin.mjs ./deploy/bootstrap-admin.mjs
|
||||
COPY deploy/serve.mjs ./deploy/serve.mjs
|
||||
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:3000/', {signal: AbortSignal.timeout(8000)}).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
ENTRYPOINT ["sh", "/app/deploy/container-entrypoint.sh"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dune — Arrakis Table
|
||||
|
||||
An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Advanced and unfinished expansion starts remain disabled. Start with [current status](docs/CURRENT_STATUS.md) for implemented systems, remaining requirements and release gates; detailed historical evidence remains in the [implementation log](docs/IMPLEMENTATION_STATUS.md).
|
||||
An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Basic and clearly labelled Advanced-preview games can start with selected expansion factions and supported optional modules. Unsupported module combinations still reject at start. Start with [current status](docs/CURRENT_STATUS.md) for implemented systems, remaining requirements and release gates; detailed historical evidence remains in the [implementation log](docs/IMPLEMENTATION_STATUS.md).
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -14,6 +14,107 @@ npm run dev -- --host 0.0.0.0
|
||||
|
||||
Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
|
||||
|
||||
In the lobby, the host may deal random factions or start a prediction-card
|
||||
faction draft. Draft picks proceed from the highest drawn number, and the new
|
||||
factions are assigned together after every seat chooses. AI seats choose
|
||||
automatically. Both methods clear human readiness; direct selection remains
|
||||
available. Human seats can offer a faction swap, which takes effect only when
|
||||
the recipient accepts. Pending offers pause other lobby changes and can be
|
||||
declined by the recipient or canceled by the proposer. A human may also offer
|
||||
to swap with an AI seat, which currently accepts immediately. Accepted swaps
|
||||
clear human readiness. See
|
||||
[faction allocation](docs/FACTION_ALLOCATION.md).
|
||||
|
||||
During play, **Public deals** lets two to six human seats record terms,
|
||||
accept or decline an offer, and agree to release it. Every party must accept,
|
||||
and every pair of parties must be nonallied when the offer is made or accepted.
|
||||
Terms are visible to the
|
||||
table and rely on players to honor free-text terms. An optional immediate spice
|
||||
payment is executed using the normal bribe rules when the final participant
|
||||
accepts. A structured payment may instead be due on the current or a later
|
||||
turn; the payer can pay it early or on the due turn. The provisional payment
|
||||
feasibility and expiry rules cover supported cases, while free-text enforcement,
|
||||
broader combinations and AI negotiation remain unfinished. See
|
||||
[public deals](docs/PUBLIC_DEALS.md).
|
||||
|
||||
For the provisional two-person setup, the host enables **Two players, two
|
||||
factions each** in the lobby. Each person joins with one faction, then adds a
|
||||
second and uses **Active faction** to switch seats. Ready each faction before
|
||||
the host starts. The factions are independent unless they form an alliance;
|
||||
the ordinary victory target is four strongholds per side. The host may add an
|
||||
AI opponent and choose its two factions; each uses its own current AI policy
|
||||
and difficulty. The mode cannot be disabled while a controller has a second
|
||||
faction. Counts for optional modules and wider interactions remain provisional.
|
||||
See [the implementation scope](docs/TWO_PLAYER_TWO_FACTIONS.md).
|
||||
|
||||
New ordinary-auction lots show a **ten-second bidding countdown**. Native
|
||||
inspection and response decisions hold the clock. At expiry, the server passes
|
||||
or submits only a bid required by a binding Truthtrance answer; normal payment
|
||||
remains due. Administrative controls re-arm time after resumption. Already-open
|
||||
older unstamped lots retain their saved timing. See the
|
||||
[time-limit scope and online conventions](docs/AUCTION_TIME_LIMIT.md).
|
||||
|
||||
For Advanced play, choose **Rules → Advanced preview** before creating a room,
|
||||
or use the same selector as host in an existing lobby. Everyone must
|
||||
ready again after changing rules, then the host chooses **Begin Advanced preview**.
|
||||
This mode is unfinished; see [preview scope and limits](docs/ADVANCED_PREVIEW.md).
|
||||
|
||||
New Advanced Revival phases follow First Player order counterclockwise. Finish
|
||||
your native force and leader choices with **Finish Revival turn** before the
|
||||
next player acts; support responses keep their own authorized actors. Basic
|
||||
and already-opened older Revival phases retain their current readiness timing.
|
||||
See [Revival source and preview scope](docs/ADVANCED_REVIVAL_ORDER.md).
|
||||
|
||||
New Advanced Charity phases also follow First Player order counterclockwise.
|
||||
Wait for your turn, claim if eligible, then choose **Finish Charity turn**.
|
||||
CHOAM opening income and any cancellation response finish before the next seat
|
||||
acts. An owned Juice of Sapho offers first/last among remaining Charity turns;
|
||||
a player who already claimed finishes before a reordered later queue. This
|
||||
queued timing is provisional. Basic and already-opened older phases retain
|
||||
concurrent readiness. See [Charity order and remaining scope](docs/ADVANCED_CHARITY_ORDER.md).
|
||||
|
||||
Choose expansion families when creating the room. Before starting, the host can
|
||||
select **Leader Skills**, **Ecaz Treachery Cards** (the independent three-card
|
||||
variant), **Nexus Cards**, **Discoveries**, **Homeworlds**, **Tech Tokens** or
|
||||
**Stronghold Cards** in the lobby. These are unfinished previews, not complete
|
||||
combination support: setup rejects unsupported selections rather than silently
|
||||
dropping an option. Tech Tokens require three or more seats; Stronghold Cards
|
||||
require Advanced. Changing a selection clears human readiness. Existing games
|
||||
are not converted.
|
||||
|
||||
Advanced uses the user-authorized revised rules: every newly opened Storm
|
||||
after the first draws the Storm Deck, even without Fremen. Finishing your
|
||||
Shipment and Movement sends your own shared allied fighters to Tanks;
|
||||
advisors, Polar Sink and native Ecaz coexistence retain their exceptions.
|
||||
Basic and an already recorded current Storm keep their existing protocol.
|
||||
See [Advanced source boundaries](docs/BASE_ADVANCED_READINESS_20260907.md#authorized-advanced-source-follow-up--1-october-2026)
|
||||
and [allied separation](docs/ALLIED_TRANSIT_RULES.md).
|
||||
At an allied separation deadline, a concealed No-Field provisionally reveals
|
||||
before the ending player's actual shared forces go to Tanks. The persistent
|
||||
warning identifies the affected territory; finishing needs no extra confirmation.
|
||||
Basic retains its later deadline and new-alliance grace.
|
||||
See [separation source and limits](docs/ALLIED_TRANSIT_RULES.md).
|
||||
Advanced Hajr is played immediately after a completed Force Movement and its
|
||||
required arrival decisions. Basic keeps its broader own-turn timing. The
|
||||
[Ornithopter combination](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026)
|
||||
uses one unused movement entitlement and retains Hajr's separate move.
|
||||
The single physical Hajr can be played again after legitimate
|
||||
[paid Box recovery](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026).
|
||||
Advanced still requires a fresh post-movement opportunity.
|
||||
Advanced Family Atomics is available after turn1, after storm distance is known
|
||||
and before movement, from physical fighters on/adjacent to the Wall with no
|
||||
intervening storm. A concealed No-Field counts as one for this qualification,
|
||||
even0; a marker on the Wall provisionally reveals before its actual forces
|
||||
are destroyed. An adjacent marker stays concealed. The used Advanced card
|
||||
appears in **Removed from game**. Basic keeps
|
||||
its current Storm/discard procedure. See [source and scope](docs/FAMILY_ATOMICS.md).
|
||||
After Guild Cunning, either physical Ornithopter mode may provisionally replace
|
||||
an earned unused Hajr movement. Cunning itself adds no ordinary move; abandoned
|
||||
card groups grant no extra credit. See the [combination contract](docs/NEXUS_GUILD_CUNNING_RUNTIME.md#physical-ornithopter-composition--18-october-2026).
|
||||
Advanced advisors may use both physical Ornithopter modes. Advisors do not grant
|
||||
city access; normal-range groups use current fighter occupancy. See the
|
||||
[advisor source cutover](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#advanced-advisor-card-movement--18-october-2026).
|
||||
|
||||
Before starting, open **Configure** on an AI seat to change its difficulty,
|
||||
faction or player circle without replacing it. Changes save immediately and
|
||||
clear human readiness. See [lobby AI configuration](docs/LOBBY_AI_CONFIGURATION.md).
|
||||
@@ -30,6 +131,41 @@ room; lesson progress stays in your browser and does not change saved games.
|
||||
|
||||
Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
|
||||
|
||||
For private NAS hosting and manually applied checkpoint image updates, see the
|
||||
[TrueNAS container guide](docs/TRUENAS.md). Its separate persistent storage starts
|
||||
with new games; the requested host port is 33046.
|
||||
|
||||
The **Administration** link opens `/admin`, with separate administrator sign-in,
|
||||
session controls and a searchable room directory. **Room controls** lets owners
|
||||
and operators pause/resume rooms and lock/unlock new joins. The NAS container
|
||||
prints a newly generated initial owner access key in its log only if there are
|
||||
no administrator accounts. Save it privately: log readers can use it, and later
|
||||
starts do not print it again. See [room controls](docs/ADMIN_ROOM_CONTROLS.md)
|
||||
and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery).
|
||||
Owners can [manage administrator accounts](docs/ADMIN_ACCOUNTS.md) with
|
||||
one-time personal keys, role changes, disabling and fresh-key rotation
|
||||
that can safely re-enable a disabled account. Save a new key privately
|
||||
before clearing its tab-scoped recovery record; an old key cannot be reused.
|
||||
Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with
|
||||
an explicitly owned new host seat and initial AI players.
|
||||
[Configure lobby](docs/ADMIN_LOBBY_CONFIGURATION.md) manages supported rules, AI
|
||||
seats and host assignment without private seat access.
|
||||
[Remove or restore](docs/ADMIN_ROOM_REMOVAL.md) recoverably removes selected rooms;
|
||||
use the Directory filter to find them for restoration.
|
||||
[Close or reopen](docs/ADMIN_ROOM_CLOSURE.md) stops further play while preserving
|
||||
readable tables and history, then resumes the same saved position.
|
||||
[Archive or unarchive](docs/ADMIN_ROOM_ARCHIVE.md) organizes closed rooms while
|
||||
retaining their tables and history; unarchiving leaves play closed.
|
||||
[Participant AI](docs/ADMIN_PARTICIPANT_AI.md) supports an absent human in a paused
|
||||
game while preserving their seat and takeback control.
|
||||
[Discussion controls](docs/ADMIN_DISCUSSION.md) mute or unmute new messages from
|
||||
a human seat while preserving history and gameplay.
|
||||
[Owner-only backups](docs/ADMIN_BACKUPS.md) capture and download private room
|
||||
snapshots without changing play. Import/restore, permanent deletion and further
|
||||
participant support remain unfinished.
|
||||
[Action history](docs/ADMIN_ACTION_HISTORY.md) searches recorded admin operations,
|
||||
with role-limited reasons and public before/after settings.
|
||||
|
||||
## Saved seats and reconnecting
|
||||
|
||||
The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated.
|
||||
@@ -47,6 +183,12 @@ for saved retries and limits.
|
||||
|
||||
New create/join requests save a private, tab-scoped retry record before sending. If the response is uncertain, keep the tab open and use the explicit retry; refresh preserves the same request. Confirmed success removes that record. Clearly rejected first requests return to the form for correction. A retry can recover the original room and seat without creating duplicates, but closing the tab or clearing its data can lose the proof. This record is separate from the long-term seat recovery kit.
|
||||
|
||||
Normal creation/join shows brief progress; recovery instructions stay open when
|
||||
attention is needed. Completed-action notices wait for **Continue** so their text
|
||||
can be read at your pace. Seat recovery and handover confirmations also wait for
|
||||
**Continue**; admin and seat-control requests show recovery instructions only
|
||||
when needed. See [readable notices](docs/READABLE_NOTICES.md).
|
||||
|
||||
An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details.
|
||||
|
||||
Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits.
|
||||
@@ -87,6 +229,137 @@ cyborg movement, HMS routes and battle aftermath through the same full deck; see
|
||||
connects ordinary skill actions and saved market/revival choices; see
|
||||
[CHOAM integration](docs/CHOAM_LEADER_SKILLS.md).
|
||||
|
||||
Normal **Mentat questions** now use the user-approved uniform private
|
||||
card-show step in supported Leader Skills development games. Use the
|
||||
existing `--profile leader-skills` entry; no separate question flag is
|
||||
needed. Exact matching and fallback both use the same private step.
|
||||
Empty-hand interpretation and public start gates remain unfinished; see
|
||||
[normal activation and controls](docs/MENTAT_QUESTION.md#normal-activation-and-approved-ux--11-october-2026).
|
||||
|
||||
For local printed **Kull Wahad** testing, use `node --import tsx tools/start-prototype.ts --profile kull`
|
||||
on a fresh ready Basic/Advanced CHOAM-plus-classic lobby with `choam,ix` decks
|
||||
and no optional modules, following the [safe prototype workflow](docs/DEVELOPMENT.md).
|
||||
The tool requires the exact room/version and a private backup output directory.
|
||||
The preview reserves the interrupted Karama and permits only a different eligible
|
||||
counter; BG interception is before conversion. New Advanced attempts restrict
|
||||
the specific Karama effect; Basic retains phase-wide prevention. Advanced phase
|
||||
expiry is provisional. Unpayable overbid recovery remains guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md).
|
||||
This does not enable public expansion starts or certify full CHOAM.
|
||||
|
||||
For local **CHOAM Nexus Cunning Kull**, use `node --import tsx tools/start-prototype.ts --profile nexus-kull`
|
||||
on a fresh ready Basic/Advanced CHOAM-plus-classic lobby with `choam,ix` Treachery
|
||||
and Nexus alone. It omits physical Sandtrout. The same exact-version/private-backup
|
||||
safeguards apply. Choose printed Kull or a canonical own Treachery fuel:
|
||||
Nexus spends on accepted declaration, fuel only on success; distinct prevention
|
||||
retains fuel while leaving Nexus spent. See [verified bounded runtime and limits](docs/NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile).
|
||||
Older printed-only games do not gain Nexus choices, and public starts stay gated.
|
||||
|
||||
For local **Richese Nexus Betrayal** testing, use `node --import tsx tools/start-prototype.ts --profile richese-betrayal`
|
||||
on a fresh ready Basic/Advanced CHOAM/Richese-plus-classic lobby with the `choam`
|
||||
deck and Nexus only. The same exact-version/private-backup safeguards apply.
|
||||
The preview connects public self-cache purchase veto without payment and
|
||||
other-buyer cache/Black Market sale diversion with unchanged delivery and price.
|
||||
See the [cost, private timing, recovery evidence and remaining boundaries](docs/NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026).
|
||||
This does not enable public expansion starts or certify the full Nexus family.
|
||||
|
||||
For local **Guild Nexus Betrayal** testing, use `node --import tsx tools/start-prototype.ts --profile guild-betrayal`
|
||||
on a fresh ready Basic/Advanced classic lobby with native Guild, base decks
|
||||
and Nexus. Enable Homeworlds in the lobby before readiness only when exercising
|
||||
Homeworld/Junction routes; the tool does not add that module to an ordinary room.
|
||||
The exact-version/private-backup safeguards apply. The holder takes one whole
|
||||
funded shipment payment, including their own or income overridden at Junction;
|
||||
this is not a second shipment, free upfront financing or extra Guild income.
|
||||
See [payment custody and source boundaries](docs/NEXUS_GUILD_RULES.md#betrayal-payment-replacement).
|
||||
Existing games and public start gates remain unchanged.
|
||||
|
||||
For local **Ixian Nexus purchased-card replacement**, use
|
||||
`node --import tsx tools/start-prototype.ts --profile ixian-replacement`
|
||||
on a fresh ready classic Basic/Advanced lobby with base Treachery, or Basic
|
||||
Tleilaxu plus classic opponents with the Ix deck and no Ixians; Nexus alone.
|
||||
The fresh native variant omits optional Sandtrout. Exact-version/private-backup
|
||||
safeguards apply; existing games and
|
||||
public starts are unchanged. A real alliance-qualified Nexus must first
|
||||
give an unallied buyer its card, so effect scenarios need at least three
|
||||
seats. **Keep purchased card** retains both cards; eligible **Use Ixian Nexus**
|
||||
discards exactly the normal paid/printed-Karama purchase before drawing the
|
||||
real deck top, with the original payment and auction continuation once.
|
||||
Full hands and same-card redraw from an exhausted deck are supported.
|
||||
Harkonnen buyers and special sale origins remain guarded; see
|
||||
[verified source, privacy and runtime boundaries](docs/NEXUS_IXIAN_REPLACEMENT_RULES.md).
|
||||
|
||||
For local **Ixian Nexus Betrayal**, use
|
||||
`node --import tsx tools/start-prototype.ts --profile ixian-betrayal`
|
||||
on a fresh ready Basic/Advanced lobby with native Ixians, otherwise classic
|
||||
factions or Tleilaxu, the forty-seven-card Ix Treachery Deck and Nexus alone.
|
||||
Exact-version/private-backup safeguards apply; no existing-game retrofit or
|
||||
public start is enabled. Native alliance-qualified Nexus rights still apply.
|
||||
Original Karama counters resolve first; neutral rival acknowledgements then
|
||||
offer **Pass** or privately eligible **Use Ixian Nexus** before the original
|
||||
Bidding inspection/draw or Advanced Technology exchange. Use spends one
|
||||
physical Nexus and prevents only that attempt; all-pass keeps its native
|
||||
effect and continuation. See the
|
||||
[bounded verified runtime](docs/NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026).
|
||||
|
||||
For local **Harkonnen Nexus Betrayal**, use
|
||||
`node --import tsx tools/start-prototype.ts --profile harkonnen-betrayal`
|
||||
on a fresh ready Basic/Advanced classic lobby with native Harkonnen and Nexus
|
||||
alone. Exact-version/private-backup safeguards and natural alliance-qualified
|
||||
Nexus rights apply; public starts and existing games are unchanged. After any
|
||||
original allied Karama counter allows an actual matching native traitor call,
|
||||
the declared-card acknowledgement offers **Pass** or privately eligible
|
||||
**Use Harkonnen Nexus**. Use returns that exact physical traitor immediately
|
||||
and preserves one automatic private actual Mentat replacement, not a draw-now
|
||||
exchange. This is a bounded provisional local timing/privacy convention;
|
||||
bounded verification is Partial/Development Verified; the universal reaction-policy question stays open.
|
||||
See the [source contract and excluded modes](docs/NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026).
|
||||
|
||||
Normal **Spice Banker income** is mandatory in fresh supported all-fourteen
|
||||
Leader Skills setups. Use the ordinary
|
||||
`node --import tsx tools/start-prototype.ts --profile leader-skills`
|
||||
entry with the lobby's supported original modules; there is no separate
|
||||
income profile or toggle. Exact-version/private-backup safeguards
|
||||
apply; public starts and existing games are unchanged. Another payer's one
|
||||
actual bank payment of at least four gains one mandatory front-shield spice
|
||||
once per phase; it stays unspendable until automatic native Mentat collection.
|
||||
Original payment, recipient and separate payer legs stay intact.
|
||||
Tleilaxu's own paid revival is a bank payment; another faction's payment to
|
||||
Tleilaxu is not, unless its income is canceled. The separate free-revival
|
||||
bank reward never adds to the original payer's qualifying amount.
|
||||
Earned original-faction retention after death/capture/reassignment remains
|
||||
provisional; existing games are not retrofitted and complete-mode acceptance
|
||||
remains open. See the [default setup and source contract](docs/SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
|
||||
|
||||
For local **expansion factions + Stronghold Cards**, use
|
||||
`node --import tsx tools/start-prototype.ts --profile stronghold-factions`
|
||||
on a fresh ready Advanced lobby with selected Ixian/Tleilaxu and/or
|
||||
CHOAM/Richese native factions and classic opponents. At least one expansion
|
||||
faction and its distinct `ix`/`choam` decks are required. CHOAM-only deck35;
|
||||
Ix or both47; Richese cache10 stays separate. Stronghold Cards alone start
|
||||
unowned and use native end-Mentat control. Other overlays/Ecaz/Moritani,
|
||||
public expansion starts and existing games are unchanged. Paired/mixed E1/E2
|
||||
rules, legal AI and human phone controls are bounded Development Verified;
|
||||
save/privacy/custody assurance follows all rules. See the
|
||||
[bounded integration](docs/STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules).
|
||||
|
||||
For local **Advanced Ecaz combined-army Occupy**, use
|
||||
`node --import tsx tools/start-prototype.ts --profile ecaz-occupy`
|
||||
with the existing `--db`, `--room`, `--version` and private `--out` arguments
|
||||
on a fresh ready2..6-seat Advanced lobby: native Ecaz with classic, Ixian,
|
||||
Tleilaxu and CHOAM opponents, optional native Moritani. Select distinct `ecaz`
|
||||
and each native `ix`/`choam` family: ordinary33/47/35 cards retain original
|
||||
offers/HMS/Auditor/Face Dancers. Explicit `--ecaz-treachery` independently adds
|
||||
all three physical cards before setup, making36/50/38. Harass returns only
|
||||
card-user undialed forces while preserving original fixed commitment;
|
||||
Reinforcements costs three own reserves under its documented provisional policy.
|
||||
Richese mixed planning and other overlays remain unfinished.
|
||||
The selected lead owns leaders/cards/
|
||||
payment; total dial includes mandatory free `ceil(E/2)` plus native allied
|
||||
strength. Karama switches to the selected lead's own army, other ally zero.
|
||||
Whole participating armies must be clear and connected; BG's voluntary fighter
|
||||
choice remains. Rule/390px controls are bounded Development Verified, not full
|
||||
Ecaz/Basic/public-start acceptance. See the
|
||||
[authorized source and native proof](docs/ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026).
|
||||
|
||||
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
||||
counts separately from hands, auction cards and discards. See
|
||||
[draw-pile counts](docs/DRAW_PILES.md).
|
||||
@@ -108,8 +381,8 @@ sample. Short cues mark phase changes and automatic action notices. Sound starts
|
||||
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
|
||||
for activation, background behavior and prototype limits.
|
||||
|
||||
**Automatic action notices** briefly identify completed actions in house colors.
|
||||
Bursts summarize older notices while retaining their details in the chronicle;
|
||||
**Automatic action notices** identify completed actions in house colors and stay
|
||||
visible until **Continue**. Bursts summarize older notices while retaining their details in the chronicle;
|
||||
see [completed-action feedback](docs/ACTION_FEEDBACK.md).
|
||||
|
||||
In Advanced development games, **Your Harkonnen inspection** preserves the cards
|
||||
@@ -118,22 +391,42 @@ completes automatically; see [private exchange history](docs/HARKONNEN_EXCHANGE.
|
||||
|
||||
## Verify changes
|
||||
|
||||
Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests
|
||||
and the [repeatable verification tools](docs/VERIFICATION_WORKFLOW.md). Tests are discovered automatically; the default
|
||||
suite includes in-memory persistence recovery and needs no server:
|
||||
Use the [bounded context index](docs/DEVELOPMENT.md#context-index) for canonical
|
||||
feature/source paths and the [feedback/proof schedule](docs/DEVELOPMENT.md#feedback-and-proof-schedule).
|
||||
The **2 October 2026** priority implements all rules before comprehensive review
|
||||
and save/recovery/privacy/custody assurance; those remain final requirements,
|
||||
not per-prototype gates. Keep legal behavior, usable controls, minimal legal AI
|
||||
and actual changed-path smoke. Batch related rules with one integration owner;
|
||||
helpers do not run shared checks while edits are in flight.
|
||||
|
||||
```sh
|
||||
# Inspect file selection, then repair the exact failing case first.
|
||||
npm test -- ecaz-spice --list
|
||||
npm test -- ecaz-spice --name 'fallback'
|
||||
# Run the related union once the batch is coherent.
|
||||
npm test -- ecaz-collection ecaz-spice
|
||||
npm run check:quick
|
||||
# Also exercise the actual changed runtime path.
|
||||
# Full offline proof after all rules or a justified broad integration check.
|
||||
npm run check
|
||||
# App/build/dependency changes also need a build.
|
||||
npm run build
|
||||
```
|
||||
|
||||
Run `npm run test:integration` against a development server for HTTP/session
|
||||
changes. `npm run test:multiplayer` remains available for recovery plus HTTP
|
||||
checks; `npm run test:recovery` runs only in-memory persistence tests. All test
|
||||
commands accept filename fragments and `--list` after `--`.
|
||||
Tests are discovered automatically. The offline suite includes in-memory
|
||||
persistence recovery and needs no server. Run relevant `test:integration` cases
|
||||
against a development server for changed HTTP/session behavior, not repeated
|
||||
assurance at every rule checkpoint. `test:multiplayer` retains recovery plus HTTP;
|
||||
`test:recovery` selects in-memory persistence. Filename fragments form a union;
|
||||
`--name REGEX` narrows cases. `--list` previews files, not matching case names.
|
||||
Bad fragments/patterns and zero-executed selections fail. Default output retains
|
||||
failures, diagnostics and counts; use `--verbose` for full per-pass output.
|
||||
`check:quick` runs typecheck and lint only; it is not full-suite or release proof.
|
||||
See [source-bound verification](docs/VERIFICATION_WORKFLOW.md#compact-checks-and-source-evidence)
|
||||
for private full logs/reports and deferred final audits. Existing games, access
|
||||
controls, secret protection and CI/deployment/publication gates remain protected.
|
||||
|
||||
The production build is written to `dist`. `npm start` runs that build with Wrangler; apply its database migrations to the intended storage environment before use. The included local migration command targets the development database, not a remote deployment.
|
||||
The production build is written to `dist`. `npm start` serves that immutable build directly through Miniflare, without Wrangler's development proxy. Apply migrations to the intended storage environment first. Local serving defaults to the existing `.wrangler/state` and port 3000; `DUNE_STATE_PATH` and `DUNE_PORT` select explicit alternatives. The container entrypoint retains `/data` and applies additive migrations before startup. The local migration command does not target a remote deployment.
|
||||
|
||||
Reproduce the current AI study with:
|
||||
|
||||
@@ -147,6 +440,6 @@ See [AI calibration](docs/AI_CALIBRATION.md), [multiplayer audit](docs/MULTIPLAY
|
||||
|
||||
The user-authorized 13 September 2026 local reset established a fresh saved-game baseline; see the [reset record](docs/VERIFICATION_WORKFLOW.md#authorized-reset-13-september-2026). Preserve all games created after that checkpoint.
|
||||
|
||||
Reuse the running development server. Restart only when a code or configuration change, or an observed server condition, makes leaving it running likely to cause issues; use judgment rather than an hourly schedule. Preserve database state, warn before interrupting human play, and defer until a safe decision point. Verify server health and restoration after a necessary restart. No recurring automation is installed; the user requested leaving it removed. Fix causes of crashes and lost progress rather than relying on restarts.
|
||||
Reuse the running development server. Restart only for a relevant code/configuration change or observed problem, not an hourly schedule. Preserve database state, warn before interrupting human play and defer to a safe point. Observe health after a necessary local restart; comprehensive restoration assurance follows all rules, while actual production deployments retain their existing safeguards. No recurring automation is installed; leave it removed. Fix crashes and broken rule continuation rather than relying on restarts.
|
||||
|
||||
The application bundles its artwork, fonts and rules guidance. Source provenance and third-party notices live in developer documentation; the player-facing reference uses internal links. Publication remains gated on completion and integrated verification of the requested rules, expansions and critical player journeys.
|
||||
@@ -0,0 +1,21 @@
|
||||
.admin-accounts { max-width: 1060px; }
|
||||
.admin-accounts .admin-room-control { max-width: none; }
|
||||
.admin-accounts .admin-room-control > label.admin-check { display: flex; align-items: center; gap: 12px; margin: 16px 0; }
|
||||
.admin-accounts .admin-check input { width: 22px; height: 22px; min-height: 22px; flex: none; accent-color: var(--primary); }
|
||||
.admin-accounts .admin-room-control > label:not(.admin-check) { margin: 14px 0 8px; }
|
||||
.accounts-review { display: grid; grid-template-columns: minmax(100px, max-content) minmax(0, 1fr); gap: 8px 20px; overflow-wrap: anywhere; }
|
||||
.accounts-review dt { color: #b7bbaf; }
|
||||
.accounts-review dd { margin: 0; }
|
||||
.accounts-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.accounts-list { list-style: none; padding: 0; display: grid; gap: 12px; }
|
||||
.accounts-list li { border: 1px solid var(--border); padding: 16px; display: flex; align-items: center; justify-content: space-between; gap: 20px; overflow-wrap: anywhere; }
|
||||
.accounts-list li p { margin: 8px 0 0; }
|
||||
.accounts-id { font-family: monospace; }
|
||||
#accounts-private-key { font-family: monospace; overflow-wrap: anywhere; }
|
||||
@media (max-width: 700px) {
|
||||
.accounts-list li { flex-direction: column; align-items: stretch; }
|
||||
.accounts-actions { flex-direction: column; align-items: stretch; }
|
||||
.accounts-review { grid-template-columns: minmax(0, 1fr); }
|
||||
.accounts-review dd { margin-bottom: 8px; }
|
||||
#accounts-private-key { font-size: 0.85rem; }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import type { AdminAccountInput, AdminAccountRow, AdminAccountsDirectory, AdminAccountResult } from '@/lib/admin-accounts';
|
||||
import { adminAccountKeyStatus, clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult, type AdminAccountRecord } from '@/lib/admin-accounts-client';
|
||||
import '../admin.css';
|
||||
import './accounts.css';
|
||||
|
||||
type SessionAccount = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
type Mutation = 'provision' | 'role' | 'disable' | 'rotate';
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'The account request could not be completed.';
|
||||
const denied = (error: unknown) => error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0);
|
||||
|
||||
export default function AccountsPage() {
|
||||
const [owner, setOwner] = useState<SessionAccount | null>(null);
|
||||
const [directory, setDirectory] = useState<AdminAccountsDirectory | null>(null);
|
||||
const [record, setRecord] = useState<AdminAccountRecord | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState<AdminAccountRow['role']>('viewer');
|
||||
const [reason, setReason] = useState('');
|
||||
const [action, setAction] = useState<Mutation>('provision');
|
||||
const [target, setTarget] = useState<AdminAccountRow | null>(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const inFlight = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
|
||||
const loseAccess = useCallback(() => {
|
||||
setOwner(null); setDirectory(null); setRecord(null); setTarget(null); setConfirmed(false);
|
||||
setNotice('Owner access changed or expired. Sign in to administration again. Any saved request remains in this tab for its original owner.');
|
||||
}, []);
|
||||
|
||||
const loadDirectory = useCallback(async (account: SessionAccount, page = 1) => {
|
||||
const data = await requestJson<AdminAccountsDirectory>(`/api/admin/accounts?page=${page}`, {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': account.id },
|
||||
});
|
||||
if (mounted.current) setDirectory(data);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
let canceled = false;
|
||||
requestJson<{ admin: SessionAccount }>('/api/admin/session', { cache: 'no-store' })
|
||||
.then(async ({ admin }) => {
|
||||
if (canceled) return;
|
||||
if (admin.role !== 'owner') { loseAccess(); setNotice('Only an owner can manage administrator accounts.'); return; }
|
||||
await loadDirectory(admin);
|
||||
if (canceled) return;
|
||||
let saved: AdminAccountRecord | null;
|
||||
try { saved = readAdminAccountRecord(window.sessionStorage, admin.id); }
|
||||
catch (error) { setOwner(admin); setStorageProblem(true); setNotice(message(error)); return; }
|
||||
setOwner(admin); setRecord(saved); setStorageProblem(false); setStale(false); setConfirmed(false);
|
||||
setNotice(saved?.kind === 'pending' ? 'This tab has an unconfirmed account request. Review and retry the exact saved request; do not generate another.' : '');
|
||||
})
|
||||
.catch(error => {
|
||||
if (canceled) return;
|
||||
setRecord(null); setOwner(null); setDirectory(null);
|
||||
setNotice(denied(error) ? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
})
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; mounted.current = false; };
|
||||
}, [refresh, loadDirectory, loseAccess]);
|
||||
useEffect(() => {
|
||||
const revalidate = () => {
|
||||
if (document.visibilityState === 'hidden') return;
|
||||
setLoading(true); setOwner(null); setDirectory(null); setRecord(null); setConfirmed(false);
|
||||
setRefresh(value => value + 1);
|
||||
};
|
||||
window.addEventListener('focus', revalidate);
|
||||
window.addEventListener('pageshow', revalidate);
|
||||
document.addEventListener('visibilitychange', revalidate);
|
||||
return () => {
|
||||
window.removeEventListener('focus', revalidate);
|
||||
window.removeEventListener('pageshow', revalidate);
|
||||
document.removeEventListener('visibilitychange', revalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function refreshAccounts(page = directory?.page ?? 1) {
|
||||
if (!owner || inFlight.current) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await loadDirectory(owner, page);
|
||||
setTarget(null); setConfirmed(false); if (!record) setStale(false); setNotice('Account directory refreshed. Review current values before making another change.');
|
||||
} catch (error) { if (denied(error)) loseAccess(); else setNotice(message(error)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function startReview(event: React.SyntheticEvent<HTMLFormElement, SubmitEvent>) {
|
||||
event.preventDefault();
|
||||
if (!owner || !directory || storageProblem || record || inFlight.current || busy) return;
|
||||
try {
|
||||
const input = action === 'provision'
|
||||
? newAdminAccountRequest({ action, name, role, reason })
|
||||
: selected && selected.id !== owner.id
|
||||
? action === 'role'
|
||||
? newAdminAccountRequest({ action, target: selected.id, expectedUpdatedAt: selected.updatedAt, role, reason })
|
||||
: newAdminAccountRequest({ action, target: selected.id, expectedUpdatedAt: selected.updatedAt, reason })
|
||||
: null;
|
||||
if (!input) throw new Error('Choose a current account other than your own; role changes and disabling require an enabled account.');
|
||||
saveAdminAccountRequest(window.sessionStorage, owner.id, input);
|
||||
setRecord({ kind: 'pending', input }); setConfirmed(false); setStale(false);
|
||||
setNotice('Review the exact request below. No account change has been sent yet.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
async function submit(input: AdminAccountInput) {
|
||||
if (!owner || inFlight.current || !confirmed || storageProblem || stale || record?.kind !== 'pending' ||
|
||||
(input.action !== 'provision' && (input.target === owner.id || !directory?.accounts.some(row => row.id === input.target)))) return;
|
||||
inFlight.current = true; setBusy(true); setConfirmed(false);
|
||||
try {
|
||||
// A restored tab must not submit under another or a demoted account.
|
||||
const session = await requestJson<{ admin: SessionAccount }>('/api/admin/session', { cache: 'no-store' });
|
||||
if (session.admin.id !== owner.id || session.admin.role !== 'owner') { loseAccess(); return; }
|
||||
saveAdminAccountRequest(window.sessionStorage, owner.id, input);
|
||||
const result = await requestJson<AdminAccountResult>('/api/admin/accounts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': owner.id }, body: JSON.stringify(input),
|
||||
});
|
||||
if (!validAdminAccountResult(result, input)) throw new Error('The server did not confirm the exact account request. Keep the saved retry.');
|
||||
completeAdminAccountRequest(window.sessionStorage, owner.id, input, result);
|
||||
if (!mounted.current) return;
|
||||
setRecord(input.action === 'provision' || input.action === 'rotate' ? { kind: 'completed', input, account: result.account } : null);
|
||||
setTarget(null); setReason(''); setName(''); setStale(false);
|
||||
setNotice(result.replayed ? 'The original account operation was confirmed; no duplicate change was made.' : 'Account operation confirmed.');
|
||||
try { await loadDirectory(owner, input.action === 'rotate' ? directory?.page ?? 1 : 1); }
|
||||
catch (error) { if (denied(error)) loseAccess(); else setNotice(`Account operation confirmed, but refresh failed: ${message(error)}`); }
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
if (denied(error)) { loseAccess(); return; }
|
||||
if (error instanceof ClientRequestError && error.status === 409) {
|
||||
setStale(true); setTarget(null);
|
||||
try { await loadDirectory(owner, directory?.page ?? 1); }
|
||||
catch (readError) { if (denied(readError)) { loseAccess(); return; } }
|
||||
setNotice('The account request conflicted with current access or a newer account version. Review the refreshed directory before discarding the saved request; no new request can be sent yet.');
|
||||
} else setNotice(requestMayHaveCompleted(error)
|
||||
? `${message(error)} The change may have completed. Confirm the exact saved request by retrying; do not make a new one.` : message(error));
|
||||
} finally { inFlight.current = false; if (mounted.current) setBusy(false); }
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (!owner || busy || !directory || !confirmed) return;
|
||||
try {
|
||||
clearAdminAccountRecord(window.sessionStorage, owner.id);
|
||||
setRecord(null); setStorageProblem(false); setStale(false); setConfirmed(false);
|
||||
setNotice('This tab’s local record was cleared. No existing account was changed by clearing it.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
function select(row: AdminAccountRow, next: Mutation) {
|
||||
if (!owner || row.id === owner.id || (next !== 'rotate' && !row.enabled) || record || busy) return;
|
||||
setTarget(row); setAction(next); setRole(row.role); setReason(''); setConfirmed(false); setNotice('');
|
||||
}
|
||||
|
||||
const pending = record?.kind === 'pending' ? record.input : null;
|
||||
const pendingTarget = pending?.action !== 'provision' && pending ? directory?.accounts.find(row => row.id === pending.target) : null;
|
||||
const completed = record?.kind === 'completed' ? record : null;
|
||||
const keyStatus = completed ? adminAccountKeyStatus(completed, directory) : null;
|
||||
const selected = target && directory?.accounts.find(item => item.id === target.id && item.updatedAt === target.updatedAt && (action === 'rotate' || item.enabled));
|
||||
const totalPages = directory ? Math.max(1, Math.ceil(directory.total / directory.pageSize)) : 1;
|
||||
|
||||
return <main className="admin-shell admin-accounts">
|
||||
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Administrator accounts</h1></div>
|
||||
{owner && <span>{owner.name} <span className="admin-tag">owner</span></span>}</header>
|
||||
<p>Only an owner can provision accounts, change roles, disable access or rotate another account’s key. Account changes do not change games or seats.</p>
|
||||
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||
{loading ? <output>Checking owner access and account directory…</output> : !owner ?
|
||||
<p><a className="admin-room-link" href="/admin">Return to administrator sign-in</a></p> : <>
|
||||
{storageProblem ? <section className="admin-room-control" aria-labelledby="accounts-storage"><h2 id="accounts-storage">Unreadable local request</h2>
|
||||
<p>Account changes are blocked. Inspect the account directory before discarding the unreadable record. A previously submitted operation may have committed, and a newly issued key may be lost if you discard it.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />I checked the directory and understand this tab’s saved retry or key may be lost.</label>
|
||||
<Button disabled={!confirmed || busy || !directory} onClick={discard}>Discard unreadable local record</Button>
|
||||
</section> : pending ? <section className="admin-room-control" aria-labelledby="accounts-pending"><h2 id="accounts-pending">Saved account request</h2>
|
||||
<p>{stale ? 'This request conflicted. Inspect the refreshed directory and explicitly discard it to prepare another request.' : 'The exact operation is saved in this tab. A previous attempt may have succeeded. Retry only this request to confirm its outcome.'}</p>
|
||||
<dl className="accounts-review"><dt>Action</dt><dd>{pending.action}</dd><dt>Account name</dt><dd>{pending.action === 'provision' ? pending.name : pendingTarget?.name ?? 'Find this account in the directory before sending'}</dd>
|
||||
<dt>Account ID</dt><dd>{pending.action === 'provision' ? pending.id : pending.target}</dd>
|
||||
{pending.action !== 'provision' && <><dt>Current role</dt><dd>{pendingTarget?.role ?? 'Unavailable'}</dd><dt>Current state</dt><dd>{pendingTarget ? pendingTarget.enabled ? 'Enabled' : 'Disabled' : 'Unavailable'}</dd><dt>Reviewed version</dt><dd>{new Date(pending.expectedUpdatedAt).toLocaleString()}</dd></>}
|
||||
{(pending.action === 'provision' || pending.action === 'role') && <><dt>Desired role</dt><dd>{pending.role}</dd></>}
|
||||
<dt>Reason</dt><dd>{pending.reason}</dd><dt>Operation ID</dt><dd>{pending.operationId}</dd></dl>
|
||||
{pending.action !== 'provision' && pendingTarget?.updatedAt !== pending.expectedUpdatedAt && <p role="alert">This account’s current version differs from the reviewed version. Only the exact saved operation can be retried; a new request requires discarding it after checking the directory.</p>}
|
||||
{pending.action === 'disable' && <p role="alert">Disabling {pendingTarget?.name ?? pending.target} blocks the existing access key and permanently revokes every current session. Re-enabling requires a new key rotation; its old key will never work again. Check its current role and exact ID before proceeding.</p>}
|
||||
{pending.action === 'rotate' && <p role="alert">Rotating {pendingTarget?.name ?? pending.target} ({pending.target}) replaces its key with a fresh one and revokes every existing session. Its role and name stay unchanged. A disabled account is re-enabled with the new key; its old key remains invalid. Save the new key securely after confirmation. Do not rotate your own account.</p>}
|
||||
<p className="admin-secondary">A new key stays hidden until the exact operation is confirmed. Do not enter credentials in the reason. Discarding an uncertain provision or rotation request can permanently lose the only copy of its key.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />{pending.action === 'disable' ? `I confirm disabling ${pendingTarget?.name ?? pending.target} (${pending.target}) and revoking its sessions for the recorded reason.` : pending.action === 'rotate' ? `I confirm rotating the key for ${pendingTarget?.name ?? pending.target} (${pending.target}), revoking all its sessions${pendingTarget?.enabled === false ? ', and re-enabling access with only the new key' : ''} for the recorded reason.` : 'I verified this exact account ID, name, action, desired role and reason before changing administrator access.'}</label>
|
||||
<div className="accounts-actions"><Button disabled={busy || !confirmed || stale || (pending.action !== 'provision' && (!pendingTarget || pending.target === owner.id))} onClick={() => void submit(pending)}>{busy ? 'Checking request…' : 'Confirm and send exact saved request'}</Button>
|
||||
<Button variant="outline" disabled={busy || !confirmed || !directory} onClick={discard}>Discard saved request after checking directory</Button></div>
|
||||
</section> : completed ? <section className="admin-room-control" aria-labelledby="accounts-key"><h2 id="accounts-key">Save or verify this account key</h2>
|
||||
<p>Account <strong>{completed.account.name}</strong> ({completed.account.id}) {completed.input.action === 'rotate' ? 'was rotated and enabled by this operation; its previous key and sessions were revoked at that time.' : 'was provisioned by this operation.'} This key is available only in this tab until you explicitly clear its local record. It is not returned by the server. Do not distribute it without checking its current access; it cannot be recovered after clearing.</p>
|
||||
{keyStatus === 'matching' ? <p>The directory matched this operation at the last read. A later change can still invalidate the key; verify sign-in before distributing it.</p> :
|
||||
<p role="alert">{keyStatus === 'changed' ? 'The account changed or was disabled after this operation.' : 'The account is not shown on the current directory page, so its current access cannot be confirmed.'} This saved key may no longer work. Do not distribute it; check the account and rotate a fresh key if needed.</p>}
|
||||
<label htmlFor="accounts-private-key">One-time account access key</label>
|
||||
<Input id="accounts-private-key" readOnly value={completed.input.key} autoComplete="off" spellCheck={false} onFocus={event => event.currentTarget.select()} />
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />I saved this key securely and understand clearing this record cannot recover it.</label>
|
||||
<Button disabled={!confirmed || busy} onClick={discard}>Clear saved key and start another request</Button>
|
||||
</section> : <section className="admin-room-control" aria-labelledby="accounts-new"><h2 id="accounts-new">{action === 'provision' ? 'Provision an account' : action === 'role' ? 'Change account role' : action === 'rotate' ? 'Rotate account key' : 'Disable an account'}</h2>
|
||||
<form onSubmit={startReview}>
|
||||
{action === 'provision' ? <><label htmlFor="accounts-name">Account name<Input id="accounts-name" required maxLength={80} value={name} disabled={busy} onChange={event => setName(event.target.value)} /></label>
|
||||
<label htmlFor="accounts-role">Initial role<select id="accounts-role" value={role} disabled={busy} onChange={event => setRole(event.target.value as AdminAccountRow['role'])}><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="owner">Owner</option></select></label></> : selected ? <><p>Target: <strong>{selected.name}</strong> ({selected.id}) · current role {selected.role}</p>
|
||||
{action === 'role' && <label htmlFor="accounts-role">Desired role<select id="accounts-role" value={role} disabled={busy} onChange={event => setRole(event.target.value as AdminAccountRow['role'])}><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="owner">Owner</option></select></label>}
|
||||
{action === 'disable' && <p>Disabling blocks this account’s existing key and permanently revokes its current sessions. Re-enabling requires a fresh key rotation; its old key cannot be used again.</p>}
|
||||
{action === 'rotate' && <p>Current state: {selected.enabled ? 'enabled' : 'disabled'}. Rotation revokes all existing sessions, replaces the old key, preserves the current role and name, and enables this account with only the newly generated key. Your own key cannot be rotated here.</p>}</> : <p>The selected account changed. Refresh and choose it again.</p>}
|
||||
<label htmlFor="accounts-reason">Reason for action history<Input id="accounts-reason" required maxLength={300} value={reason} disabled={busy} onChange={event => setReason(event.target.value)} /></label>
|
||||
<p className="admin-secondary">Use an operational reason without access keys, credentials or private game information. The next screen shows the exact request for confirmation before anything is sent.</p>
|
||||
<Button type="submit" disabled={busy || !directory || !reason.trim() || (action === 'provision' ? !name.trim() : !selected || action === 'role' && role === selected.role)}>Review exact account request</Button>
|
||||
</form>
|
||||
</section>}
|
||||
<section aria-labelledby="accounts-directory"><div className="admin-section-heading"><h2 id="accounts-directory">Account directory</h2><div className="accounts-actions"><Button variant="outline" disabled={busy || !!record || storageProblem} onClick={() => { setAction('provision'); setTarget(null); setRole('viewer'); setReason(''); setConfirmed(false); }}>Provision account</Button><Button variant="outline" disabled={busy} onClick={() => void refreshAccounts()}>Refresh accounts</Button></div></div>
|
||||
{directory ? <><p>{directory.total.toLocaleString()} accounts · page {directory.page} of {totalPages}</p>
|
||||
<ul className="accounts-list">{directory.accounts.map(row => <li key={row.id}><div><strong>{row.name}</strong> <span className="admin-tag">{row.role}</span> <span className="admin-tag">{row.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
<p className="accounts-id">{row.id}</p><p>Updated {new Date(row.updatedAt).toLocaleString()}</p></div>
|
||||
{row.id !== owner.id && !record && !storageProblem && <div className="accounts-actions">{row.enabled && <><Button variant="outline" disabled={busy} onClick={() => select(row, 'role')}>Change role · {row.name}</Button>
|
||||
<Button variant="outline" disabled={busy} onClick={() => select(row, 'disable')}>Disable · {row.name}</Button></>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => select(row, 'rotate')}>Rotate key · {row.name}</Button></div>}</li>)}</ul>
|
||||
{directory.accounts.length === 0 && <p>No accounts on this page.</p>}
|
||||
<nav className="admin-pagination" aria-label="Account pages"><Button variant="outline" disabled={busy || directory.page <= 1} onClick={() => void refreshAccounts(directory.page - 1)}>Previous</Button>
|
||||
<span>Page {directory.page} of {totalPages}</span><Button variant="outline" disabled={busy || directory.page >= totalPages} onClick={() => void refreshAccounts(directory.page + 1)}>Next</Button></nav></> : <p>Account directory unavailable. Changes are blocked until it loads.</p>}
|
||||
</section>
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.admin-shell { max-width: 1420px; margin: auto; padding: 32px; line-height: 1.5; }
|
||||
.admin-header, .admin-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.admin-header { padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--border); }
|
||||
.admin-return { color: var(--primary); font-size: 1rem; }
|
||||
.admin-header h1 { display: flex; align-items: center; gap: 12px; margin: 12px 0 0; }
|
||||
.admin-session { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; }
|
||||
.admin-shell h2 { margin-top: 0; }
|
||||
.admin-secondary { color: #b7bbaf; }
|
||||
.admin-login { max-width: 580px; margin: 48px auto; padding: 30px; border: 1px solid var(--border); background: #171c16; }
|
||||
.admin-login form { display: grid; gap: 12px; margin: 24px 0; }
|
||||
.admin-shell input, .admin-shell select, .admin-shell button { min-height: 42px; font-size: 1rem; }
|
||||
.admin-shell input, .admin-shell select { width: 100%; border: 1px solid var(--border); border-radius: 4px; background: #151a14; color: var(--foreground); padding: 8px 10px; }
|
||||
.admin-shell button { white-space: normal; }
|
||||
.admin-notice { display: block; padding: 14px 18px; border: 1px solid var(--primary); background: #25291e; }
|
||||
.admin-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 16px; padding: 22px 0; border-bottom: 1px solid var(--border); }
|
||||
.admin-filters label { display: grid; gap: 6px; font-size: 0.9375rem; flex: 1 1 145px; }
|
||||
.admin-filters .admin-search { flex: 2 1 240px; }
|
||||
.admin-results { margin: 20px 0 12px; }
|
||||
.admin-room-table { table-layout: fixed; font-size: 0.9375rem; }
|
||||
.admin-room-table th, .admin-room-table td { padding: 16px 12px; white-space: normal; overflow-wrap: anywhere; vertical-align: top; }
|
||||
.admin-room-table th:first-child { width: 30%; }
|
||||
.admin-room-table td p { margin: 8px 0; }
|
||||
.admin-room-table details { margin-top: 8px; }
|
||||
.admin-room-table summary { cursor: pointer; color: var(--primary); }
|
||||
.admin-room-table ul { padding-left: 20px; }
|
||||
.admin-code { font-family: monospace; font-size: 1.125rem; letter-spacing: 1px; }
|
||||
.admin-tag { display: inline-block; font-size: 0.875rem; border: 1px solid #626951; border-radius: 4px; padding: 2px 7px; margin-left: 5px; }
|
||||
.admin-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0; }
|
||||
.admin-session-control { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 24px; max-width: 820px; }
|
||||
.admin-confirm { border: 1px solid var(--border); padding: 16px; }
|
||||
.admin-confirm button { margin-right: 10px; }
|
||||
.admin-development { border-top: 1px solid var(--border); margin-top: 28px; padding-top: 20px; }
|
||||
.admin-room-control { margin: 24px 0; padding: 24px; border: 1px solid var(--primary); border-radius: 8px; background: #171c16; max-width: 880px; }
|
||||
.admin-room-control form { display: grid; gap: 16px; margin-top: 24px; }
|
||||
.admin-room-control button { min-height: 44px; }
|
||||
.admin-room-control label { display: grid; gap: 6px; }
|
||||
.admin-room-control .admin-check { display: flex; align-items: center; gap: 12px; min-height: 44px; }
|
||||
.admin-room-control .admin-check input { width: 22px; height: 22px; min-height: 22px; flex-shrink: 0; accent-color: var(--primary); }
|
||||
.admin-room-control .admin-confirm { margin-top: 16px; }
|
||||
.admin-room-control h2:focus-visible { outline: 2px solid var(--primary); outline-offset: 5px; }
|
||||
.admin-room-actions, .admin-created-result { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.admin-created-result h3, .admin-created-result p { flex-basis: 100%; }
|
||||
.admin-create-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap: 16px; }
|
||||
.admin-create-bot { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: 12px; margin-bottom: 16px; }
|
||||
.admin-room-creation fieldset { min-width: 0; border: 1px solid var(--border); padding: 16px; }
|
||||
.admin-room-link { display: inline-flex; min-height: 44px; align-items: center; padding: 8px 16px; border: 1px solid var(--primary); border-radius: 4px; color: var(--primary); }
|
||||
@media (max-width: 700px) {
|
||||
.admin-shell { padding: 20px 16px; }
|
||||
.admin-header, .admin-section-heading { align-items: start; flex-direction: column; gap: 16px; }
|
||||
.admin-header h1 { font-size: 1.8rem; }
|
||||
.admin-login { padding: 20px; margin: 24px 0; }
|
||||
.admin-room-control { padding: 18px; }
|
||||
.admin-create-bot { grid-template-columns: 1fr; }
|
||||
.admin-room-table { display: block; }
|
||||
.admin-room-table thead { display: none; }
|
||||
.admin-room-table tbody { display: block; }
|
||||
.admin-room-table tr { display: grid; grid-template-columns: 1fr; padding: 14px 0; }
|
||||
.admin-room-table td { display: block; padding: 6px 0; }
|
||||
.admin-room-table td:nth-child(2)::before { content: 'Rules: '; color: var(--primary); }
|
||||
.admin-room-table td:nth-child(3)::before { content: 'Progress: '; color: var(--primary); }
|
||||
.admin-room-table td:nth-child(4)::before { content: 'Last game change: '; color: var(--primary); }
|
||||
}
|
||||
|
||||
.admin-audit-list { list-style: none; padding: 0; display: grid; gap: 20px; }
|
||||
.admin-audit-list article { padding: 20px; border: 1px solid var(--border); background: #171c16; overflow-wrap: anywhere; }
|
||||
.admin-audit-list h3 { margin: 0; }
|
||||
.admin-audit-list time { color: #b7bbaf; }
|
||||
.admin-audit-envelope { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 18px; }
|
||||
.admin-audit-envelope dt, .admin-audit-changes dt { color: #b7bbaf; }
|
||||
.admin-audit-envelope dd, .admin-audit-changes dd { margin: 0; }
|
||||
.admin-audit-list summary { cursor: pointer; min-height: 44px; padding: 10px 0; color: var(--primary); }
|
||||
.admin-audit-changes { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; }
|
||||
@media (max-width: 700px) {
|
||||
.admin-audit-list article { padding: 16px; }
|
||||
.admin-audit-envelope { grid-template-columns: minmax(0, 1fr); }
|
||||
.admin-audit-envelope dd { margin-bottom: 10px; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||
import { AUDIT_ACTIONS, AUDIT_CATEGORIES, adminAuditResponse, type AdminAuditPage } from '@/lib/admin-audit';
|
||||
import '../admin.css';
|
||||
|
||||
type Filters = { q: string; category: string; from: string; to: string };
|
||||
const empty: Filters = { q:'',category:'all',from:'',to:'' };
|
||||
export default function ActionHistory() {
|
||||
const [filters,setFilters] = useState(empty);
|
||||
const [applied,setApplied] = useState(empty);
|
||||
const [result,setResult] = useState<AdminAuditPage | null>(null);
|
||||
const [loading,setLoading] = useState(true);
|
||||
const [denied,setDenied] = useState(false);
|
||||
const [notice,setNotice] = useState('');
|
||||
const [reload,setReload] = useState(0);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const selected = { q:query.get('q') ?? '',category:query.get('category') || 'all',from:query.get('from') ?? '',to:query.get('to') ?? '' };
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore shareable, non-secret history filters after SSR.
|
||||
setFilters(selected); setApplied(selected); setLoading(true); setResult(null); setDenied(false); setNotice('');
|
||||
requestJson<{ admin: { id: string } }>('/api/admin/session',{cache:'no-store'})
|
||||
.then(async session => adminAuditResponse(await requestJson<unknown>('/api/admin/audit?'+query,{cache:'no-store',headers:{'X-Dune-Admin-Id':session.admin.id}})))
|
||||
.then(page => { if (!canceled) setResult(page); })
|
||||
.catch(error => { if (!canceled) {
|
||||
setDenied(error instanceof ClientRequestError && [401,403].includes(error.status ?? 0));
|
||||
setNotice(error instanceof Error ? error.message : 'Action history could not be loaded.');
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; };
|
||||
},[reload]);
|
||||
useEffect(() => {
|
||||
const refresh = () => { if (document.visibilityState !== 'hidden') { setResult(null); setReload(value => value+1); } };
|
||||
window.addEventListener('focus',refresh); window.addEventListener('pageshow',refresh); document.addEventListener('visibilitychange',refresh);
|
||||
return () => { window.removeEventListener('focus',refresh); window.removeEventListener('pageshow',refresh); document.removeEventListener('visibilitychange',refresh); };
|
||||
},[]);
|
||||
const link = (page: number) => '/admin/audit?'+new URLSearchParams({...applied,page:String(page)});
|
||||
return <main className="admin-shell">
|
||||
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Action history</h1></div>
|
||||
<Button variant="outline" disabled={loading} onClick={() => setReload(value => value+1)}>Refresh history</Button></header>
|
||||
<p>Recorded administrator actions, newest first. This history describes what was applied; later actions may have changed the room again.</p>
|
||||
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||
{denied ? <p><a className="admin-room-link" href="/admin">Sign in to administration</a></p> : <>
|
||||
<form method="get" action="/admin/audit" className="admin-filters" aria-label="Filter action history">
|
||||
<label className="admin-search" htmlFor="audit-query">Room, seat or administrator<Input id="audit-query" name="q" maxLength={80} value={filters.q} onChange={event => setFilters({...filters,q:event.target.value})} /></label>
|
||||
<label htmlFor="audit-category">Action group<select id="audit-category" name="category" value={filters.category} onChange={event => setFilters({...filters,category:event.target.value})}>
|
||||
<option value="all">All actions</option>{Object.entries(AUDIT_CATEGORIES).map(([id,label]) => <option key={id} value={id}>{label}</option>)}
|
||||
</select></label>
|
||||
<label htmlFor="audit-from">From date (UTC)<Input id="audit-from" name="from" type="date" value={filters.from} onChange={event => setFilters({...filters,from:event.target.value})} /></label>
|
||||
<label htmlFor="audit-to">Through date (UTC)<Input id="audit-to" name="to" type="date" value={filters.to} onChange={event => setFilters({...filters,to:event.target.value})} /></label>
|
||||
<Button type="submit" disabled={loading}>Apply history filters</Button><a className="admin-room-link" href="/admin/audit">Clear filters</a>
|
||||
</form>
|
||||
{loading && <output>Loading action history…</output>}
|
||||
{result && <section aria-labelledby="audit-results-title">
|
||||
<h2 id="audit-results-title" className="admin-results">{result.total.toLocaleString()} recorded {result.total === 1 ? 'action' : 'actions'}</h2>
|
||||
<p className="admin-secondary">{result.reasonsVisible ? 'Operational reasons are visible to owners and operators.' : 'Your viewer role hides operational reasons.'} Search matches room/seat/account identifiers and current administrator names; it does not search reasons or private game contents.</p>
|
||||
{result.events.length === 0 ? <p>No records on this page. Adjust the filters or return to the first page.</p> : <ol className="admin-audit-list">{result.events.map(event => <li key={event.id}>
|
||||
<article aria-labelledby={'event-'+event.id}>
|
||||
<div className="admin-section-heading"><h3 id={'event-'+event.id}>{AUDIT_ACTIONS[event.action]}</h3>{event.createdAt === null ? <span>Time unavailable</span> : <time dateTime={new Date(event.createdAt).toISOString()}>{new Date(event.createdAt).toLocaleString()}</time>}</div>
|
||||
<p><span className="admin-tag">Recorded</span> {AUDIT_CATEGORIES[event.category]}{event.roomCode && <> · Room <strong className="admin-code">{event.roomCode}</strong></>}</p>
|
||||
<dl className="admin-audit-envelope">
|
||||
<dt>Administrator</dt><dd>{event.actorId ? <>{event.actorName ?? 'Account no longer available'} <span className="admin-secondary">({event.actorId})</span></> : 'External account operation; no administrator attribution was recorded'}</dd>
|
||||
{event.targetAdminId && <><dt>Account affected</dt><dd>{event.targetAdminName ?? 'Account no longer available'} <span className="admin-secondary">({event.targetAdminId})</span></dd></>}
|
||||
{event.targetSeatId && <><dt>Seat affected</dt><dd>{event.targetSeatId}</dd></>}
|
||||
{event.reason !== null && <><dt>Reason</dt><dd>{event.reason}</dd></>}
|
||||
</dl>
|
||||
{event.changes.length > 0 && <details><summary>Recorded settings</summary><dl className="admin-audit-changes">{event.changes.map(change => <div key={change.field}>
|
||||
<dt>{change.field}</dt><dd>{change.before !== null ? <><span className="admin-secondary">Before:</span> {change.before}<br /></> : null}{change.after !== null ? <><span className="admin-secondary">After:</span> {change.after}</> : null}</dd>
|
||||
</div>)}</dl></details>}
|
||||
</article>
|
||||
</li>)}</ol>}
|
||||
<nav className="admin-pagination" aria-label="Action history pages">
|
||||
{result.page > 1 && <><a className="admin-room-link" href={link(1)}>First page</a><a className="admin-room-link" href={link(result.page-1)}>Previous page</a></>}
|
||||
<span>Page {result.page} of {Math.max(1,Math.ceil(result.total/result.pageSize))}</span>
|
||||
{result.page*result.pageSize < result.total && <a className="admin-room-link" href={link(result.page+1)}>Next page</a>}
|
||||
</nav>
|
||||
<p className="admin-secondary">Times use your device’s time zone; date filters use UTC. Names reflect current administrator accounts. External CLI account operations may lack actor attribution; web-managed account changes record the acting owner. Rejected attempts are not yet recorded here. New actions may move records between pages.</p>
|
||||
</section>}
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AdminBackups } from '@/components/admin-backups';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||
import '../admin.css';
|
||||
|
||||
type Account = { id: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'Administrator access could not be checked.';
|
||||
|
||||
export default function BackupsPage() {
|
||||
const [owner, setOwner] = useState<Account | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' })
|
||||
.then(result => {
|
||||
if (canceled) return;
|
||||
if (result.admin?.role !== 'owner' || !result.admin.id) {
|
||||
setOwner(null);
|
||||
setNotice('Room backups are available only to an owner. Sign in with an owner account to continue.');
|
||||
} else { setOwner(result.admin); setNotice(''); }
|
||||
})
|
||||
.catch(error => { if (!canceled) {
|
||||
setOwner(null);
|
||||
setNotice(error instanceof ClientRequestError && error.status === 401
|
||||
? 'Administrator sign-in required. Sign in to administration with an owner account.' : errorMessage(error));
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; };
|
||||
}, [revision]);
|
||||
|
||||
return <main className="admin-shell">
|
||||
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Room backups</h1></div>
|
||||
<Button variant="outline" disabled={loading} onClick={() => { setOwner(null); setLoading(true); setRevision(value => value + 1); }}>Check access again</Button></header>
|
||||
<p>Owner-only snapshots preserve a room without changing live play. Backups cannot be restored from this page.</p>
|
||||
<p role="note" className="admin-notice">Privacy: downloaded snapshots contain private game state and may contain access credentials. Keep files secure; do not share them with players or upload them to public storage. This page shows metadata only, never the snapshot contents.</p>
|
||||
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||
{loading ? <output>Checking owner access…</output> : owner ?
|
||||
<AdminBackups key={owner.id} accountId={owner.id} onDenied={() => {
|
||||
setOwner(null); setNotice('Owner access changed or expired. Sign in again before using room backups.');
|
||||
}} /> : <p><a className="admin-room-link" href="/admin">Sign in to administration</a></p>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.admin-operations-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.admin-operations-grid > div {
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: #171c16;
|
||||
}
|
||||
|
||||
.admin-operations-grid dt { color: #b7bbaf; font-size: 0.9rem; }
|
||||
.admin-operations-grid dd { margin: 0.35rem 0 0; font-size: 1.2rem; overflow-wrap: anywhere; }
|
||||
|
||||
.admin-stalled-table {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-stalled-table caption { text-align: left; color: #b7bbaf; font-size: 0.9rem; padding-bottom: 0.35rem; }
|
||||
.admin-stalled-table th, .admin-stalled-table td {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
.admin-stalled-table thead th { color: #b7bbaf; font-size: 0.9rem; }
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||
import type { AdminAttempt, AdminAttemptReason } from '@/db/admin-attempts';
|
||||
import type { AdminIntegrity, AdminOperations, AdminStalledDecision } from '@/db/admin-operations';
|
||||
import '../admin.css';
|
||||
import './operations.css';
|
||||
|
||||
type Account = { id: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
type Snapshot = AdminOperations & { revision: string };
|
||||
type Stalled = { observedAt: number; stallMs: number; stalled: AdminStalledDecision[] };
|
||||
type Attempts = {
|
||||
observedAt: number;
|
||||
windowMs: number;
|
||||
total: number;
|
||||
byReason: { reason: AdminAttemptReason; count: number }[];
|
||||
recent: AdminAttempt[];
|
||||
};
|
||||
const ATTEMPT_LABELS: Record<AdminAttemptReason, string> = {
|
||||
invalid_key_format: 'Malformed access key',
|
||||
unknown_or_disabled_key: 'Unknown or disabled access key',
|
||||
missing_session: 'Missing or malformed session',
|
||||
unknown_or_expired_session: 'Unknown, expired or revoked session',
|
||||
role_denied: 'Role does not permit the action',
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Operational status is unavailable.';
|
||||
const idle = (idleMs: number) => `${Math.floor(idleMs / 3_600_000)}h ${Math.floor(idleMs % 3_600_000 / 60_000)}m`;
|
||||
|
||||
export default function OperationsPage() {
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [integrity, setIntegrity] = useState<AdminIntegrity | null>(null);
|
||||
const [integrityNotice, setIntegrityNotice] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const integrityEpoch = useRef(0);
|
||||
const [stalled, setStalled] = useState<Stalled | null>(null);
|
||||
const [stalledNotice, setStalledNotice] = useState('');
|
||||
const [loadingStalled, setLoadingStalled] = useState(false);
|
||||
const stalledEpoch = useRef(0);
|
||||
const [attempts, setAttempts] = useState<Attempts | null>(null);
|
||||
const [attemptsNotice, setAttemptsNotice] = useState('');
|
||||
const [loadingAttempts, setLoadingAttempts] = useState(false);
|
||||
const attemptsEpoch = useRef(0);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const epoch = integrityEpoch;
|
||||
requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' })
|
||||
.then(async ({ admin }) => {
|
||||
if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.');
|
||||
const result = await requestJson<Snapshot>('/api/admin/operations', {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id },
|
||||
});
|
||||
if (!canceled) { setSnapshot(result); setNotice(''); }
|
||||
})
|
||||
.catch(error => {
|
||||
if (canceled) return;
|
||||
setSnapshot(null);
|
||||
setNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)
|
||||
? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
})
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; epoch.current++; };
|
||||
}, [refresh]);
|
||||
|
||||
async function checkIntegrity() {
|
||||
const epoch = ++integrityEpoch.current;
|
||||
setChecking(true);
|
||||
setIntegrity(null);
|
||||
setIntegrityNotice('');
|
||||
try {
|
||||
const { admin } = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' });
|
||||
if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.');
|
||||
const result = await requestJson<AdminIntegrity>('/api/admin/operations?integrity=1', {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id },
|
||||
});
|
||||
if (epoch === integrityEpoch.current) setIntegrity(result);
|
||||
} catch (error) {
|
||||
if (epoch === integrityEpoch.current)
|
||||
setIntegrityNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)
|
||||
? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
} finally {
|
||||
if (epoch === integrityEpoch.current) setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStalled() {
|
||||
const epoch = ++stalledEpoch.current;
|
||||
setLoadingStalled(true);
|
||||
setStalled(null);
|
||||
setStalledNotice('');
|
||||
try {
|
||||
const { admin } = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' });
|
||||
if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.');
|
||||
const result = await requestJson<Stalled>('/api/admin/operations?stalled=1', {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id },
|
||||
});
|
||||
if (epoch === stalledEpoch.current) setStalled(result);
|
||||
} catch (error) {
|
||||
if (epoch === stalledEpoch.current)
|
||||
setStalledNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)
|
||||
? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
} finally {
|
||||
if (epoch === stalledEpoch.current) setLoadingStalled(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAttempts() {
|
||||
const epoch = ++attemptsEpoch.current;
|
||||
setLoadingAttempts(true);
|
||||
setAttempts(null);
|
||||
setAttemptsNotice('');
|
||||
try {
|
||||
const { admin } = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' });
|
||||
if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.');
|
||||
const result = await requestJson<Attempts>('/api/admin/operations?attempts=1', {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id },
|
||||
});
|
||||
if (epoch === attemptsEpoch.current) setAttempts(result);
|
||||
} catch (error) {
|
||||
if (epoch === attemptsEpoch.current)
|
||||
setAttemptsNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)
|
||||
? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
} finally {
|
||||
if (epoch === attemptsEpoch.current) setLoadingAttempts(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <main className="admin-shell">
|
||||
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Operations</h1></div>
|
||||
<Button variant="outline" disabled={loading} onClick={() => {
|
||||
integrityEpoch.current++;
|
||||
stalledEpoch.current++;
|
||||
attemptsEpoch.current++;
|
||||
setAttempts(null); setAttemptsNotice(''); setLoadingAttempts(false);
|
||||
setIntegrity(null); setIntegrityNotice(''); setChecking(false);
|
||||
setStalled(null); setStalledNotice(''); setLoadingStalled(false);
|
||||
setSnapshot(null); setLoading(true); setRefresh(value => value + 1);
|
||||
}}>Refresh status</Button></header>
|
||||
<p>Owner-only, read-only counts from the current database and the revision baked into this server build. Refresh to take a new sample; no monitoring or automatic repair runs here.</p>
|
||||
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||
{loading ? <output>Checking owner access and database…</output> : snapshot ? <section aria-labelledby="operations-sample">
|
||||
<h2 id="operations-sample">Sample at {new Date(snapshot.observedAt).toLocaleString()}</h2>
|
||||
<dl className="admin-operations-grid">
|
||||
<div><dt>Server build revision</dt><dd>{snapshot.revision}</dd></div>
|
||||
<div><dt>Saved rooms (including removed/archived)</dt><dd>{snapshot.rooms}</dd></div>
|
||||
<div><dt>Removed rooms</dt><dd>{snapshot.removedRooms}</dd></div>
|
||||
<div><dt>Archived rooms</dt><dd>{snapshot.archivedRooms}</dd></div>
|
||||
<div><dt>Paused rooms</dt><dd>{snapshot.pausedRooms}</dd></div>
|
||||
<div><dt>Closed rooms</dt><dd>{snapshot.closedRooms}</dd></div>
|
||||
<div><dt>Active seat credentials</dt><dd>{snapshot.activeSeats}</dd></div>
|
||||
<div><dt>Invalid JSON room states</dt><dd>{snapshot.unreadableRooms}</dd></div>
|
||||
<div><dt>Latest room write</dt><dd>{snapshot.lastRoomChange === null ? 'No rooms' : new Date(snapshot.lastRoomChange).toLocaleString()}</dd></div>
|
||||
<div><dt>Room backup snapshots</dt><dd>{snapshot.backupSnapshots}</dd></div>
|
||||
<div><dt>Backup snapshot payload bytes</dt><dd>{snapshot.backupBytes.toLocaleString()}</dd></div>
|
||||
<div><dt>Recorded private exports</dt><dd>{snapshot.backupDownloads}</dd></div>
|
||||
<div><dt>Rooms waiting on a stalled interaction</dt><dd>{snapshot.stalledRooms}</dd></div>
|
||||
<div><dt>Oldest stalled room write</dt><dd>{snapshot.oldestStalledChange === null ? 'None stalled' : new Date(snapshot.oldestStalledChange).toLocaleString()}</dd></div>
|
||||
<div><dt>Database size</dt><dd>{(snapshot.databaseBytes / 1048576).toFixed(1)} MB</dd></div>
|
||||
<div><dt>Database pages</dt><dd>{snapshot.databasePages.toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
<p>These counters do not check SQLite integrity, disk free space, backup recoverability or server health outside this request. The invalid JSON count detects syntax only, not rule-level save validity; this page cannot repair a room.</p>
|
||||
<Button variant="outline" disabled={loadingStalled} onClick={() => void checkStalled()}>List stalled decisions</Button>
|
||||
<p>Read-only sample of up to ten live rooms whose public pending interaction has been idle longest. It excludes paused, closed, removed and archived rooms and shows no private game contents.</p>
|
||||
{loadingStalled && <output>Sampling stalled rooms…</output>}
|
||||
{stalledNotice && <p role="alert" className="admin-notice">{stalledNotice}</p>}
|
||||
{stalled && <section aria-label="Stalled decisions">
|
||||
<p>Sampled {new Date(stalled.observedAt).toLocaleString()}; idle means no room write for {Math.round(stalled.stallMs / 3_600_000)} hours.</p>
|
||||
{stalled.stalled.length === 0 ? <p>No live room is waiting on a stalled interaction.</p>
|
||||
: <table className="admin-stalled-table">
|
||||
<caption>Stalled rooms, longest idle first</caption>
|
||||
<thead><tr><th scope="col">Room</th><th scope="col">Waiting on</th><th scope="col">Turn</th><th scope="col">Phase</th><th scope="col">Idle</th></tr></thead>
|
||||
<tbody>{stalled.stalled.map(room => <tr key={room.code}>
|
||||
<th scope="row">{room.code}</th>
|
||||
<td>{room.kind}</td>
|
||||
<td>{room.turn ?? '—'}</td>
|
||||
<td>{room.phase ?? '—'}</td>
|
||||
<td>{idle(room.idleMs)}</td>
|
||||
</tr>)}</tbody>
|
||||
</table>}
|
||||
<p>This sample changes nothing. Pause, resume, close or remove a room only through its own controls and audit.</p>
|
||||
</section>}
|
||||
<Button variant="outline" disabled={loadingAttempts} onClick={() => void checkAttempts()}>List failed attempts</Button>
|
||||
<p>Read-only counters and the twenty most recent rejected administrator requests. Only a fixed reason, the attempted role and an already-known account id are stored: never a key, session token, request body or address.</p>
|
||||
{loadingAttempts && <output>Sampling rejected requests…</output>}
|
||||
{attemptsNotice && <p role="alert" className="admin-notice">{attemptsNotice}</p>}
|
||||
{attempts && <section aria-label="Failed administrator attempts">
|
||||
<p>Sampled {new Date(attempts.observedAt).toLocaleString()}; counters cover the last {Math.round(attempts.windowMs / 86_400_000)} days ({attempts.total} rejected requests).</p>
|
||||
{attempts.byReason.length === 0 ? <p>No rejected administrator request was recorded in the window.</p>
|
||||
: <ul>{attempts.byReason.map(row => <li key={row.reason}>{ATTEMPT_LABELS[row.reason]}: <strong>{row.count}</strong></li>)}</ul>}
|
||||
{attempts.recent.length === 0 ? null
|
||||
: <table className="admin-stalled-table">
|
||||
<caption>Most recent rejected requests, newest first</caption>
|
||||
<thead><tr><th scope="col">When</th><th scope="col">Kind</th><th scope="col">Reason</th><th scope="col">Role</th><th scope="col">Account</th></tr></thead>
|
||||
<tbody>{attempts.recent.map((row, index) => <tr key={`${row.createdAt}-${index}`}>
|
||||
<td>{new Date(row.createdAt).toLocaleString()}</td>
|
||||
<td>{row.kind}</td>
|
||||
<td>{ATTEMPT_LABELS[row.reason]}</td>
|
||||
<td>{row.role ?? '—'}</td>
|
||||
<td>{row.accountId ? `${row.accountId.slice(0, 8)}…` : '—'}</td>
|
||||
</tr>)}</tbody>
|
||||
</table>}
|
||||
<p>This sample changes nothing and identifies no credential. Use it to notice repeated failures, not to lock out a seat or account automatically.</p>
|
||||
</section>}
|
||||
<Button variant="outline" disabled={checking} onClick={() => void checkIntegrity()}>Run integrity check</Button>
|
||||
<p>This manual, read-only SQLite quick check and foreign-key check may take time. It reports pass/fail only: not disk capacity, backup recoverability or game-rule validity.</p>
|
||||
{checking && <output>Checking database structure and references…</output>}
|
||||
{integrityNotice && <p role="alert" className="admin-notice">{integrityNotice}</p>}
|
||||
{integrity && <section aria-label="Database integrity result">
|
||||
<p>Checked {new Date(integrity.observedAt).toLocaleString()}.</p>
|
||||
<p>SQLite structure: <strong>{integrity.sqlite === 'passed' ? 'Passed' : 'Failed'}</strong>.
|
||||
Foreign keys: <strong>{integrity.foreignKeys === 'passed' ? 'Passed' : 'Failed'}</strong>.</p>
|
||||
{integrity.sqlite === 'failed' || integrity.foreignKeys === 'failed'
|
||||
? <p role="alert">Do not repair or restore this database automatically. Preserve a protected snapshot and investigate privately.</p> : null}
|
||||
</section>}
|
||||
</section> : <p><a className="admin-room-link" href="/admin">Sign in to administration</a></p>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Shield, RefreshCw, LogOut } from 'lucide-react';
|
||||
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||
import type { AdminDirectory } from '@/lib/admin-directory';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import { AdminRoomControls } from '@/components/admin-room-controls';
|
||||
import { AdminRoomCreation } from '@/components/admin-room-creation';
|
||||
import { AdminLobbyControls } from '@/components/admin-lobby-controls';
|
||||
import { AdminDiscussion } from '@/components/admin-discussion';
|
||||
import { AdminSeatAi } from '@/components/admin-seat-ai';
|
||||
import { AdminRoomArchive } from '@/components/admin-room-archive';
|
||||
import { AdminRoomClosure } from '@/components/admin-room-closure';
|
||||
import { AdminRoomRemoval } from '@/components/admin-room-removal';
|
||||
import './admin.css';
|
||||
|
||||
type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
type Filters = { q: string; status: string; rules: string; sort: string; availability: string; removal: string; archive: string };
|
||||
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all', removal: 'active', archive: 'unarchived' };
|
||||
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
|
||||
|
||||
export default function Administration() {
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [restoring, setRestoring] = useState(true);
|
||||
const [key, setKey] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [directory, setDirectory] = useState<AdminDirectory | null>(null);
|
||||
const [filters, setFilters] = useState(initialFilters);
|
||||
const [applied, setApplied] = useState(initialFilters);
|
||||
const [confirmAll, setConfirmAll] = useState(false);
|
||||
const [selectedDiscussion, setSelectedDiscussion] = useState<string | null>(null);
|
||||
const [selectedSeatAi, setSelectedSeatAi] = useState<string | null>(null);
|
||||
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
|
||||
const [selectedLobby, setSelectedLobby] = useState<string | null>(null);
|
||||
const [selectedArchive, setSelectedArchive] = useState<string | null>(null);
|
||||
const [selectedClosure, setSelectedClosure] = useState<string | null>(null);
|
||||
const [selectedRemoval, setSelectedRemoval] = useState<string | null>(null);
|
||||
const [creatingRoom, setCreatingRoom] = useState(false);
|
||||
const [operationBusy, setOperationBusy] = useState(false);
|
||||
const controlsBusy = busy || operationBusy;
|
||||
const epoch = useRef(0);
|
||||
const clearSession = useCallback(() => {
|
||||
epoch.current++;
|
||||
setAccount(null); setDirectory(null); setConfirmAll(false);
|
||||
setBusy(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false);
|
||||
}, []);
|
||||
const accessChanged = useCallback(() => {
|
||||
clearSession();
|
||||
setNotice('Administrator access changed. Sign in again to confirm any saved room request.');
|
||||
}, [clearSession]);
|
||||
const load = useCallback(async (next: Filters, page = 1) => {
|
||||
const current = ++epoch.current;
|
||||
setBusy(true); setNotice('');
|
||||
try {
|
||||
const query = new URLSearchParams({ ...next, page: String(page) });
|
||||
const result = await requestJson<AdminDirectory>('/api/admin/rooms?' + query, { cache: 'no-store' });
|
||||
if (current === epoch.current) { setDirectory(result); setApplied(next); }
|
||||
} catch (error) {
|
||||
if (current === epoch.current) {
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) clearSession();
|
||||
setNotice(errorMessage(error));
|
||||
}
|
||||
} finally { if (current === epoch.current) setBusy(false); }
|
||||
}, [clearSession]);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' })
|
||||
.then(async result => { if (!canceled) { setAccount(result.admin); await load(initialFilters); } })
|
||||
.catch(error => { if (!canceled && !(error instanceof ClientRequestError && error.status === 401)) setNotice(errorMessage(error)); })
|
||||
.finally(() => { if (!canceled) setRestoring(false); });
|
||||
return () => { canceled = true; };
|
||||
}, [load]);
|
||||
|
||||
async function sessionAction(action: 'login' | 'logout' | 'logoutAll') {
|
||||
if (controlsBusy) return;
|
||||
setBusy(true); setNotice('');
|
||||
const submittedKey = key.trim();
|
||||
setKey('');
|
||||
try {
|
||||
const result = await requestJson<{ admin?: Account }>('/api/admin/session', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, ...(action === 'login' ? { key: submittedKey } : {}) }),
|
||||
});
|
||||
if (result.admin) { setAccount(result.admin); await load(initialFilters); }
|
||||
else { clearSession(); setNotice(action === 'logoutAll' ? 'All your administrator sessions are signed out.' : 'Signed out.'); }
|
||||
} catch (error) {
|
||||
// Reconcile an uncertain response with a read; never repeat a mutation automatically.
|
||||
try {
|
||||
const result = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' });
|
||||
setAccount(result.admin);
|
||||
await load(initialFilters);
|
||||
setNotice(action === 'login' ? '' : 'You are still signed in. Retry the sign-out action.');
|
||||
} catch (readError) {
|
||||
if (readError instanceof ClientRequestError && readError.status === 401) clearSession();
|
||||
setNotice(errorMessage(error));
|
||||
}
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <main className="admin-shell">
|
||||
<header className="admin-header">
|
||||
<div><a className="admin-return" href="/">Arrakis Table</a><h1><Shield aria-hidden="true" /> Administration</h1></div>
|
||||
{account && <div className="admin-session"><span>{account.name} <span className="admin-tag">{account.role}</span></span>
|
||||
<Button variant="outline" disabled={controlsBusy} onClick={() => void sessionAction('logout')}><LogOut aria-hidden="true" /> Sign out</Button></div>}
|
||||
</header>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{restoring ? <output>Checking administrator access…</output> : !account ?
|
||||
<section className="admin-login" aria-labelledby="admin-sign-in">
|
||||
<h2 id="admin-sign-in">Administrator sign-in</h2>
|
||||
<p>Use your personal administrator access key. Room invitations and saved-seat recovery kits cannot sign in here.</p>
|
||||
<form onSubmit={event => { event.preventDefault(); void sessionAction('login'); }}>
|
||||
<label htmlFor="admin-key">Access key</label>
|
||||
<Input id="admin-key" type="password" autoComplete="current-password" autoCapitalize="none" spellCheck={false}
|
||||
maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={controlsBusy} />
|
||||
<Button type="submit" disabled={controlsBusy || !key.trim()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
|
||||
</form>
|
||||
<p className="admin-secondary">First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.</p>
|
||||
</section> : <>
|
||||
<section aria-labelledby="admin-rooms-title">
|
||||
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
|
||||
<div className="admin-room-actions"><a className="admin-room-link" href="/admin/audit">Action history</a>{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
|
||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/backups">Room backups</a>}
|
||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/operations">Operations</a>}
|
||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/accounts">Accounts</a>}
|
||||
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
|
||||
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
|
||||
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
|
||||
onCreated={code => { const next = { ...initialFilters, q: code }; setFilters(next); void load(next); }} />}
|
||||
<form className="admin-filters" onSubmit={event => { event.preventDefault(); void load(filters); }}>
|
||||
<label className="admin-search" htmlFor="admin-search">Room code or player name<Input id="admin-search" value={filters.q} maxLength={80} disabled={controlsBusy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
|
||||
<label>Directory<select disabled={controlsBusy} value={filters.removal} onChange={e => setFilters({ ...filters, removal: e.target.value })}>
|
||||
<option value="active">Active rooms</option><option value="removed">Removed rooms</option><option value="all">All rooms</option>
|
||||
</select></label>
|
||||
<label>Archive<select disabled={controlsBusy} value={filters.archive} onChange={e => setFilters({ ...filters, archive: e.target.value })}>
|
||||
<option value="unarchived">Unarchived rooms</option><option value="archived">Archived rooms</option><option value="all">All rooms</option>
|
||||
</select></label>
|
||||
<label>Status<select disabled={controlsBusy} value={filters.status} onChange={e => setFilters({ ...filters, status: e.target.value })}>
|
||||
<option value="all">All statuses</option><option value="lobby">Lobby</option><option value="setup">Setup</option><option value="playing">Playing</option><option value="finished">Finished</option>
|
||||
</select></label>
|
||||
<label>Rules<select disabled={controlsBusy} value={filters.rules} onChange={e => setFilters({ ...filters, rules: e.target.value })}>
|
||||
<option value="all">All rules</option><option value="basic">Basic</option><option value="advanced">Advanced</option>
|
||||
</select></label>
|
||||
<label>Availability<select disabled={controlsBusy} value={filters.availability} onChange={e => setFilters({ ...filters, availability: e.target.value })}>
|
||||
<option value="all">All rooms</option><option value="running">Running</option><option value="closed">Closed</option><option value="paused">Paused</option><option value="locked">New joins locked</option>
|
||||
</select></label>
|
||||
<label>Sort<select disabled={controlsBusy} value={filters.sort} onChange={e => setFilters({ ...filters, sort: e.target.value })}>
|
||||
<option value="recent">Recently changed</option><option value="oldest">Oldest change</option><option value="code">Room code</option>
|
||||
</select></label>
|
||||
<Button type="submit" disabled={controlsBusy}>Apply filters</Button>
|
||||
</form>
|
||||
{selectedDiscussion && <AdminDiscussion key={`${account.id}:${selectedDiscussion}`} accountId={account.id} code={selectedDiscussion}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedDiscussion(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
{selectedSeatAi && <AdminSeatAi key={`${account.id}:${selectedSeatAi}`} accountId={account.id} code={selectedSeatAi}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedSeatAi(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
{selectedRoom && <AdminRoomControls key={`${account.id}:${selectedRoom}`} accountId={account.id} code={selectedRoom}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedRoom(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
{selectedLobby && <AdminLobbyControls key={`${account.id}:${selectedLobby}`} accountId={account.id} code={selectedLobby}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedLobby(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
{selectedArchive && <AdminRoomArchive key={`${account.id}:${selectedArchive}`} accountId={account.id} code={selectedArchive}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedArchive(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
{selectedClosure && <AdminRoomClosure key={`${account.id}:${selectedClosure}`} accountId={account.id} code={selectedClosure}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedClosure(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
{selectedRemoval && <AdminRoomRemoval key={`${account.id}:${selectedRemoval}`} accountId={account.id} code={selectedRemoval}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedRemoval(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
<div aria-busy={busy}>
|
||||
{!directory ? <output>{busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'}</output> : <>
|
||||
<p className="admin-results">{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match</p>
|
||||
{directory.rooms.length === 0 ? <p>No rooms on this page. Change the filters or return to the first page.</p> :
|
||||
<Table className="admin-room-table"><TableHeader><TableRow>
|
||||
<TableHead>Room & players</TableHead><TableHead>Rules</TableHead><TableHead>Progress</TableHead><TableHead>Last game change</TableHead>
|
||||
</TableRow></TableHeader><TableBody>{directory.rooms.map(room => <TableRow key={room.code}>
|
||||
<TableCell><strong className="admin-code">{room.code}</strong> <span className="admin-tag">{room.status}</span>
|
||||
{room.archived && <span className="admin-tag">Archived</span>} {room.removed && <span className="admin-tag">Removed</span>}
|
||||
{room.control.closed && <span className="admin-tag">Closed</span>}
|
||||
{room.control.paused && <span className="admin-tag">Paused</span>}
|
||||
{room.control.joinLocked && <span className="admin-tag">Joins locked</span>}
|
||||
<p>Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}</p>
|
||||
<details><summary>{room.players.length} {room.players.length === 1 ? 'player' : 'players'}</summary><ul>{room.players.map(player => <li key={player.id}>{player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}</li>)}</ul></details>
|
||||
</TableCell>
|
||||
<TableCell>{room.advanced ? room.preview ? 'Advanced preview' : 'Advanced development' : 'Basic'}
|
||||
{room.expansions.length > 0 && <p>Expansions: {room.expansions.map(id => id === 'ix' ? 'Ixians & Tleilaxu' : id === 'choam' ? 'CHOAM & Richese' : 'Ecaz & Moritani').join(', ')}</p>}
|
||||
<p className="admin-secondary">{room.modules.join(', ') || 'No optional modules'}</p>
|
||||
</TableCell>
|
||||
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
|
||||
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
|
||||
</TableCell>
|
||||
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedSeatAi(null); setSelectedDiscussion(room.code); }}>Discussion controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedDiscussion(null); setSelectedSeatAi(room.code); }}>Participant AI · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(room.code); }}>Archive or unarchive · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedArchive(null); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(room.code); }}>Close or reopen · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); setSelectedClosure(null); setSelectedArchive(null); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
|
||||
</TableRow>)}</TableBody></Table>}
|
||||
<nav className="admin-pagination" aria-label="Room pages">
|
||||
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
|
||||
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, directory.page - 1)}>Previous</Button>
|
||||
<span>Page {directory.page} of {Math.max(1, Math.ceil(directory.total / directory.pageSize))}</span>
|
||||
<Button variant="outline" disabled={controlsBusy || directory.page * directory.pageSize >= directory.total} onClick={() => void load(applied, directory.page + 1)}>Next</Button>
|
||||
</nav>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="admin-session-control" aria-labelledby="admin-access-title"><h2 id="admin-access-title">Your access</h2>
|
||||
<p>Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.</p>
|
||||
{!confirmAll ? <Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
|
||||
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={controlsBusy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
|
||||
</section>
|
||||
<p className="admin-secondary admin-development">Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, close/reopen play, archive/unarchive closed rooms, and recoverably remove or restore rooms. Participant AI can support human seats in paused games; discussion controls mute or unmute new messages from a seat. Permanent removal, further participant support and backup tools are still being implemented.</p>
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminAccount, readAdminAccounts } from '@/db/admin-accounts';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
import { validAdminAccountInput } from '@/lib/admin-accounts';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||
if (expected && expected !== identity.id) throw new AdminError('The signed-in administrator changed. Reload accounts.', 401);
|
||||
return adminResponse(await readAdminAccounts(env.DB, identity, new URL(request.url).searchParams));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload accounts.', 401);
|
||||
if (!validAdminAccountInput(body)) throw new AdminError('Check the account details, operation identifier and operational reason.', 400);
|
||||
return adminResponse(await applyAdminAccount(env.DB, identity, body));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { readAdminAudit } from '@/db/admin-audit';
|
||||
import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB,adminToken(request),['owner','operator','viewer']);
|
||||
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||
if (expected && expected !== identity.id) throw new AdminError('The signed-in administrator changed. Reload action history.',401);
|
||||
return adminResponse(await readAdminAudit(env.DB,identity,new URL(request.url).searchParams));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { downloadAdminBackup } from '@/db/admin-backups';
|
||||
import { adminFailure, adminToken } from '@/lib/admin-http';
|
||||
|
||||
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||
if (expected !== null && expected !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
const { id } = await context.params;
|
||||
const { backup, payload } = await downloadAdminBackup(env.DB, identity, id);
|
||||
return new Response(payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="dune-room-${backup.roomCode}-backup-${backup.id}.json"`,
|
||||
'Cache-Control': 'no-store',
|
||||
'Vary': 'Cookie',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { captureAdminBackup, listAdminBackups, validBackupInput } from '@/db/admin-backups';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
function matchIdentity(request: Request, id: string, mutation: boolean) {
|
||||
const header = request.headers.get('X-Dune-Admin-Id');
|
||||
if ((mutation || header !== null) && header !== id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
matchIdentity(request, identity.id, false);
|
||||
const search = new URL(request.url).searchParams;
|
||||
if ([...search.keys()].some(key => key !== 'room') || search.getAll('room').length !== 1)
|
||||
throw new AdminError('Enter exactly one room code.', 400);
|
||||
return adminResponse({ backups: await listAdminBackups(env.DB, identity, search.get('room') ?? '') });
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
matchIdentity(request, identity.id, true);
|
||||
if (!validBackupInput(input)) throw new AdminError('Provide a room, current version, operation identifier and operational reason.', 400);
|
||||
return adminResponse(await captureAdminBackup(env.DB, identity, input));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { readAdminAttempts } from '@/db/admin-attempts';
|
||||
import { ADMIN_STALL_MS, readAdminIntegrity, readAdminOperations, readAdminStalledDecisions } from '@/db/admin-operations';
|
||||
import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
import { buildRevision } from '@/lib/build-revision';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||
if (expected && expected !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload operations.', 401);
|
||||
const query = new URL(request.url).searchParams;
|
||||
const reads = ['integrity', 'stalled', 'attempts'];
|
||||
if (query.size && (query.size !== 1 || !reads.some(read => query.get(read) === '1')))
|
||||
throw new AdminError('Invalid operations request.', 400);
|
||||
if (query.get('integrity') === '1')
|
||||
return adminResponse(await readAdminIntegrity(env.DB, identity));
|
||||
if (query.get('stalled') === '1')
|
||||
return adminResponse({ revision: buildRevision, observedAt: Date.now(),
|
||||
stallMs: ADMIN_STALL_MS, stalled: await readAdminStalledDecisions(env.DB, identity) });
|
||||
if (query.get('attempts') === '1')
|
||||
return adminResponse({ revision: buildRevision, ...await readAdminAttempts(env.DB, identity) });
|
||||
return adminResponse({ revision: buildRevision, ...await readAdminOperations(env.DB, identity) });
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminArchive, readAdminArchive } from '@/db/admin-archive';
|
||||
import { validAdminArchiveInput } from '@/lib/admin-archive';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
function code(request: Request) {
|
||||
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
|
||||
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request));
|
||||
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
return adminResponse(await readAdminArchive(env.DB, identity, code(request)));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminArchiveInput(input)) throw new AdminError('Choose archive or unarchive, current versions and an operational reason.', 400);
|
||||
const room = code(request);
|
||||
const result = await applyAdminArchive(env.DB, identity, room, input);
|
||||
return adminResponse(result);
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { env, waitUntil } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminClosure, readAdminClosure } from '@/db/admin-closure';
|
||||
import { resumeRoom } from '@/db/room-continuation';
|
||||
import { validAdminClosureInput } from '@/lib/admin-closure';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
function code(request: Request) {
|
||||
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
|
||||
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request));
|
||||
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
return adminResponse(await readAdminClosure(env.DB, identity, code(request)));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminClosureInput(input)) throw new AdminError('Choose closure or reopening, current versions and an operational reason.', 400);
|
||||
const room = code(request);
|
||||
const result = await applyAdminClosure(env.DB, identity, room, input);
|
||||
if (!result.replayed && !result.room.closed && !result.room.paused) waitUntil(resumeRoom(room));
|
||||
return adminResponse(result);
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { env, waitUntil } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminRoomControl, readAdminRoomControl } from '@/db/admin-lifecycle';
|
||||
import { resumeRoom } from '@/db/room-continuation';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
import { validAdminRoomControlInput } from '@/lib/room-control';
|
||||
|
||||
function roomCode(request: Request) {
|
||||
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
|
||||
if (!/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request));
|
||||
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
return adminResponse(await readAdminRoomControl(env.DB, identity, roomCode(request)));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminRoomControlInput(body)) throw new AdminError('Provide room settings and a reason of 1–300 characters.', 400);
|
||||
const code = roomCode(request);
|
||||
const result = await applyAdminRoomControl(env.DB, identity, code, body);
|
||||
if (!result.paused && !result.closed) waitUntil(resumeRoom(code));
|
||||
return adminResponse(result);
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminDiscussion, readAdminDiscussion } from '@/db/admin-discussion';
|
||||
import { validAdminDiscussionInput } from '@/lib/admin-discussion';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
function code(request: Request) {
|
||||
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
|
||||
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
return code;
|
||||
}
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request));
|
||||
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
return adminResponse(await readAdminDiscussion(env.DB, identity, code(request)));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner','operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminDiscussionInput(input)) throw new AdminError('Choose a participant, discussion setting, current versions and a short reason.', 400);
|
||||
return adminResponse(await applyAdminDiscussion(env.DB, identity, code(request), input));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { configureAdminLobby, readAdminLobby } from '@/db/admin-lobby-configuration';
|
||||
import { validAdminLobbyInput } from '@/lib/admin-lobby-configuration';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
function code(request: Request) {
|
||||
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
|
||||
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request));
|
||||
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
return adminResponse(await readAdminLobby(env.DB, identity, code(request)));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminLobbyInput(body)) throw new AdminError('Choose a lobby change, revision and operational reason.', 400);
|
||||
return adminResponse(await configureAdminLobby(env.DB, identity, code(request), body));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { env, waitUntil } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminRemoval, readAdminRemoval } from '@/db/admin-removal';
|
||||
import { resumeRoom } from '@/db/room-continuation';
|
||||
import { validAdminRemovalInput } from '@/lib/admin-removal';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
function code(request: Request) {
|
||||
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
|
||||
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request));
|
||||
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
return adminResponse(await readAdminRemoval(env.DB, identity, code(request)));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminRemovalInput(input)) throw new AdminError('Choose removal or restoration, current versions and an operational reason.', 400);
|
||||
const room = code(request);
|
||||
const result = await applyAdminRemoval(env.DB, identity, room, input);
|
||||
if (!result.replayed && !result.room.removed && !result.room.paused && !result.room.closed) waitUntil(resumeRoom(room));
|
||||
return adminResponse(result);
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminSeatAi, readAdminSeatAi } from '@/db/admin-seat-ai';
|
||||
import { validAdminSeatAiInput } from '@/lib/admin-seat-ai';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
function code(request: Request) {
|
||||
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
|
||||
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request));
|
||||
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
return adminResponse(await readAdminSeatAi(env.DB, identity, code(request)));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminSeatAiInput(input)) throw new AdminError('Choose a participant, difficulty, current versions and an operational reason.', 400);
|
||||
const room = code(request);
|
||||
const result = await applyAdminSeatAi(env.DB, identity, room, input);
|
||||
return adminResponse(result);
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { readAdminDirectory } from '@/db/admin-directory';
|
||||
import { createAdminRoom } from '@/db/admin-room-creation';
|
||||
import { RuleError } from '@/game/engine';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
import { validAdminRoomCreationInput } from '@/lib/admin-room-creation';
|
||||
import { requestOrigin } from '@/lib/request-origin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator', 'viewer']);
|
||||
return adminResponse(await readAdminDirectory(env.DB, identity, new URL(request.url).searchParams));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||
if (!validAdminRoomCreationInput(body)) throw new AdminError('Check the host, rules, AI seats and operational reason.', 400);
|
||||
const result = await createAdminRoom(env.DB, identity, body);
|
||||
const headers: Record<string, string> = {};
|
||||
if (result.hostAccess) headers['Set-Cookie'] = `dune_${result.code}=${body.sessionToken}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.code}; Max-Age=2592000${requestOrigin(request, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`;
|
||||
return adminResponse(result, headers);
|
||||
} catch (error) { return adminFailure(error instanceof RuleError ? new AdminError(error.message, 400) : error); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, adminLogin, adminLogout, adminLogoutAll, requireAdmin } from '@/db/admin-access';
|
||||
import { adminBody, adminCookie, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { id, name, role } = await requireAdmin(env.DB, adminToken(request));
|
||||
return adminResponse({ admin: { id, name, role } });
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
if (body.action === 'login' && Object.keys(body).every(k => ['action', 'key'].includes(k))) {
|
||||
const result = await adminLogin(env.DB, body.key);
|
||||
return adminResponse({ admin: result.admin }, { 'Set-Cookie': adminCookie(request, result.token, env.DUNE_PUBLIC_ORIGIN) });
|
||||
}
|
||||
if (Object.keys(body).length !== 1) throw new AdminError('Invalid session action.', 400);
|
||||
if (body.action === 'logout') await adminLogout(env.DB, adminToken(request));
|
||||
else if (body.action === 'logoutAll') await adminLogoutAll(env.DB, await requireAdmin(env.DB, adminToken(request)));
|
||||
else throw new AdminError('Choose a valid session action.', 400);
|
||||
return adminResponse({ signedOut: true }, { 'Set-Cookie': adminCookie(request, '', env.DUNE_PUBLIC_ORIGIN) });
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { requestOrigin } from '@/lib/request-origin';
|
||||
import { authenticateSelectedSeat, selectedSeatId, seatCookie, roomSeatCookies } from '@/lib/selected-seat';
|
||||
import {
|
||||
authenticate,
|
||||
createSeatHandover,
|
||||
revokeSeatHandover,
|
||||
claimSeatHandover,
|
||||
recoverSeat,
|
||||
SeatControlError,
|
||||
RoomEntryError,
|
||||
setRecoveryKey,
|
||||
setSeatAiDelegate,
|
||||
revokeSeatAiDelegate,
|
||||
@@ -14,12 +16,12 @@ import {
|
||||
import { RuleError } from '@/game/engine';
|
||||
import { validSeatAiDelegateRequest } from '@/lib/seat-ai-delegation';
|
||||
import { resumeRoom } from '@/db/room-continuation';
|
||||
import { waitUntil } from 'cloudflare:workers';
|
||||
import { env, waitUntil } from 'cloudflare:workers';
|
||||
const noStore = { 'Cache-Control': 'no-store' };
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
if (req.headers.get('origin') && req.headers.get('origin') !== url.origin)
|
||||
if (req.headers.get('origin') && req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN))
|
||||
throw new SeatControlError(
|
||||
'Invalid request origin.',
|
||||
'INVALID_ORIGIN',
|
||||
@@ -47,13 +49,7 @@ export async function POST(req: Request) {
|
||||
400,
|
||||
);
|
||||
const input = body as Record<string, unknown>;
|
||||
const token =
|
||||
req.headers
|
||||
.get('cookie')
|
||||
?.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`dune_${code}=`))
|
||||
?.split('=')[1] ?? '';
|
||||
const selector = selectedSeatId(req);
|
||||
if (
|
||||
input.type === 'setSeatAiDelegate' ||
|
||||
input.type === 'revokeSeatAiDelegate' ||
|
||||
@@ -65,7 +61,7 @@ export async function POST(req: Request) {
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const auth = await authenticate(code, token);
|
||||
const auth = await authenticateSelectedSeat(req, code);
|
||||
const result =
|
||||
input.type === 'setSeatAiDelegate'
|
||||
? await setSeatAiDelegate(code, auth, input.version, {
|
||||
@@ -84,7 +80,7 @@ export async function POST(req: Request) {
|
||||
ownerId: input.ownerId,
|
||||
grantId: input.grantId,
|
||||
});
|
||||
if (result.view.botsPending || needsAutomaticRoomRecovery(result.view))
|
||||
if (!result.view.roomControl?.paused && !result.view.roomControl?.closed && (result.view.botsPending || needsAutomaticRoomRecovery(result.view)))
|
||||
waitUntil(resumeRoom(code));
|
||||
return Response.json(result, { headers: noStore });
|
||||
}
|
||||
@@ -93,7 +89,7 @@ export async function POST(req: Request) {
|
||||
input.type === 'createSeatHandover' ||
|
||||
input.type === 'revokeSeatHandover'
|
||||
) {
|
||||
const auth = await authenticate(code, token);
|
||||
const auth = await authenticateSelectedSeat(req, code);
|
||||
if (input.type === 'createSeatHandover')
|
||||
return Response.json(
|
||||
await createSeatHandover(code, auth, input.version, {
|
||||
@@ -118,20 +114,44 @@ export async function POST(req: Request) {
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
if (input.type === 'claimSeatHandover' && token) {
|
||||
let existing;
|
||||
try {
|
||||
existing = await authenticate(code, token);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
if (typeof input.playerId !== 'string' || !input.playerId || input.playerId.length > 100)
|
||||
throw new SeatControlError('Provide a valid player ID.', 'INVALID_CONTROL_REQUEST', 400);
|
||||
const targetId = input.playerId;
|
||||
if (input.type === 'claimSeatHandover') {
|
||||
// A caller cannot evade the existing-browser restriction by selecting a
|
||||
// nonexistent scoped cookie while retaining another legacy credential.
|
||||
for (const cookie of roomSeatCookies(req, code)) {
|
||||
if (!cookie) continue;
|
||||
let existing;
|
||||
try {
|
||||
existing = await authenticateSelectedSeat(new Request(req.url, { headers: { cookie: `dune_${code}=${cookie}` } }), code);
|
||||
} catch (error) {
|
||||
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
|
||||
}
|
||||
if (existing && existing.playerId !== targetId)
|
||||
throw new SeatControlError(
|
||||
'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.',
|
||||
'ALREADY_SEATED', 409,
|
||||
);
|
||||
}
|
||||
if (existing && existing.playerId !== input.playerId)
|
||||
throw new SeatControlError(
|
||||
'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.',
|
||||
'ALREADY_SEATED',
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (selector && selector !== targetId)
|
||||
throw new SeatControlError('The selected seat does not match this recovery request.', 'INVALID_CONTROL_REQUEST', 400);
|
||||
// Determine ownership before recovery rotates the old credential.
|
||||
const existingLegacy = seatCookie(req, `dune_${code}`);
|
||||
let legacyOtherSeat = false;
|
||||
if (existingLegacy) {
|
||||
try {
|
||||
legacyOtherSeat = (await authenticateSelectedSeat(new Request(req.url, { headers: { cookie: `dune_${code}=${existingLegacy}` } }), code)).playerId !== targetId;
|
||||
} catch (error) {
|
||||
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
|
||||
}
|
||||
}
|
||||
// A recovery claim may omit the selector. Replace any existing scoped
|
||||
// cookie for its target so that a stale token cannot shadow the new one.
|
||||
const hasTargetCookie = !!seatCookie(req, `dune_${code}_${targetId}`);
|
||||
const cookieName = hasTargetCookie || legacyOtherSeat
|
||||
? `dune_${code}_${targetId}` : `dune_${code}`;
|
||||
const result =
|
||||
input.type === 'claimSeatHandover'
|
||||
? await claimSeatHandover(code, {
|
||||
@@ -158,7 +178,7 @@ export async function POST(req: Request) {
|
||||
{
|
||||
headers: {
|
||||
...noStore,
|
||||
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${url.protocol === 'https:' ? '; Secure' : ''}`,
|
||||
'Set-Cookie': `${cookieName}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -172,7 +192,7 @@ export async function POST(req: Request) {
|
||||
? 'Invalid JSON.'
|
||||
: 'The server could not complete this seat request.',
|
||||
code:
|
||||
error instanceof SeatControlError
|
||||
(error instanceof SeatControlError || error instanceof RoomEntryError)
|
||||
? error.code
|
||||
: error instanceof RuleError
|
||||
? 'INVALID_SESSION'
|
||||
@@ -182,7 +202,7 @@ export async function POST(req: Request) {
|
||||
},
|
||||
{
|
||||
status:
|
||||
error instanceof SeatControlError
|
||||
(error instanceof SeatControlError || error instanceof RoomEntryError)
|
||||
? error.status
|
||||
: error instanceof RuleError
|
||||
? 409
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { authenticate } from '@/db/rooms';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { requestOrigin } from '@/lib/request-origin';
|
||||
import { RoomEntryError } from '@/db/rooms';
|
||||
import { authenticateSelectedSeat } from '@/lib/selected-seat';
|
||||
import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk';
|
||||
import { RuleError } from '@/game/engine';
|
||||
const noStore = { 'Cache-Control': 'no-store' };
|
||||
@@ -8,20 +11,13 @@ async function handle(req: Request, write: boolean) {
|
||||
if (
|
||||
write &&
|
||||
req.headers.get('origin') &&
|
||||
req.headers.get('origin') !== url.origin
|
||||
req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)
|
||||
)
|
||||
throw new TableTalkError('Invalid request origin.', 403);
|
||||
const code = url.pathname.split('/').at(-2)!.toUpperCase();
|
||||
if (!/^[A-Z2-9]{8}$/.test(code))
|
||||
throw new TableTalkError('Invalid room code.', 400);
|
||||
const token =
|
||||
req.headers
|
||||
.get('cookie')
|
||||
?.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`dune_${code}=`))
|
||||
?.split('=')[1] ?? '';
|
||||
const auth = await authenticate(code, token);
|
||||
const auth = await authenticateSelectedSeat(req, code);
|
||||
if (write) {
|
||||
const raw = await req.text();
|
||||
if (raw.length > 6500)
|
||||
@@ -48,6 +44,7 @@ async function handle(req: Request, write: boolean) {
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
...((error instanceof TableTalkError || error instanceof RoomEntryError) && error.code ? { code: error.code } : {}),
|
||||
error:
|
||||
error instanceof TableTalkError || error instanceof RuleError
|
||||
? error.message
|
||||
@@ -57,7 +54,7 @@ async function handle(req: Request, write: boolean) {
|
||||
},
|
||||
{
|
||||
status:
|
||||
error instanceof TableTalkError
|
||||
(error instanceof TableTalkError || error instanceof RoomEntryError)
|
||||
? error.status
|
||||
: error instanceof RuleError
|
||||
? 409
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { requestOrigin } from '@/lib/request-origin';
|
||||
import { authenticateSelectedSeat, selectedSeatId, seatCookie } from '@/lib/selected-seat';
|
||||
import {
|
||||
readSeatView,
|
||||
authenticate,
|
||||
joinRoom,
|
||||
addControlledFaction,
|
||||
act,
|
||||
RoomEntryError,
|
||||
needsAutomaticRoomRecovery,
|
||||
@@ -9,7 +11,7 @@ import {
|
||||
} from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
import { waitUntil } from 'cloudflare:workers';
|
||||
import { env, waitUntil } from 'cloudflare:workers';
|
||||
import { resumeRoom } from '@/db/room-continuation';
|
||||
|
||||
const codeOf = (req: Request) => {
|
||||
@@ -18,13 +20,6 @@ const codeOf = (req: Request) => {
|
||||
throw new RuleError('Enter the eight-character room code.');
|
||||
return code;
|
||||
};
|
||||
const tokenOf = (req: Request, code: string) =>
|
||||
req.headers
|
||||
.get('cookie')
|
||||
?.split(';')
|
||||
.map((x) => x.trim())
|
||||
.find((x) => x.startsWith(`dune_${code}=`))
|
||||
?.split('=')[1] ?? '';
|
||||
const failure = (e: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
@@ -51,11 +46,11 @@ const failure = (e: unknown) =>
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const code = codeOf(req),
|
||||
id = await authenticate(code, tokenOf(req, code));
|
||||
id = await authenticateSelectedSeat(req, code);
|
||||
const view = await readSeatView(code, id);
|
||||
// Reconnect resumes persisted automatic effects/acknowledgements and AI work
|
||||
// after an interruption. Actual human choices remain engine-owned decisions.
|
||||
if (view.botsPending || needsAutomaticRoomRecovery(view))
|
||||
if (!view.roomControl?.paused && !view.roomControl?.closed && (view.botsPending || needsAutomaticRoomRecovery(view)))
|
||||
waitUntil(resumeRoom(code));
|
||||
return Response.json(view, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
@@ -68,7 +63,7 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
if (
|
||||
req.headers.get('origin') &&
|
||||
req.headers.get('origin') !== new URL(req.url).origin
|
||||
req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)
|
||||
)
|
||||
return Response.json(
|
||||
{ error: 'Invalid request origin.' },
|
||||
@@ -82,13 +77,13 @@ export async function POST(req: Request) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new RuleError('The request is invalid.');
|
||||
if (body.type === 'join') {
|
||||
const existing = tokenOf(req, code);
|
||||
const existing = selectedSeatId(req) || seatCookie(req, `dune_${code}`);
|
||||
let auth: SeatAuth | null = null;
|
||||
if (existing) {
|
||||
try {
|
||||
auth = await authenticate(code, existing);
|
||||
auth = await authenticateSelectedSeat(req, code);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
if (selectedSeatId(req) || error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
|
||||
}
|
||||
}
|
||||
if (body.entry === undefined && auth) {
|
||||
@@ -115,7 +110,7 @@ export async function POST(req: Request) {
|
||||
headers: {
|
||||
...(r.token
|
||||
? {
|
||||
'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
|
||||
'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
|
||||
}
|
||||
: {}),
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -123,7 +118,19 @@ export async function POST(req: Request) {
|
||||
},
|
||||
);
|
||||
}
|
||||
const id = await authenticate(code, tokenOf(req, code));
|
||||
if (body.type === 'addControlledFaction') {
|
||||
const auth = await authenticateSelectedSeat(req, code);
|
||||
if (!Number.isSafeInteger(body.version) || typeof body.name !== 'string' || typeof body.faction !== 'string')
|
||||
throw new RuleError('Enter your name and faction and provide the current table version.');
|
||||
const r = await addControlledFaction(code, auth, body.version, body.name, body.faction as FactionId, body.entry);
|
||||
return Response.json({ ...r.view, ...(r.entryReceipt ? { entryReceipt: r.entryReceipt } : {}) }, {
|
||||
headers: {
|
||||
'Set-Cookie': `dune_${code}_${r.view.me}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
const id = await authenticateSelectedSeat(req, code);
|
||||
if (
|
||||
!Number.isSafeInteger(body.version) ||
|
||||
!body.action ||
|
||||
@@ -131,7 +138,7 @@ export async function POST(req: Request) {
|
||||
)
|
||||
throw new RuleError('The action is invalid.');
|
||||
const view = await act(code, id, body.version, body.action);
|
||||
if (view.botsPending || needsAutomaticRoomRecovery(view))
|
||||
if (!view.roomControl?.paused && !view.roomControl?.closed && (view.botsPending || needsAutomaticRoomRecovery(view)))
|
||||
waitUntil(resumeRoom(code));
|
||||
return Response.json(view, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { requestOrigin } from '@/lib/request-origin';
|
||||
import { createRoom, RoomEntryError } from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
@@ -5,7 +7,7 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
if (
|
||||
req.headers.get('origin') &&
|
||||
req.headers.get('origin') !== new URL(req.url).origin
|
||||
req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)
|
||||
)
|
||||
return Response.json(
|
||||
{ error: 'Invalid request origin.' },
|
||||
@@ -40,7 +42,7 @@ export async function POST(req: Request) {
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
|
||||
'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -99,6 +99,11 @@ a:focus-visible {
|
||||
font-size: 14px;
|
||||
color: #c6c6b9;
|
||||
}
|
||||
.build-revision {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.masthead-right a {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
@@ -473,6 +478,10 @@ input[data-slot='input'] {
|
||||
line-height: 1.8;
|
||||
color: #a8af9c;
|
||||
}
|
||||
.development-note.advanced-preview-note {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.development-note {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
|
||||
+266
-37
@@ -1,13 +1,21 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { TableTalk } from '@/components/table-talk';
|
||||
import { RemovedRoomNotice } from '@/components/removed-room-notice';
|
||||
import { GameTable } from '@/components/game-table';
|
||||
import { SeatConfirmation, type SeatConfirmationKind } from '@/components/seat-confirmation';
|
||||
import { RoomEntryConfirmation } from '@/components/room-entry-confirmation';
|
||||
import { RulesetControls } from '@/components/ruleset-controls';
|
||||
import { TwoPlayerControls } from '@/components/two-player-controls';
|
||||
import { FactionAllocation } from '@/components/faction-allocation';
|
||||
import { FactionSwap } from '@/components/faction-swap';
|
||||
import { randomId } from '@/lib/random-id';
|
||||
import {
|
||||
SeatHandoverSetup,
|
||||
SeatHandoverClaim,
|
||||
} from '@/components/seat-handover';
|
||||
import { HANDOVER_CLAIM_STORAGE_KEY } from '@/lib/seat-handover';
|
||||
import { buildRevision, buildRevisionLabel } from '@/lib/build-revision';
|
||||
import { SeatAiDelegation } from '@/components/seat-ai-delegation';
|
||||
import {
|
||||
SeatRecoverySetup,
|
||||
@@ -30,6 +38,8 @@ import {
|
||||
ClientRequestError,
|
||||
requestJson,
|
||||
requestMayHaveCompleted,
|
||||
isRoomRemoved,
|
||||
subscribeRoomRemoval,
|
||||
} from '@/lib/client-request';
|
||||
|
||||
import {
|
||||
@@ -49,6 +59,9 @@ const inviteRejected = (error: unknown) =>
|
||||
error instanceof ClientRequestError &&
|
||||
error.kind === 'http' &&
|
||||
[401, 403, 404, 409].includes(error.status ?? 0);
|
||||
|
||||
type TwoPlayerEntry = { room: string; seat: string; bodyText: string };
|
||||
const TWO_PLAYER_ENTRY_KEY = 'dune.pending-controlled-faction.v1';
|
||||
export default function Home() {
|
||||
const activeRoom = useRef<string | null>(null);
|
||||
const activeSeat = useRef<string | null>(null);
|
||||
@@ -65,13 +78,18 @@ export default function Home() {
|
||||
const [needsReconcile, setNeedsReconcile] = useState(false);
|
||||
const [botRecovery, setBotRecovery] = useState(false);
|
||||
const [game, setGame] = useState<GameView | null>(null);
|
||||
const removedRooms = useRef(new Set<string>());
|
||||
const [removedRoom, setRemovedRoom] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [connection, setConnection] = useState('');
|
||||
const [selected, setSelected] = useState('atreides');
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [expansions, setExpansions] = useState<string[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [seatConfirmation, setSeatConfirmation] = useState<SeatConfirmationKind | null>(null);
|
||||
const [entryConfirmation, setEntryConfirmation] = useState<{ kind: 'create' | 'join'; code: string } | null>(null);
|
||||
const seatClaimUncertain = useRef(false);
|
||||
const [claimUncertain, setClaimUncertain] = useState(false);
|
||||
const pendingEntry = useRef<RoomEntryAttempt | null>(null);
|
||||
@@ -79,31 +97,49 @@ export default function Home() {
|
||||
null,
|
||||
);
|
||||
const [entryProblem, setEntryProblem] = useState('');
|
||||
const [initialEntryPending, setInitialEntryPending] = useState(false);
|
||||
const [showEntryRecovery, setShowEntryRecovery] = useState(false);
|
||||
const [showHandover, setShowHandover] = useState(false);
|
||||
const [handoverPending, setHandoverPending] = useState(true);
|
||||
const [aiPermissionPending, setAiPermissionPending] = useState(true);
|
||||
const [abandonAcknowledged, setAbandonAcknowledged] = useState(false);
|
||||
const [twoPlayerEntry, setTwoPlayerEntry] = useState<TwoPlayerEntry | null>(null);
|
||||
const [twoPlayerEntryProblem, setTwoPlayerEntryProblem] = useState('');
|
||||
const f = faction(selected);
|
||||
const roomCode = game?.code;
|
||||
const roomUnavailable = !!game && removedRoom === game.code;
|
||||
const markRoomRemoved = useCallback((room: string) => {
|
||||
if (activeRoom.current !== room || removedRooms.current.has(room)) return;
|
||||
removedRooms.current.add(room);
|
||||
++epoch.current;
|
||||
recovery.current = null; botAttempt.current = null;
|
||||
setRemovedRoom(room); setNeedsReconcile(false); setBotRecovery(false); setConnection(''); setNotice('');
|
||||
}, []);
|
||||
useEffect(() => subscribeRoomRemoval(markRoomRemoved), [markRoomRemoved]);
|
||||
// This function is deliberately GET-only, including WebMCP reads and recovery.
|
||||
const refresh = useCallback(
|
||||
async (room: string, fresh = false): Promise<GameView> => {
|
||||
let pending = reads.current.get(room);
|
||||
const seat = activeSeat.current;
|
||||
const readKey = `${room}:${seat ?? ''}`;
|
||||
let pending = reads.current.get(readKey);
|
||||
if (pending && !fresh) return pending;
|
||||
// Reconciliation must start after previously issued reads have settled.
|
||||
while (pending) {
|
||||
await pending.catch(() => {});
|
||||
pending = reads.current.get(room);
|
||||
pending = reads.current.get(readKey);
|
||||
}
|
||||
const readEpoch = epoch.current;
|
||||
const request = requestJson<GameView>(`/api/rooms/${room}`, {
|
||||
cache: 'no-store',
|
||||
headers: seat ? { 'X-Dune-Seat': seat } : undefined,
|
||||
})
|
||||
.then((data) => {
|
||||
if (
|
||||
activeRoom.current !== room ||
|
||||
activeSeat.current !== seat ||
|
||||
(seat !== null && data.me !== seat) ||
|
||||
readEpoch !== epoch.current ||
|
||||
(removedRooms.current.has(room) && !fresh) ||
|
||||
data.version < (knownVersions.current.get(room) ?? -1)
|
||||
)
|
||||
throw new ClientRequestError(
|
||||
@@ -111,6 +147,8 @@ export default function Home() {
|
||||
'aborted',
|
||||
);
|
||||
activeSeat.current = data.me;
|
||||
removedRooms.current.delete(room);
|
||||
setRemovedRoom(null);
|
||||
knownVersions.current.set(room, data.version);
|
||||
setGame((previous) =>
|
||||
!previous ||
|
||||
@@ -139,9 +177,9 @@ export default function Home() {
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
reads.current.delete(room);
|
||||
reads.current.delete(readKey);
|
||||
});
|
||||
reads.current.set(room, request);
|
||||
reads.current.set(readKey, request);
|
||||
return request;
|
||||
},
|
||||
[],
|
||||
@@ -149,12 +187,13 @@ export default function Home() {
|
||||
|
||||
const reconcile = useCallback(
|
||||
async (room: string) => {
|
||||
if (removedRooms.current.has(room)) return;
|
||||
recovery.current = { room, epoch: ++epoch.current };
|
||||
setNeedsReconcile(true);
|
||||
try {
|
||||
await refresh(room, true);
|
||||
} catch {
|
||||
if (activeRoom.current === room)
|
||||
if (activeRoom.current === room && !removedRooms.current.has(room))
|
||||
setConnection(
|
||||
'Reconnect to confirm the latest table before taking another action.',
|
||||
);
|
||||
@@ -168,6 +207,8 @@ export default function Home() {
|
||||
const room = data.code;
|
||||
if (
|
||||
!data.botsPending ||
|
||||
removedRooms.current.has(room) ||
|
||||
data.roomControl?.paused || data.roomControl?.closed ||
|
||||
activeRoom.current !== room ||
|
||||
data.me !== activeSeat.current ||
|
||||
data.version < (knownVersions.current.get(room) ?? -1) ||
|
||||
@@ -189,16 +230,17 @@ export default function Home() {
|
||||
try {
|
||||
const next = await requestJson<GameView>(`/api/rooms/${room}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': seat! },
|
||||
body: JSON.stringify({
|
||||
version: data.version,
|
||||
action: { type: 'advanceBots' },
|
||||
}),
|
||||
});
|
||||
if (next.me !== seat) throw new ClientRequestError('The server returned a different faction seat.', 'invalid-response');
|
||||
if (
|
||||
activeRoom.current === room &&
|
||||
activeSeat.current === seat &&
|
||||
epoch.current === requestEpoch
|
||||
epoch.current === requestEpoch && next.me === seat
|
||||
) {
|
||||
knownVersions.current.set(
|
||||
room,
|
||||
@@ -238,6 +280,22 @@ export default function Home() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY);
|
||||
if (saved) {
|
||||
const value: unknown = JSON.parse(saved);
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid saved request');
|
||||
const attempt = value as TwoPlayerEntry;
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
if (Object.keys(attempt).sort().join(',') !== 'bodyText,room,seat' || typeof attempt.room !== 'string' || !/^[A-Z2-9]{8}$/.test(attempt.room) || typeof attempt.seat !== 'string' || !uuid.test(attempt.seat) || typeof attempt.bodyText !== 'string' || attempt.bodyText.length > 4096) throw new Error('Invalid saved request');
|
||||
const body = JSON.parse(attempt.bodyText);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body) || JSON.stringify(body) !== attempt.bodyText || Object.keys(body).sort().join(',') !== 'entry,faction,name,type,version' || body.type !== 'addControlledFaction' || typeof body.version !== 'number' || !Number.isSafeInteger(body.version) || body.version < 0 || typeof body.name !== 'string' || !body.name.trim() || body.name.length > 32 || !FACTIONS.some((f) => f.id === body.faction) || !body.entry || typeof body.entry !== 'object' || Object.keys(body.entry).sort().join(',') !== 'operationId,sessionToken' || typeof body.entry.operationId !== 'string' || !uuid.test(body.entry.operationId) || typeof body.entry.sessionToken !== 'string' || !/^[0-9a-f]{64}$/.test(body.entry.sessionToken)) throw new Error('Invalid saved request');
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore a private retry record from this tab.
|
||||
setTwoPlayerEntry(attempt);
|
||||
}
|
||||
} catch {
|
||||
setTwoPlayerEntryProblem('The saved second-faction request could not be read safely. It remains in this tab; restore access to its storage before continuing.');
|
||||
}
|
||||
// A one-use handover needs its exact receipt before any other seat flow.
|
||||
try {
|
||||
if (window.sessionStorage.getItem(HANDOVER_CLAIM_STORAGE_KEY) !== null) {
|
||||
@@ -297,7 +355,7 @@ export default function Home() {
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomCode) return;
|
||||
if (!roomCode || removedRoom === roomCode) return;
|
||||
let live = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const tick = async () => {
|
||||
@@ -314,7 +372,7 @@ export default function Home() {
|
||||
nextPollMs = data.botsPending ? 500 : 2000;
|
||||
}
|
||||
} catch {
|
||||
if (live && activeRoom.current === roomCode)
|
||||
if (live && activeRoom.current === roomCode && !removedRooms.current.has(roomCode))
|
||||
setConnection('Connection interrupted. Retrying…');
|
||||
} finally {
|
||||
// Schedule after completion, so slow requests cannot stack polling cycles.
|
||||
@@ -326,7 +384,7 @@ export default function Home() {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [roomCode, refresh]);
|
||||
}, [roomCode, refresh, removedRoom]);
|
||||
|
||||
async function dispatchEntry(
|
||||
attempt: RoomEntryAttempt,
|
||||
@@ -370,6 +428,8 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
activeRoom.current = data.code;
|
||||
removedRooms.current.delete(data.code);
|
||||
setRemovedRoom(null);
|
||||
activeSeat.current = data.me;
|
||||
setHandoverPending(true);
|
||||
knownVersions.current.set(data.code, data.version);
|
||||
@@ -379,6 +439,7 @@ export default function Home() {
|
||||
setNeedsReconcile(false);
|
||||
setShowEntryRecovery(false);
|
||||
setAbandonAcknowledged(false);
|
||||
setEntryConfirmation({ kind: attempt.kind, code: data.code });
|
||||
setGame(data);
|
||||
window.history.replaceState(null, '', `/?room=${data.code}`);
|
||||
} catch (error) {
|
||||
@@ -406,6 +467,7 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (firstDispatch) setInitialEntryPending(false);
|
||||
mutationPending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -423,7 +485,7 @@ export default function Home() {
|
||||
let attempt: RoomEntryAttempt;
|
||||
try {
|
||||
attempt = createRoomEntry(
|
||||
{ name, faction: selected, expansions, advanced: false },
|
||||
{ name, faction: selected, expansions, advanced },
|
||||
join ? code : undefined,
|
||||
);
|
||||
} catch {
|
||||
@@ -442,6 +504,7 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
pendingEntry.current = attempt;
|
||||
setInitialEntryPending(true);
|
||||
setEntryAttempt(attempt);
|
||||
setAbandonAcknowledged(false);
|
||||
await dispatchEntry(attempt, true);
|
||||
@@ -456,6 +519,7 @@ export default function Home() {
|
||||
seatClaimUncertain.current
|
||||
)
|
||||
return;
|
||||
if (activeRoom.current) removedRooms.current.delete(activeRoom.current);
|
||||
activeRoom.current = null;
|
||||
setHandoverPending(true);
|
||||
setAiPermissionPending(true);
|
||||
@@ -467,6 +531,10 @@ export default function Home() {
|
||||
setBotRecovery(false);
|
||||
setConnection('');
|
||||
setGame(null);
|
||||
setSeatConfirmation(null);
|
||||
setEntryConfirmation(null);
|
||||
setRemovedRoom(null);
|
||||
setRestoreFailed(false);
|
||||
setNotice('');
|
||||
window.history.replaceState(null, '', '/');
|
||||
}
|
||||
@@ -496,7 +564,12 @@ export default function Home() {
|
||||
}
|
||||
|
||||
async function send(action: Action) {
|
||||
if (!game || mutationPending.current || recovery.current) return;
|
||||
if (!game || removedRooms.current.has(game.code) || mutationPending.current || recovery.current || twoPlayerEntry || twoPlayerEntryProblem) return;
|
||||
if (game.roomControl?.closed) { setNotice('An administrator closed this room. The saved table remains readable; play resumes after reopening.'); return; }
|
||||
if (game.roomControl?.paused && !(action.type === 'setAutopilot' && action.difficulty === null)) {
|
||||
setNotice('An administrator paused this room. Game decisions will be available when the room resumes.');
|
||||
return;
|
||||
}
|
||||
const room = game.code,
|
||||
seat = game.me,
|
||||
requestEpoch = epoch.current;
|
||||
@@ -506,13 +579,14 @@ export default function Home() {
|
||||
try {
|
||||
const data = await requestJson<GameView>(`/api/rooms/${room}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': seat },
|
||||
body: JSON.stringify({ version: game.version, action }),
|
||||
});
|
||||
if (data.me !== seat) throw new ClientRequestError('The server returned a different faction seat.', 'invalid-response');
|
||||
if (
|
||||
activeRoom.current === room &&
|
||||
activeSeat.current === seat &&
|
||||
epoch.current === requestEpoch
|
||||
epoch.current === requestEpoch && data.me === seat
|
||||
) {
|
||||
knownVersions.current.set(
|
||||
room,
|
||||
@@ -546,6 +620,103 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
function switchFaction(seat: string) {
|
||||
if (!game || !game.controlledFactionSeats?.some((owned) => owned.id === seat) ||
|
||||
seat === game.me || mutationPending.current || reconnectPending.current ||
|
||||
recovery.current || handoverPending || aiPermissionPending || claimUncertain ||
|
||||
twoPlayerEntry || twoPlayerEntryProblem || roomUnavailable) return;
|
||||
const room = game.code;
|
||||
++epoch.current;
|
||||
activeSeat.current = seat;
|
||||
setHandoverPending(true);
|
||||
setAiPermissionPending(true);
|
||||
setGame(null);
|
||||
setRestoring(true);
|
||||
setRestoreFailed(false);
|
||||
setNotice('');
|
||||
void refresh(room, true).catch((error: Error) => {
|
||||
if (activeRoom.current === room && activeSeat.current === seat) {
|
||||
setRestoreFailed(true);
|
||||
setNotice(error.message);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (activeRoom.current === room && activeSeat.current === seat) setRestoring(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchControlledFaction(attempt: TwoPlayerEntry, firstDispatch: boolean) {
|
||||
if (mutationPending.current || reconnectPending.current || recovery.current || handoverPending || aiPermissionPending || claimUncertain || activeRoom.current !== attempt.room) return;
|
||||
const requestEpoch = epoch.current;
|
||||
mutationPending.current = true;
|
||||
setBusy(true);
|
||||
setNotice('');
|
||||
let responseReceived = false;
|
||||
try {
|
||||
const data = await requestJson<GameView & { entryReceipt?: { operationId: string; replayed: boolean } }>(`/api/rooms/${attempt.room}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': attempt.seat },
|
||||
body: attempt.bodyText,
|
||||
});
|
||||
responseReceived = true;
|
||||
if (activeRoom.current !== attempt.room || requestEpoch !== epoch.current) return;
|
||||
const body = JSON.parse(attempt.bodyText);
|
||||
if (data.code !== attempt.room || data.me === attempt.seat ||
|
||||
data.entryReceipt?.operationId !== body.entry.operationId || typeof data.entryReceipt?.replayed !== 'boolean' ||
|
||||
!data.players.some((player) => player.id === data.me && player.faction === body.faction))
|
||||
throw new ClientRequestError('The server did not confirm the saved second-faction request.', 'invalid-response');
|
||||
window.sessionStorage.removeItem(TWO_PLAYER_ENTRY_KEY);
|
||||
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== null) throw new Error('The saved retry proof could not be removed.');
|
||||
setTwoPlayerEntry(null);
|
||||
++epoch.current;
|
||||
activeSeat.current = data.me;
|
||||
knownVersions.current.set(attempt.room, Math.max(data.version, knownVersions.current.get(attempt.room) ?? -1));
|
||||
setHandoverPending(true);
|
||||
setAiPermissionPending(true);
|
||||
setGame(data);
|
||||
setNotice('Second faction added. You are now viewing its seat. Switch factions to make each faction ready.');
|
||||
} catch (error) {
|
||||
if (activeRoom.current !== attempt.room || requestEpoch !== epoch.current) return;
|
||||
if (firstDispatch && !responseReceived && !requestMayHaveCompleted(error)) {
|
||||
try {
|
||||
window.sessionStorage.removeItem(TWO_PLAYER_ENTRY_KEY);
|
||||
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== null) throw new Error();
|
||||
setTwoPlayerEntry(null);
|
||||
} catch {
|
||||
setTwoPlayerEntryProblem('The rejected request was not sent again, but its saved proof could not be removed. Restore storage access before continuing.');
|
||||
}
|
||||
}
|
||||
setNotice(!firstDispatch || responseReceived || requestMayHaveCompleted(error)
|
||||
? `${(error as Error).message} The request may have completed. Retry the saved request exactly; it will not be sent again automatically.`
|
||||
: (error as Error).message);
|
||||
} finally {
|
||||
mutationPending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addControlledFaction(name: string, factionId: string) {
|
||||
if (!game || twoPlayerEntry || twoPlayerEntryProblem || mutationPending.current || recovery.current || handoverPending || aiPermissionPending || claimUncertain) return;
|
||||
const attempt: TwoPlayerEntry = {
|
||||
room: game.code,
|
||||
seat: game.me,
|
||||
bodyText: JSON.stringify({
|
||||
type: 'addControlledFaction', version: game.version, name, faction: factionId,
|
||||
entry: { operationId: randomId(), sessionToken: Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, '0')).join('') },
|
||||
}),
|
||||
};
|
||||
try {
|
||||
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== null) throw new Error();
|
||||
const text = JSON.stringify(attempt);
|
||||
window.sessionStorage.setItem(TWO_PLAYER_ENTRY_KEY, text);
|
||||
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== text) throw new Error();
|
||||
} catch {
|
||||
setTwoPlayerEntryProblem('The second-faction request could not be saved safely. Nothing was sent; restore storage access before continuing.');
|
||||
return;
|
||||
}
|
||||
setTwoPlayerEntry(attempt);
|
||||
await dispatchControlledFaction(attempt, true);
|
||||
}
|
||||
|
||||
async function reconnect(resumeBots = false) {
|
||||
const room = activeRoom.current;
|
||||
if (!room || mutationPending.current || reconnectPending.current) return;
|
||||
@@ -561,8 +732,14 @@ export default function Home() {
|
||||
if (resumeBots) await advanceBots(data, true);
|
||||
} catch (error) {
|
||||
if (activeRoom.current === room) {
|
||||
if (restoreFailed && inviteRejected(error)) {
|
||||
if (isRoomRemoved(error)) {
|
||||
setConnection(error.message);
|
||||
} else if (restoreFailed && inviteRejected(error)) {
|
||||
removedRooms.current.delete(room);
|
||||
setRemovedRoom(null);
|
||||
activeRoom.current = null;
|
||||
activeSeat.current = null;
|
||||
++epoch.current;
|
||||
setRestoreFailed(false);
|
||||
setCode(room);
|
||||
setNotice((error as Error).message);
|
||||
@@ -587,13 +764,16 @@ export default function Home() {
|
||||
setBusy(pending);
|
||||
}
|
||||
|
||||
function restoredSeat(view: GameView, claim = false) {
|
||||
function restoredSeat(view: GameView, claim = false, confirmation?: SeatConfirmationKind) {
|
||||
if (removedRooms.current.has(view.code)) return false;
|
||||
if (
|
||||
!claim &&
|
||||
(activeRoom.current !== view.code || activeSeat.current !== view.me)
|
||||
)
|
||||
return;
|
||||
if (claim) {
|
||||
setEntryConfirmation(null);
|
||||
setSeatConfirmation(confirmation ?? null);
|
||||
setHandoverPending(true);
|
||||
setAiPermissionPending(true);
|
||||
activeRoom.current = view.code;
|
||||
@@ -620,6 +800,7 @@ export default function Home() {
|
||||
setRestoreFailed(false);
|
||||
setConnection('');
|
||||
setNotice('');
|
||||
return true;
|
||||
}
|
||||
useEffect(() => {
|
||||
const ctx = (
|
||||
@@ -659,8 +840,7 @@ export default function Home() {
|
||||
<SeatHandoverClaim
|
||||
onCancel={() => window.location.reload()}
|
||||
onRestored={(view) => {
|
||||
restoredSeat(view, true);
|
||||
setShowHandover(false);
|
||||
if (restoredSeat(view, true, 'handover')) setShowHandover(false);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
@@ -668,15 +848,43 @@ export default function Home() {
|
||||
if (game)
|
||||
return (
|
||||
<>
|
||||
<GameTable
|
||||
{entryConfirmation?.code === game.code && !roomUnavailable && <div className="table-shell">
|
||||
<RoomEntryConfirmation {...entryConfirmation} onContinue={() => setEntryConfirmation(null)} />
|
||||
</div>}
|
||||
{seatConfirmation && !roomUnavailable && <div className="table-shell">
|
||||
<SeatConfirmation kind={seatConfirmation} onContinue={() => setSeatConfirmation(null)} />
|
||||
</div>}
|
||||
{!roomUnavailable && <div className="table-shell"><TwoPlayerControls
|
||||
game={game}
|
||||
disabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem}
|
||||
draftLocked={!!game.factionDraft || !!game.factionSwap}
|
||||
pendingEntry={twoPlayerEntry !== null}
|
||||
entryRoom={twoPlayerEntry?.room}
|
||||
entryProblem={twoPlayerEntryProblem}
|
||||
onModeChange={(enabled) => void send({ type: 'twoPlayerMode', enabled })}
|
||||
onSwitch={switchFaction}
|
||||
onAdd={(name, factionId) => void addControlledFaction(name, factionId)}
|
||||
onAddBot={(faction, difficulty) => void send({ type: 'addBot', faction, difficulty })}
|
||||
onAddControlledBot={(controller, faction, difficulty) => void send({ type: 'addControlledBot', controller, faction, difficulty })}
|
||||
onRetry={() => { if (twoPlayerEntry) void dispatchControlledFaction(twoPlayerEntry, false); }}
|
||||
retryDisabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntryProblem || twoPlayerEntry?.room !== game.code}
|
||||
/></div>}
|
||||
{!roomUnavailable && <div className="table-shell"><FactionAllocation game={game} send={send}
|
||||
busy={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem || !!game.factionSwap} /></div>}
|
||||
{!roomUnavailable && <div className="table-shell"><FactionSwap game={game} send={send}
|
||||
busy={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem} /></div>}
|
||||
{roomUnavailable ? <main className="table-shell"><RemovedRoomNotice code={game.code} busy={busy || checkingConnection} onRetry={() => void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} /></main> : <GameTable
|
||||
key={`${game.code}:${game.me}`}
|
||||
game={game}
|
||||
send={send}
|
||||
busy={busy || needsReconcile}
|
||||
busy={busy || needsReconcile || !!twoPlayerEntry || !!twoPlayerEntryProblem}
|
||||
onExit={exitTable}
|
||||
exitDisabled={handoverPending || aiPermissionPending}
|
||||
/>
|
||||
/>}
|
||||
<div className="table-shell">
|
||||
<TableTalk key={`talk:${game.code}:${game.me}`} game={game} />
|
||||
{!roomUnavailable && <TableTalk key={`talk:${game.code}:${game.me}`} game={game} />}
|
||||
{/* Keep controllers mounted: recovery kits and exact attempts can be memory-only. */}
|
||||
<div hidden={roomUnavailable}>
|
||||
{(entryAttempt || entryProblem) && (
|
||||
<section className="notice">
|
||||
<p>
|
||||
@@ -700,14 +908,14 @@ export default function Home() {
|
||||
<SeatRecoverySetup
|
||||
key={`${game.code}:${game.me}`}
|
||||
game={game}
|
||||
disabled={busy || needsReconcile || checkingConnection}
|
||||
disabled={roomUnavailable || busy || needsReconcile || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onRestored={(view) => restoredSeat(view)}
|
||||
/>
|
||||
<SeatHandoverSetup
|
||||
key={`handover:${game.code}:${game.me}`}
|
||||
game={game}
|
||||
disabled={busy || needsReconcile || checkingConnection}
|
||||
disabled={roomUnavailable || busy || needsReconcile || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onUncertain={setHandoverPending}
|
||||
onRestored={(view) => restoredSeat(view)}
|
||||
@@ -715,13 +923,14 @@ export default function Home() {
|
||||
<SeatAiDelegation
|
||||
key={`ai-permission:${game.code}:${game.me}`}
|
||||
game={game}
|
||||
disabled={busy || needsReconcile || checkingConnection}
|
||||
disabled={roomUnavailable || busy || needsReconcile || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onUncertain={setAiPermissionPending}
|
||||
onRestored={(view) => restoredSeat(view)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(notice || connection || needsReconcile || botRecovery) && (
|
||||
{!roomUnavailable && (notice || connection || needsReconcile || botRecovery) && (
|
||||
<output className="floating-notice">
|
||||
{notice && <span>{notice}</span>}
|
||||
{connection && <span>{connection}</span>}
|
||||
@@ -767,7 +976,7 @@ export default function Home() {
|
||||
DUNE<span>ARRAKIS TABLE</span>
|
||||
</div>
|
||||
</header>
|
||||
<section className="notice">
|
||||
{removedRoom ? <RemovedRoomNotice code={removedRoom} busy={checkingConnection} onRetry={() => void reconnect()} onExit={() => { removedRooms.current.delete(removedRoom); activeRoom.current = null; activeSeat.current = null; ++epoch.current; setRestoreFailed(false); setRemovedRoom(null); setConnection(''); setNotice(''); window.history.replaceState(null, '', '/'); }} /> : <section className="notice">
|
||||
<h1>
|
||||
{restoring
|
||||
? 'Restoring your table'
|
||||
@@ -804,6 +1013,20 @@ export default function Home() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>}
|
||||
</main>
|
||||
);
|
||||
// A first request is progress, not recovery. Keep the actionable recovery
|
||||
// instructions on screen only after uncertainty or a saved-request restore.
|
||||
if (initialEntryPending && entryAttempt)
|
||||
return (
|
||||
<main className="table-shell" aria-busy="true">
|
||||
<header className="masthead">
|
||||
<div className="wordmark">DUNE<span>ARRAKIS TABLE</span></div>
|
||||
</header>
|
||||
<section className="notice">
|
||||
<h1>{entryAttempt.kind === 'create' ? 'Creating your room…' : 'Joining the room…'}</h1>
|
||||
<output aria-live="polite">Please wait.</output>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@@ -898,7 +1121,7 @@ export default function Home() {
|
||||
disabled={busy || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onUncertain={controlUncertain}
|
||||
onRestored={(view) => restoredSeat(view, true)}
|
||||
onRestored={(view) => restoredSeat(view, true, 'recovery')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -912,11 +1135,11 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="masthead-right">
|
||||
<span className="status-dot" /> Multiplayer table{' '}
|
||||
<Link href="/learn">Learn to play</Link>
|
||||
<Link href="/rules">
|
||||
<a href="/learn">Learn to play</a>
|
||||
<a href="/rules">
|
||||
<BookOpen size={17} />
|
||||
Rulebook
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lobby-grid">
|
||||
@@ -1014,10 +1237,14 @@ export default function Home() {
|
||||
placeholder="Enter your name"
|
||||
maxLength={32}
|
||||
/>
|
||||
<div className="setting-title">
|
||||
<span>Rules</span>
|
||||
<span className="pill">BASIC</span>
|
||||
</div>
|
||||
<RulesetControls
|
||||
advanced={advanced}
|
||||
disabled={busy || claimUncertain}
|
||||
onChange={(value) => {
|
||||
setAdvanced(value);
|
||||
if (value && f.expansion !== 'base' && !expansions.includes(f.expansion)) setSelected('atreides');
|
||||
}}
|
||||
/>
|
||||
<p className="fine">2–6 players · up to 10 turns</p>
|
||||
<div className="setting-title">
|
||||
<span>Expansions</span>
|
||||
@@ -1083,7 +1310,7 @@ export default function Home() {
|
||||
disabled={busy || needsReconcile || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onUncertain={controlUncertain}
|
||||
onRestored={(view) => restoredSeat(view, true)}
|
||||
onRestored={(view) => restoredSeat(view, true, 'recovery')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1117,6 +1344,8 @@ export default function Home() {
|
||||
</div>
|
||||
<footer>
|
||||
<span>Unofficial fan implementation</span>
|
||||
<a href="/admin">Administration</a>
|
||||
<span className="build-revision" title={buildRevision}>Git revision: <code>{buildRevisionLabel}</code></span>
|
||||
<span>Dune · A game of conquest, diplomacy & betrayal</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
export default function Rules() {
|
||||
return (
|
||||
<main className="rule-page">
|
||||
<Link href="/">← Return to the table</Link>
|
||||
<a href="/">← Return to the table</a>
|
||||
<h1>Rules coverage ledger</h1>
|
||||
<p>
|
||||
This project targets classic Gale Force Nine Dune, the advanced game,
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { randomId } from '@/lib/random-id';
|
||||
|
||||
type Backup = { id: string; roomCode: string; roomVersion: number; createdAt: number; sizeBytes: number; digest: string };
|
||||
type Pending = { roomCode: string; operationId: string; expectedVersion: number; reason: string };
|
||||
type Room = { code: string; version: number };
|
||||
const codePattern = /^[A-Z2-9]{8}$/;
|
||||
const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'The backup request could not be completed.';
|
||||
const storageKey = (accountId: string, code: string) => `dune.admin-backup-request.v1:${accountId}:${code}`;
|
||||
|
||||
function parsePending(raw: string | null, code: string): Pending | null {
|
||||
if (raw === null) return null;
|
||||
let value: unknown;
|
||||
try { value = raw.length <= 1024 ? JSON.parse(raw) : null; } catch { value = null; }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('This tab’s saved backup request is unreadable. Resolve the local record before creating another backup.');
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Object.keys(record).length !== 4 || record.roomCode !== code || typeof record.operationId !== 'string' || !idPattern.test(record.operationId) ||
|
||||
!Number.isSafeInteger(record.expectedVersion) || Number(record.expectedVersion) < 0 || typeof record.reason !== 'string' ||
|
||||
record.reason.length > 300 || !record.reason.trim() || /\p{Cc}/u.test(record.reason))
|
||||
throw new Error('This tab’s saved backup request is unreadable. Resolve the local record before creating another backup.');
|
||||
return record as Pending;
|
||||
}
|
||||
|
||||
function readBackups(value: unknown, code: string): Backup[] {
|
||||
if (!value || typeof value !== 'object' || !('backups' in value) || !Array.isArray(value.backups)) throw new Error('The server returned an unreadable backup list.');
|
||||
const backups: unknown[] = value.backups;
|
||||
if (!backups.every(item => !!item && typeof item === 'object' &&
|
||||
'id' in item && typeof item.id === 'string' && idPattern.test(item.id) &&
|
||||
'roomCode' in item && item.roomCode === code &&
|
||||
'roomVersion' in item && Number.isSafeInteger(item.roomVersion) && Number(item.roomVersion) >= 0 &&
|
||||
'createdAt' in item && Number.isSafeInteger(item.createdAt) && Number(item.createdAt) >= 0 && Number(item.createdAt) <= 8_640_000_000_000_000 &&
|
||||
'sizeBytes' in item && Number.isSafeInteger(item.sizeBytes) && Number(item.sizeBytes) >= 0 &&
|
||||
'digest' in item && typeof item.digest === 'string' && /^[a-f0-9]{64}$/.test(item.digest)))
|
||||
throw new Error('The server returned unreadable backup metadata.');
|
||||
return backups as Backup[];
|
||||
}
|
||||
|
||||
export function AdminBackups({ accountId, onDenied }: { accountId: string; onDenied: () => void }) {
|
||||
const [entry, setEntry] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [backups, setBackups] = useState<Backup[] | null>(null);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inFlight = useRef(false);
|
||||
const generation = useRef(0);
|
||||
const alive = useRef(true);
|
||||
useEffect(() => {
|
||||
const mounted = alive;
|
||||
const currentGeneration = generation;
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; currentGeneration.current++; };
|
||||
}, []);
|
||||
const headers = { 'X-Dune-Admin-Id': accountId };
|
||||
|
||||
async function readRoomAndList(selected: string): Promise<{ view: Room | null; list: Backup[] }> {
|
||||
const [view, response] = await Promise.all([
|
||||
requestJson<Room>(`/api/admin/rooms/${selected}/removal`, { cache: 'no-store', headers })
|
||||
.catch(error => {
|
||||
if (error instanceof ClientRequestError && error.status === 404) return null;
|
||||
throw error;
|
||||
}),
|
||||
requestJson<unknown>(`/api/admin/backups?room=${selected}`, { cache: 'no-store', headers }),
|
||||
]);
|
||||
if (view && (view.code !== selected || !Number.isSafeInteger(view.version) || view.version < 0))
|
||||
throw new Error('The server returned an unreadable room version.');
|
||||
return { view, list: readBackups(response, selected) };
|
||||
}
|
||||
|
||||
function reconcile(saved: Pending | null, list: Backup[]) {
|
||||
if (!saved) return false;
|
||||
const found = list.find(item => item.id === saved.operationId);
|
||||
if (!found) return false;
|
||||
if (found.roomVersion !== saved.expectedVersion) throw new Error('The saved operation has conflicting backup metadata. Do not retry it.');
|
||||
window.sessionStorage.removeItem(storageKey(accountId, saved.roomCode));
|
||||
setPending(null);
|
||||
setNotice('The saved operation is present in the backup list. No second snapshot was requested.');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function load(selected: string) {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setLoading(true); setNotice('');
|
||||
const epoch = ++generation.current;
|
||||
setCode(selected); setRoom(null); setBackups(null); setPending(null); setStorageProblem(false); setConfirmed(false);
|
||||
try {
|
||||
let saved: Pending | null = null;
|
||||
let savedError: unknown = null;
|
||||
try { saved = parsePending(window.sessionStorage.getItem(storageKey(accountId, selected)), selected); }
|
||||
catch (error) { savedError = error; setStorageProblem(true); }
|
||||
const { view, list } = await readRoomAndList(selected);
|
||||
if (epoch !== generation.current || !alive.current) return;
|
||||
setRoom(view); setBackups(list); setPending(saved);
|
||||
if (savedError) setNotice(message(savedError));
|
||||
else if (saved && !reconcile(saved, list)) setNotice('The saved operation is not in the current list. Review the available room and list before manually retrying the exact request.');
|
||||
} catch (error) {
|
||||
if (epoch !== generation.current || !alive.current) return;
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else {
|
||||
setNotice(message(error));
|
||||
}
|
||||
} finally { inFlight.current = false; if (epoch === generation.current && alive.current) setLoading(false); }
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current || !code) return;
|
||||
inFlight.current = true; setBusy(true); setNotice('');
|
||||
try {
|
||||
const { view, list } = await readRoomAndList(code);
|
||||
if (alive.current) {
|
||||
setRoom(view); setBackups(list); setConfirmed(false);
|
||||
if (!reconcile(pending, list)) setNotice(pending ? 'The saved operation is not in the current list. Review it before a manual retry.' :
|
||||
view ? 'Current room version and backup list refreshed.' : 'Backup list refreshed. The live room is no longer available.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setRoom(null); setBackups(null);
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function create(saved?: Pending) {
|
||||
if (inFlight.current || !backups || storageProblem || (!saved && (!room || pending || !confirmed || !reason.trim())) ||
|
||||
(saved && (!pending || saved.operationId !== pending.operationId || !confirmed))) return;
|
||||
inFlight.current = true; setBusy(true); setConfirmed(false);
|
||||
let input: Pending | null = null;
|
||||
let sent = false;
|
||||
try {
|
||||
// A fresh authoritative read precedes every POST. A changed version needs new explicit review.
|
||||
const latest = await readRoomAndList(code);
|
||||
if (!alive.current) return;
|
||||
setRoom(latest.view); setBackups(latest.list);
|
||||
if (saved && reconcile(saved, latest.list)) return;
|
||||
if (!saved && (!latest.view || latest.view.version !== room?.version)) {
|
||||
setNotice(latest.view ? 'The live room advanced since your review. Review the new version and confirm again; no snapshot was requested.' :
|
||||
'The live room is no longer available. Its existing snapshots can still be downloaded; no snapshot was requested.');
|
||||
return;
|
||||
}
|
||||
input = saved ?? { roomCode: code, operationId: randomId(), expectedVersion: latest.view!.version, reason: reason.trim() };
|
||||
if (input.roomCode !== code || input.reason.length > 300 || /\p{Cc}/u.test(input.reason)) {
|
||||
setNotice('Enter a one-line reason of 1–300 characters.'); return;
|
||||
}
|
||||
const key = storageKey(accountId, code);
|
||||
const existing = parsePending(window.sessionStorage.getItem(key), code);
|
||||
if (existing && JSON.stringify(existing) !== JSON.stringify(input)) throw new Error('Resolve the saved backup request before starting another.');
|
||||
window.sessionStorage.setItem(key, JSON.stringify(input));
|
||||
if (window.sessionStorage.getItem(key) !== JSON.stringify(input)) throw new Error('The backup request could not be preserved in this tab. Nothing was sent.');
|
||||
setPending(input); setNotice('Requesting an immutable snapshot…');
|
||||
sent = true;
|
||||
const result = await requestJson<unknown>('/api/admin/backups', {
|
||||
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
||||
});
|
||||
if (!result || typeof result !== 'object' || !('replayed' in result) || typeof result.replayed !== 'boolean' || !('backup' in result))
|
||||
throw new Error('The server did not confirm the backup operation.');
|
||||
const receipt = readBackups({ backups: [result.backup] }, code)[0];
|
||||
if (receipt.id !== input.operationId || receipt.roomVersion !== input.expectedVersion)
|
||||
throw new Error('The server returned conflicting backup confirmation.');
|
||||
// The authenticated exact-operation receipt is authoritative even when this
|
||||
// backup has fallen outside the newest 100 metadata rows.
|
||||
let storageCleared = true;
|
||||
try { window.sessionStorage.removeItem(storageKey(accountId, code)); }
|
||||
catch {
|
||||
storageCleared = false;
|
||||
if (alive.current) setStorageProblem(true);
|
||||
}
|
||||
if (alive.current) {
|
||||
setPending(null); setReason('');
|
||||
const storageWarning = storageCleared ? '' : ' This tab could not clear its saved retry record; do not retry this confirmed operation.';
|
||||
setNotice((result.replayed ? 'The existing snapshot was confirmed; no second snapshot was created.' : 'Snapshot created. The live room was not changed.') + storageWarning);
|
||||
try {
|
||||
const after = await readRoomAndList(code);
|
||||
if (alive.current) {
|
||||
setRoom(after.view); setBackups(after.list);
|
||||
if (!after.list.some(item => item.id === receipt.id))
|
||||
setNotice('The snapshot was confirmed by its operation receipt, but is not among the currently listed snapshots. No retry is needed.' + storageWarning);
|
||||
}
|
||||
} catch (readError) {
|
||||
if (!alive.current) return;
|
||||
setRoom(null); setBackups(null);
|
||||
setNotice('The snapshot was confirmed, but the current list could not be refreshed. Use Refresh room and backups; do not retry capture.' + storageWarning);
|
||||
if (readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!alive.current) return;
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) { onDenied(); return; }
|
||||
if (!sent || !input) {
|
||||
setNotice(`${message(error)} No snapshot request was sent.`);
|
||||
return;
|
||||
}
|
||||
const uncertain = requestMayHaveCompleted(error);
|
||||
if (!uncertain) {
|
||||
try { window.sessionStorage.removeItem(storageKey(accountId, code)); setPending(null); }
|
||||
catch { setStorageProblem(true); }
|
||||
}
|
||||
setNotice(uncertain ? `${message(error)} The request may have completed. Checking the current room and backup list before any retry…` : message(error));
|
||||
try {
|
||||
const after = await readRoomAndList(code);
|
||||
if (alive.current) {
|
||||
setRoom(after.view); setBackups(after.list);
|
||||
if (uncertain && !reconcile(input, after.list)) setNotice(`${message(error)} No matching snapshot appears in the current list. Review the saved request before manually retrying it; no automatic retry was sent.`);
|
||||
}
|
||||
} catch (readError) {
|
||||
if (alive.current) {
|
||||
setRoom(null); setBackups(null);
|
||||
setNotice(`${message(error)} The outcome could not be checked. Refresh the current room and backup list before retrying.`);
|
||||
if (readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied();
|
||||
}
|
||||
}
|
||||
} finally { inFlight.current = false; if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function download(backup: Backup) {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); setNotice('');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 60_000);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/backups/${encodeURIComponent(backup.id)}/download`, {
|
||||
method: 'GET', cache: 'no-store', credentials: 'same-origin', headers, signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
if ([401, 403].includes(response.status)) { onDenied(); return; }
|
||||
throw new Error(`The snapshot could not be downloaded (${response.status}).`);
|
||||
}
|
||||
if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) throw new Error('The server returned an unexpected download type.');
|
||||
const blob = await response.blob();
|
||||
if (!alive.current) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `dune-room-${backup.roomCode}-backup-${backup.id}.json`;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setNotice('Snapshot download started. Store the private file securely.');
|
||||
} finally { setTimeout(() => URL.revokeObjectURL(url), 60_000); }
|
||||
} catch (error) { if (alive.current) setNotice(error instanceof DOMException && error.name === 'AbortError' ? 'Download timed out. Try again.' : message(error)); }
|
||||
finally { clearTimeout(timeout); inFlight.current = false; if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
return <section aria-labelledby="admin-backups-title" aria-busy={busy || loading}>
|
||||
<h2 id="admin-backups-title">Find a room</h2>
|
||||
<form className="admin-filters" onSubmit={event => { event.preventDefault(); if (codePattern.test(entry.trim().toUpperCase())) void load(entry.trim().toUpperCase()); else setNotice('Enter the eight-character room code using A–Z and 2–9.'); }}>
|
||||
<label className="admin-search" htmlFor="backup-room-code">Room code<Input id="backup-room-code" value={entry} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy || loading} onChange={event => setEntry(event.target.value.toUpperCase())} required /></label>
|
||||
<Button type="submit" disabled={busy || loading || !codePattern.test(entry.trim())}>Show room backups</Button>
|
||||
</form>
|
||||
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||
{loading && <output>Reading current room version and snapshots…</output>}
|
||||
{code && !loading && <section className="admin-room-control" aria-labelledby="backup-current-room">
|
||||
<div className="admin-section-heading"><h2 id="backup-current-room">Room {code}</h2><Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh room and backups</Button></div>
|
||||
{backups ? <>
|
||||
{room ? <p>Current live room version: <strong>{room.version}</strong>. Snapshot creation reads the saved room; it does not pause, modify, or restore live play.</p> :
|
||||
<p>The live room no longer exists. Existing snapshots remain available to download, but a new snapshot cannot be created.</p>}
|
||||
<h3>Saved snapshots ({backups.length})</h3>
|
||||
{backups.length === 0 ? <p>No snapshots recorded for this room.</p> : <ol>{backups.map(backup => <li key={backup.id}>
|
||||
<article><p>Version {backup.roomVersion} · <time dateTime={new Date(backup.createdAt).toISOString()}>{new Date(backup.createdAt).toLocaleString()}</time> · {backup.sizeBytes.toLocaleString()} bytes</p>
|
||||
<p>Snapshot ID: <code>{backup.id}</code><br />SHA-256: <code>{backup.digest}</code></p>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void download(backup)}>Download snapshot for room {code}, version {backup.roomVersion}</Button>
|
||||
</article>
|
||||
</li>)}</ol>}
|
||||
{storageProblem ? <div className="admin-confirm"><p>A saved request cannot be read or preserved in this tab. No new snapshot can be requested until the local record is resolved. This does not remove any server snapshot.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />I reviewed the current backup list and want to clear this tab’s unreadable retry record.</label>
|
||||
<Button variant="outline" disabled={busy || !confirmed} onClick={() => {
|
||||
try {
|
||||
window.sessionStorage.removeItem(storageKey(accountId, code));
|
||||
if (window.sessionStorage.getItem(storageKey(accountId, code)) !== null) throw new Error('The local record could not be cleared.');
|
||||
setStorageProblem(false); setPending(null); setConfirmed(false); setRoom(null); setBackups(null);
|
||||
setNotice('Local retry record cleared. This did not change any server snapshot. Refresh the room and list before creating a new one.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}}>Clear unreadable local record</Button></div> : pending ? <div className="admin-confirm">
|
||||
<h3>Saved request needs review</h3><p>Room {pending.roomCode}, version {pending.expectedVersion}; reason: {pending.reason}</p>
|
||||
<p>This exact request is preserved in this tab. It is not in the current list; a confirmed older snapshot may also be outside the list window. Retry uses the same operation ID and cannot create a second snapshot for this operation. The live room may have changed or no longer exist.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />I reviewed the available room, list and saved request before retrying.</label>
|
||||
<Button disabled={busy || !confirmed} onClick={() => void create(pending)}>Retry saved request</Button>
|
||||
</div> : room ? <form onSubmit={event => { event.preventDefault(); void create(); }}>
|
||||
<label htmlFor="backup-reason">Operational reason<Input id="backup-reason" value={reason} maxLength={300} required disabled={busy} onChange={event => { setReason(event.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short one-line reason. Do not include credentials or private game details.</p>
|
||||
<div className="admin-confirm"><p>Create one immutable snapshot of <strong>room {code}</strong> at live version <strong>{room.version}</strong>. This captures private game state and access material, but does not change the game. If the version changes, refresh and review again.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />I reviewed the room, version, privacy warning and reason, and want to create this snapshot.</label>
|
||||
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || /\p{Cc}/u.test(reason)}>Create snapshot</Button>
|
||||
</div>
|
||||
</form> : null}
|
||||
</> : <p>The current room or backup list is unavailable. Refresh before creating or retrying any snapshot.</p>}
|
||||
</section>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminDiscussionResponse, adminDiscussionConfirmation, clearAdminDiscussionRequest, newAdminDiscussionRequest, readAdminDiscussionRequest, saveAdminDiscussionRequest } from '@/lib/admin-discussion-client';
|
||||
import type { AdminDiscussionInput, AdminDiscussionView } from '@/lib/admin-discussion';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
|
||||
type Props = {
|
||||
accountId: string; code: string; canManage: boolean; onClose: () => void;
|
||||
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Participant availability could not be loaded.';
|
||||
|
||||
export function AdminDiscussion({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminDiscussionView | null>(null);
|
||||
const [target, setTarget] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminDiscussionInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/discussion`;
|
||||
const selected = room?.players.find(player => player.id === target && player.control !== 'ai');
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminDiscussionRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved discussion request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminDiscussionResponse(result, code); if (!canceled) setRoom(current); })
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = adminDiscussionResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setRoom(current); setTarget(''); setConfirmed(false); setConfirmationCode(''); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function submit(saved?: AdminDiscussionInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room?.editable || !selected || pending || !confirmed || confirmationCode !== code))) return;
|
||||
let input: AdminDiscussionInput;
|
||||
try {
|
||||
input = saved ?? newAdminDiscussionRequest(room!, target, !selected!.muted, reason);
|
||||
saveAdminDiscussionRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving discussion setting…');
|
||||
try {
|
||||
const result = adminDiscussionConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminDiscussionRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setRoom(result.room); setTarget(''); setConfirmed(false); setConfirmationCode(''); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current discussion settings are shown below; later changes have been preserved.' :
|
||||
input.muted ? 'Discussion muted for this seat. The player can still read messages and play. Saved-message retries can confirm earlier sends.' : 'Discussion unmuted for this seat. New messages are available again when the room is open.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminDiscussionRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Preserve exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false); setConfirmationCode('');
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminDiscussionResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setRoom(current);
|
||||
} catch (readError) {
|
||||
// Keep the exact retry proof, but never leave stale privileged controls visible.
|
||||
if (alive.current && readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied();
|
||||
}
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!canManage || !room || !confirmed || busy) return;
|
||||
try {
|
||||
clearAdminDiscussionRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current discussion settings before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-discussion-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-discussion-title" ref={title} tabIndex={-1}>Discussion controls · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close discussion controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading participant discussion…</output> : <>
|
||||
{room ? <>
|
||||
<p>Room: <strong>{room.removed ? 'Removed' : room.archived ? 'Archived' : room.closed ? 'Closed' : 'Open'}</strong>. Game: <strong>{room.status}</strong>.</p>
|
||||
<p>Mute blocks new public and private messages from a seat. Its owner can still read discussion, receive messages and play. Existing messages stay saved; this panel cannot read them.</p>
|
||||
{room.closed && <p>The room is closed to all new messages. These settings also apply when it reopens.</p>}
|
||||
{room.players.length > 0 && <ul>{room.players.map(player => <li key={player.id}>{player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI seat — no discussion' : player.muted ? 'Discussion muted' : 'Discussion allowed'}</li>)}</ul>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh discussion settings</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading discussion settings</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect these settings. An owner or operator can mute or unmute a seat.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={event => setConfirmed(event.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.muted ? 'Mute' : 'Unmute'} discussion</strong> for <strong>{room?.players.find(player => player.id === pending.target)?.name ?? pending.target}</strong> in room <strong>{code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again, even if another administrator changed the setting afterward.</p>
|
||||
<p>After refresh or sign-in, use All rooms in both directory filters to find later removed or archived rooms and reopen Discussion controls.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : room && !room.editable ? <p>{room.blockedReason}</p> : room && !room.players.some(player => player.control !== 'ai') ? <p>No human seats are available for discussion moderation.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-discussion-target">Participant<select id="admin-discussion-target" required value={target} disabled={busy} onChange={event => { setTarget(event.target.value); setConfirmed(false); }}>
|
||||
<option value="">Choose a human seat</option>{room.players.filter(player => player.control !== 'ai').map(player => <option key={player.id} value={player.id}>{player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.muted ? 'Muted' : 'Allowed'}</option>)}
|
||||
</select></label>
|
||||
<label htmlFor="admin-discussion-reason">Reason for the audit history<Input id="admin-discussion-reason" value={reason} maxLength={300} required disabled={busy} onChange={event => { setReason(event.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not copy private messages, credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
<p>{selected ? `${selected.muted ? 'Unmute' : 'Mute'} discussion for ${selected.name}.` : 'Choose a participant above.'} The setting follows this game seat through recovery and handover. A later owner inherits it until an administrator changes it.</p>
|
||||
<p>Game decisions, AI control, seat access, recovery and saved messages remain unchanged. Previously saved sends can still be confirmed.</p>
|
||||
<label htmlFor="admin-discussion-code">Type {code} to confirm this room<Input id="admin-discussion-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={event => { setConfirmationCode(event.target.value); setConfirmed(false); }} /></label>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !selected} onChange={event => setConfirmed(event.target.checked)} />I have reviewed this seat and understand how its discussion setting affects current and future owners.</label>
|
||||
<Button type="submit" disabled={busy || !selected || !confirmed || !reason.trim() || confirmationCode !== code}>{selected?.muted ? 'Unmute discussion' : 'Mute discussion'}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminLobbyResponse, adminLobbyConfirmation, clearAdminLobbyRequest, describeAdminLobbyAction, newAdminLobbyRequest, readAdminLobbyRequest, saveAdminLobbyRequest } from '@/lib/admin-lobby-client';
|
||||
import type { AdminLobbyAction, AdminLobbyInput, AdminLobbyView } from '@/lib/admin-lobby-configuration';
|
||||
import { FACTIONS, type FactionId } from '@/game/catalog';
|
||||
import { DIFFICULTIES, type Difficulty } from '@/game/bot-profiles';
|
||||
|
||||
type Props = {
|
||||
accountId: string; code: string; canManage: boolean; onClose: () => void;
|
||||
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'The lobby could not be loaded.';
|
||||
const baseFactions = FACTIONS.filter(faction => faction.expansion === 'base');
|
||||
|
||||
export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [lobby, setLobby] = useState<AdminLobbyView | null>(null);
|
||||
const [kind, setKind] = useState<AdminLobbyAction['type']>('rules');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [target, setTarget] = useState('');
|
||||
const [faction, setFaction] = useState<FactionId>('atreides');
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>('Easy');
|
||||
const [position, setPosition] = useState(1);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [pending, setPending] = useState<AdminLobbyInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/lobby`;
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminLobbyRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved lobby request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminLobbyResponse(result, code); if (!canceled) { setLobby(current); setEnabled(current.advanced); } })
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = adminLobbyResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setLobby(current); setConfirmed(false); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
function selectTarget(id: string) {
|
||||
setTarget(id); setConfirmed(false);
|
||||
const player = lobby?.players.find(player => player.id === id);
|
||||
if (player?.bot) { setFaction(player.faction as FactionId); setDifficulty(player.bot); setPosition(player.position); }
|
||||
}
|
||||
function selectKind(next: AdminLobbyAction['type']) {
|
||||
setKind(next); setConfirmed(false); setTarget('');
|
||||
setEnabled(next === 'rules' ? lobby?.advanced ?? false : next === 'techTokens' ? lobby?.techTokens ?? false :
|
||||
next === 'shortAllianceVictory' ? lobby?.shortAllianceVictory ?? false : lobby?.strongholdCards ?? false);
|
||||
if (next === 'addBot') setFaction(baseFactions.find(item => !lobby?.players.some(player => player.faction === item.id))?.id ?? 'atreides');
|
||||
if (next === 'removeBot' || next === 'configureBot') selectTarget(lobby?.players.find(player => player.bot)?.id ?? '');
|
||||
if (next === 'assignHost') setTarget(lobby?.players.find(player => player.hostEligible && player.id !== lobby.host)?.id ?? '');
|
||||
}
|
||||
const action: AdminLobbyAction = kind === 'rules' ? { type: kind, advanced: enabled } :
|
||||
kind === 'techTokens' || kind === 'strongholdCards' || kind === 'shortAllianceVictory' ? { type: kind, enabled } :
|
||||
kind === 'addBot' ? { type: kind, faction, difficulty } :
|
||||
kind === 'configureBot' ? { type: kind, target, faction, difficulty, position } : { type: kind, target };
|
||||
const selectedPlayer = lobby?.players.find(player => player.id === target);
|
||||
const availableFactions = baseFactions.filter(item => !lobby?.players.some(player => player.faction === item.id && (kind !== 'configureBot' || player.id !== target)));
|
||||
const actionable = lobby?.editable && (kind === 'rules' ? enabled !== lobby.advanced :
|
||||
kind === 'techTokens' ? enabled !== lobby.techTokens : kind === 'strongholdCards' ? enabled !== lobby.strongholdCards && (!enabled || lobby.advanced) :
|
||||
kind === 'shortAllianceVictory' ? enabled !== lobby.shortAllianceVictory :
|
||||
kind === 'addBot' ? lobby.players.length < 6 && availableFactions.some(item => item.id === faction) :
|
||||
kind === 'assignHost' ? selectedPlayer?.hostEligible && target !== lobby.host :
|
||||
kind === 'removeBot' ? !!selectedPlayer?.bot : !!selectedPlayer?.bot && availableFactions.some(item => item.id === faction) &&
|
||||
(selectedPlayer.faction !== faction || selectedPlayer.bot !== difficulty || selectedPlayer.position !== position));
|
||||
|
||||
async function submit(saved?: AdminLobbyInput) {
|
||||
if (inFlight.current || !lobby || !canManage || storageProblem || (!saved && (!confirmed || !actionable))) return;
|
||||
let input: AdminLobbyInput;
|
||||
try {
|
||||
input = saved ?? newAdminLobbyRequest(lobby, action, reason);
|
||||
saveAdminLobbyRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving lobby change…');
|
||||
try {
|
||||
const result = adminLobbyConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminLobbyRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setLobby(result.lobby); setConfirmed(false); setReason('');
|
||||
setNotice(result.replayed ? 'The original lobby change was already recorded. Current settings are shown below.' : 'Lobby change saved. Current settings are shown below.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminLobbyRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false);
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved lobby request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminLobbyResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setLobby(current);
|
||||
} catch { /* Preserve the original outcome and retry instructions. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
function discardUnreadableRecord() {
|
||||
if (!confirmed || busy) return;
|
||||
try {
|
||||
clearAdminLobbyRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review the current lobby before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-lobby-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-lobby-title" ref={title} tabIndex={-1}>Lobby configuration · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close lobby configuration</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading lobby configuration…</output> : !lobby ? <Button disabled={busy} onClick={() => void refresh()}>Retry loading lobby</Button> : <>
|
||||
<p>Current rules: <strong>{lobby.advanced ? 'Advanced preview' : 'Basic'}</strong> · Tech Tokens {lobby.techTokens ? 'on' : 'off'} · Stronghold Cards {lobby.strongholdCards ? 'on' : 'off'} · Three-stronghold alliance victory {lobby.shortAllianceVictory ? 'on' : 'off'}.</p>
|
||||
<p>Advanced preview is unfinished. Players must review the warning and the host must explicitly start the preview.</p>
|
||||
<ul className="admin-lobby-roster">{lobby.players.map(player => <li key={player.id}>
|
||||
<strong>{player.name}</strong>{player.id === lobby.host ? ' · Host' : ''} · {FACTIONS.find(item => item.id === player.faction)?.name ?? player.faction} · {player.bot ? `AI: ${player.bot}` : 'Human'} · Circle {player.position} · {player.ready ? 'Ready' : 'Not ready'}
|
||||
</li>)}</ul>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh current lobby</Button>
|
||||
{!lobby.editable && <p>{lobby.blockedReason ?? 'This lobby cannot currently be changed.'}</p>}
|
||||
{!canManage ? <p>Your viewer role can inspect this lobby. An owner or operator can change it.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)} />I have inspected the current lobby and want to remove the local retry record.</label>
|
||||
<Button disabled={busy || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request for {code}: {describeAdminLobbyAction(pending.action, lobby)}.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh. Retrying confirms an already completed change without applying it again, including after the game has started.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved lobby request</Button>
|
||||
</div> : lobby.editable && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label>Change<select value={kind} disabled={busy} onChange={e => selectKind(e.target.value as AdminLobbyAction['type'])}>
|
||||
<option value="rules">Rules mode</option><option value="techTokens">Tech Tokens</option><option value="strongholdCards">Stronghold Cards</option><option value="shortAllianceVictory">Three-stronghold alliance victory</option>
|
||||
<option value="addBot">Add AI player</option><option value="configureBot">Configure AI player</option><option value="removeBot">Remove AI player</option><option value="assignHost">Assign host</option>
|
||||
</select></label>
|
||||
{(kind === 'rules' || kind === 'techTokens' || kind === 'strongholdCards' || kind === 'shortAllianceVictory') && <label>{kind === 'rules' ? 'Rules mode' : kind === 'techTokens' ? 'Tech Tokens' : kind === 'strongholdCards' ? 'Stronghold Cards' : 'Three-stronghold alliance victory'}<select value={String(enabled)} disabled={busy} onChange={e => { setEnabled(e.target.value === 'true'); setConfirmed(false); }}>
|
||||
<option value="false">{kind === 'rules' ? 'Basic' : 'Off'}</option><option value="true" disabled={kind === 'strongholdCards' && !lobby.advanced}>{kind === 'rules' ? 'Advanced preview' : 'On'}</option>
|
||||
</select></label>}
|
||||
{kind === 'rules' && !enabled && lobby.strongholdCards && <p>Switching to Basic also turns off Stronghold Cards.</p>}
|
||||
{kind === 'techTokens' && <p>Tech Tokens require at least three players before the game can start.</p>}
|
||||
{kind === 'shortAllianceVictory' && <p>Two-member alliances need three strongholds with at least three controllers; two-controller games retain four.</p>}
|
||||
{(kind === 'configureBot' || kind === 'removeBot' || kind === 'assignHost') && <label>{kind === 'assignHost' ? 'New host' : 'AI player'}<select value={target} disabled={busy} onChange={e => selectTarget(e.target.value)}>
|
||||
<option value="">Choose a player</option>{lobby.players.filter(player => kind === 'assignHost' ? player.hostEligible && player.id !== lobby.host : player.bot).map(player => <option key={player.id} value={player.id}>{player.name}</option>)}
|
||||
</select></label>}
|
||||
{(kind === 'addBot' || kind === 'configureBot') && <div className="admin-create-grid">
|
||||
<label>AI faction<select value={faction} disabled={busy} onChange={e => { setFaction(e.target.value as FactionId); setConfirmed(false); }}>{availableFactions.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
|
||||
<label>AI difficulty<select value={difficulty} disabled={busy} onChange={e => { setDifficulty(e.target.value as Difficulty); setConfirmed(false); }}>{DIFFICULTIES.map(item => <option key={item}>{item}</option>)}</select></label>
|
||||
{kind === 'configureBot' && <label>Player circle<select value={position} disabled={busy} onChange={e => { setPosition(Number(e.target.value)); setConfirmed(false); }}>{[1, 2, 3, 4, 5, 6].map(item => <option key={item} disabled={lobby.players.some(player => player.id !== target && player.position === item)}>{item}</option>)}</select></label>}
|
||||
</div>}
|
||||
{kind === 'assignHost' && <p>The selected human gains host controls. The previous host keeps their seat. Existing seat credentials remain unchanged, and all humans must ready again.</p>}
|
||||
<label htmlFor="admin-lobby-reason">Reason for the audit history<Input id="admin-lobby-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
<p>{describeAdminLobbyAction(action, lobby)} in room <strong>{code}</strong>. Settings affect everyone in this lobby and may clear readiness. This action gives the administrator no private seat access.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !actionable} onChange={e => setConfirmed(e.target.checked)} />I have reviewed this change and am ready to apply it.</label>
|
||||
<Button type="submit" disabled={busy || !actionable || !confirmed || !reason.trim()}>Apply lobby change</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminArchiveResponse, adminArchiveConfirmation, clearAdminArchiveRequest, newAdminArchiveRequest, readAdminArchiveRequest, saveAdminArchiveRequest } from '@/lib/admin-archive-client';
|
||||
import type { AdminArchiveInput, AdminArchiveView } from '@/lib/admin-archive';
|
||||
|
||||
type Props = {
|
||||
accountId: string; code: string; canManage: boolean; onClose: () => void;
|
||||
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Room availability could not be loaded.';
|
||||
|
||||
export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminArchiveView | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminArchiveInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/archive`;
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminArchiveRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved archive or unarchive request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminArchiveResponse(result, code); if (!canceled) setRoom(current); })
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = adminArchiveResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setRoom(current); setConfirmed(false); setConfirmationCode(''); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function submit(saved?: AdminArchiveInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room || room.removed || !room.closed || pending || !confirmed || (!room.archived && confirmationCode !== code)))) return;
|
||||
let input: AdminArchiveInput;
|
||||
try {
|
||||
input = saved ?? newAdminArchiveRequest(room!, !room!.archived, reason);
|
||||
saveAdminArchiveRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…');
|
||||
try {
|
||||
const result = adminArchiveConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminArchiveRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setRoom(result.room); setConfirmed(false); setConfirmationCode(''); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current room availability is shown below.' :
|
||||
input.archived ? 'Room archived. It remains closed and readable to existing players. Find it with the Archive filter.' : 'Room unarchived. It remains closed until an administrator separately reopens it.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminArchiveRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false); setConfirmationCode('');
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminArchiveResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!canManage || !room || !confirmed || busy) return;
|
||||
try {
|
||||
clearAdminArchiveRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current room availability before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-archive-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-archive-title" ref={title} tabIndex={-1}>Archive or unarchive · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close archive controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading room archive…</output> : <>
|
||||
{room ? <>
|
||||
<p>Archive status: <strong>{room.archived ? 'Archived' : 'Unarchived'}</strong>. Room availability: <strong>{room.removed ? 'Removed' : room.closed ? 'Closed' : 'Open'}</strong>.</p>
|
||||
<p>Archiving organizes a closed room without changing its saved game, credentials or discussion. Existing players keep their readable table and history.</p>
|
||||
{room.archivedAt !== null && <p className="admin-secondary">Archived at: <time dateTime={new Date(room.archivedAt).toISOString()}>{new Date(room.archivedAt).toLocaleString()}</time>.</p>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh archive status</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading archive status</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect archive status. An owner or operator can archive or unarchive rooms.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.archived ? 'Archive' : 'Unarchive'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again; the current archive status may differ after another administrator’s action.</p>
|
||||
<p>After refresh or sign-in, choose All rooms in both the Directory and Archive filters to find the room and reopen these controls.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : room?.removed ? <p>Restore this removed room before changing its archive status. Any saved archive request can still be confirmed above.</p> : room && !room.closed ? <p>Use Close or reopen to close this room before archiving it. Archiving does not interrupt play or assign an outcome.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-archive-reason">Reason for the audit history<Input id="admin-archive-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
{room.archived ? <>
|
||||
<p>Return room <strong>{code}</strong> to the unarchived directory. Its game, seats, history, pause and joining settings remain saved.</p>
|
||||
<p>Unarchiving leaves the room closed. Reopen it separately when play should resume.</p>
|
||||
</> : <>
|
||||
<p>Archive closed room <strong>{code}</strong>. It leaves the default administrator list and remains available under Archived rooms or All rooms in the Archive filter.</p>
|
||||
<p>Players can still read their saved table and history. The room stays closed to further play; archiving does not delete it or change its winner.</p>
|
||||
<label htmlFor="admin-archive-code">Type {code} to confirm this room<Input id="admin-archive-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
|
||||
</>}
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{room.archived ? `I have reviewed room ${code} and understand that unarchiving leaves it closed.` : `I have reviewed closed room ${code} and want to archive it.`}</label>
|
||||
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || (!room.archived && confirmationCode !== code)}>{room.archived ? 'Unarchive room' : 'Archive room'}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminClosureResponse, adminClosureConfirmation, clearAdminClosureRequest, newAdminClosureRequest, readAdminClosureRequest, saveAdminClosureRequest } from '@/lib/admin-closure-client';
|
||||
import type { AdminClosureInput, AdminClosureView } from '@/lib/admin-closure';
|
||||
|
||||
type Props = {
|
||||
accountId: string; code: string; canManage: boolean; onClose: () => void;
|
||||
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Room availability could not be loaded.';
|
||||
|
||||
export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminClosureView | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminClosureInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/closure`;
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminClosureRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved closure or reopening request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminClosureResponse(result, code); if (!canceled) setRoom(current); })
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = adminClosureResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setRoom(current); setConfirmed(false); setConfirmationCode(''); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function submit(saved?: AdminClosureInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room || room.removed || room.archived || pending || !confirmed || (!room.closed && confirmationCode !== code)))) return;
|
||||
let input: AdminClosureInput;
|
||||
try {
|
||||
input = saved ?? newAdminClosureRequest(room!, !room!.closed, reason);
|
||||
saveAdminClosureRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.closed ? 'Closing room…' : 'Reopening room…');
|
||||
try {
|
||||
const result = adminClosureConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminClosureRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setRoom(result.room); setConfirmed(false); setConfirmationCode(''); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current room availability is shown below.' :
|
||||
input.closed ? 'Room closed to further play. Existing players can still read their saved table and discussion.' : 'Room reopened with its saved game, seats and previous pause and joining settings.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminClosureRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false); setConfirmationCode('');
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminClosureResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!canManage || !room || !confirmed || busy) return;
|
||||
try {
|
||||
clearAdminClosureRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current room availability before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-closure-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-closure-title" ref={title} tabIndex={-1}>Close or reopen · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close room availability controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading room availability…</output> : <>
|
||||
{room ? <>
|
||||
<p>Current availability: <strong>{room.closed ? 'Closed' : 'Open'}</strong>. Saved pause setting: <strong>{room.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{room.joinLocked ? 'locked' : 'open'}</strong>.</p>
|
||||
{room.closed && <p>The table and discussion history remain readable to existing players. New play, messages and seat changes are stopped.</p>}
|
||||
{room.closedAt !== null && <p className="admin-secondary">Closed at: <time dateTime={new Date(room.closedAt).toISOString()}>{new Date(room.closedAt).toLocaleString()}</time>.</p>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh room availability</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading room availability</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect room availability. An owner or operator can close or reopen rooms.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.closed ? 'Close' : 'Reopen'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
|
||||
<p>After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : room?.removed ? <p>Restore this removed room before changing its closure. Any saved close/reopen request can still be confirmed above.</p> : room?.archived ? <p>Unarchive this room before reopening it. The saved game remains closed and readable to existing players.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-closure-reason">Reason for the audit history<Input id="admin-closure-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
{room.closed ? <>
|
||||
<p>Reopen play in room <strong>{code}</strong>. Saved progress and seats are retained, and current credentials and access revocations remain in effect.</p>
|
||||
<p>Reopening preserves the previous pause and joining settings. {room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
|
||||
</> : <>
|
||||
<p>Close room <strong>{code}</strong> to further play. This stops joining, game decisions, AI, new messages and seat security changes. Existing players can still read their own table and discussion history.</p>
|
||||
<p>Warn active players before interrupting their game. Closing preserves the exact pending game and seats. It does not award a winner. An administrator can reopen it later.</p>
|
||||
<label htmlFor="admin-closure-code">Type {code} to confirm this room<Input id="admin-closure-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
|
||||
</>}
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{room.closed ? `I have reviewed room ${code} and its saved pause setting and am ready to reopen it.` : `I have checked room ${code}, warned active players, and am ready to close it.`}</label>
|
||||
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || (!room.closed && confirmationCode !== code)}>{room.closed ? 'Reopen room' : 'Close room'}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { clearAdminRoomRequest, newAdminRoomRequest, readAdminRoomRequest, retainAdminRoomRequest, saveAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import type { AdminRoomControlInput, RoomControl } from '@/lib/room-control';
|
||||
|
||||
type Props = {
|
||||
accountId: string;
|
||||
code: string;
|
||||
canManage: boolean;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
onDenied: () => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'The room settings could not be loaded.';
|
||||
function controlResponse(value: RoomControl): RoomControl {
|
||||
if (!value || typeof value.paused !== 'boolean' || typeof value.joinLocked !== 'boolean' ||
|
||||
!Number.isSafeInteger(value.revision) || value.revision < 0 ||
|
||||
!(value.updatedAt === null || Number.isSafeInteger(value.updatedAt))) throw new Error('The server returned unreadable room settings.');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [control, setControl] = useState<RoomControl | null>(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [joinLocked, setJoinLocked] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [pending, setPending] = useState<AdminRoomControlInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const inFlight = useRef(false);
|
||||
const url = `/api/admin/rooms/${code}/control`;
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminRoomRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved request needs confirmation. Review the current settings, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => {
|
||||
const current = controlResponse(result);
|
||||
if (!canceled) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); }
|
||||
})
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = controlResponse(await requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }));
|
||||
if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); setConfirmed(false); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function submit(saved?: AdminRoomControlInput) {
|
||||
if (inFlight.current || !control || !canManage || storageProblem || (!saved && !confirmed)) return;
|
||||
let input: AdminRoomControlInput;
|
||||
try {
|
||||
input = saved ?? newAdminRoomRequest(control, paused, joinLocked, reason);
|
||||
saveAdminRoomRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…');
|
||||
try {
|
||||
const result = await requestJson<RoomControl & { replayed: boolean }>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
});
|
||||
const current = controlResponse(result);
|
||||
if (typeof result.replayed !== 'boolean') throw new Error('The server did not confirm this operation.');
|
||||
clearAdminRoomRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked);
|
||||
setConfirmed(false); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current settings are shown below.' : 'Room settings saved.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
const uncertain = requestMayHaveCompleted(error);
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminRoomRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false);
|
||||
setNotice(uncertain ? `${message(error)} The change may have been saved. Use Retry saved request to confirm; no new request has been sent.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else {
|
||||
try {
|
||||
const current = controlResponse(await requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }));
|
||||
if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); }
|
||||
} catch { /* Preserve the original outcome and retry instructions. */ }
|
||||
}
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!confirmed || busy) return;
|
||||
try {
|
||||
clearAdminRoomRequest(window.sessionStorage, accountId, code);
|
||||
setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review the current settings before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
const changed = control && (control.paused !== paused || control.joinLocked !== joinLocked);
|
||||
return <section className="admin-room-control" aria-labelledby="admin-room-control-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-room-control-title" ref={title} tabIndex={-1}>Room {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close room controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading current room settings…</output> : !control ? <Button disabled={busy} onClick={() => void refresh()}>Retry loading settings</Button> : <>
|
||||
<p>Current status: <strong>{control.closed ? 'Closed' : control.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{control.joinLocked ? 'locked' : 'open'}</strong>.</p>
|
||||
<p>Pause stops player decisions, automatic effects and AI play. The pending game stays saved. Discussion, seat recovery and taking back control remain available.</p>
|
||||
<p>A joining lock blocks new players. Existing players can reconnect or recover their seats.</p>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh current settings</Button>
|
||||
{!canManage ? <p>Your viewer role can inspect settings. An owner or operator can change them.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)} />I have inspected the current settings and want to remove the local retry record.</label>
|
||||
<Button disabled={busy || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.</p>
|
||||
<p>Reason: {pending.reason}</p>
|
||||
<p>The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : control.closed ? <p>Reopen this room before changing pause or joining settings. Existing settings are preserved while closed.</p> : <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label className="admin-check"><input type="checkbox" checked={paused} disabled={busy} onChange={e => { setPaused(e.target.checked); setConfirmed(false); }} />Pause this room</label>
|
||||
<label className="admin-check"><input type="checkbox" checked={joinLocked} disabled={busy} onChange={e => { setJoinLocked(e.target.checked); setConfirmed(false); }} />Lock new joins</label>
|
||||
<label htmlFor="admin-room-reason">Reason for the audit history<Input id="admin-room-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => setReason(e.target.value)} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
<p>This affects every player in room <strong>{code}</strong>. Warn active players before interrupting their game. Resuming keeps the pending choices and restarts AI pacing.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !changed} onChange={e => setConfirmed(e.target.checked)} />I have checked this room and am ready to apply these settings.</label>
|
||||
<Button type="submit" disabled={busy || !changed || !confirmed || !reason.trim()}>Apply room settings</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { FACTIONS, type FactionId } from '@/game/catalog';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { clearAdminRoomCreation, completeAdminRoomCreation, newAdminRoomCreationRequest, readAdminRoomCreation, saveAdminRoomCreation, validAdminRoomCreationResult, type AdminRoomCreationRecord } from '@/lib/admin-room-creation-client';
|
||||
import type { AdminRoomCreationInput, AdminRoomCreationResult } from '@/lib/admin-room-creation';
|
||||
|
||||
type Props = { accountId: string; onClose: () => void; onCreated: (code: string) => void; onDenied: () => void; onBusyChange: (busy: boolean) => void };
|
||||
const factions = FACTIONS.filter(faction => faction.expansion === 'base');
|
||||
const difficulties = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'The room could not be created.';
|
||||
|
||||
export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onBusyChange }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [faction, setFaction] = useState<FactionId>('atreides');
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [techTokens, setTechTokens] = useState(false);
|
||||
const [strongholdCards, setStrongholdCards] = useState(false);
|
||||
const [bots, setBots] = useState<AdminRoomCreationInput['bots']>([]);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [record, setRecord] = useState<AdminRoomCreationRecord | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
heading.current?.focus();
|
||||
try {
|
||||
const saved = readAdminRoomCreation(window.sessionStorage, accountId);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore tab-scoped creation proof after SSR.
|
||||
setRecord(saved);
|
||||
if (saved?.kind === 'pending') setNotice('This tab has an unconfirmed room request. Retry it to find the original room.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
setLoading(false);
|
||||
return () => { alive.current = false; };
|
||||
}, [accountId]);
|
||||
|
||||
async function submit(saved?: AdminRoomCreationInput) {
|
||||
if (inFlight.current || storageProblem || (!saved && (!confirmed || record))) return;
|
||||
let input: AdminRoomCreationInput;
|
||||
try {
|
||||
input = saved ?? newAdminRoomCreationRequest({ name, faction, advanced, techTokens, strongholdCards, bots, reason });
|
||||
saveAdminRoomCreation(window.sessionStorage, accountId, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true);
|
||||
setRecord({ kind: 'pending', input }); setNotice('Creating your room…');
|
||||
try {
|
||||
const result = await requestJson<AdminRoomCreationResult>('/api/admin/rooms', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
});
|
||||
if (validAdminRoomCreationResult(result) && result.operationId === input.operationId && result.roomRemoved) {
|
||||
if (alive.current) {
|
||||
setNotice(`Room ${result.code} exists but has been removed. Its exact creation proof is still saved in this tab. Restore the room in Administration, then retry this same request to confirm host access.`);
|
||||
setConfirmed(false);
|
||||
onCreated(result.code);
|
||||
}
|
||||
return;
|
||||
}
|
||||
completeAdminRoomCreation(window.sessionStorage, accountId, input, result);
|
||||
if (alive.current) {
|
||||
setRecord({ kind: 'completed', result }); setConfirmed(false);
|
||||
setNotice(result.replayed ? 'The original room has been found. No duplicate room was created.' : 'Room created. The lobby is ready to configure and invite players.');
|
||||
onCreated(result.code);
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminRoomCreation(window.sessionStorage, accountId); cleared = true; } catch { /* Preserve exact retry if storage is unavailable. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setRecord(null);
|
||||
setConfirmed(false);
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The room may already exist. Retry the saved request to confirm it.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function clearRecord() {
|
||||
if (busy || (storageProblem && !confirmed)) return;
|
||||
try {
|
||||
clearAdminRoomCreation(window.sessionStorage, accountId);
|
||||
setRecord(null); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local creation record cleared. Existing rooms and saved seats are unchanged.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
function addBot() {
|
||||
const next = factions.find(item => item.id !== faction && !bots.some(bot => bot.faction === item.id));
|
||||
if (next) { setBots([...bots, { faction: next.id, difficulty: 'Medium' }]); setConfirmed(false); }
|
||||
}
|
||||
|
||||
const result = record?.kind === 'completed' ? record.result : null;
|
||||
return <section className="admin-room-control admin-room-creation" aria-labelledby="admin-create-title" aria-busy={busy || loading}>
|
||||
<div className="admin-section-heading"><h2 id="admin-create-title" ref={heading} tabIndex={-1}>Create a room</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close room creation</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Checking this tab’s saved creation request…</output> : storageProblem ? <div className="admin-confirm">
|
||||
<p>A room may already have been created. Check the directory before removing this unreadable record. Removing it cannot cancel creation and may lose this tab’s host-seat retry proof.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />I checked the directory and want to discard this local record.</label>
|
||||
<Button disabled={!confirmed} onClick={clearRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : record?.kind === 'pending' ? <div className="admin-confirm">
|
||||
<p>Unconfirmed room: host <strong>{record.input.name}</strong> · {record.input.advanced ? 'Advanced preview' : 'Basic'} · {record.input.bots.length} AI players.</p>
|
||||
<p>The exact request is kept privately in this tab across refresh. Keep the tab and its storage until the result is confirmed. Retrying finds the same room.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(record.input)}>Retry saved room creation</Button>
|
||||
</div> : result ? <div className="admin-created-result">
|
||||
<h3>Room <span className="admin-code">{result.code}</span></h3>
|
||||
<p>Share invitation code <strong>{result.code}</strong>. Players can enter it on the home page to join an available faction.</p>
|
||||
{result.hostAccess ? <><p>When creation was confirmed, this browser received the host seat. Open the lobby to change settings, invite human players, or use <strong>Pass your seat to another player</strong> to give someone else the host seat.</p><a className="admin-room-link" href={`/?room=${result.code}`}>Open your host seat</a></> : <p>The original host seat has since been transferred or its access revoked. This confirmation does not restore old seat access.</p>}
|
||||
<Button variant="outline" onClick={clearRecord}>Create another room</Button>
|
||||
</div> : <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<p>Create a lobby with a new human host seat in this browser. Players join by invitation; AI seats can be included below. Nothing starts until the host and players complete normal readiness.</p>
|
||||
<div className="admin-create-grid">
|
||||
<label htmlFor="admin-host-name">Host player name<Input id="admin-host-name" value={name} maxLength={32} required disabled={busy} onChange={event => { setName(event.target.value); setConfirmed(false); }} /></label>
|
||||
<label htmlFor="admin-host-faction">Host faction<select id="admin-host-faction" value={faction} disabled={busy} onChange={event => { setFaction(event.target.value as FactionId); setConfirmed(false); }}>
|
||||
{factions.map(item => <option key={item.id} value={item.id} disabled={bots.some(bot => bot.faction === item.id)}>{item.name}</option>)}
|
||||
</select></label>
|
||||
<label htmlFor="admin-create-rules">Rules<select id="admin-create-rules" value={advanced ? 'advanced' : 'basic'} disabled={busy} onChange={event => { setAdvanced(event.target.value === 'advanced'); if (event.target.value === 'basic') setStrongholdCards(false); setConfirmed(false); }}>
|
||||
<option value="basic">Basic</option><option value="advanced">Advanced preview</option>
|
||||
</select></label>
|
||||
</div>
|
||||
{advanced && <p className="admin-notice">Advanced preview is unfinished. Only the six classic factions are available, and starting requires the existing preview confirmation.</p>}
|
||||
<label className="admin-check"><input type="checkbox" checked={techTokens} disabled={busy} onChange={event => { setTechTokens(event.target.checked); setConfirmed(false); }} />Tech Tokens · at least three players required to start</label>
|
||||
<label className="admin-check"><input type="checkbox" checked={strongholdCards} disabled={busy || !advanced} onChange={event => { setStrongholdCards(event.target.checked); setConfirmed(false); }} />Stronghold Cards · Advanced rules required</label>
|
||||
<fieldset disabled={busy}><legend>Initial AI players</legend>
|
||||
<p className="admin-secondary">The other seats remain available for human players. AI strategy tuning is still in development.</p>
|
||||
{bots.map((bot, index) => <div className="admin-create-bot" key={index}>
|
||||
<label htmlFor={`admin-bot-faction-${index}`}>AI {index + 1} faction<select id={`admin-bot-faction-${index}`} value={bot.faction} onChange={event => { setBots(bots.map((item, at) => at === index ? { ...item, faction: event.target.value as FactionId } : item)); setConfirmed(false); }}>
|
||||
{factions.map(item => <option key={item.id} value={item.id} disabled={item.id === faction || bots.some((other, at) => at !== index && other.faction === item.id)}>{item.name}</option>)}
|
||||
</select></label>
|
||||
<label htmlFor={`admin-bot-difficulty-${index}`}>Difficulty<select id={`admin-bot-difficulty-${index}`} value={bot.difficulty} onChange={event => { const difficulty = difficulties.find(value => value === event.target.value); if (difficulty) setBots(bots.map((item, at) => at === index ? { ...item, difficulty } : item)); setConfirmed(false); }}>
|
||||
{difficulties.map(value => <option key={value}>{value}</option>)}
|
||||
</select></label>
|
||||
<Button type="button" variant="outline" onClick={() => { setBots(bots.filter((_, at) => at !== index)); setConfirmed(false); }}>Remove AI {index + 1}</Button>
|
||||
</div>)}
|
||||
<Button type="button" variant="outline" disabled={bots.length >= 5} onClick={addBot}>Add an AI player</Button>
|
||||
</fieldset>
|
||||
<label htmlFor="admin-create-reason">Reason for the audit history<Input id="admin-create-reason" value={reason} maxLength={300} required disabled={busy} onChange={event => { setReason(event.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use an operational reason; leave out credentials and private game information.</p>
|
||||
<div className="admin-confirm"><label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />This browser will own the new host seat. I have checked these settings.</label>
|
||||
<Button type="submit" disabled={busy || !confirmed || !name.trim() || !reason.trim()}>Create room with host seat</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminRemovalResponse, adminRemovalConfirmation, clearAdminRemovalRequest, newAdminRemovalRequest, readAdminRemovalRequest, saveAdminRemovalRequest } from '@/lib/admin-removal-client';
|
||||
import type { AdminRemovalInput, AdminRemovalView } from '@/lib/admin-removal';
|
||||
|
||||
type Props = {
|
||||
accountId: string; code: string; canManage: boolean; onClose: () => void;
|
||||
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Room availability could not be loaded.';
|
||||
|
||||
export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminRemovalView | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminRemovalInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/removal`;
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminRemovalRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved removal or restoration request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminRemovalResponse(result, code); if (!canceled) setRoom(current); })
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = adminRemovalResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setRoom(current); setConfirmed(false); setConfirmationCode(''); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function submit(saved?: AdminRemovalInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room || pending || !confirmed || (!room.removed && confirmationCode !== code)))) return;
|
||||
let input: AdminRemovalInput;
|
||||
try {
|
||||
input = saved ?? newAdminRemovalRequest(room!, !room!.removed, reason);
|
||||
saveAdminRemovalRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…');
|
||||
try {
|
||||
const result = adminRemovalConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminRemovalRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setRoom(result.room); setConfirmed(false); setConfirmationCode(''); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current room availability is shown below.' :
|
||||
input.removed ? 'Room removed from ordinary access. Its saved game and seats are preserved for restoration.' : 'Room restored with its saved game, seats and previous pause and joining settings.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminRemovalRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false); setConfirmationCode('');
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminRemovalResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!canManage || !room || !confirmed || busy) return;
|
||||
try {
|
||||
clearAdminRemovalRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current room availability before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-removal-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-removal-title" ref={title} tabIndex={-1}>Remove or restore · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close removal controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading room availability…</output> : <>
|
||||
{room ? <>
|
||||
<p>Current availability: <strong>{room.removed ? 'Removed' : 'Active'}</strong>. Saved pause setting: <strong>{room.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{room.joinLocked ? 'locked' : 'open'}</strong>.</p>
|
||||
{room.removed && <p>Ordinary room access is unavailable. The game, seats and messages remain saved.</p>}
|
||||
{room.removedAt !== null && <p className="admin-secondary">Last removal: <time dateTime={new Date(room.removedAt).toISOString()}>{new Date(room.removedAt).toLocaleString()}</time>.</p>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh room availability</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading room availability</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect room availability. An owner or operator can remove or restore rooms.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing the record does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.removed ? 'Remove' : 'Restore'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
|
||||
<p>After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-removal-reason">Reason for the audit history<Input id="admin-removal-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
{room.removed ? <>
|
||||
<p>Restore ordinary access to room <strong>{code}</strong>. Saved progress and seats are retained, and current credentials and access revocations remain in effect.</p>
|
||||
<p>Restoration preserves the previous pause and joining settings. {room.archived && 'This room remains archived; use the Archive filter to find it after restoration.'} {room.closed && 'This room is also closed: restoration keeps it closed to further play until reopened.'} {room.closed ? 'AI remains stopped while closed.' : room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
|
||||
</> : <>
|
||||
<p>Remove room <strong>{code}</strong> from ordinary access. This interrupts every player: private room access, joining, game actions, AI play, seat security changes and messages stop until restoration.</p>
|
||||
<p>Warn active players before interrupting their game. This recoverable removal keeps the saved game and seats for restoration.</p>
|
||||
<label htmlFor="admin-removal-code">Type {code} to confirm this room<Input id="admin-removal-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
|
||||
</>}
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{room.removed ? `I have reviewed room ${code} and its saved pause setting and am ready to restore it.` : `I have checked room ${code}, warned active players, and am ready to remove it.`}</label>
|
||||
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || (!room.removed && confirmationCode !== code)}>{room.removed ? 'Restore room' : 'Remove room'}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminSeatAiResponse, adminSeatAiConfirmation, clearAdminSeatAiRequest, newAdminSeatAiRequest, readAdminSeatAiRequest, saveAdminSeatAiRequest } from '@/lib/admin-seat-ai-client';
|
||||
import type { AdminSeatAiInput, AdminSeatAiView } from '@/lib/admin-seat-ai';
|
||||
import { DIFFICULTIES, type Difficulty } from '@/game/bot-profiles';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
|
||||
type Props = {
|
||||
accountId: string; code: string; canManage: boolean; onClose: () => void;
|
||||
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Participant availability could not be loaded.';
|
||||
const label = (difficulty: Difficulty) => difficulty[0].toUpperCase() + difficulty.slice(1);
|
||||
|
||||
export function AdminSeatAi({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminSeatAiView | null>(null);
|
||||
const [target, setTarget] = useState('');
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>('Easy');
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminSeatAiInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/seat-ai`;
|
||||
const selected = room?.players.find(player => player.id === target && player.eligible);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminSeatAiRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved participant AI request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminSeatAiResponse(result, code); if (!canceled) setRoom(current); })
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = adminSeatAiResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setRoom(current); setTarget(''); setConfirmed(false); setConfirmationCode(''); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function submit(saved?: AdminSeatAiInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room?.editable || !selected || pending || !confirmed || confirmationCode !== code))) return;
|
||||
let input: AdminSeatAiInput;
|
||||
try {
|
||||
input = saved ?? newAdminSeatAiRequest(room!, target, difficulty, reason);
|
||||
saveAdminSeatAiRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Enabling participant AI…');
|
||||
try {
|
||||
const result = adminSeatAiConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminSeatAiRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setRoom(result.room); setTarget(''); setConfirmed(false); setConfirmationCode(''); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current participant control is shown below; later changes have been preserved.' :
|
||||
'Participant AI enabled. The room remains paused. The player keeps their seat and can take back control. Resume separately through Room controls when everyone is ready.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminSeatAiRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Preserve exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false); setConfirmationCode('');
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminSeatAiResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setRoom(current);
|
||||
} catch (readError) {
|
||||
// Keep the exact retry proof, but never leave stale privileged controls visible.
|
||||
if (alive.current && readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied();
|
||||
}
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!canManage || !room || !confirmed || busy) return;
|
||||
try {
|
||||
clearAdminSeatAiRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current participant control before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-seat-ai-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-seat-ai-title" ref={title} tabIndex={-1}>Participant AI · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close participant controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading participant availability…</output> : <>
|
||||
{room ? <>
|
||||
<p>Room: <strong>{room.removed ? 'Removed' : room.archived ? 'Archived' : room.closed ? 'Closed' : room.paused ? 'Paused' : 'Running'}</strong>. Game: <strong>{room.status}</strong>.</p>
|
||||
<p>Use AI to support an absent player in a paused game. They keep their cards, forces and access, and can take back control. No private seat access is given to the administrator.</p>
|
||||
{room.players.length > 0 && <ul>{room.players.map(player => <li key={player.id}>{player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.control === 'human' ? 'Human' : `${player.difficulty ? label(player.difficulty) : ''} AI${player.control === 'autopilot' ? ' on human seat' : ''}`}</li>)}</ul>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh participants</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading participants</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect participants. An owner or operator can enable participant AI.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: enable <strong>{label(pending.difficulty)} AI</strong> for <strong>{room?.players.find(player => player.id === pending.target)?.name ?? pending.target}</strong> in room <strong>{code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again, including after the player takes back control or the room resumes.</p>
|
||||
<p>After refresh or sign-in, use All rooms in both Directory and Archive filters to find the room and reopen Participant AI.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : room && !room.editable ? <p>{room.blockedReason}</p> : room && !room.players.some(player => player.eligible) ? <p>No human participant is eligible. A participant needs current seat access and must not already be controlled by AI.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-seat-ai-target">Participant<select id="admin-seat-ai-target" required value={target} disabled={busy} onChange={e => { setTarget(e.target.value); setConfirmed(false); }}>
|
||||
<option value="">Choose a human participant</option>{room.players.filter(player => player.eligible).map(player => <option key={player.id} value={player.id}>{player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction}</option>)}
|
||||
</select></label>
|
||||
<label htmlFor="admin-seat-ai-difficulty">AI difficulty<select id="admin-seat-ai-difficulty" value={difficulty} disabled={busy} onChange={e => { setDifficulty(e.target.value as Difficulty); setConfirmed(false); }}>
|
||||
{DIFFICULTIES.map(value => <option key={value} value={value}>{label(value)}</option>)}
|
||||
</select></label>
|
||||
<label htmlFor="admin-seat-ai-reason">Reason for the audit history<Input id="admin-seat-ai-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
<p>{selected ? `Enable ${label(difficulty)} AI for ${selected.name}.` : 'Choose the participant above.'} The room will remain paused. Unused permissions this player gave others to start AI will be revoked; their seat and recovery access remain.</p>
|
||||
<p>Resume separately through Room controls when play should continue. This does not remove or restrict a participant.</p>
|
||||
<label htmlFor="admin-seat-ai-code">Type {code} to confirm this room<Input id="admin-seat-ai-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !selected} onChange={e => setConfirmed(e.target.checked)} />I have reviewed this participant and understand that they retain their seat access and can take back control.</label>
|
||||
<Button type="submit" disabled={busy || !selected || !confirmed || !reason.trim() || confirmationCode !== code}>Enable participant AI</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { GameView } from '@/game/engine';
|
||||
|
||||
/** The engine quote carries public locations only, never concealed force values. */
|
||||
export function AdvancedAllySeparation({ quote, basic = false }: {
|
||||
quote: GameView['advancedAllySeparation'];
|
||||
basic?: boolean;
|
||||
}) {
|
||||
if (!quote?.territories.length) return null;
|
||||
return <output id={basic ? 'basic-ally-separation' : 'advanced-ally-separation'} className="notice block">
|
||||
{basic ? <>
|
||||
Your ally’s Shipment and Movement turn is finished. Ending your turn now sends your forces in{' '}
|
||||
{quote.territories.join(', ')} to Tanks under the Basic alliance rule.
|
||||
</> : <>
|
||||
Finishing now sends your forces in{' '}
|
||||
{quote.territories.join(', ')} to Tanks under the Advanced alliance rule.
|
||||
</>}
|
||||
{quote.noField && <span className="mt-2 block">
|
||||
Provisional No-Field consequence: at this ending deadline, reveal your marker and place its forces
|
||||
using only available reserves, then send the actual revealed forces to Tanks. A zero marker creates
|
||||
no counter. Your ally’s forces stay unchanged.
|
||||
</span>}
|
||||
</output>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { territory } from '@/game/board';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
export function AdvisorPreparation({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const offer = game.advisorPreparation;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (!offer || game.status !== 'playing' || !game.advanced ||
|
||||
game.phase < 2 || game.phase > 5 || me?.faction !== 'beneGesserit') return null;
|
||||
const priorityPending = !!game.response || !!game.decision || !!game.phaseOpening;
|
||||
const blocked = offer.blocked || (priorityPending
|
||||
? 'Finish the current response, decision or phase opening before preparing advisors.'
|
||||
: null);
|
||||
const disabled = busy || !!blocked || !!game.truthtrance || !!game.nexusTraitors?.pending;
|
||||
return (
|
||||
<section className="notice space-y-3" aria-label="Bene Gesserit advisor preparation">
|
||||
<h3 className="font-serif text-xl">Prepare advisors for battle</h3>
|
||||
<p className="muted">
|
||||
After Spice Blow and any Nexus, you may prepare advisors until the first
|
||||
actual shipment has occurred. This window includes Charity, Bidding, Revival
|
||||
and the opening of Shipment and Movement.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Choose a territory to turn all your advisors there into fighters, across
|
||||
every sector. Storm, ordinary allied occupation and fresh-advisor restrictions
|
||||
still apply. The existing supported Ecaz co-side composition retains its
|
||||
separate eligibility.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Provisional Karama policy: a canceled preparation blocks that territory
|
||||
until this window ends. Other eligible territories remain available.
|
||||
Intrusion, Nexus conversion and automatic advisor flips remain independent.
|
||||
</p>
|
||||
<p className="fine">
|
||||
The ordinary advisor chooser during Shipment and Movement remains
|
||||
available. Declining that choice does not close this preparation window.
|
||||
</p>
|
||||
{blocked && <p className="notice" id="advisor-preparation-unavailable">{blocked}</p>}
|
||||
{!offer.territories.length && (
|
||||
<p className="muted">No advisor territory is currently eligible for preparation.</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{offer.territories.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
data-testid={`advisor-preparation-${id}`}
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={disabled}
|
||||
aria-describedby={blocked ? 'advisor-preparation-unavailable' : undefined}
|
||||
onClick={() => {
|
||||
if (!disabled) act({ type: 'prepareAdvisors', territory: id });
|
||||
}}
|
||||
>
|
||||
Prepare advisors · {territory(id).name}
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { parseAllianceClaim, type AllianceClaim } from '@/game/alliance-promises';
|
||||
|
||||
type Partner = { id: string; name: string };
|
||||
export function allianceClaimInputError(value: unknown, partners?: readonly Partner[]): string | null {
|
||||
try {
|
||||
const claim = parseAllianceClaim(value);
|
||||
return partners && !partners.some(partner => partner.id === claim.partner)
|
||||
? 'Choose one of the current alliance partners.' : null;
|
||||
} catch (error) { return error instanceof Error ? error.message : 'Choose the current turn and another player.'; }
|
||||
}
|
||||
|
||||
/** The parent supplies this Nexus turn and its current named-other choices. */
|
||||
export function AllianceClaimFields(props: {
|
||||
value: AllianceClaim;
|
||||
onChange: (claim: AllianceClaim) => void;
|
||||
partners: readonly Partner[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const error = allianceClaimInputError(props.value, props.partners);
|
||||
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Final alliance at this Nexus close">
|
||||
<legend>Final alliance at this Nexus close</legend>
|
||||
<p>Turn {props.value.turn} · Current Nexus</p>
|
||||
<label htmlFor="truth-alliance-partner">Named alliance partner</label>
|
||||
<select id="truth-alliance-partner" disabled={props.disabled}
|
||||
value={props.partners.some(partner => partner.id === props.value.partner) ? props.value.partner : ''}
|
||||
onChange={event => {
|
||||
const next = { ...props.value, partner: event.currentTarget.value };
|
||||
if (!props.disabled && !allianceClaimInputError(next, props.partners)) props.onChange(parseAllianceClaim(next));
|
||||
}}>
|
||||
<option value="">Choose another player</option>
|
||||
{props.partners.map(partner => <option key={partner.id} value={partner.id}>{partner.name}</option>)}
|
||||
</select>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<p className="fine">
|
||||
This asks about actual membership when this Nexus closes. Offers alone are not alliances.
|
||||
A Yes needs an existing alliance or a real reciprocal invitation; it cannot promise the other player’s consent.
|
||||
Otherwise No or unknown may apply, and the holder can ask again or save the card.
|
||||
</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
parseAllianceExpression, ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES,
|
||||
type AllianceExpression,
|
||||
} from '@/game/alliance-expression-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Partner = { id: string; name: string };
|
||||
const newLeaf = (): AllianceExpression => ({ kind: 'partner', partner: '' });
|
||||
export function allianceExpressionInputError(value: unknown, partners: readonly Partner[]): string | null {
|
||||
try {
|
||||
const expression = parseAllianceExpression(value);
|
||||
const visit = (node: AllianceExpression): void => {
|
||||
if (node.kind === 'partner') {
|
||||
if (!partners.some(partner => partner.id === node.partner)) throw new Error('Choose a current named other alliance partner.');
|
||||
} else node.terms.forEach(visit);
|
||||
};
|
||||
visit(expression);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Choose a valid final-alliance expression.';
|
||||
}
|
||||
}
|
||||
|
||||
// Detached placeholders validate only structure, leaving incomplete public
|
||||
// partner choices visible and repairable in the original controlled tree.
|
||||
const structuralDraft = (node: AllianceExpression): AllianceExpression => node.kind === 'partner'
|
||||
? { ...node, partner: 'partner' }
|
||||
: { ...node, terms: node.terms.map(structuralDraft) };
|
||||
const inputSize = (node: AllianceExpression): { leaves: number; depth: number } => {
|
||||
if (node.kind === 'partner') return { leaves: 1, depth: 0 };
|
||||
const sizes = node.terms.map(inputSize);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
};
|
||||
|
||||
/** Public partner choices never predict another player's consent. The server
|
||||
* owns feasibility and final actual membership at the original Nexus close. */
|
||||
export function AllianceExpressionFields({ value, partners, disabled, onChange }: {
|
||||
value: AllianceExpression;
|
||||
partners: readonly Partner[];
|
||||
disabled: boolean;
|
||||
onChange: (expression: AllianceExpression) => void;
|
||||
}) {
|
||||
const size = inputSize(value), error = allianceExpressionInputError(value, partners);
|
||||
const structuralError = (next: AllianceExpression) => {
|
||||
try { parseAllianceExpression(structuralDraft(next)); return false; } catch { return true; }
|
||||
};
|
||||
const commit = (next: AllianceExpression) => {
|
||||
if (!disabled && !structuralError(next)) onChange(next);
|
||||
};
|
||||
const render = (node: AllianceExpression, path: string, replace: (next: AllianceExpression) => AllianceExpression,
|
||||
remove?: () => AllianceExpression): ReactElement => {
|
||||
const group = node.kind !== 'partner', id = `truth-alliance-expression-${path}`;
|
||||
const allowed = (next: AllianceExpression) => !disabled && !structuralError(replace(next));
|
||||
const change = (next: AllianceExpression) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): AllianceExpression => ({ kind, terms: [node, newLeaf()] });
|
||||
return <fieldset key={path} data-alliance-expression-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Alliance group ${path} · ${node.kind.toUpperCase()}` : `Alliance condition ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label htmlFor={`${id}-meaning`}>Alliance group {path} meaning</label>
|
||||
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'and' || kind === 'or') change({ ...node, kind });
|
||||
}}><option value="and">All conditions · AND</option><option value="or">At least one condition · OR</option></select>
|
||||
{node.terms.map((term, index) => render(term, `${path}.${index + 1}`,
|
||||
next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
|
||||
() => {
|
||||
const terms = node.terms.filter((_, i) => i !== index);
|
||||
return replace(terms.length === 1 ? terms[0] : { ...node, terms });
|
||||
}))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-alliance-expression-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add alliance condition</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: AllianceExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
|
||||
return <Button key={kind} type="button" data-alliance-expression-control={`nested-${kind}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</> : <>
|
||||
<label htmlFor={`${id}-partner`}>Named alliance partner</label>
|
||||
<select id={`${id}-partner`} value={partners.some(partner => partner.id === node.partner) ? node.partner : ''}
|
||||
disabled={disabled} aria-invalid={!partners.some(partner => partner.id === node.partner)} onChange={event => {
|
||||
const partner = event.currentTarget.value;
|
||||
if (partners.some(choice => choice.id === partner)) change({ ...node, partner });
|
||||
}}>
|
||||
<option value="">Choose a partner</option>
|
||||
{partners.map(partner => <option key={partner.id} value={partner.id}>{partner.name}</option>)}
|
||||
</select>
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-alliance-expression-control={`${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-alliance-expression-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="alliance-expression"
|
||||
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
|
||||
<legend>Final alliance expression · Current Nexus</legend>
|
||||
<p className="fine">Each condition concerns actual membership when this Nexus closes. Offers alone are not alliances.
|
||||
AND requires every condition; OR requires at least one. No makes the whole expression false, not necessarily every condition.
|
||||
Legal alternatives remain available; the question does not pin a fixed branch.</p>
|
||||
<p className="fine">Yes and No settle only at the actual Nexus close. Forming an alliance does not fulfill the answer early.
|
||||
You cannot promise the other player’s consent. If future consent is needed, unknown may apply; ask again or save the card.</p>
|
||||
<p className="fine">App input limits: {ALLIANCE_EXPRESSION_MAX_LEAVES} conditions and {ALLIANCE_EXPRESSION_MAX_DEPTH} levels of grouping.
|
||||
Groups do not count as conditions. These are input limits, not additional printed rules.</p>
|
||||
<p className="fine">{size.leaves} / {ALLIANCE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {ALLIANCE_EXPRESSION_MAX_DEPTH} levels of grouping</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { allianceExpressionText } from '@/game/alliance-expression-promises';
|
||||
import { AlliancePromiseGuideButton } from './alliance-promises';
|
||||
|
||||
export function AllianceExpressionPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) {
|
||||
const game = props.game, own = game.allianceExpressionPromises.filter(promise => promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named partner';
|
||||
const live = own.some(promise => promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released);
|
||||
const scalarOwnsGuide = game.alliancePromises.some(promise => promise.player === game.me &&
|
||||
promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released);
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance alliance expression promises" id="alliance-expression-promise-guidance">
|
||||
<h3>Your alliance expression promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {allianceExpressionText(promise.claim, name)}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled at the Nexus close.' : ' This whole answer stays binding until this Nexus closes.'}
|
||||
</li>)}</ul>
|
||||
<p>Each condition concerns final actual alliance membership when this Nexus closes.
|
||||
AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
|
||||
Legal alternatives remain available; no fixed branch is selected for you.
|
||||
Yes and No settle only at the actual Nexus close. Forming an alliance does not fulfill the answer early.
|
||||
Offers alone are not alliances.</p>
|
||||
<p className="fine">An owned Yes route needs an existing alliance or an actual reciprocal invitation.
|
||||
Neither answer promises another player’s consent or prevents their original actions.
|
||||
If future consent is needed, unknown may apply; the holder can ask again or save the card.
|
||||
Honor any earlier promises first; their own controls handle required payments.
|
||||
{' '}<a href="/rules#truthtrance-alliance-expression">Alliance expression rules</a>.</p>
|
||||
{live && !scalarOwnsGuide && <AlliancePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises';
|
||||
import { evaluateAllianceExpression, type AllianceExpression } from '@/game/alliance-expression-promises';
|
||||
import { coalitionAllies, coalitionOf } from '@/game/coalition-alliance';
|
||||
import { quoteNexusAlliance } from '@/game/nexus-alliance';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type AlliancePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
const publicContext = (game: GameView) => ({
|
||||
players: game.players.map(player => ({ id: player.id, ally: player.ally, allySinceTurn: player.allySinceTurn })),
|
||||
offers: game.allianceOffers, coalitionState: game.coalitionState,
|
||||
});
|
||||
|
||||
/** Group membership is public. Evaluate only pure expressions here: mixed
|
||||
* questions retain their privately compiled facts on the server. */
|
||||
function expressionMatchesMembers(expression: AllianceExpression, allies: readonly string[]): boolean {
|
||||
if (expression.kind === 'partner') return allies.includes(expression.partner);
|
||||
return expression.kind === 'and' ? expression.terms.every(term => expressionMatchesMembers(term, allies))
|
||||
: expression.terms.some(term => expressionMatchesMembers(term, allies));
|
||||
}
|
||||
|
||||
/** A guide may end a pair, accept real consent or confirm current membership.
|
||||
* It never sends a proposal on another player's behalf. */
|
||||
export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
|
||||
const game = props.game, guide = game.alliancePromiseGuide, nexus = game.alliancePromiseNexus;
|
||||
if (!guide || !nexus) return null;
|
||||
const actor = game.me, turn = nexus.turn, action = guide.action;
|
||||
const event = action.alliancePromiseEvent, signature = JSON.stringify(action);
|
||||
const contextSignature = JSON.stringify(publicContext(game));
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === current.me);
|
||||
const live = current.alliancePromises.filter(promise => promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
const pure = current.allianceExpressionPromises.filter(promise => promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
const mixed = current.mixedAlliancePromises.filter(promise => promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
// Pure membership is visible. The server alone knows a mixed question's
|
||||
// frozen facts and reduced future obligation; never reconstruct it here.
|
||||
const matches = (allies: readonly string[]) => live.every(promise => allies.includes(promise.partner) === promise.answer) &&
|
||||
pure.every(promise => (allies.length <= 1 ? evaluateAllianceExpression(promise.claim, allies[0] ?? null)
|
||||
: expressionMatchesMembers(promise.claim, allies)) === promise.answer);
|
||||
let group: ReturnType<typeof coalitionOf> = null, allies = me?.ally ? [me.ally] : [];
|
||||
try {
|
||||
if (current.coalitionState && me) {
|
||||
group = coalitionOf(current.players, current.coalitionState, actor);
|
||||
allies = coalitionAllies(current.players, current.coalitionState, actor);
|
||||
}
|
||||
} catch { return false; }
|
||||
let ownedAction = action.type === 'ready' && !!me && matches(allies);
|
||||
if (action.type === 'coalitionLeave')
|
||||
ownedAction = !!group && group.members.length >= 3 && !!current.coalitionState &&
|
||||
current.coalitionState.proposal === null && action.expectedRevision === current.coalitionState.revision && matches([]);
|
||||
if (action.type === 'alliance') {
|
||||
if ((group?.members.length ?? 0) >= 3) ownedAction = false;
|
||||
else if (action.target === null) ownedAction = true;
|
||||
else if (typeof action.target === 'string' && action.target !== actor && current.players.some(player => player.id === action.target)) {
|
||||
try {
|
||||
const quote = quoteNexusAlliance({ ...publicContext(current), actor, target: action.target });
|
||||
ownedAction = !!quote.formed && matches(quote.allies[actor] ? [quote.allies[actor]!] : []);
|
||||
} catch { ownedAction = false; }
|
||||
}
|
||||
}
|
||||
return !props.busy && current === game && alliancePromiseModeSupported(current) && current.status === 'playing' &&
|
||||
current.phase === 1 && current.nexus && !current.spiceWindow && !current.ready.includes(actor) &&
|
||||
current.me === actor && current.turn === turn && current.alliancePromiseNexus?.turn === turn &&
|
||||
current.alliancePromiseGuide === guide && current.alliancePromiseGuide.action === action && JSON.stringify(action) === signature &&
|
||||
JSON.stringify(publicContext(current)) === contextSignature && typeof event === 'string' &&
|
||||
[...live, ...pure, ...mixed].some(promise => promise.event === event) && ownedAction && !!me && !me.autopilot &&
|
||||
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
|
||||
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
const me = game.players.find(player => player.id === actor);
|
||||
const partner = game.players.find(player => player.id === action.target)?.name ?? 'the named partner';
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="alliance-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
|
||||
{action.type === 'ready' ? 'Confirm Ready for this Nexus while honoring your answers'
|
||||
: action.type === 'coalitionLeave' ? 'Leave your group alliance while honoring your answers'
|
||||
: action.target === null ? me?.ally ? 'Break your current alliance while honoring your answers'
|
||||
: 'Withdraw your alliance offer while honoring your answers'
|
||||
: `Accept ${partner}’s alliance invitation while honoring your answers`}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
export function AlliancePromises(props: AlliancePromiseProps) {
|
||||
const own = props.game.alliancePromises.filter(promise => promise.player === props.game.me);
|
||||
if (!own.length) return null;
|
||||
const live = own.some(promise => promise.turn === props.game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released);
|
||||
const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named partner';
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance alliance promises" id="alliance-promise-guidance">
|
||||
<h3>Your alliance promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {allianceClaimText(promise, name)}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled at the Nexus close.' : ' This answer stays binding until this Nexus closes.'}
|
||||
</li>)}</ul>
|
||||
<p>
|
||||
The answer concerns your final actual alliance membership at this Nexus close.
|
||||
Yes remains live after forming an alliance and settles only at the close.
|
||||
No requires ending without the named partner; a temporary alliance can still be ended before the close.
|
||||
Offers alone are not alliances.
|
||||
</p>
|
||||
<p className="fine">
|
||||
An owned Yes route needs an existing alliance or an actual reciprocal invitation.
|
||||
Neither answer promises another player’s consent or prevents their original actions.
|
||||
If no owned route exists, No or unknown may apply; the holder can ask again or save the card.
|
||||
Honor any earlier promises first; their own controls handle required payments.
|
||||
{' '}<a href="/rules#truthtrance-alliance">Alliance promise rules</a>.
|
||||
</p>
|
||||
{live && <AlliancePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MOBILE_STRONGHOLD, territory } from '@/game/board';
|
||||
import { faction } from '@/game/catalog';
|
||||
import type { AmbassadorToken } from '@/game/ecaz-ambassadors';
|
||||
|
||||
/** Placed Ambassador faces are public. Unplaced custody never appears on the map. */
|
||||
export function AmbassadorBoardMarkers({
|
||||
tokens,
|
||||
}: {
|
||||
tokens: readonly AmbassadorToken[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{tokens.flatMap((token) => {
|
||||
if (token.zone !== 'placed' || !token.location) return [];
|
||||
const place = territory(token.location);
|
||||
const identity = faction(token.effect);
|
||||
const label = `${identity.name} Ambassador in ${place.name}`;
|
||||
// Separate Ambassadors from the force row, spice and hidden Terror markers.
|
||||
// The HMS has its own wide box, with room beside its native force row.
|
||||
const offset = token.location === MOBILE_STRONGHOLD ? [65, 24] : [-22, -59];
|
||||
return [
|
||||
<g
|
||||
key={token.id}
|
||||
transform={`translate(${place.center[0] + offset[0]},${place.center[1] + offset[1]})`}
|
||||
pointerEvents="none"
|
||||
aria-label={label}
|
||||
>
|
||||
<title>{label}</title>
|
||||
<rect x="10" y="-10" width="37" height="20" rx="5" fill="#182725" stroke={identity.color} />
|
||||
<circle r="15" fill="#182725" stroke={identity.color} strokeWidth="2" />
|
||||
<text y="5" textAnchor="middle" fill={identity.color} fontSize="15" fontWeight="700">
|
||||
{identity.sigil}
|
||||
</text>
|
||||
<text x="30" y="4" textAnchor="middle" fill="#f2dfb9" fontSize="10" fontWeight="700">
|
||||
Amb
|
||||
</text>
|
||||
</g>,
|
||||
];
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { location, splitLocation, territory } from '@/game/board';
|
||||
import { sandmasterDestinationCollection } from '@/game/sandmaster-movement';
|
||||
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from '@/game/advisor-ambassador-matching';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
@@ -29,6 +31,12 @@ export function AmbassadorMovement({
|
||||
const [elites, setElites] = useState<Record<string, number>>({});
|
||||
const [takeMarker, setTakeMarker] = useState(false);
|
||||
const [fighters, setFighters] = useState(false);
|
||||
const [collectSandmaster, setCollectSandmaster] = useState(true);
|
||||
const [sandmasterSelection, setSandmasterSelection] = useState<{
|
||||
source: string;
|
||||
target: string;
|
||||
key: string;
|
||||
} | null>(null);
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'move' ||
|
||||
@@ -78,8 +86,27 @@ export function AmbassadorMovement({
|
||||
!!source.marker &&
|
||||
location(source.territory, source.marker.sector) === target;
|
||||
const includesMarker = !!source.marker && takeMarker && !markerAtTarget;
|
||||
const sandmaster = !includesMarker && destination
|
||||
? sandmasterDestinationCollection(game, game.me, source.territory, destination.territory, destination.sector)
|
||||
: null;
|
||||
const sandmasterPile =
|
||||
sandmasterSelection?.source === source.territory &&
|
||||
sandmasterSelection.target === target &&
|
||||
sandmaster?.piles.some((pile) => pile.key === sandmasterSelection.key)
|
||||
? sandmasterSelection.key
|
||||
: sandmaster?.key;
|
||||
let matchingBlock:string|null=null;
|
||||
if(destination&&total>0){
|
||||
try{
|
||||
quoteAdvisorAmbassadorMatching({...game,pendingAmbassador:{...entry,turn:game.turn,phase:game.phase,
|
||||
beneficiary:game.me},advisorAmbassadorMatchingDenied:entry.matchingDenied},
|
||||
{parent:entry.event,owner:game.me,origin:source.territory,destination:destination.territory,
|
||||
sector:destination.sector,advisors:destination.advisors,group:selectedForces});
|
||||
}catch(error){if(!(error instanceof AdvisorAmbassadorMatchingError))throw error;matchingBlock=error.message;}
|
||||
}
|
||||
const reason =
|
||||
destination?.blocked ??
|
||||
matchingBlock ??
|
||||
(!destination
|
||||
? 'Choose an available destination.'
|
||||
: !total && !includesMarker
|
||||
@@ -93,6 +120,7 @@ export function AmbassadorMovement({
|
||||
setForces((old) => ({ ...old, [key]: 0 }));
|
||||
setElites((old) => ({ ...old, [key]: 0 }));
|
||||
setFighters(false);
|
||||
setSandmasterSelection(null);
|
||||
};
|
||||
const targetTerritories = [...new Set(destinations.map((d) => d.territory))];
|
||||
return (
|
||||
@@ -104,6 +132,9 @@ export function AmbassadorMovement({
|
||||
Relocate a group from one territory. This does not spend your ordinary
|
||||
shipment or movement. The Ambassador has already been triggered.
|
||||
</p>
|
||||
{game.advanced&&game.players.find(player=>player.id===game.me)?.faction==='beneGesserit'&&(
|
||||
<p className="notice">An opposite-type incoming group has a separate matching response before relocation. If matching is prevented, choose another legal packet or destination; the original trigger stays committed.</p>
|
||||
)}
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-source`}>
|
||||
Source territory
|
||||
<select
|
||||
@@ -118,6 +149,7 @@ export function AmbassadorMovement({
|
||||
setElites({});
|
||||
setTakeMarker(false);
|
||||
setFighters(false);
|
||||
setSandmasterSelection(null);
|
||||
}}
|
||||
>
|
||||
{sources.map((s) => (
|
||||
@@ -270,6 +302,42 @@ export function AmbassadorMovement({
|
||||
Request a flip to fighters on arrival
|
||||
</label>
|
||||
)}
|
||||
{sandmaster && (
|
||||
<label className="flex min-h-11 items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={collectSandmaster && !sandmaster.blocked}
|
||||
disabled={busy || !!sandmaster.blocked}
|
||||
onChange={(event) => setCollectSandmaster(event.target.checked)}
|
||||
/>
|
||||
{sandmaster.blocked ?? 'Collect 1 spice here with Sandmaster before arrival reactions'}
|
||||
</label>
|
||||
)}
|
||||
{sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && (
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-sandmaster-pile`}>
|
||||
Sandmaster debit sector — provisional owner allocation
|
||||
<select
|
||||
id={`${id}-sandmaster-pile`}
|
||||
className="min-h-11"
|
||||
disabled={busy || !collectSandmaster}
|
||||
value={sandmasterPile ?? ''}
|
||||
onChange={(event) => setSandmasterSelection({
|
||||
source: source.territory,
|
||||
target,
|
||||
key: event.target.value,
|
||||
})}
|
||||
>
|
||||
{sandmaster.piles.map((pile) => (
|
||||
<option key={pile.key} value={pile.key}>
|
||||
Sector {splitLocation(pile.key).sector}: {pile.before} → {pile.before - 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && (
|
||||
<p className="fine">You choose the one-spice debit sector. This provisional allocation policy is not a publisher ruling.</p>
|
||||
)}
|
||||
<p className="m-0 text-sm" aria-live="polite">
|
||||
Selected: {total} physical forces
|
||||
{includesMarker ? ' and one concealed No-Field' : ''}.
|
||||
@@ -301,6 +369,12 @@ export function AmbassadorMovement({
|
||||
}
|
||||
: {}),
|
||||
...(fighters && destination.canFight ? { fighters: true } : {}),
|
||||
...(sandmaster?.blocked === null
|
||||
? {
|
||||
sandmasterCollect: collectSandmaster,
|
||||
...(collectSandmaster && sandmasterPile ? { sandmasterPile } : {}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type AmbassadorNoFieldOffer = NonNullable<
|
||||
NonNullable<GameView['ambassadorEntry']>['noField']
|
||||
>;
|
||||
|
||||
const titles: Record<AmbassadorNoFieldOffer['stage'], string> = {
|
||||
own: 'Use your No-Field',
|
||||
request: 'Request an allied No-Field',
|
||||
supply: 'Supply an allied No-Field',
|
||||
consent: 'Confirm allied No-Field shipment',
|
||||
};
|
||||
|
||||
const submitLabels: Record<AmbassadorNoFieldOffer['stage'], string> = {
|
||||
own: 'Confirm No-Field choice',
|
||||
request: 'Request selected No-Field shipment',
|
||||
supply: 'Offer selected No-Field shipment',
|
||||
consent: 'Accept selected No-Field offer',
|
||||
};
|
||||
|
||||
/** Receives complete server actions; selecting a marker never edits a physical shipment. */
|
||||
export function AmbassadorNoFieldChoice({
|
||||
offer,
|
||||
event,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
act,
|
||||
busy,
|
||||
id = 'ambassador-no-field-choice',
|
||||
}: {
|
||||
offer: AmbassadorNoFieldOffer;
|
||||
event: string;
|
||||
selectedKey: string;
|
||||
onSelect: (key: string) => void;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
const selected = offer.options.find((option) => option.key === selectedKey);
|
||||
const reason = selected?.blocked ??
|
||||
(selected ? null : 'Choose a No-Field option.');
|
||||
return (
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-3 rounded border p-3"
|
||||
aria-label="Ambassador No-Field shipment"
|
||||
data-event={event}
|
||||
data-stage={offer.stage}
|
||||
>
|
||||
<h4 className="m-0 font-semibold">{titles[offer.stage]}</h4>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Choose a No-Field as the entire Ambassador shipment grant.
|
||||
</p>
|
||||
<p className="m-0 text-sm leading-6">{offer.policy}</p>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-option`}>
|
||||
No-Field choice
|
||||
<select
|
||||
id={`${id}-option`}
|
||||
className="min-h-11"
|
||||
value={selected?.key ?? ''}
|
||||
disabled={busy || !offer.options.length}
|
||||
onChange={(e) => {
|
||||
if (!busy) onSelect(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a No-Field option</option>
|
||||
{offer.options.map((option) => (
|
||||
<option key={option.key} value={option.key} disabled={!!option.blocked}>
|
||||
{option.label}{option.blocked ? ` · ${option.blocked}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{reason && <p id={`${id}-reason`} className="m-0 text-sm">{reason}</p>}
|
||||
<Button
|
||||
data-testid="ambassador-no-field-submit"
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && selected && !selected.blocked) act(selected.action);
|
||||
}}
|
||||
>
|
||||
{submitLabels[offer.stage]}
|
||||
</Button>
|
||||
{offer.decline && (
|
||||
<Button
|
||||
data-testid="ambassador-no-field-decline"
|
||||
className="min-h-11 whitespace-normal"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy && offer.decline) act(offer.decline);
|
||||
}}
|
||||
>
|
||||
{offer.declineLabel ?? 'Decline No-Field offer'}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AmbassadorNoFieldSelection({
|
||||
offer,
|
||||
event,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
offer: AmbassadorNoFieldOffer;
|
||||
event: string;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
return (
|
||||
<AmbassadorNoFieldChoice
|
||||
id={id}
|
||||
offer={offer}
|
||||
event={event}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={setSelectedKey}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmbassadorNoField({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
destination,
|
||||
sector,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
destination?: string;
|
||||
sector?: number;
|
||||
}) {
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'ship' ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!entry.noField
|
||||
) return null;
|
||||
const offer = entry.noField;
|
||||
const filtered = offer.stage === 'own' || offer.stage === 'request'
|
||||
? {
|
||||
...offer,
|
||||
options: offer.options.filter((option) =>
|
||||
!option.territory ||
|
||||
((!destination || option.territory === destination) &&
|
||||
(sector === undefined || option.sector === sector)),
|
||||
),
|
||||
}
|
||||
: offer;
|
||||
return (
|
||||
<AmbassadorNoFieldSelection
|
||||
key={`${entry.event}:${offer.stage}:${destination ?? ''}:${sector ?? ''}`}
|
||||
offer={filtered}
|
||||
event={entry.event}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Action, GameView } from '@/game/engine';
|
||||
import { location, territory } from '@/game/board';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { AmbassadorNoField } from './ambassador-no-field';
|
||||
|
||||
const bounded = (value: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, Number.isSafeInteger(value) ? value : min));
|
||||
@@ -26,24 +27,46 @@ export function AmbassadorShipment({
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'ship' ||
|
||||
entry.beneficiary !== game.me ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!entry.shipment
|
||||
game.decision.player !== game.me
|
||||
)
|
||||
return null;
|
||||
if (entry.noField?.stage === 'supply' || entry.noField?.stage === 'consent')
|
||||
return <AmbassadorNoField game={game} act={act} busy={busy} />;
|
||||
if (entry.beneficiary !== game.me || !entry.shipment) return null;
|
||||
const me = game.players.find((p) => p.id === game.me)!;
|
||||
const shipment = entry.shipment;
|
||||
const destinations = shipment.destinations.map((d) => ({
|
||||
territory: d.territory,
|
||||
sector: d.sector,
|
||||
blocked: d.blocked,
|
||||
}));
|
||||
for (const option of entry.noField?.options ?? []) {
|
||||
if (!option.territory || option.sector === undefined) continue;
|
||||
const existing = destinations.find((d) =>
|
||||
d.territory === option.territory && d.sector === option.sector,
|
||||
);
|
||||
if (existing) {
|
||||
if (!option.blocked) existing.blocked = null;
|
||||
} else destinations.push({
|
||||
territory: option.territory,
|
||||
sector: option.sector,
|
||||
blocked: option.blocked,
|
||||
});
|
||||
}
|
||||
const destination =
|
||||
shipment.destinations.find(
|
||||
destinations.find(
|
||||
(d) => location(d.territory, d.sector) === destinationKey,
|
||||
) ??
|
||||
shipment.destinations.find((d) => !d.blocked) ??
|
||||
shipment.destinations[0];
|
||||
destinations.find((d) => !d.blocked) ??
|
||||
destinations[0];
|
||||
const physicalDestination = shipment.destinations.find((d) =>
|
||||
d.territory === destination?.territory && d.sector === destination?.sector,
|
||||
);
|
||||
const maximum = Math.min(
|
||||
shipment.maximum,
|
||||
me.reserves,
|
||||
destination?.maximum ?? shipment.maximum,
|
||||
physicalDestination?.maximum ?? 0,
|
||||
);
|
||||
const amount = maximum ? bounded(count ?? maximum, 1, maximum) : 0;
|
||||
const minimumElite = Math.max(
|
||||
@@ -54,11 +77,11 @@ export function AmbassadorShipment({
|
||||
const elite = bounded(eliteCount, minimumElite, maximumElite);
|
||||
const reason = !amount
|
||||
? 'You have no reserve forces available for this shipment.'
|
||||
: !destination
|
||||
: !physicalDestination
|
||||
? 'No shipment destination is currently available.'
|
||||
: destination.blocked;
|
||||
: physicalDestination.blocked;
|
||||
const territories = [
|
||||
...new Set(shipment.destinations.map((d) => d.territory)),
|
||||
...new Set(destinations.map((d) => d.territory)),
|
||||
];
|
||||
return (
|
||||
<div
|
||||
@@ -112,9 +135,9 @@ export function AmbassadorShipment({
|
||||
id={`${id}-territory`}
|
||||
className="min-h-11"
|
||||
value={destination?.territory ?? ''}
|
||||
disabled={busy || !shipment.destinations.length}
|
||||
disabled={busy || !destinations.length}
|
||||
onChange={(e) => {
|
||||
const options = shipment.destinations.filter(
|
||||
const options = destinations.filter(
|
||||
(d) => d.territory === e.target.value,
|
||||
);
|
||||
const chosen = options.find((d) => !d.blocked) ?? options[0];
|
||||
@@ -142,7 +165,7 @@ export function AmbassadorShipment({
|
||||
disabled={busy || !destination}
|
||||
onChange={(e) => setDestinationKey(e.target.value)}
|
||||
>
|
||||
{shipment.destinations
|
||||
{destinations
|
||||
.filter((d) => d.territory === destination?.territory)
|
||||
.map((d) => (
|
||||
<option key={d.sector} value={location(d.territory, d.sector)}>
|
||||
@@ -152,16 +175,13 @@ export function AmbassadorShipment({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{destination?.advisors && (
|
||||
{physicalDestination?.advisors && (
|
||||
<p className="m-0 text-sm">These forces join your advisors.</p>
|
||||
)}
|
||||
<p className="m-0 text-sm" aria-live="polite">
|
||||
Selected: {amount} physical forces, including {elite} elites. Cost: 0
|
||||
spice.
|
||||
</p>
|
||||
<p className="m-0 text-sm">
|
||||
No-Field substitution for this effect remains unfinished.
|
||||
</p>
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="m-0 text-sm leading-6">
|
||||
{reason}
|
||||
@@ -195,6 +215,13 @@ export function AmbassadorShipment({
|
||||
>
|
||||
Send no forces
|
||||
</Button>
|
||||
<AmbassadorNoField
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
destination={destination?.territory}
|
||||
sector={destination?.sector}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HelpTip } from '@/components/help-tip';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
export function AtreidesFullPlan({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const offer = game.atreidesFullPlan;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
const battle = game.battle;
|
||||
if (!offer || game.status !== 'playing' || !game.advanced ||
|
||||
game.phase !== 6 || !battle || me?.faction !== 'atreides') return null;
|
||||
const ownOffer = game.decision?.kind === 'fullPlanOffer' && game.decision.player === game.me;
|
||||
const priorityPending = !!game.response || !!game.phaseOpening ||
|
||||
(!!game.decision && !ownOffer) || !!game.truthtrance || !!game.nexusTraitors?.pending;
|
||||
const blocked = offer.blocked || (battle.revealed
|
||||
? 'The Battle Plans have already been revealed.'
|
||||
: priorityPending
|
||||
? 'Finish the current response, decision or preparation before inspecting a full plan.'
|
||||
: null);
|
||||
const disabled = busy || !!blocked;
|
||||
const ownSealed = battle.submitted.includes(game.me);
|
||||
return (
|
||||
<section className="notice space-y-3" aria-label="Atreides full Battle Plan inspection">
|
||||
<h3 className="font-serif text-xl">Inspect a full Battle Plan</h3>
|
||||
<p className="muted">
|
||||
Spend your once-per-game special Karama after battle preparation and
|
||||
before public plan revelation, including when zero or one plan is sealed.
|
||||
If the chosen combatant has not sealed, they commit first; only you
|
||||
receive the inspection. Existing sealed plans cannot change.
|
||||
</p>
|
||||
<p className="fine">
|
||||
{battle.submitted.length} of 2 plans sealed. Leaving this power unused
|
||||
now keeps the opportunity open until public revelation. Battle plan
|
||||
submission continues normally.
|
||||
</p>
|
||||
{ownSealed && (
|
||||
<p className="notice">
|
||||
Your original sealed plan stays fixed. Plans reveal as soon as the
|
||||
other combatant seals; this power does not unlock either plan.
|
||||
</p>
|
||||
)}
|
||||
{!!battle.noFieldPlayers.length && (
|
||||
<p className="notice">
|
||||
Provisional power distinction: special Karama reads the complete
|
||||
committed No-Field plan, including dial and support. Ordinary
|
||||
Prescience still cannot request its dial. This private read does not
|
||||
reveal or materialize the marker before both plans seal.
|
||||
</p>
|
||||
)}
|
||||
<HelpTip topic="fullPlan" />
|
||||
{blocked && <p className="notice" id="atreides-full-plan-unavailable">{blocked}</p>}
|
||||
{!offer.cards.length && (
|
||||
<p className="muted">No spendable Karama is currently available for this special power.</p>
|
||||
)}
|
||||
{!offer.targets.length && (
|
||||
<p className="muted">No combatant is currently available for full-plan inspection.</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{offer.cards.flatMap((card) => offer.targets.map((target) => {
|
||||
const name = game.players.find((player) => player.id === target)?.name ?? 'the selected combatant';
|
||||
return (
|
||||
<Button
|
||||
key={`${card}-${target}`}
|
||||
data-testid={`atreides-full-plan-${card}-${target}`}
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={disabled}
|
||||
aria-describedby={blocked ? 'atreides-full-plan-unavailable' : undefined}
|
||||
onClick={() => {
|
||||
if (!disabled) act({ type: 'card', mode: 'special', card, target });
|
||||
}}
|
||||
>
|
||||
Spend Karama · inspect {name}{battle.submitted.includes(target) ? ' · sealed plan' : ''}
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
);
|
||||
}))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AuctionClockView } from '@/game/auction-clock';
|
||||
|
||||
/** Browser time only animates the latest public server frame. It never submits
|
||||
* an auction action or decides that an authoritative deadline has expired. */
|
||||
export function AuctionCountdown({ clock, playerName, paused = false }: {
|
||||
clock: AuctionClockView | null;
|
||||
playerName: string;
|
||||
paused?: boolean;
|
||||
}) {
|
||||
const [local, setLocal] = useState<{ clock: AuctionClockView; remainingMs: number } | null>(null);
|
||||
const running = !!clock?.event && clock.running && !paused;
|
||||
useEffect(() => {
|
||||
if (!clock || !running || clock.remainingMs <= 0) return;
|
||||
const startedAt = performance.now();
|
||||
const timer = setInterval(() => {
|
||||
const remainingMs = Math.max(0, clock.remainingMs - Math.max(0, performance.now() - startedAt));
|
||||
setLocal({ clock, remainingMs });
|
||||
if (remainingMs === 0) clearInterval(timer);
|
||||
}, 200);
|
||||
return () => clearInterval(timer);
|
||||
}, [clock, running]);
|
||||
|
||||
if (!clock) return null;
|
||||
const remainingMs = running && local?.clock === clock ? local.remainingMs : Math.max(0, clock.remainingMs);
|
||||
const seconds = Math.ceil(remainingMs / 1000);
|
||||
const status = clock.event !== null && seconds === 0
|
||||
? `${paused ? 'Auction paused. ' : ''}Awaiting server resolution.`
|
||||
: clock.event === null
|
||||
? `${paused ? 'Auction paused. ' : ''}Clock starts when ordinary bidding is ready.`
|
||||
: paused ? 'Auction paused.'
|
||||
: !clock.running ? 'Bidding clock held while the current game decision finishes.'
|
||||
: `${playerName} may bid or pass.`;
|
||||
return <section className="notice space-y-2" aria-label="Ordinary auction countdown" data-testid="auction-countdown">
|
||||
<output className="block">{status}</output>
|
||||
<p role="timer" aria-live="off" aria-atomic="true" aria-label={`${playerName}’s remaining bidding time`}
|
||||
className="font-semibold tabular-nums">{seconds} {seconds === 1 ? 'second' : 'seconds'} {clock.event === null ? 'available' : 'remaining'}</p>
|
||||
<p className="fine">Ordinary bidding gives each player 10 seconds to bid or pass. The server controls the deadline and resolves expiry.</p>
|
||||
<p className="fine">Online convention: if passing would violate a binding Truthtrance answer, the server submits only the required legal bid.
|
||||
If that bid wins, its normal payment is still owed.</p>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useReducer,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { Bell, BellOff } from 'lucide-react';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import { advanceActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||
import { initialActionNotices, updateActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type { AutomaticActionEvent } from '@/lib/action-notice-queue';
|
||||
@@ -42,89 +40,28 @@ export function AutomaticActionNotice({
|
||||
readPreference,
|
||||
serverPreference,
|
||||
);
|
||||
// The first snapshot establishes a baseline. Refreshing never replays history.
|
||||
const seenSeq = useRef(
|
||||
events.reduce((last, event) => Math.max(last, event.seq), -1),
|
||||
);
|
||||
const queue = useRef<QueuedActionNotice[]>([]);
|
||||
const active = useRef<QueuedActionNotice | null>(null);
|
||||
const [notice, setNotice] = useState<
|
||||
(QueuedActionNotice & { fading: boolean }) | null
|
||||
>(null);
|
||||
const [completed, setCompleted] = useState(0);
|
||||
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const clearTimers = useCallback(() => {
|
||||
for (const timer of timers.current) clearTimeout(timer);
|
||||
timers.current = [];
|
||||
}, []);
|
||||
|
||||
const showNext = useCallback(() => {
|
||||
if (active.current) return;
|
||||
if (!readPreference() || document.visibilityState === 'hidden') {
|
||||
queue.current = [];
|
||||
return;
|
||||
}
|
||||
const next = queue.current.shift();
|
||||
if (!next) return;
|
||||
active.current = next;
|
||||
clearTimers();
|
||||
timers.current = [
|
||||
setTimeout(() => {
|
||||
setNotice({ ...next, fading: false });
|
||||
}, 0),
|
||||
setTimeout(() => {
|
||||
setNotice((current) =>
|
||||
current?.id === next.id ? { ...current, fading: true } : current,
|
||||
);
|
||||
}, 1250),
|
||||
setTimeout(() => {
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
setCompleted((count) => count + 1);
|
||||
}, 1500),
|
||||
];
|
||||
}, [clearTimers]);
|
||||
const [presentation, updatePresentation] = useReducer(updateActionNotices, events, initialActionNotices);
|
||||
|
||||
useEffect(() => {
|
||||
// Preserve the visible notice. Summarize older pending actions so a bot
|
||||
// batch cannot leave minutes of stale feedback; the chronicle keeps detail.
|
||||
const next = advanceActionNotices({ seenSeq: seenSeq.current, pending: queue.current }, events, {
|
||||
active: !!active.current,
|
||||
show: readPreference() && document.visibilityState !== 'hidden',
|
||||
updatePresentation({
|
||||
type: 'events', events, enabled,
|
||||
visible: document.visibilityState !== 'hidden',
|
||||
});
|
||||
seenSeq.current = next.seenSeq;
|
||||
queue.current = next.pending;
|
||||
showNext();
|
||||
}, [events, showNext, completed]);
|
||||
}, [events, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
updatePresentation({ type: 'hide' });
|
||||
};
|
||||
document.addEventListener('visibilitychange', hide);
|
||||
return () => document.removeEventListener('visibilitychange', hide);
|
||||
}, [clearTimers]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !enabled;
|
||||
memoryPreference = next;
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
updatePresentation({ type: 'clear' });
|
||||
try {
|
||||
localStorage.setItem(PREFERENCE_KEY, String(next));
|
||||
memoryPreference = undefined;
|
||||
@@ -133,9 +70,6 @@ export function AutomaticActionNotice({
|
||||
}
|
||||
window.dispatchEvent(new Event(PREFERENCE_EVENT));
|
||||
};
|
||||
const house = notice
|
||||
? FACTIONS.find((candidate) => candidate.id === notice.faction)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -155,26 +89,30 @@ export function AutomaticActionNotice({
|
||||
aria-atomic="true"
|
||||
className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2"
|
||||
>
|
||||
{enabled && notice && (
|
||||
<span
|
||||
className={`block rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl transition-opacity duration-250 motion-reduce:opacity-100 motion-reduce:transition-none ${notice.fading ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
<span
|
||||
className="block text-xs font-semibold tracking-wider uppercase"
|
||||
style={{ color: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
{house?.name ?? 'Automatic action'}
|
||||
</span>
|
||||
<span className="mt-1 block break-words text-sm font-medium leading-5">
|
||||
{notice.name}
|
||||
</span>
|
||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||
</span>}
|
||||
</span>
|
||||
{enabled && presentation.active && (
|
||||
<ActionNoticeCard notice={presentation.active} onContinue={() => updatePresentation({ type: 'continue' })} />
|
||||
)}
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The notice alone accepts input; it does not trap or move keyboard focus. */
|
||||
export function ActionNoticeCard({ notice, onContinue }: { notice: QueuedActionNotice; onContinue: () => void }) {
|
||||
const house = FACTIONS.find((candidate) => candidate.id === notice.faction);
|
||||
return <span
|
||||
className="pointer-events-auto block max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl"
|
||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
<span className="block text-sm font-semibold leading-5 tracking-wider uppercase" style={{ color: house?.color ?? '#d4b77b' }}>
|
||||
{house?.name ?? 'Automatic action'}
|
||||
</span>
|
||||
<span className="mt-1 block break-words text-base font-medium leading-6">{notice.name}</span>
|
||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||
</span>}
|
||||
<Button type="button" variant="outline" className="mt-3 min-h-11 max-w-full whitespace-normal" style={{ fontSize: '1rem' }} onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { PlanClaim } from '@/game/battle-promises';
|
||||
import {
|
||||
BATTLE_CLAIM_MAX_DEPTH,
|
||||
BATTLE_CLAIM_MAX_NODES,
|
||||
BATTLE_CLAIM_MAX_TERMS,
|
||||
battleClaimExpressionError,
|
||||
battleClaimExpressionSize,
|
||||
} from '@/game/battle-claim-expression';
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const newClaim = (): PlanClaim => ({ kind: 'dial', compare: 'eq', value: 0 });
|
||||
|
||||
/** Controlled structure editor. Invalid leaf input stays available for repair. */
|
||||
export function BattleClaimExpressionFields({ value, game, disabled, onChange }: {
|
||||
value: PlanClaim;
|
||||
game: GameView;
|
||||
disabled: boolean;
|
||||
onChange: (claim: PlanClaim) => void;
|
||||
}) {
|
||||
const size = battleClaimExpressionSize(value);
|
||||
const commit = (next: PlanClaim) => {
|
||||
if (!disabled && !battleClaimExpressionError(next)) onChange(next);
|
||||
};
|
||||
const render = (
|
||||
node: PlanClaim,
|
||||
path: string,
|
||||
replace: (next: PlanClaim) => PlanClaim,
|
||||
remove?: () => PlanClaim,
|
||||
): ReactElement => {
|
||||
const group = node.kind === 'and' || node.kind === 'or';
|
||||
const allowed = (next: PlanClaim) => !disabled && !battleClaimExpressionError(replace(next));
|
||||
const change = (next: PlanClaim) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): PlanClaim => ({ kind, terms: [node, newClaim()] });
|
||||
return <fieldset key={path} data-battle-claim-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Plan condition ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label>
|
||||
Group {path} meaning
|
||||
<select value={node.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.target.value;
|
||||
if (kind === 'and' || kind === 'or') change({ ...node, kind });
|
||||
}}>
|
||||
<option value="and">All conditions · AND</option>
|
||||
<option value="or">At least one condition · OR</option>
|
||||
</select>
|
||||
</label>
|
||||
{node.terms.map((term, index) => render(
|
||||
term,
|
||||
`${path}.${index + 1}`,
|
||||
next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
|
||||
() => {
|
||||
const terms = node.terms.filter((_, i) => i !== index);
|
||||
return replace(terms.length === 1 ? terms[0] : { ...node, terms });
|
||||
},
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-battle-claim-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newClaim()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newClaim()] })}>
|
||||
Add plan condition
|
||||
</Button>
|
||||
<Button type="button" data-battle-claim-control={`nested-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, { kind: 'and', terms: [newClaim(), newClaim()] }] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, { kind: 'and', terms: [newClaim(), newClaim()] }] })}>
|
||||
Add nested group
|
||||
</Button>
|
||||
</div>
|
||||
</> : <BattleClaimFields value={node} game={game} onChange={change} />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-battle-claim-control={`and-${path}`} disabled={!allowed(wrap('and'))}
|
||||
onClick={() => change(wrap('and'))}>Group with AND</Button>
|
||||
<Button type="button" data-battle-claim-control={`or-${path}`} disabled={!allowed(wrap('or'))}
|
||||
onClick={() => change(wrap('or'))}>Group with OR</Button>
|
||||
{remove && <Button type="button" data-battle-claim-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="truth-battle-plan-expression"
|
||||
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
|
||||
<legend>Battle-plan expression</legend>
|
||||
<p className="fine">
|
||||
AND requires all conditions; OR requires at least one. The player answers once for the whole
|
||||
expression. No makes the whole expression false, not necessarily every condition.
|
||||
</p>
|
||||
<p className="fine">
|
||||
A definite answer commits the player to a legal battle plan that keeps the whole answer.
|
||||
Available answers and the sealed plan are checked by the game.
|
||||
</p>
|
||||
<p className="fine">
|
||||
App limits: {BATTLE_CLAIM_MAX_TERMS} terms per group, {BATTLE_CLAIM_MAX_NODES} total clauses
|
||||
including groups, and {BATTLE_CLAIM_MAX_DEPTH} levels of grouping. These are input limits,
|
||||
not additional printed rules.
|
||||
</p>
|
||||
<p className="fine">{size.nodes} / {BATTLE_CLAIM_MAX_NODES} total clauses · {size.depth} / {BATTLE_CLAIM_MAX_DEPTH} levels of grouping</p>
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import type { BattleOutcomeWindow } from '@/game/battle-outcome-window';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function BattleOutcomeWindowControls({
|
||||
window,
|
||||
me,
|
||||
players,
|
||||
busy,
|
||||
onReady,
|
||||
}: {
|
||||
window: BattleOutcomeWindow;
|
||||
me: string;
|
||||
players: readonly { id: string; name: string }[];
|
||||
busy: boolean;
|
||||
onReady: (event: string) => void;
|
||||
}) {
|
||||
const combatants = [window.attacker, window.defender];
|
||||
const participant = combatants.includes(me);
|
||||
const ownReady = window.ready.includes(me);
|
||||
return (
|
||||
<section className="my-4 flex min-w-0 flex-col gap-3" aria-label="Final battle actions" data-event={window.event}>
|
||||
<h3>Final battle actions</h3>
|
||||
<p>
|
||||
Play any optional final battle card you want to use, then finish.
|
||||
Battle resolution waits for both players.
|
||||
</p>
|
||||
<p className="notice">
|
||||
If Juice of Sapho changes the aggressor, both finished marks are
|
||||
cleared and both players get a fresh final opportunity.
|
||||
</p>
|
||||
<ul aria-label="Final battle action status">
|
||||
{combatants.map(player => (
|
||||
<li key={player}>
|
||||
{players.find(seat => seat.id === player)?.name ?? player}:{' '}
|
||||
{window.ready.includes(player) ? 'Finished' : 'Choosing final actions'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{participant && (
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || window.closed || ownReady}
|
||||
onClick={() => {
|
||||
if (!participant || busy || window.closed || ownReady) return;
|
||||
onReady(window.event);
|
||||
}}
|
||||
>
|
||||
Finish final battle actions
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
battleCardLabel,
|
||||
isWeaponCard,
|
||||
isStoneBurner,
|
||||
isMirrorWeapon,
|
||||
isDefenseCard,
|
||||
VOICE_KINDS,
|
||||
voiceChoices,
|
||||
} from '@/game/battle-cards';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,20 +21,23 @@ import {
|
||||
type BattleCardSlot,
|
||||
} from '@/game/battle-card-slots';
|
||||
import { canUsePlanetologistBattleSpecial } from '@/game/leader-skill-combat';
|
||||
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
|
||||
|
||||
/** Both selectors evaluate the complete physical pair, in either selection order. */
|
||||
export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, leader: string, weapon: string, defense: string) {
|
||||
const me = game.players.find(player => player.id === game.me)!;
|
||||
const fixed = battlePlanCommitments(game)[slot];
|
||||
const assignments = game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [];
|
||||
return (me.hand ?? []).filter(card => {
|
||||
const w = slot === 'weapon' ? card : me.hand?.find(c => c.id === weapon);
|
||||
const d = slot === 'defense' ? card : me.hand?.find(c => c.id === defense);
|
||||
const planetologistWeapon = canUsePlanetologistBattleSpecial({
|
||||
assignments: game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [],
|
||||
assignments,
|
||||
selectedLeader: leader, card: w,
|
||||
});
|
||||
return card.id !== 'ecaz-reinforcements' &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked)) &&
|
||||
return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked) || (slot === 'weapon' && planetologistWeapon)) &&
|
||||
(card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked) || (slot === 'weapon' && planetologistWeapon)) &&
|
||||
(!isMirrorWeapon(card) || !mirrorWeaponModeBlock(game)) &&
|
||||
battleCardSlotEligible(slot, card, { planetologistWeapon }) &&
|
||||
(!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) &&
|
||||
validBattleSlotPair(w, d, planetologistWeapon);
|
||||
@@ -130,12 +134,44 @@ export function BattlePreparation({
|
||||
<p className="notice">
|
||||
Waiting for {game.players.find((p) => p.id === preparation.owner)?.name}
|
||||
{preparation.kind === 'voice'
|
||||
|| preparation.kind === 'nexusVoice'
|
||||
? ' to choose or decline the Voice.'
|
||||
: preparation.kind === 'prescience'
|
||||
? ' to choose or decline prescience.'
|
||||
: ' to reveal the requested battle-plan element.'}
|
||||
</p>
|
||||
);
|
||||
if (preparation.kind === 'nexusVoice') {
|
||||
const offer = game.nexusBorrowedVoice;
|
||||
if (!offer) return <p className="notice">Waiting for the borrowed Voice opportunity.</p>;
|
||||
const selected = offer.choices.find(choice => choice.kind === kind && choice.must === must);
|
||||
return <section aria-label="Borrowed Voice" className="space-y-3">
|
||||
<h3>Bene Gesserit Nexus · Secret Ally</h3>
|
||||
<p className="muted">Both combatants continue in attacker order before Prescience or sealed plans. Only an unallied holder may spend the card.</p>
|
||||
{offer.blocked && <p className="notice">{offer.blocked}</p>}
|
||||
{offer.canUse && <>
|
||||
<label htmlFor="borrowed-voice-kind">Card or type
|
||||
<select id="borrowed-voice-kind" value={kind} onChange={e => setKind(e.target.value)}>
|
||||
{voiceChoices(game.advanced).filter(k => offer.choices.some(choice => choice.kind === k))
|
||||
.map(k => <option key={k} value={k}>{battleCardLabel(k)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="decision-checkbox">
|
||||
<input type="checkbox" checked={must} onChange={e => setMust(e.target.checked)} />
|
||||
Require this card or type (unchecked: forbid)
|
||||
</label>
|
||||
<p className="notice">Provisional policy: Karama may cancel the Voice. The Nexus card stays spent.</p>
|
||||
<Button className="game-action" disabled={busy || !!offer.blocked || !selected}
|
||||
onClick={() => act({ type: 'nexusBorrowedVoice', event: offer.event, use: true, kind, must })}>
|
||||
Spend Nexus · Use the Voice
|
||||
</Button>
|
||||
</>}
|
||||
<Button className="game-action" disabled={busy || !!offer.blocked}
|
||||
onClick={() => act({ type: 'nexusBorrowedVoice', event: offer.event, use: false })}>
|
||||
Continue without borrowed Voice
|
||||
</Button>
|
||||
</section>;
|
||||
}
|
||||
if (preparation.kind === 'voice')
|
||||
return (
|
||||
<>
|
||||
@@ -147,14 +183,15 @@ export function BattlePreparation({
|
||||
before Atreides chooses prescience and the combatants seal their
|
||||
plans.
|
||||
</p>
|
||||
{preparation.blocked && <p className="notice">{preparation.blocked}</p>}
|
||||
<label htmlFor="voice-kind">
|
||||
Card type
|
||||
Card or type
|
||||
<select
|
||||
id="voice-kind"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
{VOICE_KINDS.map((k) => (
|
||||
{voiceChoices(game.advanced).filter((name) => name !== 'mirrorWeapon' || !mirrorWeaponModeBlock(game)).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{battleCardLabel(k)}
|
||||
</option>
|
||||
@@ -167,11 +204,11 @@ export function BattlePreparation({
|
||||
checked={must}
|
||||
onChange={(e) => setMust(e.target.checked)}
|
||||
/>
|
||||
Require this type (unchecked: forbid)
|
||||
Require this card or type (unchecked: forbid)
|
||||
</label>
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy}
|
||||
disabled={busy || !!preparation.blocked}
|
||||
onClick={() => act({ type: 'voice', kind, must })}
|
||||
>
|
||||
Use the Voice
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useId } from 'react';
|
||||
import { HelpTip } from './help-tip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||
import { planClaimText, type PlanClaim } from '@/game/battle-promises';
|
||||
import { reinforcementsSourceLabel } from './reinforcements-sources';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import type { Action, GameView, Plan } from '@/game/engine';
|
||||
|
||||
@@ -68,7 +69,7 @@ export function BattleClaimFields({
|
||||
min={0}
|
||||
max={40}
|
||||
step={c.kind === 'dial' ? 0.5 : 1}
|
||||
value={c.value}
|
||||
value={Number.isNaN(c.value) ? '' : c.value}
|
||||
onChange={(e) =>
|
||||
onChange({ ...c, value: Number(e.target.value) })
|
||||
}
|
||||
@@ -122,19 +123,18 @@ export function BattleClaimFields({
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function BattlePromises({
|
||||
game: g,
|
||||
fill,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
export function BattlePromises(props: {
|
||||
game: GameView;
|
||||
fill: (plan: Plan) => void;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { game: g, fill, act } = props;
|
||||
const b = g.battle;
|
||||
if (!b || (!b.truthPromises.length && !b.compliantPreparation)) return null;
|
||||
if (!b) return null;
|
||||
const mixedRows = (g.mixedBattlePromises ?? []).filter(promise => promise.player === g.me && promise.turn === g.turn &&
|
||||
promise.battle === b.event && promise.territory === b.territory && !promise.fulfilled && !promise.released);
|
||||
if (!b.truthPromises.length && !b.compliantPreparation && (!b.compliantPlan || !mixedRows.length)) return null;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const name = (id: string | null) =>
|
||||
id === null
|
||||
@@ -143,6 +143,39 @@ export function BattlePromises({
|
||||
g.allLeaders.find((l) => l.id === id)?.name ??
|
||||
id);
|
||||
const plan = b.compliantPlan;
|
||||
const preparation = b.compliantPreparation;
|
||||
const nextPreparation = preparation?.actions[0];
|
||||
const actor = g.me, turn = g.turn, event = b.event, territory = b.territory;
|
||||
const attacker = b.attacker, defender = b.defender;
|
||||
const mixed = mixedRows
|
||||
.map(promise => ({ promise, event: promise.event, asker: promise.asker, answer: promise.answer,
|
||||
mixed: promise.mixed, signature: JSON.stringify(promise.mixed) }));
|
||||
const mixedAssistance = mixed.length > 0;
|
||||
const planSignature = JSON.stringify(plan), preparationSignature = JSON.stringify(preparation);
|
||||
// Native completion owns feasibility. For mixed assistance, a saved callback
|
||||
// also has to belong to this exact live question and current private guide.
|
||||
const canUseMixedAssistance = () => !mixedAssistance || (
|
||||
!props.busy && props.game === g && g.status === 'playing' && g.phase === 6 && g.me === actor && g.turn === turn &&
|
||||
g.battle === b && typeof event === 'string' && b.event === event && b.territory === territory &&
|
||||
b.attacker === attacker && b.defender === defender && [attacker, defender].includes(actor) &&
|
||||
!b.revealed && !b.submitted.includes(actor) && mixed.length > 0 &&
|
||||
mixed.every(context => g.mixedBattlePromises.includes(context.promise) && context.promise.event === context.event &&
|
||||
context.promise.asker === context.asker && context.promise.answer === context.answer &&
|
||||
context.promise.mixed === context.mixed && JSON.stringify(context.promise.mixed) === context.signature &&
|
||||
context.promise.player === actor &&
|
||||
context.promise.turn === turn && context.promise.battle === event && context.promise.territory === territory &&
|
||||
!context.promise.fulfilled && !context.promise.released) &&
|
||||
g.mixedBattlePromises.filter(promise => promise.player === actor && promise.turn === turn &&
|
||||
promise.battle === event && promise.territory === territory && !promise.fulfilled && !promise.released).length === mixed.length &&
|
||||
!g.truthtrance && !g.response && !g.decision && !g.phaseOpening && !g.automaticContinuationPending &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !!me &&
|
||||
g.players.find(player => player.id === actor) === me && !me.autopilot
|
||||
);
|
||||
const canPrepare = () => !props.busy && !g.truthtrance && !g.response && !g.decision && !g.phaseOpening &&
|
||||
canUseMixedAssistance() && (!mixedAssistance || b.compliantPreparation === preparation &&
|
||||
b.compliantPreparation?.actions[0] === nextPreparation && JSON.stringify(preparation) === preparationSignature && props.act === act);
|
||||
const canFill = () => !props.busy && canUseMixedAssistance() && (!mixedAssistance ||
|
||||
b.compliantPlan === plan && !b.compliantPreparation && JSON.stringify(plan) === planSignature && props.fill === fill);
|
||||
return (
|
||||
<section
|
||||
className="notice truthtrance-history"
|
||||
@@ -182,7 +215,7 @@ export function BattlePromises({
|
||||
{action.type === 'nexusSuboids'
|
||||
? 'Use Ixian Cunning to give Suboids full strength without spice support'
|
||||
: action.type === 'nexusSardaukar'
|
||||
? 'Declare Emperor Cunning to count five ordinary forces as Sardaukar for this battle'
|
||||
? `Declare Emperor Cunning to count ${g.nexusSardaukar?.shortCount ?? 'five'} ordinary forces as Sardaukar for this battle`
|
||||
: action.mode === 'special'
|
||||
? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice`
|
||||
: typeof action.leader === 'string'
|
||||
@@ -197,24 +230,18 @@ export function BattlePromises({
|
||||
: 'The revival is complete. Resolve its income response before reviewing and sealing the plan.'}{' '}
|
||||
Other legal ways to keep your answer remain available.
|
||||
</p>
|
||||
{b.compliantPreparation.actions[0] && (
|
||||
{b.compliantPreparation.actions[0] && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy ||
|
||||
!!g.truthtrance ||
|
||||
!!g.response ||
|
||||
!!g.decision ||
|
||||
!!g.phaseOpening
|
||||
}
|
||||
onClick={() => act(b.compliantPreparation!.actions[0])}
|
||||
disabled={!canPrepare()}
|
||||
onClick={() => { if (canPrepare() && nextPreparation) act(nextPreparation); }}
|
||||
>
|
||||
Complete next preparation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{plan && (
|
||||
{plan && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && (
|
||||
<details>
|
||||
<summary>Review a compliant plan · private</summary>
|
||||
<p>
|
||||
@@ -229,11 +256,17 @@ export function BattlePromises({
|
||||
<br />
|
||||
Kwisatz: {plan.kwisatz ? 'Included' : 'Not included'}
|
||||
</p>
|
||||
{plan.reinforcementsSources && (
|
||||
<p className="fine">Native reserve fee: {reinforcementsSourceLabel(g, plan.reinforcementsSources)}</p>
|
||||
)}
|
||||
{plan.planetologistSpecial && (
|
||||
<p className="fine">Planetologist alternate Special: +2 if the leader survives; no normal card effect.</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
These choices satisfy your current commitments. Review and change
|
||||
the other elements before sealing your plan.
|
||||
</p>
|
||||
<Button variant="outline" disabled={busy} onClick={() => fill(plan)}>
|
||||
<Button variant="outline" disabled={!canFill()} onClick={() => { if (canFill()) fill(plan); }}>
|
||||
Fill these plan choices
|
||||
</Button>
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { parseBidClaim, type BidClaim } from '@/game/bid-promises';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export function bidClaimInputError(value: unknown): string | null {
|
||||
try { parseBidClaim(value); return null; }
|
||||
catch (error) { return error instanceof Error ? error.message : 'Choose a positive whole bid minimum.'; }
|
||||
}
|
||||
|
||||
/** The parent supplies the public current lot; only the minimum is editable. */
|
||||
export function BidClaimFields(props: {
|
||||
value: BidClaim;
|
||||
onChange: (claim: BidClaim) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const error = bidClaimInputError(props.value);
|
||||
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Ordinary lot bid condition">
|
||||
<legend>Current ordinary-lot bid</legend>
|
||||
<p>Turn {props.value.turn} · Ordinary lot {props.value.lot}</p>
|
||||
<label htmlFor="truth-bid-minimum">Minimum bid in spice</label>
|
||||
<Input id="truth-bid-minimum" type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required
|
||||
disabled={props.disabled} value={Number.isFinite(props.value.minimum) ? props.value.minimum : ''}
|
||||
aria-invalid={!!error} aria-describedby="truth-bid-help"
|
||||
onChange={event => {
|
||||
if (props.disabled) return;
|
||||
const next = { ...props.value, minimum: event.currentTarget.valueAsNumber };
|
||||
if (bidClaimInputError(next)) return;
|
||||
props.onChange(parseBidClaim(next));
|
||||
}} />
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<p className="fine" id="truth-bid-help">
|
||||
This asks whether you will place at least one bid of this size from now until this lot closes.
|
||||
It does not promise a win. If you win, the original payment rules still apply. Ordinary bank auctions only;
|
||||
optional modules and native special auctions are excluded.
|
||||
</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bidClaimText, bidPromiseModeSupported } from '@/game/bid-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type BidPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
|
||||
/** The server's current guide owns bid feasibility and funding. This leaf only
|
||||
* checks that its personalized event and public action opportunity stay live. */
|
||||
export function BidPromiseGuideButton(props: BidPromiseProps) {
|
||||
const game = props.game, guide = game.bidPromiseGuide, lot = game.bidPromiseLot;
|
||||
if (!guide || !lot) return null;
|
||||
const actor = game.me, turn = lot.turn, number = lot.lot;
|
||||
const action = guide.action, event = action.bidPromiseEvent, signature = JSON.stringify(action);
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === current.me);
|
||||
return !props.busy && current === game && bidPromiseModeSupported(current) && current.status === 'playing' && current.phase === 3 &&
|
||||
current.me === actor && current.auction?.active === actor &&
|
||||
current.turn === turn && current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === number &&
|
||||
current.bidPromiseGuide === guide && current.bidPromiseGuide.action === action && JSON.stringify(action) === signature &&
|
||||
typeof event === 'string' && (action.type === 'bid' || action.type === 'passBid') &&
|
||||
(current.bidPromises.some(promise => promise.event === event && promise.player === actor &&
|
||||
promise.turn === turn && promise.lot === number && !promise.fulfilled && !promise.released) ||
|
||||
current.mixedBidPromises.some(promise => promise.event === event && promise.player === actor &&
|
||||
promise.turn === turn && promise.lot === number && !promise.fulfilled && !promise.released)) &&
|
||||
!!me && !me.autopilot && !current.truthtrance && !current.response && !current.decision &&
|
||||
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="bid-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
|
||||
{action.type === 'bid'
|
||||
? `Bid ${typeof action.amount === 'number' ? action.amount : 'the suggested amount'} spice while honoring your answers`
|
||||
: 'Pass this bid while honoring your answers'}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
export function BidPromises(props: BidPromiseProps) {
|
||||
const own = props.game.bidPromises.filter(promise => promise.player === props.game.me);
|
||||
if (!own.length) return null;
|
||||
const waiting = own.some(promise => !promise.fulfilled && !promise.released &&
|
||||
props.game.bidPromiseLot?.turn === props.game.turn && promise.turn === props.game.turn && promise.lot === props.game.bidPromiseLot.lot);
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance bid promises" id="bid-promise-guidance">
|
||||
<h3>Your bid promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}, ordinary lot {promise.lot}: You answered {promise.answer ? 'Yes' : 'No'} to {bidClaimText(promise)}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for this lot.'}
|
||||
</li>)}</ul>
|
||||
<p>
|
||||
Yes requires one qualifying bid from your answer until this lot closes, while it remains possible.
|
||||
No forbids every later bid at or above that minimum through the close of this lot.
|
||||
A qualifying declaration fulfills this answer even if another player wins.
|
||||
If you win, the original payment rules still apply.
|
||||
</p>
|
||||
<p className="fine">
|
||||
<a href="/rules#truthtrance-bid">Bid promise rules</a>.{' '}
|
||||
These commitments concern the current ordinary bank lot. Optional modules and native special auctions are excluded.
|
||||
Ordinary bids keep their existing funding and payment rules.
|
||||
</p>
|
||||
{waiting && props.game.auction?.active !== props.game.me && <p>Your next bid opportunity is still ahead; this promise remains visible while others bid.</p>}
|
||||
{waiting && <BidPromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { parseBribeClaim, type BribeClaim } from '@/game/bribe-promises';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type Recipient = { id: string; name: string };
|
||||
export function bribeClaimInputError(value: unknown, recipients?: readonly Recipient[]): string | null {
|
||||
try {
|
||||
const claim = parseBribeClaim(value);
|
||||
return recipients && !recipients.some(recipient => recipient.id === claim.recipient)
|
||||
? 'Choose one of the current bribe recipients.' : null;
|
||||
} catch (error) { return error instanceof Error ? error.message : 'Choose a recipient and a positive safe whole minimum.'; }
|
||||
}
|
||||
|
||||
/** The parent supplies this turn and its current public recipient choices. */
|
||||
export function BribeClaimFields(props: {
|
||||
value: BribeClaim;
|
||||
onChange: (claim: BribeClaim) => void;
|
||||
recipients: readonly Recipient[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const error = bribeClaimInputError(props.value, props.recipients);
|
||||
const change = (value: unknown) => {
|
||||
if (!props.disabled && !bribeClaimInputError(value, props.recipients)) props.onChange(parseBribeClaim(value));
|
||||
};
|
||||
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Future ordinary bribe condition">
|
||||
<legend>Future ordinary bribe payments</legend>
|
||||
<p>Turn {props.value.turn}</p>
|
||||
<label htmlFor="truth-bribe-recipient">Bribe recipient</label>
|
||||
<select id="truth-bribe-recipient" disabled={props.disabled}
|
||||
value={props.recipients.some(recipient => recipient.id === props.value.recipient) ? props.value.recipient : ''}
|
||||
onChange={event => change({ ...props.value, recipient: event.currentTarget.value })}>
|
||||
<option value="">Choose a recipient</option>
|
||||
{props.recipients.map(recipient => <option key={recipient.id} value={recipient.id}>{recipient.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor="truth-bribe-minimum">Minimum future ordinary bribe payments in spice</label>
|
||||
<Input id="truth-bribe-minimum" type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required disabled={props.disabled}
|
||||
value={Number.isFinite(props.value.minimum) ? props.value.minimum : ''} aria-invalid={!!error}
|
||||
aria-describedby="truth-bribe-help"
|
||||
onChange={event => change({ ...props.value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<p className="fine" id="truth-bribe-help">
|
||||
Count cumulative future ordinary bribe payments to this recipient after the answer; smaller payments add together.
|
||||
Earlier payments, gifts and ally aid do not count. The sender pays now; the recipient collects the payment at the start of Mentat Pause.
|
||||
Payments made during Mentat Pause wait until the start of next turn's Mentat Pause.
|
||||
</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
parseBribeExpression, BRIBE_EXPRESSION_MAX_DEPTH, BRIBE_EXPRESSION_MAX_LEAVES,
|
||||
type BribeExpression,
|
||||
} from '@/game/bribe-expression-promises';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type Recipient = { id: string; name: string };
|
||||
// Every new condition requires the author to choose its public recipient.
|
||||
const newLeaf = (): BribeExpression => ({ kind: 'payment', recipient: '', minimum: 1 });
|
||||
export function bribeExpressionInputError(value: unknown): string | null {
|
||||
try { parseBribeExpression(value); return null; }
|
||||
catch (error) { return error instanceof Error ? error.message : 'Choose a valid ordinary bribe expression.'; }
|
||||
}
|
||||
|
||||
// Detached placeholders validate only the structure. They allow empty recipient
|
||||
// choices and invalid numeric drafts to remain visible while the author repairs
|
||||
// them; the real tree is always passed intact to the parent.
|
||||
const structuralDraft = (node: BribeExpression): BribeExpression => node.kind === 'payment'
|
||||
? { ...node, recipient: 'recipient', minimum: 1 }
|
||||
: { ...node, terms: node.terms.map(structuralDraft) };
|
||||
const inputSize = (node: BribeExpression): { leaves: number; depth: number } => {
|
||||
if (node.kind !== 'and' && node.kind !== 'or') return { leaves: 1, depth: 0 };
|
||||
const sizes = node.terms.map(inputSize);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
};
|
||||
|
||||
/** Hook-free controlled editor. The server owns recipient eligibility, funding
|
||||
* and compliance with the whole answer. */
|
||||
export function BribeExpressionFields({ value, recipients, disabled, onChange }: {
|
||||
value: BribeExpression;
|
||||
recipients: readonly Recipient[];
|
||||
disabled: boolean;
|
||||
onChange: (expression: BribeExpression) => void;
|
||||
}) {
|
||||
const size = inputSize(value), error = bribeExpressionInputError(value);
|
||||
const commit = (next: BribeExpression) => {
|
||||
if (!disabled && !bribeExpressionInputError(structuralDraft(next))) onChange(next);
|
||||
};
|
||||
const render = (node: BribeExpression, path: string, replace: (next: BribeExpression) => BribeExpression,
|
||||
remove?: () => BribeExpression): ReactElement => {
|
||||
const group = node.kind !== 'payment';
|
||||
const id = `truth-bribe-expression-${path}`;
|
||||
const allowed = (next: BribeExpression) => !disabled && !bribeExpressionInputError(structuralDraft(replace(next)));
|
||||
const change = (next: BribeExpression) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): BribeExpression => ({ kind, terms: [node, newLeaf()] });
|
||||
return <fieldset key={path} data-bribe-expression-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Bribe group ${path} · ${node.kind.toUpperCase()}` : `Bribe condition ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label htmlFor={`${id}-meaning`}>Bribe group {path} meaning</label>
|
||||
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'and' || kind === 'or') change({ ...node, kind });
|
||||
}}>
|
||||
<option value="and">All conditions · AND</option>
|
||||
<option value="or">At least one condition · OR</option>
|
||||
</select>
|
||||
{node.terms.map((term, index) => render(term, `${path}.${index + 1}`,
|
||||
next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
|
||||
() => {
|
||||
const terms = node.terms.filter((_, i) => i !== index);
|
||||
return replace(terms.length === 1 ? terms[0] : { ...node, terms });
|
||||
}))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-bribe-expression-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add bribe condition</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: BribeExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
|
||||
return <Button key={kind} type="button" data-bribe-expression-control={`nested-${kind}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</> : <>
|
||||
<label htmlFor={`${id}-recipient`}>Bribe recipient</label>
|
||||
<select id={`${id}-recipient`} value={recipients.some(recipient => recipient.id === node.recipient) ? node.recipient : ''}
|
||||
disabled={disabled} aria-invalid={!recipients.some(recipient => recipient.id === node.recipient)} onChange={event => {
|
||||
const recipient = event.currentTarget.value;
|
||||
if (recipients.some(choice => choice.id === recipient)) change({ ...node, recipient });
|
||||
}}>
|
||||
<option value="">Choose a recipient</option>
|
||||
{recipients.map(recipient => <option key={recipient.id} value={recipient.id}>{recipient.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-minimum`}>Minimum future ordinary bribe payments in spice</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required disabled={disabled}
|
||||
value={Number.isFinite(node.minimum) ? node.minimum : ''}
|
||||
aria-invalid={!!bribeExpressionInputError(node)} onChange={event => change({ ...node, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-bribe-expression-control={`${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-bribe-expression-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="bribe-expression"
|
||||
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
|
||||
<legend>Ordinary bribe expression</legend>
|
||||
<p className="fine">
|
||||
AND requires every condition; OR requires at least one. No makes the whole expression false,
|
||||
not necessarily every condition. Legal alternatives remain available; the question does not pin a fixed branch.
|
||||
Count cumulative future ordinary bribe payments after the answer this turn; smaller payments add together.
|
||||
Earlier payments, gifts and ally aid do not count.
|
||||
</p>
|
||||
<p className="fine">The sender pays immediately; the recipient collects at the next Mentat opening.
|
||||
Payments made during Mentat Pause wait until the start of next turn's Mentat Pause.</p>
|
||||
<p className="fine">App input limits: {BRIBE_EXPRESSION_MAX_LEAVES} conditions and {BRIBE_EXPRESSION_MAX_DEPTH} levels of grouping.
|
||||
Groups do not count as conditions. These are input limits, not additional printed rules.</p>
|
||||
<p className="fine">{size.leaves} / {BRIBE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {BRIBE_EXPRESSION_MAX_DEPTH} levels of grouping</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeExpressionRecipients, bribeExpressionText, liveBribeExpressionPromises } from '@/game/bribe-expression-promises';
|
||||
import { liveBribePromises } from '@/game/bribe-promises';
|
||||
import { BribePromiseGuideButton } from './bribe-promises';
|
||||
|
||||
type BribeExpressionPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
|
||||
export function BribeExpressionPromises(props: BribeExpressionPromiseProps) {
|
||||
const game = props.game, own = game.bribeExpressionPromises.filter(promise => promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named recipient';
|
||||
const live = liveBribeExpressionPromises(own, game.me, game.turn).length > 0;
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance bribe expression promises" id="bribe-expression-promise-guidance">
|
||||
<h3>Your bribe expression promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {bribeExpressionText(promise.claim, name)}.
|
||||
{' '}Recorded future ordinary payments: {bribeExpressionRecipients(promise.claim).map(recipient =>
|
||||
`${name(recipient)}: ${promise.progress.find(progress => progress.recipient === recipient)?.amount ?? 0} spice`).join('; ')}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This whole answer is binding for this turn.'}
|
||||
</li>)}</ul>
|
||||
<p>
|
||||
AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
|
||||
Legal alternatives remain available; no fixed branch is selected for you.
|
||||
Smaller payments to the same recipient add together after the answer. Prior payments, gifts and ally aid do not count.
|
||||
Preserve the spendable spice needed for your Yes answers.
|
||||
</p>
|
||||
<p className="fine">
|
||||
The sender pays immediately. The recipient collects the payment at the next Mentat opening.
|
||||
Payments made during Mentat Pause wait until the start of next turn's Mentat Pause.
|
||||
Collection does not count the payment a second time.
|
||||
Ordinary bribes can be paid out of turn when the current bribe controls permit them; original timing and payment rules still apply.
|
||||
{' '}<a href="/rules#truthtrance-bribe-expression">Bribe expression rules</a>.
|
||||
</p>
|
||||
{live && <p>{game.phase === 8
|
||||
? 'Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening.'
|
||||
: 'Your next Mentat opening is still ahead. Accepted payments count now and their recipients collect at that opening.'}
|
||||
{' '}No stays binding until the actual turn ends; opening collection does not end it.</p>}
|
||||
{live && !liveBribePromises(game.bribePromises, game.me, game.turn).length && <BribePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { areCoalitionAllies } from '@/game/coalition-alliance';
|
||||
import { bribeAction } from '@/game/bribe-options';
|
||||
import { bribeClaimText, bribePromiseModeSupported, liveBribePromises } from '@/game/bribe-promises';
|
||||
import { liveBribeExpressionPromises } from '@/game/bribe-expression-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type BribePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
|
||||
/** Native timing/funding remains in the current projected bribe options. An
|
||||
* ordinary bribe may be made outside this sender's phase action turn. */
|
||||
export function BribePromiseGuideButton(props: BribePromiseProps) {
|
||||
const game = props.game, guide = game.bribePromiseGuide;
|
||||
if (!guide) return null;
|
||||
const actor = game.me, turn = game.turn, phase = game.phase, action = guide.action;
|
||||
const event = action.bribePromiseEvent, signature = JSON.stringify(action);
|
||||
const send = props.send, charity = game.charity;
|
||||
const charityOwner = action.type === 'charity' ? game.players.find(player => player.id === actor) : undefined;
|
||||
const charitySource = (current: GameView) => {
|
||||
const owner = current.players.find(player => player.id === actor);
|
||||
return JSON.stringify({ quote: current.charity, claimed: owner?.charityClaimed, spice: owner?.spice });
|
||||
};
|
||||
const charitySignature = action.type === 'charity' ? charitySource(game) : null;
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === current.me), options = current.bribeOptions;
|
||||
const target = typeof action.target === 'string' ? current.players.find(player => player.id === action.target) : undefined;
|
||||
const limit = options.targets.find(recipient => recipient.id === action.target);
|
||||
const payment = action.type === 'bribe' && !!me && !!target && target.id !== actor && target.id !== me.ally &&
|
||||
(!current.coalitionState || !areCoalitionAllies(current.players, current.coalitionState, actor, target.id)) &&
|
||||
options.blocked === null && Number.isSafeInteger(options.available) &&
|
||||
typeof action.amount === 'number' && action.amount <= options.available &&
|
||||
!!limit && Number.isSafeInteger(limit.maximum) && action.amount <= limit.maximum &&
|
||||
!!bribeAction(options, target.id, action.amount);
|
||||
const claim = action.type === 'charity' && phase === 2 && !!me && me === charityOwner &&
|
||||
Object.keys(action).every(key => ['type', 'bribePromiseEvent'].includes(key)) &&
|
||||
!current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity &&
|
||||
charitySource(current) === charitySignature && current.charity.incomePending === false &&
|
||||
Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0;
|
||||
return !props.busy && props.send === send && current === game && bribePromiseModeSupported(current) && current.status === 'playing' &&
|
||||
Number.isSafeInteger(current.phase) && current.phase >= 0 && current.phase <= 8 && current.phase === phase &&
|
||||
current.turn === turn && current.me === actor &&
|
||||
current.bribePromiseGuide === guide && current.bribePromiseGuide.action === action && JSON.stringify(action) === signature &&
|
||||
typeof event === 'string' &&
|
||||
(liveBribePromises(current.bribePromises, actor, turn).some(promise => promise.event === event) ||
|
||||
liveBribeExpressionPromises(current.bribeExpressionPromises, actor, turn).some(promise => promise.event === event) ||
|
||||
current.mixedBribePromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released)) &&
|
||||
!!me && !me.autopilot && (payment || claim) &&
|
||||
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
|
||||
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="bribe-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) send(action); }}>
|
||||
{action.type === 'charity' ? `Claim ${game.charity.amount} Charity spice to continue your payments`
|
||||
: `Pay ${typeof action.amount === 'number' ? action.amount : 'the quoted'} spice to ${recipient} while honoring your answers`}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
export function BribePromises(props: BribePromiseProps) {
|
||||
const own = props.game.bribePromises.filter(promise => promise.player === props.game.me);
|
||||
if (!own.length) return null;
|
||||
const live = liveBribePromises(own, props.game.me, props.game.turn).length > 0;
|
||||
const pendingChoamIncome = live && props.game.status === 'playing' &&
|
||||
props.game.players.find(player => player.id === props.game.me)?.faction === 'choam' &&
|
||||
Number.isSafeInteger(props.game.phase) && props.game.phase >= 0 && props.game.phase <= 2 &&
|
||||
props.game.charity.incomePending && !props.game.charity.incomeCanceled && props.game.charity.multiplier > 0;
|
||||
const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named recipient';
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance bribe promises" id="bribe-promise-guidance">
|
||||
<h3>Your bribe promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {bribeClaimText(promise, name)}.
|
||||
{' '}Recorded future ordinary payments to {name(promise.recipient)}: {promise.progress} spice.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for this turn.'}
|
||||
</li>)}</ul>
|
||||
<p>
|
||||
Yes requires reaching the cumulative minimum while it remains possible; preserve the needed spendable spice.
|
||||
No forbids reaching that minimum during the rest of this turn. Smaller payments to the named recipient add together.
|
||||
Prior payments, gifts and ally aid do not count.
|
||||
</p>
|
||||
{pendingChoamIncome && <p className="fine" data-testid="choam-pending-payment-income">
|
||||
Automatic CHOAM income resolves at the Charity opening. It is not current spendable cash.
|
||||
Your ordinary Charity claim is separate. Karama or opposing claims can change the funding.
|
||||
Current payment guides may still offer legal prepayments.</p>}
|
||||
<p className="fine">
|
||||
The sender pays immediately. The recipient collects the payment at the start of Mentat Pause.
|
||||
Payments made during Mentat Pause wait until the start of next turn's Mentat Pause.
|
||||
Ordinary bribes can be paid out of turn when the current bribe controls permit them; original timing and payment rules still apply.
|
||||
A legal timeline may use payments now, one remaining Charity claim and later installments.
|
||||
The future claim is not current spendable spice; only actual payments add to the recorded total.
|
||||
{' '}<a href="/rules#truthtrance-bribe">Bribe promise rules</a>.
|
||||
</p>
|
||||
{live && <BribePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
import { useId, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeAction } from '@/game/bribe-options';
|
||||
import { faction } from '@/game/catalog';
|
||||
@@ -20,7 +19,7 @@ export function Bribes({ game: g, act, busy }: {
|
||||
return (
|
||||
<details className="notice bribe-panel">
|
||||
<summary>Bribes{options.incoming > 0 ? ` · ${options.incoming} spice awaiting Mentat` : ''}</summary>
|
||||
<p id={description}>Pay another non-allied faction now. The amount is public; the recipient can spend their share only from the next Mentat Pause. A message or promise alone does not pay spice.</p>
|
||||
<p id={description}>Pay another non-allied faction now. The amount is public; the recipient collects their share at the start of Mentat Pause. Payments made during Mentat Pause wait until the start of next turn's Mentat Pause. A message or promise alone does not pay spice.</p>
|
||||
<p>Your incoming bribes: <strong>{options.incoming} spice</strong>, separate from your spendable supply.</p>
|
||||
{options.blocked ? <output className="bribe-status">{options.blocked}</output> : null}
|
||||
{options.targets.length === 0 ? <p>No non-allied recipient is available.</p> : (
|
||||
@@ -47,7 +46,7 @@ export function Bribes({ game: g, act, busy }: {
|
||||
<p>{recipient ? `You may pay up to ${recipient.maximum} spice to this recipient.` : `Uncommitted spice: ${options.available}. Choose a recipient to see the payment limit.`}</p>
|
||||
{recipient?.limitation ? <p>{recipient.limitation}</p> : null}
|
||||
{recipient && !options.blocked && !action ? <output className="bribe-status">{recipient.maximum < 1 ? 'No spice is available for this payment.' : `Enter a whole number from 1 to ${recipient.maximum}.`}</output> : null}
|
||||
<p>Eligible Bureaucrats may divert two spice to the Bank. AI opponents can receive spice, but do not negotiate deals yet. <Link href="/rules?topic=bribes#bribes">Bribe rules</Link></p>
|
||||
<p>Eligible Bureaucrats may divert two spice to the Bank. AI opponents can receive spice, but do not negotiate deals yet. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -24,14 +24,25 @@ export function BureaucratPayment({
|
||||
return null;
|
||||
const payer = game.players.find((player) => player.id === pending.payer)?.name ?? pending.payer;
|
||||
const payee = game.players.find((player) => player.id === pending.payee)?.name ?? pending.payee;
|
||||
const redirectAmount = pending.amount - pending.redirect;
|
||||
return (
|
||||
<section className="min-w-0 space-y-3" aria-label="Bureaucrat payment">
|
||||
<h3 className="font-serif text-xl">Bureaucrat payment</h3>
|
||||
<p className="text-base leading-7">
|
||||
The payment from {payer} to {payee} is {pending.amount} spice. You may redirect {pending.redirect}{' '}
|
||||
spice to the Bank: {payee} receives {redirectAmount} spice and the Bank receives{' '}
|
||||
{pending.redirect}, while {payer} still pays the full amount.
|
||||
{pending.richeseAllocation ? <>
|
||||
The original Richese sale costs {pending.amount} spice. A qualifying portion goes to {payee}.
|
||||
You may redirect {pending.redirect} spice from that portion to the Bank.{' '}
|
||||
{pending.homeworldAllocation
|
||||
? <>The remaining portion follows the original Homeworld income allocation.</>
|
||||
: <>{payee} receives {pending.redirect} less than their original portion.</>}{' '}
|
||||
Each contributor still pays their full original share.
|
||||
</> : <>
|
||||
{payer ? <>The payment from {payer} to {payee}</> : <>The qualifying allied-funded payment to {payee}</>}{' '}
|
||||
is {pending.amount} spice. You may redirect {pending.redirect} spice to the Bank.{' '}
|
||||
{pending.homeworldAllocation
|
||||
? <>{pending.amount - pending.redirect} spice then follows the original Homeworld income allocation, including any occupied share.</>
|
||||
: <>{payee} receives {pending.amount - pending.redirect} spice and the Bank receives {pending.redirect}.</>}{' '}
|
||||
{payer ? `${payer} still pays the full amount` : 'Each contributor still pays their full original share'}.
|
||||
</>}
|
||||
</p>
|
||||
{pending.kind === 'bribe' && (
|
||||
<p className="text-base leading-7">
|
||||
@@ -39,6 +50,11 @@ export function BureaucratPayment({
|
||||
It cannot be spent before then.
|
||||
</p>
|
||||
)}
|
||||
{pending.kind === 'revival' && (
|
||||
<p className="text-base leading-7">
|
||||
This is the actual revival price. A separate free-revival award, if any, stays with Tleilaxu.
|
||||
</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
Allowing the full payment preserves Bureaucrat for later use this phase.
|
||||
</p>
|
||||
@@ -60,7 +76,7 @@ export function BureaucratPayment({
|
||||
!busy && act({ type: 'decision', event: pending.event, redirect: false })
|
||||
}
|
||||
>
|
||||
Allow full {pending.amount}-spice payment
|
||||
{pending.richeseAllocation ? 'Allow original portion' : `Allow full ${pending.amount}-spice payment`}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -40,6 +40,23 @@ function ReinforcementChoice({ game, act, busy }: Props) {
|
||||
<p>
|
||||
Add one reserve force at the battle location, or leave it in reserves.
|
||||
</p>
|
||||
<p className="fine" aria-label="Caladan arrival rules">
|
||||
This force joins the original surviving winner at the original battle location, including inside
|
||||
the Hidden Mobile Stronghold. Its direct-shipment ban does not block this placement. No spice,
|
||||
shipment, movement or revival allowance is spent, and no Spiritual Advisor opportunity is created.
|
||||
HMS entry is provisional. Storm-sector entry remains unavailable.
|
||||
</p>
|
||||
<p className="fine" aria-label="Provisional Caladan arrival reactions">
|
||||
{game.advanced && 'Advanced Bene Gesserit Intrusion resolves first. '}
|
||||
Provisional arrival classification: eligible Ambassador and Terror reactions then follow their
|
||||
original arrival order.
|
||||
</p>
|
||||
<p className="fine" aria-label="Provisional Face Dance order">
|
||||
Provisional order: this optional force joins the original victorious
|
||||
army before any later Face Dance. If replacement occurs, it returns with
|
||||
the surviving army and counts toward the current replacement limit.
|
||||
This does not create another victory or grant another Caladan reinforcement.
|
||||
</p>
|
||||
<label className="block space-y-1">
|
||||
<span>Reinforcement destination</span>
|
||||
<select
|
||||
@@ -74,7 +91,7 @@ function ReinforcementChoice({ game, act, busy }: Props) {
|
||||
className="game-action"
|
||||
disabled={busy || !choice.action}
|
||||
onClick={() => {
|
||||
if (choice.action) act(choice.action);
|
||||
if (!busy && choice.action) act(choice.action);
|
||||
}}
|
||||
>
|
||||
Add one reserve force
|
||||
@@ -83,9 +100,8 @@ function ReinforcementChoice({ game, act, busy }: Props) {
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !canAct}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', event: offer.event, decline: true })
|
||||
}
|
||||
onClick={() => { if (!busy && canAct)
|
||||
act({ type: 'decision', event: offer.event, decline: true }); }}
|
||||
>
|
||||
Leave in reserves
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
type Props = {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
export function CharityClaim(props: Props) {
|
||||
const { game, act, busy } = props;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (game.status !== 'playing' || game.phase !== 2 || !me) return null;
|
||||
const actor = game.me, turn = game.turn, order = game.charityOrder;
|
||||
const currentActor = order?.remaining[0];
|
||||
const currentName = game.players.find(player => player.id === currentActor)?.name ?? 'the current player';
|
||||
const ownTurn = !order || (order.version === 1 || order.version === 2 || order.version === 3) && order.turn === turn && order.remaining[0] === actor;
|
||||
const blocked = busy || !!game.truthtrance || !!game.nexusTraitors?.pending || game.charity.incomePending ||
|
||||
!!game.response || !!game.decision || !!game.phaseOpening || !!game.automaticContinuationPending ||
|
||||
!ownTurn || !!game.charity.orderBlocked;
|
||||
const canClaim = !blocked && !game.charity.truthBlocked && !me.charityClaimed && game.charity.amount > 0;
|
||||
const canFinish = !!order && !blocked;
|
||||
return (
|
||||
<>
|
||||
{order && <div className="fine" data-testid="charity-turn-status">
|
||||
<p>{order.version === 2 || order.version === 3 ? 'Juice of Sapho changed the remaining Charity order. Completed turns stay finished.' : 'Advanced Charity proceeds from the First Player counterclockwise, one player at a time.'}</p>
|
||||
<p>{(order.version !== 1 && order.version !== 2 && order.version !== 3) || order.turn !== turn
|
||||
? 'This Charity turn order is no longer current.'
|
||||
: !currentActor ? 'All Charity turns are complete.'
|
||||
: !order.remaining.includes(actor) ? `Your Charity turn is complete. Waiting for ${currentName} to finish Charity.`
|
||||
: currentActor === actor ? 'It is your Charity turn. Claim if eligible, then finish your turn to continue.'
|
||||
: `Waiting for ${currentName} to finish Charity. Your turn comes later in the public order.`}</p>
|
||||
{game.charity.orderBlocked && <p>{game.charity.orderBlocked}</p>}
|
||||
</div>}
|
||||
<p className="muted">
|
||||
{game.charity.multiplier === 0
|
||||
? 'Inflation cancels charity for every faction this turn.'
|
||||
: game.charity.multiplier === 2
|
||||
? 'Inflation doubles the amount of charity you receive. Ordinary eligibility still requires fewer than two spice; advanced Bene Gesserit receives four regardless of wealth.'
|
||||
: game.advanced && me.faction === 'beneGesserit'
|
||||
? 'Receive two spice once per turn. If you already hold two or more, opponents may cancel the faction benefit with Karama.'
|
||||
: 'If you have fewer than two spice, claim charity once to reach two.'}
|
||||
</p>
|
||||
<p className="fine">
|
||||
{game.charity.payer
|
||||
? `The ordinary claim is ${game.charity.ordinary} spice. CHOAM pays as much of the ordinary claim as it can; the Spice Bank supplies any shortfall. This funding rule is provisional.`
|
||||
: 'Charity is paid by the Spice Bank.'}
|
||||
{game.charity.homeworld > 0 &&
|
||||
` Low Homeworld population adds ${game.charity.homeworld} spice directly from the bank${game.charity.multiplier === 2 ? ' after Inflation doubles the bonus' : ''}.`}
|
||||
</p>
|
||||
{game.charity.truthBlocked && <p className="notice" data-testid="charity-truth-blocked">{game.charity.truthBlocked}</p>}
|
||||
<Button
|
||||
data-testid="charity-claim-submit"
|
||||
className="game-action"
|
||||
disabled={!canClaim}
|
||||
onClick={() => {
|
||||
if (canClaim) act({ type: 'charity' });
|
||||
}}
|
||||
>
|
||||
{game.charity.amount > 0
|
||||
? `Claim ${game.charity.amount} charity spice`
|
||||
: 'Claim charity'}
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
{order && <Button
|
||||
data-testid="charity-turn-finish"
|
||||
className="game-action"
|
||||
disabled={!canFinish}
|
||||
onClick={() => { if (canFinish) act({ type: 'ready' }); }}
|
||||
>
|
||||
Finish Charity turn
|
||||
</Button>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { choamPowerKey } from '@/game/choam-power-options';
|
||||
import { ChoamPowerCost } from './choam-power-cost';
|
||||
import { NexusCardFace } from './nexus-cards';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function ChoamKull({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [selected, select] = useState('');
|
||||
const reaction = game.kullReaction;
|
||||
if (!reaction) return null;
|
||||
const reactor = game.players.find((player) => player.id === reaction.player);
|
||||
const target = game.players.find((player) => player.id === reaction.target);
|
||||
const ownsOffer = game.me === reaction.player && reaction.canDecline;
|
||||
const disabled = busy || game.status !== 'playing';
|
||||
// Foreign projections never authorize private cost controls or inspectors.
|
||||
const plays = ownsOffer ? reaction.plays : [];
|
||||
const play = plays.find((candidate) => choamPowerKey(candidate) === selected) ??
|
||||
plays.find((candidate) => !candidate.blocked) ?? plays[0];
|
||||
const canUse = !disabled && !reaction.blocked && !!play && !play.blocked;
|
||||
|
||||
return (
|
||||
<section aria-label="Kull Wahad response" className="notice min-w-0 space-y-3">
|
||||
<h3>Kull Wahad · attempted Karama play</h3>
|
||||
<p>
|
||||
{target?.name ?? 'The activating player'} is attempting {reaction.intent}.
|
||||
The attempted card remains unplayed while {reactor?.name ?? 'CHOAM'} responds.
|
||||
</p>
|
||||
{!ownsOffer ? (
|
||||
<p aria-live="polite">Waiting for {reactor?.name ?? 'CHOAM'} to respond.</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Decline to continue the original play, or choose a listed physical
|
||||
cost for Kull Wahad. Printed Kull uses its own Worthless Card;
|
||||
CHOAM Nexus Cunning can use any eligible Treachery Card.
|
||||
Successful Kull retains the attempted card and prevents {reaction.scope === 'effect'
|
||||
? <>that player from using {reaction.effect} again this phase; other Karama effects remain available.</>
|
||||
: <>that player’s Karama activations for this phase.</>} A different eligible
|
||||
Karama can prevent Kull before that restriction takes effect.
|
||||
</p>
|
||||
{reaction.blocked && <p className="fine">{reaction.blocked}</p>}
|
||||
<Button
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled)
|
||||
act({ type: 'kullDecision', event: reaction.event, decline: true });
|
||||
}}
|
||||
>
|
||||
Decline Kull · allow the attempted play
|
||||
</Button>
|
||||
{!reaction.blocked && play && (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<ChoamPowerCost plays={plays} play={play} select={select} busy={disabled} />
|
||||
{play.source === 'nexus' && game.nexusCards?.card === 'choam' && (
|
||||
<NexusCardFace card={game.nexusCards.card} mode="cunning" />
|
||||
)}
|
||||
<p className="fine">Physical Treachery cost: {play.card.id}</p>
|
||||
<Button
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={!canUse}
|
||||
onClick={() => {
|
||||
if (canUse)
|
||||
act({
|
||||
type: 'kullDecision', event: reaction.event,
|
||||
source: play.source, card: play.card.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Use Kull Wahad · {play.card.name} ({play.card.id})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -79,8 +79,10 @@ export function ChoamWorthless({
|
||||
(play) => play.source === 'nexus' && play.effect === 'kull',
|
||||
) && (
|
||||
<p className="fine">
|
||||
Kull Wahad’s Karama prevention effect is not implemented. CHOAM
|
||||
Cunning cannot use it yet.
|
||||
Kull Wahad reacts only to another player’s Karama attempt.{' '}
|
||||
{g.nexusKullPreview
|
||||
? 'Choose printed or Nexus fuel in that opportunity.'
|
||||
: 'Nexus Kull is unavailable outside its explicit preview.'}
|
||||
</p>
|
||||
)}
|
||||
{reactive && (
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Player } from '@/game/engine';
|
||||
import { eliteRevivalRemaining, paidForceRevivalCost } from '@/game/revival';
|
||||
|
||||
/** Public alliance members only; the engine remains the legality authority. */
|
||||
export type CoalitionEmperorAlly = Pick<Player, 'id' | 'name' | 'faction' | 'tanks' | 'elites'>;
|
||||
export type CoalitionEmperorAidAction =
|
||||
| { type: 'emperorGift'; target: string; amount: number }
|
||||
| { type: 'emperorRevival'; target: string; amount: number; elite: number };
|
||||
|
||||
function positiveInteger(raw: string): number | null {
|
||||
if (!/^[1-9]\d*$/.test(raw)) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Render for an Emperor in a staged group alliance; pair controls stay separate. */
|
||||
export function CoalitionEmperorAid({ allies, phase, advanced, availableSpice,
|
||||
emperorExtra, revivalOrderHead, busy = false, act }: {
|
||||
allies: readonly CoalitionEmperorAlly[];
|
||||
phase: number;
|
||||
advanced: boolean;
|
||||
/** Spice not committed to coalition pledges or other obligations. */
|
||||
availableSpice: number;
|
||||
emperorExtra: Readonly<Record<string, number>>;
|
||||
revivalOrderHead?: string | null;
|
||||
busy?: boolean;
|
||||
act: (action: CoalitionEmperorAidAction) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [giftTarget, setGiftTarget] = useState('');
|
||||
const [giftRaw, setGiftRaw] = useState('1');
|
||||
const [revivalTarget, setRevivalTarget] = useState('');
|
||||
const [revivalRaw, setRevivalRaw] = useState('1');
|
||||
const [eliteRaw, setEliteRaw] = useState('0');
|
||||
const giftAlly = allies.find(ally => ally.id === giftTarget);
|
||||
const revivalAlly = allies.find(ally => ally.id === revivalTarget);
|
||||
const gift = positiveInteger(giftRaw);
|
||||
const amount = positiveInteger(revivalRaw);
|
||||
const elite = /^(0|[1-9]\d*)$/.test(eliteRaw) && Number.isSafeInteger(Number(eliteRaw))
|
||||
? Number(eliteRaw) : null;
|
||||
const maximum = revivalAlly ? Math.max(0, Math.min(
|
||||
3 - (emperorExtra[revivalAlly.id] ?? 0), revivalAlly.tanks,
|
||||
Math.floor(availableSpice / 2),
|
||||
)) : 0;
|
||||
const cost = revivalAlly && amount && elite !== null
|
||||
? paidForceRevivalCost(revivalAlly, amount, elite) : null;
|
||||
const eliteTanks = revivalAlly?.elites?.tanks ?? 0;
|
||||
const legalElite = revivalAlly && amount && elite !== null &&
|
||||
elite <= Math.min(amount, eliteRevivalRemaining(revivalAlly, advanced)) &&
|
||||
amount - elite <= revivalAlly.tanks - eliteTanks;
|
||||
const canGift = !!giftAlly && gift !== null && gift <= availableSpice && !busy;
|
||||
const canRevive = phase === 4 && !!revivalAlly && amount !== null && amount <= maximum &&
|
||||
legalElite && cost !== null && cost <= availableSpice &&
|
||||
(!revivalOrderHead || revivalOrderHead === revivalAlly.id) && !busy;
|
||||
|
||||
return <section className="notice min-w-0 space-y-3 break-words" aria-label="Emperor group alliance aid">
|
||||
<h3>Emperor alliance aid</h3>
|
||||
<p className="fine">Choose which ally receives your aid. Spice and revivals take effect after the Karama response.</p>
|
||||
<label htmlFor={`${id}-gift-target`}>Ally to receive spice</label>
|
||||
<select id={`${id}-gift-target`} value={giftAlly?.id ?? ''} disabled={busy}
|
||||
onChange={event => setGiftTarget(event.target.value)}>
|
||||
<option value="">Choose an ally</option>
|
||||
{allies.map(ally => <option key={ally.id} value={ally.id}>{ally.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-gift-amount`}>Spice to share</label>
|
||||
<input id={`${id}-gift-amount`} type="number" inputMode="numeric" min={1} max={availableSpice}
|
||||
step={1} value={giftRaw} disabled={busy} onChange={event => setGiftRaw(event.target.value)} />
|
||||
<button type="button" className="game-action" disabled={!canGift}
|
||||
onClick={() => { if (giftAlly && gift !== null && canGift) act({ type: 'emperorGift', target: giftAlly.id, amount: gift }); }}>
|
||||
Share spice
|
||||
</button>
|
||||
{phase === 4 && <div className="space-y-2">
|
||||
<label htmlFor={`${id}-revival-target`}>Ally to receive extra revivals</label>
|
||||
<select id={`${id}-revival-target`} value={revivalAlly?.id ?? ''} disabled={busy}
|
||||
onChange={event => { setRevivalTarget(event.target.value); setEliteRaw('0'); }}>
|
||||
<option value="">Choose an ally</option>
|
||||
{allies.map(ally => <option key={ally.id} value={ally.id}>{ally.name}</option>)}
|
||||
</select>
|
||||
{revivalAlly && <>
|
||||
<p className="fine">{revivalAlly.name} has {revivalAlly.tanks} forces in the tanks and can receive up to {maximum} extra revivals now.</p>
|
||||
{revivalOrderHead && revivalOrderHead !== revivalAlly.id && <p className="fine">Wait for this ally’s revival turn.</p>}
|
||||
<label htmlFor={`${id}-revival-amount`}>Forces to revive</label>
|
||||
<input id={`${id}-revival-amount`} type="number" inputMode="numeric" min={1} max={maximum}
|
||||
step={1} value={revivalRaw} disabled={busy} onChange={event => setRevivalRaw(event.target.value)} />
|
||||
{revivalAlly.elites && <>
|
||||
<label htmlFor={`${id}-revival-elite`}>{revivalAlly.faction === 'ixians' ? 'Cyborgs' : 'Elite forces'}</label>
|
||||
<input id={`${id}-revival-elite`} type="number" inputMode="numeric" min={0}
|
||||
max={Math.min(amount ?? 0, eliteRevivalRemaining(revivalAlly, advanced))} step={1}
|
||||
value={eliteRaw} disabled={busy} onChange={event => setEliteRaw(event.target.value)} />
|
||||
</>}
|
||||
{cost !== null && <p className="fine">You pay {cost} spice. This uses {revivalAlly.name}’s extra-revival allowance.</p>}
|
||||
</>}
|
||||
<button type="button" className="game-action" disabled={!canRevive}
|
||||
onClick={() => { if (revivalAlly && amount !== null && elite !== null && canRevive)
|
||||
act({ type: 'emperorRevival', target: revivalAlly.id, amount, elite }); }}>
|
||||
Pay for extra ally revivals
|
||||
</button>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
/** Only the viewer's own pledges and contributions offered to that viewer. */
|
||||
export type CoalitionFundingView = {
|
||||
targets: readonly { id: string; name: string; pledged: number; maximum: number; minimum?: number }[];
|
||||
incoming: readonly { donor: string; name: string; amount: number }[];
|
||||
blocked: string | null;
|
||||
};
|
||||
export type CoalitionFundingContribution = { donor: string; amount: number };
|
||||
export type CoalitionPledgeAction = { type: 'coalitionPledge'; recipient: string; amount: number };
|
||||
|
||||
function safeMaximum(value: number): number {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
/** Keep only positive donor-specific allocations within the current pledges. */
|
||||
export function coalitionFundingContributions(
|
||||
incoming: CoalitionFundingView['incoming'], selections: Readonly<Record<string, number>>,
|
||||
): CoalitionFundingContribution[] {
|
||||
return incoming.flatMap(entry => {
|
||||
const selected = selections[entry.donor];
|
||||
const amount = Number.isFinite(selected)
|
||||
? Math.max(0, Math.min(safeMaximum(entry.amount), Math.trunc(selected))) : 0;
|
||||
return amount ? [{ donor: entry.donor, amount }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Advisory validation only; the engine verifies current membership, escrow,
|
||||
* commitments and wallet again when it receives the action. */
|
||||
export function coalitionPledgeAction(
|
||||
target: CoalitionFundingView['targets'][number], raw: string,
|
||||
blocked: string | null, busy = false,
|
||||
): CoalitionPledgeAction | null {
|
||||
if (busy || blocked || !/^(0|[1-9]\d*)$/.test(raw)) return null;
|
||||
const amount = Number(raw);
|
||||
if (!Number.isSafeInteger(amount) || amount < safeMaximum(target.minimum ?? 0) ||
|
||||
amount > safeMaximum(target.maximum)) return null;
|
||||
return { type: 'coalitionPledge', recipient: target.id, amount };
|
||||
}
|
||||
|
||||
export function CoalitionFunding({ view, busy = false, act, onContributionsChange, purpose = 'shipment' }: {
|
||||
view: CoalitionFundingView;
|
||||
busy?: boolean;
|
||||
purpose?: 'bid' | 'shipment';
|
||||
act: (action: CoalitionPledgeAction) => void;
|
||||
onContributionsChange?: (contributions: CoalitionFundingContribution[]) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [pledges, setPledges] = useState<Record<string, string>>({});
|
||||
const incomingKey = JSON.stringify(view.incoming.map(({donor, amount}) => [donor, amount]));
|
||||
const [selection, setSelection] = useState<{key:string; shares:Record<string, number>}>({key:'', shares:{}});
|
||||
const shares = selection.key === incomingKey ? selection.shares : {};
|
||||
|
||||
function changeShare(donor: string, amount: number) {
|
||||
const next = { ...shares, [donor]: amount };
|
||||
const contributions = coalitionFundingContributions(view.incoming, next);
|
||||
setSelection({key:incomingKey, shares:Object.fromEntries(contributions.map(item => [item.donor, item.amount]))});
|
||||
onContributionsChange?.(contributions);
|
||||
}
|
||||
|
||||
return <section aria-label="Group alliance funding" className="alliance-funding space-y-3">
|
||||
<h3>Group alliance funding</h3>
|
||||
<p>Set aside spice separately for each ally. Unspent pledges return at the end of the phase.</p>
|
||||
{view.blocked && <p className="notice">{view.blocked}</p>}
|
||||
{view.targets.map((target, index) => {
|
||||
const raw = pledges[target.id] ?? String(target.pledged);
|
||||
const action = coalitionPledgeAction(target, raw, view.blocked, busy);
|
||||
const inputId = `${id}-pledge-${index}`;
|
||||
const minimum = safeMaximum(target.minimum ?? 0);
|
||||
return <div key={target.id} className="space-y-2">
|
||||
<label htmlFor={inputId}>Pledge to {target.name} · {target.pledged} spice currently set aside</label>
|
||||
{minimum > 0 && <p>The current high bid commits {minimum} spice of this pledge.</p>}
|
||||
<input id={inputId} type="number" min={minimum} max={safeMaximum(target.maximum)} step={1}
|
||||
inputMode="numeric" value={raw} disabled={busy || !!view.blocked}
|
||||
onChange={event => setPledges(previous => ({ ...previous, [target.id]: event.target.value }))} />
|
||||
<button type="button" className="game-action" disabled={!action}
|
||||
onClick={() => { if (action) act(action); }}>Set funding for {target.name}</button>
|
||||
</div>;
|
||||
})}
|
||||
{view.incoming.length ? <div>
|
||||
<p>Spice pledged to you:</p>
|
||||
{view.incoming.map((entry, index) => {
|
||||
const maximum = safeMaximum(entry.amount);
|
||||
const value = Math.max(0, Math.min(maximum, shares[entry.donor] ?? 0));
|
||||
const inputId = `${id}-share-${index}`;
|
||||
return <div key={entry.donor} className="space-y-2">
|
||||
<p>{entry.name}: {entry.amount} spice</p>
|
||||
{onContributionsChange && <>
|
||||
<label htmlFor={inputId}>Use from {entry.name} for your next {purpose}</label>
|
||||
<input id={inputId} type="number" min={0} max={maximum} step={1}
|
||||
inputMode="numeric" value={value} disabled={busy || !!view.blocked}
|
||||
onChange={event => {
|
||||
const entered = Number(event.target.value);
|
||||
changeShare(entry.donor, Number.isFinite(entered)
|
||||
? Math.max(0, Math.min(maximum, Math.trunc(entered))) : 0);
|
||||
}} />
|
||||
</>}
|
||||
</div>;
|
||||
})}
|
||||
</div> : <p>No allies have pledged spice to you.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import type { CoalitionAdmission, CoalitionGroup } from '../game/coalition-alliance';
|
||||
|
||||
/** Public Nexus projection. The authoritative engine still checks all timing,
|
||||
* eligibility, event and revision constraints before changing the alliance. */
|
||||
export type CoalitionNexusView = {
|
||||
me: string;
|
||||
players: readonly { id: string; name: string }[];
|
||||
groups: readonly CoalitionGroup[];
|
||||
proposal: CoalitionAdmission | null;
|
||||
revision: number;
|
||||
/** Absent preserves the original, at-most-three alliance prototype. */
|
||||
variant?: 'unlimited';
|
||||
/** Until group admission can reconcile a live bribe commitment, only
|
||||
* departure remains available in this Nexus. */
|
||||
admissionBlocked?: boolean;
|
||||
/** Invitation choices that this actor cannot make while commitments apply. */
|
||||
blockedInvitations?: readonly { groupId: string; entrant: string }[];
|
||||
/** Whether this actor is unable to accept the pending invitation. */
|
||||
acceptBlocked?: boolean;
|
||||
};
|
||||
|
||||
export type CoalitionNexusIntent =
|
||||
| { kind: 'propose'; groupId: string; entrant: string; expectedRevision: number }
|
||||
| { kind: 'answer'; event: string; nexus: string; turn: number; accept: boolean; expectedRevision: number }
|
||||
| { kind: 'leave'; expectedRevision: number };
|
||||
|
||||
/** Choices are informational; the server remains the legality authority. */
|
||||
export function coalitionNexusOptions(view: CoalitionNexusView): {
|
||||
invitations: { groupId: string; entrant: string; members: readonly string[] }[];
|
||||
mayLeave: boolean;
|
||||
mayAccept: boolean;
|
||||
mayDecline: boolean;
|
||||
} {
|
||||
const seated = view.players.some(player => player.id === view.me);
|
||||
const ownGroup = view.groups.find(group => group.members.includes(view.me));
|
||||
const occupied = new Set(view.groups.flatMap(group => group.members));
|
||||
const invitations = !seated || view.proposal || view.admissionBlocked ? [] : view.groups.flatMap(group =>
|
||||
(group.members.length < 2 || group.members.length >= (view.variant === 'unlimited' ? 6 : 3)) ? [] : view.players
|
||||
.filter(player => !occupied.has(player.id) &&
|
||||
(player.id === view.me || group.members.includes(view.me)) &&
|
||||
!view.blockedInvitations?.some(blocked =>
|
||||
blocked.groupId === group.id && blocked.entrant === player.id))
|
||||
.map(player => ({ groupId: group.id, entrant: player.id, members: group.members })));
|
||||
const participants = view.proposal
|
||||
? [...view.proposal.original.members, view.proposal.entrant] : [];
|
||||
const mayDecline = seated && participants.includes(view.me);
|
||||
return {
|
||||
invitations,
|
||||
mayLeave: seated && !!ownGroup && !view.proposal,
|
||||
mayAccept: mayDecline && !view.admissionBlocked && !view.acceptBlocked &&
|
||||
!view.proposal!.approvals.includes(view.me),
|
||||
mayDecline,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render only when the group-alliance option and Nexus are authorized. */
|
||||
export function CoalitionNexus({ view, disabled = false, onIntent }: {
|
||||
view: CoalitionNexusView;
|
||||
disabled?: boolean;
|
||||
onIntent: (intent: CoalitionNexusIntent) => void;
|
||||
}) {
|
||||
const options = coalitionNexusOptions(view);
|
||||
const name = (id: string) => view.players.find(player => player.id === id)?.name ?? id;
|
||||
const names = (ids: readonly string[]) => ids.map(name).join(', ');
|
||||
const proposal = view.proposal;
|
||||
const ownGroup = view.groups.find(group => group.members.includes(view.me));
|
||||
const submit = (intent: CoalitionNexusIntent) => { if (!disabled) onIntent(intent); };
|
||||
return <section aria-label="Group alliance negotiations" className="notice min-w-0 space-y-3 break-words">
|
||||
<h3>Group alliance negotiations</h3>
|
||||
{view.variant === 'unlimited' ? <p>Every current member and the invited player must agree.
|
||||
The existing alliance remains in place while an invitation is pending.</p> :
|
||||
<p>All three players must agree before an invitation forms an alliance.
|
||||
The existing pair remains allied while an invitation is pending.</p>}
|
||||
{view.groups.length ? <ul>{view.groups.map(group =>
|
||||
<li key={group.id}>{names(group.members)}</li>)}</ul> : <p>There are no alliances yet.</p>}
|
||||
{proposal ? <div aria-live="polite">
|
||||
{view.admissionBlocked && <p>Acceptance is unavailable while a bribe commitment remains unresolved. You may decline this invitation.</p>}
|
||||
{!view.admissionBlocked && view.acceptBlocked && <p>Acceptance is unavailable while it would conflict with your commitments. You may decline this invitation.</p>}
|
||||
<p>{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.</p>
|
||||
<p>Approved: {names(proposal.approvals)}. Waiting for: {names(
|
||||
[...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)),
|
||||
) || 'no one'}.</p>
|
||||
{options.mayAccept && <button className="game-action" disabled={disabled} onClick={() => submit({
|
||||
kind: 'answer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn,
|
||||
accept: true, expectedRevision: view.revision,
|
||||
})}>Accept invitation</button>}
|
||||
{options.mayDecline && <button className="game-action" disabled={disabled} onClick={() => submit({
|
||||
kind: 'answer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn,
|
||||
accept: false, expectedRevision: view.revision,
|
||||
})}>{proposal.proposer === view.me ? 'Withdraw and cancel invitation' :
|
||||
proposal.approvals.includes(view.me) ? 'Withdraw approval and cancel invitation' :
|
||||
'Decline and cancel invitation'}</button>}
|
||||
</div> : <div>
|
||||
{view.admissionBlocked && <p>Invitations are unavailable while a bribe commitment remains unresolved.
|
||||
Finish the commitment or wait until its binding period ends.</p>}
|
||||
{!view.admissionBlocked && !!view.blockedInvitations?.length && <p>Some invitations are unavailable while they would conflict with your commitments.</p>}
|
||||
{options.invitations.length > 0 && <p>{view.variant === 'unlimited'
|
||||
? 'Invite an unallied player to an existing alliance, or ask to join one.'
|
||||
: 'Invite an unallied player to an existing pair, or ask to join a pair.'}</p>}
|
||||
{options.invitations.map(option => <button className="game-action" disabled={disabled}
|
||||
key={JSON.stringify([option.groupId, option.entrant])} onClick={() => submit({
|
||||
kind: 'propose', groupId: option.groupId, entrant: option.entrant, expectedRevision: view.revision,
|
||||
})}>{option.entrant === view.me ? `Ask to join ${names(option.members)}` :
|
||||
`Invite ${name(option.entrant)} to ${names(option.members)}`}</button>)}
|
||||
{options.mayLeave && <button className="game-action" disabled={disabled} onClick={() => submit({
|
||||
kind: 'leave', expectedRevision: view.revision,
|
||||
})}>Leave {names(ownGroup!.members)}</button>}
|
||||
{ownGroup?.members.length === 3 && <p>Leaving keeps the other two players allied under the provisional online policy.</p>}
|
||||
{view.variant === 'unlimited' && (ownGroup?.members.length ?? 0) > 3 &&
|
||||
<p>Leaving keeps the other players allied under the provisional online policy.</p>}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { battleCardLabel } from '@/game/battle-cards';
|
||||
import { isPortableSnooper } from '@/game/battle-cards';
|
||||
import { copiedDefenseKind, diplomatDefenseLabel } from '@/game/diplomat-defense';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function DiplomatDefense({
|
||||
@@ -20,11 +21,20 @@ export function DiplomatDefense({
|
||||
const visibleCards = [...(game.battle?.cards ?? []), ...(me?.hand ?? [])];
|
||||
const card = (id: string) => visibleCards.find((entry) => entry.id === id);
|
||||
const source = card(decision.source);
|
||||
const copiedDefense = source ? battleCardLabel(source.kind) : 'defense';
|
||||
const copiedKind =
|
||||
game.battle?.diplomatDefense?.kind ?? copiedDefenseKind(source);
|
||||
const copiedDefense = copiedKind
|
||||
? diplomatDefenseLabel(copiedKind)
|
||||
: 'defense';
|
||||
const interpretedRole =
|
||||
copiedKind === 'shieldSnooper' ||
|
||||
copiedKind === 'weirdingWay' ||
|
||||
copiedKind === 'chemistry' ||
|
||||
isPortableSnooper(source);
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-label="Diplomat defense choice">
|
||||
<p>
|
||||
The opposing committed defense,{' '}
|
||||
The opposing used defense,{' '}
|
||||
<strong>{source?.name ?? decision.source}</strong>, provides{' '}
|
||||
<strong>{copiedDefense}</strong> protection. Diplomat can copy that
|
||||
protection onto one of your committed Worthless cards.
|
||||
@@ -34,6 +44,13 @@ export function DiplomatDefense({
|
||||
must be discarded after this battle. Decline to proceed without a copied
|
||||
defense.
|
||||
</p>
|
||||
{interpretedRole && (
|
||||
<p className="fine" data-copied-role-interpretation="true">
|
||||
First-version interpretation: copy the defense role already used by
|
||||
the opponent, not its activation, late-play opportunity or requirement
|
||||
for another card in your own plan.
|
||||
</p>
|
||||
)}
|
||||
{decision.cards.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { splitLocation, territory } from '@/game/board';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function DiplomatRetreat({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [destination, setDestination] = useState<string | null>(null);
|
||||
const [choiceIndex, setChoiceIndex] = useState(0);
|
||||
const decision = game.decision?.kind === 'diplomatRetreat' ? game.decision : null;
|
||||
if (!decision || decision.player !== game.me) return null;
|
||||
|
||||
const selectedDestination = decision.destinations.find((offer) => offer.location === destination)
|
||||
?? decision.destinations[0];
|
||||
const choice = selectedDestination?.choices[choiceIndex] ?? selectedDestination?.choices[0];
|
||||
const selectedLocation = selectedDestination ? splitLocation(selectedDestination.location) : null;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" aria-label="Diplomat retreat choice">
|
||||
<p>
|
||||
Your skilled Diplomat lost this battle. Move up to their strength in
|
||||
undialed physical forces to one empty adjacent non-stronghold territory,
|
||||
or leave the forces where they are.
|
||||
</p>
|
||||
{selectedDestination && choice && selectedLocation ? (
|
||||
<>
|
||||
<label htmlFor={`${id}-destination`}>Retreat destination</label>
|
||||
<select
|
||||
id={`${id}-destination`}
|
||||
value={selectedDestination.location}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setDestination(event.target.value);
|
||||
setChoiceIndex(0);
|
||||
}}
|
||||
>
|
||||
{decision.destinations.map((offer) => {
|
||||
const location = splitLocation(offer.location);
|
||||
return (
|
||||
<option key={offer.location} value={offer.location}>
|
||||
{territory(location.territory).name} · sector {location.sector}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<label htmlFor={`${id}-forces`}>Physical forces to retreat</label>
|
||||
<select
|
||||
id={`${id}-forces`}
|
||||
value={selectedDestination.choices.indexOf(choice)}
|
||||
disabled={busy}
|
||||
onChange={(event) => setChoiceIndex(Number(event.target.value))}
|
||||
>
|
||||
{selectedDestination.choices.map((amount, index) => (
|
||||
<option key={`${amount.normal}:${amount.elite}`} value={index}>
|
||||
{amount.normal} ordinary · {amount.elite} elite
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() => !busy && act({
|
||||
type: 'decision', event: decision.event,
|
||||
destination: selectedDestination.location, normal: choice.normal, elite: choice.elite,
|
||||
})}
|
||||
>
|
||||
Retreat to {territory(selectedLocation.territory).name} · sector {selectedLocation.sector}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">No legal retreat destination and force combination is available.</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() => !busy && act({
|
||||
type: 'decision', event: decision.event,
|
||||
destination: null, normal: 0, elite: 0,
|
||||
})}
|
||||
>
|
||||
Decline Diplomat retreat
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const tokenRules: Record<DiscoveryTokenFace, string> = {
|
||||
ornithopter: 'Take this token. On a later turn, you may spend it to move up to three territories instead of your usual allowance for one movement action. Remove the token after that action.',
|
||||
};
|
||||
|
||||
function DiscoveryRules({ face }: { face: DiscoveryTokenFace }) {
|
||||
function DiscoveryRules({ face, advanced }: { face: DiscoveryTokenFace; advanced: boolean }) {
|
||||
const definition = DISCOVERY_TOKEN_BY_ID[face];
|
||||
return (
|
||||
<details className="rounded-lg border border-[#a88b60]/40 bg-[#171d18]">
|
||||
@@ -30,7 +30,9 @@ function DiscoveryRules({ face }: { face: DiscoveryTokenFace }) {
|
||||
Read {definition.name} rules
|
||||
</summary>
|
||||
<div className="space-y-3 border-t border-[#a88b60]/40 px-3 py-4 text-base leading-7 text-[#eee5d2]">
|
||||
<p>{tokenRules[face]}</p>
|
||||
<p>{face === 'orgiz-processing-station' && advanced
|
||||
? 'During Spice Collection, an occupant takes one of the collected spice each time collection occurs in a territory containing spice.'
|
||||
: tokenRules[face]}</p>
|
||||
{definition.kind === 'location' && (
|
||||
<>
|
||||
<p>This location is a separate territory inside the surrounding territory. Revealing it leaves it empty. Normal ground movement enters the surrounding territory before entering the location; shipment uses stronghold prices.</p>
|
||||
@@ -79,7 +81,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
{token.territory && <p>In {territory(token.territory).name} · sector {token.sector}</p>}
|
||||
{token.face && <DiscoveryRules face={token.face} />}
|
||||
{token.face && <DiscoveryRules face={token.face} advanced={game.advanced} />}
|
||||
{(inspect || reveal) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{inspect && <Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
|
||||
@@ -92,7 +94,9 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="fine">Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.</p>
|
||||
<p className="fine">Inspection and reveal are optional. Next-turn free entry, Cistern income for every fighter occupant and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. {game.advanced
|
||||
? 'Each Orgiz fighter occupant takes one spice per qualifying territory from the first actual foreign ordinary collector in storm order.'
|
||||
: 'Each Orgiz fighter occupant keeps the provisional one-spice transfer per positive board deposit from the first actual foreign ordinary collector in storm order.'} Shared Ecaz lots wait for actual allocation, then consider positive foreign recipients Ecaz first, ally second, excluding zero shares and the occupant itself. Basic counts actual positive deposits; Advanced counts the territory once. Actual ordinary receipts identify each payer; co-present forces or old wallets alone never qualify. Empty purses never create debt. Mixed Jacurutu dials use a defeated-player choice. Testing Station occupants each choose once in original storm order, cumulatively. These ordering/allocation interpretations are provisional.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [moveMarker, setMoveMarker] = useState(!!offer.noField);
|
||||
const me = game.players.find((player) => player.id === game.me)!;
|
||||
const eliteName = specialForceName(me.faction);
|
||||
const groups: DiscoveryEntrySource[] = offer.sources.flatMap((source) => {
|
||||
@@ -64,7 +65,8 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
(sum, group) => sum + group.normal + group.elite,
|
||||
0,
|
||||
);
|
||||
const move = discoveryEntryMoveAction(game, groups);
|
||||
const noField = moveMarker ? offer.noField : undefined;
|
||||
const move = discoveryEntryMoveAction(game, groups, noField);
|
||||
const decline = discoveryEntryDeclineAction(game);
|
||||
const disabled = busy || !!me.autopilot || !!offer.blocked;
|
||||
|
||||
@@ -85,7 +87,7 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
},
|
||||
}));
|
||||
};
|
||||
const selectAll = (selected: boolean) =>
|
||||
const selectAll = (selected: boolean) => {
|
||||
setCounts(
|
||||
Object.fromEntries(
|
||||
offer.sources.map((source) => [
|
||||
@@ -96,6 +98,8 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
]),
|
||||
),
|
||||
);
|
||||
setMoveMarker(selected && !!offer.noField);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Discovery location entry" className="space-y-4">
|
||||
@@ -113,19 +117,32 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
{offer.blocked}
|
||||
</p>
|
||||
)}
|
||||
{offer.noField && <div className="space-y-2">
|
||||
<p className="fine">
|
||||
Provisional concealed No-Field entry: move the marker without revealing its value or materializing
|
||||
reserves. Its existing shipment history stays unchanged. This costs no spice and uses no shipment
|
||||
or normal movement.
|
||||
</p>
|
||||
<label className="flex min-h-11 items-center gap-2" htmlFor={`${controlId}-no-field`}>
|
||||
<input id={`${controlId}-no-field`} type="checkbox" checked={moveMarker} disabled={disabled}
|
||||
aria-label="Move concealed No-Field inside" onChange={event => setMoveMarker(event.target.checked)} />
|
||||
Move concealed No-Field from {territory(offer.parent).name}, sector {splitLocation(offer.noField.source).sector}
|
||||
</label>
|
||||
<p aria-live="polite">Selected: {amount} physical {amount === 1 ? 'force' : 'forces'} · {noField ? 1 : 0} concealed marker</p>
|
||||
</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled || !offer.sources.length}
|
||||
disabled={disabled || (!offer.sources.length && !offer.noField)}
|
||||
onClick={() => selectAll(true)}
|
||||
>
|
||||
Select all forces
|
||||
{offer.noField ? 'Select all forces and marker' : 'Select all forces'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled || !offer.sources.length}
|
||||
disabled={disabled || (!offer.sources.length && !offer.noField)}
|
||||
onClick={() => selectAll(false)}
|
||||
>
|
||||
Clear selection
|
||||
@@ -191,20 +208,21 @@ function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
className="game-action"
|
||||
disabled={disabled || !move}
|
||||
onClick={() => {
|
||||
if (move) act(move);
|
||||
if (!disabled && move) act(move);
|
||||
}}
|
||||
>
|
||||
Move {amount} {amount === 1 ? 'force' : 'forces'} inside
|
||||
{noField ? `Move ${amount} physical ${amount === 1 ? 'force' : 'forces'} and 1 concealed marker inside`
|
||||
: `Move ${amount} ${amount === 1 ? 'force' : 'forces'} inside`}
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !!me.autopilot || !decline}
|
||||
onClick={() => {
|
||||
if (decline) act(decline);
|
||||
if (!busy && !me.autopilot && decline) act(decline);
|
||||
}}
|
||||
>
|
||||
Leave forces outside
|
||||
{offer.noField ? 'Leave forces and marker outside' : 'Leave forces outside'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -20,11 +20,11 @@ export function DiscoveryStormDecision({
|
||||
return (
|
||||
<section aria-label="Ecological Testing Station" className="space-y-3">
|
||||
<p>
|
||||
Your forces occupy Ecological Testing Station. The storm will move{' '}
|
||||
{offer.base} sectors. You may decrease or increase this by one, or keep
|
||||
the ordinary movement.
|
||||
Your forces occupy Ecological Testing Station. The storm currently
|
||||
moves {offer.base} sectors. You may decrease or increase this by one, or
|
||||
keep the current movement.
|
||||
</p>
|
||||
<p className="fine">This choice cannot change Weather Control.</p>
|
||||
<p className="fine">Prototype ordering: each occupying faction chooses once in storm order; adjustments are cumulative. This choice cannot change Weather Control.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{offer.options.map((option) => {
|
||||
const action = actions.find(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { Card } from '@/game/cards';
|
||||
import type { Action } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export interface DistransTransferActionProps {
|
||||
card: Pick<Card, 'id'>;
|
||||
recipient: { id: string; name?: string } | undefined;
|
||||
given: Pick<Card, 'id' | 'name'> | undefined;
|
||||
reason: string | null;
|
||||
reasonId: string;
|
||||
busy: boolean;
|
||||
act: (action: Action) => void;
|
||||
}
|
||||
|
||||
export function DistransTransferAction({
|
||||
card,
|
||||
recipient,
|
||||
given,
|
||||
reason,
|
||||
reasonId,
|
||||
busy,
|
||||
act,
|
||||
}: DistransTransferActionProps) {
|
||||
const disabled = busy || !!reason || !recipient || !given;
|
||||
return (
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
data-testid="distrans-transfer"
|
||||
disabled={disabled}
|
||||
aria-describedby={reason ? reasonId : undefined}
|
||||
onClick={() => {
|
||||
if (!disabled && recipient && given)
|
||||
act({
|
||||
type: 'card',
|
||||
card: card.id,
|
||||
target: recipient.id,
|
||||
give: given.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Give {given?.name ?? 'a card'} to {recipient?.name ?? 'the recipient'}{' '}
|
||||
and discard Distrans
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+16
-18
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
import { CardInspector, CardRules } from './card-inspector';
|
||||
import { DistransTransferAction } from './distrans-transfer-action';
|
||||
|
||||
export function Distrans({
|
||||
game,
|
||||
@@ -49,6 +49,12 @@ export function Distrans({
|
||||
discard Distrans. No spice is paid. The transfer happens immediately,
|
||||
without a recipient confirmation or Karama response.
|
||||
</p>
|
||||
<p className="m-0 text-sm leading-6" aria-label="Provisional Distrans bid timing">
|
||||
Provisional bid timing: you may transfer before the first bid or pass
|
||||
on an ordinary, Once Around or normal Black Market lot, or before the
|
||||
first sealed submission in a Silent lot. Once that bidding has begun,
|
||||
the lot stays blocked while unresolved.
|
||||
</p>
|
||||
<CardInspector card={distrans.card} />
|
||||
{choice && (
|
||||
<>
|
||||
@@ -103,23 +109,15 @@ export function Distrans({
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && choice && given)
|
||||
act({
|
||||
type: 'card',
|
||||
card: distrans.card.id,
|
||||
target: choice.recipient,
|
||||
give: given.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Give {given?.name ?? 'a card'} to {recipient?.name ?? 'the recipient'}{' '}
|
||||
and discard Distrans
|
||||
</Button>
|
||||
<DistransTransferAction
|
||||
card={distrans.card}
|
||||
recipient={choice ? { id: choice.recipient, name: recipient?.name } : undefined}
|
||||
given={given}
|
||||
reason={reason}
|
||||
reasonId={`${id}-reason`}
|
||||
busy={busy}
|
||||
act={act}
|
||||
/>
|
||||
{!!choice?.unavailable.length && (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-3"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
/** Counts only: this component never receives deck contents or private cards. */
|
||||
export function DrawPiles({ piles }: {
|
||||
@@ -19,7 +18,7 @@ export function DrawPiles({ piles }: {
|
||||
</dl>
|
||||
<p>Draw piles exclude hands, auction cards and discards.
|
||||
{(piles.treachery === 0 || piles.spice === 0) && ' An empty pile may be replenished from eligible discards.'}
|
||||
{' '}<Link href="/rules?topic=draw-piles#draw-piles">About these piles</Link>
|
||||
{' '}<a href="/rules?topic=draw-piles#draw-piles">About these piles</a>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -221,13 +221,15 @@ export function DukeVidal({ game }: { game: GameView }) {
|
||||
))}
|
||||
<p className="m-0 rounded-lg border border-[#675071] bg-[#2c2232] p-4 text-sm leading-6 text-[#e5d3df]">
|
||||
Current support covers Moritani acquisition, direct Ecaz
|
||||
Ambassador acquisition for Ecaz, battle use and death.
|
||||
Ambassador acquisition or consensual alliance, battle use
|
||||
and death, and Ecaz-only paid or Ghola revival to living
|
||||
set-aside custody. Native ordinary-disc skills may coexist
|
||||
with the separate Duke without assigning him a skill.
|
||||
Unused Ecaz custody survives the turn; unused Moritani
|
||||
custody expires. Ecaz alliance and loan alternatives,
|
||||
reacquisition while already controlled, revival, and
|
||||
captured or ghola custody remain unfinished. Full Ecaz and
|
||||
Moritani games are unavailable. Inspection does not select
|
||||
a battle leader.
|
||||
custody expires. Duke loans, reacquisition while already
|
||||
controlled, shared skill assignment and exceptional capture
|
||||
remain unfinished. Full Ecaz and Moritani games are
|
||||
unavailable. Inspection does not select a battle leader.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import type { GameView } from '@/game/engine';
|
||||
|
||||
/** Waiting receipts are private to their respondent. Once attached to a real
|
||||
* lot, the ordinary Richese bid panel owns the subsequent action and status. */
|
||||
export function EarlyRicheseBidPromises({ game }: { game: GameView }) {
|
||||
if (!game.me) return null;
|
||||
const waiting = game.ownEarlyRicheseBidPromises.filter(row => row.player === game.me &&
|
||||
row.turn === game.turn && !row.boundEvent && !row.fulfilled && !row.released);
|
||||
if (!waiting.length) return null;
|
||||
return <section className="notice space-y-3" aria-label="Your upcoming Richese cache bid promises">
|
||||
<h3>Your upcoming Richese cache bid promises</h3>
|
||||
<ul>{waiting.map(row => <li key={row.id}>
|
||||
Turn {row.turn}: You answered {row.answer ? 'Yes' : 'No'} to bidding at least {row.minimum} spice in the first Richese cache auction this turn.
|
||||
</li>)}</ul>
|
||||
<p>Waiting for the first Richese cache auction. Its card and method are not yet decided. Your answer applies while the opportunity remains possible.</p>
|
||||
</section>;
|
||||
}
|
||||
@@ -19,14 +19,14 @@ import {
|
||||
AMBASSADOR_COMMON_GUIDANCE,
|
||||
AMBASSADOR_REFERENCE,
|
||||
} from '@/game/ambassador-reference';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { territory } from '@/game/board';
|
||||
import { faction } from '@/game/catalog';
|
||||
|
||||
export const AMBASSADOR_COVERAGE =
|
||||
'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. Duke loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
|
||||
'Development coverage: Ambassador placement, including the placed Hidden Mobile Stronghold, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen, Guild and Tleilaxu entry effects are supported, plus Bene Gesserit copies. The Tleilaxu effect offers physical forces or a first-death own leader, including Atreides’ separate Kwisatz Haderach token. Basic offers an optional new-ally Duke loan; Advanced automatically grants the available Duke after accepted alliance consent. One-battle loan tenure is provisional; an unused living loan is set aside at turn end. Ordinary revival accounting, repeat-return/shared-leader cases and competing arrivals remain for refinement; Ecaz starts remain labelled previews.';
|
||||
function tokenLocation(token: AmbassadorToken) {
|
||||
if (token.zone === 'placed')
|
||||
return `Placed in ${TERRITORIES.find((t) => t.id === token.location)?.name ?? 'a stronghold'}`;
|
||||
return `Placed in ${token.location ? territory(token.location).name : 'a stronghold'}`;
|
||||
return {
|
||||
pool: 'Unused pool',
|
||||
supply: 'Available supply',
|
||||
@@ -211,6 +211,40 @@ export function EcazPlacement({
|
||||
const id = useId();
|
||||
const [selectedToken, setSelectedToken] = useState('');
|
||||
const [selectedTerritory, setSelectedTerritory] = useState('');
|
||||
return (
|
||||
<EcazPlacementChoice
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
id={id}
|
||||
selectedToken={selectedToken}
|
||||
selectedTerritory={selectedTerritory}
|
||||
onSelectToken={setSelectedToken}
|
||||
onSelectTerritory={setSelectedTerritory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Uses only the current projected supply and destinations, including the placed HMS. */
|
||||
export function EcazPlacementChoice({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
id,
|
||||
selectedToken,
|
||||
selectedTerritory,
|
||||
onSelectToken,
|
||||
onSelectTerritory,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
id: string;
|
||||
selectedToken: string;
|
||||
selectedTerritory: string;
|
||||
onSelectToken: (token: string) => void;
|
||||
onSelectTerritory: (territory: string) => void;
|
||||
}) {
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
const state = game.ambassadors;
|
||||
if (
|
||||
@@ -221,14 +255,7 @@ export function EcazPlacement({
|
||||
)
|
||||
return null;
|
||||
const supply = state.tokens.filter((token) => token.zone === 'supply');
|
||||
const destinations = TERRITORIES.filter(
|
||||
(territory) =>
|
||||
territory.type === 'stronghold' &&
|
||||
!territory.sectors.includes(game.storm) &&
|
||||
!state.tokens.some(
|
||||
(token) => token.zone === 'placed' && token.location === territory.id,
|
||||
),
|
||||
);
|
||||
const destinations = state.destinations;
|
||||
const token = supply.find((candidate) => candidate.id === selectedToken);
|
||||
const destination = destinations.find(
|
||||
(candidate) => candidate.id === selectedTerritory,
|
||||
@@ -254,7 +281,7 @@ export function EcazPlacement({
|
||||
className="min-h-11 w-full"
|
||||
value={token?.id ?? ''}
|
||||
disabled={busy || state.blocked}
|
||||
onChange={(event) => setSelectedToken(event.target.value)}
|
||||
onChange={(event) => onSelectToken(event.target.value)}
|
||||
>
|
||||
<option value="">Choose from your supply</option>
|
||||
{supply.map((candidate) => (
|
||||
@@ -279,12 +306,13 @@ export function EcazPlacement({
|
||||
className="min-h-11 w-full"
|
||||
value={destination?.id ?? ''}
|
||||
disabled={busy || state.blocked}
|
||||
onChange={(event) => setSelectedTerritory(event.target.value)}
|
||||
onChange={(event) => onSelectTerritory(event.target.value)}
|
||||
>
|
||||
<option value="">Choose a stronghold outside the storm</option>
|
||||
<option value="">Choose an available stronghold</option>
|
||||
{destinations.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
{candidate.sheltered ? ' · sheltered from the storm' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -301,7 +329,7 @@ export function EcazPlacement({
|
||||
)}
|
||||
{!destinations.length && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
No ordinary stronghold is currently available for an Ambassador.
|
||||
No stronghold is currently available for an Ambassador.
|
||||
</p>
|
||||
)}
|
||||
{!affordable && (
|
||||
@@ -312,6 +340,7 @@ export function EcazPlacement({
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="ecaz-ambassador-placement-submit"
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
@@ -326,10 +355,13 @@ export function EcazPlacement({
|
||||
Place Ambassador · {state.nextCost} spice
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="ecaz-ambassador-placement-finish"
|
||||
variant="outline"
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
onClick={() => {
|
||||
if (!busy) act({ type: 'decision', decline: true });
|
||||
}}
|
||||
>
|
||||
Finish Ambassador placement
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function EcazDukeAcquisitionChoices({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
!entry ||
|
||||
entry.stage !== 'offer' ||
|
||||
entry.effect !== 'ecaz' ||
|
||||
entry.owner !== game.me ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me
|
||||
)
|
||||
return null;
|
||||
|
||||
const recipients = entry.beneficiaries.length
|
||||
? entry.beneficiaries
|
||||
: [{
|
||||
player: entry.owner,
|
||||
blocked: entry.dukeAcquisition
|
||||
? entry.dukeAcquisition.blocked
|
||||
: 'Duke acquisition is unavailable for this opportunity.',
|
||||
}];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-label="Ecaz Ambassador Duke acquisition">
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Acquire Duke Vidal for a listed recipient. The Ambassador returns to
|
||||
your supply.
|
||||
</p>
|
||||
<p className="m-0 text-sm leading-6" aria-label="Provisional shared Duke tenure">
|
||||
Provisional ordinary acquisition: the chosen recipient keeps Duke Vidal
|
||||
until battle use, including when unused beyond this turn, unless another
|
||||
rule takes him away. A recipient holding him under a different temporary
|
||||
tenure can renew that control with the Ambassador. The separate Duke
|
||||
loan after a new alliance lasts for the current turn. Only Ecaz may revive him.
|
||||
</p>
|
||||
{recipients.map((recipient) => {
|
||||
const blocked = recipient.blocked;
|
||||
const disabled = busy || !!blocked;
|
||||
const reasonId = `ecaz-duke-${entry.event}-${recipient.player}-blocked`;
|
||||
const name = game.players.find((player) => player.id === recipient.player)?.name ?? recipient.player;
|
||||
return (
|
||||
<div key={recipient.player} className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
data-testid={`ecaz-duke-acquisition-${recipient.player}`}
|
||||
disabled={disabled}
|
||||
aria-describedby={blocked ? reasonId : undefined}
|
||||
onClick={() => {
|
||||
if (!disabled)
|
||||
act({
|
||||
type: 'decision',
|
||||
event: entry.event,
|
||||
trigger: true,
|
||||
beneficiary: recipient.player,
|
||||
choice: 'duke',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Acquire Duke Vidal for {name}
|
||||
</Button>
|
||||
{blocked && <p id={reasonId} className="m-0 text-sm leading-6">{blocked}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+98
-34
@@ -13,6 +13,7 @@ import { LeaderInspector } from './leader-inspector';
|
||||
import { AmbassadorInspector, AMBASSADOR_COVERAGE } from './ecaz-ambassadors';
|
||||
import { AmbassadorMovement } from './ambassador-movement';
|
||||
import { AmbassadorShipment } from './ambassador-shipment';
|
||||
import { EcazDukeAcquisitionChoices } from './ecaz-duke-acquisition-choices';
|
||||
|
||||
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
|
||||
|
||||
@@ -32,7 +33,7 @@ export function EcazEntry({
|
||||
!entry ||
|
||||
game.decision?.kind !== 'ecazAmbassador' ||
|
||||
game.decision.player !== game.me ||
|
||||
!['offer', 'allianceReply', 'copy', 'cards', 'move', 'ship'].includes(
|
||||
!['offer', 'allianceReply', 'loan', 'copy', 'cards', 'move', 'ship', 'revival'].includes(
|
||||
entry.stage,
|
||||
)
|
||||
)
|
||||
@@ -51,11 +52,6 @@ export function EcazEntry({
|
||||
(choice) => !choice.blocked && selected.includes(choice.card.id),
|
||||
);
|
||||
const legalSelection = entry.effect === 'choam' || chosen.length === 1;
|
||||
const dukeBlock =
|
||||
entry.dukeAcquisition?.blocked ??
|
||||
(entry.dukeAcquisition
|
||||
? null
|
||||
: 'Duke acquisition is unavailable for this opportunity.');
|
||||
const allianceBlock =
|
||||
entry.allianceOffer?.blocked ??
|
||||
(entry.allianceOffer ? null : 'This alliance opportunity is unavailable.');
|
||||
@@ -72,16 +68,18 @@ export function EcazEntry({
|
||||
{game.players.find((p) => p.id === entry.entrant)?.name} entered this
|
||||
stronghold. {guide.gameplay}
|
||||
</p>
|
||||
{game.advanced && entry.effect === 'ecaz' && (
|
||||
<p className="fine">
|
||||
Advanced source override: supplied rulebook, physical page 30, adds an
|
||||
available Duke after accepted alliance consent automatically. The
|
||||
publisher token guide’s optional loan wording above remains Basic guidance.
|
||||
</p>
|
||||
)}
|
||||
{token && <AmbassadorInspector token={token} />}
|
||||
{entry.stage === 'offer' ? (
|
||||
<>
|
||||
{entry.effect === 'ecaz' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Acquire Duke Vidal for Ecaz. The Ambassador returns to your
|
||||
supply. Ecaz keeps Duke until battle use or until Moritani takes
|
||||
him.
|
||||
</p>
|
||||
{duke && (
|
||||
<LeaderInspector
|
||||
identity={{
|
||||
@@ -92,26 +90,7 @@ export function EcazEntry({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !!dukeBlock}
|
||||
aria-describedby={dukeBlock ? `${id}-duke-block` : undefined}
|
||||
onClick={() => {
|
||||
if (!dukeBlock)
|
||||
send({
|
||||
trigger: true,
|
||||
beneficiary: entry.owner,
|
||||
choice: 'duke',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Acquire Duke Vidal for Ecaz
|
||||
</Button>
|
||||
{dukeBlock && (
|
||||
<p id={`${id}-duke-block`} className="m-0 text-sm leading-6">
|
||||
{dukeBlock}
|
||||
</p>
|
||||
)}
|
||||
<EcazDukeAcquisitionChoices game={game} act={act} busy={busy} />
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !!allianceBlock}
|
||||
@@ -141,7 +120,10 @@ export function EcazEntry({
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Both factions must be unallied. The entrant chooses whether to
|
||||
accept. The triggered Ambassador returns to supply even if the
|
||||
offer is refused. Duke loans remain unfinished.
|
||||
offer is refused.{' '}
|
||||
{game.advanced
|
||||
? 'In Advanced, accepting also adds an available Duke to the entrant’s pool automatically. The first version uses one-battle tenure and sets an unused living Duke aside at turn end.'
|
||||
: 'In Basic, an accepted alliance can be followed by an optional Duke loan when the disc is available.'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -194,8 +176,11 @@ export function EcazEntry({
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{game.players.find((p) => p.id === entry.owner)?.name} offers you an
|
||||
alliance. Accepting activates both factions’ alliance abilities
|
||||
immediately. Duke Vidal is not included in this offer. Your
|
||||
remaining actions resume after your reply.
|
||||
immediately.{' '}
|
||||
{game.advanced
|
||||
? 'An available Duke Vidal is added to your pool automatically, without a second acknowledgement. One-battle tenure is provisional; an unused living Duke is set aside at turn end.'
|
||||
: 'In Basic, Ecaz can then optionally lend an available Duke Vidal.'}{' '}
|
||||
Your remaining actions then resume.
|
||||
</p>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
@@ -213,6 +198,19 @@ export function EcazEntry({
|
||||
Refuse alliance
|
||||
</Button>
|
||||
</>
|
||||
) : entry.stage === 'loan' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Lend Duke Vidal to your new ally for this turn, or continue without
|
||||
lending him. Basic prototype: one battle; an unused living Duke is
|
||||
set aside at turn end. Return and capture interactions remain provisional.
|
||||
</p>
|
||||
<Button className={buttonClass} disabled={busy || !!entry.dukeLoan?.blocked}
|
||||
onClick={() => send({ loan: true })}>Lend Duke Vidal to the new ally</Button>
|
||||
{entry.dukeLoan?.blocked && <p>{entry.dukeLoan.blocked}</p>}
|
||||
<Button variant="outline" className={buttonClass} disabled={busy}
|
||||
onClick={() => send({ loan: false })}>Continue without lending Duke</Button>
|
||||
</>
|
||||
) : entry.stage === 'copy' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
@@ -254,6 +252,72 @@ export function EcazEntry({
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : entry.stage === 'revival' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Revive one eligible leader or return up to {entry.revival?.maximum ?? 0}
|
||||
{' '}physical forces for free. This independent grant leaves ordinary
|
||||
allowances unchanged provisionally. When Tleilaxu is seated, a
|
||||
positive return can earn it one bank spice, sharing the beneficiary’s
|
||||
once-per-turn free-revival reward. Canceling income keeps the return.
|
||||
This income interpretation is provisional. First-death leaders and
|
||||
the first-killed Kwisatz Haderach need not wait for the ordinary
|
||||
revival cohort. Further returns must satisfy their ordinary death
|
||||
condition under the provisional interpretation. Kwisatz Haderach
|
||||
keeps its separate next-cohort marker.
|
||||
Other revival interactions remain under development.
|
||||
</p>
|
||||
{entry.revival?.leaders.some(leader => leader.destination === 'setAside') && <p className="m-0 text-sm leading-6">
|
||||
Duke returns alive to the shared area, unclaimed. A separate legal
|
||||
acquisition is needed before he can fight.
|
||||
</p>}
|
||||
{entry.revival?.blocked && (
|
||||
<p className="m-0 text-sm leading-6">{entry.revival.blocked}</p>
|
||||
)}
|
||||
{!entry.revival?.blocked && entry.revival?.leaders.map(leader => (
|
||||
<Button key={leader.id} variant="outline" className={buttonClass}
|
||||
disabled={busy} onClick={() => send({ leader: leader.id })}>
|
||||
Revive {leader.name} for free
|
||||
</Button>
|
||||
))}
|
||||
{!entry.revival?.blocked &&
|
||||
(entry.revival?.maximum ?? 0) > 0 && (
|
||||
<fieldset
|
||||
className="flex min-w-0 flex-col gap-2 border-0 p-0"
|
||||
disabled={busy}
|
||||
>
|
||||
<legend className="text-sm">Forces to return</legend>
|
||||
{Array.from(
|
||||
{ length: entry.revival!.maximum },
|
||||
(_, index) => index + 1,
|
||||
).map((count) => (
|
||||
<Button
|
||||
key={count}
|
||||
variant="outline"
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
send({
|
||||
forces: count,
|
||||
elite: Math.min(entry.revival!.eliteMaximum, count),
|
||||
})
|
||||
}
|
||||
>
|
||||
Return {count} {count === 1 ? 'force' : 'forces'}
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
{entry.revival?.eliteBlock && <p className="fine">{entry.revival.eliteBlock}</p>}
|
||||
<Button
|
||||
variant="outline"
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() => send({ decline: true })}
|
||||
>
|
||||
Return no forces
|
||||
</Button>
|
||||
</>
|
||||
) : entry.stage === 'ship' ? (
|
||||
<AmbassadorShipment
|
||||
key={entry.event}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from 'next/link';
|
||||
import { leaders } from '@/game/cards';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { LeaderInspector } from './leader-inspector';
|
||||
@@ -16,7 +15,7 @@ export function EcazLoyaltyCard({ loyalty }: { loyalty: GameView['ecazLoyalty']
|
||||
<p className="text-base! leading-7!">This Ecaz Traitor Card was randomly set aside face up before dealing. It stays outside the deck for this game and cannot be drawn as a Traitor or Face Dancer. Its leader remains in play under the normal leader rules.</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<LeaderInspector kind="loyalty" identity={{ id: leader.id, name: leader.name, factionName: 'Ecaz', strength: leader.strength }} />
|
||||
<Link className="py-3 text-base underline" href="/rules?topic=ecaz-loyalty#ecaz-loyalty">Loyalty rules</Link>
|
||||
<a className="py-3 text-base underline" href="/rules?topic=ecaz-loyalty#ecaz-loyalty">Loyalty rules</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { ecazOccupyLeadOptions, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function EcazOccupyLeadChoice({ game, act, busy }: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const options = ecazOccupyLeadOptions(game);
|
||||
const occupy = game.battle?.ecazOccupy;
|
||||
if (!occupy || !options.length) return null;
|
||||
const decision = game.decision?.kind === 'ecazBattleLead' ? game.decision : null;
|
||||
const chooser = game.players.find((player) => player.id === decision?.player)?.name ?? decision?.player ?? 'The current chooser';
|
||||
const ecaz = game.players.find((player) => player.id === occupy.ecaz)?.name ?? 'Ecaz';
|
||||
const ally = game.players.find((player) => player.id === occupy.ally)?.name ?? 'Ecaz’s ally';
|
||||
return <section aria-label="Ecaz combined-army lead" className="space-y-3">
|
||||
<p>
|
||||
{chooser} chooses who forms the plan. The lead uses their own leaders,
|
||||
Treachery cards and spice. With Occupy active, {ally} supplies the variable
|
||||
dialed army regardless of who leads; Ecaz’s contribution is mandatory and free.
|
||||
</p>
|
||||
{decision?.lowEcaz && <p className="fine">
|
||||
{ecaz} was at Low population when this battle opened, so its Homeworld penalty gives the opponent this choice.
|
||||
</p>}
|
||||
{!game.advanced && <p className="fine">
|
||||
Basic prototype: Ecaz’s half-force contribution and winning losses round up;
|
||||
survivors round down. The main printed rule is used provisionally because
|
||||
its odd-force FAQ example conflicts.
|
||||
</p>}
|
||||
<p className="fine">
|
||||
Karama may cancel Occupy after this choice, before battle powers and plans.
|
||||
The chosen lead then dials only their own army; the other ally contributes zero.
|
||||
</p>
|
||||
{options.map((option) => <div key={option.lead}>
|
||||
<Button className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy || !!option.blocked}
|
||||
onClick={() => { if (!busy && !option.blocked) act(option.action); }}>
|
||||
{option.label} leads · their leaders, cards and spice
|
||||
</Button>
|
||||
{option.blocked && <p className="fine">{option.blocked}</p>}
|
||||
</div>)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** Public owner labels belong above the existing native plan/power editor. */
|
||||
export function EcazOccupyBattleSummary({ game }: { game: GameView }) {
|
||||
const occupy = game.battle?.ecazOccupy;
|
||||
const profile = occupy?.profile;
|
||||
if (!occupy || !profile) return null;
|
||||
const lead = game.players.find((player) => player.id === profile.planOwner)?.name ?? profile.planOwner;
|
||||
const forceOwner = game.players.find((player) => player.id === profile.forceOwner)?.name ?? profile.forceOwner;
|
||||
const other = game.players.find((player) => player.id ===
|
||||
(profile.lead === profile.ecaz ? profile.ally : profile.ecaz))?.name ?? 'The other ally';
|
||||
return <section aria-label="Ecaz battle army and plan owners" className="notice space-y-2">
|
||||
<p><strong>{lead}</strong> forms the plan, chooses their leaders and cards, and pays any spice support.</p>
|
||||
{profile.canceled ? <p>
|
||||
Occupy canceled: {forceOwner} dials their own army. {other} contributes zero;
|
||||
there is no fixed Ecaz strength. Co-occupancy permission is unchanged.
|
||||
</p> : <p>
|
||||
The variable dial uses <strong>{forceOwner}’s army</strong>, including its native force properties.
|
||||
The total dial must include <strong>{profile.fixedEcazDial} fixed Ecaz strength</strong> at full strength,
|
||||
with no spice cost, plus the chosen variable army strength.
|
||||
</p>}
|
||||
{!game.advanced && !profile.canceled && profile.ecazForces.normal % 2 !== 0 &&
|
||||
<p className="fine">Provisional Basic rounding: {profile.fixedEcazDial} Ecaz
|
||||
counters contribute and are lost on an ordinary win; the remainder survive.</p>}
|
||||
{game.response?.kind === 'ecazOccupy' && <p className="fine">
|
||||
Allow this power to keep the combined army, or use an eligible Karama below.
|
||||
Cancellation keeps {lead} as lead and replaces the variable army with {lead}’s own forces before planning.
|
||||
</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** Render next to the native total wheel/support input, not a second plan editor. */
|
||||
export function EcazOccupyDialExplanation({ game, dial, support }: {
|
||||
game: GameView;
|
||||
dial: number;
|
||||
support: number;
|
||||
}) {
|
||||
const control = ecazOccupyPlanControl(game, dial, support);
|
||||
if (!control) return null;
|
||||
const forceOwner = game.players.find((player) => player.id === control.profile.forceOwner)?.name ?? control.profile.forceOwner;
|
||||
return <div aria-label="Ecaz total dial and payment" className="space-y-2">
|
||||
<p className="fine">
|
||||
Total dial {dial} = fixed Ecaz {control.fixedEcazDial} + {forceOwner}’s variable strength{' '}
|
||||
{control.variableDial ?? '—'}. Spice support applies only to the variable army.
|
||||
Your personal spice: {control.personalSpice}. Current legal support budget: {control.supportMaximum}.
|
||||
</p>
|
||||
{control.blocked && <output className="notice">{control.blocked}</output>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action } from '@/game/engine';
|
||||
import type { EcazSpecialKaramaQuote } from '@/game/ecaz-special-karama';
|
||||
import { Button } from './ui/button';
|
||||
import { CardInspector } from './card-inspector';
|
||||
|
||||
/** The parent supplies this private quote only to its Ecaz owner. */
|
||||
export function EcazSpecialKarama({
|
||||
quote,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
quote: EcazSpecialKaramaQuote | null;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [selection, setSelection] = useState('');
|
||||
if (!quote) return null;
|
||||
const karama = quote.cards.find((card) => card.id === selection) ?? quote.cards[0];
|
||||
const reason = quote.blocked ??
|
||||
(!quote.event ? 'No current battle event is available.' :
|
||||
!karama ? 'You need an available Karama card.' : null);
|
||||
return (
|
||||
<details className="my-4" open={quote.declared}>
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Ecaz special Karama: leader-disc difference
|
||||
</summary>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<p>
|
||||
Before Battle Plans are revealed, spend one Karama to declare your
|
||||
once-per-game special power. Your Battle Plan must contain neither a
|
||||
weapon nor a defense. You may declare before sealing your plan.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Provisional prototype policy, not a publisher ruling: after reveal,
|
||||
the absolute difference between the printed or copied leader-disc
|
||||
values adds virtual dial strength. Other leader modifiers are excluded.
|
||||
This adds no physical forces, spice support payments or casualties.
|
||||
The bonus is not quoted from your opponent’s hidden plan.
|
||||
</p>
|
||||
{quote.declared ? (
|
||||
<output className="notice">
|
||||
Declared for this battle. Keep both your weapon and defense slots
|
||||
empty; the leader-disc bonus is determined after reveal.
|
||||
</output>
|
||||
) : (
|
||||
<>
|
||||
{quote.cards.length > 0 && (
|
||||
<>
|
||||
<label htmlFor={`${id}-card`}>Karama to spend</label>
|
||||
<select
|
||||
id={`${id}-card`}
|
||||
className="min-h-11"
|
||||
value={karama?.id ?? ''}
|
||||
disabled={busy || !!reason}
|
||||
onChange={(event) => setSelection(event.target.value)}
|
||||
>
|
||||
{quote.cards.map((card) => (
|
||||
<option value={card.id} key={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{karama && <CardInspector card={karama} />}
|
||||
</>
|
||||
)}
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="notice">
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && quote.event && karama)
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karama.id,
|
||||
event: quote.event,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Spend Karama to declare the Ecaz leader-disc bonus
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,13 @@ export function EliteCount({
|
||||
value,
|
||||
onChange,
|
||||
max,
|
||||
min = 0,
|
||||
label = 'Elite forces included',
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
max: number;
|
||||
min?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
@@ -19,7 +21,7 @@ export function EliteCount({
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { fighterCount } from '@/game/advisors';
|
||||
import { validLocation } from '@/game/board';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export interface FaceDanceActionsProps {
|
||||
game: GameView;
|
||||
sources: Record<string, number>;
|
||||
sector: number;
|
||||
busy: boolean;
|
||||
act: (action: Action) => void;
|
||||
}
|
||||
|
||||
/** The existing public survivor count and the caller's own selectable forces. */
|
||||
export function faceDanceForceChoices(game: GameView) {
|
||||
const d = game.decision;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
const world =
|
||||
d?.kind === 'faceDance' && d.player === game.me
|
||||
? game.combatLocations?.find(
|
||||
(place) => place.id === d.territory && place.kind === 'homeworld',
|
||||
)
|
||||
: undefined;
|
||||
const foreign = d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined;
|
||||
const maximum =
|
||||
d?.kind === 'faceDance' && d.player === game.me && me
|
||||
? world
|
||||
? (foreign?.normal ?? 0) + (foreign?.elite ?? 0)
|
||||
: d.winners
|
||||
? game.players.reduce(
|
||||
(sum, player) =>
|
||||
sum +
|
||||
(d.winners!.includes(player.id)
|
||||
? fighterCount(player, d.territory)
|
||||
: 0),
|
||||
0,
|
||||
)
|
||||
: fighterCount(
|
||||
game.players.find((player) => player.id === d.winner)!,
|
||||
d.territory,
|
||||
)
|
||||
: 0;
|
||||
const availableSources: Record<string, number> =
|
||||
d?.kind === 'faceDance' && d.player === game.me && me
|
||||
? world
|
||||
? me.forces
|
||||
: { reserves: me.reserves, ...me.forces }
|
||||
: {};
|
||||
return { world, maximum, availableSources };
|
||||
}
|
||||
|
||||
/** Hook-free so the actual guarded callbacks can be exercised directly. */
|
||||
export function FaceDanceActions({
|
||||
game,
|
||||
sources,
|
||||
sector,
|
||||
busy,
|
||||
act,
|
||||
}: FaceDanceActionsProps) {
|
||||
const d = game.decision;
|
||||
const owner = d?.kind === 'faceDance' && d.player === game.me;
|
||||
const me = owner ? game.players.find((player) => player.id === game.me) : undefined;
|
||||
const held = me?.faceDancers?.some(
|
||||
(card) => !card.revealed && d?.kind === 'faceDance' && card.leader === d.identity,
|
||||
);
|
||||
const { world, maximum, availableSources } = faceDanceForceChoices(game);
|
||||
const selected =
|
||||
sources && typeof sources === 'object' && !Array.isArray(sources)
|
||||
? Object.entries(sources)
|
||||
: null;
|
||||
const validCounts =
|
||||
selected !== null &&
|
||||
selected.every(
|
||||
([key, count]) =>
|
||||
Object.hasOwn(availableSources, key) &&
|
||||
Number.isSafeInteger(count) &&
|
||||
count >= 0 &&
|
||||
count <= availableSources[key],
|
||||
);
|
||||
const total = validCounts ? selected.reduce((sum, [, count]) => sum + count, 0) : null;
|
||||
const validSources = validCounts &&
|
||||
Number.isSafeInteger(total) &&
|
||||
total! <= maximum;
|
||||
const revealDisabled =
|
||||
busy ||
|
||||
!owner ||
|
||||
!me ||
|
||||
!held ||
|
||||
!!(d?.kind === 'faceDance' && d.blocked) ||
|
||||
!validSources ||
|
||||
(!world &&
|
||||
!(d?.kind === 'faceDance' && Number.isSafeInteger(sector) && validLocation(d.territory, sector)));
|
||||
const declineDisabled = busy || !owner || !me;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
data-testid="face-dance-reveal"
|
||||
disabled={revealDisabled}
|
||||
onClick={() => {
|
||||
if (!revealDisabled)
|
||||
act({
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
sources,
|
||||
...(world ? {} : { sector }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reveal Face Dancer
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="face-dance-decline"
|
||||
variant="outline"
|
||||
disabled={declineDisabled}
|
||||
onClick={() => {
|
||||
if (!declineDisabled) act({ type: 'decision', reveal: false });
|
||||
}}
|
||||
>
|
||||
Decline Face Dancer
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,35 +2,31 @@
|
||||
import { useState } from 'react';
|
||||
import { territory, splitLocation } from '@/game/board';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
import { FaceDanceActions, faceDanceForceChoices } from './face-dance-actions';
|
||||
import { Input } from './ui/input';
|
||||
export function FaceDanceDecision({
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
|
||||
export function FaceDanceDecision(props: Props) {
|
||||
const { game } = props;
|
||||
const d = game.decision;
|
||||
if (
|
||||
d?.kind !== 'faceDance' ||
|
||||
d.player !== game.me ||
|
||||
!game.players.some((player) => player.id === game.me)
|
||||
)
|
||||
return null;
|
||||
return <FaceDanceChoice key={`${d.territory}:${d.winner}:${d.identity}`} {...props} />;
|
||||
}
|
||||
|
||||
function FaceDanceChoice({
|
||||
game: g,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (a: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
}: Props) {
|
||||
const d = g.decision;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const world =
|
||||
d?.kind === 'faceDance'
|
||||
? g.combatLocations?.find(
|
||||
(place) => place.id === d.territory && place.kind === 'homeworld',
|
||||
)
|
||||
: undefined;
|
||||
const foreign =
|
||||
d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined;
|
||||
const maximum =
|
||||
d?.kind === 'faceDance'
|
||||
? world
|
||||
? (foreign?.normal ?? 0) + (foreign?.elite ?? 0)
|
||||
: Object.entries(g.players.find((p) => p.id === d.winner)!.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === d.territory)
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
: 0;
|
||||
const { world, maximum, availableSources } = faceDanceForceChoices(g);
|
||||
const [sources, setSources] = useState<Record<string, number>>(
|
||||
world
|
||||
? {}
|
||||
@@ -46,9 +42,9 @@ export function FaceDanceDecision({
|
||||
(c) => !c.revealed && c.leader === d.identity,
|
||||
);
|
||||
const total = Object.values(sources).reduce((sum, n) => sum + n, 0);
|
||||
const availableSources = world
|
||||
? me.forces
|
||||
: { reserves: me.reserves, ...me.forces };
|
||||
const emperorReturn = g.advanced && !!g.homeworlds?.worlds &&
|
||||
g.players.some((player) => player.faction === 'emperor' &&
|
||||
(player.id === d.winner || d.winners?.includes(player.id)));
|
||||
return (
|
||||
<>
|
||||
<h2>Face Dancer opportunity</h2>
|
||||
@@ -62,12 +58,19 @@ export function FaceDanceDecision({
|
||||
{d.blocked}
|
||||
</output>
|
||||
)}
|
||||
{emperorReturn && (
|
||||
<p className="fine" aria-label="Provisional Emperor Face Dance return">
|
||||
Provisional return: the Emperor’s normal forces return to Kaitain;
|
||||
Sardaukar return to Salusa Secundus. This is a reserve return with no
|
||||
revival fee or income, new battle or additional battle reward.
|
||||
</p>
|
||||
)}
|
||||
{held ? (
|
||||
<>
|
||||
<p>
|
||||
Return the winner’s {maximum} remaining forces to reserves. Replace
|
||||
up to {maximum} with your forces. Their leader goes to the tanks
|
||||
without a spice bounty.
|
||||
Return the {d.winners ? 'winning allied side’s' : 'winner’s'} {maximum} remaining
|
||||
forces to reserves. Replace up to {maximum} with your forces. The
|
||||
selected winning leader goes to the tanks without a spice bounty.
|
||||
</p>
|
||||
{world && (
|
||||
<p>
|
||||
@@ -113,40 +116,11 @@ export function FaceDanceDecision({
|
||||
<p>
|
||||
{total} / {maximum} replacement forces selected
|
||||
</p>
|
||||
<Button
|
||||
disabled={
|
||||
busy ||
|
||||
!!d.blocked ||
|
||||
total > maximum ||
|
||||
Object.entries(sources).some(
|
||||
([key, n]) =>
|
||||
n < 0 ||
|
||||
!Number.isSafeInteger(n) ||
|
||||
n > (availableSources[key] ?? 0),
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
sources,
|
||||
...(world ? {} : { sector }),
|
||||
})
|
||||
}
|
||||
>
|
||||
Reveal Face Dancer
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>You have no unrevealed Face Dancer matching the winning leader.</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', reveal: false })}
|
||||
>
|
||||
Decline Face Dancer
|
||||
</Button>
|
||||
<FaceDanceActions game={g} sources={sources} sector={sector} busy={busy} act={act} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/** Lobby allocation uses public seat identities only. The engine owns the
|
||||
* randomized order and validates every pick again at commitment. */
|
||||
export function FactionAllocation({ game, send, busy }: {
|
||||
game: GameView;
|
||||
send: (action: Action) => Promise<void>;
|
||||
busy: boolean;
|
||||
}) {
|
||||
if (game.status !== 'lobby') return null;
|
||||
const draft = game.factionDraft;
|
||||
const isHost = game.host === game.me;
|
||||
const seatName = (id: string) => {
|
||||
const player = game.players.find((candidate) => candidate.id === id);
|
||||
return player ? `${player.name}${player.bot ? ' (AI)' : ''}` : 'Unknown player';
|
||||
};
|
||||
if (!draft) return <section className="notice" aria-label="Faction allocation">
|
||||
<h2>Choose factions</h2>
|
||||
<p>Players can choose their own factions. The host can also randomly deal factions or start a prediction-card draft.</p>
|
||||
{isHost && <>
|
||||
<p className="fine">Random dealing changes the current choices immediately. Draft choices replace the current factions only after everyone has picked. Either option clears human readiness.</p>
|
||||
{game.players.length < 2 && <p className="fine">At least two faction seats are needed to allocate factions.</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" disabled={busy || game.players.length < 2} onClick={() => void send({ type: 'factionAllocation', mode: 'random' })}>
|
||||
Deal random factions
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={busy || game.players.length < 2} onClick={() => void send({ type: 'factionAllocation', mode: 'draft' })}>
|
||||
Draft factions by prediction card
|
||||
</Button>
|
||||
</div>
|
||||
</>}
|
||||
</section>;
|
||||
|
||||
const next = draft.order[draft.picks.length];
|
||||
const picked = new Map(draft.picks.map((pick) => [pick.player, pick.faction]));
|
||||
const used = new Set(draft.picks.map((pick) => pick.faction));
|
||||
const available = FACTIONS.filter((faction) =>
|
||||
(faction.expansion === 'base' || game.expansions.includes(faction.expansion)) && !used.has(faction.id));
|
||||
const isTurn = next?.player === game.me;
|
||||
return <section className="notice" aria-label="Faction draft">
|
||||
<h2>Prediction-card faction draft</h2>
|
||||
<p>Highest prediction card chooses first. Existing factions remain in place until every seat has picked; other lobby changes wait for the draft to finish.</p>
|
||||
<ol>
|
||||
{draft.order.map((entry) => {
|
||||
const choice = picked.get(entry.player);
|
||||
const faction = FACTIONS.find((item) => item.id === choice);
|
||||
return <li key={entry.player}>
|
||||
{seatName(entry.player)} · prediction {entry.card}: {faction ? faction.name : next?.player === entry.player ? 'choosing now' : 'waiting'}
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
{isTurn ? <>
|
||||
<h3>Choose your faction</h3>
|
||||
<p className="fine">Choose one of the remaining factions. Each faction can be chosen only once.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{available.map((faction) => <Button key={faction.id} type="button" variant="outline" disabled={busy}
|
||||
onClick={() => void send({ type: 'factionDraftPick', event: draft.event, faction: faction.id })}>
|
||||
{faction.name}
|
||||
</Button>)}
|
||||
</div>
|
||||
</> : next && <p>Waiting for {seatName(next.player)} to choose a faction{game.players.find((player) => player.id === next.player)?.bot ? '; the AI will choose automatically' : ''}.</p>}
|
||||
{isHost && <Button type="button" variant="outline" disabled={busy} onClick={() => void send({ type: 'factionDraftCancel', event: draft.event })}>
|
||||
Cancel faction draft
|
||||
</Button>}
|
||||
</section>;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||||
import { Eye, X } from 'lucide-react';
|
||||
import { FACTIONS, faction, type FactionId } from '@/game/catalog';
|
||||
@@ -34,8 +33,8 @@ export function FactionSheet({ factionId, advanced }: { factionId: FactionId; ad
|
||||
</section>}
|
||||
<footer>
|
||||
<p>The rules and game are still being completed. This sheet describes faction powers; table controls show the choices currently available. Optional modules can add or change powers.</p>
|
||||
<Link href={`/rules?topic=faction-${factionId}#faction-${factionId}`}>Full {house.name} reference</Link>
|
||||
{advanced && <Link href={`/rules?topic=advanced-${factionId}#advanced-${factionId}`}>Advanced {house.name} reference</Link>}
|
||||
<a href={`/rules?topic=faction-${factionId}#faction-${factionId}`}>Full {house.name} reference</a>
|
||||
{advanced && <a href={`/rules?topic=advanced-${factionId}#advanced-${factionId}`}>Advanced {house.name} reference</a>}
|
||||
</footer>
|
||||
</article>;
|
||||
}
|
||||
@@ -64,7 +63,7 @@ export function FactionInspector({ factionId, advanced }: { factionId: FactionId
|
||||
<DialogTitle>{house.name} faction sheet</DialogTitle>
|
||||
<DialogDescription>Read public faction powers and alliance guidance without changing the game.</DialogDescription>
|
||||
<FactionSheetReader key={`${factionId}:${advanced}`} factionId={factionId} tableAdvanced={advanced} />
|
||||
<Link className="faction-sheet-help" href="/rules?topic=faction-sheets#faction-sheets">Faction sheet help</Link>
|
||||
<a className="faction-sheet-help" href="/rules?topic=faction-sheets#faction-sheets">Faction sheet help</a>
|
||||
</section>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
/** Human recipients consent explicitly; AI recipients accept immediately. */
|
||||
export function FactionSwap({ game, send, busy }: {
|
||||
game: GameView;
|
||||
send: (action: Action) => Promise<void>;
|
||||
busy: boolean;
|
||||
}) {
|
||||
if (game.status !== 'lobby') return null;
|
||||
|
||||
const factionName = (id: string) => FACTIONS.find((faction) => faction.id === id)?.name ?? id;
|
||||
const seatName = (id: string) => game.players.find((player) => player.id === id)?.name ?? 'Unknown player';
|
||||
const swap = game.factionSwap;
|
||||
if (swap) return <section className="notice" aria-label="Faction swap offer">
|
||||
<h2>Faction swap offer</h2>
|
||||
<p>{seatName(swap.from)} offers to exchange {factionName(swap.fromFaction)} with {seatName(swap.to)}'s {factionName(swap.toFaction)}.</p>
|
||||
<p>Both players must agree. The factions change only if {seatName(swap.to)} accepts.</p>
|
||||
<p>Lobby changes and starting the game are paused until the offer is accepted, declined, or canceled.</p>
|
||||
{game.me === swap.to && <div className="flex flex-wrap gap-2">
|
||||
<Button type="button" disabled={busy} onClick={() => void send({ type: 'factionSwapRespond', event: swap.event, accept: true })}>
|
||||
Accept swap
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void send({ type: 'factionSwapRespond', event: swap.event, accept: false })}>
|
||||
Decline swap
|
||||
</Button>
|
||||
</div>}
|
||||
{game.me === swap.from && <Button type="button" variant="outline" disabled={busy}
|
||||
onClick={() => void send({ type: 'factionSwapCancel', event: swap.event })}>
|
||||
Cancel swap offer
|
||||
</Button>}
|
||||
</section>;
|
||||
|
||||
const self = game.players.find((player) => player.id === game.me);
|
||||
const targets = game.players.filter((player) => player.id !== game.me);
|
||||
return <section className="notice" aria-label="Faction swaps">
|
||||
<h2>Swap factions</h2>
|
||||
<p>Offer to exchange factions with another player. A human recipient must accept before either faction changes.</p>
|
||||
{game.factionDraft && <p>Finish or cancel the faction draft before offering a swap.</p>}
|
||||
{game.players.some((player) => player.bot && player.id !== game.me) && <p className="fine">AI seats accept immediately. The exchange clears human readiness.</p>}
|
||||
{self && !self.bot && targets.length > 0 ? <div className="flex flex-wrap gap-2">
|
||||
{targets.map((player) => <Button key={player.id} type="button" variant="outline" disabled={busy || !!game.factionDraft}
|
||||
onClick={() => void send({ type: 'factionSwapOffer', target: player.id })}>
|
||||
{player.bot
|
||||
? `Swap immediately with ${player.name} (${factionName(player.faction)} AI)`
|
||||
: `Offer swap to ${player.name} (${factionName(player.faction)})`}
|
||||
</Button>)}
|
||||
</div> : <p className="fine">{self?.bot ? 'Only human players can offer a swap.' : 'A second seat is needed to offer a swap.'}</p>}
|
||||
</section>;
|
||||
}
|
||||
Loaded 100 of 2851 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user