Implement Grumman collection and private stacked Terror choices
Add conserved token collection with four bank spice, owner-only stack selection, recovery proofs, controls and AI. Preserve unresolved removal and expansion release gates. Verified 3712 offline cases, types, lint, production build, 40 HTTP cases, three-seat desktop/mobile play, and unchanged 3081 preexisting saved games.
This commit is contained in:
1 parent
28ba1618ba
commit
1db11a0187
27 files changed
+2451
-43
No files matched your search
@@ -7,6 +7,8 @@ import { ShipmentPromises } from './shipment-promises';
|
||||
import { HomeworldShipment } from './homeworld-shipment';
|
||||
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
|
||||
import { CaladanReinforcement } from './caladan-reinforcement';
|
||||
import { GrummanCollection } from './grumman-collection';
|
||||
import { TerrorBoardMarkers } from './terror-board-markers';
|
||||
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
|
||||
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
import { JunctionTransport } from './junction-transport';
|
||||
@@ -1287,36 +1289,7 @@ export function GameTable({
|
||||
));
|
||||
})}
|
||||
<NoFieldBoardMarkers players={g.players} />
|
||||
{g.moritaniTerror?.tokens
|
||||
.filter((token) => token.status === 'placed' && token.location)
|
||||
.map((token) => {
|
||||
const t = territory(token.location!);
|
||||
return (
|
||||
<g
|
||||
key={token.id}
|
||||
transform={`translate(${t.center[0] + 42},${t.center[1] - 22})`}
|
||||
pointerEvents="none"
|
||||
aria-label={`Hidden Terror token in ${t.name}`}
|
||||
>
|
||||
<title>Hidden Terror token · {t.name}</title>
|
||||
<circle
|
||||
r="15"
|
||||
fill="#392b41"
|
||||
stroke="#d6b9dc"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
y="5"
|
||||
textAnchor="middle"
|
||||
fill="#f4e1f1"
|
||||
fontSize="15"
|
||||
fontWeight="700"
|
||||
>
|
||||
T
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} />
|
||||
{Object.entries(g.spice)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([k, n]) => {
|
||||
@@ -2030,6 +2003,8 @@ export function GameTable({
|
||||
<h2>
|
||||
{g.decision.kind === 'caladanReinforcement'
|
||||
? 'Caladan victory reinforcement'
|
||||
: g.decision.kind === 'grummanCollection'
|
||||
? 'Grumman Collection'
|
||||
: g.decision.kind === 'homeworldRevivalDeployment'
|
||||
? 'Revival deployment'
|
||||
: g.decision.kind === 'ecazSpice'
|
||||
@@ -2308,6 +2283,8 @@ export function GameTable({
|
||||
<HomeworldRevivalDeployment game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'caladanReinforcement' ? (
|
||||
<CaladanReinforcement game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'grummanCollection' ? (
|
||||
<GrummanCollection game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'ecazSpice' ? (
|
||||
g.ecazSpice?.allocation ? (
|
||||
<EcazSpice
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { TERROR_DEFINITIONS } from '@/game/moritani-terror';
|
||||
import {
|
||||
grummanCollectionCanAct,
|
||||
grummanCollectionChoice,
|
||||
} from '@/game/grumman-collection-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
|
||||
export function GrummanCollection({ game, act, busy }: Props) {
|
||||
if (
|
||||
!game.grummanCollection ||
|
||||
game.grummanCollection.player !== game.me ||
|
||||
game.decision?.kind !== 'grummanCollection' ||
|
||||
game.decision.player !== game.me
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<CollectionChoice
|
||||
key={game.grummanCollection.event}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionChoice({ game, act, busy }: Props) {
|
||||
const offer = game.grummanCollection!;
|
||||
const [token, setToken] = useState(offer.tokens[0]?.id ?? '');
|
||||
const [destination, setDestination] = useState(
|
||||
offer.destinations[0]?.id ?? '',
|
||||
);
|
||||
const canAct = grummanCollectionCanAct(game);
|
||||
const choice = grummanCollectionChoice(game, token, destination);
|
||||
const disabled = busy || !canAct || !!offer.blocked;
|
||||
return (
|
||||
<section aria-label="Grumman collection" className="space-y-3">
|
||||
<p>
|
||||
Add one available Terror token to a stronghold to collect 4 spice. You
|
||||
receive the spice only after the addition resolves.
|
||||
</p>
|
||||
<label className="block space-y-1">
|
||||
<span>Your Terror token</span>
|
||||
<select
|
||||
aria-label="Your Terror token"
|
||||
className="w-full"
|
||||
value={token}
|
||||
disabled={disabled || !offer.tokens.length}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
>
|
||||
{!offer.tokens.length && <option value="">No available token</option>}
|
||||
{offer.tokens.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{TERROR_DEFINITIONS[candidate.kind].name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span>Destination stronghold</span>
|
||||
<select
|
||||
aria-label="Destination stronghold"
|
||||
className="w-full"
|
||||
value={destination}
|
||||
disabled={disabled || !offer.destinations.length}
|
||||
onChange={(event) => setDestination(event.target.value)}
|
||||
>
|
||||
{!offer.destinations.length && (
|
||||
<option value="">No available stronghold</option>
|
||||
)}
|
||||
{offer.destinations.map((target) => (
|
||||
<option key={target.id} value={target.id}>
|
||||
{target.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{choice.blocked && (
|
||||
<p className="fine" aria-live="polite">
|
||||
{choice.blocked}
|
||||
</p>
|
||||
)}
|
||||
<p className="fine">Removal unavailable: {offer.removeBlocked}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !choice.action}
|
||||
onClick={() => {
|
||||
if (choice.action) act(choice.action);
|
||||
}}
|
||||
>
|
||||
Add token and collect 4 spice
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !canAct}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', event: offer.event, decline: true })
|
||||
}
|
||||
>
|
||||
Pass Grumman collection
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -459,6 +459,58 @@ export function MoritaniEntry({
|
||||
);
|
||||
}
|
||||
|
||||
// Stacked tokens are selected privately before any selected-kind lookup.
|
||||
if (entry.stage === 'select') {
|
||||
if (me.faction !== 'moritani') return null;
|
||||
const selectionDisabled =
|
||||
disabled ||
|
||||
game.status !== 'playing' ||
|
||||
!!game.phaseOpening ||
|
||||
!!game.automaticContinuationPending;
|
||||
return (
|
||||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||||
<h3 id={`${formId}-title`}>Choose a Terror token</h3>
|
||||
<p className="text-sm leading-6">
|
||||
Choose one token in {territory(entry.territory).name} for this
|
||||
arrival. Selecting a token keeps its face private; its reaction
|
||||
choices follow. You may instead leave all tokens hidden and continue
|
||||
the arrival.
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{entry.candidates?.map((candidate) => (
|
||||
<div key={candidate.token} className="space-y-2">
|
||||
<Button
|
||||
className={controlClass}
|
||||
disabled={selectionDisabled}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', token: candidate.token })
|
||||
}
|
||||
>
|
||||
Select {TERROR_DEFINITIONS[candidate.kind].name}
|
||||
</Button>
|
||||
{candidate.revealBlocked && (
|
||||
<p className="text-sm leading-6">{candidate.revealBlocked}</p>
|
||||
)}
|
||||
{candidate.canOfferAlliance && (
|
||||
<p className="text-sm leading-6">
|
||||
An alliance offer is available after selection.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className={controlClass}
|
||||
disabled={selectionDisabled}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Leave all tokens hidden
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (me.faction !== 'moritani' || !('kind' in entry) || !entry.kind)
|
||||
return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { territory } from '@/game/board';
|
||||
|
||||
/** The map shows public stack counts without reading any hidden token face. */
|
||||
export function TerrorBoardMarkers({ tokens }: {
|
||||
tokens: readonly { status: string; location: string | null }[];
|
||||
}) {
|
||||
const groups = new Map<string, number>();
|
||||
for (const token of tokens)
|
||||
if (token.status === 'placed' && token.location)
|
||||
groups.set(token.location, (groups.get(token.location) ?? 0) + 1);
|
||||
return [...groups].map(([id, count]) => {
|
||||
const place = territory(id);
|
||||
const label = `${count} hidden Terror token${count === 1 ? '' : 's'} in ${place.name}`;
|
||||
return (
|
||||
<g key={id} transform={`translate(${place.center[0] + 42},${place.center[1] - 22})`}
|
||||
pointerEvents="none" aria-label={label}>
|
||||
<title>{label}</title>
|
||||
{count > 1 && <circle cx="3" cy="-3" r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />}
|
||||
<circle r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />
|
||||
<text y="5" textAnchor="middle" fill="#f4e1f1" fontSize="15" fontWeight="700">
|
||||
{count === 1 ? 'T' : `T${count}`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -403,10 +403,12 @@ export function needsAutomaticRoomRecovery(
|
||||
richeseAuction?: unknown;
|
||||
battle?: { revealed: boolean; territory: string } | null;
|
||||
automaticContinuationPending?: boolean;
|
||||
grummanCollection?: { event: string; stage?: string } | null;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
state.automaticContinuationPending === true ||
|
||||
state.grummanCollection?.stage === 'waiting' ||
|
||||
!!state.pendingTreacheryDiscard ||
|
||||
!!state.response ||
|
||||
!!state.richeseAuction ||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Grumman Collection and stacked Terror
|
||||
|
||||
Source audit, 10 September 2026. This supplements [HOMEWORLD_COLLECTION_RULES.md](HOMEWORLD_COLLECTION_RULES.md). It does not certify runtime integration or change release gates. The existing user question about unrevealed removal custody remains pending; this audit asks no duplicate question.
|
||||
|
||||
## Evidence and its limits
|
||||
|
||||
The original Grumman high face was visually inspected again in the [physical component photograph, BGG image 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani), whose provenance is recorded in [HOMEWORLD_COMPONENT_AUDIT.md](HOMEWORLD_COMPONENT_AUDIT.md). It applies at 8–20 native Moritani reserves. During Spice Collection, Moritani may add a Terror token to a stronghold already holding one, or remove a board Terror token, then receive four bank spice. The face does not print an explicit repetition count or specify the removed token’s next custody zone.
|
||||
|
||||
Fresh publisher-indexed passages from the [official Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) confirm:
|
||||
|
||||
- **p5:** six hidden physical Terror tokens; ordinary Mentat placement/movement targets an empty eligible stronghold; an opposing entry can permit revealing one token. Revealed tokens leave the game.
|
||||
- **p8:** Ecaz and its ally agree on desert allocation, with the stated fallback when they disagree. Both receive applicable Advanced shared stronghold income.
|
||||
- **p14:** storm/explosions do not remove Terror. A concealed No-Field placement can trigger it, including zero; revealing an existing No-Field or Face Dancer does not.
|
||||
- **p16:** Terror cannot be placed on Homeworlds. Canceling Enemy of My Enemy prevents its alliance but leaves the token revelation available.
|
||||
|
||||
The [designer’s expansion page](https://futurepastimes.com/dune-ecaz-moritani) was also checked. Bounded searches found no authenticated designer or publisher clarification of multiple revelations from one stacked entry, Grumman versus Ecaz timing, or unrevealed Grumman removal custody. Tournament rewrites and community implementations were not treated as official rulings. No new audio clarification is claimed.
|
||||
|
||||
## Supported composition
|
||||
|
||||
**One operation and one payment per Collection.** Treat the singular phase opportunity as one optional add-or-remove transaction. This follows ordinary phase-ability grammar; it is not an explicit Grumman FAQ answer. There is no authority for repeatedly adding/removing tokens to earn unlimited spice. The four-spice award follows a completed legal operation, so declining or lacking a usable operation does not pay. Keep its completion separate from ordinary Mentat placement usage.
|
||||
|
||||
**Owner chooses a physical token.** A stack makes several tokens candidates for the same ordinary entry opportunity. The existing singular revelation language supports choosing one eligible token rather than automatically choosing the first array element. No retrieved source expressly allows activating several tokens from that same entry. The bounded implementation should resolve one selected token or decline, leaving other tokens placed; resolving the effect must not manufacture another entry event. This is ordinary-trigger composition, not a stacking-specific clarification. Separate later arrivals retain their own opportunities and low-Grumman entry requirements.
|
||||
|
||||
The chosen token must remain bound to the original entry through Enemy of My Enemy, its refusal or cancellation, card discards and saved continuations. A refusal applies the selected token’s compulsory outcome. Canceling the alliance must not silently select a different token. Existing effect-specific implementation gates still apply to the selected token; a supported neighbor in the stack does not implement an unfinished effect.
|
||||
|
||||
**After Ecaz settlement is an implementation ordering.** Neither inspected component sets precedence between these two Collection opportunities. Resolving Grumman after existing Ecaz allocation and bank-income continuations is a coherent serializer for the supported effects: allocation changes balances, while Grumman changes token custody and awards fixed bank income; neither operation changes the other’s force count, token availability or allocation formula. No material conflict between those ordinary operations was identified.
|
||||
|
||||
Do not describe that serializer as printed priority. Revalidate current native population when the Grumman action is accepted. An allowed intervening effect that changes population, token custody or the phase must use the same continuation checks; a future noncommuting interaction would require review. Ecaz settlement cannot consume Grumman’s opportunity, and Grumman cannot overwrite an unresolved allocation. Its award is not desert collection and must not create a shared Ecaz allocation or Giedi collection trigger.
|
||||
|
||||
## Unresolved removal custody
|
||||
|
||||
Removal from the board is not revelation. The revelation disposal rule does not establish that an unrevealed Grumman removal is permanently spent. Conversely, other effects’ express return-to-supply instructions do not prove this removal returns to hidden supply. Neither result was authenticated by this audit.
|
||||
|
||||
Keep that operation guarded pending the existing question. Do not reveal its face, call the revelation handler, rotate it into reusable supply, or credit four spice before its destination is resolved. Addition can be implemented independently using an actual available token and a stronghold already containing Terror. It does not authorize Homeworlds, the Hidden Mobile Stronghold, an invented token or movement from another stack.
|
||||
|
||||
## Concrete code review findings
|
||||
|
||||
At the inspected checkpoint, `openTerrorEntry` in [engine.ts](../game/engine.ts) selects the first placed token at the destination. Stacking requires an owner-visible candidate choice, with hidden faces excluded from other seats, and a durable selected identity afterward.
|
||||
|
||||
[moritani-alliance-cancellation.ts](../game/moritani-alliance-cancellation.ts) separately requires exactly one placed token at the entry location. Replace that location-wide uniqueness assumption when stacking is introduced. Preserve uniqueness of the selected **physical ID**, its placed status, location, original entry proof and valid effect. Two distinct colocated tokens are legal candidates; two copies of the same physical ID remain corruption.
|
||||
|
||||
The registry, projection and ordinary placement quote have different responsibilities: permit multiple distinct placed tokens at one location in the registry, expose public stack size without hidden faces, and continue requiring an empty destination for ordinary Mentat placement. Preserve all unselected tokens, original entry counts and protected token custody across rejection and JSON/SQLite recovery.
|
||||
|
||||
Only this source document was written in this task. Local links and whitespace were checked. Runtime tests, browser checks and checkpoint publication are outside this audit.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Grumman Collection and stacked Terror runtime
|
||||
|
||||
Development checkpoint, 10 September 2026. Grumman has Collection addition/decline controls and stacked Terror has an owned token-selection continuation. The checks below pass; this is not a complete Moritani, Homeworld or expansion implementation. Start and publication gates remain in force. Printed evidence, interpretation boundaries and the existing removal question are in [GRUMMAN_STACKED_TERROR_RULES.md](GRUMMAN_STACKED_TERROR_RULES.md).
|
||||
|
||||
## Current Collection behavior
|
||||
|
||||
[grumman-collection.ts](../game/grumman-collection.ts) validates the six physical Terror identities and current native population. At eight or more native Moritani reserves, the owner can add one available hidden token to an ordinary stronghold already holding Terror. The accepted transaction places that token and awards four bank spice. It neither relocates another token nor consumes ordinary Mentat placement. Storm does not exclude a placement destination; Homeworlds and the Hidden Mobile Stronghold are excluded. Declining changes no token and pays nothing.
|
||||
|
||||
The engine stages a separate [Collection receipt](../game/grumman-collection-return.ts) after ordinary collection, then opens the owned choice when Ecaz collection and other active continuations have settled. It checks population again on acceptance. A low-population waiting opportunity can expire when Collection ends; an open choice can always be declined. Recorded event, owner, turn, stage and outcome prevent inconsistent restoration or reapplying the same completed operation. The production room recovery detector recognizes waiting Grumman continuations.
|
||||
|
||||
One operation per Collection follows the card’s singular phase grammar. Serializing it after Ecaz settlement is implementation ordering, not a printed priority rule. The inspected ordinary operations do not change each other’s native force count, token availability or allocation formula. Grumman’s four bank spice is neither desert collection nor an Ecaz shared pool and does not trigger Giedi income.
|
||||
|
||||
[grumman-collection-options.ts](../game/grumman-collection-options.ts), [the owned controls](../components/grumman-collection.tsx), and all four bot profiles share the projected legal addition/decline choices. Opponents receive no available token faces or selectable destinations. Public logs describe the placement and four-spice award without disclosing the hidden token type.
|
||||
|
||||
## Stacked entry and selected-token recovery
|
||||
|
||||
An opposing entry into a stronghold containing multiple Terror tokens opens a private `select` stage. Moritani chooses one original candidate before the ordinary reveal/alliance offer, or declines the whole entry. Supported token effects use the existing handlers; resolving one token does not manufacture another entry to activate its neighbor. One chosen revelation is the ordinary-trigger composition described in the source audit, not a newly discovered stacking FAQ.
|
||||
|
||||
[terror-entry-receipt.ts](../game/terror-entry-receipt.ts) binds the original candidates and public entering group. The selected identity has its own consistency proof, which is also included in the entry signature after selection. This prevents removing the selection proof and rewinding a selected entry to the selection stage while retaining the original receipt. Enemy of My Enemy cancellation preserves the selected identity; its previous one-token-per-location check now validates the selected physical identity without rejecting legitimate colocated tokens.
|
||||
|
||||
Every unselected candidate remains reserved at its original location while the entry is unresolved. The selected token must also remain placed before revelation. Once Sabotage has actually consumed its token and discarded a card, the saved `terrorDiscard` child correctly expects that token to be removed while retaining all unselected tokens at the stronghold. Independent review reproduced and repaired the earlier false rejection of this genuine child. The regression captures the actual inner-dispatch discard and restores it; it does not claim a user-visible HTTP pause at that internal boundary.
|
||||
|
||||
[Map markers](../components/terror-board-markers.tsx) group colocated public tokens and display their count without consulting hidden faces. Only Moritani sees candidate identities/effects in the selection controls. Revealed effects follow their normal public rules; every still-hidden neighbor retains its private face.
|
||||
|
||||
## Boundaries retained
|
||||
|
||||
- Removing an unrevealed board token remains unavailable until its destination is established. The existing question is still pending; removal neither reveals/spends a token nor pays four spice.
|
||||
- Occupation lifecycle and occupied Grumman immunity remain outside this checkpoint. Other unfinished Terror effects and combined arrival/cancellation interactions retain their existing guards. Stacking does not implement them.
|
||||
- Deterministic signatures detect inconsistent saved fields; they are not cryptographic authentication against arbitrary coordinated rewriting of all stored state and proofs. Older entries missing all new metadata retain their documented legacy limitation; readers do not reconstruct an original entering group from current occupants.
|
||||
- Complete faction/expansion setup, combined games and the overall user goal remain unfinished. No gate is removed by passing these focused tests.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
The five focused suites contain 30 cases: seven [pure quotes](../tests/grumman-collection.test.ts), seven [engine scenarios](../tests/grumman-collection-engine.test.ts), seven [production SQLite recovery cases](../tests/grumman-collection-recovery.test.ts), six [controls/markers/AI cases](../tests/grumman-collection-controls.test.ts), and three [selection/discard review cases](../tests/grumman-selection-integrity.test.ts). The shared [scenario fixture](../tests/fixture-grumman-collection.ts) uses genuine Moritani/Atreides/Harkonnen Homeworld setup and conserved later positions; Collection and paid entry receipts are generated by real actions.
|
||||
|
||||
The seven SQL cases and focused lint passed. SQL coverage includes competing addition/decline and token-selection writes, a lost addition response, the separate actual Mentat placement, one Robbery payment, all-seat privacy, unrelated-room preservation, and rejection without writes for inconsistent phase, custody, candidate, selected-token and completed-operation records.
|
||||
|
||||
- **`npm run check`: PASS**, including types, lint and all **3,712 offline cases** (90.4 seconds). The older delayed-Chemistry test now initializes the real six-token inventory when manually adding Moritani; production validation remains strict.
|
||||
- **`npm run build`: PASS.** Later edits only corrected that test fixture, clarified a code comment and recorded this documentation.
|
||||
- **`npm run test:integration`: PASS**, all **40 HTTP/session cases**.
|
||||
- **Browser:** isolated three-human QA room `HU6YUC7Z` used genuine initialized Homeworld state and conserved staged positions. Real keyboard actions ended movement, added Sabotage alongside Robbery for four spice, shipped one Atreides force into Arrakeen, selected Robbery privately, revealed it and took half the entrant’s remaining spice. The resulting version 10 has Moritani/Atreides/Harkonnen spice balances 34/9/20, one remaining hidden Sabotage token, and the consumed Robbery token. All three private seats refreshed successfully, with no opponent hand/spice or hidden-token leaks and no page errors. Both Collection and stacked-selection controls were visually inspected at desktop and 390-pixel phone widths; neither overflowed. The board exposed the public two-token count without exposing faces.
|
||||
- **Saved rooms:** after the browser scenarios and HTTP suite, all **3,081 preexisting room versions and state hashes remained unchanged**. The database contains 3,112 rooms, including this isolated QA room and 30 integration-test rooms. No database reset or migration occurred. The existing development server was reused; the preceding controlled restart was at 21:42 UTC on 9 September. This checkpoint does not claim a new restart.
|
||||
|
||||
Read-only browser restoration also passed for the earlier Caladan room `LV2TNQ88` (version 5) and revival room `463GUCY3` (version 7), with all three private seats in each room. Their saved resources and completed continuations remained intact.
|
||||
|
||||
Local verification logs: `/tmp/dune-grumman-verified-check.log`, `/tmp/dune-grumman-final-build.log`, `/tmp/dune-grumman-final-http.log`. Browser images: `/tmp/dune-grumman-desktop.png`, `/tmp/dune-grumman-phone.png`, `/tmp/dune-grumman-stack-desktop.png`, `/tmp/dune-grumman-stack-phone.png`. These temporary evidence files and private browser sessions are not shipped with the application.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Primary-source audit, 9 September 2026. This document supplies a bounded implementation contract, not a runtime checkpoint. Occupation lifecycle and the separate low-Guild contributor-rounding questions remain pending; do not duplicate them.
|
||||
|
||||
The 10 September source follow-up is [GRUMMAN_STACKED_TERROR_RULES.md](GRUMMAN_STACKED_TERROR_RULES.md); current addition/selection wiring and its pending final verification are in [GRUMMAN_STACKED_TERROR_RUNTIME.md](GRUMMAN_STACKED_TERROR_RUNTIME.md).
|
||||
|
||||
## Evidence inspected
|
||||
|
||||
The original high faces were visually read again from `/tmp/dune-card-economy-homeworlds.jpg`, the 3024×4032 original photograph associated with [BGG image 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani), posted by Jaakko/zaksalo on 2 October 2023. Grumman and Giedi Prime are both legible in its bottom row. The photograph is primary component evidence; the photographer's opinions are not rules authority. The [component audit](HOMEWORLD_COMPONENT_AUDIT.md) records all face provenance.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Giedi collection and low Grumman entry
|
||||
|
||||
The later high-Grumman addition and stacked-entry development checkpoint is in
|
||||
[GRUMMAN_STACKED_TERROR_RUNTIME.md](GRUMMAN_STACKED_TERROR_RUNTIME.md). Its final
|
||||
verification remains separate from the historical checks below.
|
||||
|
||||
This checkpoint connects two population effects. The complete Homeworld module,
|
||||
Moritani faction and expansion starts remain gated. Printed component evidence,
|
||||
the No-Field composition and Grumman's unresolved removal destination are in
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Dune implementation status
|
||||
|
||||
**Grumman Collection and stacked Terror checkpoint, 10 September 2026:** [GRUMMAN_STACKED_TERROR_RUNTIME.md](GRUMMAN_STACKED_TERROR_RUNTIME.md) records one hidden-token addition with four bank spice, separate Mentat placement, private stacked-token selection, shared controls/AI and durable discard/alliance continuations. Review fixed selected-choice rewinds and restoration of a consumed Sabotage token during its genuine discard child. **3,712 offline cases, types, lint, production build and 40 HTTP/session cases pass.** Desktop/phone keyboard Collection, shipment, token selection, Robbery payment, public stack count and three-seat private refresh passed. All 3,081 preexisting room versions/hashes remained unchanged. Removal custody, occupation, unfinished effects and complete expansion games remain unresolved; no release gate opened. The existing server was reused and the removed automation stays removed.
|
||||
|
||||
**Caladan victory reinforcement checkpoint, 10 September 2026:** [HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) records the actual high-Caladan post-victory one-force transfer, current population and survivors, original battle location, exact native/visitor custody, owned controls and all-profile AI. Shared Ambassador arrivals preserve the original reinforcement and its nested children. Independent review reproduced and fixed arrival-to-choice rewinds and a deleted preceding cleanup choice; new revival returns also carry version-bound independent progress, while legacy completed QA saves remain readable. Final types, lint, **3,682 offline cases** (89.9 seconds), production build and **40 HTTP cases** pass. Eight Caladan production SQLite cases cover competing writes, reloads, private projections, nested arrivals and corruption. Two older Face Dance fixtures now explicitly decline the preceding guarded Caladan option, without choosing the unresolved placement order. Desktop and phone keyboard placement and three-seat refresh passed; final restart restoration passed for both Caladan and revival QA games. All **3,051** pre-existing room versions/hashes remain unchanged after restart and additive HTTP work. Low-Tupile public contact/once-per-faction quoting has seven cases but no playable disclosure action. Face Dancer ordering, guarded entry combinations, occupation, other missing effects and complete expansion games remain unfinished; no release gate opened. User workflow optimizations remain in use and automation stays removed.
|
||||
|
||||
**Homeworld revival deployment checkpoint, 10 September 2026:** [HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md](HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md) records newly revived Fedaykin placement and Tleilax ordinary Free Revival deployment, exact typed groups, normal/Emperor-extra/Ghola source distinctions, owned controls and all-profile AI. Signed return, income and Ambassador completion records preserve nested arrivals and CHOAM sale restoration. Independent review found and fixed altered-offer acceptance and deleted-child premature completion. Final types, lint, **3,634 offline cases**, production build and **40 HTTP cases** pass; seven new production SQLite cases cover competing writes, lost responses, nested Ambassador restoration and corruption. The final suite also made an older auction test deterministic when the purchased Karama creates a second income responder. Desktop and phone keyboard placement, Ghola decline, refresh and three private seats passed without overflow or page errors. The hourly restart preserved all **3,050** saved room versions/hashes and restored the QA seats. First-reaching-high timing, partial/split groups, storm/arrival classification, occupation and complete expansion games remain unfinished; no release gate opened. User workflow optimizations remain in use and the automation stays removed.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
|
||||
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
|
||||
import { caladanReinforcementActions } from './caladan-reinforcement-options';
|
||||
import { grummanCollectionActions } from './grumman-collection-options';
|
||||
import { botHomeworldShipmentPaymentAllowed } from './homeworld-payment-options';
|
||||
import { guildHomeworldShipmentActions } from './guild-homeworld-shipment-options';
|
||||
import { choamSaleGholaTiming } from './choam-market-ghola';
|
||||
@@ -1471,6 +1472,10 @@ function policyActions(g: GameView): Action[] {
|
||||
if (d.kind === 'moritaniTerror') {
|
||||
const entry = g.terrorEntry;
|
||||
if (!entry) return [];
|
||||
if (entry.stage === 'select') {
|
||||
const candidate = entry.candidates?.find((candidate) => candidate.canReveal || candidate.canOfferAlliance);
|
||||
return [candidate ? { type: 'decision', token: candidate.token } : { type: 'decision', decline: true }];
|
||||
}
|
||||
if (entry.stage === 'allianceReply') {
|
||||
const moritani = g.players.find(
|
||||
(player) => player.faction === 'moritani',
|
||||
@@ -2378,6 +2383,8 @@ function policyActions(g: GameView): Action[] {
|
||||
return homeworldRevivalDeploymentActions(g);
|
||||
if (d.kind === 'caladanReinforcement')
|
||||
return caladanReinforcementActions(g);
|
||||
if (d.kind === 'grummanCollection')
|
||||
return grummanCollectionActions(g);
|
||||
if (d.kind === 'battleCards')
|
||||
return [
|
||||
{
|
||||
|
||||
+140
-6
@@ -13,6 +13,8 @@ import type { HomeworldForces } from './homeworld-custody';
|
||||
import { nativeReserveSources } from './homeworld-options';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { lowGrummanRevealBlock } from './homeworld-collection';
|
||||
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
|
||||
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature,
|
||||
homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return';
|
||||
@@ -34,7 +36,7 @@ import {
|
||||
type HomeworldRevivalReturn,
|
||||
} from './homeworld-revival-return';
|
||||
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
|
||||
import { terrorEntrySignature, validateTerrorEntrySignature } from './terror-entry-receipt';
|
||||
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
|
||||
import {
|
||||
homeworldLowBonus,
|
||||
homeworldRevivalKaramaBlock,
|
||||
@@ -577,6 +579,7 @@ export type Auction = {
|
||||
};
|
||||
export type Decision =
|
||||
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
|
||||
| { kind: 'grummanCollection'; player: string; event: string }
|
||||
| { kind: 'caladanReinforcement'; player: string; event: string }
|
||||
| { kind: 'choamAudit'; player: string; event: string }
|
||||
| { kind: 'choamAuditPayment'; player: string; event: string }
|
||||
@@ -1086,6 +1089,8 @@ export type Game = {
|
||||
pendingTerrorEntry?: {
|
||||
/** Original public arrival, absent only in legacy saves. */
|
||||
entrySignature?: string;
|
||||
candidates?: string[];
|
||||
selectionSignature?: string;
|
||||
token: string;
|
||||
entrant: string;
|
||||
territory: string;
|
||||
@@ -1104,6 +1109,7 @@ export type Game = {
|
||||
turn: number;
|
||||
phase: number;
|
||||
stage:
|
||||
| 'select'
|
||||
| 'offer'
|
||||
| 'robbery'
|
||||
| 'discard'
|
||||
@@ -1116,6 +1122,7 @@ export type Game = {
|
||||
ambassadorEvent?: string;
|
||||
} | null;
|
||||
moritaniTerror?: TerrorState;
|
||||
grummanCollection?: GrummanCollection;
|
||||
pendingMoritaniPlacement?: {
|
||||
token: string;
|
||||
territory: string;
|
||||
@@ -2490,6 +2497,7 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
|
||||
homeworldRevivalReturn: g.homeworldRevivalReturn,
|
||||
...(g.homeworldRevivalProgress === undefined ? {} : { homeworldRevivalProgress: g.homeworldRevivalProgress }),
|
||||
homeworldVictoryReinforcement: g.homeworldVictoryReinforcement,
|
||||
...(g.grummanCollection === undefined ? {} : { grummanCollection: g.grummanCollection }),
|
||||
dukeVidal: g.dukeVidal,
|
||||
revivalFreeIncome: g.revivalFreeIncome,
|
||||
players: g.players.map((p) => ({
|
||||
@@ -8715,6 +8723,17 @@ function terrorEntryIntegrity(g: Game) {
|
||||
g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') &&
|
||||
g.players.filter((p) => p.faction === 'moritani').length === 1,
|
||||
'The saved Terror entry has lost its original turn or seated participants.');
|
||||
if (entry.candidates) {
|
||||
const committedDiscard = continuation?.kind === 'terrorDiscard' && entry === continuation.entry;
|
||||
const reserved = entry.candidates.filter((id) => id !== entry.token ||
|
||||
(!committedDiscard && ['select', 'offer', 'allianceResponse', 'allianceReply'].includes(entry.stage)));
|
||||
requireRule(reserved.every((id) => g.moritaniTerror?.tokens.filter((token) =>
|
||||
token.id === id && token.status === 'placed' && token.location === entry.territory).length === 1),
|
||||
'The stacked Terror choice has lost an original reserved token.');
|
||||
if (committedDiscard)
|
||||
requireRule(g.moritaniTerror?.tokens.some((token) => token.id === entry.token && token.status === 'removed'),
|
||||
'The stacked Terror discard has lost its consumed token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Public original batch size; concealed No-Field markers count as one. */
|
||||
@@ -8737,9 +8756,10 @@ function openTerrorEntry(
|
||||
ambassadorEvent?: string,
|
||||
) {
|
||||
const moritani = byFaction(g, 'moritani');
|
||||
const token = g.moritaniTerror?.tokens.find(
|
||||
const tokens = g.moritaniTerror?.tokens.filter(
|
||||
(t) => t.status === 'placed' && t.location === to,
|
||||
);
|
||||
) ?? [];
|
||||
const token = tokens[0];
|
||||
if (
|
||||
!moritani ||
|
||||
!token ||
|
||||
@@ -8755,6 +8775,7 @@ function openTerrorEntry(
|
||||
);
|
||||
g.pendingTerrorEntry = {
|
||||
token: token.id,
|
||||
...(tokens.length > 1 ? { candidates: tokens.map((t) => t.id) } : {}),
|
||||
entrant: entrant.id,
|
||||
territory: to,
|
||||
sector,
|
||||
@@ -8763,7 +8784,7 @@ function openTerrorEntry(
|
||||
cause,
|
||||
turn: g.turn,
|
||||
phase: g.phase,
|
||||
stage: 'offer',
|
||||
stage: tokens.length > 1 ? 'select' : 'offer',
|
||||
resume,
|
||||
...(ambassadorEvent ? { ambassadorEvent } : {}),
|
||||
};
|
||||
@@ -8933,6 +8954,24 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
: p.faction === 'moritani'),
|
||||
'This Terror entry opportunity is no longer current.',
|
||||
);
|
||||
if (entry.stage === 'select') {
|
||||
if (action.decline === true) {
|
||||
requireRule(action.token === undefined, 'Declining leaves every stacked token hidden.');
|
||||
log(g, `${p.name} left all Terror tokens hidden after this entry.`);
|
||||
finishTerrorEntry(g);
|
||||
return;
|
||||
}
|
||||
requireRule(typeof action.token === 'string' && entry.candidates?.includes(action.token),
|
||||
'Choose one of the original stacked Terror tokens.');
|
||||
requireRule(action.reveal === undefined && action.alliance === undefined,
|
||||
'Choose the token before deciding whether to reveal it or offer an alliance.');
|
||||
entry.token = action.token;
|
||||
entry.stage = 'offer';
|
||||
entry.selectionSignature = terrorSelectionSignature(entry);
|
||||
entry.entrySignature = terrorEntrySignature(entry);
|
||||
g.decision = { kind: 'moritaniTerror', player: p.id, entrant: entry.entrant, territory: entry.territory };
|
||||
return;
|
||||
}
|
||||
const token = g.moritaniTerror?.tokens.find((t) => t.id === entry.token);
|
||||
requireRule(token, 'The entry’s Terror token is unavailable.');
|
||||
const entrant = getPlayer(g, entry.entrant);
|
||||
@@ -9865,6 +9904,11 @@ function finishEcazPlacement(g: Game) {
|
||||
completePhase(g);
|
||||
}
|
||||
function completePhase(g: Game) {
|
||||
if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') {
|
||||
g.grummanCollection.stage = 'complete';
|
||||
g.grummanCollection.outcome = 'expired';
|
||||
g.grummanCollection.signature = grummanCollectionSignature(g.grummanCollection);
|
||||
}
|
||||
if (g.phase === 8) settleStrongholdOwnership(g);
|
||||
g.phase++;
|
||||
if (g.phase === 9) {
|
||||
@@ -10236,6 +10280,77 @@ function creditGiediCollection(g: Game, player: string, desert: number) {
|
||||
log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert collection once this phase.',
|
||||
{ faction: 'harkonnen', name: 'Giedi Prime collection' });
|
||||
}
|
||||
function grummanCollectionIntegrity(g: Game) {
|
||||
homeworldRule(() => validateGrummanCollection(g));
|
||||
const frame = g.grummanCollection;
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
|
||||
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const decisions = contexts.flatMap((context) => context?.decision?.kind === 'grummanCollection' ? [context.decision] : []);
|
||||
requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event) &&
|
||||
(frame?.stage !== 'choice' || decisions.length > 0),
|
||||
'The Grumman Collection choice has lost its original phase opportunity.');
|
||||
}
|
||||
function stageGrummanCollection(g: Game) {
|
||||
const owner = byFaction(g, 'moritani');
|
||||
if (!owner || !g.homeworlds?.custody || g.grummanCollection?.turn === g.turn) return;
|
||||
const frame: GrummanCollection = { event: crypto.randomUUID(), turn: g.turn,
|
||||
player: owner.id, stage: 'waiting', signature: '' };
|
||||
frame.signature = grummanCollectionSignature(frame);
|
||||
g.grummanCollection = frame;
|
||||
}
|
||||
function grummanCollectionAutomatic(g: Game): boolean {
|
||||
const frame = g.grummanCollection;
|
||||
return !!(frame?.stage === 'waiting' && frame.turn === g.turn && g.phase === 7 &&
|
||||
!g.phaseOpening && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard &&
|
||||
!g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama &&
|
||||
(!g.ecazCollection || g.ecazCollection.stage === 'complete') &&
|
||||
homeworldRule(() => quoteGrummanCollection(g, frame.player)).high);
|
||||
}
|
||||
function resumeGrummanCollection(g: Game) {
|
||||
if (!grummanCollectionAutomatic(g)) return;
|
||||
const frame = g.grummanCollection!;
|
||||
frame.stage = 'choice';
|
||||
frame.signature = grummanCollectionSignature(frame);
|
||||
g.decision = { kind: 'grummanCollection', player: frame.player, event: frame.event };
|
||||
}
|
||||
function decideGrummanCollection(g: Game, p: Player, action: Action) {
|
||||
const frame = g.grummanCollection;
|
||||
requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event,
|
||||
'Choose the current Grumman Collection opportunity.');
|
||||
if (action.decline === true) {
|
||||
requireRule(action.token === undefined && action.territory === undefined && action.mode === undefined,
|
||||
'Declining Grumman does not move a token or collect spice.');
|
||||
frame.outcome = 'decline';
|
||||
log(g, `${p.name} declined Grumman’s optional Terror change; no bank spice was collected.`);
|
||||
} else {
|
||||
requireRule(action.mode === 'add' || action.mode === 'remove', 'Choose a Grumman token operation.');
|
||||
requireRule(typeof action.token === 'string', 'Choose a physical Terror token.');
|
||||
const destination = typeof action.territory === 'string' ? action.territory : '';
|
||||
requireRule(action.mode !== 'add' || !!destination, 'Choose a stronghold for the added token.');
|
||||
const intent = action.mode === 'remove'
|
||||
? { mode: 'remove' as const, token: action.token }
|
||||
: { mode: 'add' as const, token: action.token, destination };
|
||||
const quote = homeworldRule(() => quoteGrummanCollectionAction(g, p.id, intent));
|
||||
requireRule(Number.isSafeInteger(p.spice + quote.amount), 'Grumman income would overflow the spice balance.');
|
||||
g.moritaniTerror = quote.state;
|
||||
p.spice += quote.amount;
|
||||
frame.outcome = 'add'; frame.token = action.token; frame.territory = destination;
|
||||
log(g, `${p.name} added one hidden Terror token to ${territory(frame.territory).name}, which already held Terror, and received 4 spice from the bank. The separate Mentat placement remains available according to its own usage.`,
|
||||
{ faction: p.faction, name: 'Grumman Collection' });
|
||||
}
|
||||
frame.stage = 'complete';
|
||||
frame.signature = grummanCollectionSignature(frame);
|
||||
}
|
||||
function projectedGrummanCollection(g: Game, player: string) {
|
||||
const frame = g.grummanCollection;
|
||||
if (frame?.stage !== 'choice') return null;
|
||||
const quote = homeworldRule(() => quoteGrummanCollection(g, frame.player));
|
||||
return { event: frame.event, player: frame.player, blocked: quote.blocked, removeBlocked: quote.removeBlocked,
|
||||
tokens: player === frame.player ? quote.tokens.map((token) => ({ id: token.id, kind: token.kind })) : [],
|
||||
destinations: player === frame.player ? quote.destinations.map((id) => ({ id, name: territory(id).name })) : [] };
|
||||
}
|
||||
function commitCollection(
|
||||
g: Game,
|
||||
quote: ReturnType<typeof quoteSpiceCollection>,
|
||||
@@ -10295,8 +10410,13 @@ function commitCollection(
|
||||
}
|
||||
for (const receipt of quote.receipts)
|
||||
creditGiediCollection(g, receipt.player, receipt.desert);
|
||||
stageGrummanCollection(g);
|
||||
}
|
||||
function collect(g: Game) {
|
||||
if (g.grummanCollection?.turn === g.turn) {
|
||||
grummanCollectionIntegrity(g);
|
||||
return;
|
||||
}
|
||||
if (g.ecazCollection?.turn === g.turn) {
|
||||
ecazCollectionIntegrity(g);
|
||||
return;
|
||||
@@ -11025,6 +11145,7 @@ function gholaOptions(g: Game, p: Player) {
|
||||
};
|
||||
}
|
||||
function marketGholaIntegrity(g: Game) {
|
||||
grummanCollectionIntegrity(g);
|
||||
homeworldVictoryReturnIntegrity(g);
|
||||
homeworldRevivalReturnIntegrity(g);
|
||||
terrorEntryIntegrity(g);
|
||||
@@ -16013,6 +16134,7 @@ function finishActionContinuations(g: Game) {
|
||||
resumeHomeworldRevivalReturn(g);
|
||||
resumeHomeworldVictoryReturn(g);
|
||||
resumeMarketGhola(g);
|
||||
resumeGrummanCollection(g);
|
||||
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
|
||||
if (
|
||||
!g.truthtrance &&
|
||||
@@ -16544,6 +16666,10 @@ function applyActionInner(
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'grummanCollection') {
|
||||
decideGrummanCollection(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'moritaniTerror') {
|
||||
decideTerror(g, p, action);
|
||||
return g;
|
||||
@@ -19411,7 +19537,7 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
schema: g.schema,
|
||||
botsPending: g.botsPending ?? false,
|
||||
automaticContinuationPending: !!g.pendingTreacheryDiscard || homeworldRevealPending(g) || homeworldShipmentAutomatic(g),
|
||||
automaticContinuationPending: !!g.pendingTreacheryDiscard || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
botNextActionAt: g.botNextActionAt ?? null,
|
||||
code: g.code,
|
||||
version: g.version,
|
||||
@@ -19428,6 +19554,7 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
moritaniPendingPlacement:
|
||||
me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
|
||||
grummanCollection: projectedGrummanCollection(g, id),
|
||||
terrorEntry: g.pendingTerrorEntry
|
||||
? (() => {
|
||||
const entry = g.pendingTerrorEntry!;
|
||||
@@ -19440,7 +19567,14 @@ export function viewGame(state: Game, id: string) {
|
||||
sector: entry.sector,
|
||||
cause: entry.cause,
|
||||
stage: entry.stage,
|
||||
...(me.faction === 'moritani' && token
|
||||
...(me.faction === 'moritani' && entry.stage === 'select'
|
||||
? { candidates: entry.candidates!.map((id) => {
|
||||
const candidate = g.moritaniTerror!.tokens.find((t) => t.id === id)!;
|
||||
return { token: id, kind: candidate.kind, canReveal: !terrorRevealBlocked(g, entry, candidate.kind),
|
||||
canOfferAlliance: !terrorAllianceBlocked(g, entry, candidate.kind),
|
||||
revealBlocked: terrorRevealBlocked(g, entry, candidate.kind) ?? undefined };
|
||||
}) } : {}),
|
||||
...(me.faction === 'moritani' && token && entry.stage !== 'select'
|
||||
? {
|
||||
kind: token.kind as TerrorKind,
|
||||
canOfferAlliance: !terrorAllianceBlocked(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Use the current private projection; do not reconstruct hidden Terror supply. */
|
||||
export function grummanCollectionCanAct(g: GameView): boolean {
|
||||
return !!(
|
||||
g.status === 'playing' &&
|
||||
g.grummanCollection?.player === g.me &&
|
||||
g.decision?.kind === 'grummanCollection' &&
|
||||
g.decision.player === g.me &&
|
||||
g.decision.event === g.grummanCollection.event &&
|
||||
!g.response &&
|
||||
!g.truthtrance &&
|
||||
!g.phaseOpening &&
|
||||
!g.automaticContinuationPending
|
||||
);
|
||||
}
|
||||
|
||||
export function grummanCollectionChoice(
|
||||
g: GameView,
|
||||
token: string,
|
||||
territory: string,
|
||||
): { blocked: string | null; action: Action | null } {
|
||||
const offer = g.grummanCollection;
|
||||
let blocked: string | null = null;
|
||||
if (!grummanCollectionCanAct(g))
|
||||
blocked = 'Wait for the current choice to finish.';
|
||||
else if (offer!.blocked) blocked = offer!.blocked;
|
||||
else if (!offer!.tokens.some((candidate) => candidate.id === token))
|
||||
blocked = 'Choose a currently available Terror token.';
|
||||
else if (!offer!.destinations.some((candidate) => candidate.id === territory))
|
||||
blocked = 'Choose a current destination stronghold.';
|
||||
return {
|
||||
blocked,
|
||||
action: blocked
|
||||
? null
|
||||
: {
|
||||
type: 'decision',
|
||||
event: offer!.event,
|
||||
mode: 'add',
|
||||
token,
|
||||
territory,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function grummanCollectionActions(g: GameView): Action[] {
|
||||
if (!grummanCollectionCanAct(g)) return [];
|
||||
const offer = g.grummanCollection!;
|
||||
const choice = grummanCollectionChoice(
|
||||
g,
|
||||
offer.tokens[0]?.id ?? '',
|
||||
offer.destinations[0]?.id ?? '',
|
||||
);
|
||||
return [
|
||||
choice.action ?? { type: 'decision', event: offer.event, decline: true },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Game } from './engine';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
|
||||
export type GrummanCollection = {
|
||||
event: string;
|
||||
turn: number;
|
||||
player: string;
|
||||
stage: 'waiting' | 'choice' | 'complete';
|
||||
outcome?: 'add' | 'decline' | 'expired';
|
||||
token?: string;
|
||||
territory?: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
/** Bind phase progress so a completed operation cannot rewind to a choice. */
|
||||
export function grummanCollectionSignature(frame: Omit<GrummanCollection, 'signature'>): string {
|
||||
return JSON.stringify(['grummanCollection', frame.event, frame.turn, frame.player,
|
||||
frame.stage, frame.outcome ?? null, frame.token ?? null, frame.territory ?? null]);
|
||||
}
|
||||
|
||||
export function validateGrummanCollection(g: Pick<Game, 'turn' | 'phase' | 'players' | 'homeworlds' | 'grummanCollection'>): void {
|
||||
const frame = g.grummanCollection;
|
||||
if (frame === undefined) return;
|
||||
const text = (s: unknown): s is string => typeof s === 'string' && !!s;
|
||||
if (!frame || typeof frame !== 'object' || Array.isArray(frame) ||
|
||||
Object.keys(frame).some((key) => !['event', 'turn', 'player', 'stage', 'outcome', 'token', 'territory', 'signature'].includes(key)) ||
|
||||
!text(frame.event) || !text(frame.player) || !Number.isSafeInteger(frame.turn) || frame.turn < 1 || frame.turn > g.turn ||
|
||||
!g.homeworlds?.custody || g.players.filter((p) => p.id === frame.player && p.faction === 'moritani').length !== 1 ||
|
||||
!['waiting', 'choice', 'complete'].includes(frame.stage) ||
|
||||
(frame.stage !== 'complete' && (frame.turn !== g.turn || g.phase !== 7)) ||
|
||||
(frame.stage === 'complete' ? !['add', 'decline', 'expired'].includes(frame.outcome ?? '') : frame.outcome !== undefined) ||
|
||||
(frame.outcome === 'add' ? !text(frame.token) || !TERROR_STRONGHOLDS.includes(frame.territory ?? '') : frame.token !== undefined || frame.territory !== undefined) ||
|
||||
frame.signature !== grummanCollectionSignature(frame))
|
||||
throw new HomeworldCustodyError('The saved Grumman Collection opportunity no longer matches its original phase and completion.');
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Game } from './engine';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import {
|
||||
TERROR_KINDS,
|
||||
TERROR_STRONGHOLDS,
|
||||
type TerrorState,
|
||||
type TerrorToken,
|
||||
} from './moritani-terror';
|
||||
|
||||
export type GrummanCollectionContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds' | 'moritaniTerror'
|
||||
>;
|
||||
export type GrummanCollectionQuote = {
|
||||
player: string;
|
||||
population: number | null;
|
||||
high: boolean;
|
||||
blocked: string | null;
|
||||
/** Owner-only physical identities and secret faces; never a public projection. */
|
||||
tokens: TerrorToken[];
|
||||
destinations: string[];
|
||||
removeBlocked: string;
|
||||
};
|
||||
export type GrummanCollectionAction =
|
||||
| { mode: 'add'; token: string; destination: string }
|
||||
| { mode: 'decline' }
|
||||
| { mode: 'remove'; token: string };
|
||||
|
||||
const removalBlock =
|
||||
'Grumman Terror removal awaits a ruling on the removed token’s next custody zone.';
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new HomeworldCustodyError(message);
|
||||
}
|
||||
function knownKeys(value: unknown, allowed: string[]): boolean {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).every((key) => allowed.includes(key))
|
||||
);
|
||||
}
|
||||
function stateFor(
|
||||
context: GrummanCollectionContext,
|
||||
ownerId: string,
|
||||
): TerrorState {
|
||||
const owners = context.players.filter((player) => player.id === ownerId);
|
||||
if (owners.length !== 1 || owners[0].faction !== 'moritani')
|
||||
fail('Only Moritani can use the native Grumman collection opportunity.');
|
||||
const state = context.moritaniTerror;
|
||||
if (
|
||||
!knownKeys(state, ['tokens', 'placementTurn', 'supplyEpoch']) ||
|
||||
!Array.isArray(state?.tokens) ||
|
||||
state.tokens.length !== TERROR_KINDS.length ||
|
||||
(state.placementTurn !== undefined &&
|
||||
(!Number.isSafeInteger(state.placementTurn) ||
|
||||
state.placementTurn < 1)) ||
|
||||
(state.supplyEpoch !== undefined &&
|
||||
(!Number.isSafeInteger(state.supplyEpoch) || state.supplyEpoch < 0))
|
||||
)
|
||||
fail(
|
||||
'Grumman needs the complete original Terror inventory and placement records.',
|
||||
);
|
||||
const ids = new Set<string>(),
|
||||
kinds = new Set<string>();
|
||||
for (const token of state.tokens) {
|
||||
if (
|
||||
!knownKeys(token, ['id', 'kind', 'location', 'status']) ||
|
||||
typeof token.id !== 'string' ||
|
||||
!token.id ||
|
||||
ids.has(token.id) ||
|
||||
!TERROR_KINDS.includes(token.kind) ||
|
||||
kinds.has(token.kind) ||
|
||||
!['available', 'placed', 'removed', 'extortion'].includes(token.status) ||
|
||||
(token.status === 'placed'
|
||||
? typeof token.location !== 'string' ||
|
||||
!TERROR_STRONGHOLDS.includes(token.location)
|
||||
: token.location !== null) ||
|
||||
(token.status === 'extortion' && token.kind !== 'extortion')
|
||||
)
|
||||
fail(
|
||||
'Grumman needs six distinct Terror tokens in their valid custody zones.',
|
||||
);
|
||||
ids.add(token.id);
|
||||
kinds.add(token.kind);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Current native population only. The caller owns phase, once-only receipt and
|
||||
* pending-entry reservations. Occupied Homeworld lifecycle remains separate. */
|
||||
export function quoteGrummanCollection(
|
||||
context: GrummanCollectionContext,
|
||||
ownerId: string,
|
||||
): GrummanCollectionQuote {
|
||||
const state = stateFor(context, ownerId);
|
||||
const home = context.homeworlds?.custody
|
||||
? homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).find(
|
||||
(candidate) =>
|
||||
candidate.native === ownerId && candidate.card === 'grumman',
|
||||
)
|
||||
: undefined;
|
||||
const high = home?.side === 'high';
|
||||
const tokens = high
|
||||
? state.tokens.filter((token) => token.status === 'available')
|
||||
: [];
|
||||
const destinations = high
|
||||
? TERROR_STRONGHOLDS.filter((destination) =>
|
||||
state.tokens.some(
|
||||
(token) =>
|
||||
token.status === 'placed' && token.location === destination,
|
||||
),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
player: ownerId,
|
||||
population: home?.population ?? null,
|
||||
high,
|
||||
blocked: !high
|
||||
? 'Grumman needs at least eight native forces for its collection opportunity.'
|
||||
: !tokens.length
|
||||
? 'There is no available Terror token to add.'
|
||||
: !destinations.length
|
||||
? 'Adding Terror requires an ordinary stronghold already containing a Terror token.'
|
||||
: null,
|
||||
tokens: tokens.map((token) => ({ ...token })),
|
||||
destinations: [...destinations],
|
||||
removeBlocked: removalBlock,
|
||||
};
|
||||
}
|
||||
|
||||
/** A successful addition and its four bank spice are one quote. No effects,
|
||||
* randomness, supply rotation or Mentat placement allowance are consumed here. */
|
||||
export function quoteGrummanCollectionAction(
|
||||
context: GrummanCollectionContext,
|
||||
ownerId: string,
|
||||
action: GrummanCollectionAction,
|
||||
): { state: TerrorState; amount: 0 | 4 } {
|
||||
const state = stateFor(context, ownerId);
|
||||
if (
|
||||
!knownKeys(
|
||||
action,
|
||||
action?.mode === 'add'
|
||||
? ['mode', 'token', 'destination']
|
||||
: action?.mode === 'remove'
|
||||
? ['mode', 'token']
|
||||
: ['mode'],
|
||||
)
|
||||
)
|
||||
fail('Choose one Grumman collection operation.');
|
||||
if (action.mode === 'decline')
|
||||
return { state: structuredClone(state), amount: 0 };
|
||||
if (action.mode === 'remove') fail(removalBlock);
|
||||
if (action.mode !== 'add') fail('Choose one Grumman collection operation.');
|
||||
const quote = quoteGrummanCollection(context, ownerId);
|
||||
if (quote.blocked) fail(quote.blocked);
|
||||
if (!quote.tokens.some((token) => token.id === action.token))
|
||||
fail('Choose an available physical Terror token from your supply.');
|
||||
if (!quote.destinations.includes(action.destination))
|
||||
fail('Choose an ordinary stronghold already containing Terror.');
|
||||
const result = structuredClone(state);
|
||||
const token = result.tokens.find(
|
||||
(candidate) => candidate.id === action.token,
|
||||
)!;
|
||||
token.status = 'placed';
|
||||
token.location = action.destination;
|
||||
return { state: result, amount: 4 };
|
||||
}
|
||||
@@ -187,11 +187,7 @@ export function quoteMoritaniAllianceCancellation(
|
||||
matches.length === 1 &&
|
||||
matches[0].status === 'placed' &&
|
||||
matches[0].location === entry.territory &&
|
||||
TERROR_KINDS.includes(matches[0].kind) &&
|
||||
tokens.filter(
|
||||
(token) =>
|
||||
token.status === 'placed' && token.location === entry.territory,
|
||||
).length === 1,
|
||||
TERROR_KINDS.includes(matches[0].kind),
|
||||
'The alliance opportunity no longer has its original hidden Terror token.',
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -274,6 +274,7 @@ export type TerrorDefinition = Readonly<{
|
||||
export const TERROR_COMMON_GAMEPLAY: readonly string[] = Object.freeze([
|
||||
'During Mentat Pause, Moritani may place one hidden Terror token from supply or move one placed token. Choose an ordinary stronghold without another Terror token. Storm does not prevent placement; Homeworlds and the Hidden Mobile Stronghold are excluded.',
|
||||
'A token may be revealed when another faction enters its stronghold by shipment or movement. An ally does not qualify, but Bene Gesserit advisors do. Existing occupation alone does not trigger it. Declining the opportunity keeps the token hidden and unspent.',
|
||||
'When a stronghold contains several Terror tokens, Moritani chooses one for that entry before deciding whether to reveal it or offer an alliance. The remaining tokens stay hidden and do not activate from that same entry.',
|
||||
'Before revealing a token, Moritani may instead offer the entrant an alliance, except when the entrant is Ecaz. Acceptance ends both previous alliances and returns the token to supply without revealing it. Refusal requires the token to be revealed.',
|
||||
'Karama may prevent the placement or relocation opportunity, or prevent Enemy of My Enemy from forming an alliance while leaving revelation available. There is no generic Karama cancellation of a Terror token’s reveal or effect.',
|
||||
'Terror tokens survive storm and Lasgun/Shield explosions. A Richese No-Field placement can trigger one even with a zero token; revealing a No-Field or replacing forces with Face Dancers does not.',
|
||||
|
||||
@@ -3134,6 +3134,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
|
||||
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
|
||||
'Low Tupile’s intelligence advantage requires contact with another faction’s Homeworld or that faction’s presence on Tupile, and is usable once per faction. Its public eligibility checks are implemented, but the private disclosure action and occupation-dependent availability remain unfinished.',
|
||||
'During Spice Collection, Grumman with at least eight native reserves may add one available Terror token to an ordinary stronghold that already has Terror, then collect four bank spice. This opportunity is separate from Mentat placement and follows any pending shared spice allocation. Declining collects nothing. Removing a token remains unavailable while its destination after removal awaits a ruling.',
|
||||
'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.',
|
||||
'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.',
|
||||
'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
|
||||
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
|
||||
@@ -3163,6 +3165,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'game/homeworld-revival-return.ts',
|
||||
'game/homeworld-victory-reinforcement.ts',
|
||||
'game/homeworld-victory-return.ts',
|
||||
'game/grumman-collection.ts',
|
||||
'game/grumman-collection-return.ts',
|
||||
'game/grumman-collection-options.ts',
|
||||
'game/terror-entry-receipt.ts',
|
||||
'components/grumman-collection.tsx',
|
||||
'components/terror-board-markers.tsx',
|
||||
'game/homeworld-arrival.ts',
|
||||
'game/tupile-intelligence.ts',
|
||||
'game/homeworld-shipment.ts',
|
||||
@@ -3302,6 +3310,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/caladan-reinforcement-controls.test.ts',
|
||||
'tests/homeworld-victory-reinforcement-recovery.test.ts',
|
||||
'tests/tupile-intelligence.test.ts',
|
||||
'tests/grumman-collection.test.ts',
|
||||
'tests/grumman-collection-engine.test.ts',
|
||||
'tests/grumman-collection-controls.test.ts',
|
||||
'tests/grumman-collection-recovery.test.ts',
|
||||
'tests/grumman-selection-integrity.test.ts',
|
||||
'tests/homeworld-benefits-recovery.test.ts',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ const DECISIONS = {
|
||||
ecazSpice: true,
|
||||
homeworldRevivalDeployment: true,
|
||||
caladanReinforcement: true,
|
||||
grummanCollection: true,
|
||||
choamStorm: true,
|
||||
choamMovement: true,
|
||||
choamMentat: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
|
||||
* This is a consistency receipt, not an authorization token or secret digest. */
|
||||
export function terrorEntrySignature(entry: TerrorEntry): string {
|
||||
return JSON.stringify([
|
||||
entry.token,
|
||||
entry.candidates === undefined ? entry.token : ['stack', entry.candidates, entry.selectionSignature ?? null],
|
||||
entry.entrant,
|
||||
entry.territory,
|
||||
entry.sector,
|
||||
@@ -23,14 +23,29 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
|
||||
]);
|
||||
}
|
||||
|
||||
export function terrorSelectionSignature(entry: TerrorEntry): string {
|
||||
return JSON.stringify(['terrorSelection', entry.candidates, entry.token, entry.entrant,
|
||||
entry.territory, entry.sector, entry.amount, entry.elite, entry.cause,
|
||||
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null]);
|
||||
}
|
||||
|
||||
/** Older saves without a receipt remain explicit legacy state. Never invent
|
||||
* their original entry count from the destination's present occupants. */
|
||||
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
if (entry.entrySignature === undefined) return;
|
||||
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.stage !== 'select') return;
|
||||
if (
|
||||
typeof entry.entrySignature !== 'string' ||
|
||||
typeof entry.token !== 'string' ||
|
||||
!entry.token ||
|
||||
(entry.candidates === undefined
|
||||
? entry.selectionSignature !== undefined || entry.stage === 'select'
|
||||
: !Array.isArray(entry.candidates) || entry.candidates.length < 2 ||
|
||||
entry.candidates.some((id) => typeof id !== 'string' || !id) ||
|
||||
new Set(entry.candidates).size !== entry.candidates.length ||
|
||||
!entry.candidates.includes(entry.token) ||
|
||||
(entry.stage === 'select'
|
||||
? entry.token !== entry.candidates[0] || entry.selectionSignature !== undefined
|
||||
: entry.selectionSignature !== terrorSelectionSignature(entry))) ||
|
||||
typeof entry.entrant !== 'string' ||
|
||||
!entry.entrant ||
|
||||
!TERROR_STRONGHOLDS.includes(entry.territory) ||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { baseDeck, treacheryDeck, spiceDeck, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { cashInCards } from '../game/choam-karama';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { createTerrorState } from '../game/moritani-terror';
|
||||
import {
|
||||
homeworldContext,
|
||||
homeworldGameIntegrity,
|
||||
@@ -465,6 +466,7 @@ void test('Moritani delayed Chemistry cleanup preserves its actual weapon or def
|
||||
const moritani = newPlayer('mo', 'Moritani', 'moritani');
|
||||
moritani.spice = 20;
|
||||
g.players.push(moritani);
|
||||
g.moritaniTerror = createTerrorState(() => 0);
|
||||
g.order.push('mo');
|
||||
player(g, 'other').ally = 'mo';
|
||||
moritani.ally = 'other';
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeHomeworldGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck, type Card } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
|
||||
|
||||
export const grummanPlayer = (g: Game, id: string) =>
|
||||
g.players.find((p) => p.id === id)!;
|
||||
export const grummanReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
|
||||
/** Real Homeworld audit initialization and faction setup. Only the conserved
|
||||
* pre-Collection board position is staged; no signed phase/arrival is fabricated.
|
||||
* Runtime Moritani support here does not lift its public expansion-start gate. */
|
||||
export function grummanCollectionFixture(
|
||||
options: { native?: number; advanced?: boolean } = {},
|
||||
): Game {
|
||||
let g = createGame(
|
||||
'GRUMMANCOLLECTION',
|
||||
newPlayer('m', 'Moritani', 'moritani'),
|
||||
options.advanced ?? false,
|
||||
[],
|
||||
);
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer('g', 'Harkonnen', 'harkonnen'));
|
||||
g = applyAction(g, 'm', { type: 'homeworlds', enabled: true });
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeHomeworldGameForAudit(g);
|
||||
for (let n = 0; g.status === 'setup' && n < 60; n++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand.splice(0));
|
||||
Object.assign(p, {
|
||||
spice: 20,
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
shipped: true,
|
||||
moved: 0,
|
||||
});
|
||||
}
|
||||
const owner = grummanPlayer(g, 'm');
|
||||
owner.reserves = options.native ?? 8;
|
||||
owner.forces = { 'polar_sink:0': 20 - owner.reserves };
|
||||
assert.ok(g.moritaniTerror);
|
||||
const robbery = g.moritaniTerror.tokens.find(
|
||||
(token) => token.kind === 'robbery',
|
||||
)!;
|
||||
g.moritaniTerror = placeTerror(g.moritaniTerror, robbery.id, 'arrakeen', 1);
|
||||
Object.assign(g, {
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
active: 'a',
|
||||
movementRemaining: ['a'],
|
||||
order: ['a', 'm', 'g'],
|
||||
ready: [],
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
storm: 18,
|
||||
spice: {},
|
||||
});
|
||||
grummanInventory(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
export function enterGrummanCollection(g: Game): Game {
|
||||
const before = grummanReload(g);
|
||||
const next = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(next.phase, 7);
|
||||
grummanInventory(next);
|
||||
return next;
|
||||
}
|
||||
export function grummanToken(g: Game, kind: TerrorKind) {
|
||||
return g.moritaniTerror!.tokens.find((token) => token.kind === kind)!;
|
||||
}
|
||||
export function addGrummanToken(
|
||||
g: Game,
|
||||
kind: TerrorKind = 'sabotage',
|
||||
territory = 'arrakeen',
|
||||
): Game {
|
||||
assert.equal(g.decision?.kind, 'grummanCollection');
|
||||
return applyAction(g, g.grummanCollection!.player, {
|
||||
type: 'decision',
|
||||
event: g.grummanCollection!.event,
|
||||
mode: 'add',
|
||||
token: grummanToken(g, kind).id,
|
||||
territory,
|
||||
});
|
||||
}
|
||||
export function holdGrummanCard(g: Game, player: string, kind: string): Card {
|
||||
const index = g.deck.findIndex(
|
||||
(card) => card.kind === kind || card.effect === kind,
|
||||
);
|
||||
assert.ok(index >= 0, `Missing ${kind}`);
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
grummanPlayer(g, player).hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Later movement checkpoint preserves the actual Collection receipt/stack.
|
||||
* IDs derive from factions so SQL fixtures can bind seats before signing. */
|
||||
export function stageGrummanArrival(g: Game): Game {
|
||||
const next = grummanReload(g);
|
||||
const entrant = next.players.find((p) => p.faction === 'atreides')!;
|
||||
Object.assign(next, {
|
||||
turn: g.turn + 1,
|
||||
phase: 5,
|
||||
active: entrant.id,
|
||||
movementRemaining: [entrant.id],
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
entrant.shipped = false;
|
||||
entrant.moved = 0;
|
||||
grummanInventory(next);
|
||||
return next;
|
||||
}
|
||||
export function enterGrummanStack(g: Game, amount = 1): Game {
|
||||
return applyAction(g, g.active!, {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
export function grummanReject(
|
||||
g: Game,
|
||||
id: string,
|
||||
action: Action,
|
||||
match: RegExp = /./,
|
||||
): void {
|
||||
const before = grummanReload(g);
|
||||
assert.throws(() => applyAction(g, id, action), match);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
export function grummanInventory(g: Game): void {
|
||||
homeworldGameIntegrity(g);
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves +
|
||||
p.tanks +
|
||||
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
|
||||
20,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
baseDeck()
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
);
|
||||
assert.equal(g.moritaniTerror!.tokens.length, 6);
|
||||
assert.equal(
|
||||
new Set(g.moritaniTerror!.tokens.map((token) => token.id)).size,
|
||||
6,
|
||||
);
|
||||
assert.equal(
|
||||
new Set(g.moritaniTerror!.tokens.map((token) => token.kind)).size,
|
||||
6,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { GrummanCollection } from '../components/grumman-collection';
|
||||
import { MoritaniEntry } from '../components/moritani-terror';
|
||||
import { TerrorBoardMarkers } from '../components/terror-board-markers';
|
||||
import {
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type GameView,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
grummanCollectionActions,
|
||||
grummanCollectionCanAct,
|
||||
grummanCollectionChoice,
|
||||
} from '../game/grumman-collection-options';
|
||||
|
||||
/** Private projected choices isolate client behavior. Engine and recovery suites
|
||||
* cover actual collection entry, token custody and paid receipts. */
|
||||
function fixture(): GameView {
|
||||
const g = createGame(
|
||||
'GRUMMANCONTROLS',
|
||||
newPlayer('m', 'Moritani', 'moritani'),
|
||||
);
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 7,
|
||||
active: 'a',
|
||||
order: ['m', 'a'],
|
||||
storm: 18,
|
||||
});
|
||||
const view = viewGame(g, 'm');
|
||||
view.decision = {
|
||||
kind: 'grummanCollection',
|
||||
player: 'm',
|
||||
event: 'collection-1',
|
||||
};
|
||||
view.grummanCollection = {
|
||||
event: 'collection-1',
|
||||
player: 'm',
|
||||
blocked: null,
|
||||
removeBlocked: 'Removing a token awaits a custody ruling.',
|
||||
tokens: [
|
||||
{ id: 'terror-1', kind: 'robbery' },
|
||||
{ id: 'terror-2', kind: 'sabotage' },
|
||||
],
|
||||
destinations: [
|
||||
{ id: 'arrakeen', name: 'Arrakeen' },
|
||||
{ id: 'carthag', name: 'Carthag' },
|
||||
],
|
||||
};
|
||||
return view;
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(GrummanCollection, { game, busy, act() {} }),
|
||||
);
|
||||
}
|
||||
function entryMarkup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(MoritaniEntry, { game, busy, act() {} }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('public map markers group hidden Terror stacks without reading token faces', () => {
|
||||
const tokens = [
|
||||
{ status: 'placed', location: 'arrakeen' },
|
||||
{ status: 'placed', location: 'arrakeen' },
|
||||
{ status: 'placed', location: 'carthag' },
|
||||
{ status: 'available', location: null },
|
||||
{ status: 'removed', location: null },
|
||||
];
|
||||
for (const token of tokens)
|
||||
Object.defineProperty(token, 'kind', {
|
||||
get() {
|
||||
throw new Error('Map read a hidden Terror face');
|
||||
},
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
createElement('svg', null, createElement(TerrorBoardMarkers, { tokens })),
|
||||
);
|
||||
assert.equal(
|
||||
(html.match(/aria-label="2 hidden Terror tokens in Arrakeen"/g) ?? [])
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(html.match(/aria-label="1 hidden Terror token in Carthag"/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.equal((html.match(/<g /g) ?? []).length, 2);
|
||||
assert.match(html, />T2<\/text>/);
|
||||
assert.match(html, />T<\/text>/);
|
||||
assert.doesNotMatch(html, /available|removed/);
|
||||
});
|
||||
|
||||
void test('Grumman controls require an explicit available-token addition before the four-spice reward', () => {
|
||||
const view = fixture();
|
||||
const before = structuredClone(view);
|
||||
assert.deepEqual(grummanCollectionChoice(view, 'terror-2', 'carthag'), {
|
||||
blocked: null,
|
||||
action: {
|
||||
type: 'decision',
|
||||
event: 'collection-1',
|
||||
mode: 'add',
|
||||
token: 'terror-2',
|
||||
territory: 'carthag',
|
||||
},
|
||||
});
|
||||
const html = markup(view);
|
||||
assert.match(html, /Your Terror token/);
|
||||
assert.match(html, /Robbery/);
|
||||
assert.match(html, /Sabotage/);
|
||||
assert.match(html, /only after the addition resolves/);
|
||||
assert.match(html, /Add token and collect 4 spice/);
|
||||
assert.match(
|
||||
html,
|
||||
/Removal unavailable: Removing a token awaits a custody ruling/,
|
||||
);
|
||||
assert.doesNotMatch(html, /<button[^>]*>Remove/);
|
||||
assert.deepEqual(view, before);
|
||||
});
|
||||
|
||||
void test('stale tokens or destinations cannot add; blocked or exhausted collection can still be declined', () => {
|
||||
const view = fixture();
|
||||
assert.equal(
|
||||
grummanCollectionChoice(view, 'used-token', 'arrakeen').action,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
grummanCollectionChoice(view, 'terror-1', 'polar_sink').action,
|
||||
null,
|
||||
);
|
||||
for (const patch of [
|
||||
{ blocked: 'Grumman is now low.' },
|
||||
{ tokens: [] },
|
||||
{ destinations: [] },
|
||||
]) {
|
||||
const blocked = {
|
||||
...view,
|
||||
grummanCollection: { ...view.grummanCollection!, ...patch },
|
||||
};
|
||||
assert.deepEqual(grummanCollectionActions(blocked), [
|
||||
{ type: 'decision', event: 'collection-1', decline: true },
|
||||
]);
|
||||
const html = markup(blocked);
|
||||
assert.match(
|
||||
html,
|
||||
/<button[^>]* disabled=""[^>]*>Add token and collect 4 spice<\/button>/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<button(?![^>]* disabled="")[^>]*>Pass Grumman collection<\/button>/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Grumman policy follows only the current private offer across profiles and never reads another player', () => {
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = fixture();
|
||||
view.players[0].bot = profile;
|
||||
Object.defineProperty(view, 'players', {
|
||||
get() {
|
||||
throw new Error('Policy read player data');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(grummanCollectionActions(view), [
|
||||
{
|
||||
type: 'decision',
|
||||
event: 'collection-1',
|
||||
mode: 'add',
|
||||
token: 'terror-1',
|
||||
territory: 'arrakeen',
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('collection ownership, event and interruption fences prevent stale or observer controls', () => {
|
||||
const view = fixture();
|
||||
assert.equal(grummanCollectionCanAct(view), true);
|
||||
for (const patch of [
|
||||
{ me: 'a' },
|
||||
{ status: 'finished' },
|
||||
{ decision: null },
|
||||
{
|
||||
decision: {
|
||||
kind: 'grummanCollection',
|
||||
player: 'a',
|
||||
event: 'collection-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
decision: { kind: 'grummanCollection', player: 'm', event: 'old-event' },
|
||||
},
|
||||
{ decision: { kind: 'moritaniPlacement', player: 'm' } },
|
||||
{ response: { kind: 'capture', owner: 'a', passed: [] } },
|
||||
{ truthtrance: { stage: 'answer' } },
|
||||
{ phaseOpening: { kind: 'ix' } },
|
||||
{ automaticContinuationPending: true },
|
||||
]) {
|
||||
const blocked = { ...view, ...patch } as GameView;
|
||||
assert.equal(grummanCollectionCanAct(blocked), false);
|
||||
assert.deepEqual(grummanCollectionActions(blocked), []);
|
||||
assert.equal(
|
||||
grummanCollectionChoice(blocked, 'terror-1', 'arrakeen').action,
|
||||
null,
|
||||
);
|
||||
}
|
||||
assert.equal(markup({ ...view, me: 'a' }), '');
|
||||
assert.equal(markup({ ...view, grummanCollection: null }), '');
|
||||
assert.equal(
|
||||
(markup(view, true).match(/<button[^>]* disabled=""/g) ?? []).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
void test('stacked entry requires a private token selection before reaction and never consults a selected kind or outsider candidates', () => {
|
||||
const view = fixture();
|
||||
view.decision = {
|
||||
kind: 'moritaniTerror',
|
||||
player: 'm',
|
||||
entrant: 'a',
|
||||
territory: 'arrakeen',
|
||||
};
|
||||
view.terrorEntry = {
|
||||
entrant: 'a',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
cause: 'shipment',
|
||||
stage: 'select',
|
||||
candidates: [
|
||||
{
|
||||
token: 'terror-1',
|
||||
kind: 'robbery',
|
||||
canReveal: true,
|
||||
canOfferAlliance: true,
|
||||
revealBlocked: undefined,
|
||||
},
|
||||
{
|
||||
token: 'terror-2',
|
||||
kind: 'atomics',
|
||||
canReveal: false,
|
||||
canOfferAlliance: false,
|
||||
revealBlocked: 'Atomics awaits implementation.',
|
||||
},
|
||||
],
|
||||
};
|
||||
Object.defineProperty(view.terrorEntry, 'kind', {
|
||||
get() {
|
||||
throw new Error('Selected kind is unavailable');
|
||||
},
|
||||
});
|
||||
const html = entryMarkup(view);
|
||||
assert.match(html, /Select Robbery/);
|
||||
assert.match(html, /Select Atomics/);
|
||||
assert.match(html, /Selecting a token keeps its face private/);
|
||||
assert.match(html, /Atomics awaits implementation/);
|
||||
assert.match(html, /Leave all tokens hidden/);
|
||||
assert.doesNotMatch(html, /Reveal Robbery|Take half their spice/);
|
||||
for (const patch of [
|
||||
{},
|
||||
{ automaticContinuationPending: true },
|
||||
{ phaseOpening: { kind: 'ix' } },
|
||||
{ response: { kind: 'capture' } },
|
||||
]) {
|
||||
const blocked = { ...view, ...patch } as GameView;
|
||||
assert.equal(
|
||||
(
|
||||
entryMarkup(blocked, !Object.keys(patch).length).match(
|
||||
/<button[^>]* disabled=""/g,
|
||||
) ?? []
|
||||
).length,
|
||||
3,
|
||||
);
|
||||
}
|
||||
const outsider = {
|
||||
...view,
|
||||
me: 'a',
|
||||
decision: { kind: 'moritaniTerror', player: 'a' },
|
||||
} as GameView;
|
||||
Object.defineProperty(outsider.terrorEntry!, 'candidates', {
|
||||
get() {
|
||||
throw new Error('Read hidden candidates');
|
||||
},
|
||||
});
|
||||
assert.equal(entryMarkup(outsider), '');
|
||||
assert.equal(
|
||||
entryMarkup({
|
||||
...outsider,
|
||||
decision: {
|
||||
kind: 'moritaniTerror',
|
||||
player: 'm',
|
||||
entrant: 'a',
|
||||
territory: 'arrakeen',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { grummanCollectionActions } from '../game/grumman-collection-options';
|
||||
import {
|
||||
grummanCollectionFixture,
|
||||
enterGrummanCollection,
|
||||
grummanPlayer,
|
||||
grummanReload,
|
||||
grummanToken,
|
||||
addGrummanToken,
|
||||
holdGrummanCard,
|
||||
stageGrummanArrival,
|
||||
enterGrummanStack,
|
||||
grummanReject,
|
||||
grummanInventory,
|
||||
} from './fixture-grumman-collection';
|
||||
|
||||
function stable(g: Game) {
|
||||
grummanInventory(g);
|
||||
assert.deepEqual(normalizeAutomaticGame(grummanReload(g)), g);
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(grummanReload(g), p.id);
|
||||
for (const other of view.players.filter((seat) => seat.id !== p.id)) {
|
||||
assert.equal(other.hand, undefined);
|
||||
assert.equal(other.spice, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
function mentat(g: Game) {
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.phase, 8);
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
return g;
|
||||
}
|
||||
function decide(g: Game, extra: Record<string, unknown>) {
|
||||
return applyAction(grummanReload(g), 'm', { type: 'decision', ...extra });
|
||||
}
|
||||
|
||||
void test('actual Basic and Advanced Collection opens at native eight, adds exactly one hidden token and pays four once', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const initial = grummanCollectionFixture({ advanced });
|
||||
const tokens = structuredClone(initial.moritaniTerror);
|
||||
let g = enterGrummanCollection(initial);
|
||||
assert.equal(g.grummanCollection?.stage, 'choice');
|
||||
assert.equal(grummanPlayer(g, 'm').spice, 20);
|
||||
assert.deepEqual(g.moritaniTerror, tokens);
|
||||
const event = g.grummanCollection!.event;
|
||||
const token = grummanToken(g, 'sabotage').id;
|
||||
stable(g);
|
||||
g = addGrummanToken(grummanReload(g));
|
||||
assert.equal(grummanPlayer(g, 'm').spice, 24);
|
||||
assert.equal(g.grummanCollection?.outcome, 'add');
|
||||
assert.equal(g.grummanCollection?.stage, 'complete');
|
||||
assert.equal(g.moritaniTerror?.placementTurn, 1);
|
||||
assert.deepEqual(
|
||||
g.moritaniTerror?.tokens.filter((t) => t.id !== token),
|
||||
tokens!.tokens.filter((t) => t.id !== token),
|
||||
);
|
||||
assert.deepEqual(grummanToken(g, 'sabotage'), {
|
||||
id: token,
|
||||
kind: 'sabotage',
|
||||
status: 'placed',
|
||||
location: 'arrakeen',
|
||||
});
|
||||
assert.equal(
|
||||
g.moritaniTerror?.tokens.filter((t) => t.location === 'arrakeen').length,
|
||||
2,
|
||||
);
|
||||
grummanReject(g, 'm', {
|
||||
type: 'decision',
|
||||
event,
|
||||
mode: 'add',
|
||||
token,
|
||||
territory: 'arrakeen',
|
||||
});
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('low seven waits without income and expires on Mentat; a current high choice may decline after falling low', () => {
|
||||
const low = enterGrummanCollection(grummanCollectionFixture({ native: 7 }));
|
||||
assert.equal(low.grummanCollection?.stage, 'waiting');
|
||||
assert.equal(low.decision, null);
|
||||
assert.equal(viewGame(low, 'm').grummanCollection, null);
|
||||
stable(low);
|
||||
const expired = mentat(low);
|
||||
assert.equal(expired.grummanCollection?.outcome, 'expired');
|
||||
assert.equal(grummanPlayer(expired, 'm').spice, 20);
|
||||
assert.deepEqual(expired.moritaniTerror, low.moritaniTerror);
|
||||
stable(expired);
|
||||
|
||||
const high = enterGrummanCollection(grummanCollectionFixture());
|
||||
// Conserved later population change tests use-time eligibility without
|
||||
// fabricating a second Collection event or its payment.
|
||||
grummanPlayer(high, 'm').reserves--;
|
||||
grummanPlayer(high, 'm').forces['polar_sink:0']++;
|
||||
const event = high.grummanCollection!.event;
|
||||
assert.match(viewGame(high, 'm').grummanCollection!.blocked!, /eight native/);
|
||||
grummanReject(
|
||||
high,
|
||||
'm',
|
||||
{
|
||||
type: 'decision',
|
||||
event,
|
||||
mode: 'add',
|
||||
token: grummanToken(high, 'sabotage').id,
|
||||
territory: 'arrakeen',
|
||||
},
|
||||
/eight native/,
|
||||
);
|
||||
const declined = decide(high, { event, decline: true });
|
||||
assert.equal(declined.grummanCollection?.outcome, 'decline');
|
||||
assert.equal(grummanPlayer(declined, 'm').spice, 20);
|
||||
assert.deepEqual(declined.moritaniTerror, high.moritaniTerror);
|
||||
stable(declined);
|
||||
});
|
||||
|
||||
void test('owner event, physical availability, destination and pending removal rulings reject before token or spice mutation', () => {
|
||||
const g = enterGrummanCollection(grummanCollectionFixture());
|
||||
const event = g.grummanCollection!.event;
|
||||
const token = grummanToken(g, 'sabotage').id;
|
||||
for (const action of [
|
||||
{ event: 'old-event', mode: 'add', token, territory: 'arrakeen' },
|
||||
{
|
||||
event,
|
||||
mode: 'add',
|
||||
token: grummanToken(g, 'robbery').id,
|
||||
territory: 'arrakeen',
|
||||
},
|
||||
{ event, mode: 'add', token, territory: 'carthag' },
|
||||
{ event, mode: 'remove', token: grummanToken(g, 'robbery').id },
|
||||
{ event, decline: true, token },
|
||||
])
|
||||
grummanReject(g, 'm', { type: 'decision', ...action });
|
||||
grummanReject(g, 'a', {
|
||||
type: 'decision',
|
||||
event,
|
||||
mode: 'add',
|
||||
token,
|
||||
territory: 'arrakeen',
|
||||
});
|
||||
for (const id of ['a', 'g']) {
|
||||
const view = viewGame(g, id);
|
||||
assert.deepEqual(view.grummanCollection!.tokens, []);
|
||||
assert.deepEqual(view.grummanCollection!.destinations, []);
|
||||
assert.deepEqual(grummanCollectionActions(view), []);
|
||||
assert.ok(view.moritaniTerror!.tokens.every((t) => !('kind' in t)));
|
||||
}
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(g, 'm');
|
||||
view.players.find((p) => p.id === 'm')!.bot = profile;
|
||||
assert.deepEqual(botActions(view), grummanCollectionActions(view));
|
||||
const played = applyAction(grummanReload(g), 'm', botActions(view)[0]);
|
||||
assert.equal(grummanPlayer(played, 'm').spice, 24);
|
||||
stable(played);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Collection addition preserves the separate actual Mentat placement and its Karama response', () => {
|
||||
const initial = grummanCollectionFixture();
|
||||
holdGrummanCard(initial, 'a', 'karama');
|
||||
let g = mentat(addGrummanToken(enterGrummanCollection(initial)));
|
||||
const token = grummanToken(g, 'assassination').id;
|
||||
g = decide(g, { token, territory: 'carthag' });
|
||||
assert.equal(g.response?.kind, 'moritaniPlacement');
|
||||
assert.equal(grummanToken(g, 'assassination').status, 'available');
|
||||
for (let n = 0; g.response && n < 8; n++) {
|
||||
const responder = g.players.find(
|
||||
(p) => !g.response!.passed.includes(p.id),
|
||||
)!;
|
||||
g = applyAction(g, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(grummanToken(g, 'assassination').location, 'carthag');
|
||||
assert.equal(g.moritaniTerror!.placementTurn, 2);
|
||||
assert.equal(grummanPlayer(g, 'm').spice, 24);
|
||||
assert.equal(g.grummanCollection?.outcome, 'add');
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('actual stacked entry privately selects either Robbery or Sabotage and consumes only the selected token', () => {
|
||||
for (const kind of ['robbery', 'sabotage'] as const) {
|
||||
const initial = grummanCollectionFixture();
|
||||
const shield = holdGrummanCard(initial, 'a', 'shield');
|
||||
holdGrummanCard(initial, 'm', 'worthless');
|
||||
const collection = addGrummanToken(enterGrummanCollection(initial));
|
||||
let g = enterGrummanStack(stageGrummanArrival(collection));
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'select');
|
||||
assert.equal(g.pendingTerrorEntry?.amount, 1);
|
||||
assert.equal(grummanPlayer(g, 'a').reserves, 19);
|
||||
assert.equal(grummanPlayer(g, 'a').spice, 19);
|
||||
const owned = viewGame(g, 'm').terrorEntry!;
|
||||
assert.equal(owned.kind, undefined);
|
||||
assert.equal(owned.candidates?.length, 2);
|
||||
for (const id of ['a', 'g']) {
|
||||
assert.equal(viewGame(g, id).terrorEntry?.candidates, undefined);
|
||||
assert.equal(viewGame(g, id).terrorEntry?.kind, undefined);
|
||||
}
|
||||
stable(g);
|
||||
grummanReject(g, 'm', { type: 'decision', reveal: true }, /Choose one/);
|
||||
grummanReject(
|
||||
g,
|
||||
'm',
|
||||
{ type: 'decision', token: grummanToken(g, 'atomics').id },
|
||||
/original stacked/,
|
||||
);
|
||||
const allHidden = decide(g, { decline: true });
|
||||
assert.equal(allHidden.pendingTerrorEntry, null);
|
||||
assert.deepEqual(allHidden.moritaniTerror, g.moritaniTerror);
|
||||
const token = grummanToken(g, kind).id;
|
||||
const others = g.moritaniTerror!.tokens.filter((t) => t.id !== token);
|
||||
g = decide(g, { token });
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(viewGame(g, 'm').terrorEntry?.kind, kind);
|
||||
stable(g);
|
||||
g = decide(g, { reveal: true });
|
||||
if (kind === 'robbery') g = decide(g, { choice: 'spice' });
|
||||
else {
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
|
||||
g = decide(g, { decline: true });
|
||||
}
|
||||
assert.equal(g.pendingTerrorEntry, null);
|
||||
assert.equal(grummanToken(g, kind).status, 'removed');
|
||||
assert.deepEqual(
|
||||
g.moritaniTerror!.tokens.filter((t) => t.id !== token),
|
||||
others,
|
||||
);
|
||||
assert.equal(grummanPlayer(g, 'a').forces['arrakeen:10'], 1);
|
||||
assert.equal(grummanPlayer(g, 'a').reserves, 19);
|
||||
assert.equal(grummanPlayer(g, 'm').spice, kind === 'robbery' ? 34 : 24);
|
||||
assert.equal(
|
||||
g.discard.filter((card) => card.id === shield.id).length,
|
||||
kind === 'sabotage' ? 1 : 0,
|
||||
);
|
||||
grummanReject(g, 'm', { type: 'decision', reveal: true });
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Karama cancellation restores the selected stacked alliance offer without changing the other hidden token', () => {
|
||||
const initial = grummanCollectionFixture();
|
||||
const card = holdGrummanCard(initial, 'a', 'karama');
|
||||
let g = enterGrummanStack(
|
||||
stageGrummanArrival(addGrummanToken(enterGrummanCollection(initial))),
|
||||
);
|
||||
const token = grummanToken(g, 'robbery').id;
|
||||
g = decide(g, { token });
|
||||
const others = structuredClone(g.moritaniTerror);
|
||||
g = decide(g, { alliance: true });
|
||||
assert.equal(g.response?.kind, 'moritaniAlliance');
|
||||
g = applyAction(grummanReload(g), 'a', {
|
||||
type: 'card',
|
||||
card: card.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.pendingTerrorEntry?.token, token);
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(viewGame(g, 'm').terrorEntry?.canOfferAlliance, false);
|
||||
assert.deepEqual(g.moritaniTerror, others);
|
||||
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
|
||||
stable(g);
|
||||
g = decide(g, { reveal: true });
|
||||
g = decide(g, { choice: 'spice' });
|
||||
assert.equal(grummanToken(g, 'sabotage').status, 'placed');
|
||||
assert.equal(grummanToken(g, 'robbery').status, 'removed');
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('signed collection and selected stack reject corrupted phase, completion, candidate or selection on reads and actions', () => {
|
||||
const collection = enterGrummanCollection(grummanCollectionFixture());
|
||||
const entry = decide(
|
||||
enterGrummanStack(stageGrummanArrival(addGrummanToken(collection))),
|
||||
{ token: grummanToken(collection, 'sabotage').id },
|
||||
);
|
||||
for (const [source, corrupt] of [
|
||||
[
|
||||
collection,
|
||||
(g: Game) => {
|
||||
g.grummanCollection!.stage = 'complete';
|
||||
},
|
||||
],
|
||||
[
|
||||
collection,
|
||||
(g: Game) => {
|
||||
g.grummanCollection!.turn++;
|
||||
},
|
||||
],
|
||||
[
|
||||
collection,
|
||||
(g: Game) => {
|
||||
g.decision = null;
|
||||
},
|
||||
],
|
||||
[
|
||||
entry,
|
||||
(g: Game) => {
|
||||
g.pendingTerrorEntry!.token = grummanToken(g, 'robbery').id;
|
||||
},
|
||||
],
|
||||
[
|
||||
entry,
|
||||
(g: Game) => {
|
||||
g.pendingTerrorEntry!.candidates!.pop();
|
||||
},
|
||||
],
|
||||
] as const) {
|
||||
const g = grummanReload(source);
|
||||
corrupt(g);
|
||||
const before = grummanReload(g);
|
||||
for (const p of g.players)
|
||||
assert.throws(
|
||||
() => viewGame(g, p.id),
|
||||
/Grumman|Terror|original|selection/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAutomaticGame(g),
|
||||
/Grumman|Terror|original|selection/i,
|
||||
);
|
||||
grummanReject(g, 'm', {
|
||||
type: 'decision',
|
||||
event: g.grummanCollection?.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, webcrypto } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import * as engine from '../game/engine';
|
||||
import * as bots from '../game/bots';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
|
||||
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
|
||||
function unitStore(runBots = bots.runBots) {
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
const hooks: { beforeWrite?: () => Promise<void> } = {};
|
||||
const writes: { expected: number; changes: number }[] = [];
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort())
|
||||
sqlite.exec(
|
||||
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
||||
);
|
||||
class Statement {
|
||||
values: (string | number | null)[] = [];
|
||||
constructor(readonly sql: string) {}
|
||||
bind(...values: (string | number | null)[]) {
|
||||
this.values = values;
|
||||
return this;
|
||||
}
|
||||
async first() {
|
||||
return sqlite.prepare(this.sql).get(...this.values) ?? null;
|
||||
}
|
||||
async run() {
|
||||
const continuation = this.sql.startsWith('UPDATE rooms SET state');
|
||||
if (continuation) await hooks.beforeWrite?.();
|
||||
const changes = Number(
|
||||
sqlite.prepare(this.sql).run(...this.values).changes,
|
||||
);
|
||||
if (continuation)
|
||||
writes.push({ expected: Number(this.values[3]), changes });
|
||||
return { meta: { changes } };
|
||||
}
|
||||
}
|
||||
const database = {
|
||||
prepare: (sql: string) => new Statement(sql),
|
||||
batch: async (statements: Statement[]) => {
|
||||
sqlite.exec('BEGIN');
|
||||
try {
|
||||
const results = [];
|
||||
for (const statement of statements) results.push(await statement.run());
|
||||
sqlite.exec('COMMIT');
|
||||
return results;
|
||||
} catch (error) {
|
||||
sqlite.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
function loadRooms() {
|
||||
const exports = {};
|
||||
runInNewContext(
|
||||
ts.transpileModule(
|
||||
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
},
|
||||
).outputText,
|
||||
{
|
||||
exports,
|
||||
crypto: webcrypto,
|
||||
TextEncoder,
|
||||
structuredClone,
|
||||
// Keep persisted JSON values in the test realm for strict structural assertions.
|
||||
JSON,
|
||||
require: (name: string) => {
|
||||
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
||||
if (name === '@/game/engine') return engine;
|
||||
if (name === '@/game/bots') return { ...bots, runBots };
|
||||
throw new Error('Unexpected module ' + name);
|
||||
},
|
||||
},
|
||||
);
|
||||
return exports as typeof Rooms;
|
||||
}
|
||||
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
|
||||
}
|
||||
|
||||
import {
|
||||
grummanCollectionFixture, grummanInventory, grummanPlayer as p,
|
||||
grummanReload as reload, grummanToken as token, holdGrummanCard,
|
||||
stageGrummanArrival,
|
||||
} from './fixture-grumman-collection';
|
||||
|
||||
type Game = engine.Game;
|
||||
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function persisted() {
|
||||
const store = unitStore();
|
||||
const made = await store.rooms.createRoom('Grumman Collection SQL QA', 'moritani', false, []);
|
||||
const code = made.view.code;
|
||||
const credentials = [made.token];
|
||||
for (const faction of ['atreides', 'harkonnen'] as const) {
|
||||
const joined = await store.rooms.joinRoom(code, faction, faction);
|
||||
credentials.push(joined.token!);
|
||||
}
|
||||
const auths = await Promise.all(credentials.map((value) => store.rooms.authenticate(code, value)));
|
||||
const initial = await store.rooms.readRoom(code);
|
||||
let encoded = JSON.stringify(grummanCollectionFixture());
|
||||
for (const [index, id] of ['m', 'a', 'g'].entries())
|
||||
encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId));
|
||||
const g: Game = JSON.parse(encoded);
|
||||
g.code = code;
|
||||
g.host = auths[0].playerId;
|
||||
g.version = initial.version;
|
||||
holdGrummanCard(g, auths[0].playerId, 'worthless');
|
||||
holdGrummanCard(g, auths[1].playerId, 'projectile');
|
||||
assert.equal(g.grummanCollection, undefined);
|
||||
const save = (state: Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(state), state.version, code);
|
||||
save(g);
|
||||
const other = await store.rooms.createRoom('Untouched Grumman room', 'fremen', false, []);
|
||||
const otherBefore = await store.rooms.readRoom(other.view.code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, credentials, auths, ids: auths.map((auth) => auth.playerId), initial: g,
|
||||
save, otherCode: other.view.code, otherBefore };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof persisted>>;
|
||||
function snapshot(f: Fixture) {
|
||||
const row = f.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(f.code)!;
|
||||
return { version: row.version, hash: createHash('sha256').update(String(row.state)).digest('hex') };
|
||||
}
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const rooms = f.restart();
|
||||
const before = snapshot(f);
|
||||
for (const [index, credential] of f.credentials.entries()) {
|
||||
const auth = await rooms.authenticate(f.code, credential);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, engine.viewGame(g, f.ids[index]));
|
||||
for (const opponent of view.players.filter((seat) => seat.id !== auth.playerId)) {
|
||||
assert.equal(opponent.hand, undefined);
|
||||
assert.equal(opponent.spice, undefined);
|
||||
}
|
||||
if (index !== 0) {
|
||||
if (view.grummanCollection) {
|
||||
assert.deepEqual(view.grummanCollection.tokens, []);
|
||||
assert.deepEqual(view.grummanCollection.destinations, []);
|
||||
}
|
||||
assert.equal(view.terrorEntry?.candidates, undefined);
|
||||
assert.equal(view.terrorEntry?.kind, undefined);
|
||||
for (const placed of view.moritaniTerror!.tokens.filter((candidate) => candidate.status === 'placed'))
|
||||
assert.equal('kind' in placed, false);
|
||||
}
|
||||
}
|
||||
homeworldGameIntegrity(g);
|
||||
grummanInventory(g);
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
|
||||
return rooms;
|
||||
}
|
||||
async function act(f: Fixture, g: Game, actor: string, action: engine.Action) {
|
||||
const rooms = f.restart();
|
||||
await rooms.act(f.code, f.auths.find((auth) => auth.playerId === actor)!, g.version, action, clock);
|
||||
return rooms.readRoom(f.code);
|
||||
}
|
||||
function barrier(f: Fixture) {
|
||||
let count = 0;
|
||||
let release!: () => void;
|
||||
const wait = new Promise<void>((resolve) => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++count === 2) release(); await wait; };
|
||||
}
|
||||
async function opened(f: Fixture) {
|
||||
const g = await act(f, f.initial, f.initial.active!, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
assert.equal(g.decision?.kind, 'grummanCollection');
|
||||
assert.equal(g.grummanCollection?.stage, 'choice');
|
||||
assert.equal(g.moritaniTerror!.placementTurn, 1);
|
||||
await restored(f, g);
|
||||
f.writes.length = 0;
|
||||
return g;
|
||||
}
|
||||
function add(g: Game): engine.Action {
|
||||
return { type: 'decision', event: g.grummanCollection!.event, mode: 'add', token: token(g, 'sabotage').id, territory: 'arrakeen' };
|
||||
}
|
||||
async function stacked(f: Fixture) {
|
||||
const g = await opened(f);
|
||||
const added = await act(f, g, f.ids[0], add(g));
|
||||
const movement = stageGrummanArrival(added);
|
||||
f.save(movement);
|
||||
// The following actual paid shipment creates the original stack receipt.
|
||||
const entered = await act(f, movement, f.ids[1], { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 });
|
||||
assert.equal(entered.pendingTerrorEntry?.stage, 'select');
|
||||
assert.equal(entered.pendingTerrorEntry?.candidates?.length, 2);
|
||||
assert.equal(entered.pendingTerrorEntry?.amount, 1);
|
||||
assert.equal(entered.grummanCollection?.outcome, 'add');
|
||||
await restored(f, entered);
|
||||
f.writes.length = 0;
|
||||
return entered;
|
||||
}
|
||||
|
||||
void test('production SQL competing Grumman add and decline commit one token change and at most four spice', async () => {
|
||||
const f = await persisted();
|
||||
try {
|
||||
const g = await opened(f);
|
||||
const rooms = await restored(f, g);
|
||||
barrier(f);
|
||||
const outcomes = await Promise.allSettled([
|
||||
rooms.act(f.code, f.auths[0], g.version, add(g), clock),
|
||||
f.rooms.act(f.code, f.auths[0], g.version, { type: 'decision', event: g.grummanCollection!.event, decline: true }, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(outcomes.filter((outcome) => outcome.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map((write) => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const done = await rooms.readRoom(f.code);
|
||||
const added = done.grummanCollection!.outcome === 'add';
|
||||
assert.equal(done.grummanCollection!.stage, 'complete');
|
||||
assert.equal(done.version, g.version + 1);
|
||||
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + (added ? 4 : 0));
|
||||
assert.equal(token(done, 'sabotage').status, added ? 'placed' : 'available');
|
||||
assert.deepEqual(token(done, 'robbery'), token(g, 'robbery'));
|
||||
assert.equal(done.moritaniTerror!.placementTurn, 1);
|
||||
const before = snapshot(f);
|
||||
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, add(g), clock));
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
await restored(f, done);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('production SQL lost Grumman add response preserves four spice and independent same-turn Mentat placement', async () => {
|
||||
const f = await persisted();
|
||||
try {
|
||||
const g = await opened(f);
|
||||
await f.rooms.act(f.code, f.auths[0], g.version, add(g), clock);
|
||||
const rooms = f.restart();
|
||||
let done = await rooms.readRoom(f.code);
|
||||
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + 4);
|
||||
assert.equal(done.moritaniTerror!.tokens.filter((value) => value.location === 'arrakeen').length, 2);
|
||||
assert.equal(done.moritaniTerror!.placementTurn, 1);
|
||||
const before = snapshot(f);
|
||||
await assert.rejects(rooms.act(f.code, f.auths[0], g.version, add(g), clock));
|
||||
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, add(g), clock));
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
await restored(f, done);
|
||||
for (const id of f.ids) {
|
||||
if (done.phase !== 7) break;
|
||||
done = await act(f, done, id, { type: 'ready' });
|
||||
}
|
||||
assert.equal(done.phase, 8);
|
||||
assert.equal(done.decision?.kind, 'moritaniPlacement');
|
||||
const extra = token(done, 'sneakAttack').id;
|
||||
done = await act(f, done, f.ids[0], { type: 'decision', token: extra, territory: 'carthag' });
|
||||
for (let n = 0; done.response?.kind === 'moritaniPlacement' && n < 20; n++)
|
||||
done = await act(f, done, done.players.find((seat) => !done.response!.passed.includes(seat.id))!.id, { type: 'passResponse' });
|
||||
assert.equal(token(done, 'sneakAttack').location, 'carthag');
|
||||
assert.equal(done.moritaniTerror!.placementTurn, g.turn);
|
||||
assert.equal(done.grummanCollection!.outcome, 'add');
|
||||
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + 4);
|
||||
await restored(f, done);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('production SQL guarded removal and declined Grumman choice never pay or change token custody', async () => {
|
||||
const f = await persisted();
|
||||
try {
|
||||
const g = await opened(f);
|
||||
const before = snapshot(f);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, {
|
||||
type: 'decision', event: g.grummanCollection!.event, mode: 'remove', token: token(g, 'robbery').id,
|
||||
}, clock));
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
const done = await act(f, g, f.ids[0], { type: 'decision', event: g.grummanCollection!.event, decline: true });
|
||||
assert.equal(done.grummanCollection!.outcome, 'decline');
|
||||
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
|
||||
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice);
|
||||
await restored(f, done);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
// Signatures are deterministic consistency receipts. These scenarios retain
|
||||
// original evidence; they do not claim cryptographic protection from wholesale
|
||||
// rewriting of every stored field and its matching receipt.
|
||||
void test('production SQL Grumman phase and completion corruption rejects all seat views and writes', async () => {
|
||||
const f = await persisted();
|
||||
try {
|
||||
const g = await opened(f);
|
||||
const completed = await act(f, g, f.ids[0], add(g));
|
||||
const cases: [Game, (state: Game) => void][] = [
|
||||
[g, (state) => { delete state.grummanCollection; }],
|
||||
[g, (state) => { state.decision = null; }],
|
||||
[g, (state) => { state.grummanCollection!.event = 'another'; }],
|
||||
[g, (state) => { state.grummanCollection!.player = f.ids[1]; }],
|
||||
[g, (state) => { state.grummanCollection!.turn--; }],
|
||||
[g, (state) => { state.grummanCollection!.signature = 'altered'; }],
|
||||
[completed, (state) => { state.grummanCollection!.stage = 'choice'; delete state.grummanCollection!.outcome; delete state.grummanCollection!.token; delete state.grummanCollection!.territory; state.decision = { kind: 'grummanCollection', player: f.ids[0], event: g.grummanCollection!.event }; }],
|
||||
[completed, (state) => { state.grummanCollection!.token = token(state, 'robbery').id; }],
|
||||
[completed, (state) => { state.grummanCollection!.territory = 'carthag'; }],
|
||||
];
|
||||
for (const [original, mutate] of cases) {
|
||||
const corrupt = reload(original);
|
||||
mutate(corrupt);
|
||||
f.save(corrupt);
|
||||
f.writes.length = 0;
|
||||
const before = snapshot(f);
|
||||
const rooms = f.restart();
|
||||
for (const auth of f.auths) await assert.rejects(rooms.readSeatView(f.code, auth));
|
||||
await assert.rejects(rooms.act(f.code, f.auths[0], corrupt.version, add(g), clock));
|
||||
assert.throws(() => engine.normalizeAutomaticGame(corrupt));
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('production SQL two competing stacked-token choices preserve one selected identity and original paid entry', async () => {
|
||||
const f = await persisted();
|
||||
try {
|
||||
const g = await stacked(f);
|
||||
const candidates = g.pendingTerrorEntry!.candidates!;
|
||||
const rooms = await restored(f, g);
|
||||
barrier(f);
|
||||
const results = await Promise.allSettled(candidates.map((id) =>
|
||||
rooms.act(f.code, f.auths[0], g.version, { type: 'decision', token: id }, clock)));
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map((write) => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const selected = await rooms.readRoom(f.code);
|
||||
assert.equal(selected.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.ok(candidates.includes(selected.pendingTerrorEntry!.token));
|
||||
for (const field of ['candidates', 'entrant', 'territory', 'sector', 'amount', 'elite', 'cause', 'turn', 'phase', 'resume'] as const)
|
||||
assert.deepEqual(selected.pendingTerrorEntry![field], g.pendingTerrorEntry![field]);
|
||||
assert.equal(typeof selected.pendingTerrorEntry?.selectionSignature, 'string');
|
||||
assert.deepEqual(selected.moritaniTerror, g.moritaniTerror);
|
||||
assert.equal(p(selected, f.ids[1]).reserves, p(g, f.ids[1]).reserves);
|
||||
assert.deepEqual(p(selected, f.ids[1]).forces, p(g, f.ids[1]).forces);
|
||||
assert.equal(p(selected, f.ids[1]).spice, p(g, f.ids[1]).spice);
|
||||
await restored(f, selected);
|
||||
const done = await act(f, selected, f.ids[0], { type: 'decision', decline: true });
|
||||
assert.equal(done.pendingTerrorEntry, null);
|
||||
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
|
||||
const before = snapshot(f);
|
||||
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, { type: 'decision', token: candidates[1] }, clock));
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
await restored(f, done);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('production SQL selected Robbery reveals only that token and resolves one income after restart', async () => {
|
||||
const f = await persisted();
|
||||
try {
|
||||
let g = await stacked(f);
|
||||
const original = reload(g);
|
||||
g = await act(f, g, f.ids[0], { type: 'decision', token: token(g, 'robbery').id });
|
||||
await restored(f, g);
|
||||
g = await act(f, g, f.ids[0], { type: 'decision', reveal: true });
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
|
||||
assert.equal(token(g, 'robbery').status, 'removed');
|
||||
assert.deepEqual(token(g, 'sabotage'), token(original, 'sabotage'));
|
||||
const rooms = await restored(f, g);
|
||||
const expected = Math.ceil(p(g, f.ids[1]).spice / 2);
|
||||
await rooms.act(f.code, f.auths[0], g.version, { type: 'decision', choice: 'spice' }, clock);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.pendingTerrorEntry, null);
|
||||
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + expected);
|
||||
assert.equal(p(done, f.ids[1]).spice, p(g, f.ids[1]).spice - expected);
|
||||
assert.equal(token(done, 'sabotage').status, 'placed');
|
||||
assert.deepEqual(p(done, f.ids[1]).forces, p(original, f.ids[1]).forces);
|
||||
const before = snapshot(f);
|
||||
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, { type: 'decision', choice: 'spice' }, clock));
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
await restored(f, done);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('production SQL stacked original candidates, selected identity and physical custody cannot be edited across reload', async () => {
|
||||
const f = await persisted();
|
||||
try {
|
||||
const g = await stacked(f);
|
||||
const selected = await act(f, g, f.ids[0], { type: 'decision', token: token(g, 'sabotage').id });
|
||||
const cases: [Game, (state: Game) => void][] = [
|
||||
[g, (state) => { state.pendingTerrorEntry!.candidates!.reverse(); }],
|
||||
[g, (state) => { state.pendingTerrorEntry!.candidates!.push(state.pendingTerrorEntry!.candidates![0]); }],
|
||||
[g, (state) => { delete state.pendingTerrorEntry!.candidates; }],
|
||||
[g, (state) => { state.pendingTerrorEntry!.amount++; }],
|
||||
[g, (state) => { token(state, 'sabotage').location = 'carthag'; }],
|
||||
[g, (state) => { token(state, 'sabotage').status = 'removed'; token(state, 'sabotage').location = null; }],
|
||||
[selected, (state) => { state.pendingTerrorEntry!.token = token(state, 'robbery').id; }],
|
||||
[selected, (state) => { delete state.pendingTerrorEntry!.selectionSignature; }],
|
||||
[selected, (state) => { state.pendingTerrorEntry!.selectionSignature = 'another-selection'; }],
|
||||
[selected, (state) => {
|
||||
state.pendingTerrorEntry!.stage = 'select';
|
||||
state.pendingTerrorEntry!.token = state.pendingTerrorEntry!.candidates![0];
|
||||
delete state.pendingTerrorEntry!.selectionSignature;
|
||||
}],
|
||||
];
|
||||
for (const [original, mutate] of cases) {
|
||||
const corrupt = reload(original);
|
||||
mutate(corrupt);
|
||||
f.save(corrupt);
|
||||
f.writes.length = 0;
|
||||
const before = snapshot(f);
|
||||
const rooms = f.restart();
|
||||
for (const auth of f.auths) await assert.rejects(rooms.readSeatView(f.code, auth));
|
||||
await assert.rejects(rooms.act(f.code, f.auths[0], corrupt.version, { type: 'decision', decline: true }, clock));
|
||||
assert.throws(() => engine.normalizeAutomaticGame(corrupt));
|
||||
assert.deepEqual(snapshot(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { newPlayer } from '../game/engine';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import {
|
||||
quoteGrummanCollection,
|
||||
quoteGrummanCollectionAction,
|
||||
type GrummanCollectionAction,
|
||||
type GrummanCollectionContext,
|
||||
} from '../game/grumman-collection';
|
||||
import {
|
||||
createTerrorState,
|
||||
placeTerror,
|
||||
projectTerror,
|
||||
revealTerror,
|
||||
TERROR_KINDS,
|
||||
type TerrorState,
|
||||
} from '../game/moritani-terror';
|
||||
|
||||
function fixture(native = 8, advanced = true): GrummanCollectionContext {
|
||||
const players = [
|
||||
newPlayer('m', 'Moritani', 'moritani'),
|
||||
newPlayer('v', 'Visitor', 'harkonnen'),
|
||||
];
|
||||
players[0].reserves = native;
|
||||
const context = { advanced, players };
|
||||
const state = placeTerror(
|
||||
createTerrorState(() => 0),
|
||||
'terror-1',
|
||||
'arrakeen',
|
||||
2,
|
||||
);
|
||||
state.supplyEpoch = 4;
|
||||
return {
|
||||
...context,
|
||||
homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
|
||||
moritaniTerror: state,
|
||||
};
|
||||
}
|
||||
const add = {
|
||||
mode: 'add',
|
||||
token: 'terror-2',
|
||||
destination: 'arrakeen',
|
||||
} as const;
|
||||
|
||||
void test('Grumman quotes the exact current high threshold in both rules modes; foreign visitors never qualify', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const native of [0, 7, 8, 20]) {
|
||||
const g = fixture(native, advanced);
|
||||
g.players[1].reserves = 0;
|
||||
g.homeworlds!.custody!.visitors['homeworld:moritani'] = {
|
||||
v: { normal: 20, elite: 0 },
|
||||
};
|
||||
const quote = quoteGrummanCollection(g, 'm');
|
||||
assert.equal(quote.population, native);
|
||||
assert.equal(quote.high, native >= 8);
|
||||
assert.equal(quote.blocked === null, native >= 8);
|
||||
assert.deepEqual(quote.destinations, native >= 8 ? ['arrakeen'] : []);
|
||||
assert.equal(quote.tokens.length, native >= 8 ? 5 : 0);
|
||||
}
|
||||
const g = fixture();
|
||||
delete g.homeworlds;
|
||||
assert.equal(quoteGrummanCollection(g, 'm').population, null);
|
||||
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
|
||||
assert.throws(() => quoteGrummanCollection(g, 'v'));
|
||||
});
|
||||
|
||||
void test('one available token adds to an existing stack for four bank spice without consuming Mentat or changing identities', () => {
|
||||
const g = fixture();
|
||||
const original = structuredClone(g);
|
||||
const first = quoteGrummanCollectionAction(g, 'm', add);
|
||||
assert.equal(first.amount, 4);
|
||||
assert.equal(first.state.placementTurn, 2);
|
||||
assert.equal(first.state.supplyEpoch, 4);
|
||||
assert.deepEqual(g, original);
|
||||
assert.equal(
|
||||
first.state.tokens.filter((token) => token.location === 'arrakeen').length,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
first.state.tokens.map((token) => token.id),
|
||||
g.moritaniTerror!.tokens.map((token) => token.id),
|
||||
);
|
||||
assert.deepEqual(
|
||||
first.state.tokens.map((token) => token.kind).sort(),
|
||||
[...TERROR_KINDS].sort(),
|
||||
);
|
||||
assert.ok(
|
||||
first.state.tokens.every(
|
||||
(token, index) => token !== g.moritaniTerror!.tokens[index],
|
||||
),
|
||||
);
|
||||
// A subsequent independent opportunity can add to an existing stack, while
|
||||
// ordinary Mentat still requires an empty stronghold and its own turn.
|
||||
g.moritaniTerror = first.state;
|
||||
const second = quoteGrummanCollectionAction(g, 'm', {
|
||||
...add,
|
||||
token: 'terror-3',
|
||||
});
|
||||
assert.equal(
|
||||
second.state.tokens.filter((token) => token.location === 'arrakeen').length,
|
||||
3,
|
||||
);
|
||||
assert.throws(() => placeTerror(second.state, 'terror-4', 'arrakeen', 3));
|
||||
assert.equal(
|
||||
placeTerror(second.state, 'terror-4', 'carthag', 3).placementTurn,
|
||||
3,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(second.state)), second.state);
|
||||
});
|
||||
|
||||
void test('offers need available supply and an existing ordinary stronghold token; storm is not a placement input', () => {
|
||||
const g = fixture();
|
||||
Object.defineProperty(g, 'storm', {
|
||||
get() {
|
||||
throw new Error('Storm is irrelevant to Terror placement');
|
||||
},
|
||||
});
|
||||
assert.equal(quoteGrummanCollectionAction(g, 'm', add).amount, 4);
|
||||
for (const destination of [
|
||||
'carthag',
|
||||
'homeworld:moritani',
|
||||
'mobile_stronghold',
|
||||
'arrakeen:10',
|
||||
'hagga_basin',
|
||||
])
|
||||
assert.throws(() =>
|
||||
quoteGrummanCollectionAction(g, 'm', { ...add, destination }),
|
||||
);
|
||||
g.moritaniTerror = createTerrorState(() => 0);
|
||||
assert.match(quoteGrummanCollection(g, 'm').blocked!, /already containing/);
|
||||
g.moritaniTerror.tokens.forEach((token) => {
|
||||
token.status = 'placed';
|
||||
token.location = 'arrakeen';
|
||||
});
|
||||
assert.match(quoteGrummanCollection(g, 'm').blocked!, /no available/);
|
||||
});
|
||||
|
||||
void test('decline gives zero with cloned unchanged custody; unresolved removal never spends or reveals a token', () => {
|
||||
for (const native of [7, 8]) {
|
||||
const g = fixture(native);
|
||||
const before = JSON.stringify(g);
|
||||
const declined = quoteGrummanCollectionAction(g, 'm', { mode: 'decline' });
|
||||
assert.equal(declined.amount, 0);
|
||||
assert.deepEqual(declined.state, g.moritaniTerror);
|
||||
assert.notEqual(declined.state, g.moritaniTerror);
|
||||
assert.match(quoteGrummanCollection(g, 'm').removeBlocked, /custody zone/);
|
||||
for (const token of ['terror-1', 'terror-2', 'atomics-aftermath', 'absent'])
|
||||
assert.throws(
|
||||
() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token }),
|
||||
/custody zone/,
|
||||
);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('adding never relocates, recovers a spent token or accepts stale high eligibility', () => {
|
||||
const g = fixture();
|
||||
const offered = quoteGrummanCollection(g, 'm');
|
||||
offered.tokens[0].status = 'removed';
|
||||
assert.equal(g.moritaniTerror!.tokens[1].status, 'available');
|
||||
g.players[0].reserves = 7;
|
||||
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
|
||||
g.players[0].reserves = 8;
|
||||
for (const token of ['terror-1', 'missing'])
|
||||
assert.throws(() =>
|
||||
quoteGrummanCollectionAction(g, 'm', { ...add, token }),
|
||||
);
|
||||
g.moritaniTerror = revealTerror(g.moritaniTerror!, 'terror-1');
|
||||
assert.throws(() =>
|
||||
quoteGrummanCollectionAction(g, 'm', { ...add, token: 'terror-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
void test('malformed physical inventories and operation shapes reject without mutation', () => {
|
||||
const original = fixture();
|
||||
const corruptions: ((s: TerrorState) => void)[] = [
|
||||
(s) => {
|
||||
s.tokens.pop();
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[1].id = s.tokens[0].id;
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[1].kind = s.tokens[0].kind;
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[1].location = 'carthag';
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[0].location = 'homeworld:moritani';
|
||||
},
|
||||
(s) => {
|
||||
s.tokens[0].status = 'removed';
|
||||
},
|
||||
(s) => {
|
||||
s.tokens.find((t) => t.kind !== 'extortion')!.status = 'extortion';
|
||||
},
|
||||
(s) => {
|
||||
s.supplyEpoch = -1;
|
||||
},
|
||||
(s) => {
|
||||
s.placementTurn = 1.5;
|
||||
},
|
||||
];
|
||||
for (const corrupt of corruptions) {
|
||||
const g = structuredClone(original);
|
||||
corrupt(g.moritaniTerror!);
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => quoteGrummanCollection(g, 'm'));
|
||||
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
for (const action of [
|
||||
null,
|
||||
{},
|
||||
{ mode: 'add' },
|
||||
{ ...add, amount: 4 },
|
||||
{ mode: 'decline', token: 'terror-2' },
|
||||
])
|
||||
assert.throws(() =>
|
||||
quoteGrummanCollectionAction(
|
||||
original,
|
||||
'm',
|
||||
action as GrummanCollectionAction,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
void test('the owner quote reads no hands, balances or force positions and stacked public views retain hidden faces', () => {
|
||||
const g = fixture();
|
||||
for (const player of g.players)
|
||||
for (const field of [
|
||||
'hand',
|
||||
'spice',
|
||||
'tanks',
|
||||
'forces',
|
||||
'traitors',
|
||||
'noField',
|
||||
])
|
||||
Object.defineProperty(player, field, {
|
||||
get() {
|
||||
throw new Error(`Private ${field}`);
|
||||
},
|
||||
});
|
||||
const result = quoteGrummanCollectionAction(g, 'm', add);
|
||||
const view = projectTerror(result.state, false);
|
||||
assert.deepEqual(view.tokens, [
|
||||
{ id: 'terror-1', location: 'arrakeen', status: 'placed' },
|
||||
{ id: 'terror-2', location: 'arrakeen', status: 'placed' },
|
||||
]);
|
||||
for (const token of g.moritaniTerror!.tokens) Object.freeze(token);
|
||||
Object.freeze(g.moritaniTerror!.tokens);
|
||||
Object.freeze(g.moritaniTerror);
|
||||
assert.equal(quoteGrummanCollectionAction(g, 'm', add).amount, 4);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
import { createHomeworldCustody } from '../game/homeworld-custody';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { quoteGrummanCollectionAction } from '../game/grumman-collection';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
function entered(victimCard = false) {
|
||||
let g = createGame('STACKREVIEW', newPlayer('m', 'Moritani', 'moritani'));
|
||||
g.players.push(
|
||||
newPlayer('e', 'Entrant', 'emperor'),
|
||||
newPlayer('a', 'Observer', 'atreides'),
|
||||
);
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
active: 'e',
|
||||
order: ['e', 'm', 'a'],
|
||||
movementRemaining: ['e', 'm', 'a'],
|
||||
deck: baseDeck(),
|
||||
});
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.spice = 20;
|
||||
p.hand = [];
|
||||
}
|
||||
g.players[1].elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
|
||||
const initial = createTerrorState(() => 0);
|
||||
const robbery = initial.tokens.find((t) => t.kind === 'robbery')!.id;
|
||||
const sabotage = initial.tokens.find((t) => t.kind === 'sabotage')!.id;
|
||||
g.moritaniTerror = placeTerror(initial, robbery, 'arrakeen', 1);
|
||||
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
|
||||
// Focused phase-five audit setup: the stack itself uses the physical quote;
|
||||
// phase-seven creation/once-only award is covered by the collection suites.
|
||||
g.moritaniTerror = quoteGrummanCollectionAction(g, 'm', {
|
||||
mode: 'add',
|
||||
token: sabotage,
|
||||
destination: 'arrakeen',
|
||||
}).state;
|
||||
if (victimCard) g.players[1].hand.push(g.deck.shift()!);
|
||||
g = applyAction(g, 'e', {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 2,
|
||||
});
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'select');
|
||||
return { g, robbery, sabotage };
|
||||
}
|
||||
function rejected(g: Game, action: Action) {
|
||||
const before = reload(g);
|
||||
assert.throws(() => viewGame(g, 'm'));
|
||||
assert.throws(() => normalizeAutomaticGame(g));
|
||||
assert.throws(() => applyAction(g, 'm', action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('a selected actual stacked entry cannot rewind, delete its selection proof or switch token after JSON recovery', () => {
|
||||
const { g: initial, sabotage, robbery } = entered();
|
||||
const selected = applyAction(initial, 'm', {
|
||||
type: 'decision',
|
||||
token: sabotage,
|
||||
});
|
||||
assert.equal(selected.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(selected.pendingTerrorEntry?.token, sabotage);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(selected)), reload(selected));
|
||||
assert.equal(viewGame(selected, 'm').terrorEntry?.kind, 'sabotage');
|
||||
assert.equal('kind' in viewGame(selected, 'a').terrorEntry!, false);
|
||||
assert.equal('candidates' in viewGame(selected, 'a').terrorEntry!, false);
|
||||
for (const edit of [
|
||||
(g: Game) => {
|
||||
g.pendingTerrorEntry!.stage = 'select';
|
||||
g.pendingTerrorEntry!.token = g.pendingTerrorEntry!.candidates![0];
|
||||
delete g.pendingTerrorEntry!.selectionSignature;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.pendingTerrorEntry!.selectionSignature;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingTerrorEntry!.token = robbery;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.pendingTerrorEntry!.candidates;
|
||||
},
|
||||
]) {
|
||||
const g = reload(selected);
|
||||
edit(g);
|
||||
rejected(g, { type: 'decision', token: robbery });
|
||||
}
|
||||
});
|
||||
|
||||
void test('unselected physical stack tokens stay reserved before and after the chosen token is revealed', () => {
|
||||
const { g: initial, sabotage, robbery } = entered();
|
||||
const selected = applyAction(initial, 'm', {
|
||||
type: 'decision',
|
||||
token: robbery,
|
||||
});
|
||||
const revealed = applyAction(reload(selected), 'm', {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
});
|
||||
assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery');
|
||||
assert.equal(
|
||||
revealed.moritaniTerror!.tokens.find((t) => t.id === robbery)!.status,
|
||||
'removed',
|
||||
);
|
||||
for (const source of [initial, selected, revealed]) {
|
||||
const g = reload(source);
|
||||
g.moritaniTerror!.tokens.find((t) => t.id === sabotage)!.location =
|
||||
'carthag';
|
||||
rejected(g, { type: 'decision', decline: true });
|
||||
}
|
||||
assert.doesNotThrow(() => viewGame(revealed, 'm'));
|
||||
});
|
||||
|
||||
void test('an actual stacked Sabotage inner-dispatch discard restores its consumed selection while preserving every other token', () => {
|
||||
const { g: initial, sabotage, robbery } = entered(true);
|
||||
const arrived = applyAction(initial, 'm', {
|
||||
type: 'decision',
|
||||
token: sabotage,
|
||||
});
|
||||
const victimCard = arrived.players[1].hand[0].id;
|
||||
// Capture the genuine durable batch before the public action wrapper drains
|
||||
// it. This verifies saved-continuation restoration, not a claimed HTTP pause.
|
||||
const observed: {
|
||||
applyActionInner?: (g: Game, id: string, action: Action) => Game;
|
||||
} = {};
|
||||
runInNewContext(
|
||||
ts.transpileModule(
|
||||
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
||||
'\nexport { applyActionInner };\n',
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
},
|
||||
).outputText,
|
||||
{
|
||||
exports: observed,
|
||||
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
||||
crypto,
|
||||
structuredClone,
|
||||
TextEncoder,
|
||||
JSON,
|
||||
},
|
||||
);
|
||||
const pending = reload(
|
||||
observed.applyActionInner!(arrived, 'm', {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
}),
|
||||
);
|
||||
const continuation = pending.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(continuation.kind, 'terrorDiscard');
|
||||
if (continuation.kind !== 'terrorDiscard')
|
||||
throw new Error('Missing actual Terror discard');
|
||||
assert.equal(continuation.entry.stage, 'offer');
|
||||
assert.equal(continuation.entry.token, sabotage);
|
||||
assert.equal(
|
||||
pending.moritaniTerror!.tokens.find((t) => t.id === sabotage)!.status,
|
||||
'removed',
|
||||
);
|
||||
assert.doesNotThrow(() => viewGame(pending, 'm'));
|
||||
const finished = normalizeAutomaticGame(reload(pending));
|
||||
assert.equal(finished.pendingTreacheryDiscard, null);
|
||||
assert.equal(finished.pendingTerrorEntry, null);
|
||||
assert.equal(
|
||||
finished.discard.filter((card) => card.id === victimCard).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(finished.players[1].hand.length, 0);
|
||||
assert.equal(
|
||||
finished.moritaniTerror!.tokens.find((t) => t.id === robbery)!.location,
|
||||
'arrakeen',
|
||||
);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(finished)), reload(finished));
|
||||
for (const tokenId of [robbery, sabotage]) {
|
||||
const corrupted = reload(pending);
|
||||
const token = corrupted.moritaniTerror!.tokens.find(
|
||||
(t) => t.id === tokenId,
|
||||
)!;
|
||||
token.status = 'available';
|
||||
token.location = null;
|
||||
rejected(corrupted, { type: 'advanceBots' });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user