Compare commits

...
Author SHA1 Message Date
Quintine 2eaaab876e Support bribe promises with Kull and Richese
Verify and publish checkpoint container / container (push) Blocked by required conditions
2026-10-29 01:08:07 +11:00
Quintine 52d932002b Support group departures in joint commitment plans
Verify and publish checkpoint container / container (push) In progress
2026-10-29 01:01:51 +11:00
Quintine 6524ed3bf2 Allow group admission with compatible commitments
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-29 00:54:36 +11:00
Quintine 0faa7923b0 Allow group departures with bribe commitments
Verify and publish checkpoint container / container (push) Successful in 10m51s
2026-10-29 00:46:59 +11:00
Quintine 3e0fa2a8a2 Honor group alliances in Truthtrance
Verify and publish checkpoint container / container (push) Successful in 11m22s
2026-10-29 00:41:56 +11:00
Quintine 9cb65a21f5 Prototype unlimited alliance variant
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-29 00:33:42 +11:00
Quintine 37acaeb9d2 Support mixed Richese Kull bid answers
Verify and publish checkpoint container / container (push) Successful in 11m38s
2026-10-29 00:28:25 +11:00
Quintine 8b577f2487 Support Richese Kull current-lot bid answers
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-29 00:25:37 +11:00
Quintine bf73c41ae0 Support Kull phase bids with Richese
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-29 00:22:11 +11:00
Quintine f667383565 Connect Kull bid answers and group transport
Verify and publish checkpoint container / container (push) Successful in 12m6s
2026-10-29 00:17:48 +11:00
Quintine 420943b890 Allow group actions after settled Truthtrance
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-29 00:12:09 +11:00
Quintine 9cc659602c Support group-funded Karama shipment pricing
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-29 00:09:16 +11:00
Quintine 40c9651b80 Support group Fremen worm protection
Verify and publish checkpoint container / container (push) Successful in 10m58s
2026-10-29 00:05:12 +11:00
Quintine 6e26ceb216 Recognize group allies in bribes and deals
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 23:57:55 +11:00
Quintine 2fe97ddd1f Support staged group Guild transport
Verify and publish checkpoint container / container (push) Successful in 12m5s
2026-10-28 23:52:21 +11:00
Quintine 478b38f5de Support group Harkonnen traitor calls
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 23:46:15 +11:00
Quintine 83ccd71821 Support group battle preparation powers
Verify and publish checkpoint container / container (push) Successful in 10m57s
2026-10-28 23:42:41 +11:00
Quintine d8d2e7207b Support staged group Fremen revival grants
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 23:37:46 +11:00
Quintine 9081249574 Support staged group Emperor aid
Verify and publish checkpoint container / container (push) Successful in 10m13s
2026-10-28 23:33:37 +11:00
Quintine b89c225d5b Support group Guild-rate reserve shipments
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 23:27:49 +11:00
Quintine 1eeeb32566 Support staged group bid funding
Verify and publish checkpoint container / container (push) Successful in 10m27s
2026-10-28 23:22:50 +11:00
Quintine 34aa64cdef Support staged group shipment funding
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 23:14:44 +11:00
Quintine 624f95dde4 Connect staged group Nexus negotiations
Verify and publish checkpoint container / container (push) Successful in 10m24s
2026-10-28 23:04:40 +11:00
Quintine 4798a3579b Connect group allied transit custody
Verify and publish checkpoint container / container (push) Successful in 10m27s
2026-10-28 22:59:38 +11:00
Quintine ea3b21f92c Extend Basic Semuta poison continuation
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 22:56:03 +11:00
Quintine 9c6b2d884b Support Basic allied No-Field transit
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 22:53:10 +11:00
Quintine 40d567a053 Support paid No-Field companions in Basic allied transit
Verify and publish checkpoint container / container (push) Successful in 10m34s
2026-10-28 22:49:42 +11:00
Quintine 8083598cef Support Basic allied No-Field transit
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 22:44:26 +11:00
Quintine 50c6201d33 Expand independent-card Semuta continuations
Verify and publish checkpoint container / container (push) Successful in 11m22s
2026-10-28 22:39:34 +11:00
Quintine 6719245f40 Connect independent-card battle and arrival reactions
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 22:34:39 +11:00
Quintine 4d769af8c7 Expand Semuta and Richese Homeworld shipments
Verify and publish checkpoint container / container (push) Successful in 10m7s
2026-10-28 22:26:47 +11:00
Quintine 1ba942138d Prototype Semuta after Recruits in Basic play
Verify and publish checkpoint container / container (push) Successful in 10m20s
2026-10-28 22:19:12 +11:00
Quintine 0e9209531a Support conditional Truthtrance before storm and spice
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 22:10:08 +11:00
Quintine e529cc2d7a Extend Truthtrance bidding and prepare alliance controls
Verify and publish checkpoint container / container (push) Successful in 11m0s
2026-10-28 22:06:55 +11:00
Quintine 77376c39d6 Prepare allied shipment quotes for larger alliances
Verify and publish checkpoint container / container (push) Canceled after 0s
2026-10-28 22:01:51 +11:00
Quintine cea310b030 Prototype charity Truthtrance and coalition rule foundations
Verify and publish checkpoint container / container (push) Successful in 10m14s
2026-10-28 21:58:30 +11:00
Quintine e896570f07 Add optional short alliance victory
Verify and publish checkpoint container / container (push) Successful in 11m2s
2026-10-28 21:40:24 +11:00
Quintine 8fb9f59106 Make mixed Homeworld HTTP fixture deterministic
Verify and publish checkpoint container / container (push) Successful in 11m55s
2026-10-28 21:30:49 +11:00
Quintine 02695aea7c Prototype conditional future battle Truthtrance
Verify and publish checkpoint container / container (push) Failing after 11m11s
2026-10-28 21:17:44 +11:00
Quintine 71009d73e3 Support Kull during Basic Guild and revival responses
Verify and publish checkpoint container / container (push) Failing after 6m28s
2026-10-28 20:57:25 +11:00
Quintine 91ce096818 Enable Kull during Basic battle preparation
Verify and publish checkpoint container / container (push) Failing after 7m45s
2026-10-28 20:47:15 +11:00
Quintine 6bf165a6aa Support Semuta after Box searches during bidding 2026-10-28 20:40:07 +11:00
Quintine 7dfdf8d593 Prepare Gitea checkpoint container workflow 2026-10-28 20:35:37 +11:00
Quintine 0406fc1869 Connect Semuta to Moritani initial battle discards 2026-10-28 20:29:19 +11:00
Quintine d0e4c177e4 Support mixed No-Field Homeworld transfers 2026-10-28 20:22:37 +11:00
Quintine 1f03ae673b Support concealed Homeworld transfers and Ecaz victory 2026-10-28 20:13:31 +11:00
Quintine 850227c26d Prototype Richese No-Fields on foreign Homeworlds 2026-10-28 20:04:03 +11:00
Quintine dd3c199e2e Keep deal deadlines current as turns advance 2026-10-28 19:38:55 +11:00
Quintine ea910e9c82 Resume suspended movement after Sapho recovery 2026-10-28 19:34:56 +11:00
Quintine 26be6fd8ee Support Sapho replay and public cache questions 2026-10-28 19:28:19 +11:00
Quintine 7d71647a90 Support future-turn public deal payments 2026-10-28 19:21:32 +11:00
Quintine bb193d8f8c Support current-turn payments in public deals 2026-10-28 19:13:17 +11:00
Quintine 974b7fd99f Carry phase bid commitments through expansion auctions 2026-10-28 18:54:28 +11:00
Quintine b64b732e50 Support early Truthtrance cache bid commitments 2026-10-28 18:44:38 +11:00
Quintine ee3e1a0ab2 Support Truthtrance questions about battle prescience 2026-10-28 18:34:11 +11:00
Quintine 7d26929abd Count mobile stronghold passengers for spice collection 2026-10-28 01:03:06 +11:00
Quintine 3edebecc89 Preserve Hajr after another seat accepts a paid deal 2026-10-28 00:57:50 +11:00
Quintine 6da6ff7f83 Support immediate payments in public deals 2026-10-28 00:54:20 +11:00
Quintine e133a5fe2b Support multiparty public deals 2026-10-28 00:48:40 +11:00
Quintine 86c51b4aa7 Prototype public deal agreements 2026-10-28 00:40:59 +11:00
Quintine c3f37a574d Support Truthtrance on Basic Ixian auction knowledge 2026-10-28 00:30:59 +11:00
Quintine 910e1f9b40 Allow faction swaps with AI seats 2026-10-28 00:25:38 +11:00
Quintine c6b4a165bd Support consensual lobby faction swaps 2026-10-28 00:22:46 +11:00
Quintine 4dc2df2513 Add random faction deal and prediction draft 2026-10-28 00:16:04 +11:00
Quintine a244aeb5f8 Support named ordinary cards in Advanced Voice 2026-10-28 00:08:16 +11:00
Quintine b00d720e40 Support AI opponents in two-faction games 2026-10-28 00:02:02 +11:00
Quintine aa6f77372f Support Truthtrance on Atreides auction inspection 2026-10-27 23:57:01 +11:00
Quintine 235c2eb03f Support Truthtrance on Atreides spice foresight 2026-10-27 23:53:13 +11:00
Quintine 24255905ed Prototype two-player four-faction setup 2026-10-27 23:48:08 +11:00
Quintine 70ef142577 Support Advanced Sapho Charity suspension 2026-10-27 23:36:27 +11:00
Quintine fcd93ca252 Support agreed extended turns in Basic and Advanced 2026-10-27 23:29:04 +11:00
Quintine a3af6b755d Prototype unanimous Advanced victory continuation 2026-10-27 23:22:58 +11:00
Quintine 3c62c284a7 Complete Advanced Revival suspension and solo win ranking 2026-10-27 23:15:16 +11:00
Quintine 812657bb35 Allow Advanced Sapho to pause a started movement turn 2026-10-27 23:08:17 +11:00
Quintine 533c651b07 Enforce Salusa free Sardaukar revival penalty 2026-10-27 23:01:54 +11:00
Quintine 1f92affea7 Allow Voice to name Portable Snooper 2026-10-27 22:49:34 +11:00
Quintine 0902289592 Offer Semuta after ready battle poison discard 2026-10-27 22:42:33 +11:00
Quintine 69bbb09dcf Support Ecaz renewal of temporary Duke tenure 2026-10-27 22:37:04 +11:00
Quintine 4e55ab9e86 Allow selective mobile stronghold spice collection 2026-10-27 22:30:48 +11:00
Quintine 31b3c1ab87 Support Truthtrance Storm dial comparisons 2026-10-27 22:21:38 +11:00
Quintine c8889e94e2 Enable Basic Semuta and Truthtrance bribe questions 2026-10-27 22:18:00 +11:00
Quintine ac4302a8ed Correct Emperor special Sardaukar revival limits 2026-10-27 22:12:36 +11:00
Quintine b750a3d3eb Expose Advanced printed Kull preview in lobbies 2026-10-27 22:05:43 +11:00
Quintine f1bef950e0 Handle Sabotage gifts at the recipient hand limit 2026-10-27 21:59:52 +11:00
Quintine e9154a90f1 Enable Basic Kull by default and suspend Sapho turns 2026-10-27 21:51:43 +11:00
Quintine f96183f350 Support native Truthtrance questions with Basic Kull 2026-10-27 21:39:42 +11:00
Quintine 1059cc1682 Support sector-specific Truthtrance movement 2026-10-27 21:27:07 +11:00
Quintine 536d334543 Support sector-specific Truthtrance shipments 2026-10-27 21:19:07 +11:00
Quintine 8006e53580 Support Truthtrance questions about storm dials 2026-10-27 21:08:02 +11:00
Quintine 56b3aeab68 Support Truthtrance battle cancellation questions 2026-10-27 20:57:00 +11:00
Quintine 813a2bacbe Correct expansion preview guidance 2026-10-27 20:48:50 +11:00
Quintine fbdf11c789 Resolve Basic Ambassador child arrivals during Terror 2026-10-27 20:40:41 +11:00
Quintine a45d81aa34 Support native Basic Ambassador and Terror arrivals 2026-10-27 20:29:47 +11:00
Quintine a07c6d8e50 Support Basic Richese Kull and Semuta preview 2026-10-27 20:23:57 +11:00
Quintine 6d50829c0c Enable Basic Kull with Ecaz and Moritani 2026-10-27 20:13:04 +11:00
Quintine 69a9330810 Enable Basic Kull preview with native Ix factions 2026-10-27 20:04:56 +11:00
Quintine cd1f726593 Fix shipment promise forecasts and movement openings 2026-10-27 19:57:01 +11:00
Quintine 21f95bdd5b Support Truthtrance bribes in Basic Kull preview 2026-10-27 19:37:38 +11:00
Quintine 92c68ec4f6 Expose Basic Kull preview in lobby 2026-10-27 19:23:23 +11:00
Quintine 2cd2a60a36 Support early Charity Truthtrance commitments 2026-10-27 19:13:48 +11:00
Quintine 72ff9db5e8 Support Truthtrance charity commitments in Basic play 2026-10-27 19:07:38 +11:00
Quintine 6a31315c95 Support Sapho Truth order during Richese cache auctions 2026-10-27 18:55:42 +11:00
Quintine 8e16542784 Preserve Sapho Truth order during ordinary auctions 2026-10-27 18:46:15 +11:00
Quintine 298c8245a6 Support Sapho ordering for simultaneous Truthtrances 2026-10-27 18:31:15 +11:00
Quintine 4690200f59 Keep forced Silent bids indistinguishable in notices 2026-10-27 18:09:16 +11:00
Quintine b64c657c1d Fix recovery assertions for revoked seats 2026-10-27 18:06:21 +11:00
Quintine 3061dc5004 Support Truthtrance in Richese Silent bidding 2026-10-27 18:01:55 +11:00
Quintine 090d764f6f Support Truthtrance in Richese Once Around bidding 2026-10-27 17:56:20 +11:00
Quintine 65a1936b94 Keep Truthtrance bid answers playable 2026-10-27 17:48:29 +11:00
Quintine 2916b4d48f Support phase-wide Truthtrance bid commitments 2026-10-27 17:44:53 +11:00
Quintine 474674f7fe Handle prior leaders in Basic battle explosions 2026-10-27 17:24:21 +11:00
Quintine c3576b2deb Resolve prior leaders in Advanced battle explosions 2026-10-27 17:20:39 +11:00
Quintine f50e436506 Allow Distrans before Richese bidding starts 2026-10-27 17:12:05 +11:00
Quintine 813a19bb1d Allow retrying canceled Richese gifts 2026-10-27 17:02:23 +11:00
Quintine e5c7e338e8 Prototype Emperor Nexus shortage handling 2026-10-27 16:57:12 +11:00
Quintine b620cea873 Preserve binding Truthtrance through Salusa Ghola 2026-10-27 16:16:30 +11:00
Quintine 53b37adf9f Renew impossible Salusa Ghola inspection answers 2026-10-27 16:01:22 +11:00
Quintine 1bb7a99836 Allow Salusa Ghola before battle commitments 2026-10-27 15:49:56 +11:00
Quintine b5fb6aeb64 Retire occupied Ecaz Duke tenure through captivity 2026-10-27 15:36:44 +11:00
Quintine f0ebdf9c87 Preserve Caladan Sneak Attack child arrivals 2026-10-27 15:05:53 +11:00
Quintine 2e4aba9630 Integrate Caladan victory placement and entry reactions 2026-10-27 14:39:27 +11:00
Quintine 97edc3eac8 Integrate Imperial Homeworld movement with Ornithopter 2026-10-27 14:06:29 +11:00
Quintine 7f999f4573 Allow concealed No-Fields in free Discovery entry 2026-10-27 13:22:14 +11:00
Quintine 11e50e7f49 Resolve concealed No-Field losses in Discovery blows 2026-10-27 13:00:03 +11:00
Quintine 933a9c1379 Support concealed No-Field Guild transport 2026-10-27 12:44:34 +11:00
Quintine e41abdaf2e Complete concealed No-Field allied separation endings 2026-10-27 12:11:45 +11:00
Quintine 57a59c1663 Resolve Family Atomics with concealed No-Field forces 2026-10-27 11:54:38 +11:00
Quintine 28df687bf9 Connect Sapho to ordered Advanced Charity 2026-10-27 11:42:00 +11:00
Quintine e1596dbcee Implement revised Advanced Family Atomics rules 2026-10-27 11:24:41 +11:00
Quintine dff0c77a5b Round CHOAM income after totaling eligible support 2026-10-27 10:59:21 +11:00
Quintine 54787c7f7b Sequence Advanced Charity from the First Player 2026-10-27 10:50:22 +11:00
Quintine 84fdd404c9 Align Advanced Ix starting cards with Harkonnen pool 2026-10-27 10:26:44 +11:00
Quintine e530d267a3 Connect trained E3 leaders to Advanced Harkonnen capture 2026-10-27 09:59:28 +11:00
Quintine c56d9c85ef Credit Banker for paid placement and disposal invoices 2026-10-27 09:38:37 +11:00
Quintine ff2826b980 Allow individual revived Fedaykin placement 2026-10-27 09:22:27 +11:00
Quintine 6a0fba860d Connect printed flight and poison plays to Truthtrance 2026-10-27 08:49:45 +11:00
Quintine b02839ce2a Bind completed Semuta exchanges to Truthtrance promises 2026-10-27 08:27:53 +11:00
Quintine 4c4bebe85e Bind printed transfer and order plays to Truthtrance 2026-10-27 08:00:08 +11:00
Quintine 3e959504ef Bind printed Box use to shared Truthtrance funding 2026-10-27 07:09:30 +11:00
Quintine 3abde581db Fund ordinary promises from native CHOAM opening income 2026-10-27 06:42:56 +11:00
Quintine 38738eabac Plan ordinary promises across one native Charity claim 2026-10-27 05:52:48 +11:00
Quintine b81823bbec Connect phase-opening Amal promises and native payment order 2026-10-27 05:08:40 +11:00
Quintine 00741a37d0 Bind Thumper promises before the original hidden Spice draw 2026-10-27 04:33:20 +11:00
Quintine fd38c51ffd Bind Truthtrance Karama promises to shipment beneficiaries 2026-10-27 03:41:27 +11:00
Quintine fd0b2f0fa5 Bind Truthtrance Karama promises to original auction lots 2026-10-27 03:07:33 +11:00
Quintine e88f871385 Bind Truthtrance card plays to original Storm and spice windows 2026-10-27 02:25:46 +11:00
Quintine 09a81a9b65 Bind Truthtrance answers to native battle power declarations 2026-10-27 01:31:56 +11:00
Quintine c93b693137 Connect Truthtrance promises to printed Hajr and Ghola plays 2026-10-27 01:06:08 +11:00
Quintine f548039cd8 Enforce ordinary auction bidding deadlines online 2026-10-27 00:07:15 +11:00
Quintine f76c8dfb93 Combine current battle decisions with turn commitments 2026-10-26 23:11:48 +11:00
Quintine 1a79fd3131 Add final Nexus alliances to combined turn commitments 2026-10-26 22:32:59 +11:00
Quintine 921b4fe1e3 Add bids and ordinary Revival to combined turn commitments 2026-10-26 21:42:45 +11:00
Quintine 0d72a33ee5 Combine current-turn payments with shipment and movement promises 2026-10-26 21:09:49 +11:00
Quintine 8dbec05844 Support earlier combined shipment and movement promises 2026-10-26 20:17:37 +11:00
Quintine d4e82c74b1 Combine native shipment and ground movement promises 2026-10-26 19:25:07 +11:00
Quintine 13398aa76e Combine current facts with native Traitor decisions 2026-10-26 18:52:06 +11:00
Quintine 812f77e348 Combine current facts with native battle plans 2026-10-26 18:07:52 +11:00
Quintine 05330ca7c9 Support whole and mixed final Nexus alliance questions 2026-10-26 17:26:05 +11:00
Quintine 0d979e4f8b Combine current facts with native bid declarations 2026-10-26 17:05:16 +11:00
Quintine 957d5a172f Combine current facts with native Revival 2026-10-26 16:53:27 +11:00
Quintine 6b2dfb84a2 Combine current facts with native bribe payments 2026-10-26 16:39:30 +11:00
Quintine b63a60da64 Combine current facts with future ground movement 2026-10-26 16:25:54 +11:00
Quintine d524642b65 Support whole AND/OR future ground moves 2026-10-26 16:09:35 +11:00
Quintine 0e3c155876 Support whole AND/OR ordinary bribe commitments 2026-10-26 15:53:57 +11:00
Quintine c81e00e6dd Allow late Mentat bribes and retain turn-end promises 2026-10-26 15:41:10 +11:00
Quintine f5ae61f382 Support grouped Truthtrance revival commitments 2026-10-26 15:16:32 +11:00
Quintine a76effb932 Add named Truthtrance leader-revival commitments 2026-10-26 14:52:17 +11:00
Quintine 5627512d16 Bind Truthtrance alliance promises to actual Nexus closure 2026-10-26 14:22:46 +11:00
Quintine 335b4ce638 Add cumulative Truthtrance bribe-payment commitments 2026-10-26 13:29:37 +11:00
Quintine 7fc21113e3 Extend Truthtrance revival promises through earlier turn phases 2026-10-26 13:09:17 +11:00
Quintine 24653799cf Add cumulative Truthtrance force-revival commitments 2026-10-26 12:57:18 +11:00
Quintine bd6e5bbf7f Add current-lot Truthtrance bid commitments 2026-10-26 12:44:00 +11:00
Quintine 4ab20a2838 Preserve World occupation capabilities while Sandtrout waits 2026-10-26 12:18:06 +11:00
Quintine 09eb857583 Initialize occupation benefits in original World module setups 2026-10-26 12:10:32 +11:00
Quintine 396f9e0ccb Initialize occupation benefits for registered World Skills starts 2026-10-26 11:59:03 +11:00
Quintine 7f9daa10fa Connect Homeworld occupation with original phase modules 2026-10-26 11:43:16 +11:00
Quintine 0a98a92f4b Compose independent Ecaz cards with selected phase modules 2026-10-26 11:18:50 +11:00
Quintine 68a7870e2e Compose selected-deck Tech and Stronghold modules 2026-10-26 11:07:56 +11:00
Quintine 82370d906f Support Sapho ordering during Advanced Revival 2026-10-26 10:48:38 +11:00
Quintine 011b6aa096 Apply player order to fresh Advanced Revival phases 2026-10-26 10:33:54 +11:00
Quintine fe6c41ba40 Reconcile independent rule prototype coverage 2026-10-26 10:15:26 +11:00
Quintine ec549028ba Enable Sandmaster collection on flight and marker routes 2026-10-26 10:11:18 +11:00
Quintine 4af90e547c Compose Planetologist movement with Ornithopter flights 2026-10-26 09:53:21 +11:00
Quintine 90c50736f2 Support concealed No-Field Planetologist gathering 2026-10-26 09:37:01 +11:00
Quintine f6e6671ac5 Support Planetologist range for concealed Richese No-Fields 2026-10-26 09:30:02 +11:00
Quintine 2c3dde51d4 Route Emperor Face Dance returns to typed native reserves 2026-10-26 09:19:54 +11:00
Quintine 4ee51917bc Expose full Truthtrance battle-plan and shipment editors 2026-10-26 09:02:16 +11:00
Quintine 8e2f2c6a95 Expose grouped Truthtrance current-fact questions 2026-10-26 08:50:28 +11:00
Quintine 2ea3c58f68 Allow Distrans before the first ordinary auction bid 2026-10-26 08:40:44 +11:00
Quintine 34dccdfd99 Allow Ecaz Ambassador Duke acquisition for an existing ally 2026-10-26 08:31:55 +11:00
Quintine cab6b41c67 Allow Caladan reinforcement before Face Dance 2026-10-26 07:58:51 +11:00
Quintine 18bf5d4423 Allow Residual Poison after Cunning inspections 2026-10-26 07:34:38 +11:00
Quintine 0c848bec74 Allow Box recovery of purchased shipment costs 2026-10-26 07:16:27 +11:00
Quintine 725672364d Allow Fremen Nexus Betrayal before native worm rides 2026-10-26 07:06:15 +11:00
Quintine 78bcf22bca Grant Duke Vidal through occupied Ecaz Homeworld 2026-10-26 06:44:16 +11:00
Quintine d5c5342d2d Enable early native-faction Truthtrance promises 2026-10-26 06:21:34 +11:00
Quintine 8053e1fb26 Allow early Advanced Truthtrance shipment promises 2026-10-26 05:57:10 +11:00
Quintine 99d442fc68 Offer Semuta on Guild and Revival Spiritual grants 2026-10-26 05:36:55 +11:00
Quintine 617a6593a9 Preserve Guild advisor arrivals and support matching Semuta costs 2026-10-26 05:23:31 +11:00
Quintine 81fbbca309 Offer Semuta on Spiritual Advisor and relocation costs 2026-10-26 05:03:01 +11:00
Quintine 92f2e8125b Offer Semuta on World, battle and Face Dance conversion costs 2026-10-26 04:50:02 +11:00
Quintine 27959b4655 Offer Semuta on transport, Gamont and Storm conversion costs 2026-10-26 04:27:22 +11:00
Quintine 06b0b354d0 Offer Semuta on native Advanced advisor conversion costs 2026-10-26 04:07:56 +11:00
Quintine 40c31a46e4 Offer Semuta on native market and allied revival cancellation costs 2026-10-26 03:49:54 +11:00
Quintine 56d55a1565 Offer Semuta on native Ecaz and Moritani cancellation costs 2026-10-26 03:36:15 +11:00
Quintine 12e3a448c6 Offer Semuta on bonus No-Field and allied Traitor cancellation costs 2026-10-26 03:10:26 +11:00
Quintine 3a01c8f1c0 Offer Semuta on native combat power cancellation costs 2026-10-26 02:41:22 +11:00
Quintine b3ea9f9c97 Offer Semuta on native Storm and worm cancellation costs 2026-10-26 02:18:55 +11:00
Quintine 4d09b6bffa Offer Semuta on pending revival benefit cancellation costs 2026-10-26 02:00:26 +11:00
Quintine d018a506e1 Offer Semuta on native movement cancellation costs 2026-10-26 01:28:26 +11:00
Quintine 30c066726b Offer Semuta on advisor, foresight and Face Dancer counters 2026-10-26 00:59:31 +11:00
Quintine 2431370abb Offer Semuta on Tleilaxu revival income cancellation costs 2026-10-26 00:34:43 +11:00
Quintine 70c95f2686 Offer Semuta on auction advantage cancellation costs 2026-10-26 00:21:13 +11:00
Quintine 0d868a3838 Offer Semuta on Richese cancellation costs 2026-10-25 23:56:09 +11:00
Quintine 8e6602633d Allow Sapho order behind a started turn 2026-10-25 23:33:04 +11:00
Quintine 3bf7d26e80 Connect Richese Nexus to Karama acquisitions 2026-10-25 23:14:17 +11:00
Quintine cf268243f3 Connect Richese Nexus to ordinary auction purchases 2026-10-25 22:40:36 +11:00
Quintine 724d9dce1e Connect revived arrivals to native Terror continuations 2026-10-25 22:04:33 +11:00
Quintine ab69f97cfa Connect revived force arrivals to Bene Gesserit responses 2026-10-25 21:36:05 +11:00
Quintine 33523d1926 Apply Low Ecaz opponent-owned battle lead choice 2026-10-25 21:04:38 +11:00
Quintine 96de962622 Compose purchased Karama rates with Richese Cunning 2026-10-25 20:51:41 +11:00
Quintine 61c269123c Combine High Richese reserves with Nexus Cunning 2026-10-25 20:35:34 +11:00
Quintine ce676f0e54 Allow Tleilaxu revival prevention for Low Homeworlds 2026-10-25 20:08:43 +11:00
Quintine 5bcf5bab48 Implement High Richese mixed No-Field shipments 2026-10-25 19:56:38 +11:00
Quintine 052737d0f9 Implement Ix High paid Cyborg revival bonus 2026-10-25 19:39:01 +11:00
Quintine 86608f7ea9 Bind current Traitor declarations with Truthtrance 2026-10-25 19:23:14 +11:00
Quintine 724eabf99c Support native Advanced and No-Field movement promises 2026-10-25 18:54:19 +11:00
Quintine 2ae2b984c0 Support classic Advanced movement Truthtrance promises 2026-10-25 18:38:02 +11:00
Quintine 0d8481e821 Counter each changing origin in a resident BG gather
Hold both physical groups until all required matching responses allow the original movement. Share normal packet denials across gather and ordinary moves, then resume one original movement suffix.
2026-10-24 23:16:02 +11:00
Quintine b2a20226d7 Counter BG matching before fresh Guild and Junction transport
Preserve original grants, private funding and unused shipments while matching is pending. Restore legal replacement choices after prevention and resume the original permission, price and transfer once.
2026-10-24 22:59:31 +11:00
Quintine c999054ae0 Counter incoming BG matching before Fremen Ambassador relocation
Bind the selected packet to its original allied grant before transfer or collection. Preserve the committed trigger, restore legal replacement choices after prevention and resume the original movement once.
2026-10-24 22:38:55 +11:00
Quintine 767bace774 Separate Spiritual Advisor shipment matching and alone conversion
Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
2026-10-24 22:28:42 +11:00
Quintine 438a5692a6 Counter incoming advisor matching before normal movement and shipment
Preserve residents and unused actions when one selected packet cannot match. Bind public matching receipts to private declarations and share packet-opportunity denials with legal bot filtering.
2026-10-24 21:50:02 +11:00
Quintine abf0305ddf Resolve advisor conversion after Ambassador and Discovery transfers
Preserve original transfer and arrival cursors, admit no new Discovery advisor sources, and offer one mandatory counter for empty Ambassador advisor arrivals.
2026-10-24 21:32:12 +11:00
Quintine d66b594770 Resume storm traversal after surviving advisor conversion
Preserve current-sector losses and original Fremen casualty priority before the mandatory BG counter. Bind public sector facts to a private continuation and resume only the remaining storm once.
2026-10-24 21:19:18 +11:00
Quintine 9959f1b347 Align notice guidance with persistent Continue controls 2026-10-24 21:14:32 +11:00
Quintine 9bce22abf5 Resolve advisor conversion after complete Face Dance replacement
Capture surviving alone BG groups at positive remote board sources and zero
replacement targets. Preserve returned armies, replacement, original leader
death, revelation and card cycle. Native Face Dance stays immune; consume the
publicly sourced parent before one original aftermath continuation.

