From 011b6aa096f88c0fdddf1c78a258dc88b99c552b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:33:54 +1100 Subject: [PATCH] Apply player order to fresh Advanced Revival phases --- README.md | 6 + components/game-table.tsx | 11 +- components/revival-commerce.tsx | 68 +++--- components/revival-turn.tsx | 71 ++++++ docs/ADVANCED_REVIVAL_ORDER.md | 89 +++++++ docs/BASE_ADVANCED_READINESS_20260907.md | 7 + docs/CURRENT_STATUS.md | 32 +++ docs/IMPLEMENTATION_STATUS.md | 30 +++ docs/RULE_DECISIONS.md | 17 ++ game/bots.ts | 16 +- game/engine.ts | 71 +++++- game/reference.ts | 26 +- game/revival-order.ts | 91 +++++++ .../advanced-revival-order-controls.test.tsx | 225 ++++++++++++++++++ tests/advanced-revival-order-engine.test.ts | 213 +++++++++++++++++ tests/advanced-source-engine.test.ts | 12 +- tests/fixture-advanced-revival-order.ts | 148 ++++++++++++ tests/fixture-advanced-source.ts | 2 + tests/fixture-stronghold-factions.ts | 4 + tests/revival-order.test.ts | 119 +++++++++ 20 files changed, 1212 insertions(+), 46 deletions(-) create mode 100644 components/revival-turn.tsx create mode 100644 docs/ADVANCED_REVIVAL_ORDER.md create mode 100644 game/revival-order.ts create mode 100644 tests/advanced-revival-order-controls.test.tsx create mode 100644 tests/advanced-revival-order-engine.test.ts create mode 100644 tests/fixture-advanced-revival-order.ts create mode 100644 tests/revival-order.test.ts diff --git a/README.md b/README.md index aaf140cf..5591865c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ 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). + 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 diff --git a/components/game-table.tsx b/components/game-table.tsx index c9f1363b..b00b104f 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -172,6 +172,7 @@ import { IxHomeworldRevivalBonus } from './ix-homeworld-revival'; import { controlsLeader } from '@/game/leader-control'; import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards'; import { RevivalCommerce } from './revival-commerce'; +import { RevivalTurn } from './revival-turn'; import { FaceDanceDecision } from './face-dance-decision'; import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors'; import { @@ -4374,6 +4375,7 @@ export function GameTable({ )} {g.phase === 4 && ( <> + {g.revival.prevented && (

Tleilaxu prevented your force and leader revivals for @@ -4501,6 +4503,7 @@ export function GameTable({ elite: ally.elites ? allyEliteAmount : 0, }, cost > (me.spice ?? 0) || + (!!g.revivalOrder && g.revivalOrder.remaining[0] !== ally.id) || amount > 3 - (g.emperorExtra[ally.id] ?? 0), )} @@ -4512,7 +4515,7 @@ export function GameTable({ { type: 'reviveKwisatz', }, - !g.revival.kwisatz.affordable, + !!g.revival.orderBlocked || !g.revival.kwisatz.affordable, )} {(g.emperorExtra[me.id] ?? 0) > 0 && (

@@ -4535,7 +4538,7 @@ export function GameTable({ ) : undefined, }, - g.revival.prevented || + !!g.revival.orderBlocked || g.revival.prevented || !me.tanks || eliteAmount > g.revival.eliteRemaining || amount > g.revival.forcesRemaining || @@ -4620,7 +4623,7 @@ export function GameTable({ type: 'reviveLeader', leader: selectedRevival?.id ?? '', }, - !selectedRevival?.affordable, + !!g.revival.orderBlocked || !selectedRevival?.affordable, )} ); @@ -5778,7 +5781,7 @@ export function GameTable({ )} )} - {[1, 2, 4, 7, 8].includes(g.phase) && + {[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && actionButton( g.ready.includes(me.id) ? 'Waiting for the table' diff --git a/components/revival-commerce.tsx b/components/revival-commerce.tsx index 286d8368..ac6e84bb 100644 --- a/components/revival-commerce.tsx +++ b/components/revival-commerce.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import type { Action, GameView } from '@/game/engine'; import { Button } from './ui/button'; import { Input } from './ui/input'; +import { revivalTurnOrderBlock } from './revival-turn'; export function RevivalCommerce({ game: g, act, @@ -18,6 +19,7 @@ export function RevivalCommerce({ const [selection, setSelection] = useState(''); const [prices, setPrices] = useState>({}); if (!tleilaxu) return null; + const orderBlocked = revivalTurnOrderBlock(g); const name = (id: string) => id === 'kwisatz' ? 'Kwisatz Haderach' @@ -43,6 +45,7 @@ export function RevivalCommerce({ Your native skilled leader keeps its own assignment.

)} + {orderBlocked &&

{orderBlocked}

} {g.players .filter((p) => p.id !== me.id) .flatMap((p) => p.leaders) @@ -57,21 +60,18 @@ export function RevivalCommerce({ const cost = g.revival.discount ? Math.ceil(l.strength / 2) : l.strength; + const unavailable = busy || !!orderBlocked || + cost > (me.spice ?? 0) || me.gholaBlocked?.includes(l.id) || + me.leaders.filter(leader => !leader.dead && controlsLeader(me, leader)).length >= 5; return ( @@ -93,9 +93,10 @@ export function RevivalCommerce({ g.revivalRules.limitBlocked || g.revivalRules.expanded.includes(p.id) } - onClick={() => - act({ type: 'tleilaxuRevivalLimit', target: p.id }) - } + onClick={() => { + if (!busy && !g.revivalRules.limitBlocked && !g.revivalRules.expanded.includes(p.id)) + act({ type: 'tleilaxuRevivalLimit', target: p.id }); + }} > Allow five revivals @@ -108,7 +109,10 @@ export function RevivalCommerce({ g.revivalRules.discountBlocked || g.revivalRules.allyDiscount === me.ally } - onClick={() => act({ type: 'tleilaxuAllyDiscount' })} + onClick={() => { + if (!busy && !g.revivalRules.discountBlocked && g.revivalRules.allyDiscount !== me.ally) + act({ type: 'tleilaxuAllyDiscount' }); + }} > Offer ally half-price revival @@ -134,20 +138,20 @@ export function RevivalCommerce({ @@ -181,6 +185,7 @@ export function RevivalCommerce({ A request and its quoted price are visible only to you and Tleilaxu. No spice is paid until you accept and the response resolves.

+ {orderBlocked &&

{orderBlocked}

} {request ? ( <>

@@ -193,16 +198,21 @@ export function RevivalCommerce({

{request.price !== null && !request.declined && ( )} @@ -214,6 +224,7 @@ export function RevivalCommerce({ diff --git a/components/revival-turn.tsx b/components/revival-turn.tsx new file mode 100644 index 00000000..a6a4cc97 --- /dev/null +++ b/components/revival-turn.tsx @@ -0,0 +1,71 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; + +/** Native own revival choices follow the projected serial turn. Responder + * powers use their separate commerce controls and do not use this guard. */ +export function revivalTurnOrderBlock(game: GameView): string | null { + const order = game.revivalOrder; + if (!game.advanced || !order) return null; + if (order.version !== 1 || order.turn !== game.turn || game.status !== 'playing' || game.phase !== 4) + return 'This Revival turn is no longer current.'; + if (!game.players.some(player => player.id === game.me) || !order.original.includes(game.me)) + return 'This seat has no Revival turn in the current order.'; + if (order.completed.includes(game.me) || !order.remaining.includes(game.me)) + return 'Your Revival turn is finished.'; + const actor = order.remaining[0]; + if (actor !== game.me) + return game.revival.orderBlocked || `Waiting for ${game.players.find(player => player.id === actor)?.name ?? 'the current player'} to finish their Revival turn.`; + if (game.active !== actor) return 'The current Revival actor is unavailable.'; + return game.revival.orderBlocked || null; +} + +export function finishRevivalTurnBlocked(game: GameView): string | null { + if (!game.advanced || !game.revivalOrder) return 'No ordered Revival turn is available.'; + const order = revivalTurnOrderBlock(game); + if (order) return order; + if (game.response || game.decision || game.truthtrance || game.phaseOpening || + game.nexusTraitors?.pending || game.nexusCards?.waiting.length || game.revival.pending) + return 'Finish the current Revival response or decision before ending your turn.'; + if (game.automaticContinuationPending || game.players.find(player => player.id === game.me)?.autopilot) + return 'Wait for the current automatic action to finish.'; + return null; +} + +/** Hook-free actual callback; an unaccepted quote does not prevent finishing. */ +export function FinishRevivalTurn({ game, act, busy }: Props) { + const blocked = finishRevivalTurnBlocked(game); + const identity = JSON.stringify([game.turn, game.revivalOrder?.turn, game.revivalOrder?.original]); + return ; +} + +export function RevivalTurn({ game, act, busy }: Props) { + const order = game.revivalOrder; + if (!game.advanced || !order || game.phase !== 4) return null; + const name = (id: string) => game.players.find(player => player.id === id)?.name ?? id; + const blocked = finishRevivalTurnBlocked(game); + return
+

Revival turn order

+

{order.remaining.length ? `Current Revival actor: ${name(order.remaining[0])}.` : 'All Revival turns are finished.'}

+

Remaining Revival turns: {order.remaining.map(name).join(' → ') || 'None'}.

+

Completed Revival turns: {order.completed.map(name).join(', ') || 'None yet'}.

+

+ Provisional grouping: finish your force, leader and Kwisatz Haderach revival + choices together before ending your turn. Revival turns follow the printed + storm order for forces. Tleilaxu can still answer another player’s requests. +

+ {blocked && {blocked}} + +
; +} diff --git a/docs/ADVANCED_REVIVAL_ORDER.md b/docs/ADVANCED_REVIVAL_ORDER.md new file mode 100644 index 00000000..bc95c51d --- /dev/null +++ b/docs/ADVANCED_REVIVAL_ORDER.md @@ -0,0 +1,89 @@ +# Advanced Revival player order + +26 October 2026. **Prototyped / Partial.** This is one core source cutover, +not complete Advanced or optional-module certification. + +## Source and interpretation + +The user-authorized `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, +physical9/printed7, expressly starts **Force Revival** with the First Player +and proceeds counterclockwise. It is the supplied unofficial Advanced authority +bound by the [source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026), +not a newly located publisher ruling. Reproduce the bounded read with: + +```sh +pdftotext -f 9 -l 9 -layout UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf - +``` + +The adjacent Leader Revival paragraph says leader returns are in addition to +force revival. This preview explicitly composes native force and leader/KH/ +foreign-Ghola choices into one participant opportunity. The source does not +independently print a separate leader queue. Basic's publisher text supplies +no matching Force Revival sequence, so Basic keeps concurrent readiness. + +## Connected behavior + +- A new Advanced Revival opens a version1 public order using the actual + First Player order already determined by Storm. Only its first remaining + participant can make ordinary self returns, negotiate/request/accept a native + leader return, or explicitly finish. Accepted native requests mark that + opportunity started; subsequent requests retain it. +- Tleilaxu may quote or decline the current request and offer native limit/ + discount grants outside its own opportunity. Fremen grants remain separate. + Emperor can pay for extra allied forces during the beneficiary's current + opportunity. The response remains owned by its original authorized actor. +- A committed return and all required payment, income, card, placement and + arrival decisions finish before ordinary Finish. Answering a response does + not advance or consume the responder's own opportunity. +- Finish withdraws any outstanding unaccepted own leader negotiation, adds + exactly that participant to the completed prefix and advances once. Completed + native turns cannot reopen through another return, Finish or an early + Truthtrance shipment/movement commitment. +- Card effects, Nexus and special Karama retain their separate existing timing. + The guard targets native self actions rather than all actions in the phase. + Private future-shipment witnesses may still consider a later owned Revival + preparation; their policy waits for the real opportunity before proposing it. +- Closing decisions retain the completed frame until the phase actually ends. + The next phase removes it. A phase already opened without the frame retains + its legacy readiness semantics; projection never manufactures a queue midway. + +`game/revival-order.ts` owns the immutable order calculation and validation; +the engine owns phase lifecycle, transactions and projection. Controls use +`components/revival-turn.tsx`, the existing native return buttons and commerce +controls. Minimal AI follows the same projected availability. No action kind, +database migration or saved-game conversion is added. + +## Evidence and remaining scope + +Focused cases are `tests/revival-order.test.ts`, +`tests/advanced-revival-order-engine.test.ts` and +`tests/advanced-revival-order-controls.test.tsx`. Their native source is +`tests/fixture-advanced-revival-order.ts`: public E/A/T plus the original Ix +deck, actual setup and first Revival; the return variant additionally earns +Tanks through paid shipment and a later actual Storm. Controlled old-envelope +and prior leader-death supplements are labelled separately. + +Frozen proof passes **12 files,105/105**, types/lint/build and **nine runtime/ +control paths** with13 GameTable SSR renders. Seven paths are native: first +ordered Revival (41 source actions), played normal1 shipment for2 and actual +later card-Storm casualty/free revival (99), Basic readiness (38), and four +minimal policies with5 further actions each. Native47 Treachery identities, +the returned ordinary counter and separate Tleilaxu bank1 remain. The other two +paths are explicitly controlled old-envelope and leader-death supplements. + +Source-driver readiness and a stale Hajr test now use actual current Revival +and immediate post-movement controls. Feedback also removes false later-Storm +dials and mandatory uncontested-income acknowledgements, uses semantic status +markup and narrows an optional test ledger. Application source is unchanged +between successful `proof-v5` tests/types/lint/build and `proof-v6` runtime; +only the private SSR loader needed its existing Vinext aliases/CSS mapping. +Combined report and source manifest are in +`/home/quintine/.local/share/dune/checkpoints/20261026-advanced-revival-order-proof-v6/`; +journeys/HTML are adjacent `20261026-advanced-revival-order-runtime/`. +This is SSR/selected-props callback evidence. No saved-room/D1/server, reset, +restart, deployment or recovery operation occurs. + +Sapho's new Revival adapter, broader combinations, full rules review, +save/recovery/privacy/custody assurance, browser/mobile and exact-revision +deployed acceptance remain due. Existing games, the local PDF and hosting +configuration are preserved. No mode/publication gate opens. diff --git a/docs/BASE_ADVANCED_READINESS_20260907.md b/docs/BASE_ADVANCED_READINESS_20260907.md index 49b9cc98..017d6db2 100644 --- a/docs/BASE_ADVANCED_READINESS_20260907.md +++ b/docs/BASE_ADVANCED_READINESS_20260907.md @@ -1,5 +1,12 @@ # Six-base-faction Advanced readiness audit +**26 October follow-up:** supplied Advanced physical9/printed7 orders Force +Revival from the First Player counterclockwise. A fresh-phase cutover now +connects that native queue, controls and minimal policies, with explicit +force/leader grouping and unchanged Basic/already-opened legacy timing. +[Current contract and evidence](ADVANCED_REVIVAL_ORDER.md). This supersedes +the original concurrent Revival behavior without certifying complete Advanced. + Read-only runtime audit, 7 September 2026. Scope is classic GF9 Advanced with any valid selection of the six original factions, two through six players, **no expansion and no optional module**. This document does not enable a mode or certify complete rules compliance. It reads the full active goal and preserves its wider twelve-faction scope. ## Finding diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f35ad599..6c9bf60a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,38 @@ open mode gates. ## Current checkpoint and work +**Advanced Revival player order — 26 October. Prototyped / Partial.** Supplied +Advanced physical9/printed7 explicitly orders Force Revival from the First +Player counterclockwise. New Advanced phases now connect that native order, +ordinary returns, negotiations and explicit Finish. Grouping native leader/KH/ +foreign-Ghola choices in the same opportunity is an explicit composition. +Required responders remain authorized out of turn; Emperor-funded extras use +the beneficiary's opportunity. Basic and an already-opened unstamped phase keep +their readiness timing. Card/Nexus/special-Karama timing remains distinct. +Persistent actor/remaining/completed controls and guarded Finish feed minimal +policies, including a prevented Tleilaxu still answering another request. + +Three disjoint helper/native/control lanes and root integration pass **12 files, +105/105**, types/lint/build and **nine runtime paths**,13 GameTable SSR renders +and actual selected-props Finish callbacks. Seven paths are native: E/A/T +ix first +Revival (41 source actions), actual paid normal1 shipment for2, next card Storm +loss and turn-two free return (99), Basic readiness (38), and four minimal +policies completing in5 further actions each. Native47 Treachery identities, +ordinary return and separate Tleilaxu bank1 remain. Two labelled controlled +old-envelope/leader-death paths supplement that played evidence. + +Feedback repairs stale source-driver readiness and Hajr timing, mistaken +Storm-dial/compulsory-income-pause assumptions, semantic status markup and a +nullable test ledger. Tests/types/lint/build pass in `proof-v5`; correcting only +the private Vinext SSR loader produces runtime `proof-v6` on identical source. +Combined report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-advanced-revival-order-proof-v6/`; +journeys/HTML: adjacent `20261026-advanced-revival-order-runtime/`. +No saved-room/D1/server, reset/restart, deployment or recovery operation occurs. +SSR/callback proof does not establish browser/mobile/deployed or full-mode +acceptance. Sapho's additional Revival adapter follows this native prerequisite; +the full goal remains unfinished. [Source and contract](ADVANCED_REVIVAL_ORDER.md). + **Independent-effect delivery inventory — 26 October. Prototyped / Partial.** A bounded read-only trace of the helpers, engine, owned controls and minimal legal policies finds connected first versions for the following families: diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 6440921b..aa836b3f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 26 October 2026 — Advanced native Revival player order + +**Prototyped / Partial.** Adopt supplied Advanced physical9/printed7 First +Player/counterclockwise Force Revival order for fresh phases. Native self +returns, requester negotiations and explicit Finish use the current participant; +leader/KH/foreign-Ghola grouping is visible implementation composition. Required +support/response actors retain their permissions; Emperor-funded extras use the +beneficiary's opportunity. Basic and already-opened unstamped phases retain +readiness. Card/Nexus/special-Karama timing stays distinct. Controls show the +public order and guard actual Finish; minimal policies preserve responder quotes +even when their own returns are prevented. Early commitments cannot reopen +completed native turns. + +Frozen **12-file105/105**, types/lint/build and **nine runtime/control paths** +with13 GameTable SSR renders pass: seven native and two labelled controlled +supplements. Genuine E/A/T +ix source programmes use41 first-Revival actions, +99 through paid normal1 shipment2 and later card-Storm death/free return, and38 +for Basic. Four policies complete ordered Revival in5 further actions each. +Native47 Treachery identities, one ordinary return and separate Tleilaxu bank1 +remain. Feedback repairs source-driver readiness, stale Hajr timing, source +Storm/income assumptions, semantic markup and a nullable test ledger. The private +SSR loader correction changes no application source; `proof-v5` checks and +`proof-v6` runtime share the exact source tree. + +Combined report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-advanced-revival-order-proof-v6/`; +journeys/HTML: adjacent `20261026-advanced-revival-order-runtime/`. No saved-room/ +D1/server, reset/restart, deployment or recovery operation. Browser/mobile, +deployed and full-mode acceptance remain due; Sapho's additional adapter follows +the native prerequisite. [Source and scope](ADVANCED_REVIVAL_ORDER.md). + ## 26 October 2026 — Independent-effect delivery inventory **Prototyped / Partial.** Three disjoint read-only traces inspect actual diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 837bad8a..3b1f56e1 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,22 @@ # Rule decision index +## Advanced Revival player order — 26 October 2026 + +Supplied authorized revision physical9/printed7 expressly orders **Force +Revival** from the First Player counterclockwise. Newly opened Advanced +Revival phases now use that source. Basic and an already-opened unstamped +Advanced phase retain their prior readiness timing; no live conversion occurs. + +Keeping native leader/KH/foreign-Ghola choices and requester negotiations inside +the same participant opportunity is an explicit implementation composition of +the adjacent “in addition” leader paragraph. It is not separately printed +whole-phase ordering. Native reactions/support remain with their authorized +responder; Emperor-funded extra forces use the beneficiary's opportunity. +Card effects, Nexus and special Karama retain distinct timing. Explicit Finish +withdraws an outstanding own leader negotiation. Sapho's additional adapter +follows separately; no full-mode gate opens. +[Source, behavior and evidence](ADVANCED_REVIVAL_ORDER.md). + ## Sandmaster flight and concealed-marker collection — 26 October 2026 Compose the moving-forces normal band with both physical Ornithopter modes, diff --git a/game/bots.ts b/game/bots.ts index 1e538605..264b25b0 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3189,8 +3189,6 @@ function policyActions(g: GameView): Action[] { prevention.targets.some(choice => choice.player === player.id && !choice.blocked)); if (target) return [{ type: 'card', mode: 'special', card: prevention.cards[0], target: target.id }]; } - if (g.revival.prevented) - return g.ready.includes(me.id) ? [] : [{ type: 'ready' }]; const actions: Action[] = []; const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu'); const ordinaryLeaderAvailable = g.revival.leaders.some((l) => !l.early); @@ -3365,13 +3363,21 @@ function policyActions(g: GameView): Action[] { actions.push({ type: 'reviveLeader', leader: leader.id }); if ( !g.ready.includes(me.id) && - !( + (g.revival.prevented || !( g.revivalRequests[me.id]?.price === null && !g.revivalRequests[me.id]?.declined - ) + )) ) actions.push({ type: 'ready' }); - return actions; + return actions.filter(action => { + if (['revive','reviveLeader','reviveKwisatz','reviveForeignGhola','requestLeaderRevival', + 'acceptLeaderRevival','cancelLeaderRequest','ready'].includes(action.type)) + return !g.revival.orderBlocked && + (action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented); + if (action.type === 'emperorRevival' && g.revivalOrder) + return g.revivalOrder.remaining[0] === me.ally; + return true; + }); } if (g.phase === 5) { const kulon = choamPowerBotPlay(g, 'kulon', (card) => technologyCardValue(g, card)); diff --git a/game/engine.ts b/game/engine.ts index c3b767eb..cbcc0116 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,4 +1,6 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; +import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder, + finishRevivalOrder, RevivalOrderError, type RevivalOrder } from './revival-order'; import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival'; import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival'; import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival'; @@ -1307,6 +1309,7 @@ type RicheseAllyOffer = { payer: string; }; export type Game = { + revivalOrder?: RevivalOrder; pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[]; pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport; pendingAutomaticAdvisorGamont?: { receipt: AutomaticAdvisorGamont; card: string; nexusEvent?: string }; @@ -17990,6 +17993,7 @@ function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null { const current = completion.early?.find(step => step.phase === g.phase)?.action; if (g.decision?.kind === 'auctionPayment') return [current?.type === 'decision' ? current : { type: 'decision', karama: false }]; + if (g.phase === 4 && nativeRevivalOrderBlock(g,player.id)) return []; if (g.phase === 2 || g.phase === 4) return current ? [current] : g.ready.includes(player.id) ? [] : [{ type: 'ready' }]; return null; } @@ -17998,13 +18002,13 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn const answers: TruthAnswer[] = []; for (const answer of [true, false]) { const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer }; - const answerSource = g.phase === 2 || g.phase === 4 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; if (groundMovementCompletion(answerSource, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no'); } return answers.length ? answers : ['unknown']; } function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) { - if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) { + if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) { g.ready = g.ready.filter(id => id !== p.id); log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`); } @@ -23556,7 +23560,7 @@ function advancePhase(g: Game) { requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.'); if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g)); commitPhaseResources(g, currentPhaseResources(g)); - g.ready = []; + if (!(g.phase === 4 && g.revivalOrder)) g.ready = []; g.active = null; const choam = byFaction(g, 'choam'); if (g.phase === 8 && choam) { @@ -23595,6 +23599,10 @@ function finishEcazPlacement(g: Game) { } function completePhase(g: Game) { if (closeNexusCards(g)) return; + if (g.phase === 4 && g.revivalOrder) { + delete g.revivalOrder; + g.ready = []; + } if (g.phase === 6) { delete g.battleOrder; delete g.battleOrderUseEvents; @@ -23632,6 +23640,33 @@ function openPhase(g: Game, initialize = true) { if (g.expansions.includes('ix')) g.phaseOpening = { passed: [], initialize }; else if (initialize) beginPhase(g); } +function revivalOrderContext(g: Game) { + return {turn:g.turn,order:g.order,ready:g.ready}; +} +function revivalOrderRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { + if (error instanceof RevivalOrderError) throw new RuleError(error.message); + throw error; + } +} +function revivalOrderIntegrity(g: Game) { + if (!g.revivalOrder) return; + requireRule(g.advanced && g.status === 'playing' && g.phase === 4, + 'The ordered Revival belongs to its original Advanced phase.'); + revivalOrderRule(() => validateRevivalOrder(revivalOrderContext(g),g.revivalOrder!)); + requireRule(g.active === (g.revivalOrder.remaining[0] ?? null), + 'Revival must retain its current player through the complete opportunity.'); +} +function nativeRevivalOrderBlock(g: Game, player: string): string | null { + return g.revivalOrder + ? revivalOrderRule(() => revivalOrderBlock(revivalOrderContext(g),g.revivalOrder!,player)) + : null; +} +function startNativeRevivalOrder(g: Game, player: string) { + if (g.revivalOrder) + g.revivalOrder = revivalOrderRule(() => startRevivalOrder(revivalOrderContext(g),g.revivalOrder!,player)); +} function beginPhase(g: Game) { if (g.phase === 0) { g.auditorInsight = null; @@ -23661,6 +23696,11 @@ function beginPhase(g: Game) { p.freeForcesRevived = 0; p.leaderRevived = false; } + if (g.advanced) { + g.revivalOrder = revivalOrderRule(() => createRevivalOrder(revivalOrderContext(g))); + g.active = g.revivalOrder.remaining[0] ?? null; + log(g, 'Advanced Force Revival proceeds from the First Player counterclockwise. Finish your Revival turn after your native force and leader choices; authorized responses and card timing remain available.'); + } } if (g.phase === 3) setAuction(g); if (g.phase === 5) { @@ -26152,6 +26192,7 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string } return revivalSeeds.map(seed => { seed.game.phase = 5; seed.game.ready = []; seed.game.karamaShipping = null; + delete seed.game.revivalOrder; const p = getPlayer(seed.game, owner); p.shipped = false; p.moved = 0; return seed; }); @@ -26356,6 +26397,7 @@ function earlyShipmentGuidance(g: Game, p: Player): { preparation: { actions: Ac ? [{type:'richeseBid',event:lot.event,amount:lot.method === 'silent' ? 0 : null,allyPayment:0}] : []}; return { preparation: null, policy: g.auction?.active === p.id ? [{ type: 'passBid' }] : [] }; } + if (g.phase === 4 && nativeRevivalOrderBlock(g,p.id)) return {preparation:null,policy:[]}; if (g.phase === 2 || g.phase === 4) return { preparation: current ? { actions: [current] } : null, policy: current ? [current] : g.ready.includes(p.id) ? [] : [{ type: 'ready' }] }; @@ -26373,8 +26415,8 @@ function shipmentTruthAnswers( ...prior, { ...claim, turn: g.turn, player: p.id, asker: '', answer }, ]; - // A new commitment reopens its respondent's owned Charity/Revival Ready. - const answerSource = g.phase === 2 || g.phase === 4 + // Concurrent legacy readiness can reopen; a completed ordered turn cannot. + const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; if (findShipmentCompletion(answerSource, p, promises)) answers.push(answer ? 'yes' : 'no'); @@ -26389,7 +26431,7 @@ function bindShipmentTruth( asker: string, mixed?: MixedShipmentExpression, ) { - if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) { + if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) { g.ready = g.ready.filter(id => id !== p.id); log(g, `${p.name} has a new Truthtrance shipment commitment and must confirm readiness again after completing any needed preparation.`); } @@ -34289,6 +34331,7 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + revivalOrderIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); if (state.spiceBankerIncomePreview) { @@ -34541,6 +34584,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { arrivalOverlapIntegrity(g); guildBetrayalIntegrity(g); harkonnenNexusBetrayalIntegrity(g); + revivalOrderIntegrity(g); return g; } function finishActionContinuations(g: Game) { @@ -37592,6 +37636,7 @@ function applyActionInner( g.phase === 4, 'Extra revivals are available during Revival.', ); + if (execution === 'live') startNativeRevivalOrder(g,recipient.id); } const maximum = t === 'emperorGift' @@ -37700,6 +37745,8 @@ function applyActionInner( 'Tleilaxu leader negotiations require the Revival phase.', ); g.revivalRequests ??= {}; + if (execution === 'live' && t !== 'quoteLeaderRevival' && t !== 'declineLeaderRevival') + startNativeRevivalOrder(g,id); if (t === 'cancelLeaderRequest') { delete g.revivalRequests[id]; return g; @@ -37783,6 +37830,7 @@ function applyActionInner( g.advanced && g.phase === 4 && p.faction === 'tleilaxu', 'Foreign gholas require advanced Tleilaxu during Revival.', ); + if (execution === 'live') startNativeRevivalOrder(g,id); requireRule(action.leader !== DUKE_VIDAL_ID, 'Only Ecaz may revive Duke Vidal, including with Ghola.'); const leader = g.players @@ -37826,6 +37874,7 @@ function applyActionInner( } if (t === 'revive') { requireRule(g.phase === 4, 'Revival is not the current phase.'); + if (execution === 'live') startNativeRevivalOrder(g,id); const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces'); const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite); requireRule( @@ -37876,6 +37925,7 @@ function applyActionInner( g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'), 'Only one leader revival is permitted this phase.', ); + if (execution === 'live') startNativeRevivalOrder(g,id); requireRule( !revivalPrevented(g, p.id), 'Tleilaxu prevented this faction’s normal revivals for this turn.', @@ -38836,6 +38886,12 @@ function applyActionInner( 'Complete the current phase action first.', ); requireRule(!g.ready.includes(id), 'You are already ready.'); + if (g.phase === 4 && g.revivalOrder) { + g.revivalOrder = revivalOrderRule(() => finishRevivalOrder(revivalOrderContext(g),g.revivalOrder!,id)); + g.active = g.revivalOrder.remaining[0] ?? null; + if (g.revivalRequests) delete g.revivalRequests[id]; + log(g, `${p.name} finished their Revival turn.`,{faction:p.faction,name:'Revival turn complete'}); + } g.ready.push(id); if (g.ready.length === g.players.length) { if (g.phase === 0) startStormMovement(g, g.stormPending!); @@ -38850,6 +38906,7 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { + revivalOrderIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); harkonnenNexusBetrayalIntegrity(state); @@ -39711,7 +39768,9 @@ export function viewGame(state: Game, id: string) { ([player]) => player === id || me.faction === 'tleilaxu', ), ), + revivalOrder: g.revivalOrder ? structuredClone(g.revivalOrder) : null, revival: { + orderBlocked: nativeRevivalOrderBlock(g,id), pending: !!g.pendingRevival, specialKaramaBlock: null, leaders: leaderRevivals.leaders, diff --git a/game/reference.ts b/game/reference.ts index 6b0fb251..2cea850d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -46,7 +46,7 @@ export const PHASE_HELP = [ 'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.', 'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.', 'Bid or pass in turn. A winning bidder chooses how to pay before income and bonus-card responses resolve.', - 'Return eligible forces and leaders from the tanks to your reserves. Finish when your revival choices are complete.', + 'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases proceed from the First Player counterclockwise: finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.', 'Ship before moving. Select a destination sector and, for a move, the forces leaving one territory.', 'Choose a battle, resolve faction powers, seal plans, decide traitors, then choose which winning cards to keep.', 'Forces collect spice in their own sector. Review the results, then continue to Mentat Pause.', @@ -3776,6 +3776,30 @@ export const RULE_TOPICS: RuleTopic[] = [ 'card-harvester', ], }, + { + id: 'advanced-revival-order', + title: 'Advanced Revival: player order', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Take native Revival choices from the First Player counterclockwise, then explicitly finish your turn.', + steps: [ + 'A newly opened Advanced Revival follows the adopted revised rules: the First Player acts first, then the remaining players counterclockwise. The panel names the current player, remaining turns and completed turns.', + 'During your turn, choose ordinary forces, your eligible leader or Kwisatz Haderach, and any native leader negotiation. This preview groups native force and leader choices into one opportunity; the source explicitly orders Force Revival and describes leader returns in addition.', + 'Tleilaxu can answer another player’s request, grant a larger limit or offer an ally discount outside its own turn. Fremen can grant allied free revival. Emperor can fund its ally’s extra forces during that ally’s opportunity. Finish every accepted return and its required responses before ending the turn.', + 'Choose Finish Revival turn when done. An unanswered leader request is withdrawn by this explicit finish. Completed native turns stay finished. Card effects, Nexus cards, special Karama and their responses keep their separate timing; taking part in a response does not consume your own turn.', + 'Basic retains concurrent readiness. An already-opened older Advanced Revival finishes its saved readiness timing; the next new Advanced Revival uses player order. This does not redeal or restart a game.', + 'Sapho’s additional Revival-order adapter remains unfinished. Full Advanced games, optional combinations and final multiplayer/deployed acceptance remain open.', + ], + checklist: [ + {area:'Implementation',status:'Partial',detail:'Fresh Advanced Revival has one current native opportunity, explicit completion and retained response ownership. Leader grouping is an explicit composition; Basic and unstamped current phases retain prior timing.',evidence:['game/revival-order.ts','game/engine.ts']}, + {area:'Player controls',status:'Partial',detail:'Persistent current/remaining/completed order, guarded Finish and native-return/commerce availability use the projected opportunity.',evidence:['components/revival-turn.tsx','components/revival-commerce.tsx','components/game-table.tsx']}, + {area:'AI',status:'Partial',detail:'Minimal policies wait for native self returns and Finish while retaining authorized responder and card actions. Strategy and calibration remain deferred.',evidence:['game/bots.ts']}, + {area:'Documentation',status:'Partial',detail:'Supplied Advanced physical9/printed7 authority, explicit leader composition and fresh-phase cutover are recorded.',evidence:['docs/ADVANCED_REVIVAL_ORDER.md','docs/RULE_DECISIONS.md']}, + {area:'Verification',status:'Partial',detail:'Focused helper, played native order/return and guarded control cases cover the changed path. Full-mode, browser/mobile, recovery and deployed acceptance remain due.',evidence:['tests/revival-order.test.ts','tests/advanced-revival-order-engine.test.ts','tests/advanced-revival-order-controls.test.tsx','tests/fixture-advanced-revival-order.ts']}, + ], + related: ['advanced-storm-spice','tleilaxu-revival','choam-revival','juice-of-sapho'], + }, { id: 'advanced-allied-separation', title: 'Advanced alliances: ending shared occupation', diff --git a/game/revival-order.ts b/game/revival-order.ts new file mode 100644 index 00000000..9e68ab7a --- /dev/null +++ b/game/revival-order.ts @@ -0,0 +1,91 @@ +/** One native Revival opportunity per faction in public First Player order. + * Leader decisions remain within that faction's opportunity; this grouping is + * an implementation composition of the supplied Advanced physical9/printed7. */ +export type RevivalOrder = { + version: 1; + turn: number; + original: string[]; + remaining: string[]; + completed: string[]; + started: boolean; +}; +export type RevivalOrderContext = { + turn: number; + order: readonly string[]; + ready: readonly string[]; +}; +export class RevivalOrderError extends Error { + constructor(message: string) { + super(message); + this.name = 'RevivalOrderError'; + } +} +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +const id = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; +const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(id); +const same = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((value, index) => value === b[index]); +function requireOrder(condition: unknown, message: string): asserts condition { + if (!condition) throw new RevivalOrderError(message); +} +function validateContext(context: RevivalOrderContext): void { + requireOrder(record(context) && Number.isSafeInteger(context.turn) && context.turn > 0 && + ids(context.order) && context.order.length >= 2 && context.order.length <= 6 && + new Set(context.order).size === context.order.length, + 'Revival needs a current turn and two through six distinct factions in public order.'); + requireOrder(ids(context.ready) && same(context.ready, context.order.slice(0, context.ready.length)), + 'Completed Revival factions must be the public-order prefix.'); +} +function copy(frame: RevivalOrder): RevivalOrder { + return { version: 1, turn: frame.turn, original: [...frame.original], + remaining: [...frame.remaining], completed: [...frame.completed], started: frame.started }; +} + +export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder { + validateContext(context); + return { version: 1, turn: context.turn, original: [...context.order], + remaining: context.order.slice(context.ready.length), completed: [...context.ready], started: false }; +} + +export function validateRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder): void { + validateContext(context); + requireOrder(record(frame) && Object.keys(frame).sort().join(',') === + 'completed,original,remaining,started,turn,version' && frame.version === 1 && + Number.isSafeInteger(frame.turn) && frame.turn === context.turn && + ids(frame.original) && ids(frame.remaining) && ids(frame.completed) && typeof frame.started === 'boolean', + 'The current Revival order is malformed or belongs to another turn.'); + requireOrder(same(frame.original, context.order) && same(frame.completed, context.ready) && + same(frame.remaining, frame.original.slice(frame.completed.length)), + 'The Revival order must retain the original public order and its completed prefix.'); + requireOrder(frame.remaining.length > 0 || !frame.started, + 'Completed Revival cannot retain a started opportunity.'); +} + +export function revivalOrderBlock(context: RevivalOrderContext, frame: RevivalOrder, player: string): string | null { + validateRevivalOrder(context, frame); + requireOrder(id(player), 'Choose a valid Revival faction.'); + if (!frame.original.includes(player)) return 'Only seated factions have a Revival opportunity.'; + if (!frame.remaining.length) return 'Revival opportunities are complete.'; + if (frame.completed.includes(player)) return 'Your Revival opportunity has finished.'; + return frame.remaining[0] === player ? null : 'Wait for your Revival opportunity.'; +} + +/** Mark an accepted self request without consuming or reopening the opportunity. + * Further requests by the same current faction retain this idempotent stamp. */ +export function startRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, player: string): RevivalOrder { + const blocked = revivalOrderBlock(context, frame, player); + requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.'); + return { ...copy(frame), started: true }; +} + +/** Receives the pre-completion ready prefix. The caller installs this frame and + * appends the same current faction to ready in its one accepted transaction. */ +export function finishRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, player: string): RevivalOrder { + const blocked = revivalOrderBlock(context, frame, player); + requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.'); + const next = { ...copy(frame), remaining: frame.remaining.slice(1), + completed: [...frame.completed, player], started: false }; + validateRevivalOrder({ ...context, ready: [...context.ready, player] }, next); + return next; +} diff --git a/tests/advanced-revival-order-controls.test.tsx b/tests/advanced-revival-order-controls.test.tsx new file mode 100644 index 00000000..46d68a92 --- /dev/null +++ b/tests/advanced-revival-order-controls.test.tsx @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { RevivalTurn, FinishRevivalTurn, finishRevivalTurnBlocked, revivalTurnOrderBlock } from '../components/revival-turn'; +import { RevivalCommerce } from '../components/revival-commerce'; +import { viewGame, type Action, type GameView } from '../game/engine'; +import { createAdvancedRevivalOrderFixture, actAdvancedRevivalOrder } from './fixture-advanced-revival-order'; + +let original: ReturnType | undefined; +function native() { + original ??= createAdvancedRevivalOrderFixture(); + return { ...original, game: structuredClone(original.game) }; +} +function projected() { + const source = native(); + return viewGame(source.game, source.actor); +} +function markup(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(RevivalTurn, { game, busy, act() {} })); +} +function commerce(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(RevivalCommerce, { game, busy, act() {} })); +} +function button(html: string, label: string) { + const found = [...html.matchAll(/]*>[\s\S]*?<\/button>/g)].map(match => match[0]) + .find(value => value.includes(`>${label}`)); + assert.ok(found, `The ${label} button is visible`); return found; +} +function click(game: GameView, busy = false): Action[] { + const actions: Action[] = []; + FinishRevivalTurn({ game, busy, act(action) { actions.push(action); } }).props.onClick(); + return actions; +} +function controlledCommerce(viewer: string, actor = viewer): GameView { + const source = native(), game = viewGame(source.game, viewer); + const originalOrder = [actor, ...game.players.map(player => player.id).filter(id => id !== actor)]; + // Controlled personalized order/leader/negotiation cases supplement the + // native source below; they install no authoritative Game outcome or pieces. + game.revivalOrder = { version: 1, turn: game.turn, original: originalOrder, + remaining: [...originalOrder], completed: [], started: false }; + game.active = actor; + game.revival.orderBlocked = actor === viewer ? null : 'Wait for the current Revival actor.'; + return game; +} + +void test('native first Revival renders persistent actor remaining completed and explicit force/leader grouping', () => { + const source = native(), game = viewGame(source.game, source.actor), before = structuredClone(game); + assert.deepEqual(game.revivalOrder!.original, source.originalOrder); + for (let render = 0; render < 3; render++) { + const html = markup(game); + assert.match(html, /aria-label="Revival turn order"/); + assert.ok(html.includes(`Current Revival actor: ${game.players.find(player => player.id === source.actor)!.name}.`)); + assert.match(html, /Remaining Revival turns:/); + assert.match(html, /Completed Revival turns: None yet/); + for (const id of game.revivalOrder!.remaining) assert.ok(html.includes(game.players.find(player => player.id === id)!.name)); + assert.match(html, /aria-label="Provisional Revival grouping"/); + assert.match(html, /force, leader and Kwisatz Haderach revival choices together/); + assert.match(html, /Tleilaxu can still answer another player’s requests/); + assert.doesNotMatch(button(html, 'Finish Revival turn'), /\sdisabled=/); + } + assert.deepEqual(game, before); +}); + +void test('the actual native Finish callback sends only ready and advances to the next original seat', () => { + const source = native(), game = viewGame(source.game, source.actor); + assert.deepEqual(click(game, true), []); + const actions = click(game); + assert.deepEqual(actions, [{ type: 'ready' }]); + const next = actAdvancedRevivalOrder(source.game, source.actor, actions[0]); + assert.equal(next.phase, 4); + assert.deepEqual(next.revivalOrder!.completed, [source.actor]); + assert.equal(next.active, next.revivalOrder!.remaining[0]); + const finished = viewGame(next, source.actor); + assert.match(markup(finished), /Your Revival turn is finished/); + assert.match(button(markup(finished), 'Finish Revival turn'), /\sdisabled=/); + assert.deepEqual(click(finished), []); + assert.deepEqual(click(viewGame(next, next.active!)), [{ type: 'ready' }]); +}); + +void test('each native seat can explicitly finish once and the last actual control leaves Revival', () => { + const source = native(); let game = source.game; + const actors: string[] = []; + while (game.phase === 4 && actors.length < source.originalOrder.length) { + const actor = game.revivalOrder!.remaining[0]; + const actions = click(viewGame(game, actor)); + assert.deepEqual(actions, [{ type: 'ready' }]); + actors.push(actor); game = actAdvancedRevivalOrder(game, actor, actions[0]); + } + assert.deepEqual(actors, source.originalOrder); + assert.equal(game.phase, 5); + assert.equal(markup(viewGame(game, source.owner)), ''); +}); + +void test('genuine Basic and unstamped legacy Advanced views retain their existing controls without this component', () => { + const basic = createAdvancedRevivalOrderFixture({ advanced: false }); + const basicView = viewGame(basic.game, basic.actor); + assert.equal(basicView.revivalOrder, null); + assert.equal(markup(basicView), ''); + assert.deepEqual(click(basicView), []); + const legacy = projected(); legacy.revivalOrder = null; + assert.equal(markup(legacy), ''); assert.deepEqual(click(legacy), []); + assert.equal(revivalTurnOrderBlock(legacy), null); +}); + +void test('a different native viewer sees the current actor and a waiting reason while the actual callback is unavailable', () => { + const source = native(), game = viewGame(source.game, source.outOfTurn); + const html = markup(game); + assert.match(html, /Current Revival actor:/); + assert.ok(finishRevivalTurnBlocked(game)); + assert.match(button(html, 'Finish Revival turn'), /\sdisabled=/); + assert.deepEqual(click(game), []); + game.revival.orderBlocked = ''; + assert.deepEqual(click(game), [], 'Empty projected notices do not bypass the actual owner'); +}); + +void test('busy finished and inconsistent current order projections guard the actual callback', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.revivalOrder!.completed.push(game.me); }, + game => { game.revivalOrder!.remaining = []; }, + game => { game.revivalOrder!.turn++; }, + game => { game.turn++; }, game => { game.phase = 5; }, + game => { game.status = 'finished'; }, game => { game.active = null; }, + game => { game.revival.orderBlocked = 'The current native turn is unavailable.'; }, + ]; + for (const change of changes) { + const game = projected(); change(game); + assert.deepEqual(click(game), []); + assert.equal(FinishRevivalTurn({ game, busy: false, act() {} }).props.disabled, true); + } + assert.deepEqual(click(projected(), true), []); + const finished = projected(); finished.revivalOrder!.remaining = []; + assert.match(markup(finished), /All Revival turns are finished/); +}); + +void test('pending Revival response decision automatic action and owned autopilot prevent early finishing', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.response = { kind: 'revivalIncome', owner: game.me, passed: [] }; }, + game => { game.decision = { kind: 'revivalStop', player: game.me, recipient: game.me, revival: 'forces' }; }, + game => { game.truthtrance = {} as NonNullable; }, + game => { game.phaseOpening = { passed: [] }; }, + game => { game.revival.pending = true; }, + game => { game.automaticContinuationPending = true; }, + game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; }, + ]; + for (const change of changes) { + const game = projected(); change(game); + assert.deepEqual(click(game), []); + assert.match(button(markup(game), 'Finish Revival turn'), /\sdisabled=/); + } +}); + +void test('a stale actual callback cannot finish another actor or a new Revival round', () => { + for (const anotherRound of [false, true]) { + const game = projected(), actions: Action[] = []; + const leaf = FinishRevivalTurn({ game, busy: false, act(action) { actions.push(action); } }); + assert.equal(leaf.props.disabled, false); + if (anotherRound) { game.turn++; game.revivalOrder!.turn++; } + else { + game.revivalOrder!.completed.push(game.me); game.revivalOrder!.remaining.shift(); + game.active = game.revivalOrder!.remaining[0]; + } + leaf.props.onClick(); assert.deepEqual(actions, []); + } +}); + +void test('an unaccepted negotiated request does not remove the current explicit Finish choice', () => { + const game = projected(); + game.revivalRequests[game.me] = { leader: game.players.find(player => player.id === game.me)!.leaders[0].id, price: null }; + assert.deepEqual(click(game), [{ type: 'ready' }]); + assert.doesNotMatch(button(markup(game), 'Finish Revival turn'), /\sdisabled=/); +}); + +void test('controlled native request accept and withdrawal controls follow own turn without disabling the entire commerce panel', () => { + const source = native(); + for (const ownsTurn of [true, false]) { + const game = controlledCommerce(source.owner, ownsTurn ? source.owner : source.responder); + const me = game.players.find(player => player.id === game.me)!; + me.spice = 50; me.leaders[0].dead = true; + game.revivalRequests[game.me] = { leader: me.leaders[0].id, price: 2 }; + const html = commerce(game); + for (const label of ['Accept revival price', 'Withdraw request']) { + if (ownsTurn) assert.doesNotMatch(button(html, label), /\sdisabled=/); + else assert.match(button(html, label), /\sdisabled=/); + } + delete game.revivalRequests[game.me]; + const request = commerce(game); + const selector = request.match(/]*id="revival-request-leader"[^>]*>/)?.[0]; + assert.ok(selector); + if (ownsTurn) assert.doesNotMatch(selector, /\sdisabled=/); + else assert.match(selector, /\sdisabled=/); + } +}); + +void test('controlled out-of-turn Tleilaxu can quote decline and support others while their own foreign ghola remains unavailable', () => { + const source = native(); + for (const ownsTurn of [true, false]) { + const game = controlledCommerce(source.responder, ownsTurn ? source.responder : source.owner); + const me = game.players.find(player => player.id === game.me)!; + const other = game.players.find(player => player.id === source.owner)!; + me.spice = 50; me.leaders[0].dead = true; me.ally = other.id; other.leaders[0].dead = true; + game.revivalRules.expanded = []; game.revivalRules.limitBlocked = false; + game.revivalRules.discountBlocked = false; game.revivalRules.allyDiscount = null; + game.revivalRequests[other.id] = { leader: other.leaders[0].id, price: null }; + const html = commerce(game); + const ghola = [...html.matchAll(/]*>[\s\S]*?<\/button>/g)].map(match => match[0]).find(value => value.includes('as a ghola')); + assert.ok(ghola); + if (ownsTurn) assert.doesNotMatch(ghola, /\sdisabled=/); + else assert.match(ghola, /\sdisabled=/); + for (const label of ['Quote this price', 'Decline request', 'Allow five revivals', 'Offer ally half-price revival']) + assert.doesNotMatch(button(html, label), /\sdisabled=/); + for (const label of ['Quote this price', 'Decline request', 'Allow five revivals', 'Offer ally half-price revival']) + assert.match(button(commerce(game, true), label), /\sdisabled=/); + } +}); + +void test('the order display and actual Finish callback read only public order actor names and owned action availability', () => { + const game = projected(); + for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves', 'leaders', 'forces', 'tanks']) + Object.defineProperty(player, field, { get() { throw Error(`Revival order read unrelated ${player.id}.${field}`); } }); + for (const field of ['homeworlds', 'revivalRequests', 'allLeaders']) + Object.defineProperty(game, field, { get() { throw Error(`Revival order read unrelated ${field}`); } }); + assert.match(markup(game), /Current Revival actor:/); + assert.deepEqual(click(game), [{ type: 'ready' }]); +}); diff --git a/tests/advanced-revival-order-engine.test.ts b/tests/advanced-revival-order-engine.test.ts new file mode 100644 index 00000000..4f41c3dd --- /dev/null +++ b/tests/advanced-revival-order-engine.test.ts @@ -0,0 +1,213 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + actAdvancedRevivalOrder, advanceAdvancedRevivalOrder, advancedRevivalOrderClean, + advancedRevivalOrderInventory, advancedRevivalOrderPlayer, advancedRevivalOrderStocks, + createAdvancedRevivalOrderFixture, finishAdvancedRevivalOrder, passAdvancedRevivalOrder, + reachAdvancedRevivalOrderActor, +} from './fixture-advanced-revival-order'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'A refused order action changes no original outcome'); +} +function current(game: Game) { + const actor = game.revivalOrder?.remaining[0]; assert.ok(actor); return actor; +} + +void test('public Advanced start earns the first Revival queue without replacing original physical state', () => { + const f = createAdvancedRevivalOrderFixture(); + assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 4); + assert.equal(f.sourceActions.length, f.sourceActionCount); assert.ok(f.sourceActionCount > 15); + assert.deepEqual(f.staging, []); + assert.ok(f.game.revivalOrder); + assert.deepEqual(f.game.revivalOrder, { version: 1, turn: 1, + original: f.originalOrder, remaining: f.originalOrder, completed: [], started: false }); + assert.equal(f.game.active, current(f.game)); + assert.deepEqual(advancedRevivalOrderInventory(f.game), f.originalInventory); + for (const player of f.game.players) { + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20); + assert.deepEqual(viewGame(f.game, player.id).revivalOrder, f.game.revivalOrder); + } + const view = viewGame(f.game, f.actor); assert.ok(view.revivalOrder); + view.revivalOrder.remaining.reverse(); + assert.deepEqual(f.game.revivalOrder.remaining, f.originalOrder, 'Projected order is a separate public snapshot'); +}); + +void test('native Advanced Revival rejects another participant return and finish before touching the source', () => { + const f = createAdvancedRevivalOrderFixture(); + rejected(f.game, f.outOfTurn, { type: 'ready' }); + rejected(f.game, f.outOfTurn, { type: 'revive', amount: 1, elite: 0 }); + assert.equal(f.game.active, f.actor); + assert.deepEqual(f.game.revivalOrder?.completed, []); +}); + +void test('native Advanced participants finish once in original order before Shipment opens', () => { + const f = createAdvancedRevivalOrderFixture(); + let game = reload(f.game); + for (let index = 0; index < f.originalOrder.length; index++) { + const actor = f.originalOrder[index]; + assert.equal(current(game), actor); assert.equal(game.active, actor); + const before = advancedRevivalOrderStocks(game); + game = actAdvancedRevivalOrder(game, actor, { type: 'ready' }); + assert.deepEqual(advancedRevivalOrderStocks(game), before); + if (index + 1 < f.originalOrder.length) { + assert.equal(game.phase, 4); + assert.deepEqual(game.revivalOrder?.original, f.originalOrder); + assert.deepEqual(game.revivalOrder?.completed, f.originalOrder.slice(0, index + 1)); + assert.deepEqual(game.revivalOrder?.remaining, f.originalOrder.slice(index + 1)); + rejected(game, actor, { type: 'ready' }); + rejected(game, actor, { type: 'revive', amount: 1 }); + } + } + game = advanceAdvancedRevivalOrder(game, state => state.phase === 5 && advancedRevivalOrderClean(state)); + assert.equal(game.phase, 5); + assert.deepEqual(advancedRevivalOrderInventory(game), f.originalInventory); +}); + +void test('native paid shipment and next Storm earn an ordered force return and separate Tleilaxu income', () => { + const f = createAdvancedRevivalOrderFixture({ casualty: true }); + assert.ok(f.shipment && f.storm); assert.equal(f.game.turn, 2); + assert.ok(f.shipment.steps.some(step => step.actor === f.owner && step.action.type === 'ship')); + assert.equal(f.storm.before.stormMovementSource?.kind, 'card'); + assert.equal(f.storm.before.stormMovementSource?.turn, 2); + assert.equal(f.storm.before.stormMovementSource?.distance, f.storm.before.stormPending); + assert.ok(f.storm.steps.some(step => step.action.type === 'ready')); + assert.ok(f.storm.steps.every(step => step.action.type !== 'stormDial')); + assert.equal(advancedRevivalOrderPlayer(f.shipment.after, f.owner).forces[f.casualtyLocation], 1); + assert.equal(advancedRevivalOrderPlayer(f.storm.after, f.owner).tanks, 1); + let game = reachAdvancedRevivalOrderActor(f.game, f.owner); + const before = structuredClone(game), original = [...game.revivalOrder!.original]; + const ownerBefore = advancedRevivalOrderPlayer(before, f.owner); + const responderBefore = advancedRevivalOrderPlayer(before, f.responder); + game = actAdvancedRevivalOrder(game, f.owner, f.revivalAction); + game = advanceAdvancedRevivalOrder(game, state => advancedRevivalOrderClean(state) || state.response?.kind === 'revivalIncome'); + assert.equal(current(game), f.owner); assert.equal(game.active, f.owner); + assert.equal(game.revivalOrder?.started, true); + assert.notEqual(f.responder, current(game)); + // With no possible cancellation the original income settles automatically; + // the test must not invent a mandatory acknowledgement or wait for it. + if (game.response) { + assert.equal(game.response.kind, 'revivalIncome'); + assert.equal(game.response.owner, f.responder); assert.equal(game.response.amount, 1); + rejected(game, f.owner, { type: 'ready' }); + game = actAdvancedRevivalOrder(game, f.responder, { type: 'passResponse' }); + game = passAdvancedRevivalOrder(game); + } + game = advanceAdvancedRevivalOrder(game, advancedRevivalOrderClean); + const ownerAfter = advancedRevivalOrderPlayer(game, f.owner); + assert.equal(ownerAfter.tanks, 0); assert.equal(ownerAfter.reserves, ownerBefore.reserves + 1); + assert.equal(ownerAfter.revived, 1); assert.equal(ownerAfter.freeForcesRevived, 1); + assert.equal(ownerAfter.spice, ownerBefore.spice); + assert.deepEqual(ownerAfter.elites, ownerBefore.elites, 'An ordinary return does not invent Sardaukar'); + assert.equal(advancedRevivalOrderPlayer(game, f.responder).spice, responderBefore.spice + 1); + assert.deepEqual(game.revivalOrder?.original, original); assert.equal(current(game), f.owner); + assert.deepEqual(advancedRevivalOrderInventory(game), f.originalInventory); + game = actAdvancedRevivalOrder(game, f.owner, { type: 'ready' }); + if (game.phase === 4) assert.ok(!game.revivalOrder?.remaining.includes(f.owner)); + game = finishAdvancedRevivalOrder(game); + assert.equal(game.phase, 5); +}); + +void test('native Tleilaxu grant remains legal outside its own ordered opportunity', () => { + const f = createAdvancedRevivalOrderFixture(); + let game = reachAdvancedRevivalOrderActor(f.game, f.owner); + const before = structuredClone(game); + game = actAdvancedRevivalOrder(game, f.responder, { type: 'tleilaxuRevivalLimit', target: f.owner }); + assert.ok(game.revivalRules?.expanded.includes(f.owner)); + assert.deepEqual(game.revivalOrder, before.revivalOrder); + assert.equal(game.active, f.owner); + assert.deepEqual(advancedRevivalOrderStocks(game), advancedRevivalOrderStocks(before)); +}); + +void test('public Basic Revival keeps concurrent readiness and has no Advanced order stamp', () => { + const f = createAdvancedRevivalOrderFixture({ advanced: false }); + assert.equal(f.game.revivalOrder, undefined); + assert.equal(viewGame(f.game, f.actor).revivalOrder, null); + let game = actAdvancedRevivalOrder(f.game, f.outOfTurn, { type: 'ready' }); + assert.ok(game.ready.includes(f.outOfTurn)); assert.equal(game.phase, 4); + game = finishAdvancedRevivalOrder(game); + assert.equal(game.phase, 5); assert.equal(game.revivalOrder, undefined); +}); + +void test('controlled legacy-envelope supplement keeps an already opened unstamped Advanced Revival concurrent', () => { + const f = createAdvancedRevivalOrderFixture(); + // Controlled format supplement only: physical cards/forces/wallets and phase + // remain native. Remove the new envelope to model a pre-cutover open phase. + const legacy = reload(f.game); delete legacy.revivalOrder; legacy.active = null; + assert.equal(viewGame(legacy, f.outOfTurn).revivalOrder, null); + let game = actAdvancedRevivalOrder(legacy, f.outOfTurn, { type: 'ready' }); + assert.equal(game.revivalOrder, undefined); assert.ok(game.ready.includes(f.outOfTurn)); + game = finishAdvancedRevivalOrder(game); + assert.equal(game.phase, 5); + game = advanceAdvancedRevivalOrder(game, state => state.turn === 2 && state.phase === 4 && advancedRevivalOrderClean(state)); + assert.equal(game.revivalOrder?.version, 1); assert.equal(game.revivalOrder?.turn, 2); + assert.deepEqual(game.revivalOrder?.original, game.order); +}); + +void test('four minimal policies offer the current native Finish and no foreign ordinary return or Finish', () => { + const f = createAdvancedRevivalOrderFixture(); + const ownActions = new Set(['ready', 'revive', 'reviveLeader', 'reviveKwisatz', + 'reviveForeignGhola', 'requestLeaderRevival', 'acceptLeaderRevival']); + for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + for (const player of f.game.players) { + const view = viewGame(f.game, player.id); view.players.find(seat => seat.id === player.id)!.bot = level; + const candidates = botActions(view); + if (player.id !== f.actor) + assert.ok(candidates.every(action => !ownActions.has(action.type)), `${level} waits for ${player.id}'s own opportunity`); + else { + const finish = candidates.find(action => action.type === 'ready'); assert.ok(finish); + assert.doesNotThrow(() => applyAction(f.game, player.id, finish)); + } + } + } +}); + +void test('controlled dead-disc supplement keeps requester actions current and the Tleilaxu quote out of turn', () => { + const f = createAdvancedRevivalOrderFixture(); + let game = reachAdvancedRevivalOrderActor(f.game, f.owner); + // Only this supplement stages prior disc deaths. No native death chronology + // is claimed; queue, physical card custody, wallets and clocks stay original. + const leader = advancedRevivalOrderPlayer(game, f.owner).leaders[0]; leader.dead = true; leader.deaths = 1; + const foreign = advancedRevivalOrderPlayer(game, 'atreides').leaders[0]; foreign.dead = true; foreign.deaths = 1; + rejected(game, 'atreides', { type: 'requestLeaderRevival', leader: foreign.id }); + rejected(game, 'atreides', { type: 'reviveLeader', leader: foreign.id }); + game = actAdvancedRevivalOrder(game, f.owner, { type: 'requestLeaderRevival', leader: leader.id }); + assert.equal(current(game), f.owner); assert.notEqual(f.responder, f.owner); + game = actAdvancedRevivalOrder(game, f.responder, { type: 'quoteLeaderRevival', target: f.owner, amount: 0 }); + assert.equal(game.revivalRequests?.[f.owner]?.price, 0); + game = actAdvancedRevivalOrder(game, f.owner, { type: 'acceptLeaderRevival' }); + game = advanceAdvancedRevivalOrder(game, advancedRevivalOrderClean); + assert.equal(advancedRevivalOrderPlayer(game, f.owner).leaders.find(disc => disc.id === leader.id)?.dead, false); + assert.equal(current(game), f.owner); +}); + +void test('controlled prevention supplement retains responder quotes and lets a prevented current requester finish', () => { + const f = createAdvancedRevivalOrderFixture(); + let game = reachAdvancedRevivalOrderActor(f.game,f.owner); + // Controlled prior death/prevention stamps supplement the played order; + // neither a native death nor a physical Karama-use chronology is claimed. + const leader = advancedRevivalOrderPlayer(game,f.owner).leaders[0]; + leader.dead = true; leader.deaths = 1; + game = actAdvancedRevivalOrder(game,f.owner,{type:'requestLeaderRevival',leader:leader.id}); + game.revivalPrevention = {player:f.responder,turn:game.turn}; + for (const level of ['Easy','Medium','Hard','Brutal'] as const) { + const view = viewGame(game,f.responder); view.players.find(p=>p.id===f.responder)!.bot=level; + assert.equal(view.revival.prevented,true); + const quote = botActions(view).find(action=>action.type==='quoteLeaderRevival'); assert.ok(quote); + assert.equal(applyAction(game,f.responder,quote).active,f.owner); + } + game.revivalPrevention = {player:f.owner,turn:game.turn}; + for (const level of ['Easy','Medium','Hard','Brutal'] as const) { + const view = viewGame(game,f.owner); view.players.find(p=>p.id===f.owner)!.bot=level; + const finish = botActions(view).find(action=>action.type==='ready'); assert.ok(finish); + const next=applyAction(game,f.owner,finish); + assert.equal(next.revivalRequests?.[f.owner],undefined); + assert.ok(next.phase!==4 || !next.revivalOrder?.remaining.includes(f.owner)); + } +}); diff --git a/tests/advanced-source-engine.test.ts b/tests/advanced-source-engine.test.ts index 479a811e..8c1a5379 100644 --- a/tests/advanced-source-engine.test.ts +++ b/tests/advanced-source-engine.test.ts @@ -241,11 +241,19 @@ void test('actual dealt Hajr retires once and the final native end still applies const actor = fixture.actor; const card = game.players.find(p => p.id === actor)!.hand.find(c => c.effect === 'hajr')!; const own = group(game, actor, fixture.lossTerritories); + // Advanced Hajr follows a completed movement. Earn that native window with + // one ordinary counter; the remaining shared group still has a real loss. + game = settleAdvancedSourceResponses(applyAction(game,actor,{type:'move', + from:'imperial_basin:9',amount:1,territory:'shield_wall',sector:9,elite:0})); game = applyAction(game, actor, {type: 'card', card: card.id}); - assert.deepEqual(group(game, actor, fixture.lossTerritories), own); + const remaining = group(game,actor,fixture.lossTerritories); + assert.equal(remaining.amount,own.amount-1); + assert.ok(remaining.amount>0); + assert.equal(remaining.tanks,own.tanks); assert.equal(game.discard.filter(c => c.id === card.id).length, 1); assert.ok(game.hajr.includes(actor)); game = applyAction(reload(game), actor, {type: 'endMovement'}); - assert.equal(group(game, actor, fixture.lossTerritories).tanks, own.tanks + own.amount); + assert.equal(group(game, actor, fixture.lossTerritories).tanks, own.tanks + remaining.amount); + assert.equal(game.players.find(p=>p.id===actor)!.forces['shield_wall:9'],1); assert.equal(game.discard.filter(c => c.id === card.id).length, 1); }); diff --git a/tests/fixture-advanced-revival-order.ts b/tests/fixture-advanced-revival-order.ts new file mode 100644 index 00000000..8489bc82 --- /dev/null +++ b/tests/fixture-advanced-revival-order.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { actAdvisorPreparation, advisorPreparationInventory } from './fixture-advisor-preparation'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type AdvancedRevivalOrderStep = { actor: string; action: Action }; +export type AdvancedRevivalOrderTransition = { + before: Game; steps: AdvancedRevivalOrderStep[]; after: Game; +}; +export const actAdvancedRevivalOrder = actAdvisorPreparation; +export const advancedRevivalOrderInventory = advisorPreparationInventory; +export const advancedRevivalOrderPlayer = (game: Game, actor: string) => { + const player = game.players.find(player => player.id === actor); + assert.ok(player, `Missing original Revival seat ${actor}`); return player; +}; +export const advancedRevivalOrderClean = (game: Game) => !game.phaseOpening && !game.response && + !game.decision && !game.pendingRevival && !game.truthtrance && !game.pendingTreacheryDiscard; +export function advancedRevivalOrderStocks(game: Game) { + return Object.fromEntries(game.players.map(player => [player.id, { + reserves: player.reserves, tanks: player.tanks, forces: structuredClone(player.forces), + spice: player.spice, revived: player.revived, + freeForcesRevived: player.freeForcesRevived, leaderRevived: player.leaderRevived, + elites: structuredClone(player.elites), + }])); +} + +/** Actual native decisions only. Ordered Revival finishes the current seat; + * an unstamped Basic/legacy phase retains its original all-seat readiness. */ +export function nextAdvancedRevivalOrder(game: Game): AdvancedRevivalOrderStep | null { + if (advancedRevivalOrderClean(game) && game.status === 'playing') { + if (game.phase === 0 && game.stormPending === null) { + const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); + if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 + ? game.stormDialers.indexOf(actor) === 0 ? game.advanced ? 11 : 10 : 0 + : 1 } }; + } + if (game.phase === 4 && game.revivalOrder) { + const actor = game.revivalOrder.remaining[0]; + if (actor) return { actor, action: { type: 'ready' } }; + } + } + if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) { + const decision = game.decision; + if (decision?.kind === 'revivalStop') + return { actor: decision.player, action: { type: 'decision', decline: true } }; + if (decision?.kind === 'guildTiming') + return { actor: decision.player, action: { type: 'decision', take: false } }; + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceAdvancedRevivalOrder(game: Game, until: (state: Game) => boolean, + actions: AdvancedRevivalOrderStep[] = []): Game { + for (let count = 0; count < 1500; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The genuine Revival source ended before its next control'); + const step = nextAdvancedRevivalOrder(game); + assert.ok(step, `Choose the original control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actAdvancedRevivalOrder(game, step.actor, step.action, actions); + } + throw Error('The genuine Revival source did not reach its finite boundary'); +} +export function passAdvancedRevivalOrder(game: Game, actions: AdvancedRevivalOrderStep[] = []): Game { + for (let count = 0; game.response && count < 60; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actAdvancedRevivalOrder(game, actor.id, { type: 'passResponse' }, actions); + } + assert.ok(!game.response); return game; +} +export function finishAdvancedRevivalOrder(game: Game, actions: AdvancedRevivalOrderStep[] = []): Game { + return advanceAdvancedRevivalOrder(game, state => state.phase === 5 && advancedRevivalOrderClean(state), actions); +} +export function reachAdvancedRevivalOrderActor(game: Game, actor: string, + actions: AdvancedRevivalOrderStep[] = []): Game { + return advanceAdvancedRevivalOrder(game, state => state.phase === 4 && advancedRevivalOrderClean(state) && + (!state.revivalOrder || state.revivalOrder.remaining[0] === actor), actions); +} + +/** Genuine public E/A/T + original Ix-deck start. The first source stops at + * turn-one Revival. casualty:true additionally earns a paid Emperor normal1 + * shipment to Hagga13 and its next native Storm death before turn-two Revival. + * Only unopened original Spice positions and scalar lotteries are selected. + * No played hand, force, wallet, Tanks, phase, active seat or queue is installed. + * The supplied Advanced physical9 orders Force Revival; the single native + * force/leader opportunity used by this slice is explicitly composed policy. */ +export function createAdvancedRevivalOrderFixture(options: { advanced?: boolean; casualty?: boolean } = {}) { + const advanced = options.advanced ?? true, casualty = options.casualty ?? false; + const owner = 'emperor', responder = 'tleilaxu', casualtyLocation = 'hagga_basin:13'; + let game = createGame('ADVANCEDREVIVALORDER', newPlayer(owner, 'Emperor', 'emperor'), advanced, ['ix']); + joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); + joinGame(game, newPlayer(responder, 'Tleilaxu', 'tleilaxu')); + const initial = structuredClone(game), actions: AdvancedRevivalOrderStep[] = []; + const act = (actor: string, action: Action) => { game = actAdvancedRevivalOrder(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceAdvancedRevivalOrder(game, until, actions); }; + const transition = (run: () => void): AdvancedRevivalOrderTransition => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length)); + assert.ok(!game.homeworlds && !game.leaderSkills && !game.nexusCards && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled); + const originalInventory = advancedRevivalOrderInventory(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + // Four ordinary original blows avoid an incidental Nexus/worm programme. + const spiceOrder = [0, 1, 2, 3].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + reach(state => state.turn === 1 && state.phase === 4 && advancedRevivalOrderClean(state)); + const firstRevival = structuredClone(game); + let shipment: AdvancedRevivalOrderTransition | null = null, storm: AdvancedRevivalOrderTransition | null = null; + if (casualty) { + reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && advancedRevivalOrderClean(state)); + shipment = transition(() => { + act(owner, { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 1, elite: 0, allyPayment: 0 }); + reach(advancedRevivalOrderClean); + }); + assert.equal(advancedRevivalOrderPlayer(game, owner).forces[casualtyLocation], 1); + assert.equal(advancedRevivalOrderPlayer(shipment.before, owner).spice - advancedRevivalOrderPlayer(game, owner).spice, 2); + reach(state => state.turn === 2 && state.phase === 0 && advancedRevivalOrderClean(state)); + if (advanced) { + assert.ok(game.stormPending !== null && game.stormPending >= 1 && game.stormPending <= 6); + assert.equal(game.stormMovementSource?.kind, 'card'); + assert.equal(game.stormMovementSource?.turn, 2); + assert.equal(game.stormMovementSource?.distance, game.stormPending); + } else assert.equal(game.stormPending, null); + storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1); }); + assert.equal(advancedRevivalOrderPlayer(game, owner).forces[casualtyLocation], undefined); + assert.equal(advancedRevivalOrderPlayer(game, owner).tanks, 1); + reach(state => state.turn === 2 && state.phase === 4 && advancedRevivalOrderClean(state)); + } + assert.deepEqual(advancedRevivalOrderInventory(game), originalInventory); + const actor = game.revivalOrder?.remaining[0] ?? game.order[0]; + const outOfTurn = game.players.find(player => player.id !== actor)!.id; + return { game, sourceGame: structuredClone(game), owner, responder, actor, outOfTurn, + originalOrder: [...game.order], initial, lobby, setup, afterSetup, firstRevival, + casualtyLocation, revivalAction: { type: 'revive', amount: 1, elite: 0 } as Action, + shipment, storm, spiceOrder, originalInventory, originalSpice, + actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, + staging: [] as string[] }; +} diff --git a/tests/fixture-advanced-source.ts b/tests/fixture-advanced-source.ts index 0caba449..0c1c38de 100644 --- a/tests/fixture-advanced-source.ts +++ b/tests/fixture-advanced-source.ts @@ -72,6 +72,8 @@ function nativeStep(game: Game): Game { } if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, {type: 'passBid'}); if (game.phase === 5) return applyAction(game, game.active!, {type: 'endMovement'}); + if (game.phase === 4 && game.revivalOrder?.remaining[0]) + return applyAction(game,game.revivalOrder.remaining[0],{type:'ready'}); // A native Richese lot can own Bidding without an ordinary auction row. if (game.phase !== 3 && (game.phase !== 6 || !game.active)) { const seat = game.players.find(p => !game.ready.includes(p.id)); diff --git a/tests/fixture-stronghold-factions.ts b/tests/fixture-stronghold-factions.ts index e86d0eb2..93e9b516 100644 --- a/tests/fixture-stronghold-factions.ts +++ b/tests/fixture-stronghold-factions.ts @@ -144,6 +144,10 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions } if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } }; if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } }; + if (game.phase === 4 && game.revivalOrder) { + const actor = game.revivalOrder.remaining[0]; + return actor ? {actor,action:{type:'ready'}} : null; + } if ([1, 2, 4, 7, 8].includes(game.phase) || (game.phase === 0 && game.stormPending !== null)) { const actor = game.players.find(p => !game.ready.includes(p.id))?.id; if (actor) return { actor, action: { type: 'ready' } }; diff --git a/tests/revival-order.test.ts b/tests/revival-order.test.ts new file mode 100644 index 00000000..981dd109 --- /dev/null +++ b/tests/revival-order.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createRevivalOrder as create, finishRevivalOrder as finish, revivalOrderBlock as blocked, + startRevivalOrder as start, validateRevivalOrder as validate, + RevivalOrderError, type RevivalOrder, type RevivalOrderContext, +} from '../game/revival-order'; + +const context = (ready: string[] = []): RevivalOrderContext => ({ turn: 3, + order: ['first', 'next', 'last'], ready }); +function rejects(context: RevivalOrderContext, frame: RevivalOrder, run: () => unknown) { + const beforeContext = structuredClone(context), beforeFrame = structuredClone(frame); + assert.throws(run, RevivalOrderError); + assert.deepEqual(context, beforeContext); assert.deepEqual(frame, beforeFrame); +} + +void test('a Revival frame retains the supplied physical order and resumes only its completed prefix', () => { + const current = context(), frame = create(current); + assert.deepEqual(frame, { version: 1, turn: 3, original: ['first', 'next', 'last'], + remaining: ['first', 'next', 'last'], completed: [], started: false }); + validate(current, frame); + assert.notEqual(frame.original, current.order); assert.notEqual(frame.remaining, current.order); + assert.notEqual(frame.original, frame.remaining); assert.notEqual(frame.completed, current.ready); + const resumed = context(['first']), continuation = create(resumed); + validate(resumed, continuation); + assert.deepEqual(continuation.remaining, ['next', 'last']); assert.deepEqual(continuation.completed, ['first']); + assert.equal(blocked(resumed, continuation, 'next'), null); + assert.match(blocked(resumed, continuation, 'first')!, /finished/); + assert.match(blocked(resumed, continuation, 'last')!, /Wait/); + assert.match(blocked(resumed, continuation, 'foreign')!, /seated/); +}); + +void test('only the current faction may start and repeated accepted self requests remain an immutable no-op stamp', () => { + const current = context(), frame = create(current), before = structuredClone(frame); + assert.equal(blocked(current, frame, 'first'), null); + rejects(current, frame, () => start(current, frame, 'next')); + rejects(current, frame, () => start(current, frame, 'foreign')); + const started = start(current, frame, 'first'); validate(current, started); + assert.equal(started.started, true); assert.deepEqual(frame, before); + assert.notEqual(started, frame); + for (const key of ['original', 'remaining', 'completed'] as const) assert.notEqual(started[key], frame[key]); + const repeated = start(current, started, 'first'); + assert.deepEqual(repeated, started); assert.notEqual(repeated, started); + for (const key of ['original', 'remaining', 'completed'] as const) assert.notEqual(repeated[key], started[key]); + repeated.remaining.pop(); repeated.original.pop(); repeated.completed.push('changed-output-only'); + assert.deepEqual(started.remaining, ['first', 'next', 'last']); assert.deepEqual(started.completed, []); + assert.deepEqual(current.order, ['first', 'next', 'last']); assert.deepEqual(current.ready, []); +}); + +void test('finishing either a pass or a started opportunity advances one physical seat and clears the next start stamp', () => { + const current = context(), frame = create(current), afterPass = finish(current, frame, 'first'); + assert.equal(afterPass.started, false); assert.deepEqual(afterPass.remaining, ['next', 'last']); + assert.deepEqual(afterPass.completed, ['first']); validate(context(['first']), afterPass); + rejects(current, frame, () => finish(current, frame, 'next')); + rejects(current, frame, () => finish(current, frame, 'foreign')); + const secondContext = context(['first']), second = start(secondContext, afterPass, 'next'); + const afterRequest = finish(secondContext, second, 'next'); + validate(context(['first', 'next']), afterRequest); + assert.deepEqual(afterRequest.remaining, ['last']); assert.deepEqual(afterRequest.completed, ['first', 'next']); + assert.equal(afterRequest.started, false); assert.equal(second.started, true); + assert.deepEqual(second.remaining, ['next', 'last']); assert.deepEqual(current.ready, []); +}); + +void test('the final completion is a legal unstarted transient and cannot reopen or finish a completed faction', () => { + const current = context(['first', 'next']), frame = start(current, create(current), 'last'); + const done = finish(current, frame, 'last'), completedContext = context(['first', 'next', 'last']); + validate(completedContext, done); + assert.deepEqual(done.remaining, []); assert.deepEqual(done.completed, completedContext.order); assert.equal(done.started, false); + assert.deepEqual(create(completedContext), done); + for (const player of completedContext.order) { + assert.match(blocked(completedContext, done, player)!, /complete/); + rejects(completedContext, done, () => start(completedContext, done, player)); + rejects(completedContext, done, () => finish(completedContext, done, player)); + } + rejects(completedContext, done, () => validate(completedContext, { ...done, started: true })); +}); + +void test('invalid turn, physical roster and non-prefix ready contexts reject before constructing an order', () => { + for (const current of [ + { ...context(), turn: 0 }, { ...context(), turn: 1.5 }, { ...context(), turn: NaN }, + { ...context(), order: ['only'] }, { ...context(), order: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }, + { ...context(), order: ['first', 'first'] }, { ...context(), order: ['first', ' '] }, + context(['next']), context(['first', 'last']), context(['first', 'first']), context(['foreign']), + context(['first', 'next', 'last', 'foreign']), + ]) assert.throws(() => create(current), RevivalOrderError); + for (const current of [null, [], { turn: 1, order: ['a', 'b'] }, + { turn: 1, order: 'a,b', ready: [] }, { turn: 1, order: ['a', 2], ready: [] }]) + assert.throws(() => create(current as unknown as RevivalOrderContext), RevivalOrderError); + const six = { turn: 1, order: ['i', 't', 'c', 'r', 'e', 'm'], ready: [] }; + validate(six, create(six)); +}); + +void test('malformed, stale or rearranged frames cannot replace the original ordered partition', () => { + const current = context(['first']), frame = create(current); + const withoutStarted = { ...frame } as Partial; delete withoutStarted.started; + for (const value of [ + null, [], withoutStarted, { ...frame, extra: true }, { ...frame, version: 2 }, { ...frame, turn: 2 }, + { ...frame, original: ['next', 'first', 'last'] }, { ...frame, original: ['first', 'next', 'next'] }, + { ...frame, remaining: ['last', 'next'] }, { ...frame, remaining: ['next'] }, + { ...frame, completed: [] }, { ...frame, completed: ['first', 'next'] }, + { ...frame, started: 'yes' }, { ...frame, remaining: ['next', 'foreign'] }, + ]) rejects(current, frame, () => validate(current, value as unknown as RevivalOrder)); + rejects(current, frame, () => start(current, frame, '')); + rejects(current, frame, () => finish(current, frame, ' ')); + rejects(current, frame, () => validate({ ...current, turn: 4 }, frame)); + rejects(current, frame, () => validate({ ...current, order: ['next', 'first', 'last'], ready: [] }, frame)); +}); + +void test('finish consumes the pre-finish ready prefix and returns detached arrays for the atomic caller update', () => { + const current = context(['first']), frame = start(current, create(current), 'next'); + rejects(current, frame, () => finish({ ...current, ready: ['first', 'next'] }, frame, 'next')); + rejects(current, frame, () => finish(current, frame, 'first')); + const next = finish(current, frame, 'next'); + for (const key of ['original', 'remaining', 'completed'] as const) assert.notEqual(next[key], frame[key]); + validate({ ...current, ready: [...current.ready, 'next'] }, next); + next.original.pop(); next.remaining.pop(); next.completed.pop(); + assert.deepEqual(frame.original, ['first', 'next', 'last']); assert.deepEqual(frame.remaining, ['next', 'last']); + assert.deepEqual(frame.completed, ['first']); assert.deepEqual(current.ready, ['first']); +});