Validation: 16 affected files201/201, types/lint/build, five native Face Dance
and GameTable SSR journeys, eight existing-profile response paths. Genuine
67/66-action sources earn original Ix47, native Face Dancers and actual battle.
Both conversion outcomes preserve nine returned survivors and one leader death;
actual BG city income is2/0. Correct a fixture's expected deck count43 to47
without changing cards or rules. Full combinations/modes remain unfinished.
Not deployed; no saved-room/D1, reset or restart operation. Runtime fingerprint
78239a9def5dd70ae404d5a614a21ea09ea615dd2a6cde7d05760715416d7401;
checkpoint prose/reference overlays are reviewed separately.
2026-10-24 21:00:58 +11:00
Quintine 0b3413fcd8 Preserve Homeworld and Junction transfers through advisor conversion
Capture surviving alone BG groups after committed native board-to-world and
Junction board transfers. Keep typed payment/custody and shipment use, private
original declaration snapshots and one original Junction arrival. Preserve
Junction immunity independently of the BG conversion counter.

Validation: 18 affected files218/218, types/lint/build, nine native transport
and GameTable SSR journeys, sixteen existing-profile response paths. Genuine
37/38/42/36-action sources earn original routes and costs3/5/10/1. Rename two
fixture variables rejected by lint without changing rule validation. Broader
combinations/full modes remain unfinished. Not deployed; no saved-room/D1,
reset or restart operation. Runtime fingerprint
5365a5d6fba829c6bcee1cb1e00977ec41769912a32180062e52af0ca59645da;
checkpoint prose/reference overlays are reviewed separately.
2026-10-24 20:49:40 +11:00
Quintine d83db397c0 Resolve advisor conversion after battle losses before optional cleanup
Preserve committed casualties, payments and mandatory disposal when battle
losses leave BG bystanders alone. Bind the public phase-six receipt to the
original battle and resume its private winner/aftermath cursor once. Record
provisional priority; explosions with no surviving advisors create no response.

Repair stale Semuta fixtures to acknowledge the existing final battle window;
a representative failure reproduces against unchanged base035be51. Production
Semuta guards stay intact.

Validation: 16 affected files264/264, types/lint/build, five native battle and
GameTable SSR journeys, eight existing-profile response paths. Genuine44-action
sources earn original cards/forces and actual Collection outcomes. Broader
combinations and complete modes remain unfinished. Not deployed; no room/D1,
reset or restart operation. Runtime fingerprint
665defe51a1f46a6b5992b6e1e4b034203f91e7101acf860eb549f5bf9865151;
checkpoint prose/reference overlays are reviewed separately.
2026-10-24 20:37:09 +11:00
Quintine 035be51915 Resolve advisor conversion after Gamont before final victory
Commit original Gamont use/return or empty revelation before its separate mandatory-conversion counter. Preserve private fuel provenance beside a public phase8 receipt, retain legacy phase5 sources and defer the genuine final chooser through both direct and Semuta cost completion. Consume the parent once; ordinary Mentat gains no chooser and victory waits for original confirmation. Existing provisional continuously-alone duration remains.

Validation: 18 files277/277, types/lint/build, five native Gamont/response/GameTable SSR journeys and eight existing-profile paths. Genuine52/56-action public Advanced sources earn a real third-stronghold win versus prevented next-turn outcome. Empty revelation remains pure-source proof; full combinations/remaining producers, browser/deployed and final assurance stay open. No saved-room/D1/restart/deployment operation or AI refinement.
2026-10-24 20:16:05 +11:00
Quintine d0f52c7bae Preserve committed Guild transport through advisor conversion responses
Offer mandatory advisor conversion prevention after Guild cross-shipment and reserve returns. Keep original permission/rate/income, payment, physical pieces and shipment use; bind the exact group to one transport parent and resume original arrival/Intrusion once. Ordinary movement stays unused and the existing provisional continuously-alone duration remains. Preserve old ground receipts, private projection behavior and all transport/module guards.

Validation: 17 files243/243, types/lint/build, eight native transport/response/GameTable SSR journeys and eight existing-profile paths. Genuine36/42-action sources earn original counters/cards and actual paid-shipment Intrusion. Preserve the unaffordable full-price cross outcome and prove the affordable return. No saved-room/D1/restart/deployment operation; broader producers, full modes and browser/deployed acceptance remain open.
2026-10-24 19:58:56 +11:00
Quintine 3aca64412f Offer Karama against mandatory advisor ground conversions
Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open.

Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
2026-10-24 19:44:12 +11:00
Quintine 1a505525a8 Open Tleilaxu special Karama throughout Revival
Permit native Advanced quiet any-player declarations alongside the original target-bound pending request. Preserve completed returns and charges. Adopt explicit provisional Ghola inclusion during the remaining Revival and phase expiry; keep printed Emperor special immunity and the existing low-Homeworld guard. Connect persistent controls and minimal legal bots through the shared quote.

Validation: 14 related files148/148, types/lint/build, six actual native control/engine/GameTable SSR journeys and four existing-profile continuations. Private-driver-only feedback reuses passed source-identical checks. Native111/113-action setup, paid shipment and battle sources earn originals. No saved-room/D1/restart/deployment operation; full modes and browser/deployed acceptance remain open.
2026-10-24 19:30:21 +11:00
Quintine 6ea691255a Add verified current Face Dancer questions to Truthtrance
Ask about a currently held unrevealed printed identity through shared facts
and AND/OR authoring. Keep ordinary Traitor meaning separate, candidate
identities public and only the aggregate answer published. Existing answer,
physical disposal and continuation flow need no new AI strategy.

Verified: 12 affected offline files86/86, types/lint and production build;
twelve actual field/question/gameplay/GameTable SSR journeys and sixteen legal
profile answers. Native Basic/Advanced sources and actual Mentat replacement
prove a current Yes creates no future reveal or retention promise.

Existing mixed-shipment admission stays unchanged. No saved-room, D1, reset,
restart or deployment operation; broader Truthtrance/mode assurance remains.
2026-10-24 19:15:46 +11:00
Quintine eff668b202 Support Moritani assassination of captured and foreign-ghola targets
Retain printed native identity and valid original custody while projecting only
uniform potential bounty before commitment. Resolve living/dead targets once,
clear selected capture/concealment and retain ordinary Ghola return custody.
Selected concealed-outcome disclosure is explicitly provisional.

Verified: 14 affected offline files100/100, types/lint and production build;
six actual choice/gameplay/GameTable SSR journeys and twelve profile paths.
Native capture source47 actions; actual paid Ghola prefix140 actions followed
by explicit conserved seven-action battle. No saved-room/D1/reset/restart or
deployment operation. Runtime-only driver fixes reused unchanged passed checks.
2026-10-24 19:03:03 +11:00
Quintine 61828150b9 Keep Atreides special inspection available until plan reveal
Allow native Advanced use after declining the first offer, including a sealed
target. Reuse projected card/target permission in persistent controls and
minimal bots; retain sealed plans and finish target-last public reveal once.

Source: authorized supplied physical27 F. Ordinary preparation priority and
target-first ordering remain provisional; no new reading acknowledgement.

Verified: 11 affected offline files126/126, types/lint, production build, five
actual control/gameplay/GameTable SSR journeys and eight legal profile paths.
No saved-room, D1, reset, restart or deployment operation.
2026-10-24 18:49:22 +11:00
Quintine 660b0a0c25 Open native advisor preparation before the first shipment
Allow Advanced BG preparation in phases2–5 at quiet boundaries, preserving
original phase/auction/readiness and native eligibility. Close a current-turn
receipt only on committed shipments, including marker and Sneak Attack entry.
Retain the legacy phase5 queue and qualify the per-territory prevention policy.
Connect persistent controls, minimal bot support and focused native evidence.
Complete modes and browser/deployed acceptance remain open.
2026-10-24 18:36:55 +11:00
Quintine 0e3d6a250e Make Fremen allied revival grants cancelable
Bind the original Fremen/ally/turn grant before Karama allowance or prevention.
A prevented grant leaves intrinsic free rates and prior payments intact. Permit
late grants under the explicit provisional Recruits current-rate policy, with
shared controls, minimal bot support and native public chronology evidence.
Complete rules and browser/deployed acceptance remain open.
2026-10-24 18:16:03 +11:00
Quintine d63efccb55 Settle CHOAM charity claims after depleted funding
Keep CHOAM's own poverty claim bank-funded. Prototype full other-faction
claims using available CHOAM cash and an explicitly provisional bank residual;
retain original eligibility, cancellation, Homeworld portions and claim limits.
Connect persistent controls and genuine public setup/bribe evidence. Complete
rules and deployed acceptance remain open.
2026-10-24 18:03:08 +11:00
Quintine 52832dc0fd Allow Ecaz Ambassadors on the placed mobile stronghold
Share dynamic stronghold legality across placement, cancellation, bots and controls. Keep the HMS sheltered, preserve original fees and entry effects, and display its public Ambassador attached to the stronghold interior.

Cover genuine Basic/Advanced placement, paid movement entry, card continuations, prevention and native relocation. No Terror or direct-shipment permission is added.
2026-10-24 17:55:16 +11:00
Quintine 659e2a4a9a Support Basic No-Field Guild Ambassador shipments
Connect native concealment and allied supplier/recipient consent to the independent grant, with explicit provisional cap and transport-fee policy. Preserve actual tokens, ordinary allowances, source-scoped Karama prevention and original income/arrival continuation.

Add genuine three/four-seat sources, focused quote/control/engine cases and bounded runtime/minimal-bot proof. Advanced and optional profiles retain their existing guards.
2026-10-24 17:36:12 +11:00
Quintine 4b2f4f3fa3 Preserve Basic shared arrival reactions through Semuta
Admit the original ordered Ecaz/Moritani entry in bare Basic Richese games, retain both native opportunities during fresh discard recovery, and bind each suspended child to its actual arrival. Show the current discard response in the table banner.

Cover real five-seat setup and placements, both owner orders, physical payouts/draws/gifts, persistent controls and minimal legal bot participation.
2026-10-24 17:17:36 +11:00
Quintine 7b9938219d Resume native Basic arrivals after Ornithopter recovery
Allow the completed flight discard in the existing bare Basic E3 profile.
Keep physical movement and its original arrival, then handle distinct
Ambassador or Terror fresh groups without replaying effects or Semuta cost.
2026-10-24 17:06:16 +11:00
Quintine 047011e243 Allow Semuta recovery before native Ghola income
Reuse the exact ordinary-discard income qualification for admission and
parent validation. Preserve physical revival and native conditional income
through claim, decline, full-hand exchange and original allow/cancel.
2026-10-24 16:48:20 +11:00
Quintine 19e554666e Admit Mirror and Stone in Basic native E3 battles
Reuse original copied-weapon and typed coalition accounting for bare Basic
Ecaz/Moritani and selected Ix. Preserve optional-module guards and physical
card identity, actual force-owner commitments and native continuations.
2026-10-24 16:34:19 +11:00
Quintine 49992d322f Include Ghola force returns in Basic movement promises
Use exact physical Ghola preparation and known/paid Box recovery without
crediting conditional income or normal revival allowances. Guide real
force return, paid shipment and qualifying ground movement; retain the
existing incomplete-mode and final acceptance boundaries.
2026-10-24 16:22:32 +11:00
Quintine c311ed4251 Support native early Basic movement promises
Price native Once Around and Silent funding in early movement forecasts, retaining full sealed donor locks and exact paid-sale boundaries. Add current zero/pass guidance, fixed non-CHOAM preopening Charity entitlement and shared owned normal-revival projection while actual counters remain pending. Preserve the mandatory Ix phase-opening continuation.

Validation: affected rules/control tests, types/lint, production build and six gameplay/control/table-rendering programmes with eight existing-profile native runs. Wider preparations, CHOAM own unresolved opening, modules/Advanced, full rules and deployed acceptance remain open. No saved-room, database, restart or deployment operation.
2026-10-24 16:05:41 +11:00
Quintine f995b6f4cd Support earlier classic Basic movement promises
Allow current-round classic Basic questions before Movement, with owned Charity, ordinary auction payment and Revival preparation. Reopen readiness, retain combined shipment obligations, reset stale allowances and release forced storm impossibility. Current-phase guidance and minimal bots preserve the route; held Karama now prepares its actual Guild shipment rate.

Validation: affected rules/control tests, types/lint, production build and four gameplay/control/table-rendering programmes. Native early invoices, wider preparations/modules, full rules and deployed acceptance remain open. No saved-game, database, restart or deployment operation.
2026-10-24 15:49:35 +11:00
Quintine 49586efd2f Support waiting-turn Basic ground-movement promises
Allow unused respondents in the current Movement queue to answer against their current owned board and resources. Isolate foreign declarations, prices, flights and searches in detached forecasting without future income/discard assumptions. Preserve native per-player Hajr history. Other players cannot fulfill or close the promise with their own moves/end. Ordinary executable guidance waits until the real owned turn.

Keep an actual already-paid Box decision actionable while waiting. Enumerate its full entitled choice, prioritizing movement cards without excluding ordinary cards that preserve an existing route; pre-payment known-discard rules remain unchanged. Preserve the original event, fee, private inspection and source continuation. Check deferred Emperor gift and Spiritual Advisor costs on their allowed outcome before another player responds. Localize native pending-movement deferral to the actual mover so another waiting owner cannot hide voluntary Box overspending. Minimal profiles may decline optional accompaniment under a positive promise.

Verify 18 files125/125, types/lint and four original-action guidance/paid-choice/table-rendering programmes. Original source19 reverses the unopened classic deal; native44 earns Box1/Hajr1 and genuinely spends Richese Truth, with Guild's actual later question supplying a second ordinary paid candidate. Eight existing-profile runs make32 accepted proposals plus12 separate neutral acknowledgements. Controlled pact/gift, BG losses, original Ornithopter acquisition and native Fremen/Box cases disclose their staging. Paired affordable/unaffordable interruptions preserve exact frames and prevent voluntary route loss.

The production build passes at the initial freeze. Only the regression gains its affordable counterpart afterward; source identity proves every application/build/dependency and other file unchanged, while final tests/types/runtime run again. Final checkpoint/reference prose overlays are separately reviewed. Earlier phases/later rounds, remaining preparations, No-Field/modules, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 15:31:17 +11:00
Quintine f0b2ac82d9 Support Basic movement promises before shipment
Allow clean active Basic ground-movement questions before or after shipment. Enumerate ordinary reserve amounts, native physical allocations, destinations and pledged payment splits through the actual declaration and shared physical commit. Detached projection stops before income/arrival effects and never counts shipment as ground fulfillment. Joint movement No and shipment Yes retain a legal next action rather than invalid End guidance.

Preserve exact pending ordinary reserve and native Guild permission/rate declarations when checking the remaining route. Voluntary wrong destination, force splitting or fee loss cannot release Yes through an opponent response. Guard unsupported sources before full speculative continuation. Existing live payment, cancellation, income and entry paths remain. Persistent busy-safe controls display reinforcement and both obligations; all four existing profiles use the same legal witness without strategy refinement.

Verify 19 files165/165, types/lint/build, six original-action gameplay/control/table-rendering programmes and four profiles with16 accepted actions. Genuine sources19/37/24/19 retain classic Arrakeen10 reinforced to11, native zero-board Emperor/CHOAM starts and original Guild5/15/5 reserve/cross transport. Second-card/counter cases explicitly transfer conserved originals. Bounded review repaired source guarding, funding completeness and exact pending-frame behavior. Stop an observed expensive search, reorder complete candidates by useful proximity and conservatively prune dead-end final moves; twelve new engine cases pass in3.2s. Correct a display-template lint issue.

Earlier phases/waiting turns, native transport/relocation preparation, No-Field/module sources, broader interactions, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 15:10:49 +11:00
Quintine b5a53da712 Support owned Box and Kulon movement-promise preparation
Extend bounded Basic ground-movement reachability to held native CHOAM Kulon and owned paid Box recovery. Before payment, use only known current-discard identities. Preserve actual paid event, full inspection, one two-spice fee, original physical card replay and existing provisional full-hand exchange. Reject a Yes-breaking selection before the server shuffle without losing the paid search. Keep No-only searches on their normal decision before movement can end.

Record movement-relevant current-discard certainty at actual disposal, paid search, mixed claim and deck rebuild boundaries without projecting raw metadata or erasing historical memory. Use deterministic preparation on private copies, include preparation state in search memoization, protect fee-funded routes from voluntary spending and keep native Kulon counter semantics. Persistent busy-safe guidance and existing legal profiles use the actual next paid action; no strategy tuning.

Verify 17 files231/231, types/lint/build, three original-action control/table-rendering programmes and four existing profiles with40 accepted actions including neutral passes. Original public sources44/25 retain actual purchases1+1, shipment costs6/1, exhausted Hajr and native Kulon route4. Separate full-hand, legacy certainty, fee-loss and acquired-counter edges disclose conserved staging. Two prior-engine Auditor fixture failures are repaired by actual battle-result acknowledgements; test-only const lint is corrected.

Other timing, preparations/counter mitigation, unknown private possibilities, optional/Advanced, full modes, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
2026-10-24 14:53:11 +11:00
Quintine edbcbe28de Enforce Basic Truthtrance ground-movement promises
Add a structured future single-movement predicate and persistent authoring, feasible-answer and promise controls for bare native Basic active post-shipment turns. Yes requires one matching physical movement while possible; No survives every later Hajr/Ornithopter opportunity until actual turn closure. Matching physical commitment precedes disposal and arrival effects. Preserve compatible promises after opposing loss and reject voluntary destruction of the last owned completion.

Reuse the actual movement declaration and physical commitment in a focused owned-action search rather than a strategic shortlist. Retain typed groups, original two-group cohorts, held Hajr, both Ornithopter modes, city access and current Kulon bonus. Deterministic preparation on private clones retains physical cost semantics without inventing response/discard events in reachability. Existing native/optional/Advanced movement retains its actual controls.

Verify 17-file218/218, types/lint/build, seven original-action gameplay/control/table-rendering programmes and all four existing legal profiles. Original public classic, paid cache Ornithopter and native allowed-Kulon sources use only unopened positions and scalar lotteries; extra-card/force-loss edges are explicitly controlled. Repair nearby stale fixture continuations to acknowledge Semuta/battle outcomes and put pre-movement Hajr in Basic. Full modes, wider preparation/timing/module coverage, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 14:32:16 +11:00
Quintine b4a556d5d3 Connect fresh Basic Semuta and Ambassador discards
Initialize the existing neutral Semuta protocol during genuine fresh public bare Basic Richese setup with selected native families. Retain original roster/deck/freshness checks, separate optional/Advanced profiles and unchanged existing saves.

Retain the original Ambassador arrival cause and admit clean ordinary shipment/movement CHOAM and Ixian private discard batches, including native BG copies. Keep CHOAM credit committed once; pause before exactly one Ix replacement. Existing owner-only commitment/selection, physical Semuta/full-hand exchange and original entrant continuation remain.

Verify original public setup, actual one-spice Semuta auction, ordinary purchase, paid placement, paid shipment and actual movement. Exercise claim/pass controls, busy protection and all four existing legal profiles without strategic tuning. Repair nearby stale tests to acknowledge actual battle outcomes and retain the existing independent-card/Discovery combination. Full modes, wider nested/optional interactions, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
2026-10-24 14:08:45 +11:00
Quintine 1f3e71fe03 Connect bare Basic native Ecaz coalitions
Admit fresh public Basic Ecaz starts with selected native E1/E2 families while retaining roster, inventory, seat and optional-profile guards. Keep actual coalition battle assertions. Include allied marker-only and mixed Richese presence, zero and capped materialization after both seals, and owner-specific casualties. Extend the explicitly provisional additive mixed-marker interpretation to Basic without new arithmetic or FAQ ruling.

Use the current physical force pool for battle-wheel half steps so Ecaz can commit Ixian Suboids; cancellation restores Ecaz-only whole steps. Preserve Basic free support, zero funding/income, native Ix substitution, lead-owned cards and original Collection.

Verify related Basic/Advanced rules, types/lint/build, nine actual-control/rendered-table programmes after genuine public Basic setup, explicit controlled board/budget/card staging and four existing profiles with legal lead/plan/native-substitution proposals. Full modes, wider optional configurations, comprehensive assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment.
2026-10-24 13:54:50 +11:00
Quintine 152039be5b Support cohort-eligible Ambassador leader and KH repeats
Replace lifetime first-death caps with the selected provisional composition: first returns remain immediate, while later native leaders and KH must satisfy their existing ordinary death/cohort conditions. Extract one phase/slot/price-independent cohort quote for ordinary Revival and the independent Ambassador. Preserve ordinary timing, payment, prevention, native early service and one-leader controls.

Snapshot an opened cohort before a free return makes a leader alive, preserving remaining ordinary returns. Keep Ecaz identity tracking and the shared Duke permission separate. KH retains activation/loss history and five native discs; update its marker above the actual opened owner cycle and old marker. Independent grants retain ordinary allowance and income policies. Explain eligibility in persistent controls and source-labelled reference.

Verify related rules/controls, types/lint/build, controlled native/copied free-return-to-next-ordinary-Revival programmes layered on original public Basic paid-entry sources, controlled Advanced KH actual entry/return, income allow/cancel and twelve minimal legal policy continuations. Assigned repeat/cohort facts are explicitly controlled, not claimed original battle histories. Full modes, wider custody/module combinations, comprehensive assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment.
2026-10-24 13:43:11 +11:00
Quintine 16f654071a Support earlier Basic Truthtrance shipment promises
Accept current-turn shipment and mixed questions before Movement in bare classic Basic games. Project remaining owned Charity/normal Revival, net current auction invoices and unused outgoing refunds; do not invent future purchases, incoming Bidding aid or rival income. Physical Karama payment and shipping discounts require distinct cards. Reopen owned Charity/Revival readiness when binding and prevent voluntary spending or closure from evading an answer. Preserve unresolved foreign invoices until their actual outcome; preflight Emperor gifts and extra ally revival costs before responses.

Connect persistent earlier-phase guidance and current preparation to the actual table and retain phase5 shipping timing. Minimal required-delivery policies preserve a legal route; nonrestrictive answers retain ordinary legal payment. Document the explicit provisional witness/readiness interpretation and inherited profile boundaries.

Verify focused rules/controls, types/lint/build, two original public Basic Storm/Charity question programmes with actual purchase2 and shipment8, and four finite minimal legal policy continuations. Full rules, wider earlier-phase configurations, comprehensive assurance and deployed acceptance remain open. No strategic calibration, saved-room operation, reset, restart or deployment.
2026-10-24 13:28:06 +11:00
Quintine 6d30e7c5e1 Connect Tleilaxu income to committed Ambassador revivals
Positive free force/leader returns share the existing beneficiary/turn free-income ledger under the explicit provisional source composition. Actual Ghola remains separately rewarded. Decline earns nothing. Bind a distinct revival-income response to the completed physical return and original Ambassador; allow/cancel finishes once and cancellation preserves the return and spent entitlement.

Add persistent preselection and income guidance. Admit the bare Basic Ecaz+Ix native Tleilaxu composition through existing coalition/Face Dance mechanics, retaining unsupported faction/module guards and existing start/battle assertions.

Verify focused tests, types/lint/build, four original public Basic native/copy allow/cancel programmes, actual control rendering and eight existing legal policies. Exact frozen evidence and boundaries are in docs/TLEILAXU_AMBASSADOR_RULES.md. Full rules/modes, wider combinations, assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment.
2026-10-24 13:07:08 +11:00
Quintine f9e204c301 Verify persistent notices and record the deployed version gap 2026-10-24 12:54:57 +11:00
Quintine 59aa56af2b Connect Ecaz Duke revival to the Tleilaxu Ambassador
The original own-leader grant omitted the shared Duke because it searched only native rosters and KH. Offer Duke only to Ecaz using canonical dead-disc identity/custody validation and the existing independent permission. Preserve ordinary first-death/KH limits and unsupported source envelopes. Native Tleilaxu and Bene Gesserit copied effects share the choice.

Return the actual shared disc alive and set aside, preserve strength and death history, clear obsolete use/concealment and record any satisfied native cohort opening before clearing death. Spend no spice, Ghola or ordinary leader allowance; add no native disc or skill. Finish the same committed Ambassador and retain original entrant movement. Persistent guidance explains that a separate legal acquisition is still needed.

Source-specific tests, genuine public Basic acquisition/death/arrival programmes, actual GameTable/entry rendering and four existing minimal profiles verify the consumer. Controlled Advanced/copy/repeated/cohort/ownership/event probes remain separate. Exact frozen checks and limits are in docs/TLEILAXU_AMBASSADOR_RULES.md.

This composes the printed grant with the selected set-aside/independent Duke policy; it does not settle generic ordinary repeat/income policy, open a module, certify full modes or claim browser/deployed/final assurance. No strategic AI refinement or saved-room/database/reset/restart/deployment operation.
2026-10-24 12:48:18 +11:00
Quintine c95e294890 Document original Ix Spice card policies and readable effects
Retain the current first-turn worm precedence, later Thumper/Sandtrout equivalence and sequential physical Harvester doubling under explicit provisional preview interpretations. Record photographed component and publisher-source provenance, authorized Advanced corroboration, and unresolved wider timing boundaries. No new numerical rule or publisher clarification is claimed.

Warn persistently before first-turn Thumper disposal and explain waiting Sandtrout replacements. Public chronicle entries explain ignored first-turn Thumper and Sandtrout encounters. Correct stale preview gates in the relevant reference topics.

Genuine public Basic/Advanced selected-Ix programmes use original setup, unopened deal and Spice lotteries, real intervening turns and actual held physical cards. Separate assertions prove phase continuation, Nexus/doubling behavior, ready reset and retained physical stock; four existing minimal profiles legally accept a harvested window. Focused tests, types/lint/build and actual GameTable rendering are recorded in docs/IX_PROTOTYPE.md.

This remains bounded prototype evidence, not full native factions, optional combinations, browser/deployed or final assurance. No strategic AI refinement, state version, saved-room/database/reset/restart/deployment operation.
2026-10-24 12:32:13 +11:00
Quintine 6fabe6aa62 Record deployed readable-notice verification gap 2026-10-24 12:28:13 +11:00
Quintine 26553dd175 Complete provisional Basic Tupile slot lifecycle
Basic marked Homeworld games now use the existing provisional current-sole-controller policy for Tupile slots. Observed eligible intervals retain one original activation across turns, close on departure/foreign contest/native high, and create a fresh source on restoration even when same-turn qualification rows deduplicate. Preserve original valid source histories and the lease format. Advanced retains its authorized last-departure rule.

Original normal-limit cleanup remains binding before a returning holder can reuse a slot. Native low intelligence becomes available after Basic loss subject to actual population/contact, without clearing historical answers or lifetime faction usage. Existing owner-only cleanup controls and minimal policies use the native event and exact held cards. Persistent table and cleanup text explain the provisional policy. No new profile, state version or module opening.

Validation and exact frozen-source evidence are recorded in the canonical Tupile runtime contract. Two original public Basic programmes use actual setup, three paid normal lots, earned Guild shipment income, a paid invasion and native battle, one continuous slot, optional real fifth purchase, paid last departure and zero/one normal-limit cleanup. All four minimal profiles submit legal held-card choices and finish the turn. Controlled interval/reactivation, allied cleanup and intelligence probes remain separate. No broad save/recovery campaign or strategy tuning.

This is a first-version application interpretation under the standing recommended-choice direction; publisher Basic lifecycle clarification, full module/mode acceptance, final assurance and browser/deployed proof remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-24 12:19:04 +11:00
Quintine ae2d0371ce Bind Truthtrance shipments before respondent turns
Admit supported active or originally queued unused shipments during the current Shipment & Movement phase. Answering preserves the real active player and queue; private future reachability uses owned resources, joint whole promises and the actual respondent profile. Hide executable guidance until that owner is active. Keep native Guild WAIT/TAKE, frozen mixed facts, interception, costs and actual delivery.

Do not release a waiting promise while another declaration is unresolved or count its approved ally contribution as freely recoverable. Before response declaration, reject Emperor gifts or optional BG accompaniment that remove the last legal completion; affordable options remain legal. Minimal AI declines optional accompaniment while bound rather than attempting to spend required reserves. No strategy tuning or new profile/module/earlier-phase opening.

Validation and exact frozen-source evidence are recorded in the canonical Truthtrance shipment contract. Three real public Basic/Advanced programmes use original unopened starting deals, paid intervening turns and a later Emperor or native out-of-order Guild fulfillment. Four minimal policies answer while waiting and ship only on their actual turn. Controlled funding, temporary price, gift and advisor probes remain separate. Existing stale scope fixtures and two type declarations were repaired during feedback.

Earlier phases, broader modules/actions, full Truthtrance/modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-24 12:07:05 +11:00
Quintine 2939ffa900 Support Truthtrance shipment promises with Advanced Guild
Admit base Advanced Guild through the existing active unused-shipment predicate. Preserve native interception, typed transport permission, rate cancellation and actual payment/arrival. Opposing stops evaluate the complete promise against absent shipment; self-interception cannot voluntarily evade a required shipment. Share the authoritative self-stop reason with controls, and hide executable completion guidance during pending native windows. No other profile opens or strategic AI tuning is added.

Validation and exact frozen-source evidence are in the canonical Truthtrance shipment contract. Public classic Advanced starts earn real physical Truthtrance/Karamas via the unopened original starting deal, native Guild TAKE/WAIT, typed Emperor delivery or opposing stop, and Guild half/full-price routes. Four minimal profiles use legal native continuations. Separate controlled probes cover No/mixed claims, self-stop, unaffordable repricing/owned alternatives and Fremen route denial. Update existing scope expectations; no new recovery campaign.

Broader timings/routes/modules, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 14:29:18 +11:00
Quintine 377e9c5160 Enable Residual Poison against Advanced Harkonnen captives
Include available ordinary captives, captured foreign gholas and shared Duke in the original random pool. Apply one public ordinary death without execution income, preserve foreign revival custody and other concealed captives, and end Duke tenure. Find the signed Semuta victim by canonical physical identity after captivity ends. Explain the provisional normal-Tanks source composition in controls and developer reference.

Validation:9 files74/74, types/lint/build and two actual-rule/rendered-table programmes plus four finite policy continuations pass on one frozen source. Details and exact unchanged-source proof are recorded in the canonical Residual Poison contract. Genuine public Advanced cache purchase, funded deployments, native victory/capture/keep and second battle cover native and captive deaths plus finite completion. Four minimal profiles use legal offered card/readiness actions. Separate controlled cases cover ghola/Duke, last-native return and Semuta claim; no strategic AI refinement.

This interpretation remains provisional. Wider interactions, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 14:18:28 +11:00
Quintine 6c83148ead Connect Sapho first and last to Extortion payer choices
Apply the printed ordered-action alternative provisionally to the unanswered native Extortion suffix. Preserve collected bank5, completed declines, payer membership, one payment3 and placement-first policy. Replay a public local-order receipt and retain it through ledger answers; expire the old payer event even when the same player remains next. Permit only the exact current Extortion decision as a clean Sapho window. Add persistent remaining-order/card controls and existing minimal policy participation. The typed Semuta suffix parks one updated payer decision and restores it without paying, advancing or recollecting.

Validation:9-file115/115, types/lint/build and six original public Basic/Advanced host/setup/paid-cache1/placed-Terror/paid-entry1/reveal/Mentat-placement-before-bank5 programmes. An actual earlier decline stays finished; first/payment3 and last/payment3 or all-decline preserve native retirement/recovery. Four profiles make eight legal holder/payer proposals and complete. Public order and private payer affordability render in actual GameTable plus enabled/busy controls. Pure replay/no-op/recovered reuse cases are separate. Bounded source/continuation review found no concrete defect.

Extortion/Sapho boundary is provisional. Direct combined Semuta claim/capture, broader combinations, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 14:07:08 +11:00
Quintine 2d89ddb6e1 Allow Sapho first while preserving unspent Guild timing
At a clean unstarted non-Guild movement boundary, Sapho FIRST now reorders the remaining turn queue and reopens the native Advanced Guild choice. Guild may take its turn ahead or wait while all non-Guild turns retain their relative order. Protect an already granted current Guild turn, keep canceled timing locked and preserve completed turns, payments and last protection. Existing discard continuations and minimal policy options need no new state or signature format. Explain the provisional precedence in the controls and reference.

Validation:7-file97/97, types/lint/build and six original-action/rendered-table programmes. Actual cache1 Sapho, original Guild wait/take, paid completed Richese prefix, Emperor2/Guild1 shipments and native phase completion. An actually dealt Karama cancels TAKE without its privilege being revived; a granted current turn cannot be displaced. Four profiles make eight legal holder/Guild proposals. Corrected an obsolete first exclusion and a fixture that mistook a later response pass for a repeated movement turn; exact failures and then the whole batch pass.

This nonconflicting composition is provisional. Actual competing first, partly completed turns, other ordered actions, direct combined Semuta claim/capture, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 13:56:29 +11:00
Quintine 84200ebc74 Allow late Sapho priority before final battle settlement
Fresh Richese-roster battles support clean aggressor changes after plans and revelation. Both combatants explicitly finish a public final opportunity regardless of held cards or result; an actual Sapho use clears both marks with a fresh event. Close priority before physical Stone allocation and other outcome settlement. Preserve plans, votes, participants, Habbanya priority and reserved physical cards. Keep old unstamped timing and discard signature bytes. Route all final vote exits through native Homeworld defense first. Add persistent controls and finite minimal bot readiness.

Validation:16-file177/177, types/lint/build and eight original-action/rendered-table programmes. Actual cache1 and paid forces use40 Basic/47 Advanced source actions; genuine Stone1 turn1/Sapho1 turn2 uses86. Ordinary and mixed count2 ties change through sealed-plan/final-window Sapho, explicit revised acknowledgements and native losses. Sixteen minimal proposals are legal; controlled reservation and pure window/control cases are separate. Bounded review fixed the Harkonnen Nexus final-vote route; frozen feedback corrected four historical fixture assumptions. All checks then passed together.

Final-window protocol is provisional. Direct late Semuta claim/capture, combined World/Nexus/Sapho, wider combinations, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 13:46:00 +11:00
Quintine cb13a4d674 Add Sapho first and last for cyclic auction lots
Provisionally apply first/last to the current ordinary or native Advanced normal Black Market lot. Preserve bids, funding and passes; skip passed seats until a real raise. Use a local full cycle and actual post-discard hand capacity. Keep the original Black Market opener in both settlement consumers and clear ordinary overrides for the next lot. Connect persistent controls, native disposal and existing minimal AI options.

Validation:10-file134/134, types/lint/build and eight original-action/rendered-table continuations. Public setup/cache1, real lobby circle choice, native Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policies are legal. Corrected fixture opener/completion assumptions and external default-history/final-lot assertions; only runtime was rerun against identical source. Bounded consumer review found no concrete defect. Other timing scopes, direct combined Semuta claim/capture, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
2026-10-18 13:28:34 +11:00
Quintine 561152d58e Compose Guild Cunning with physical Ornithopter movement
Provisionally let either physical card mode replace an earned unused Hajr movement after the second shipment. Keep active group rights separate from actual completed credits and original movement counts. Add v3 original-count receipts while preserving old formats; abandon partial flights through a bound discard suffix before Cunning resumes. Connect persistent controls and minimal legal bot candidates.

Validation: frozen13-file125/125, types/lint/build and eight original-action/rendered-table continuations. Public ready/start, cache1/Hajr1 purchases, real worm/alliance/Guild Nexus draw and paid first2/second1 shipments cover both modes, earlier flights and Basic early endings. Eight minimal policy proposals are legal. No frozen repair; bounded continuation review found no concrete defect. New Semuta grammar lacks direct combined claim/capture proof. Wider combinations, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
2026-10-18 13:10:12 +11:00
Quintine 7fe84ffbbd Allow Advanced advisors to use the physical Ornithopter
Adopt the supplied advisor/city distinction: advisors do not grant city access, but both printed physical-card modes permit advisor groups. Retain native stance/arrival/locks and distinct original cohorts; use shared advisor-aware paths and entry for minimal bot candidates. Add persistent guidance and genuine paid-acquisition/native-advisor regressions.

Validation: frozen10-file87/87, types/lint/build and three original-action/rendered-table programmes. Actual host/setup, cache1 purchase, foreign shipment/accompaniment and paid BG reinforcement cover range3, two groups and leaving the last city fighter. Eight minimal policy proposals remain legal; controlled geometry and UI checks are separate. No wider combinations, complete modes, final assurance or browser/deployed acceptance claimed. No saved-room/reset/restart/deployment operation.
2026-10-18 12:58:54 +11:00
Quintine 884a09fa92 Scope Advanced Kull to the attempted Karama effect
Adopt the supplied Advanced specific-effect wording, preserving Basic and legacy phase-wide semantics. Bind signed semantic purposes through ordinary/special activations, counters, feasibility and settlement; retain unrelated human controls and minimal bot actions. Phase expiry remains provisional; prior counter, BG and deferred-overbid choices stay.

Validation: frozen12-file111/111, types/lint/build and two original-action/rendered-table programmes. Explicit unopened deal/auction/spice order supports real paid Baliset and shipments; Advanced retains Karama for Baliset cancellation while Basic stays blocked. Four minimal policies remain legal. Correct stale exchange/phase-market/Fremen fixture assumptions. Recovery schema expectations maintained without running deferred SQL assurance. No full modes, browser/deployed acceptance, saved-game/reset/restart or deployment operation.
2026-10-18 12:49:23 +11:00
Quintine b82441c834 Allow replay of the recovered physical Hajr
Count one movement grant per accepted Hajr play after original paid Box recovery. Preserve physical card custody, Basic timing and fresh Advanced post-movement opportunities. Carry unused counts through Guild Cunning with counted declarations and signed final history while retaining legacy receipt compatibility.

Validation: frozen12-file102/102, types/lint/build and two original-action/rendered-table programmes; four minimal policies retain third moves in both modes. Real cache and normal purchases, paid shipment and Box2 recovery reuse the single original Hajr. Replay is an explicit source interpretation. Native Cunning replay has pure/related evidence; wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
2026-10-18 12:33:50 +11:00
Quintine 72e77d7f4d Compose Ornithopter with unused Hajr movement
Replace one unused movement entitlement by the selected card mode and retain independent Hajr movement after a completed two-group flight. Preserve distinct cohorts and later legal reuse. Adopt supplied Advanced immediate-after-movement Hajr play timing with explicit provisional online/card-group interpretation; Basic timing remains.

Validation: frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes; four minimal policies retain both continuations. Actual cards are acquired by original auctions and source groups use funded shipments. Multiple Hajr cards, advisors, wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
2026-10-18 12:23:37 +11:00
Quintine 6ccfaa1b4d Resolve ambiguous Stone battles with sealed physical totals
Add a provisional post-reveal physical-count commitment for new outcome-changing Basic/Advanced Stone and copied Mirror battles. Keep all original sides sealed until complete and retain matching later typed losses. Preserve legacy, ordinary and invariant-winner timing. Carry the same constraint through card cleanup and native casualty consumers.

Validation: frozen 16 files146/146, types/lint/build and14 original-action/rendered-control programmes; four minimal policies make both owned choices legally. Source timing is explicitly provisional. Wider combinations, assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
2026-10-18 12:13:06 +11:00
Quintine 677ad7aa3c Compose independent cards with Discovery and optional Nexus
Connect original selected-deck Discovery setup, optional Nexus/World/Skills/phase modules and physical card consumers through shared public capabilities. Retain Cunning revealed-location arrival, Sandtrout suppression, paid nested R/Harass and actual later Recruits revival. Preserve native trained-capture and unrelated-preview boundaries.

Validation: frozen 15-file118/118, types/lint/build and six finite original-action/rendered-control programmes; four minimal legal policies. External smoke corrections changed no application source. Existing persistent notices also rechecked23/23. Full combinations, assurance and deployed acceptance remain open; no saved-game, restart or deployment operation.
2026-10-18 11:48:45 +11:00
Quintine b4157aaa9b Connect native Fremen Nexus effects across selected modules
Retain actual Cunning appearances, paid Karama and typed remote riding with optional Sandmaster collection and source-parent HMS entry. Preserve Betrayal movement sources and apply its declared worm restriction to Great Maker reserve riding without suppressing votes or casualties. Completed Maker history no longer blocks a later ordinary offer.

Validation: frozen 14-file86/86, types/lint/build, nine finite original-action/rendered-control programmes, four minimal legal policies. Discovery destination proof remains focused quotes pending actual arrival; full combinations, assurance and deployed acceptance remain required. No saved-game, restart or deployment operation.
2026-10-18 11:35:59 +11:00
Quintine bb9d292c4c Compose native Ecaz Betrayal and Moritani Atomics
Retain original selected modules, typed native returns, advisors and hidden No-Field recall. Preserve a full unspent Guild/Sapho opening, paid ordered arrivals, capped provisional No-Field destruction and native World Sneak withdrawal. Bind desert Aftermath to its original Moritani Cunning placement.

Validation: frozen 16-file115/115, types/lint/build, eight finite original-action and GameTable SSR programmes, four minimal legal policies. Document provisional overlaps and corrected genuine recipes. No stored-game change or deployment; full combinations, comprehensive assurance and deployed acceptance remain required.
2026-10-18 11:21:28 +11:00
Quintine 8793e23d6b Compose selected E3 independent cards with Nexus rules
Selected Ecaz/Moritani decks with independent cards and Nexus now retain native Occupy, Advanced assassination, arrival ordering and shared Duke consumers, including original optional modules. Keep the trained E3/Harkonnen capture boundary and remaining individual Nexus effect limits explicit.

Validation: frozen 17-file 168/168, types/lint/build, seven finite original-action and table-SSR programmes, plus four minimal legal Cunning/expiry policies. Fixture corrections preserve the printed alliance draw gate and real paid placement affordability. No stored-game or production change; comprehensive and deployed acceptance remain open.
2026-10-18 10:59:57 +11:00
Quintine 8422cf1f2d Compose selected E1/E2 independent cards with Nexus rules
Retain selected decks and original optional modules through normal setup, distinguish native Ixian Betrayal from absent-faction replacement, route explicit CHOAM fuel before printed effects, and preserve native No-Field invoices and pair continuation. Connect canonical native cancellation stock and existing human/minimal AI consumers.

Validation: 194 affected tests across 19 files, types/lint/build, bounded native invoice review and seven finite original-action/rendered-control programmes. Runtime ec5ad6c18996d8d958ec0d01132ef3e3ea6d23d36188902eca9a63d4db2efbf8; documentation-only proof overlay a5892004937434429916496497c07a3838a324b4a5d64d4a0eb804ab5212d5bd. Other envelopes, full combinations, comprehensive assurance and deployed acceptance remain open. No saved-room/provider changes.
2026-10-18 10:46:21 +11:00
Quintine 999edb67e6 Keep room confirmations visible until acknowledged
Show confirmed create/join guidance until local Continue without polling or ordinary game actions clearing unread text. Default shared toasts to no timeout with visible Dismiss controls.

Validation: 23 focused entry/action/seat tests, types/lint/build, and original local room creation with 43-second retention through polls and a circle change, explicit dismissal and no replay. No console errors. QA room preserved. Deployed acceptance remains open. Runtime fingerprint d57fa6a34bb1738017c2690ca5ad9f04b0b99458078a60c7ddd51e2c29483301; documentation overlay a8baa23802ac1c79321d67913c27512a199699839c1932947dfa3af95434cc37.
2026-10-18 10:27:41 +11:00
Quintine 97c2b67650 Compose classic independent cards with World Nexus rules
Retain independent36, original Homeworlds and Nexus12 through classic fresh setup with optional Skills and phase modules. Resolve own-World Reinforcements fees before computing temporary Sardaukar roles, preserving original Cunning declarations, printed traits and physical counters. Record the provisional ordinary-nonrole-first policy and align human/AI quotes and captions.

Validation: types/lint/build, 128 affected tests across 15 files, bounded rule-source review and finite original-action smoke for Recruits, Harass, local-fee Cunning, all four legal policies and rendered source controls. Verified runtime c219ec587a54b44079539168d93835a5af904c8afb4de58bcec0a3d60c809f0c; documentation-only proof update 8ee21598e7be0c89a08fbad50786c346ebc8ae705492d8b31ea00fae0ffefa3c. Full modes, combinations, assurance and deployed acceptance remain open. No saved-room/provider changes.
2026-10-18 10:20:08 +11:00
Quintine 178251ad35 Restore native Ecaz simulation continuation and current rule fixtures
Match original Ecaz sample snapshots to their native Occupy and Advanced assassination state. Preserve older absent-power samples and resume the original scenario instead of rejecting fresh saved output. Update stale fixture reactions and module expectations while retaining meaningful Banker guidance and positive legal commitments. Correct the unsupported rules-completion claim.

Validation: typecheck/lint, 173 affected tests across 16 files, bounded independent source review, and one direct Ecaz continuation completed through 178 accepted actions. Runtime fingerprint 95d009d3957650b08bb24826b70aa5f00a74cdd9992b6a6c222f0665d3e3759c; documentation-only proof update a91ab248109bee74a74373fb544027a2e7cc8f178e26d2c9dad475a9618eb64d. Full rules, comprehensive assurance and deployed acceptance remain open. No saved-room or provider changes.
2026-10-18 10:08:55 +11:00
Quintine 716304bb9e Repair stale prototype module rejection expectations 2026-10-18 07:11:20 +11:00
Quintine f3eb25c17a Record non-AI checklist closure 2026-10-18 06:57:16 +11:00
Quintine 8d5c8da6c4 Compose original World Nexus core 2026-10-18 06:54:54 +11:00
Quintine ce716f3438 Compose classic Nexus with earned phase modules 2026-10-18 06:21:11 +11:00
Quintine a7cc25ae29 Unify classic Nexus core and funded native shipments 2026-10-18 05:34:36 +11:00
Quintine 366c967f8a Connect native Nexus reactions to classic independent cards 2026-10-18 04:51:33 +11:00
Quintine c466107452 Compose classic independent Treachery and Nexus cards 2026-10-18 04:25:31 +11:00
Quintine e70cd48172 feat: support provisional paid Black Market retention 2026-10-18 03:21:44 +11:00
Quintine 7891517a9b fix: bind Richese Bureaucrat to actual recipient portions 2026-10-18 02:22:27 +11:00
Quintine 06342ad34c fix: unify native Bureaucrat allied funding receipts 2026-10-18 01:44:24 +11:00
Quintine 60d1bd3b59 Unify registered native E3 Leader Skills capability 2026-10-18 01:17:13 +11:00
Quintine c265f0638e Implement mixed Planetologist advisor gather continuation 2026-10-18 00:35:36 +11:00
Quintine bc37cd38fe Connect native E3 World Skills and independent cards 2026-10-17 23:38:13 +11:00
Quintine 01985af36b Compose World Skills cards with selected ordinary decks 2026-10-17 23:07:39 +11:00
Quintine b9330d489e Compose independent Skills with original Tech and Stronghold modules
Unify fresh/live classic and native phase admission, retaining original counts, earned custody, separate income ledgers and physical card/skill continuations. Plain paired E3 phases no longer require cards or Nexus. Coupled44-file471/471, types/lint/build, genuine programmes and390px normal entry pass. Controlled card inputs explicit; no save retrofit or deployment.
2026-10-17 22:12:44 +11:00
Quintine 17adffa6a7 Connect paired E3 Skills without Nexus and retain native arrival independence
Reuse original paired roster validation with plain all14 native setup and optional independent cards. Preserve real coalition, trained death and Mentat paths; extend the existing paid Ambassador/Terror queue to paired Skills and card-only factions. Frozen29-file323/323, types/lint/build, native programmes and390px entry pass. No save conversion or deployment.
2026-10-17 21:12:35 +11:00
Quintine fde44baab5 Compose standalone E3 Skills and independent cards with native power defaults
Restore fresh Ecaz coalition and Advanced Moritani initialization; retain printed native continuations and selected-family card eligibility. Validate Mentat fallback hands against explicitly selected independent cards. Frozen25-file274/274, types/lint/build and genuine runtime/mobile entry proofs pass. No save retrofit or deployment.
2026-10-17 20:36:01 +11:00
Quintine 2ad947af54 Compose native Ix and CHOAM Skills with independent Treachery Cards 2026-10-17 19:29:37 +11:00
Quintine 7182502b16 Allow native Ecaz Skills coalitions without unrelated modules 2026-10-17 18:38:33 +11:00
Quintine 7ef3377218 Connect native Homeworld Ecaz Arrakis coalition battles 2026-10-17 18:24:11 +11:00
Quintine 210aa04811 Open supported optional modules through normal lobby previews 2026-10-17 17:22:08 +11:00
Quintine b8ad2f011f Finish 14-17 October regression triage: pass new response windows in fixtures and remove obsolete gate pins 2026-10-17 15:49:18 +11:00
Quintine dea9b20f9b Repair stale fixtures for Guild permission/rate windows, Banker ledger and opened lobby gates 2026-10-17 15:23:50 +11:00
Quintine 2dbbe8859e Treat a generic battle-power decline as passing borrowed Nexus Voice 2026-10-17 14:59:25 +11:00
Quintine 5961c75a29 Open expansion-faction starts from the normal lobby 2026-10-17 14:39:09 +11:00
Quintine 5a32c1ac85 Compose independent utility cards with classic Leader Skills 2026-10-17 14:17:24 +11:00
Quintine 40a5e51229 Align companion rules guidance with normal Skills defaults 2026-10-17 13:02:30 +11:00
Quintine 3ed9856578 Make normal Banker income mandatory in fresh Skills setups 2026-10-17 12:58:29 +11:00
Quintine 628f59b73a Connect Diplomat used-defense contexts 2026-10-17 12:12:19 +11:00
Quintine 63298be1e1 Prototype modifier-aware Smuggler battle collection 2026-10-17 11:33:07 +11:00
Quintine d6d71c7c02 Compose native World cards with original phase modules 2026-10-17 10:58:43 +11:00
Quintine cda9338f13 Compose selected native World independent cards 2026-10-17 10:31:05 +11:00
Quintine f5b7ebc8da Compose classic World cards with phase modules 2026-10-16 16:19:35 +11:00
Quintine ccdcfe2c4d Compose native Stone and Mirror with phase modules 2026-10-16 15:36:20 +11:00
Quintine 4a67e8c7be Compose independent cards with original phase modules 2026-10-16 15:07:44 +11:00
Quintine 4313983a5c Connect clean native family Reinforcements battles 2026-10-16 14:22:50 +11:00
Quintine c64c08af89 Seal exact native Homeworld Reinforcements costs and plan sources 2026-10-16 13:07:49 +11:00
Quintine 05c4c8c7fb Compose Recruits with current classic Homeworld revival rates 2026-10-16 11:34:48 +11:00
Quintine 643add3f85 Route classic Homeworld Harass returns through native custody 2026-10-16 11:07:14 +11:00
Quintine 5509abf1e5 Add current concealed No-Field Truthtrance facts 2026-10-16 10:11:15 +11:00
Quintine 24c38ebccd Distinguish Atreides special No-Field plan inspection 2026-10-16 09:37:01 +11:00
Quintine 0dfdeb9578 Connect mixed No-Field armies to native Occupy battles 2026-10-16 09:12:57 +11:00
Quintine a58b8bc92c Compose concealed No-Field battle cards before reserve costs 2026-10-16 08:56:01 +11:00
Quintine f50dda6078 Admit clean Ix Stone and Mirror with existing revealed priorities 2026-10-16 08:00:45 +11:00
Quintine 842fd72761 Compose native Stone and Reinforcements without a physical-token bonus 2026-10-16 07:10:18 +11:00
Quintine be0644c0e9 Compose same-plan Harass and Reinforcements with original reserve costs 2026-10-16 06:48:04 +11:00
Quintine feb2b03251 Compose Harass withdrawal with provisional Stone Burner timing 2026-10-16 06:35:04 +11:00
Quintine 5d13374f7e Bind direct Richese Nexus purchases and release paid bid commitments 2026-10-16 05:45:28 +11:00
Quintine 32b139281c Bind Ixian Nexus replacement to original Richese seller lots 2026-10-16 05:02:51 +11:00
Quintine 634ed293af Connect native Richese normal-purchase Ixian Nexus replacement 2026-10-16 04:01:06 +11:00
Quintine 7ef00e0a65 Admit original Advanced Tleilaxu Ixian Nexus replacement 2026-10-16 03:26:49 +11:00
Quintine 2c6bb02ccb Compose Harkonnen Ixian replacement before native purchase bonus 2026-10-16 03:19:26 +11:00
Quintine f268a36a11 Offer Ixian Nexus replacement after native BG purchases 2026-10-16 03:10:59 +11:00
Quintine 5902286db5 Compose Semuta recovery with physical Shrine card roles 2026-10-16 03:02:06 +11:00
Quintine f587374923 Preserve native BG conversion through Semuta physical costs and counters 2026-10-16 02:41:42 +11:00
Quintine 8a2df97be8 Resume printed native specials through Semuta cost and cashout stages 2026-10-16 01:47:32 +11:00
Quintine 44c2bf0da4 Preserve purchased price through Semuta without duplicate stop refund 2026-10-16 00:55:57 +11:00
Quintine 8386b41015 Resume native Guild cancellation after Semuta cost recovery 2026-10-16 00:28:23 +11:00
Quintine 2b8eb77e4e Retain Richese one-counter pricing through native Guild rate response 2026-10-15 23:55:02 +11:00
Quintine 8f9f980075 Compose purchased shipment price with immune Junction source 2026-10-15 23:30:11 +11:00
Quintine 11adb7dbf3 Separate Guild transport permission, purchase price and loan approval 2026-10-15 22:52:37 +11:00
Quintine a12250320c Compose native Guild rate with module and Homeworld price sources 2026-10-15 20:55:05 +11:00
Quintine 0db84c81b7 Connect native Guild rate cancellation and Cunning receipts 2026-10-15 20:14:39 +11:00
Quintine c10fe0b48c Connect native Guild and BG own-arrival continuations 2026-10-15 19:53:15 +11:00
Quintine e265af3cb3 Preserve permanent Traitor markers through legal replacement recycling 2026-10-15 19:08:16 +11:00
Quintine 1cd7db7d0b Connect canonical Moritani winning discs and queued Guild arrivals 2026-10-15 17:22:34 +11:00
Quintine a5a6db7868 Connect native Moritani and Harkonnen captive Duke rules 2026-10-15 16:20:58 +11:00
Quintine 559b42dd78 Track repeated Ecaz revival cohorts by native disc identity 2026-10-15 14:37:15 +11:00
Quintine 60f338e2e1 Allow Ecaz Cunning at original quiet phase boundaries 2026-10-15 13:48:02 +11:00
Quintine a24600ae1d Connect original Duke capture and Ecaz Nexus liberation 2026-10-15 13:24:47 +11:00
Quintine 172363d9d3 Admit original native Nexus bank purchases without widening legacy revivals 2026-10-15 11:41:20 +11:00
Quintine e2dc5f3d0d Compose Emperor Nexus with native Richese special purchase 2026-10-15 11:11:06 +11:00
Quintine 3ce2e1d32a Connect Emperor Nexus retention to original Ambassador purchases 2026-10-15 10:51:03 +11:00
Quintine d3e1c2f242 Connect Emperor Nexus retention to original Richese seller lots 2026-10-15 10:20:33 +11:00
Quintine 22948ff6e4 Route Emperor Nexus extra revival through native pending settlement 2026-10-15 08:17:32 +11:00
Quintine 022040972c Connect Emperor Nexus compulsory funding to native auction settlement 2026-10-15 06:38:14 +11:00
Quintine 25996ec8a7 Connect Fremen Nexus fixed quota to native partial revival 2026-10-15 06:09:36 +11:00
Quintine 985cf85e12 Connect BG Nexus fresh and storm advisor conversion 2026-10-15 04:40:46 +11:00
Quintine 82f1aae51b Connect Fremen Nexus protection before original worm devouring 2026-10-15 01:58:23 +11:00
Quintine 8ab827b902 Connect absent Bene Gesserit Nexus Voice before Prescience 2026-10-15 00:51:43 +11:00
Quintine 72e37a8978 Connect Tleilaxu Nexus free leader and paid force revival 2026-10-14 23:27:30 +11:00
Quintine 4bf6cfc875 Keep Atomics penalties on original targets after alliance changes 2026-10-14 21:06:32 +11:00
Quintine 1f38f12beb Connect Basic shared Ecaz Orgiz Collection settlement 2026-10-14 20:07:59 +11:00
Quintine bcd1b1041a Use actual Collection receipts for co-present Orgiz payers 2026-10-14 19:44:39 +11:00
Quintine bc09ac3cfd Resolve definite Orgiz payers and prove native shared Collection 2026-10-14 19:13:41 +11:00
Quintine 388c5fa8d5 Connect multi-occupant Cistern and Orgiz Collection 2026-10-14 18:48:40 +11:00
Quintine 554f2bf9e9 Connect physical Jacurutu allocation and cumulative station choices 2026-10-14 14:49:07 +11:00
Quintine 373badd560 Connect classic Homeworld Banker final Bank income and ruled rounding 2026-10-14 13:10:33 +11:00
Quintine 2efa69d10f Apply ruled Grumman return and Cunning placement 2026-10-14 08:26:05 +11:00
Quintine 7994026a70 Admit documented Skills prototype CLI combinations 2026-10-13 20:50:41 +11:00
Quintine cd580d27c8 Connect classic Nexus to normal Banker income 2026-10-13 20:44:21 +11:00
Quintine a9b26fd88c Record actual captured Skill revival controls 2026-10-13 20:26:10 +11:00
Quintine e57f8ddab7 Keep occupied Guild smoke outside actual storm 2026-10-13 20:16:09 +11:00
Quintine adb00358dd Let captured Leader Skill owners revive other leaders
User ruling 13 October 2026: the original faction keeps its existing Skill
assignment while its trained leader is captured. Reviving a different eligible
own leader is legal and simply draws no replacement card while that assignment
exists (selected reading of authorized supplied Advanced physical p21 "if you
have no Leader Skill Card"). Not a new publisher ruling; captor lower-band use,
card movement and death-return unchanged.

Remove the replacement-question veto that suppressed the entire ordinary/Ghola
revival: leaderSkillRevivalBlocked/requireLeaderSkillRevival, the gholaLeaders
early return, the view revival.leaders: [] filter and the beginRevival/Ghola/
Emperor-special-Karama call sites are gone. offerRevivedLeaderSkill is
unchanged and still suppresses a second assignment or draw.

Rewrote the two committed rejection assertions
(tests/advanced-native-skills.test.ts, tests/leader-skills-capture.test.ts) and
added native regressions covering ordinary paid, held-Ghola and Emperor special
Karama revival from a genuinely captured skilled state: exact cost, no draw or
offer, retained captive/card/controller and a conserved all14 deck.

Docs: RULE_DECISIONS, LEADER_SKILLS_CAPTURE, LEADER_SKILLS_RULES,
LEADER_SKILLS_RUNTIME, NEXUS_CARD_RULES, CURRENT_STATUS, IMPLEMENTATION_STATUS
and the game/reference.ts leader-skills topic.
2026-10-13 20:11:55 +11:00
Quintine ea12a68e56 Restore ordinary Skills in bounded Guild Betrayal composition 2026-10-13 19:19:05 +11:00
Quintine 9fb680da09 Prove Advanced occupied Bureaucrat receipts with native timing 2026-10-13 18:44:58 +11:00
Quintine 304849f8d9 Connect actual occupied Homeworld Bureaucrat split receipts 2026-10-13 18:26:44 +11:00
Quintine df9bbc5e04 Connect Guild Nexus Betrayal receipt to Bureaucrat per ruling 2026-10-13 17:47:00 +11:00
Quintine 9b83b61669 Connect Homeworld allied Bureaucrat income and record two rulings 2026-10-13 17:25:39 +11:00
Quintine 36f911cec8 Connect native paired E1 Bureaucrat funding 2026-10-13 10:12:04 +11:00
Quintine ac64adaa3f Connect native paired E3 Bureaucrat funding 2026-10-13 09:32:36 +11:00
Quintine b207dd0479 Connect native paired E2 Bureaucrat funding 2026-10-13 09:01:43 +11:00
Quintine c3d1896673 Connect classic allied Bureaucrat payment redirection 2026-10-13 08:24:56 +11:00
Quintine ec574ab0f5 Compose original Homeworld battle reward cancellation costs with Semuta 2026-10-13 07:37:31 +11:00
Quintine afe3008cae Compose original Homeworld shared-benefit cancellation costs with Semuta 2026-10-13 06:52:14 +11:00
Quintine 8c5b6c8b55 Compose native Homeworld economic Karama costs with Semuta 2026-10-13 05:50:50 +11:00
Quintine 8bc7f7aca2 Compose clean Homeworld preparation and battle chooser costs with Semuta 2026-10-13 05:09:00 +11:00
Quintine fbf77d0f80 Compose original Homeworld battle card disposal with Semuta 2026-10-13 04:34:22 +11:00
Quintine 4966b1d062 Compose Homeworld carried-flight and finite-order disposal with Semuta 2026-10-13 04:03:21 +11:00
Quintine b8a66a864b Offer completed Homeworld CHOAM market disposal to Semuta 2026-10-13 02:39:33 +11:00
Quintine 7004977e75 Compose Homeworld storm and native Worthless counter costs with Semuta 2026-10-13 02:08:42 +11:00
Quintine 506bf25bc6 Offer native CHOAM Homeworld discard costs to Semuta 2026-10-13 01:12:02 +11:00
Quintine 68f7a101a3 Respect individual Bureaucrat payment thresholds and correct Homeworld evidence 2026-10-13 00:12:10 +11:00
Quintine 38feaf17ba React to a definite Truthtrance answer in the Homeworld Semuta envelope 2026-10-12 23:41:14 +11:00
Quintine f1706ab408 React to a clean Distrans transfer in the Homeworld Semuta envelope 2026-10-12 23:36:43 +11:00
Quintine a9a8ae6b75 React to a paid Box search in the Homeworld Semuta envelope 2026-10-12 23:34:06 +11:00
Quintine cb4c32fc87 React to ordinary used cards in the Homeworld Semuta envelope 2026-10-12 23:29:37 +11:00
Quintine d14f3d1717 Offer first-death Kwisatz Haderach through the Tleilaxu Ambassador 2026-10-12 23:20:37 +11:00
Quintine bbf27c4f3d Preserve native Homeworld paid shares through Semuta income costs 2026-10-12 17:36:01 +11:00
Quintine a4f1cfc650 Compose native Homeworld Kaitain and Tupile discards with Semuta 2026-10-12 16:40:03 +11:00
Quintine d718e380b7 Offer Semuta before native paid-income Karama cancellations 2026-10-12 15:36:48 +11:00
Quintine adb054023d Prove Emperor share and Moritani cleanup through native phases 2026-10-12 15:00:16 +11:00
Quintine 308797567e Offer Semuta on printed Emperor-share cancellation and repair stale unit tests 2026-10-12 14:46:09 +11:00
Quintine 63e5f22607 Repair stale Semuta battle-frame recovery expectation 2026-10-12 14:16:01 +11:00
Quintine 7ae0a9f7ae Correct Karama reference coverage for native CHOAM cancellation costs 2026-10-12 14:04:40 +11:00
Quintine f68c556f8a Pause printed CHOAM power cancellation costs for Semuta 2026-10-12 12:39:30 +11:00
Quintine bb6835e6c3 Preserve original Cunning fuel and suffix through Semuta 2026-10-12 10:50:39 +11:00
Quintine 9afc03b347 Compose Semuta with original native Nexus discard receipts 2026-10-12 09:26:49 +11:00
Quintine 5b3729e2db Retain trained Ixian response inside paused Semuta cost 2026-10-12 08:51:20 +11:00
Quintine 90c6f0020c Compose explicit native Leader Skills with Semuta continuations 2026-10-12 08:27:20 +11:00
Quintine 03e3d9bdbf Retain original Advanced Strongholds in fresh Semuta games 2026-10-12 07:24:52 +11:00
Quintine bab8f3403d Offer Semuta on printed native battle cancellation costs 2026-10-12 06:57:15 +11:00
Quintine 74768647f4 Preserve native Ixian casualties and Moritani retention across Semuta 2026-10-12 06:13:47 +11:00
Quintine a6869b0166 Connect Semuta reactions before original native Tech award 2026-10-12 05:44:56 +11:00
Quintine a1d620d7b0 feat: preserve native live-hand audits through Semuta discards 2026-10-12 05:09:54 +11:00
Quintine f09ddf1b18 feat: preserve quoted CHOAM income through Semuta battle discards 2026-10-12 04:40:04 +11:00
Quintine 0d545deb63 feat: preserve native Face Dance through Semuta battle discards 2026-10-12 04:16:12 +11:00
Quintine 12e4bcbf45 feat: restore final CHOAM Mentat after Semuta Gamont reactions 2026-10-12 03:37:23 +11:00
Quintine c468dc9e44 feat: preserve elite and No-Field Gamont selections through Semuta 2026-10-12 03:21:29 +11:00
Quintine 3171afc522 feat: connect Semuta to reactive CHOAM denial discards 2026-10-12 02:43:16 +11:00
Quintine 11487e0cf9 docs: reconcile Mentat and Semuta capture closure notes 2026-10-12 02:10:42 +11:00
Quintine d319faa80c feat: offer Semuta after clean battle Distrans transfers 2026-10-12 02:08:24 +11:00
Quintine 235f763625 feat: connect Basic native Semuta Terror discard reactions 2026-10-12 02:00:22 +11:00
Quintine 6709522890 feat: offer Semuta after paid battle Box searches 2026-10-12 01:44:21 +11:00
Quintine 066a097618 feat: offer Semuta after ordinary battle Ghola revival 2026-10-12 01:40:24 +11:00
Quintine da61249592 feat: resume Semuta battle discards before Harkonnen capture 2026-10-12 01:35:00 +11:00
Quintine 7cb6f98332 docs: correct verified Semuta battle answer scope 2026-10-12 01:24:52 +11:00
Quintine d4a9835807 Offer Semuta on a battle-plan Truthtrance promise 2026-10-12 00:29:18 +11:00
Quintine 871e932319 Offer Semuta on a Truthtrance shipment promise 2026-10-12 00:22:28 +11:00
Quintine a487f3e1b3 Correct stale Richese runtime and Semuta guidance 2026-10-11 21:33:14 +11:00
Quintine a985937b19 Close the resolved BG setup AI audit row 2026-10-11 21:29:59 +11:00
Quintine b19e6dc940 Offer Semuta on a queued Truthtrance answer 2026-10-11 21:19:42 +11:00
Quintine bcabaaf596 Enable native Fremen Cunning with original Nexus components 2026-10-11 18:56:20 +11:00
Quintine 7571ffb2ef Correct superseded multi-pile and HMS scope guidance 2026-10-11 17:30:55 +11:00
Quintine 18693b3207 Resume native Intrusion after final-flight Semuta response 2026-10-11 17:25:26 +11:00
Quintine 9efa17b2b7 Offer Semuta before native Black Market replacement draws 2026-10-11 17:03:04 +11:00
Quintine d1ba8e0c7e Activate approved normal Mentat and document Emperor gift ruling 2026-10-11 16:46:17 +11:00
Quintine eb63967b5c Connect owned Sandmaster stronghold pile allocation 2026-10-09 05:24:33 +11:00
Quintine 0040ed1921 Connect owned Sandmaster destination pile choices 2026-10-09 04:51:12 +11:00
Quintine 24863480cb Connect owned Sandmaster ground pile selection 2026-10-09 03:54:00 +11:00
Quintine 5544b69d7e Use proxy-free runtime for built local serving 2026-10-09 03:30:27 +11:00
Quintine a1517ec949 Connect owned multi-pile battle skill choices 2026-10-09 03:14:14 +11:00
Quintine 8e204218be Align Box and Tleilax guidance with connected prototypes 2026-10-09 02:54:03 +11:00
Quintine f2fd274502 Repair deterministic Occupy test transitions and card staging 2026-10-09 02:53:09 +11:00
Quintine 38b1ba3419 Allow provisional Kulon and Ornithopter coexistence 2026-10-09 02:46:41 +11:00
Quintine 5bfcd2dfbf Record remaining intermittent prototype test flakes 2026-10-09 02:37:01 +11:00
Quintine 048c8e60ff Resolve the high-threshold revival deployment question provisionally 2026-10-09 02:12:58 +11:00
Quintine 504681db82 Resume gated preserved captures under the new Basic occupation and Occupy prototypes 2026-10-09 01:56:38 +11:00
Quintine 5e2a320512 Repair inherited stale rule guards and two minimal-legal bot gaps 2026-10-09 01:49:39 +11:00
Quintine b892967eb3 Connect provisional Basic current-sole Homeworld benefits and repair stale rule guards 2026-10-09 01:26:20 +11:00
Quintine 66e061ccca Connect Richese final-cache and provisional full-hand acquisition 2026-10-08 20:29:45 +11:00
Quintine 451ecc950c Connect provisional late-paid Recruits accounting 2026-10-08 19:31:42 +11:00
Quintine be8703dc51 Connect printed Diplomat defenses and Ecaz Tanked Duke Cunning 2026-10-08 19:05:08 +11:00
Quintine 039fe4e87f Connect Sandmaster collection to physical Ambassador relocation 2026-10-08 17:23:43 +11:00
Quintine 7cef1a3412 Connect Advanced allied reserve transit and legal departure 2026-10-08 16:58:21 +11:00
Quintine 869412bebd Connect Advanced Auditor repeated revival and provisional native cycles 2026-10-08 16:51:05 +11:00
Quintine 85729f9819 Connect provisional Advanced Richese mixed battles and exchanges 2026-10-08 16:46:48 +11:00
Quintine 4ed6beb33a Apply supplied Advanced Ixian Technology Richese-lot exclusion 2026-10-08 16:35:13 +11:00
Quintine 0315917987 Connect supplied Advanced Fremen Ecaz two-sietch victory allowance 2026-10-08 16:26:49 +11:00
Quintine d438d923a3 Connect Advanced Ecaz and Moritani special Karama first versions 2026-10-08 11:29:08 +11:00
Quintine 59a406830c Connect source-ordered Advanced Ecaz new-ally Duke grant 2026-10-08 10:14:08 +11:00
Quintine 5e293f567a Align Basic faction guidance with connected rule prototypes 2026-10-08 09:48:55 +11:00
Quintine f29f14128f Connect provisional Basic full-hand Nullentropy Box exchange 2026-10-08 09:30:34 +11:00
Quintine 8d55b8f3f7 Connect provisional Basic Guild and Karama allied No-Field prices 2026-10-08 09:15:48 +11:00
Quintine 2ede6b55ea Connect provisional Basic mixed Richese No-Field battles 2026-10-08 09:02:10 +11:00
Quintine d3a09a95fe Connect Basic allied reserve shipment and provisional departure policy 2026-10-08 08:53:44 +11:00
Quintine fbbfabb404 Remove the superseded Guild Secret Ally native-return preview block 2026-10-08 08:36:53 +11:00
Quintine 2d0f20b69c Connect the Basic Ecaz new-ally Duke loan 2026-10-08 08:30:38 +11:00
Quintine c28e24b5fa Prioritize Basic faction prototypes and connect three missing rule paths 2026-10-08 08:23:20 +11:00
Quintine f36632accc Count the Advanced CHOAM Auditor in the expected Traitor deck
tools/sample-custody.ts built its expected Traitor deck from the printed
leaders only, but Advanced CHOAM adds its printed extra Auditor disc during
setup, so every fresh Advanced all-expansion sample failed at setup with
'physical traitor custody'. The expectation now includes the Auditor for
Advanced CHOAM seats; the resumed saved positions were unaffected because the
resume path recomputes the inventory from the game.

With the fix a fresh --profile combined-homeworld-nexus --rules both --players
all run completes 2/4 (both Advanced). The two Basic samples stop at phase 6
on 'Odd-force Basic Occupy awaits the preserved publisher casualty-rounding
ruling', one of the three other unresolved questions recorded in the capture
resumption note, not on the five implemented rulings.

tests/sample-custody.test.ts (11 cases) and the faction-games suite pass;
types and lint are green.
2026-10-07 22:39:43 +11:00
Quintine 7fa737f690 Record the preserved-capture resumption under the five rulings
All 123 preserved failed-*.json captures were resumed in place with
tools/faction-games.ts --resume (same saved position, seed and physical
custody; no reset, no fresh re-run). 102 of the 116 that produced a report
now complete; seven collided on duplicate basenames and were re-run with
unique output directories.

- The low-Tupile CHOAM opening-income ruling unblocked all 23 captures that
  stopped at response choamCharity, across the mixed/single/paired/native
  Homeworld+Nexus profiles and the occupation choam-6-basic capture.
- The exhausted-cache count ruling unblocked all three captures that stopped
  at decision richeseBlackMarket (seeds 20265033, 20273010 and the paired E3
  discovery variant).
- Nine of the thirteen preserved homeworld-occupation captures now complete.
- Suk/Kwisatz, Guild Secret Ally return and Sandmaster relocation changed no
  capture outcome.

The 14 remaining captures are gated on three other unresolved questions (the
competing-occupier/native-repopulation occupation lifecycle, the Homeworld
high-threshold revival-deployment timing and the odd-force Basic Ecaz Occupy
casualty rounding), not on the five implemented rulings. No guard was
weakened and no capture was force-completed.
2026-10-07 22:33:13 +11:00
Quintine 7e3bec2721 Implement the five 7 October pending rule rulings
The user answered five recorded questions; each ruling is indexed in
RULE_DECISIONS.md and implemented with a focused regression.

- CHOAM opening income counts as a qualifying collection, so low-population
  Tupile adds its bank increment under the Inflation multiplier. The engine
  guard is removed and the projection/notice expose the added bonus.
- An exhausted Richese cache is legal and restores one ordinary lot; the
  canceled/prevented compulsory cache auction branch restores the reduction
  before the ordinary pool runs and the cache offer is skipped when no cache
  lot can run. The pure continuation quote mirrors the count.
- Suk-rescued casualties count toward Advanced Atreides' seven Kwisatz
  Haderach losses: the battle-loss counter gains the full dialed allocation in
  both rescue paths, and the earlier Suk availability restriction is removed.
- The Guild Nexus Secret Ally reserve return overrides the Arrakis-to-native-
  Homeworld restriction for the holder's own reserves and deposits through the
  shared native-reserve pipeline (generalized from the revival deposit).
- Interior passengers count as entering the territories a relocated Ixian HMS
  points into, so Sandmaster collects there before the native collection.

Full offline suite 7804/7804, types and lint pass. Historical evidence and
superseded test expectations are updated to the ruled behaviour; preserved
combined Homeworld/Nexus, occupation and Richese captures are unblocked but
not re-run here. No existing game, seat or saved state was changed.
2026-10-07 21:56:17 +11:00
Quintine e0101a937b Draw a distinct Mentat Traitor replacement
replaceMoritaniAssassinationTraitors took the reserve's first entry, but the
face-up set-aside card can still sit in that list: re-drawing it produced a
receipt whose replacement equalled the revealed card, which
moritaniAssassinateIntegrity rejects mid-Mentat. The step now draws the first
distinct physical Traitor Card and is blocked with an explicit reason when none
remains, instead of committing an invalid receipt.

This is the engine half of checkpoint e493bcf, whose message described it but
whose staged path list omitted game/engine.ts. Offline suite 7,801/7,801 with
typecheck and lint clean at that revision; this commit changes no behaviour the
suite did not already exercise.
2026-10-07 19:56:05 +11:00
Quintine e493bcf58e Report database size and make four genuine suites reproducible
Operations now report the SQLite database size and page count, read through
PRAGMA page_count/page_size in the same authority-checked batch as the
lifecycle counters with non-numeric values clamped to zero; the page renders
both and states that free disk space is still unknown.

replaceMoritaniAssassinationTraitors draws the first distinct physical Traitor
Card from the reserve instead of the reserve's first entry, because the face-up
set-aside card can still sit in that list: re-drawing it produced a receipt
whose replacement equalled the revealed card, which the assassination integrity
check rejects mid-Mentat. With no distinct card left the step is blocked with an
explicit reason instead of committing an invalid receipt.

Four tests were made reproducible after measuring their real failure rates,
because a suite that fails for random reasons cannot act as a checkpoint gate:
the Discovery typed Spice Blow test now removes other cards for the same
territory so one blow deposits the printed amount (0/20 after, ~20% before); the
Discovery E2 skills deal is pinned with the new shared
tests/deterministic-random.ts helper so a Lasgun-shield explosion cannot
pre-empt its skill consumer (0/12 after, ~20% before); the Ecaz Occupy Ix/CHOAM
policy check accepts any legal continuation and still asserts the phase it
reaches (0/30 after, ~10% before); and the mixed-E3 Moritani Cunning test keeps
the random storm off red_chasm's single sector (0/25 after, ~20% before).

Offline suite 7,801/7,801 with typecheck and lint clean. No game, seat, saved
state or deployment changes.
2026-10-07 19:55:43 +11:00
Quintine 8bea4c1a0c Record administrator failed attempts without storing credentials
Rejected administrator authentication and authorization attempts are now
recorded durably in a new additive admin_attempts table with a fixed reason
(invalid_key_format, unknown_or_disabled_key, missing_session,
unknown_or_expired_session, role_denied), the attempt kind, the attempted role
and an already-known account id. No key, key hash, session token, session hash,
request body, origin, address or user agent is stored, and the
unknown/disabled distinction is deliberately collapsed so the lookup cannot
become an account-enumeration oracle.

GET /api/admin/operations?attempts=1 returns seven-day per-reason counters plus
the twenty most recent attempts, newest first, to a live owner only, with
no-store and the same query guard as the other reads. The operations page adds
"List failed attempts" with the counters and the bounded sample, truncating each
account id to eight characters. Recording is best-effort inside the rejection
path, so a rejected request keeps its own answer even when the table is
unavailable, and the history never locks out, disables or revokes anything by
itself.

Evidence: tests/admin-attempts.test.ts asserts every recorded reason, the
kind/role/account fields, that successful sign-ins and privileged reads record
nothing, that no credential text appears anywhere in the page, the reporting
window, the twenty-row bound and the owner-only recheck after demotion, and that
a dropped attempts table leaves the original rejection answer unchanged. The
anonymous HTTP boundary denies ?attempts=1, and the panel was verified in the
real browser against a genuine recorded rejection. The shared AdminError moved
to db/admin-error.ts so the recorder and the access module no longer form a
value-import cycle. Offline suite 7,801/7,801 with typecheck and lint clean. No
game, seat, saved state or deployment changes.
2026-10-07 19:11:45 +11:00
Quintine 3bebc68cd5 Compose the Sneak Attack arrival with the Bene Gesserit reactions
A positive Moritani Sneak Attack now resolves its committed Terror entry first —
placing the reserve forces and accruing the Heighliners token — and then offers
the Bene Gesserit reactions under the same adopted shipment classification used
for ordinary off-planet arrivals: an eligible Advanced Intrusion choice when
Bene Gesserit fighters stand in the destination, carrying the free
spiritual-advisor follow-up, and the advisor shipment itself when the intrusion
is declined, with its existing territory, storm and occupancy validation. A
zero-force resolution still opens none of them, and the last "Sneak Attack
combined with Bene Gesserit arrival reactions" guard is removed rather than left
as a duplicate check.

This selected order is an implementation interpretation, not a located ruling;
the source note, the pending-interpretation registry, the component inventory,
the player reference and the status log record it. Evidence:
tests/moritani-force-leader.test.ts asserts the post-entry Intrusion offer and
the full decline -> advisor decision -> placement chain with nothing left
pending; the former gated-interaction pin was replaced by the composed
behaviour. Offline suite 7,798/7,798 with typecheck and lint clean, plus the
reference guide tests 10/10. No game, seat, saved state or deployment changes.
2026-10-07 18:53:19 +11:00
Quintine 972b781d0a Adopt the Sneak Attack shipment classification for Heighliners
A positive Moritani Sneak Attack in Shipment and Movement now accrues the
Heighliners token once for its owner through the same techIncome helper as an
ordinary off-planet arrival. The token still enforces its working phase, its
printed Guild exception and one accrual per turn, and a zero-force resolution
accrues nothing; the previous phase-5 guard is removed rather than duplicated.

This is an explicitly adopted implementation interpretation, not a retrieved
publisher ruling: the printed verb is "Send", and classifying the expressly
off-planet reserve transfer as a shipment is the supported reading already
recorded in the source note. Bene Gesserit spiritual-advisor accompaniment and
Intrusion stay gated because their ordering inherits the unresolved
arrival-timing boundary; a Spice Blow worm-ride Sneak Attack stays outside the
token's working phase.

Evidence: tests/moritani-force-leader.test.ts asserts the accrual, the
once-per-turn guard, the unchanged zero-force resolution and the altered-sector
rejection; the former unsupported-classification pin was replaced by the
adopted behaviour rather than re-pinned. The component inventory, the source
note, the pending-interpretation registry, the player reference and the status
log record the reduced boundary. Offline suite 7,797/7,797 with typecheck and
lint clean. No game, seat, saved state or deployment changes.
2026-10-07 18:40:19 +11:00
Quintine 0e1a442dca Wire the Tleilaxu Ambassador force return
The last unimplemented Ambassador effect is now reachable. A placed Tleilaxu
token gives its beneficiary — the Ecaz owner or its ally — a private
ecazAmbassador decision at the new revival stage: one to four physical counters
return from the Tanks to reserves at zero cost, quoted by the existing
game/tleilaxu-ambassador-forces.ts, which also enforces the shared
Fedaykin/Sardaukar one-per-turn counter and keeps Ixian cyborgs uncapped.

The player view projects the maximum, Tanks and elite inventory and current
elite usage to the beneficiary only; the entry control offers a per-count
button and an explicit "Return no forces" decline; all four AI profiles choose
a legal return from the decision owner's own view or decline when the Tanks are
empty. Player-facing coverage text now lists Tleilaxu.

Kept gated exactly as recorded: the printed leader alternative, ordinary
force/free/leader allowance accounting around this independent grant,
dead-twice eligibility, and Tleilaxu free-income repeat scope. No ordinary
revival allowance, price, phase or income path changes.

Evidence: tests/tleilaxu-ambassador-engine.test.ts drives a genuine arrival into
a placed token and asserts the beneficiary-only projection, the typed return
with unchanged spice, the shared elite limit, malformed/over-maximum rejection
with immutability, the ordinary-only return, the decline path and the rendered
control. The four-profile ecaz-entry-bots loop now covers tleilaxu, and the
former unsupported-effect and copy-list pins were updated to the supported
behaviour rather than re-pinned. Offline suite 7,797/7,797 with typecheck and
lint clean. No game, seat, saved state or deployment changes.
2026-10-07 18:28:57 +11:00
Quintine 07c20ae806 Diagnose stalled administrator decisions from the operations page
Add an owner-only, read-only sample of at most ten live rooms whose public
pending interaction — decision, response, Truthtrance or phase opening — has
been idle longest, with the room code, idle duration, interaction kind, turn
and phase. GET /api/admin/operations?stalled=1 repeats live owner authority,
answers no-store, rejects any other query combination and repairs nothing;
paused, closed, removed and archived rooms are excluded and no private game
content is selected. The aggregate sample adds stalledRooms and
oldestStalledChange for the same predicate.

Owner-only unit coverage asserts the counters, the oldest stalled write, the
bounded oldest-first order, every exclusion boundary, idle arithmetic, the
absence of private content and the owner recheck after demotion. The anonymous
HTTP boundary denies the new read, and the full HTTP integration suite passes
55/55 against a local build of this revision. The panel was verified in the
real browser against six hours of genuine idle rooms, which caught and fixed a
missing threshold field in the response.

No game, seat, saved state, deployment, public start or played-save conversion
changes.
2026-10-07 17:51:08 +11:00
Quintine e05a205a47 Compose the deferred Terror arrival and green the mixed E3 programmes
Queue a committed Terror entry whose arrival also opened its own
interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor
choice, or Fremen storm protection — in the additive
Game.pendingArrivalReaction list, and open the oldest queued entry from
settleAutomaticContinuations once nothing is pending. Payment, force
transfer and the original movement never replay; a Terror entry already in
flight and every Ambassador combination still reject.

Both withdrawn mixed E3 programme pairs are green: Moritani 9/9 and Ecaz
14/14, stable across 20 consecutive full-file runs. Their recorded resume
constraints became real fixture repairs, and the two restored consumer
omissions were corrected against the engine rather than re-pinned.

Repair pre-existing failures inherited at 45e6084 against current engine
behavior: stale Discovery and module gates in the native skills, Ix and
expansion-faction prototypes, the advanced Moritani skill boundary, the
Richese Nexus betrayal sale-credit expectation, one incidental gate
message and one flaky Collection-readiness assertion that reproduced at
HEAD.

Offline suite 7,793/7,793 with typecheck and lint clean. No game, seat,
saved-state, deployment, public-start or played-save conversion changes.
2026-10-07 17:24:23 +11:00
Quintine 45e6084424 Record mixed E3 Nexus composition scope and receipts 2026-10-07 15:17:01 +11:00
Quintine 48b2755088 Compose original mixed E3 and three-family Nexus 2026-10-07 15:16:37 +11:00
Quintine 6935b8650d Record exact mixed Nexus container verification receipt 2026-10-07 14:20:50 +11:00
Quintine 05ab2c92b0 Compose original mixed and paired Nexus optional modules 2026-10-07 14:12:47 +11:00
Quintine 0e1797d4e0 Record exact single-native Nexus publication receipt 2026-10-07 11:57:26 +11:00
Quintine 30a0bdbbb6 Compose original single-native E1 E2 Nexus modules and skills 2026-10-07 11:50:25 +11:00
Quintine 204d91902d Record exact standalone Nexus verification receipt 2026-10-07 10:24:12 +11:00
Quintine 185f3b37be Compose standalone E3 Nexus without Leader Skills 2026-10-07 10:18:19 +11:00
Quintine ec3d26f663 Record exact native Homeworld Nexus verification receipt 2026-10-07 09:00:55 +11:00
Quintine 2efa10a9d6 Compose native Homeworld Nexus optional modules 2026-10-07 08:54:53 +11:00
Quintine 71bc325289 Record exact classic Homeworld Nexus verification 2026-10-07 07:24:21 +11:00
Quintine c7073dc6b6 Compose classic Homeworld Nexus optional modules 2026-10-07 07:14:52 +11:00
Quintine b5f5de5c83 Record exact native Homeworld verification and publication 2026-10-07 05:32:50 +11:00
Quintine 3fb047c93d Compose native Homeworld skill and Discovery modules 2026-10-07 05:16:26 +11:00
Quintine a8758bc6b5 Record exact optional-module checkpoint and native work boundary 2026-10-06 19:59:04 +11:00
Quintine fa025220f8 Compose classic Homeworld optional modules through original rules 2026-10-06 18:56:55 +11:00
Quintine 3dd36bd9ff Record exact Homeworld checkpoint surface and container proof 2026-10-06 18:22:17 +11:00
Quintine e2dcebca35 Compose classic Homeworld Skills and Discovery with physical custody 2026-10-06 18:07:41 +11:00
Quintine 4a3bd8fe90 Correct native Nexus Discovery evidence and live scope guidance 2026-10-06 12:03:13 +11:00
Quintine b5b3a949d3 Record native trained Nexus Discovery publication proof 2026-10-06 10:44:46 +11:00
Quintine 2270c07f97 Compose native trained Skills Nexus Discovery modules 2026-10-06 10:13:07 +11:00
Quintine 958d7b3436 Record combined Nexus Discovery publication proof 2026-10-06 04:16:27 +11:00
Quintine c8a6d0bb3c Compose original classic and paired Nexus Discovery modules 2026-10-06 04:06:16 +11:00
Quintine 9e8c718739 Record native Discovery skills publication proof 2026-10-06 01:05:11 +11:00
Quintine 555afe4070 Compose native Discovery skills and repair hidden trainer participation 2026-10-06 00:51:21 +11:00
Quintine d51142bf9a Record original Discovery Stronghold publication proof 2026-10-05 22:25:43 +11:00
Quintine b0267827b5 Compose Discovery Strongholds and repair mandatory Ecaz battles 2026-10-05 22:13:53 +11:00
Quintine e798914aab Record classic Nexus Discovery publication proof 2026-10-05 21:29:34 +11:00
Quintine a2a92bb408 Compose classic Nexus with original Discovery modules 2026-10-05 21:23:02 +11:00
Quintine 4672b97417 Record combined Discovery checkpoint publication proof 2026-10-05 17:46:34 +11:00
Quintine c12b463439 Compose classic Discovery skills and Advanced Orgiz collection 2026-10-05 17:35:51 +11:00
Quintine 1947e354ce Record native Discovery checkpoint publication proof 2026-10-05 15:40:56 +11:00
Quintine 67ce690064 Connect native Discovery setup and nested force paths 2026-10-05 15:28:50 +11:00
Quintine 8d17fab3de Record high Ecaz Homeworld victory publication proof 2026-10-05 14:46:53 +11:00
Quintine 17bc4537c3 Connect printed high Ecaz Homeworld alliance victory 2026-10-05 14:41:11 +11:00
Quintine cfa0fe3952 Record occupied Tupile checkpoint publication proof 2026-10-05 14:10:30 +11:00
Quintine 3e79021a6e Connect occupied Tupile capacity and original cleanup 2026-10-05 14:03:25 +11:00
Quintine d637dbaf63 Record occupied Homeworld checkpoint publication proof 2026-10-05 13:02:47 +11:00
Quintine 0774ae6f83 Connect occupied Homeworld economics and Bidding 2026-10-05 12:48:08 +11:00
Quintine a6e80c7e23 Record exact occupied Homeworld checkpoint verification and image receipts 2026-10-05 09:25:20 +11:00
Quintine 5e902e384d Connect stable occupied Homeworld income sharing and native protections 2026-10-05 09:12:41 +11:00
Quintine 84d2ba1d3d Record verified standalone E3 checkpoint and publication receipts 2026-10-05 06:29:35 +11:00
Quintine 83f825513a Compose standalone E3 Nexus Skills and original module mechanics 2026-10-05 06:19:40 +11:00
Quintine a8c02b4a85 Record verified paired module publication and runtime evidence 2026-10-05 02:29:33 +11:00
Quintine 8b6bf38560 Make native fixtures deterministic and expose verifier failures safely 2026-10-05 02:07:51 +11:00
Quintine 2d24f43721 Compose paired Nexus Skills with original Tech and Strongholds 2026-10-05 01:08:58 +11:00
Quintine 449e27b415 Compose classic Nexus Skills with original Tech and Strongholds 2026-10-04 23:59:58 +11:00
Quintine 03aedc14d7 Record paired native skill checkpoint verification 2026-10-04 22:46:16 +11:00
Quintine fba673d215 Compose paired Nexus and native skills with signed marker shipments 2026-10-04 22:30:26 +11:00
Quintine 54ce1de1df Record classic Nexus skill checkpoint verification 2026-10-04 20:55:07 +11:00
Quintine 3ae5e1f994 Compose classic Nexus and Leader Skills with physical Suk rescue 2026-10-04 20:41:39 +11:00
Quintine c8482a2d96 Record paired Nexus module checkpoint verification 2026-10-04 19:36:10 +11:00
Quintine 9f254a6912 Compose paired Nexus with original Tech and Stronghold modules 2026-10-04 19:26:51 +11:00
Quintine 8f2dd3b1a4 Record classic Nexus checkpoint CI and exact revision proof 2026-10-04 17:20:00 +11:00
Quintine 8ceee7afd0 Compose classic Nexus with Tech and Advanced Stronghold Cards 2026-10-04 17:01:00 +11:00
Quintine b8585cdd92 Record native Tech checkpoint publication and exact runtime proof 2026-10-04 15:09:18 +11:00
Quintine 2da3c9273b Compose native faction Tech and Advanced Strongholds without Skills 2026-10-04 14:59:12 +11:00
Quintine 7cdb9b5a62 Record standalone E3 skill module container verification 2026-10-04 11:53:18 +11:00
Quintine 2b72021611 Compose standalone E3 Leader Skills with Tech and Strongholds 2026-10-04 11:41:13 +11:00
Quintine a8f5a3c953 Record combined Skills Stronghold container verification 2026-10-04 10:04:12 +11:00
Quintine f259488a9a Compose Advanced Leader Skills and Strongholds with optional Tech 2026-10-04 09:50:55 +11:00
Quintine 3c7725c3a5 Record native Skills Tech checkpoint container verification 2026-10-04 08:57:32 +11:00
Quintine 219489ae04 Compose native E1/E2 Leader Skills with original Tech Tokens 2026-10-04 08:48:47 +11:00
Quintine 7a6972cd1b Record classic Skills Tech verified publication 2026-10-04 06:17:39 +11:00
Quintine b71f953635 Compose classic Leader Skills with original Tech Tokens 2026-10-04 06:01:53 +11:00
Quintine 21d71e8282 Record verified E3 Stronghold checkpoint publication 2026-10-04 01:55:46 +10:00
Quintine 39e2954ab4 Compose native Ecaz and Moritani Stronghold rule paths 2026-10-04 01:31:22 +10:00
Quintine 50808aed5e Record verified Basic Occupy container publication 2026-10-03 21:05:38 +10:00
Quintine 5f082696a6 Compose source-clear Basic even-force Ecaz Occupy 2026-10-03 20:34:22 +10:00
Quintine 63e57bd894 Record verified native Ecaz container publication 2026-10-03 18:48:42 +10:00
Quintine f17f4e703e Compose native Ecaz skills with original Ambassador and Duke paths 2026-10-03 18:34:40 +10:00
Quintine df8f335b98 Record verified native Richese container publication 2026-10-03 17:58:44 +10:00
Quintine 032b41aa87 Compose native Richese skills and Advanced contribution receipts 2026-10-03 17:46:10 +10:00
Quintine 10d74f8db8 Record verified skill-first Moritani container publication 2026-10-03 15:50:47 +10:00
Quintine d41a7e03a9 Compose native Advanced Moritani skills with skill-first assassination 2026-10-03 15:34:12 +10:00
Quintine cb1f81977e Record verified Tleilaxu skill container publication 2026-10-03 14:21:48 +10:00
Quintine d86d2088e6 Compose Advanced Tleilaxu skills and original foreign ghola lifecycle 2026-10-03 14:06:49 +10:00
Quintine d327d1943b Record verified Advanced native skill publication 2026-10-03 13:20:08 +10:00
Quintine 7ee064df14 Compose Advanced native Ixian and CHOAM Leader Skills 2026-10-03 13:06:44 +10:00
Quintine 64c8dfc770 Record verified Richese Occupy checkpoint publication 2026-10-03 09:05:55 +10:00
Quintine 113667da55 Compose native Richese No-Field and Stone with Advanced Occupy 2026-10-03 08:48:26 +10:00
Quintine 03c77604de Record exact mixed Truthtrance publication evidence 2026-10-03 07:16:17 +10:00
Quintine cd985c7c34 Support mixed current-fact and shipment Truthtrance questions 2026-10-03 07:11:28 +10:00
Quintine 0ceb28bd31 Record exact native payment wave publication evidence 2026-10-02 21:22:36 +10:00
Quintine b83d75d9d3 Integrate native Banker payments and Tleilaxu Ixian Nexus replacement 2026-10-02 21:12:52 +10:00
Quintine d72f3a2172 Record exact native Mentat publication evidence 2026-10-02 19:43:40 +10:00
Quintine 589e1b9df2 Integrate private Mentat questions with native Basic skill profiles 2026-10-02 19:36:07 +10:00
Quintine 3fc070fcfa Record exact Moritani clarification publication evidence 2026-10-02 19:01:33 +10:00
Quintine 934b2ee4d7 Adopt authorized Advanced Moritani normal-reveal forfeiture 2026-10-02 18:55:40 +10:00
Quintine b89e93a67f Record exact combined-card Occupy publication evidence 2026-10-02 18:14:45 +10:00
Quintine cd532222a5 Integrate sourced Treachery cards with Advanced Occupy 2026-10-02 17:53:24 +10:00
Quintine 85caee6c83 Record exact native Occupy publication evidence 2026-10-02 15:05:30 +10:00
Quintine 1057eb2719 Integrate native Ixian CHOAM and Tleilaxu Occupy aftermath 2026-10-02 14:47:54 +10:00
Quintine 3333432be8 Record exact Advanced Occupy publication evidence 2026-10-02 13:22:47 +10:00
Quintine e112df858a Integrate authorized Advanced Ecaz Occupy combined battles 2026-10-02 13:06:00 +10:00
Quintine bf4122029e Record exact expanded Stronghold publication evidence 2026-10-02 11:57:23 +10:00
Quintine 288870c842 Integrate expanded native Stronghold faction rules
Broaden existing Advanced Stronghold profile to selected E1/E2 native pairs/mixed families and classic opponents;35/47 ordinary decks with separate Richese10cache. Native No-Field/real Stone acquisition/battles, FaceDancer originalwinner/replacement/endMentat and legalAI reuse existinghandlers. Targeted16, actual11 rule cases,18unstaged games11066actions/no rejects/290JSON restores; corrected per-family reporter exercised on3two-seat families. Human390pxG7VDY6KBv12 Stone kill/Habbanya/retain and736HJUD3v8 two-reserve FaceDance. Finalquick26/build/guide205links. No new save/privacy/custody/HTTP/full6500 assurance campaign, pending rulings/public/deployed gates unchanged.
2026-10-02 11:05:54 +10:00
Quintine 05ff2697bc Record exact rules-first throughput checkpoint publication 2026-10-02 09:40:59 +10:00
Quintine e9830b6ced Accelerate rules-first feedback and integrate native Strongholds
Apply user priority: save/recovery/privacy/custody assurance after all rules. Add quick types/lint, concise failure-preserving reporter, verbose mode, actual empty-execution guard, signal handling and focused verify name selection; compact canonical context/handoffs without mass moves. Tooling15, six negative CLI, named/empty source-bound smokes,206 doc links; same15-case278B vs2455B,2.35s vs2.36s no compute speedclaim. Native Advanced Ix/CHOAM47-card Stronghold profile: actual firstMentat/HMS/sixeffects,18native/four legalpolicies,five genuine2..6games3129actions/no rejection, human390px LAUDR2WNv14 bank2/personal1 plan and real battle. Earlier162/6513 checks before assurance amendment; finalquick25/build/guide pass. No new preservation/privacy audit or public/deployed/full-mode claim.
2026-10-02 09:30:52 +10:00
Quintine af6d27095c Record exact normal Banker income container verification 2026-10-02 06:52:56 +10:00
Quintine 6785e622a7 Connect normal Spice Banker deferred bank income
Fresh classic banker-income profile preserves actual bank legs, physical once-phase use and original-faction atomic Mentat collection. Provisional earned custody stays explicit; public/old/combined modes unchanged. Final check: types/lint and 6487 offline cases; focused 140; guide 10; build; native18/four legal budgets; two original six-seat CLI/human390px rooms and82 saved seats25rooms. Final two reviews no findings. Local HTTP49/55 six known upstream503 failures, not green. No reset or deployed claim.
2026-10-02 06:45:21 +10:00
Quintine 13c46450a9 Record exact Harkonnen Betrayal container verification 2026-10-02 02:23:54 +10:00
Quintine 1cf8ff72c7 Integrate Harkonnen Nexus traitor retirement and Mentat replacement 2026-10-02 02:12:18 +10:00
Quintine 0c25aad62f Record Advanced announcement publication and native continuation gates 2026-10-01 22:50:33 +10:00
Quintine 44d90bb9ae Announce Advanced leaderless battle plans before reveal 2026-10-01 22:33:01 +10:00
Quintine 3b79267c29 Record exact Ixian Betrayal container verification 2026-10-01 22:05:34 +10:00
Quintine cdfe0e5370 Integrate Ixian Nexus Betrayal at original native attempts 2026-10-01 21:58:07 +10:00
Quintine 49c99de3e2 Record exact Ixian replacement container verification 2026-10-01 17:33:10 +10:00
Quintine a2b04dbfba Integrate exact Ixian Nexus purchased-card replacement 2026-10-01 17:12:46 +10:00
Quintine c43ab64872 Record verified Advanced source container publication 2026-10-01 14:43:30 +10:00
Quintine e1056a4db0 Adopt authorized Advanced Storm and alliance separation defaults 2026-10-01 14:20:59 +10:00
Quintine 42324b4a99 Record successful Guild Betrayal container checkpoint 2026-10-01 11:22:59 +10:00
Quintine 8a56299155 Integrate full funded Guild Nexus Betrayal 2026-10-01 10:57:05 +10:00
Quintine d3bf12fcd8 Record successful Nexus Kull container checkpoint 2026-10-01 07:07:18 +10:00
Quintine 0d9d0a72d6 Integrate CHOAM Nexus Cunning Kull with source-aware costs 2026-10-01 06:06:03 +10:00
Quintine fc5cbcd720 Record successful Richese checkpoint container workflow 2026-09-30 23:32:07 +10:00
Quintine 9c0000e469 Implement bounded Richese Nexus Betrayal auctions 2026-09-30 23:18:32 +10:00
Quintine a921c0da9b Record proxy-free Kull checkpoint container verification 2026-09-30 21:27:42 +10:00
Quintine aeae152370 Add opt-in printed Kull interception and saved counter continuation 2026-09-30 21:08:33 +10:00
Quintine 3285172eec Allow user-approved atomic full-hand Semuta exchange 2026-09-30 17:39:23 +10:00
Quintine 98d556a0e0 Offer Semuta before CHOAM Jubba storm protection 2026-09-30 16:00:58 +10:00
Quintine 953be21d38 Offer Semuta before ordinary CHOAM Trip force return 2026-09-30 15:41:06 +10:00
Quintine 756af19d18 Offer Semuta after proactive CHOAM Baliset discard 2026-09-30 15:23:32 +10:00
Quintine faebab9a3a Record La La La browser and container verification 2026-09-30 15:08:50 +10:00
Quintine b130cffe53 Offer Semuta before proactive La La La revival block 2026-09-30 15:00:45 +10:00
Quintine 88bcb33342 Offer Semuta before clean CHOAM Kulon movement bonus 2026-09-30 14:48:14 +10:00
Quintine 8f67b39735 Record container HTTP pass and local Wrangler proxy caveat 2026-09-30 14:34:26 +10:00
Quintine 4c07e1bbd1 Offer Semuta after completed CHOAM card sales 2026-09-30 14:26:05 +10:00
Quintine 9bfc3ec037 Offer Semuta before private Ixian ally replacement 2026-09-30 14:08:48 +10:00
Quintine 8f6b1c8463 Offer Semuta after printed Karama auction payment 2026-09-30 13:47:37 +10:00
Quintine eede77a3e3 Offer Semuta after clean Karama auction purchase 2026-09-30 13:34:03 +10:00
Quintine 5832f4353a Record authenticated local integrity check verification 2026-09-30 13:10:11 +10:00
Quintine ddc303542a Add owner-only manual database integrity check 2026-09-30 13:04:00 +10:00
Quintine ebfae0f803 Offer Semuta after clean BG Charity Karama cost 2026-09-30 12:38:56 +10:00
Quintine 6de75a7d78 Record observed live revision and container results 2026-09-30 12:22:43 +10:00
Quintine e9f9994513 Offer Semuta after clean CHOAM Inflation Karama cost 2026-09-30 12:16:40 +10:00
Quintine d9c67ca992 Offer Semuta after clean CHOAM Charity Karama cost 2026-09-30 11:56:02 +10:00
Quintine 32d9ef4ec9 Offer Semuta after clean Residual Poison discard 2026-09-30 11:38:00 +10:00
Quintine ab5c78226a Offer Semuta after clean Sapho battle chooser discard 2026-09-30 11:19:08 +10:00
Quintine 989a986d68 Offer Semuta after clean Once Around Sapho discard 2026-09-30 11:07:09 +10:00
Quintine 9872efcc1d Offer Semuta after clean pre-plan Sapho aggressor discard 2026-09-30 10:45:56 +10:00
Quintine dffbe7b9f7 Offer Semuta after clean Ix-deck Amal discard 2026-09-30 10:25:04 +10:00
Quintine f0eafc175e Offer Semuta before Ix-deck Thumper Spice Blow 2026-09-30 10:04:48 +10:00
Quintine dd6d3a89ca Offer Semuta on clean mandatory battle batch 2026-09-30 09:41:55 +10:00
Quintine e55d275014 Offer Semuta before optional winner battle cleanup 2026-09-30 09:20:58 +10:00
Quintine 593ca3beb4 Offer Semuta choice after winner battle card batch 2026-09-30 09:05:45 +10:00
Quintine 34390bb78b Offer Semuta after clean winner battle discard 2026-09-30 08:48:20 +10:00
Quintine b3e4f1babb Offer Semuta after clean completed Ornithopter flight 2026-09-30 08:33:23 +10:00
Quintine eb4df0b99c Offer Semuta after clean Sapho movement reorder 2026-09-30 08:22:23 +10:00
Quintine 836af6fb3a Offer Semuta after clean final Truthtrance answer 2026-09-30 08:03:18 +10:00
Quintine 162f01c160 Offer Semuta after clean private Distrans transfer 2026-09-30 07:53:59 +10:00
Quintine 2ec0422b79 Offer Semuta on clean early-ended Ornithopter discard 2026-09-30 07:38:45 +10:00
Quintine 0e9945e333 Compose Semuta with completed paid Box discard 2026-09-30 01:25:22 +10:00
Quintine 9ea50a792e Prototype neutral Semuta reactions on clean ordinary discards 2026-09-30 01:07:03 +10:00
Quintine 55b47ce039 Rotate administrator keys with safe re-enable and exact retry 2026-09-30 00:01:16 +10:00
Quintine 9cf8249398 Document natural Advanced Moritani assassination reveal 2026-09-29 23:19:16 +10:00
Quintine 583cd97a25 Preflight worm-ride arrivals for bot candidates 2026-09-29 23:12:23 +10:00
Quintine 3d95db88cc Preflight Guild transport arrival candidates before bot play 2026-09-29 22:58:24 +10:00
Quintine 882d006b3c Prototype owner-only administrator account management 2026-09-29 22:46:33 +10:00
Quintine ac8742c829 Exercise Advanced Moritani assassination preview games 2026-09-29 21:39:31 +10:00
Quintine c961a93f62 Exercise Ecaz card variant and canonicalize advisor stances 2026-09-29 21:22:34 +10:00
Quintine 6ac5dabdee Enable Reinforcements in clean Ecaz-Moritani battles 2026-09-29 20:59:52 +10:00
Quintine 1083c6ccbf Record Tleilaxu Leader Skills game samples 2026-09-29 20:38:29 +10:00
Quintine df71faf240 Record Moritani Leader Skills game samples 2026-09-29 20:33:39 +10:00
Quintine 29aedf1c52 Record paired expansion game samples 2026-09-29 20:32:04 +10:00
Quintine b9c8b740ca Preflight Homeworld-sourced AI arrival overlaps 2026-09-29 20:29:40 +10:00
Quintine 41fd6fe173 Clarify Sapho finite-bid timing and pending Nexus rulings 2026-09-29 20:10:13 +10:00
Quintine 6df505f003 Add role-scoped administrator action history 2026-09-29 20:02:03 +10:00
Quintine 54cbf4d06f Connect bounded Fremen Nexus worm-riding Betrayal 2026-09-29 19:48:43 +10:00
Quintine 66a427ab55 Connect bounded Moritani Atomics Aftermath 2026-09-29 19:13:16 +10:00
Quintine b2603f6715 Connect bounded arrival overlap and Fremen Nexus movement Betrayal 2026-09-29 18:01:14 +10:00
Quintine 8ddd138e87 Extend Guild rate Karama to cross and return transport 2026-09-29 16:22:13 +10:00
Quintine 8a23b5ccd2 Add prepayment Guild rate Karama response 2026-09-29 15:32:49 +10:00
Quintine 745e4fa8ab Connect Moritani Extortion through Mentat settlement 2026-09-29 14:43:59 +10:00
Quintine 88829b7057 Exercise all-expansion Homeworld and Nexus games 2026-09-28 20:54:52 +10:00
Quintine 068da48734 Exercise combined expansion Nexus games and saved continuation 2026-09-28 20:44:25 +10:00
Quintine c6784468a1 Add owner-only operational counts dashboard 2026-09-28 20:28:40 +10:00
Quintine 53a1ba77a1 Add owner-only room backup snapshots and audited exports 2026-09-28 20:12:57 +10:00
Quintine 92564f8de5 Correct bounded Diplomat retreat guidance for Basic expansion skills 2026-09-28 19:21:05 +10:00
Quintine fa867efb88 Record full-game Leader Skills profile samples 2026-09-28 19:16:21 +10:00
Quintine 350689829d Record paired Nexus and combined-expansion game samples 2026-09-28 19:14:26 +10:00
Quintine f25174859e Record complete-game module samples and current Mirror scope 2026-09-28 19:11:33 +10:00
Quintine e6bb9809f7 Connect Tleilaxu revival payments to Bureaucrat 2026-09-28 18:57:14 +10:00
Quintine 3b118dd845 Integrate Ecaz Duke revival and bounded Mirror Weapon battles 2026-09-28 18:02:04 +10:00
Quintine 68d782ca82 Correct CHOAM Nexus reference for victory inspection 2026-09-28 14:29:47 +10:00
Quintine 1b36402dd6 Prototype native Ecaz Nexus Duke Cunning 2026-09-28 14:20:18 +10:00
Quintine 1bda2ec31e Prototype Emperor Nexus bank-auction purchase 2026-09-28 13:38:49 +10:00
Quintine 23ce406872 Connect bounded Reinforcements Battle Plan 2026-09-28 13:07:27 +10:00
Quintine 763749f25e Connect bounded Richese Nexus Cunning No-Field pair 2026-09-28 12:29:59 +10:00
Quintine 0c6d28a706 Add private Ecaz Nexus Secret Ally inquiry 2026-09-28 11:45:14 +10:00
Quintine 886a8f4478 Connect Moritani Nexus losing-card retention 2026-09-28 11:13:39 +10:00
Quintine cb98ead411 Prototype CHOAM Nexus after-victory inspection 2026-09-28 10:34:09 +10:00
Quintine 209fb18a27 Connect bounded Orgiz spice transfers to Discovery Collection 2026-09-28 09:49:14 +10:00
Quintine c9a3625ff2 Connect bounded Diplomat retreat and record live deployment 2026-09-28 09:18:36 +10:00
Quintine 4801ce003b Include administrator bootstrap in container context 2026-09-28 08:31:50 +10:00
Quintine f64b5fd33b Bootstrap initial NAS administrator on empty database 2026-09-28 08:25:18 +10:00
Quintine 2e39ea00bb Exercise Ixian Nexus expansion games 2026-09-27 23:05:27 +10:00
Quintine d77aa5472b Exercise Ecaz Nexus expansion games 2026-09-27 22:56:49 +10:00
Quintine 99c6ab5863 Exercise CHOAM Nexus expansion games 2026-09-27 22:45:53 +10:00
Quintine b7da1389da Verify combined expansion game continuation 2026-09-27 22:30:49 +10:00
Quintine 2183376edc Sample Ixian and Tleilaxu expansion rosters 2026-09-27 22:19:59 +10:00
Quintine 2fbc754484 Sample expansion faction rosters from two to six seats 2026-09-27 22:09:39 +10:00
Quintine 0bf61c8067 Connect CHOAM Nexus Betrayal random discard 2026-09-27 21:54:55 +10:00
Quintine 90c5c7af25 Exercise combined Homeworld and Nexus games 2026-09-27 21:32:01 +10:00
Quintine 6124618d42 Exercise Homeworld games across two to six seats 2026-09-27 21:23:04 +10:00
Quintine 628c536218 Exercise Nexus games across two to six seats 2026-09-27 21:12:30 +10:00
Quintine 8045c9e3eb Enable autonomous Nexus alliance participation 2026-09-27 21:02:00 +10:00
Quintine 23b6c09ae8 Exercise Homeworld and Nexus saved game samples 2026-09-27 20:49:40 +10:00
Quintine 51ef855d19 Preserve Intrusion views across JSON save boundaries 2026-09-27 20:32:38 +10:00
Quintine 0d01d18ddd Suspend Voice response controls during Nexus Traitor return 2026-09-27 20:16:10 +10:00
Quintine 8956cf0be7 Prototype Bene Gesserit Nexus Voice betrayal response 2026-09-27 19:57:26 +10:00
Quintine 22a81c6f7a Prototype Emperor Nexus battle betrayal response 2026-09-27 19:29:50 +10:00
Quintine 361a1def43 Keep checkpoint image build inputs self-contained 2026-09-27 18:57:15 +10:00
Quintine 07c739fcf7 Connect Ecaz Betrayal and Fremen worm Cunning with build revision 2026-09-27 18:52:18 +10:00
Quintine 1e690b7062 Prototype Fremen Nexus free Revival for base factions 2026-09-27 14:48:31 +10:00
Quintine 5c1c944a4f Prototype Moritani Nexus Betrayal token return 2026-09-27 14:20:06 +10:00
Quintine 066aeab207 Document OMP workflow and content-first verification priority 2026-09-27 13:47:15 +10:00
Quintine a05e85f637 Ignore private goal and deployment handover files 2026-09-26 15:11:13 +10:00
Quintine 160fdc1f30 Prototype administrative discussion mute controls
Let owners/operators mute or unmute new public/private sends from a human seat while retaining history, incoming messages, gameplay and credentials. Fence metadata and durable audit writes with live authority, membership and versions. Preserve exact admin/message retries and inherit restrictions through seat recovery.

Validation: types/lint, 5431 offline tests, build, 52 HTTP integration tests, 24 administrator HTTP groups, independent SQL/privacy/UI review and desktop/mobile refresh acceptance. All 2230 prior games and 4746 seats preserved. Additive migration 0016. Verified runtime fingerprint 588967fbb9c2e3b6abe2990c4fce96ee69e2256fb7e3ee416314b87593cebba9; documentation-only grammar/button-label review gives b5e57bdd26812debede91c588e38f247f1fa895ad23e7ae30bc8550eedcd90ec. New-revision deployed acceptance remains open; full admin/rules scope is unfinished.
2026-09-26 14:30:52 +10:00
Quintine 2cddf03932 Prototype paused admin participant AI support
Enable an existing AI difficulty for a human seat through admin controls without granting private access or advancing paused play. Preserve custody, credentials, takeback and separate paced resume. Bind atomic writes and outgoing-grant revocation to live authority, room/pause versions and active seat access with durable exact receipts.

Validation: types/lint, 5408 offline tests, build and 52 HTTP tests passed on source fingerprint 2157de0f4bb0b74d9760d52102601c52344cf674dee8ccd1d559f0a246a1d4ff; 22 authenticated admin HTTP groups, independent SQL/privacy/UI review, desktop/mobile and refresh checks. All 2098 baseline rooms and 4455 seats preserved. Migration 0015 is additive. Positive production admin QA awaits authorized access; full admin/rules scope remains unfinished.
2026-09-26 14:04:17 +10:00
Quintine 137f9223f1 Keep admin and seat recovery notices readable
Show progress during initial admin and seat-control submissions; retain exact recovery instructions for uncertain requests and explicit retries. Keep seat recovery and handover confirmations above the table until Continue without blocking gameplay. Use readable 16px text and 44px controls.

Verified isolated checkpoint: types, lint, 5381 offline tests, 52 HTTP tests and build. Local delayed/lost-response browser QA covered admin creation, durable retry, handover and seat recovery, mobile readability and saved-seat restoration. Existing 2098 rooms and 4455 seats preserved. Deployment acceptance follows separately.
2026-09-26 13:36:05 +10:00
Quintine e918e2e0c0 Add reversible administrator room archiving
Organize closed rooms with archive filters, live-authorized atomic receipts and exact saved retries. Preserve saved games, timestamps, credentials, discussion and AI deadlines. Unarchive leaves play closed; reopen requires unarchive, and removal/restoration preserves both flags.

Verified types, lint, 5379 offline tests, 52 HTTP tests and build. Twenty dedicated administrator HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a single archive after a lost response and refresh, readable recovery text, mobile unarchive and separate reopening, and saved-seat restoration. All 2,049 baseline rooms, 4,348 seats and related records preserved. Authenticated production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
2026-09-22 12:55:44 +10:00
Quintine 98b1b3261b Add administrative room close and reopen controls
Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped.

Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
2026-09-22 12:31:02 +10:00
Quintine 238de3860d Add recoverable administrator room removal and restoration
Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials.

Fix AI readiness after a joining human or human faction change so real lobby starts remain legal.

Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
2026-09-22 12:01:22 +10:00
Quintine c0cc4de9e1 Add neutral administrator lobby configuration
Configure supported rules/modules and AI seats or assign an existing human as host without granting private-seat access. Use genuine unused-lobby checks, existing engine actions, live authorization/version/pause/seat fences and atomic audit receipts. Preserve exact account-bound retries across refresh and confirm completed operations after game start without resetting play.

Verified types, lint, 5260 offline tests, 52 HTTP tests and build. Eleven authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one AI and audit after a lost response and refresh/retry, persisted rules and AI configuration, and readable 1280/390px layouts. All existing rooms and seats preserved. Actual authenticated production administrator acceptance remains pending authorized QA operator access.
2026-09-22 11:27:20 +10:00
Quintine e7d4efc3db Add configured room creation to administration
Create an explicitly owned new host seat with supported rules/modules and ordered AI seats through one live-authorized transaction. Save exact account-bound retries across refresh, record durable audit metadata, and never restore a revoked or transferred host credential. Connect invitation joining and ordinary voluntary host handover. Preserve normal readiness and Advanced preview gates.

Verified types, lint, 5240 offline tests, 52 HTTP tests and build. Eight authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one room after a lost response and refresh/retry, working host controls and saved continuation. Mobile layout passed; requested desktop viewport override did not take effect. Existing games preserved. Authenticated deployed administrator acceptance remains pending explicit QA access.
2026-09-22 11:03:12 +10:00
Quintine 8c922b446b Add administrator pause and joining-lock controls
Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices.

Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation.
2026-09-22 10:41:09 +10:00
Quintine f29d05620c Keep popup instructions readable until continued
Show concise progress during first room create/join; reserve persistent recovery instructions for saved/uncertain attempts. Retain completed-action text until a local Continue button, without gating rules or AI. Preserve unread text across backgrounding, bounded summaries, mute and private-safe feedback. Fix the container QA helper path-import collision.

Verified: typecheck, lint, 5200 offline tests, 52 HTTP tests and production build. Delayed/lost-response browser checks prove progress, refresh recovery and exact retry without duplicates. Notice stayed for over 47 seconds while the QA game progressed; Continue advances only presentation. Mobile 16px/44px controls verified. Existing games preserved. Container/deployed acceptance follows publication.
2026-09-22 09:51:55 +10:00
Quintine c2d392e155 Add administrator sign-in and private-safe room directory
Connect personal operator-provisioned admin keys, persisted roles and eight-hour sessions, current/all-session revocation and a filtered room directory. Additive schema protects final owner, revokes disabled accounts and records safe audit history. No game state changes; remaining administration scope stays explicit.

Verified: typecheck, lint, 5196 offline tests, 52 HTTP tests, production build; authenticated local HTTP and desktop/mobile browser checks. Independent privacy/security review fixed malformed nested roster searches and read-time session revocation. Preserved 1,671 prior rooms and 3,538 seats. Container/deployed acceptance follows publication.
2026-09-22 09:36:11 +10:00
Quintine 36a03bda9c Require administration panel and deployed application verification
Add the requested full admin scope: authenticated permissions, room
creation and removal, lifecycle controls, participant support, protected
backup/restore, health views and audited operations. Retain deliberate
removal safeguards and existing saved-game preservation during development.
Include administration in the non-AI completion gate before strategy tuning.

Require relevant production browser checks at dune.procrastination.games,
matched to the deployed revision, with dedicated QA rooms and separate
production evidence. Cover real navigation, authentication, multiplayer,
gameplay, persistence and admin flows; local checks alone do not certify
deployed acceptance. Keep safe restart/deployment continuity checks.

Update the authoritative goal file and tracked planning guidance while
preserving all prior rules, Git, selective-subagent and AI requirements.
Documentation only: local links/anchors, retained goal sections and diff
checks passed. No runtime change, production test, saved-game mutation or
server restart was performed for this requirements checkpoint.
2026-09-22 08:50:43 +10:00
Quintine f9668a577e Allow clearing and replacing auction bids 2026-09-21 19:47:06 +10:00
Quintine c5661ec255 Use browser navigation for reliable production page links 2026-09-21 19:25:32 +10:00
Quintine b14593fa40 Refresh disposable container port after restart 2026-09-21 18:12:23 +10:00
Quintine 55f3ae0b73 Keep container verification focused on runtime behavior 2026-09-21 18:06:37 +10:00
Quintine 0533f09f16 Preserve lint file exclusions in container builds 2026-09-21 17:53:32 +10:00
Quintine 64e8299aeb Build verified checkpoint containers for TrueNAS 2026-09-21 17:48:25 +10:00
Quintine c5508b727a Expose an explicitly unfinished Advanced preview in normal lobbies
Add room-creation and host lobby rules selectors, readiness reset, explicit
preview start and a persistent warning. Admit the six classic factions,
Tech Tokens with 3+ players and optional Stronghold Cards through genuine
setup. Keep expansion/module gates and complete-rules/publication criteria.

Persist the preview marker and enforce fresh native leaders, unchanged
piece custody and clean runtime/pacing state. Preserve private projections,
JSON continuation, exact room-entry retries and version/CAS protection.
Independent review resolved malformed leader, extra card-zone, missing
required-key and stale bot-deadline admission gaps.

Validation on the shared development tree: types, lint, 5169 offline
tests, build and 50 HTTP tests passed. The tree also contained the
separate deployment task's changes; this commit stages only preview scope.
All 57 base rosters completed real setup with each legal AI profile. Five
normal-preview-start games (2–6 seats) completed 1950 actions and
51 JSON restores without rejected actions; no strategy calibration.

Browser: actual create, host switch/readiness/Stronghold reset, explicit
start, exact private refresh, desktop and mobile warning checked. Retained
dev process stopped; backed up 1,629 saved games, restored server with all
unchanged, and resumed the same traitor choice into Storm. Baseline all-table
preservation retained 1626 rooms and 3442 seats; totals
now 1669 rooms and 3536 seats. No reset or automation.

Preview remains incomplete. Legacy unsupported expansion creation payloads
remain accepted for exact retry compatibility but cannot start. Existing
rules ambiguities and full visual acceptance remain open.
2026-09-21 17:41:35 +10:00
2851 changed files with 505006 additions and 13854 deletions

No files matched your search

+36
View File
@@ -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
+78
View File
@@ -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"
+56
View File
@@ -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"
+2
View File
@@ -43,3 +43,5 @@ next-env.d.ts
# local notes (contains credentials, never commit)
/github.md
/GOAL.local.md
/DEPLOYMENT.local.md
+1
View File
@@ -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",
+87 -39
View File
@@ -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
View File
@@ -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"]
+305 -12
View File
@@ -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.
+21
View File
@@ -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; }
}
+228
View File
@@ -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>;
}
+77
View File
@@ -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; }
}
+85
View File
@@ -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>;
}
+47
View File
@@ -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>;
}
+30
View File
@@ -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; }
+221
View File
@@ -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>;
}
+218
View File
@@ -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>;
}
+25
View File
@@ -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); }
}
+13
View File
@@ -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); }
}
+31
View File
@@ -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); }
}
+27
View File
@@ -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); }
}
+31
View File
@@ -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); }
}
+29
View File
@@ -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); }
}
+24
View File
@@ -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); }
}
+48 -28
View File
@@ -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
+8 -11
View File
@@ -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
+25 -18
View File
@@ -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' },
+4 -2
View File
@@ -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',
},
},
+9
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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,
+302
View File
@@ -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>;
}
+153
View File
@@ -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>;
}
+192
View File
@@ -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>;
}
+148
View File
@@ -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>;
}
+148
View File
@@ -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>;
}
+163
View File
@@ -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>;
}
+154
View File
@@ -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>;
}
+148
View File
@@ -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>;
}
+158
View File
@@ -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>;
}
+23
View File
@@ -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>;
}
+72
View File
@@ -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>
);
}
+42
View File
@@ -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>;
}
+116
View File
@@ -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>;
}
+114
View File
@@ -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>;
}
+42
View File
@@ -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>,
];
})}
</>
);
}
+74
View File
@@ -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 } : {}),
}
: {}),
});
}}
>
+172
View File
@@ -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}
/>
);
}
+44 -17
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+44
View File
@@ -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>;
}
+32 -94
View File
@@ -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>;
}
+55
View File
@@ -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>
);
}
+45 -8
View File
@@ -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
+54 -21
View File
@@ -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>
+38
View File
@@ -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>;
}
+64
View File
@@ -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>;
}
+48
View File
@@ -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&apos;s Mentat Pause.
</p>
</fieldset>;
}
+117
View File
@@ -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&apos;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>;
}
+43
View File
@@ -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&apos;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>;
}
+98
View File
@@ -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&apos;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>;
}
+2 -3
View File
@@ -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&apos;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>
);
}
+21 -5
View File
@@ -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>
+20 -4
View File
@@ -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>
+78
View File
@@ -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>}
</>
);
}
+89
View File
@@ -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>
);
}
+4 -2
View File
@@ -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 && (
+103
View File
@@ -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>;
}
+104
View File
@@ -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>;
}
+115
View File
@@ -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>;
}
+20 -3
View File
@@ -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}
+96
View File
@@ -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>
);
}
+8 -4
View File
@@ -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>
);
}
+27 -9
View File
@@ -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>
+4 -4
View File
@@ -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(
+47
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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>
);
+8 -6
View File
@@ -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>
+19
View File
@@ -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>;
}
+48 -16
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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>;
+94
View File
@@ -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>;
}
+96
View File
@@ -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>
);
}
+3 -1
View File
@@ -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))}
+125
View File
@@ -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>
</>
);
}
+33 -59
View File
@@ -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} />
</>
);
}
+70
View File
@@ -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>;
}
+3 -4
View File
@@ -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>
+53
View File
@@ -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)}&apos;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