diff --git a/components/discoveries.tsx b/components/discoveries.tsx new file mode 100644 index 00000000..74e8a29c --- /dev/null +++ b/components/discoveries.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { territory } from '@/game/board'; +import { DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '@/game/discoveries'; +import { discoveryAction, discoveryDiscardAction } from '@/game/discovery-options'; +import { CardInspector } from './card-inspector'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; + +/** Authored gameplay guidance from the component contract, never hidden state. */ +const tokenRules: Record = { + 'jacurutu-sietch': 'Jacurutu Sietch counts as a normal stronghold. After winning a battle here, receive one spice for each opposing undialed force sent to the Tanks.', + cistern: 'During Spice Collection, an occupant of Cistern receives two spice from the bank.', + 'ecological-testing-station': 'An occupant may increase or decrease storm movement by one during the Storm phase. This ability does not change movement set by Weather Control.', + shrine: 'An occupant of Shrine may use a Truthtrance card as Karama, or a Karama card as Truthtrance.', + 'orgiz-processing-station': 'During Spice Collection, an occupant takes one spice from each spice blow that is collected.', + 'treachery-card-stash': 'Receive one Treachery Card, then remove this token. If your hand is already full, take the new card first and then choose any card in your hand to discard, including the new card.', + 'spice-stash': 'Receive seven spice from the bank, then remove this token.', + ornithopter: 'Take this token. On a later turn, you may spend it to move up to three territories instead of your usual allowance for one movement action. Remove the token after that action.', +}; + +function DiscoveryRules({ face }: { face: DiscoveryTokenFace }) { + const definition = DISCOVERY_TOKEN_BY_ID[face]; + return ( +
+ + Read {definition.name} rules + +
+

{tokenRules[face]}

+ {definition.kind === 'location' && ( + <> +

This location is a separate territory inside the surrounding territory. Revealing it leaves it empty. Normal ground movement enters the surrounding territory before entering the location; shipment uses stronghold prices.

+

At most two factions may occupy the location. Forces inside are protected from storms and sandworms.

+

At the start of the turn after this location is revealed, before the storm and the mobile stronghold move, factions in the surrounding territory may move some or all of their non-advisor forces inside.

+ + )} +
+
+ ); +} + +export function DiscoveryPanel({ game, act, busy }: Props) { + const offer = game.discoveries; + if (!offer) return null; + const disabled = busy || !!game.players.find(player => player.id === game.me)?.autopilot; + return ( +
+
+

Discoveries

+

{offer.supplyCount} tokens remain in the supply.

+

During Spice Collection, non-advisor forces in a token’s surrounding territory can inspect it privately and choose whether to reveal it. Fremen can always see placed Hiereg faces; the Guild can always see placed Smuggler faces.

+
+ {offer.blocked &&

{offer.blocked}

} + {!offer.tokens.length &&

No Discovery tokens have been placed.

} +
+ {offer.tokens.map(token => { + const definition = token.face ? DISCOVERY_TOKEN_BY_ID[token.face] : null; + const typeName = token.type === 'hiereg' ? 'Hiereg' : 'Smuggler'; + const name = definition?.name ?? `${typeName} discovery`; + const inspect = discoveryAction(game, token.id, false); + const reveal = discoveryAction(game, token.id, true); + const owner = game.players.find(player => player.id === token.owner); + const status = token.status === 'removed' ? 'Resolved and removed' : token.status === 'carried' + ? `Carried by ${token.owner === game.me ? 'you' : owner?.name ?? 'another faction'}` + : token.revealedTurn === null ? token.face ? 'Face down · known to you' : 'Face down · unknown to you' + : `Revealed on turn ${token.revealedTurn}`; + return ( +
+
+ +
+

{typeName} token

+

{name}

+

{status}

+
+
+ {token.territory &&

In {territory(token.territory).name} · sector {token.sector}

} + {token.face && } + {(inspect || reveal) && ( +
+ {inspect && } + {reveal && } +
+ )} +
+ ); + })} +
+

Inspection and reveal are optional. Location abilities, next-turn free entry and Ornithopter use are still in development.

+
+ ); +} + +export function DiscoveryDiscardDecision({ game, act, busy }: Props) { + const [selected, select] = useState(''); + const decision = game.decision; + if (decision?.kind !== 'discoveryDiscard' || decision.player !== game.me) return null; + const me = game.players.find(player => player.id === game.me); + const hand = me?.hand ?? []; + const card = hand.find(held => held.id === selected) ?? hand.find(held => held.kind === 'worthless') ?? hand[0]; + const action = card ? discoveryDiscardAction(game, card.id) : null; + const disabled = busy || !!me?.autopilot; + return ( +
+

Treachery Card Stash · choose a discard

+

The new card is already in your hand. Choose any one of your cards to discard, including the card you just received.

+ + {card && } + +
+ ); +} diff --git a/components/game-table.tsx b/components/game-table.tsx index f165a4e2..43505698 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -12,6 +12,8 @@ import { GrummanCollection } from './grumman-collection'; import { TupileIntelligence } from './tupile-intelligence'; import { NexusCards } from './nexus-cards'; import { NexusTraitors } from './nexus-traitors'; +import { GreatMakerDecision } from './great-maker'; +import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries'; import { NexusChoamTrade } from './nexus-choam-trade'; import { NexusTleilaxu } from './nexus-tleilaxu'; import { NexusSuboids } from './nexus-suboids'; @@ -1436,6 +1438,7 @@ export function GameTable({ + @@ -2938,6 +2941,10 @@ export function GameTable({ accept: false, })} + ) : g.decision.kind === 'discoveryDiscard' ? ( + + ) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? ( + ) : g.decision.kind === 'wormProtection' ? ( <>

diff --git a/components/great-maker.tsx b/components/great-maker.tsx new file mode 100644 index 00000000..d4af44c2 --- /dev/null +++ b/components/great-maker.tsx @@ -0,0 +1,124 @@ +'use client'; +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { greatMakerRideAction } from '@/game/great-maker-options'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; +export function GreatMakerDecision({ game, act, busy }: Props) { + const [destination, setDestination] = useState(''), + [amount, setAmount] = useState(1), + [elite, setElite] = useState(0); + const d = game.decision, + frame = game.greatMaker; + if (!frame || !d || d.player !== game.me) return null; + if (d.kind === 'greatMakerVote') + return ( +

+

Great Maker · Nexus vote

+

+ Vote in storm order. A majority must vote yes for this encounter to + create a Nexus; a tie means no Nexus. +

+ {frame.votes.map((vote) => ( +

+ {game.players.find((p) => p.id === vote.player)?.name}:{' '} + {vote.yes ? 'Yes' : 'No'} +

+ ))} +
+ {[true, false].map((yes) => ( + + ))} +
+
+ ); + const offer = frame.ride; + if (d.kind !== 'greatMakerRide' || !offer) return null; + const to = + offer.destinations.find( + (to) => `${to.territory}:${to.sector}` === destination, + ) ?? offer.destinations[0]; + const n = Math.max(1, Math.min(amount, offer.max)), + minimumElite = Math.max(0, n - (offer.max - offer.eliteMax)); + const e = Math.max(minimumElite, Math.min(elite, n, offer.eliteMax)); + const action = to + ? greatMakerRideAction(game, to.territory, to.sector, n, e) + : null; + return ( +
+

Ride the Great Maker

+

+ Move any number of reserve forces to one legal destination. This uses no + spice, shipment or movement. Forces already on the board stay where they + are. +

+ + + {offer.eliteMax > 0 && ( + + )} +
+ + +
+
+ ); +} diff --git a/components/spice-card-inspector.tsx b/components/spice-card-inspector.tsx index 55200824..62dbef60 100644 --- a/components/spice-card-inspector.tsx +++ b/components/spice-card-inspector.tsx @@ -15,6 +15,10 @@ import { } from '@/components/ui/dialog'; import type { SpiceCard } from '@/game/cards'; import { territory } from '@/game/board'; +import { + isDiscoverySpiceCardId, + DISCOVERY_CARD_PLACEMENTS, +} from '@/game/discoveries'; import { PHASE_HELP } from '@/game/reference'; /** Only the supplied visible face is read. No deck, discard pile or game is accepted. */ @@ -25,9 +29,9 @@ export type SpiceCardInspectorProps = { export function spiceCardTitle(card: Readonly): string { if ('territory' in card) - return `${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`; + return `${card.discovery ? 'Discovery · ' : ''}${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`; if ('sandtrout' in card) return 'Sandtrout'; - return `Shai-Hulud${card.thumper ? ' · Thumper encounter' : ''}${card.suppressed ? ' · suppressed' : ''}`; + return `${card.greatMaker ? 'Great Maker' : 'Shai-Hulud'}${card.thumper ? ' · Thumper encounter' : ''}${card.suppressed ? ' · suppressed' : ''}`; } /** Original vector illustration, with no claim to reproduce a printed card face. */ @@ -122,13 +126,23 @@ export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) { 'territory' in card ? territory(card.territory).name : kind === 'worm' - ? 'Shai-Hulud' + ? 'worm' in card && card.greatMaker + ? 'Great Maker' + : 'Shai-Hulud' : 'Sandtrout'; + const placement = + 'territory' in card && isDiscoverySpiceCardId(card.discovery) + ? DISCOVERY_CARD_PLACEMENTS[card.discovery] + : null; const description = kind === 'territory' - ? 'This spice blow names the territory, sector and base spice amount. The storm and card effects can change how much spice actually appears.' + ? placement + ? `Destroy all previous spice and forces in the named territory, including Fremen, then place six spice in the indicated sector. Draw a random ${placement.type === 'hiereg' ? 'Hiereg' : 'Smuggler'} Discovery token and place it face down in ${territory(placement.territory).name}, sector ${placement.sector}. The storm can destroy the new spice.` + : 'This spice blow names the territory, sector and base spice amount. The storm and card effects can change how much spice actually appears.' : kind === 'worm' - ? 'A Shai-Hulud encounter can consume forces and spice in the territory identified by the preceding card in its spice discard pile. Resolve protection, Nexus and riding decisions through the table.' + ? 'worm' in card && card.greatMaker + ? 'Resolve the normal worm destruction, then vote in storm order. A strict yes majority creates a Nexus; a tie does not. Fremen may afterward ride with any number of reserve forces, following storm and occupancy rules. Preventing Fremen survival in the devoured territory does not prevent this reserve ride.' + : 'A Shai-Hulud encounter can consume forces and spice in the territory identified by the preceding card in its spice discard pile. Resolve protection, Nexus and riding decisions through the table.' : 'Sandtrout breaks existing alliances and waits for the next active drawn Shai-Hulud. It suppresses that encounter, then the immediate replacement determines whether a spice blow is doubled.'; return ( @@ -168,7 +182,9 @@ export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) {

{kind === 'territory' - ? 'Spice blow' + ? placement + ? 'Discovery spice blow' + : 'Spice blow' : kind === 'worm' ? 'Worm encounter' : 'Sandtrout'} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 55a50c82..35d1ab60 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -4,6 +4,11 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth +- [First Discovery prototype](DISCOVERY_PROTOTYPE.md): genuine base-faction + Basic/Advanced setup, seven Spice Cards, eight tokens, Great Maker, Collection + inspection/rewards and revealed nested locations are connected. Focused checks + pass; final broad checkpoint evidence is pending. Free entry, special location + benefits and Ornithopter spending remain missing. - Ix prototype checkpoint `5a76ae0`: types, lint, **4,211 offline tests**, build and **40 HTTP tests** passed. Genuine Basic/Advanced setup and two seeded complete simulations were exercised; a short browser game finished with private hand restoration. All 64 opening saved rooms were preserved; 31 QA rooms were added. This is bounded prototype evidence, not complete Ix compliance. - [Rules implementation checklist](../game/reference.ts): `RULE_TOPICS`, topic `implementation-checklist`, and each feature's implementation, controls, AI, documentation and verification entries. Coverage is per stated boundary, not an implicit whole-game certificate. @@ -36,7 +41,7 @@ restoration after necessary restarts. No recurring automation is installed. | --- | --- | --- | | Basic core | Playable loop; shared setup, Fremen movement cancellation, structured Truthtrance facts and bounded Basic/no-Guild Advanced reserve-shipment promises. | Ordinary Guild rate/transport cancellation; wider truthful questions/action commitments; integrated rules and human/AI acceptance. | | Six-faction Advanced | Genuine offline setup across 456 configurations; four-profile full-game simulations and JSON continuations. | Base gaps plus provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Public starts remain disabled. | -| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence. | Every unfinished effect and interaction, occupation entitlement, Discoveries and complete combined games. Inventory or fixture coverage does not enable a module. | +| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence; the first Discovery actions now have a connected prototype. | Every unfinished effect and interaction, occupation entitlement, remaining Discovery effects and complete combined games. Inventory or fixture coverage does not enable a module. | | AI difficulty | Four profiles consume private legal views; recorded Basic and Advanced studies complete. | Demonstrate a consistent Medium → Hard → Brutal strength ordering, improve strategic gaps, and repeat targeted studies after relevant policy/rule changes. | | Multiplayer | Versioned writes, saved seats, owner recovery kits, uncertain-request retry and voluntary own-seat AI control. | Intentional transfer, unprepared abandoned-seat recovery, unattended continuation and remaining network-failure acceptance. Preserve privacy and existing games. | | Player experience | Internal component inspectors and many desktop/phone feature journeys verified. | Complete end-to-end games, accessible controls, responsive layout, clear rules/errors and recovery across all supported combinations. | @@ -52,9 +57,13 @@ genuine setup, both expansion factions, full Ix deck and Sandtrout, shared human controls and saved continuation. The checklist now distinguishes its Prototyped development stage from Partial rules coverage. The next connected action is the [CHOAM Nexus Collection trade](NEXUS_CHOAM_SECRET_ALLY.md), with private controls, all four AI profiles and a saved payment/discard continuation. -Its after-victory inspection remains missing. [Discovery components](DISCOVERY_COMPONENTS.md) -now records all seven spice cards, eight tokens and exact card placements; -Discovery runtime and controls remain missing. +Its after-victory inspection remains missing. The [first Discovery batch](DISCOVERY_PROTOTYPE.md) +now connects the [sourced components](DISCOVERY_COMPONENTS.md) to Great Maker, +destructive Discovery blows, private inspection/reveal, stash rewards, carried +Ornithopter custody and five revealed nested locations. Only Jacurutu counts +toward stronghold victory. Next dependencies are free entry at the next turn, +the five special location benefits, and spending Ornithopter on a later move; +complete variant acceptance and normal public starts remain gated. The user changed the development order on 13 September: get working prototypes of all remaining functions into the game, then use integration and play to find diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md new file mode 100644 index 00000000..d1508062 --- /dev/null +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -0,0 +1,121 @@ +# Discovery functional prototype + +13 September 2026. The first connected Discovery batch is **Prototyped**, with +partial rules coverage. It connects physical components, real actions, private +controls, AI choices and saved continuation. Normal public Discovery and +Advanced start gates, and the publication gate, remain closed. + +## Source and setup boundary + +The [component acquisition record](DISCOVERY_COMPONENTS.md) identifies the +publisher-authored *Ecaz & Moritani* rulebook, pages 12–13, and the physical card +scan used for all six placements. Its verified inventory is seven Spice Cards +(six Discovery territory cards and Great Maker) and eight tokens. This prototype +uses those identities and effects; it does not treat test outcomes as rulings. + +`initializeDiscoveryGameForAudit` enters the real staged setup with two through +six ready base factions, in Basic or Advanced, with Discoveries enabled before +components are dealt. It adds the seven cards to the ordinary Spice Deck and +creates one of each token behind shuffled, opaque physical identities. It rejects +redealing a started or nonempty game. Other expansion decks and optional modules +are outside this initializer. No ordinary player action or room endpoint can +bypass the normal start gate through it. The existing backed-up local starter +accepts `--profile discovery` for a fresh ready base-faction lobby: + +```sh +node --import tsx tools/start-prototype.ts --profile discovery --db PATH --room CODE --version NUMBER --out /private/new-directory +``` + +It preserves all seats and other rooms, requires the current version and refuses +to redeal a started game. The default `ix` profile remains available. + +## Connected behavior + +- **Great Maker:** ordinary worm consequences resolve first. Every faction then + votes in storm order; a strict majority creates a Nexus and a tie does not. + A previous Nexus in the phase is retained. Fremen may then move any legal + number of actual reserve forces to one allowed destination without spending + spice, shipment or normal movement. Typed Fedaykin custody and storm/occupancy + restrictions apply. Canceling protection of Fremen already in the affected + territory does not cancel the separate reserve ride. +- **Discovery blows:** each of the six cards destroys existing forces and spice + in its printed blow territory, including Fremen forces, before placing six + spice subject to the ordinary storm exclusion. A random available token of + the printed type is placed face down at the card's separate token destination. +- **Inspection and reveal:** Fremen privately see placed Hiereg faces; Guild + privately sees placed Smuggler faces. During Collection, non-advisor forces in + a token's surrounding territory may inspect it and then choose whether to + reveal it. Other seats do not receive that private knowledge or the token's + hidden supply order. An inspected token may remain face down. +- **Immediate rewards:** Spice Stash pays seven bank spice once. Treachery Card + Stash draws one private card before checking the hand limit. Overflow requires + an owned discard choice, including the new card; its saved draw/discard receipt + prevents replay. Both stashes then retain removed physical custody. +- **Carried token:** revealing Ornithopter records its owner and acquisition + turn. Spending it for movement is not implemented in this batch. +- **Nested locations:** the five revealed location tokens become separate, + initially empty board territories inside their printed surroundings. Public + reveal controls admission; a private peek alone cannot create a destination. + Ordinary movement uses the parent territory, shipment uses stronghold prices, + no more than two factions enter, and occupants are protected from storm and + sandworms. Only Jacurutu Sietch contributes a stronghold toward victory. + +Great Maker vote/ride controls and Discovery token/discard controls use the same +private legal choices as the four AI profiles. Token backs expose only permitted +information; authorized faces have readable explanations of all eight effects. +Busy and autopilot states disable human actions. Those explanations include +remaining printed effects and explicitly distinguish them from available play. + +## Interpretations retained for refinement + +The Great Maker text instructs players to treat it as Shai-Hulud with listed +exceptions. The prototype consequently applies ordinary first-turn worm skipping +and Sandtrout suppression before opening its special vote and ride. Those two +interactions are **source inferences**, not separately confirmed publisher +answers or newly approved table rulings. The Discovery-only setup does not itself +enable a combined Ix/Sandtrout game. + +If the required token type has no physical supply remaining, the blow continues +without creating another token. This preserves the eight-token inventory; the +empty-supply resolution remains an implementation interpretation to refine, +rather than a certified additional rule. Existing unrelated pending rulings in +the [decision index](RULE_DECISIONS.md) remain pending. + +## Evidence and next dependencies + +Focused suites cover component inventory and private projection +(`tests/discoveries.test.ts`), genuine setup and Collection effects +(`tests/discovery-runtime.test.ts`), ordered Great Maker continuations +(`tests/great-maker.test.ts`), nested board and admission rules +(`tests/discovery-board.test.ts`, `tests/discovery-admission.test.ts`), and private +human/AI controls (`tests/discovery-controls.test.ts`). They exercise immutable +rejections, physical custody, hidden information and JSON restoration. They do +not certify a complete module, all combined routes or difficulty strength ordering. + +SQLite recovery tests (`tests/discovery-recovery.test.ts`) verify competing +reveals pay only once, the private overflow choice survives restoration, and an +already committed discard resumes without another card or reward. Independent +review regressions bind worm/protection/arrival controls to their original +encounter and enforce the stash's mandatory overflow discard. Two deterministic +complete-game smoke tests (`tests/discovery-playthrough.test.ts`, seeds +2026091303 Basic and 2026091304 Advanced) preserve all components through JSON +reloads with four AI profiles. These games exercise the current prototype and +do not certify its explicitly missing effects. + +Browser room `3RJBH26Y` completed genuine human Fremen setup. Reordering one +existing physical Discovery card to the front of this new QA room's deck let +normal Storm and Spice Blow actions draw Hagga Basin and place a Hiereg token +at Gara Kulon 8. The owning Fremen view privately identified Shrine, retained it +face down, and displayed readable enlarged token and full Spice Card guidance. +The room is paused at acceptance of that blow. No other saved room was changed +and no server restart was needed. Required broad verification results and the +source-bound report are recorded in the checkpoint commit. + +1. Add the next-turn free entry into newly revealed locations, preserving typed + groups, occupancy, turn order and saved pending choices. +2. Connect the five special location benefits: Jacurutu battle income, Cistern + Collection income, Ecological Testing Station storm adjustment, Shrine card + conversion and Orgiz collection transfer. Ordinary Jacurutu victory counting + does not implement its battle income. +3. Connect later-turn Ornithopter spending to one actual movement action, then + integrate combined modules and complete games before refining or opening gates. diff --git a/docs/EFFICIENCY_PILOT.md b/docs/EFFICIENCY_PILOT.md index e76c1168..413bdbb6 100644 --- a/docs/EFFICIENCY_PILOT.md +++ b/docs/EFFICIENCY_PILOT.md @@ -91,3 +91,16 @@ connects runtime, private controls, AI and SQLite recovery. The battle inspectio alternative stays explicitly missing instead of holding the independent trade for an exhaustive battle-cleanup design. Final checks, counter endpoint and checkpoint scope are recorded in the commit. + +The Discovery batch starts at aggregate counter 60,591,430. Three bounded helpers +cover physical inventory, nested board/admission, and private controls/docs; +reusing them adds deterministic playthroughs and independent review of the +owner's encounter/reward integration. New playable functions are Great Maker's +vote/reserve ride, six Discovery blows, private Collection inspection/reveal, +and both immediate stash rewards with saved overflow discard. Token custody +and board destinations prepare the following location/flight batch. Reused +room-start tooling now selects the Discovery profile. Reviews found and repaired +lost saved controls, a retargetable arrival and skipped overflow discard before +the stable broad check. The implementation adds no calibration study or server +restart. Final aggregate endpoint, source-bound checks and push belong in the +checkpoint commit; whole-module completion remains unclaimed. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 85dbc69c..49776a55 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 13 September 2026 — First connected Discovery prototype + +Connects genuine base-faction Basic/Advanced audit setup to all seven sourced +Spice Cards and eight opaque physical tokens. Great Maker resolves worm effects, +an ordered majority vote and a free Fremen reserve ride. The six Discovery blows +destroy prior forces/spice before their new blow and separate token placement. +Private faction peeks and Collection inspection/reveal lead to exact stash +rewards, a saved full-hand draw/discard choice, carried Ornithopter custody and +five public nested locations; only Jacurutu contributes stronghold victory. +Shared human/AI controls and JSON continuations accompany the actions. + +This is Prototyped progress with Partial coverage. Next-turn free entry, the +five special location benefits and Ornithopter spending remain missing. The +normal public module start gate stays closed. Great Maker first-turn skipping, +Sandtrout suppression and empty-token-supply behavior remain identified source +inferences for refinement, not new confirmed rulings. See the +[prototype boundary and dependency queue](DISCOVERY_PROTOTYPE.md). + +Focused component, runtime, Great Maker, board/admission, private-control and +SQLite recovery checks pass. Two seeded Basic/Advanced games finish while +preserving components; browser `3RJBH26Y` verifies genuine Fremen setup, a real +Discovery blow, native private peek and enlarged card guidance. Review repaired +lost encounter/arrival controls and an unfinished overflow discard. Broad +verification and preservation results are recorded in the checkpoint commit +and private source-bound report. No complete-module certification is claimed. + ## 13 September 2026 — CHOAM Nexus Collection prototype and Discovery inventory Adds the missing absent-CHOAM Secret Ally Collection trade through the engine, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3a630376..fc575372 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -15,6 +15,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi | Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) | | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | | CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | +| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. First actions are prototyped; free entry, special location benefits and Ornithopter spending remain missing. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | | Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) | @@ -30,6 +31,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) | | Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and the individual faction source/runtime documents above | +| Discovery prototype inferences | Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | | Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above | When a decision changes, record the exact source or user ruling in its detailed feature document, update affected implementation guidance/tests, and adjust this index and [current status](CURRENT_STATUS.md). Retain historical evidence without presenting a superseded proposal as current behavior. diff --git a/game/board-resolution-quote.ts b/game/board-resolution-quote.ts index f1b1bf5f..5348203f 100644 --- a/game/board-resolution-quote.ts +++ b/game/board-resolution-quote.ts @@ -10,7 +10,8 @@ import { gameTerritories, MOBILE_LOCATION, splitLocation, - validLocation, + TERRITORIES, + validGameLocation, type MobileBoard, } from './board'; @@ -29,10 +30,11 @@ export type BoardContext = MobileBoard & { players: readonly BoardSeat[]; }; export type AdvisorRelease = { player: string; territory: string }; -function validKey(key: string) { +function validKey(g: MobileBoard, key: string) { const p = splitLocation(key); return ( - key === `${p.territory}:${p.sector}` && validLocation(p.territory, p.sector) + key === `${p.territory}:${p.sector}` && + validGameLocation(g, p.territory, p.sector) ); } /** A public board projection: no leader, hand, secret No-Field value or resource @@ -48,22 +50,28 @@ export function settledBoard(g: BoardContext) { g.players.every((p) => g.order.includes(p.id)), 'Board resolution needs the current distinct player order and storm sector.', ); - if (g.mobileStronghold?.location) + if (g.mobileStronghold?.location) { + const pointer = splitLocation(g.mobileStronghold.location); requireBoard( - validKey(g.mobileStronghold.location) && - g.mobileStronghold.location !== MOBILE_LOCATION, + validKey(g, g.mobileStronghold.location) && + TERRITORIES.some( + (t) => + t.id === pointer.territory && t.sectors.includes(pointer.sector), + ), 'The mobile stronghold needs its board pointer.', ); + } for (const p of g.players) { requireBoard( - Object.entries(p.forces).every(([key, n]) => validKey(key) && whole(n)) && - whole(Object.values(p.forces).reduce((a, n) => a + n, 0)), + Object.entries(p.forces).every( + ([key, n]) => validKey(g, key) && whole(n), + ) && whole(Object.values(p.forces).reduce((a, n) => a + n, 0)), 'Board resolution needs valid physical force locations and counts.', ); const marker = p.noField?.deployed?.location; if (marker) requireBoard( - validLocation(marker.territory, marker.sector), + validGameLocation(g, marker.territory, marker.sector), 'The No-Field needs a valid board location.', ); } @@ -206,7 +214,7 @@ export function quoteSpiceCollection( record(p.elites.forces) && Object.entries(p.elites.forces).every( ([key, n]) => - validKey(key) && whole(n) && n <= (p.forces[key] ?? 0), + validKey(g, key) && whole(n) && n <= (p.forces[key] ?? 0), ), 'Collection needs valid physical elite groups.', ); @@ -214,7 +222,7 @@ export function quoteSpiceCollection( const { players, released } = settledBoard(g); const spice = { ...g.spice }; requireBoard( - Object.entries(spice).every(([key, n]) => validKey(key) && whole(n)), + Object.entries(spice).every(([key, n]) => validKey(g, key) && whole(n)), 'Collection needs valid spice locations and quantities.', ); const capacities = new Map>(); diff --git a/game/board.ts b/game/board.ts index 30309d04..d3f6ce9c 100644 --- a/game/board.ts +++ b/game/board.ts @@ -1,5 +1,6 @@ import data from './board-data.json'; import graphData from './board-graph.json'; +import { DISCOVERY_LOCATIONS, isDiscoveryLocationId } from './discoveries'; export const TERRITORIES = data; export const MOBILE_STRONGHOLD = 'hidden_mobile_stronghold'; export const MOBILE_LOCATION = `${MOBILE_STRONGHOLD}:0`; @@ -17,20 +18,73 @@ const mobileTerritory = { center: [1045, 110], neighbors: [], }; +const discoveryTerritories = DISCOVERY_LOCATIONS.map((entry, index) => { + const top = 180 + index * 80; + return { + id: entry.id, + name: entry.name, + sectors: [0], + // Shipping and the shared two-faction admission rule treat every revealed + // location like a stronghold. Victory applies its narrower rule separately. + type: 'stronghold', + points: [ + [930, top], + [1160, top], + [1160, top + 60], + [930, top + 60], + ], + center: [1045, top + 30], + neighbors: [], + }; +}); +type DiscoveryBoardToken = { + face: string | null; + status: string; + territory: string | null; + sector: number | null; + revealedTurn: number | null; +}; export type MobileBoard = { mobileStronghold?: { location: string | null } | null; + discoveries?: { tokens: readonly DiscoveryBoardToken[] } | null; }; -/** Static printed board data stays shared; the mobile territory belongs to a room. */ +function revealedDiscoveryLocations(g: MobileBoard) { + const locations = new Map(); + for (const token of g.discoveries?.tokens ?? []) { + if ( + token.status !== 'placed' || + !Number.isSafeInteger(token.revealedTurn) || + (token.revealedTurn ?? 0) < 1 || + !isDiscoveryLocationId(token.face) || + typeof token.territory !== 'string' || + typeof token.sector !== 'number' + ) + continue; + const parent = TERRITORIES.find((t) => t.id === token.territory); + if (!parent?.sectors.includes(token.sector)) continue; + locations.set(token.face, { + territory: token.territory, + sector: token.sector, + }); + } + return locations; +} +/** Static printed board data stays shared; mobile and revealed Discovery + * territories belong to a room. */ export function gameTerritories(g: MobileBoard) { - return g.mobileStronghold?.location - ? [...TERRITORIES, mobileTerritory] - : TERRITORIES; + const revealed = revealedDiscoveryLocations(g); + const dynamic = [ + ...(g.mobileStronghold?.location ? [mobileTerritory] : []), + ...discoveryTerritories.filter((t) => revealed.has(t.id)), + ]; + return dynamic.length ? [...TERRITORIES, ...dynamic] : TERRITORIES; } export const territory = (id: string) => { const t = id === MOBILE_STRONGHOLD ? mobileTerritory - : TERRITORIES.find((t) => t.id === id); + : (discoveryTerritories.find((t) => t.id === id) ?? + TERRITORIES.find((t) => t.id === id)); if (!t) throw new Error('Unknown territory.'); return t; }; @@ -42,10 +96,19 @@ export function splitLocation(key: string) { } export function validLocation(id: string, sector: number) { if (id === MOBILE_STRONGHOLD) return sector === 0; + if (isDiscoveryLocationId(id)) return sector === 0; return ( TERRITORIES.find((t) => t.id === id)?.sectors.includes(sector) ?? false ); } +/** Structural location validity plus the room-local reveal requirement. The + * mobile stronghold remains structurally available while awaiting placement. */ +export function validGameLocation(g: MobileBoard, id: string, sector: number) { + return ( + validLocation(id, sector) && + (!isDiscoveryLocationId(id) || revealedDiscoveryLocations(g).has(id)) + ); +} export function distance( from: string, to: string, @@ -80,25 +143,50 @@ export const FREMEN_START_LOCATIONS = FREMEN_START.flatMap((id) => territory(id).sectors.map((s) => location(id, s)), ); -/** The mobile stronghold is a dead end, one territory from its pointing sector. */ +function dynamicEntrances(g: MobileBoard, key: string): string[] | undefined { + if (key === MOBILE_LOCATION) + return g.mobileStronghold?.location ? [g.mobileStronghold.location] : []; + const at = splitLocation(key); + if (!isDiscoveryLocationId(at.territory)) return undefined; + if (at.sector !== 0) return []; + const parent = revealedDiscoveryLocations(g).get(at.territory); + if (!parent) return []; + return territory(parent.territory).sectors.map((sector) => + location(parent.territory, sector), + ); +} + +/** Room-local nested territories are dead ends. The mobile stronghold connects + * through its pointer; a revealed Discovery location connects through any + * sector of the surrounding territory and costs one extra movement territory. */ export function gameDistance( g: MobileBoard, from: string, to: string, blocked: (key: string) => boolean = () => false, ): number { - if (from !== MOBILE_LOCATION && to !== MOBILE_LOCATION) + const fromEntrances = dynamicEntrances(g, from); + const toEntrances = dynamicEntrances(g, to); + if (fromEntrances === undefined && toEntrances === undefined) return distance(from, to, blocked); - const pointer = g.mobileStronghold?.location; - if (!pointer || blocked(from) || blocked(to)) return Infinity; + if (!fromEntrances?.length && fromEntrances !== undefined) return Infinity; + if (!toEntrances?.length && toEntrances !== undefined) return Infinity; + // Preserve the mobile stronghold contract: battle callers project its + // interior to the storm-covered pointer in their obstruction callback. + if ( + (from === MOBILE_LOCATION && blocked(from)) || + (to === MOBILE_LOCATION && blocked(to)) + ) + return Infinity; if (from === to) return 0; - return ( - 1 + - distance( - from === MOBILE_LOCATION ? pointer : from, - to === MOBILE_LOCATION ? pointer : to, - blocked, - ) + const starts = fromEntrances ?? [from]; + const ends = toEntrances ?? [to]; + const endpointCost = + Number(fromEntrances !== undefined) + Number(toEntrances !== undefined); + return Math.min( + ...starts.flatMap((start) => + ends.map((end) => endpointCost + distance(start, end, blocked)), + ), ); } /** A declared sector-by-sector route makes spice collection auditable. */ diff --git a/game/bots.ts b/game/bots.ts index b500cb6b..3617ebac 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1,3 +1,5 @@ +import { discoveryBotActions } from './discovery-options'; +import { greatMakerBotActions } from './great-maker-options'; import { nexusChoamTradeBotActions } from './nexus-choam-trade-options'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options'; import { nexusMoritaniBotActions } from './nexus-moritani-options'; @@ -2340,6 +2342,8 @@ function policyActions(g: GameView): Action[] { ? [{ type: 'decision', accept: true, amount: level === 0 ? 1 : (g.homeworldMobility?.advisorSinkMaximum ?? 1) }] : []), { type: 'decision', accept: false }, ]; + if (d.kind === 'discoveryDiscard') return discoveryBotActions(g); + if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g); if (d.kind === 'wormProtection') return [{ type: 'decision', accept: true }]; if (d.kind === 'stormLosses') @@ -3369,6 +3373,8 @@ export function botActions(g: GameView): Action[] { : nexusTraitorBotActions(g); if (g.automaticContinuationPending) return []; if (g.nexusCards?.waiting.length) return nexusCardBotActions(g); + const discovery = discoveryBotActions(g); + if (discovery.length) return discovery; const trade = nexusChoamTradeBotActions(g); if (trade.length) return trade; const sardaukar = nexusSardaukarBotActions(g); diff --git a/game/cards.ts b/game/cards.ts index 44243f6b..bf9e71c6 100644 --- a/game/cards.ts +++ b/game/cards.ts @@ -253,8 +253,8 @@ export const SPICE_CARDS = [ ['sihaya_ridge', 6, 9], ] as const; export type SpiceCard = - | { territory: string; amount: number; sector: number } - | { worm: true; suppressed?: true; thumper?: true } + | { territory: string; amount: number; sector: number; discovery?: string } + | { worm: true; suppressed?: true; thumper?: true; greatMaker?: true } | { sandtrout: true }; export function spiceDeck(includeSandtrout = false): SpiceCard[] { return [ diff --git a/game/discoveries.ts b/game/discoveries.ts new file mode 100644 index 00000000..b65caf25 --- /dev/null +++ b/game/discoveries.ts @@ -0,0 +1,694 @@ +import { FACTIONS, type FactionId } from './catalog'; + +/** + * Physical inventory and hidden custody for the Ecaz & Moritani Discovery + * variant. Source acquisition: docs/DISCOVERY_COMPONENTS.md. + * + * This module does not authorize a spice blow, inspection, reveal, movement, + * or reward. Callers establish those game conditions and apply emitted effects. + */ +export type DiscoveryTokenType = 'hiereg' | 'smuggler'; +export type DiscoveryTokenFace = + | 'jacurutu-sietch' + | 'cistern' + | 'ecological-testing-station' + | 'shrine' + | 'orgiz-processing-station' + | 'treachery-card-stash' + | 'spice-stash' + | 'ornithopter'; +export type DiscoveryLocationId = Extract< + DiscoveryTokenFace, + | 'jacurutu-sietch' + | 'cistern' + | 'ecological-testing-station' + | 'shrine' + | 'orgiz-processing-station' +>; +export type DiscoveryTokenKind = 'location' | 'instant' | 'carried'; + +export const JACURUTU_SIETCH: DiscoveryLocationId = 'jacurutu-sietch'; + +export type DiscoveryLocationDefinition = Readonly<{ + id: DiscoveryLocationId; + name: string; + tokenType: DiscoveryTokenType; + stronghold: boolean; +}>; + +export const DISCOVERY_LOCATIONS: readonly DiscoveryLocationDefinition[] = + Object.freeze([ + Object.freeze({ + id: JACURUTU_SIETCH, + name: 'Jacurutu Sietch', + tokenType: 'hiereg', + stronghold: true, + }), + Object.freeze({ + id: 'cistern', + name: 'Cistern', + tokenType: 'hiereg', + stronghold: false, + }), + Object.freeze({ + id: 'ecological-testing-station', + name: 'Ecological Testing Station', + tokenType: 'hiereg', + stronghold: false, + }), + Object.freeze({ + id: 'shrine', + name: 'Shrine', + tokenType: 'hiereg', + stronghold: false, + }), + Object.freeze({ + id: 'orgiz-processing-station', + name: 'Orgiz Processing Station', + tokenType: 'smuggler', + stronghold: false, + }), + ] satisfies DiscoveryLocationDefinition[]); + +export const DISCOVERY_LOCATION_IDS: readonly DiscoveryLocationId[] = + Object.freeze(DISCOVERY_LOCATIONS.map((location) => location.id)); + +export const DISCOVERY_LOCATION_BY_ID: Readonly< + Record +> = Object.freeze( + Object.fromEntries( + DISCOVERY_LOCATIONS.map((location) => [location.id, location]), + ) as Record, +); + +export function isDiscoveryLocationId( + value: unknown, +): value is DiscoveryLocationId { + return ( + typeof value === 'string' && + DISCOVERY_LOCATION_IDS.includes(value as DiscoveryLocationId) + ); +} + +export type DiscoveryTokenDefinition = Readonly<{ + id: DiscoveryTokenFace; + name: string; + type: DiscoveryTokenType; + kind: DiscoveryTokenKind; + location: DiscoveryLocationId | null; +}>; + +export const DISCOVERY_TOKENS: readonly DiscoveryTokenDefinition[] = + Object.freeze([ + ...DISCOVERY_LOCATIONS.map((location) => + Object.freeze({ + id: location.id, + name: location.name, + type: location.tokenType, + kind: 'location' as const, + location: location.id, + }), + ), + Object.freeze({ + id: 'treachery-card-stash', + name: 'Treachery Card Stash', + type: 'smuggler', + kind: 'instant', + location: null, + }), + Object.freeze({ + id: 'spice-stash', + name: 'Spice Stash', + type: 'smuggler', + kind: 'instant', + location: null, + }), + Object.freeze({ + id: 'ornithopter', + name: 'Ornithopter', + type: 'smuggler', + kind: 'carried', + location: null, + }), + ] satisfies DiscoveryTokenDefinition[]); + +export const DISCOVERY_TOKEN_DEFINITIONS = DISCOVERY_TOKENS; + +export const DISCOVERY_TOKEN_BY_ID: Readonly< + Record +> = Object.freeze( + Object.fromEntries( + DISCOVERY_TOKENS.map((definition) => [definition.id, definition]), + ) as Record, +); + +const DISCOVERY_TOKEN_FACES = Object.freeze( + DISCOVERY_TOKENS.map((definition) => definition.id), +); + +export function isDiscoveryTokenFace( + value: unknown, +): value is DiscoveryTokenFace { + return ( + typeof value === 'string' && + DISCOVERY_TOKEN_FACES.includes(value as DiscoveryTokenFace) + ); +} + +export type DiscoverySpiceCardId = + | 'discovery-hagga-basin' + | 'discovery-rock-outcroppings' + | 'discovery-sihaya-ridge' + | 'discovery-wind-pass-north' + | 'discovery-funeral-plain' + | 'discovery-old-gap'; +export type DiscoverySpiceCard = Readonly<{ + territory: string; + amount: 6; + sector: number; + discovery: DiscoverySpiceCardId; +}>; +export type DiscoveryCardPlacement = Readonly<{ + type: DiscoveryTokenType; + territory: string; + sector: number; +}>; + +export const DISCOVERY_SPICE_CARDS: readonly DiscoverySpiceCard[] = + Object.freeze([ + Object.freeze({ + territory: 'hagga_basin', + amount: 6, + sector: 12, + discovery: 'discovery-hagga-basin', + }), + Object.freeze({ + territory: 'rock_outcroppings', + amount: 6, + sector: 13, + discovery: 'discovery-rock-outcroppings', + }), + Object.freeze({ + territory: 'sihaya_ridge', + amount: 6, + sector: 9, + discovery: 'discovery-sihaya-ridge', + }), + Object.freeze({ + territory: 'wind_pass_north', + amount: 6, + sector: 17, + discovery: 'discovery-wind-pass-north', + }), + Object.freeze({ + territory: 'funeral_plain', + amount: 6, + sector: 15, + discovery: 'discovery-funeral-plain', + }), + Object.freeze({ + territory: 'oh_gap', + amount: 6, + sector: 10, + discovery: 'discovery-old-gap', + }), + ] satisfies DiscoverySpiceCard[]); + +export const DISCOVERY_CARD_PLACEMENTS: Readonly< + Record +> = Object.freeze({ + 'discovery-hagga-basin': Object.freeze({ + type: 'hiereg', + territory: 'gara_kulon', + sector: 8, + }), + 'discovery-rock-outcroppings': Object.freeze({ + type: 'hiereg', + territory: 'meridian', + sector: 1, + }), + 'discovery-sihaya-ridge': Object.freeze({ + type: 'hiereg', + territory: 'cielago_east', + sector: 3, + }), + 'discovery-wind-pass-north': Object.freeze({ + type: 'smuggler', + territory: 'plastic_basin', + sector: 13, + }), + 'discovery-funeral-plain': Object.freeze({ + type: 'smuggler', + territory: 'pasty_mesa', + sector: 7, + }), + 'discovery-old-gap': Object.freeze({ + type: 'smuggler', + territory: 'false_wall_west', + sector: 17, + }), +}); + +const DISCOVERY_CARD_IDS = Object.freeze( + DISCOVERY_SPICE_CARDS.map((card) => card.discovery), +); + +export function isDiscoverySpiceCardId( + value: unknown, +): value is DiscoverySpiceCardId { + return ( + typeof value === 'string' && + DISCOVERY_CARD_IDS.includes(value as DiscoverySpiceCardId) + ); +} + +export type DiscoveryTokenStatus = + | 'supply' + | 'placed' + | 'carried' + | 'removed'; +export type DiscoveryOpaqueTokenId = `discovery-token-${number}`; +export type DiscoveryTokenId = DiscoveryOpaqueTokenId; +export type DiscoveryToken = { + /** Stable physical identity. It does not reveal the hidden face. */ + id: DiscoveryOpaqueTokenId; + type: DiscoveryTokenType; + face: DiscoveryTokenFace; + status: DiscoveryTokenStatus; + territory: string | null; + sector: number | null; + revealedTurn: number | null; + /** Caller-authorized faction knowledge; built-in Fremen/Guild peeks need no entry. */ + known: FactionId[]; + owner: string | null; + acquiredTurn: number | null; +}; +export type DiscoveryState = { + version: 1; + /** Array order is the private shuffled pool order; projection never exposes it. */ + tokens: DiscoveryToken[]; + /** Revealed location IDs awaiting the next-turn force-entry opportunity. */ + newlyRevealed: DiscoveryOpaqueTokenId[]; +}; + +export type ProjectedDiscoveryToken = Omit< + DiscoveryToken, + 'face' | 'known' +> & { + face: DiscoveryTokenFace | null; +}; +export type DiscoveryView = { + supplyCount: number; + tokens: ProjectedDiscoveryToken[]; + newlyRevealed: DiscoveryOpaqueTokenId[]; +}; + +export type DiscoveryRevealOutcome = + | { kind: 'location'; location: DiscoveryLocationId } + | { kind: 'treachery-card-stash' } + | { kind: 'spice-stash'; amount: 7 } + | { kind: 'ornithopter'; owner: string }; + +const factionIds = Object.freeze(FACTIONS.map((faction) => faction.id)); +const tokenKeys = Object.freeze([ + 'acquiredTurn', + 'face', + 'id', + 'known', + 'owner', + 'revealedTurn', + 'sector', + 'status', + 'territory', + 'type', +]); + +function requireValid(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function isFactionId(value: unknown): value is FactionId { + return ( + typeof value === 'string' && factionIds.includes(value as FactionId) + ); +} + +export function isDiscoveryTokenId( + value: unknown, +): value is DiscoveryOpaqueTokenId { + return typeof value === 'string' && /^discovery-token-[1-8]$/.test(value); +} + +function validTurn(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 1; +} + +function validateRandom(random: () => number, count: number): number { + const draw = random(); + requireValid( + Number.isFinite(draw) && draw >= 0 && draw < 1, + 'Discovery random draws must be finite numbers in [0, 1).', + ); + return Math.floor(draw * count); +} + +function isPlacement(type: DiscoveryTokenType, territory: string, sector: number) { + return Object.values(DISCOVERY_CARD_PLACEMENTS).some( + (placement) => + placement.type === type && + placement.territory === territory && + placement.sector === sector, + ); +} + +export function validateDiscoveryState(state: DiscoveryState): void { + requireValid( + state !== null && typeof state === 'object' && !Array.isArray(state), + 'A Discovery state is required.', + ); + requireValid( + Object.keys(state).sort().join(',') === 'newlyRevealed,tokens,version' && + state.version === 1 && + Array.isArray(state.tokens) && + Array.isArray(state.newlyRevealed), + 'Invalid Discovery state shape or version.', + ); + requireValid( + state.tokens.length === DISCOVERY_TOKENS.length, + 'A Discovery inventory contains eight physical tokens.', + ); + const ids = new Set(); + const faces = new Set(); + for (const token of state.tokens) { + requireValid( + token !== null && + typeof token === 'object' && + !Array.isArray(token) && + Object.keys(token).sort().join(',') === tokenKeys.join(','), + 'Invalid Discovery token shape.', + ); + requireValid( + isDiscoveryTokenId(token.id) && !ids.has(token.id), + 'Discovery physical IDs must be the eight distinct opaque token IDs.', + ); + ids.add(token.id); + requireValid( + isDiscoveryTokenFace(token.face) && !faces.has(token.face), + 'Every Discovery face must appear exactly once.', + ); + faces.add(token.face); + const definition = DISCOVERY_TOKEN_BY_ID[token.face]; + requireValid( + token.type === definition.type, + 'A Discovery token type must match its physical face.', + ); + requireValid( + ['supply', 'placed', 'carried', 'removed'].includes(token.status), + 'Invalid Discovery token custody status.', + ); + requireValid( + Array.isArray(token.known) && + new Set(token.known).size === token.known.length && + token.known.every(isFactionId), + 'Discovery private knowledge must contain distinct factions.', + ); + if (token.status === 'supply') { + requireValid( + token.territory === null && + token.sector === null && + token.revealedTurn === null && + token.known.length === 0 && + token.owner === null && + token.acquiredTurn === null, + 'A supplied Discovery token cannot retain board, reveal, or owner state.', + ); + } else if (token.status === 'placed') { + requireValid( + typeof token.territory === 'string' && + token.territory.length > 0 && + Number.isSafeInteger(token.sector) && + isPlacement(token.type, token.territory, token.sector as number) && + (token.revealedTurn === null || validTurn(token.revealedTurn)) && + token.owner === null && + token.acquiredTurn === null, + 'A placed Discovery token needs a printed destination and valid reveal state.', + ); + requireValid( + token.revealedTurn === null || definition.kind === 'location', + 'Only a location Discovery token remains placed after reveal.', + ); + } else if (token.status === 'carried') { + requireValid( + token.face === 'ornithopter' && + token.territory === null && + token.sector === null && + validTurn(token.revealedTurn) && + typeof token.owner === 'string' && + token.owner.length > 0 && + token.acquiredTurn === token.revealedTurn, + 'Only a revealed Ornithopter can be carried by a player.', + ); + } else { + requireValid( + definition.kind !== 'location' && + token.territory === null && + token.sector === null && + validTurn(token.revealedTurn) && + token.owner === null && + validTurn(token.acquiredTurn), + 'Only a resolved non-location Discovery token can be removed.', + ); + } + } + requireValid( + faces.size === DISCOVERY_TOKENS.length, + 'The Discovery inventory must retain every printed face.', + ); + requireValid( + new Set(state.newlyRevealed).size === state.newlyRevealed.length, + 'Newly revealed Discovery locations must be distinct.', + ); + for (const id of state.newlyRevealed) { + const token = state.tokens.find((candidate) => candidate.id === id); + requireValid( + token?.status === 'placed' && + token.revealedTurn !== null && + isDiscoveryLocationId(token.face), + 'A newly revealed entry must identify a revealed location token.', + ); + } +} + +function cloneDiscoveryState(state: DiscoveryState): DiscoveryState { + return { + version: 1, + tokens: state.tokens.map((token) => ({ + id: token.id, + type: token.type, + face: token.face, + status: token.status, + territory: token.territory, + sector: token.sector, + revealedTurn: token.revealedTurn, + known: [...token.known], + owner: token.owner, + acquiredTurn: token.acquiredTurn, + })), + newlyRevealed: [...state.newlyRevealed], + }; +} + +export function createDiscoveryState(random: () => number): DiscoveryState { + const faces = [...DISCOVERY_TOKEN_FACES]; + for (let index = faces.length - 1; index > 0; index--) { + const selected = validateRandom(random, index + 1); + [faces[index], faces[selected]] = [faces[selected], faces[index]]; + } + return { + version: 1, + tokens: faces.map((face, index) => ({ + id: `discovery-token-${index + 1}`, + type: DISCOVERY_TOKEN_BY_ID[face].type, + face, + status: 'supply', + territory: null, + sector: null, + revealedTurn: null, + known: [], + owner: null, + acquiredTurn: null, + })), + newlyRevealed: [], + }; +} + +/** Caller has already drawn and resolved the printed Discovery spice blow. */ +export function placeDiscovery( + state: DiscoveryState, + cardId: DiscoverySpiceCardId, + random: () => number, +): DiscoveryState { + validateDiscoveryState(state); + requireValid( + isDiscoverySpiceCardId(cardId), + 'Choose a printed Discovery Spice Card.', + ); + const placement = DISCOVERY_CARD_PLACEMENTS[cardId]; + const eligible = state.tokens.filter( + (token) => token.status === 'supply' && token.type === placement.type, + ); + requireValid( + eligible.length > 0, + `No ${placement.type} Discovery token remains in the supply.`, + ); + const selected = eligible[validateRandom(random, eligible.length)]; + const next = cloneDiscoveryState(state); + const token = next.tokens.find((candidate) => candidate.id === selected.id)!; + Object.assign(token, { + status: 'placed' as const, + territory: placement.territory, + sector: placement.sector, + }); + return next; +} + +/** Caller authorizes a private look, including an occupying faction's collection look. */ +export function rememberDiscoveryFace( + state: DiscoveryState, + tokenId: DiscoveryOpaqueTokenId, + faction: FactionId, +): DiscoveryState { + validateDiscoveryState(state); + requireValid(isFactionId(faction), 'Choose a valid faction for Discovery knowledge.'); + const source = state.tokens.find((token) => token.id === tokenId); + requireValid( + source?.status === 'placed' && source.revealedTurn === null, + 'Choose a hidden placed Discovery token to inspect.', + ); + const next = cloneDiscoveryState(state); + const token = next.tokens.find((candidate) => candidate.id === tokenId)!; + if (!token.known.includes(faction)) token.known.push(faction); + return next; +} + +/** Reveals custody and emits the effect; the caller applies rewards and authorizes the turn. */ +export function revealDiscoveryToken( + state: DiscoveryState, + tokenId: DiscoveryOpaqueTokenId, + turn: number, + owner?: string, +): { state: DiscoveryState; outcome: DiscoveryRevealOutcome } { + validateDiscoveryState(state); + requireValid(validTurn(turn), 'Choose a valid turn for Discovery reveal.'); + const source = state.tokens.find((token) => token.id === tokenId); + requireValid( + source?.status === 'placed' && source.revealedTurn === null, + 'Choose a hidden placed Discovery token to reveal.', + ); + const definition = DISCOVERY_TOKEN_BY_ID[source.face]; + const next = cloneDiscoveryState(state); + const token = next.tokens.find((candidate) => candidate.id === tokenId)!; + token.revealedTurn = turn; + if (definition.kind === 'location') { + next.newlyRevealed.push(token.id); + return { + state: next, + outcome: { kind: 'location', location: definition.location! }, + }; + } + token.territory = null; + token.sector = null; + token.acquiredTurn = turn; + if (source.face === 'ornithopter') { + requireValid( + typeof owner === 'string' && owner.length > 0, + 'An Ornithopter reveal needs its receiving player.', + ); + requireValid( + !next.tokens.some( + (candidate) => + candidate.status === 'carried' && candidate.owner === owner, + ), + 'That player already carries an Ornithopter.', + ); + token.status = 'carried'; + token.owner = owner; + return { state: next, outcome: { kind: 'ornithopter', owner } }; + } + token.status = 'removed'; + if (source.face === 'spice-stash') + return { state: next, outcome: { kind: 'spice-stash', amount: 7 } }; + return { state: next, outcome: { kind: 'treachery-card-stash' } }; +} + +export function clearNewlyRevealed(state: DiscoveryState): DiscoveryState { + validateDiscoveryState(state); + const next = cloneDiscoveryState(state); + next.newlyRevealed = []; + return next; +} + +/** Caller authorizes the owner's one movement action before consuming custody. */ +export function consumeOrnithopter( + state: DiscoveryState, + owner: string, +): DiscoveryState { + validateDiscoveryState(state); + requireValid( + typeof owner === 'string' && owner.length > 0, + 'Choose an Ornithopter owner.', + ); + const carried = state.tokens.filter( + (token) => token.status === 'carried' && token.owner === owner, + ); + requireValid( + carried.length === 1, + 'That player does not carry exactly one Ornithopter.', + ); + const next = cloneDiscoveryState(state); + const token = next.tokens.find((candidate) => candidate.id === carried[0].id)!; + token.status = 'removed'; + token.owner = null; + return next; +} + +/** + * Supply order and faces are never projected. Hidden placed faces are visible + * only to recorded viewers or the type's printed privileged faction. + */ +export function projectDiscoveryState( + state: DiscoveryState, + viewerFaction?: FactionId, +): DiscoveryView { + validateDiscoveryState(state); + requireValid( + viewerFaction === undefined || isFactionId(viewerFaction), + 'Choose a valid faction for the Discovery view.', + ); + return { + supplyCount: state.tokens.filter((token) => token.status === 'supply').length, + tokens: state.tokens + .filter((token) => token.status !== 'supply') + .map((token) => { + const privileged = + (viewerFaction === 'fremen' && token.type === 'hiereg') || + (viewerFaction === 'guild' && token.type === 'smuggler'); + const faceVisible = + token.revealedTurn !== null || + privileged || + (viewerFaction !== undefined && token.known.includes(viewerFaction)); + return { + id: token.id, + type: token.type, + face: faceVisible ? token.face : null, + status: token.status, + territory: token.territory, + sector: token.sector, + revealedTurn: token.revealedTurn, + owner: token.owner, + acquiredTurn: token.acquiredTurn, + }; + }), + newlyRevealed: [...state.newlyRevealed], + }; +} diff --git a/game/discovery-actions.ts b/game/discovery-actions.ts new file mode 100644 index 00000000..00f97e57 --- /dev/null +++ b/game/discovery-actions.ts @@ -0,0 +1,73 @@ +import type { Game } from './engine'; +import { fighterCount } from './advisors'; +import { projectDiscoveryState } from './discoveries'; + +export type DiscoveryStash = { + event: string; + token: string; + owner: string; + turn: number; + card: string; + hand: string[]; + stage: 'choose' | 'discard' | 'complete'; + discarded: string | null; + signature: string; +}; +export function discoveryStashSignature(record: DiscoveryStash) { + return JSON.stringify([ + record.event, + record.token, + record.owner, + record.turn, + record.card, + record.hand, + record.stage, + record.discarded, + ]); +} +/** A private view of physical tokens and the current player's Collection choices. */ +export function discoveryChoices(g: Game, owner: string, automatic: boolean) { + const p = g.players.find((player) => player.id === owner); + if (!g.discoveries || !p) return null; + const view = projectDiscoveryState(g.discoveries, p.faction); + const blocked = + g.status !== 'playing' || g.phase !== 7 + ? 'Inspect or reveal during Spice Collection.' + : automatic || + g.truthtrance || + g.response || + g.decision || + g.phaseOpening || + g.pendingKarama || + g.pendingTreacheryDiscard || + g.pendingNullentropy || + g.pendingExchange || + g.pendingAmbassador || + g.pendingRicheseGift || + g.pendingRichesePurchaseIncome || + g.nexusCards?.phase?.stage === 'drawing' || + g.nexusTraitorExchanges?.some((record) => record.stage === 'return') + ? 'Finish the current interaction first.' + : null; + const eligible = view.tokens.filter( + (token) => + token.status === 'placed' && + token.revealedTurn === null && + token.territory && + fighterCount(p, token.territory) > 0, + ); + return { + ...view, + blocked, + canInspect: blocked + ? [] + : eligible + .filter((token) => token.face === null) + .map((token) => token.id), + canReveal: blocked + ? [] + : eligible + .filter((token) => token.face !== null) + .map((token) => token.id), + }; +} diff --git a/game/discovery-options.ts b/game/discovery-options.ts new file mode 100644 index 00000000..465ed8b2 --- /dev/null +++ b/game/discovery-options.ts @@ -0,0 +1,40 @@ +import type { Action, GameView } from './engine'; + +/** Human and AI choices use only the requesting seat's quoted physical tokens. */ +export function discoveryAction(game: GameView, token: string, reveal: boolean): Action | null { + const offer = game.discoveries; + if (!offer || offer.blocked || game.status !== 'playing' || game.phase !== 7 || + game.automaticContinuationPending || game.truthtrance || game.response || game.decision || + typeof reveal !== 'boolean' || + !(reveal ? offer.canReveal : offer.canInspect).some(id => id === token)) return null; + return { type: 'discovery', token, reveal }; +} + +/** The stash has already drawn its card. Any card in this seat's hand may leave. */ +export function discoveryDiscardAction(game: GameView, card: string): Action | null { + const decision = game.decision; + if (game.status !== 'playing' || decision?.kind !== 'discoveryDiscard' || + decision.player !== game.me || game.truthtrance || game.response || + game.automaticContinuationPending || + !game.players.find(player => player.id === game.me)?.hand?.some(held => held.id === card)) return null; + return { type: 'decision', event: decision.event, card }; +} + +/** Inspect first, then reconsider the newly visible face through a fresh view. */ +export function discoveryBotActions(game: GameView): Action[] { + if (game.decision?.kind === 'discoveryDiscard') { + const hand = game.players.find(player => player.id === game.me)?.hand ?? []; + return [...hand] + .sort((a, b) => Number(b.kind === 'worthless') - Number(a.kind === 'worthless')) + .flatMap(card => { + const action = discoveryDiscardAction(game, card.id); + return action ? [action] : []; + }); + } + const offer = game.discoveries; + if (!offer) return []; + return [ + ...offer.canInspect.map(token => discoveryAction(game, token, false)), + ...offer.canReveal.map(token => discoveryAction(game, token, true)), + ].filter((action): action is Action => action !== null); +} diff --git a/game/ecaz-occupy.ts b/game/ecaz-occupy.ts index 179ea87e..bf2dbe02 100644 --- a/game/ecaz-occupy.ts +++ b/game/ecaz-occupy.ts @@ -1,3 +1,4 @@ +import { isDiscoveryLocationId } from './discoveries'; import { FACTIONS, type FactionId } from './catalog'; import { MOBILE_STRONGHOLD, TERRITORIES, validLocation } from './board'; @@ -67,7 +68,7 @@ function validate( if (at.kind === 'homeworld') return; requireOccupancy( at.kind === 'territory' && - (at.id === MOBILE_STRONGHOLD || + (at.id === MOBILE_STRONGHOLD || isDiscoveryLocationId(at.id) || TERRITORIES.some((t) => t.id === at.id)) && (at.sector === undefined || (Number.isSafeInteger(at.sector) && validLocation(at.id, at.sector))), diff --git a/game/engine.ts b/game/engine.ts index 68ca1842..56bc3628 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,6 @@ +import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions'; +import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, isDiscoverySpiceCardId, isDiscoveryTokenId, DISCOVERY_SPICE_CARDS, DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryState } from './discoveries'; +import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker'; import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade'; import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally'; import { shipmentAvailable } from './shipment-opportunity'; @@ -484,7 +487,7 @@ import { location, splitLocation, distance, - validLocation, + validGameLocation, } from './board'; export type Player = { id: string; @@ -743,6 +746,9 @@ export type Decision = } | { kind: 'wormPlacement'; player: string } | { kind: 'wormProtection'; player: string; territory: string; ally: string } + | { kind: 'discoveryDiscard'; player: string; event: string } + | { kind: 'greatMakerVote'; player: string; event: string } + | { kind: 'greatMakerRide'; player: string; event: string } | { kind: 'wormRide'; player: string; territory: string } | { kind: 'moritaniRetention'; @@ -884,6 +890,10 @@ type RicheseAllyOffer = { payer: string; }; export type Game = { + discoveryEnabled?: boolean; + discoveries?: DiscoveryState; + discoveryStash?: DiscoveryStash; + greatMaker?: GreatMaker; nexusChoamTrades?: NexusChoamTrade[]; nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] }; treacheryDiscardSequence?: number; @@ -894,6 +904,7 @@ export type Game = { batch: FreshDiscardBatch; continuation: | { kind: 'ambassador'; entry: NonNullable } + | { kind: 'discoveryStash'; event: string; owner: string; card: string } | { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number } | { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number } | { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable } @@ -2464,7 +2475,7 @@ function alliedNoFieldQuote( ); requireRule( offer.territory !== MOBILE_STRONGHOLD && - validLocation(offer.territory, offer.sector), + validGameLocation(g, offer.territory, offer.sector), 'Choose a printed planet sector for the allied No-Field shipment.', ); // Test the compulsory old-marker reveal without exposing or mutating it before acceptance. @@ -3963,7 +3974,7 @@ function treacheryDiscardIntegrity(g: Game) { m.player === owner.id && owner.shipped && gameTerritories(g).some((t) => t.id === m.origin) && - validLocation(m.to, m.sector) && + validGameLocation(g, m.to, m.sector) && Number.isSafeInteger(m.total) && m.total > 0 && typeof m.noField === 'boolean' && @@ -4050,7 +4061,7 @@ function treacheryDiscardIntegrity(g: Game) { owner?.faction === 'moritani' && entrant && entrant.id !== owner.id && - terrorEntryLocationAllowed(g,entry) && validLocation(entry.territory,entry.sector) && + terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory,entry.sector) && [ 'shipment', 'movement', @@ -4315,6 +4326,12 @@ function treacheryDiscardIntegrity(g: Game) { Array.isArray(c.optional) && new Set(c.optional).size === c.optional.length && c.optional.every((id) => getPlayer(g, c.player).hand.some((card) => card.id === id)), 'The mandatory winning card discard no longer matches its resolved battle.'); + } else if (continuation?.kind === 'discoveryStash') { + const c = continuation; + discoveryIntegrity(g); + requireRule(batch.cause === 'discovery:stash' && batch.entries.length === 1 && + batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace && batch.entries[0].card.id === c.card, + 'The Treachery Card Stash has lost its chosen discard.'); } else if (continuation?.kind === 'nexusChoamTrade') { const c = continuation; nexusChoamTradeIntegrity(g); @@ -4370,6 +4387,11 @@ function finishTreacheryDiscard(g: Game) { finishWinner(g, getPlayer(g, next.player), next.territory, next.optional); return; } + if (next.kind === 'discoveryStash') { + g.discoveryStash!.stage = 'complete'; + g.discoveryStash!.signature = discoveryStashSignature(g.discoveryStash!); + return; + } if (next.kind === 'nexusChoamTrade') { const record = g.nexusChoamTrades!.at(-1)!; record.stage = 'complete'; record.signature = nexusChoamTradeSignature(record); @@ -4799,6 +4821,7 @@ function start(g: Game) { requireRule(!g.advanced, 'Advanced rules are still being implemented.'); requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.'); requireRule(!g.nexusCards, 'Nexus card effects are still being implemented.'); + requireRule(!g.discoveryEnabled, 'Discoveries are still being implemented.'); requireRule( g.players.every((p) => faction(p.faction).expansion === 'base'), 'Expansion factions are still being implemented.', @@ -4810,6 +4833,7 @@ function start(g: Game) { initializeSetup(g); } function initializeSetup(g: Game) { + if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random); if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null }; const choam = byFaction(g, 'choam'); if (choam && g.advanced && !choam.leaders.some(isAuditorLeader)) @@ -4835,7 +4859,7 @@ function initializeSetup(g: Game) { g.stormDialers = [g.order[0], g.order.at(-1)!]; g.lastBattle = [...g.stormDialers]; g.deck = shuffle(treacheryDeck(g.expansions)); - g.spiceDeck = shuffle(spiceDeck(g.expansions.includes('ix'))); + g.spiceDeck = shuffle([...spiceDeck(g.expansions.includes('ix')), ...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, {worm:true as const, greatMaker:true as const}] : [])]); g.sandtrout = false; g.status = 'setup'; g.setupStage = 'prediction'; @@ -4998,7 +5022,13 @@ export function initializeIxGameForAudit(state: Game): Game { 'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.'); return initializeSetupGameForAudit(state, false, false, true); } -function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false): Game { +/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */ +export function initializeDiscoveryGameForAudit(state: Game): Game { + requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker, + 'Enable Discoveries in a fresh audit lobby first.'); + return initializeSetupGameForAudit(state, false, false, false, true); +} +function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false): Game { nexusCardsIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); @@ -5021,6 +5051,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f ); requireRule( (homeworlds || nexus || ix || g.expansions.length === 0) && + (discovery || !g.discoveryEnabled) && (nexus || !g.nexusCards) && !g.techTokens && !g.strongholdCards && @@ -5341,7 +5372,7 @@ function refillSpice(g: Game) { g.spiceDiscard .flat() .filter((c) => !('worm' in c && c.thumper)) - .map((c) => ('worm' in c ? { worm: true as const } : c)), + .map((c) => ('worm' in c ? { worm: true as const, ...(c.greatMaker ? {greatMaker: true as const} : {}) } : c)), ); g.spiceDiscard = [[], []]; } @@ -5395,6 +5426,11 @@ function wormSurvival(g: Game, t: string, protectedAlly?: string) { } } function afterWorm(g: Game) { + if (g.greatMaker?.stage === 'worm') { + g.wormRides = [...g.greatMaker.ridesBefore]; + openGreatMakerVote(g); + return; + } const pending = g.summonedWorm; if (!pending) { continueSpice(g); @@ -5441,6 +5477,226 @@ function afterWorm(g: Game) { 'The summoned worm has resolved. Its Nexus occurs after the spice blow.', ); } +function discoveryIntegrity(g: Game) { + if (!g.discoveryEnabled) { + requireRule(!g.discoveries && !g.discoveryStash, 'Discovery custody requires its enabled module.'); + return; + } + if (g.status === 'lobby') { + requireRule(!g.discoveries && !g.discoveryStash, 'Discovery components are dealt during setup.'); + return; + } + requireRule(g.discoveries, 'The Discovery module has lost its physical tokens.'); + nexusRule(() => validateDiscoveryState(g.discoveries!)); + for (const token of g.discoveries.tokens) requireRule( + (token.revealedTurn === null || token.revealedTurn <= g.turn) && + (token.acquiredTurn === null || token.acquiredTurn <= g.turn) && + (token.owner === null || g.players.some(player => player.id === token.owner)), + 'A Discovery token has lost its receiving player or original turn.'); + const record = g.discoveryStash, continuation = g.pendingTreacheryDiscard?.continuation; + const pending = continuation?.kind === 'discoveryStash' ? continuation : null; + const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, + g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; + if (!record) { + requireRule(!pending && !controls.some(control => control?.decision?.kind === 'discoveryDiscard'), + 'The Discovery discard has lost its original draw.'); + return; + } + const p = g.players.find(player => player.id === record.owner); + requireRule(p && Object.keys(record).sort().join(',') === 'card,discarded,event,hand,owner,signature,stage,token,turn' && + typeof record.event === 'string' && record.event && Number.isSafeInteger(record.turn) && record.turn >= 1 && record.turn <= g.turn && + ['choose','discard','complete'].includes(record.stage) && Array.isArray(record.hand) && + record.hand.every(id => typeof id === 'string') && new Set(record.hand).size === record.hand.length && record.hand.includes(record.card) && + g.discoveries.tokens.some(token => token.id === record.token && token.face === 'treachery-card-stash' && + token.status === 'removed' && token.revealedTurn === record.turn) && + record.signature === discoveryStashSignature(record) && + (record.stage === 'choose' ? record.discarded === null : record.discarded === null || record.hand.includes(record.discarded)) && + (record.stage === 'discard') === !!pending, + 'The Treachery Card Stash has lost its original draw or selected discard.'); + const overflow = record.hand.length === handLimit(p) + 1; + requireRule(record.hand.length >= 1 && record.hand.length <= handLimit(p) + 1 && + (record.stage !== 'complete' ? overflow : overflow === (record.discarded !== null)), + 'The Treachery Card Stash must complete its original overflow discard.'); + if (record.stage === 'complete') { + requireRule(!controls.some(control => control?.decision?.kind === 'discoveryDiscard'), + 'The completed stash cannot retain a discard choice.'); + return; + } + requireRule(g.status === 'playing' && g.turn === record.turn && g.phase === 7 && + JSON.stringify(p.hand.map(card => card.id).sort()) === JSON.stringify(record.hand.filter(id => id !== record.discarded).sort()), + 'Finish the original Treachery Card Stash hand before another card change.'); + if (record.stage === 'choose') requireRule(controls.some(control => control?.decision?.kind === 'discoveryDiscard' && + control.decision.player === p.id && control.decision.event === record.event), 'The stash has lost its owned discard choice.'); + if (pending) requireRule(pending.event === record.event && pending.owner === p.id && pending.card === record.discarded, + 'The stash has changed its committed discard.'); +} +function resolveDiscoveryBlow(g: Game, card: Extract) { + requireRule(g.discoveryEnabled && g.discoveries && isDiscoverySpiceCardId(card.discovery), 'This Discovery card requires its physical module.'); + const printed = DISCOVERY_SPICE_CARDS.find(value => value.discovery === card.discovery)!; + requireRule(card.territory === printed.territory && card.sector === printed.sector && card.amount === printed.amount, + 'The Discovery spice blow has changed its printed territory, sector or amount.'); + disasterPreflight(() => validateWormDevouring(g,card.territory,undefined,false)); + for (const p of g.players) killTerritory(g,p,card.territory); + for (const key of Object.keys(g.spice)) if (splitLocation(key).territory === card.territory) delete g.spice[key]; + log(g, `A Discovery spice blow destroyed all previous spice and forces in ${territory(card.territory).name}, including Fremen forces. The new spice is placed afterward.`); + const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery]; + if (g.discoveries.tokens.some(token => token.type === placement.type && token.status === 'supply')) { + g.discoveries = nexusRule(() => placeDiscovery(g.discoveries!,card.discovery as Parameters[1],random)); + log(g, `A face-down ${placement.type === 'hiereg' ? 'Hiereg' : 'Smuggler'} Discovery was placed in ${territory(placement.territory).name}, sector ${placement.sector}.`); + } else log(g, `The ${placement.type} Discovery supply is empty; no additional physical token can be placed.`); +} +function playDiscovery(g: Game, p: Player, action: Action) { + const offer = discoveryChoices(g,p.id,g.phase === 7 && grummanCollectionAutomatic(g)); + requireRule(offer && !offer.blocked && typeof action.reveal === 'boolean' && isDiscoveryTokenId(action.token) && + Object.keys(action).every(key => ['type','token','reveal'].includes(key)) && + (action.reveal ? offer.canReveal : offer.canInspect).includes(action.token), + offer?.blocked ?? 'Choose an eligible hidden Discovery in a territory containing your non-advisor forces.'); + if (!action.reveal) { + g.discoveries = nexusRule(() => rememberDiscoveryFace(g.discoveries!,action.token as Parameters[1],p.faction)); + return; + } + const token = g.discoveries!.tokens.find(token => token.id === action.token)!; + if (token.face === 'spice-stash') requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice+7), 'The stash needs a valid spice balance.'); + if (token.face === 'treachery-card-stash') requireRule(g.deck.length + g.discard.length > 0, 'No Treachery card is available to draw.'); + const revealed = nexusRule(() => revealDiscoveryToken(g.discoveries!,token.id,g.turn,p.id)); + g.discoveries = revealed.state; + g.ready = []; + log(g, `${p.name} revealed ${DISCOVERY_TOKEN_BY_ID[token.face].name} in ${territory(token.territory!).name}.`); + if (revealed.outcome.kind === 'spice-stash') { + p.spice += revealed.outcome.amount; + log(g, `${p.name} gained 7 spice from the bank. The Spice Stash was removed from the game.`); + } else if (revealed.outcome.kind === 'treachery-card-stash') { + const card = draw(g)!; + p.hand.push(card); + const overflow = p.hand.length > handLimit(p); + g.discoveryStash = {event:crypto.randomUUID(), token:token.id, owner:p.id, turn:g.turn, card:card.id, + hand:p.hand.map(card => card.id), stage:overflow ? 'choose' : 'complete', discarded:null, signature:''}; + g.discoveryStash.signature = discoveryStashSignature(g.discoveryStash); + if (overflow) g.decision = {kind:'discoveryDiscard',player:p.id,event:g.discoveryStash.event}; + log(g, `${p.name} drew one private Treachery card from the stash${overflow ? ' and must now discard any one card from the full hand' : ''}. The stash was removed from the game.`); + } else if (revealed.outcome.kind === 'ornithopter') { + log(g, `${p.name} took the Ornithopter token; it can be spent on a later turn for one movement action.`); + } else log(g, `${DISCOVERY_TOKEN_BY_ID[token.face].name} is now a separate, initially unoccupied location within ${territory(token.territory!).name}.`); +} +function decideDiscoveryDiscard(g: Game, p: Player, action: Action) { + const record = g.discoveryStash; + requireRule(record?.stage === 'choose' && record.owner === p.id && action.event === record.event && + typeof action.card === 'string' && record.hand.includes(action.card) && + Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Choose any card from your original stash hand to discard.'); + const card = discard(g,p,action.card); + record.discarded = card.id; record.stage = 'discard'; record.signature = discoveryStashSignature(record); + stageTreacheryDiscard(g,'discovery:stash',[{card,discardedBy:p.id,publicFace:true}], + {kind:'discoveryStash',event:record.event,owner:p.id,card:card.id}); + log(g, `${p.name} discarded ${card.name} after opening the Treachery Card Stash.`); +} + +function greatMakerRideOptions(g: Game, owner: string) { + const p = getPlayer(g,owner), frame = g.greatMaker; + if (frame?.stage !== 'ride' || p.faction !== 'fremen') return null; + const destinations = gameTerritories(g).flatMap(t => t.sectors.flatMap(sector => { + try { allowedEntry(g,p,t.id,sector); return [{territory:t.id,name:t.name,sector}]; } + catch (error) { if (error instanceof RuleError) return []; throw error; } + })); + return {max:p.reserves,eliteMax:p.elites?.reserves ?? 0,destinations}; +} +function stampGreatMaker(g: Game) { + g.greatMaker!.signature = greatMakerSignature(g.greatMaker!); +} +function greatMakerIntegrity(g: Game) { + const frame = g.greatMaker, continuation = g.pendingTreacheryDiscard?.continuation; + const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, + g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; + const owned = controls.filter(control => control?.decision?.kind === 'greatMakerVote' || control?.decision?.kind === 'greatMakerRide'); + if (!frame) { + requireRule(owned.length === 0, 'The Great Maker has lost its original encounter.'); + return; + } + requireRule(g.discoveryEnabled && g.discoveries, 'The Great Maker requires its physical Discovery module.'); + nexusRule(() => validateGreatMaker(frame, g.turn, g.players)); + if (frame.stage === 'complete') { + requireRule(owned.length === 0, 'The completed Great Maker cannot retain another vote or reserve ride.'); + return; + } + if (frame.arrival) requireRule(validGameLocation(g,frame.arrival.territory,frame.arrival.sector), 'The Great Maker has lost its declared reserve destination.'); + const card = g.spiceDiscard[frame.pile][frame.index]; + requireRule(g.phase === 1 && frame.turn === g.turn && g.spiceSequence?.pile === frame.pile && + card && 'worm' in card && card.greatMaker && !card.suppressed && + frame.order.join('|') === g.order.join('|'), 'The Great Maker has lost its current spice encounter.'); + if (frame.stage === 'vote' || frame.stage === 'ride') { + const kind = frame.stage === 'vote' ? 'greatMakerVote' : 'greatMakerRide'; + const owner = frame.stage === 'vote' ? frame.order[frame.votes.length] : byFaction(g, 'fremen')?.id; + requireRule(owned.length === 1 && owned[0]?.decision?.kind === kind && owned[0].decision.player === owner && owned[0].decision.event === frame.event, + 'The Great Maker has lost its owned vote or reserve ride.'); + } else { + requireRule(owned.length === 0, 'Resolve the current Great Maker interaction before voting or riding.'); + const responses = controls.flatMap(control => { + const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null; + return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; + }); + if (frame.stage === 'worm') requireRule( + controls.some(control => control?.decision?.kind === 'wormProtection' && control.decision.territory === frame.territory && control.decision.player === byFaction(g,'fremen')?.id && control.decision.ally === byFaction(g,'fremen')?.ally) || + responses.some(response => (response?.kind === 'wormSurvival' || response?.kind === 'wormAllyProtection') && response.location === frame.territory && response.owner === byFaction(g,'fremen')?.id && + (!response.recipient || response.recipient === byFaction(g,'fremen')?.ally)), + 'The Great Maker has lost its pending worm survival or protection interaction.'); + if (frame.stage === 'arrival') requireRule( + controls.some(control => control?.decision?.kind === 'intrusion' && control.decision.wormRide && control.decision.player === byFaction(g,'beneGesserit')?.id && control.decision.territory === frame.arrival!.territory) || + responses.some(response => response?.kind === 'advisorFlip' && response.advisorResume === 'wormRide' && response.owner === byFaction(g,'beneGesserit')?.id && response.location === frame.arrival!.territory) || + (g.pendingAmbassador?.resume === 'wormRide' && g.pendingAmbassador.territory === frame.arrival!.territory && g.pendingAmbassador.sector === frame.arrival!.sector) || + (g.pendingTerrorEntry?.resume === 'wormRide' && g.pendingTerrorEntry.territory === frame.arrival!.territory && g.pendingTerrorEntry.sector === frame.arrival!.sector), + 'The Great Maker has lost its reserve arrival interaction.'); + } +} +function openGreatMakerVote(g: Game) { + const frame = g.greatMaker!; + frame.stage = 'vote'; stampGreatMaker(g); + g.decision = {kind:'greatMakerVote', player:frame.order[frame.votes.length], event:frame.event}; +} +function completeGreatMaker(g: Game) { + g.greatMaker!.stage = 'complete'; stampGreatMaker(g); + continueSpice(g); +} +function decideGreatMakerVote(g: Game, p: Player, action: Action) { + const frame = g.greatMaker!; + requireRule(frame?.stage === 'vote' && frame.event === action.event && + frame.order[frame.votes.length] === p.id && typeof action.yes === 'boolean' && + Object.keys(action).every(key => ['type','event','yes'].includes(key)), 'Cast the current Great Maker vote in storm order.'); + frame.votes.push({player:p.id, yes:action.yes}); + log(g, `${p.name} voted ${action.yes ? 'yes' : 'no'} to a Great Maker Nexus.`); + if (frame.votes.length < frame.order.length) { openGreatMakerVote(g); return; } + if (greatMakerMajority(frame)) { + g.nexus = true; markNexusOccurred(g); + log(g, 'A majority voted yes: the Great Maker produces a Nexus.'); + } else log(g, 'The Great Maker vote has no yes majority, so this encounter adds no Nexus.'); + const fremen = byFaction(g, 'fremen'); + if (fremen && fremen.reserves > 0) { + frame.stage = 'ride'; stampGreatMaker(g); + g.decision = {kind:'greatMakerRide', player:fremen.id, event:frame.event}; + } else completeGreatMaker(g); +} +function decideGreatMakerRide(g: Game, p: Player, action: Action) { + const frame = g.greatMaker!; + requireRule(frame?.stage === 'ride' && frame.event === action.event && p.faction === 'fremen' && typeof action.accept === 'boolean', + 'Choose the current Great Maker reserve ride.'); + if (!action.accept) { + requireRule(Object.keys(action).every(key => ['type','event','accept'].includes(key)), 'Declining a ride selects no forces or destination.'); + log(g, `${p.name} left the optional Great Maker riders in reserves.`); + completeGreatMaker(g); return; + } + requireRule(Object.keys(action).every(key => ['type','event','accept','territory','sector','amount','elite'].includes(key)), + 'Choose only a destination and your Great Maker reserve forces.'); + const to = stringField(action.territory), sector = integer(action.sector,0,18,'Sector'); + const amount = integer(action.amount,1,p.reserves,'Riding forces'); + const elite = eliteChoice(amount,p.reserves,p.elites?.reserves ?? 0,action.elite); + allowedEntry(g,p,to,sector); + withdrawNativeReserves(g,p,amount,elite); + place(p,to,sector,amount,elite); + frame.arrival = {territory:to,sector,amount,elite}; + frame.stage = 'arrival'; stampGreatMaker(g); + log(g, `${p.name} rode the Great Maker with ${amount} reserve forces to ${territory(to).name}. This spends no shipment, movement or spice.`); + const intruded = intrusion(g,p,to,{wormRide:true}); + if (openTerritoryEntry(g,p,to,sector,amount,elite,'wormRide','wormRide') || intruded) return; + completeGreatMaker(g); +} function blowSpice(g: Game, thumper = false) { g.nexus = !!g.summonedBeforeBlow; if (!g.summonedBeforeBlow) g.wormRides = []; @@ -5490,6 +5746,16 @@ function continueSpice(g: Game, injected?: SpiceCard) { g, 'Sandtrout suppressed Shai-Hulud. Only an immediate territory replacement receives double spice.', ); + } else if (card.greatMaker) { + g.spiceDiscard[sequence.pile].push(card); + g.greatMaker = {arrival:null,event:crypto.randomUUID(),turn:g.turn,pile:sequence.pile, + index:g.spiceDiscard[sequence.pile].length-1, + territory:previous && 'territory' in previous ? previous.territory : null, + stage:'worm',order:[...g.order],votes:[],ridesBefore:[...g.wormRides],signature:''}; + stampGreatMaker(g); + if (g.greatMaker.territory) beginWorm(g,g.greatMaker.territory); + else openGreatMakerVote(g); + return; } else { g.nexus = true; markNexusOccurred(g); @@ -5510,6 +5776,7 @@ function continueSpice(g: Game, injected?: SpiceCard) { card = drawSpice(g); } if (card && 'territory' in card) { + if (card.discovery) resolveDiscoveryBlow(g, card); g.spiceDiscard[sequence.pile].push(card); const amount = card.amount * (doubleNext ? 2 : 1); g.spiceWindow = { ...card, amount, harvested: false }; @@ -5545,6 +5812,7 @@ function finishSpicePass(g: Game) { } } function nextWormRide(g: Game) { + if (g.greatMaker?.stage === 'arrival') { completeGreatMaker(g); return; } if (g.summonedNexusBeforeRides) { g.summonedNexusBeforeRides = false; g.nexus = true; @@ -7311,7 +7579,7 @@ function allowedEntry( 'The mobile stronghold has not been placed.', ); requireRule( - validLocation(t, s), + validGameLocation(g, t, s), 'Choose a sector belonging to this territory.', ); requireRule( @@ -7337,7 +7605,7 @@ function eliteChoice( 'Elite forces', ); } -function forceGroup(p: Player, action: Action) { +function forceGroup(g: Game, p: Player, action: Action) { let entries: [string, unknown][]; if (action.forces !== undefined) { requireRule( @@ -7371,7 +7639,7 @@ function forceGroup(p: Player, action: Action) { for (const [key, value] of entries) { const src = splitLocation(key); requireRule( - validLocation(src.territory, src.sector), + validGameLocation(g, src.territory, src.sector), 'Invalid source sector.', ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); @@ -7739,7 +8007,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) { 'The movement is no longer available.', ); validateFlightSelection(g, p, move); - const selected = forceGroup(p, { + const selected = forceGroup(g, p, { type: 'move', forces: Object.fromEntries(move.group), eliteForces: move.eliteGroup, @@ -8526,7 +8794,7 @@ function currentGuildAmbassador(g: Game, event?: string) { entrant && entrant.id !== owner.id && entrant.id !== beneficiary.id && - validLocation(entry.territory, entry.sector) && + validGameLocation(g, entry.territory, entry.sector) && territory(entry.territory).type === 'stronghold' && validAmbassadorResume(g, entry) && entry.relocation === undefined && @@ -15734,7 +16002,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) { ['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)), 'Use the offered tariff and explicit physical forces without concealed tokens.'); const destination = stringField(action.destination); - const context = {...homeworldContext(g), storm: g.storm, + const context = {...homeworldContext(g), storm: g.storm, discoveries:g.discoveries, players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})), mobileStronghold: g.mobileStronghold?.location ?? null, board: {[p.id]: {forces: p.forces, eliteForces: p.elites?.forces ?? {}, advisors: p.advisors}}}; @@ -15809,7 +16077,7 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout }; const order = {player: intent.player, destination: intent.destination, sources: intent.sources}; if (intent.route === 'arrakis') { - const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm, + const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm, discoveries:g.discoveries, mobileStronghold: g.mobileStronghold?.location ?? null, board: Object.fromEntries(g.players.map((p) => [p.id, {forces: p.forces, eliteForces: p.elites?.forces ?? {}}])), }, g.homeworlds!.custody!, order)); @@ -16905,7 +17173,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) { !!marker && location(marker.location.territory, marker.location.sector) === key; requireRule( - validLocation(loc.territory, loc.sector) && + validGameLocation(g, loc.territory, loc.sector) && ((other.forces[key] ?? 0) > 0 || concealed), 'Choose an occupied board sector.', ); @@ -17646,6 +17914,8 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + discoveryIntegrity(state); + greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); @@ -17797,6 +18067,8 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileShipmentPromises(g, { actor: id, action }); settleAutomaticContinuations(g); observeOccupation(g); + discoveryIntegrity(g); + greatMakerIntegrity(g); nexusChoamTradeIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); @@ -17892,6 +18164,8 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + discoveryIntegrity(state); + greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); @@ -17920,6 +18194,8 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileShipmentPromises(g); settleAutomaticContinuations(g); observeOccupation(g); + discoveryIntegrity(g); + greatMakerIntegrity(g); nexusChoamTradeIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); @@ -18011,6 +18287,7 @@ function applyActionInner( requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.'); finishNexusTraitorReturn(g, p, action); return g; } + if (t === 'discovery') { playDiscovery(g, p, action); return g; } if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; } if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; } requireRule( @@ -18393,7 +18670,7 @@ function applyActionInner( requireRule( TERRITORIES.some((t) => t.id === to) && to !== MOBILE_STRONGHOLD && - validLocation(to, sector), + validGameLocation(g, to, sector), 'Choose a printed territory and sector.', ); requireRule( @@ -18869,7 +19146,7 @@ function applyActionInner( 'You cannot replace more forces than the winner has remaining.', ); const sector = home ? 0 : integer(action.sector, 0, 18, 'Sector'); - requireRule(home || validLocation(decision.territory, sector), 'Choose a sector in the battle territory.'); + requireRule(home || validGameLocation(g, decision.territory, sector), 'Choose a sector in the battle territory.'); // This is replacement, not shipment or movement: no transport cost or worm/storm transit. for (const source of selected) { if (source.key === 'reserves') { if (!home) p.reserves -= source.count; } @@ -19292,6 +19569,12 @@ function applyActionInner( passed: [], }; } else wormSurvival(g, decision.territory); + } else if (decision.kind === 'discoveryDiscard') { + decideDiscoveryDiscard(g,p,action); + } else if (decision.kind === 'greatMakerVote') { + decideGreatMakerVote(g,p,action); + } else if (decision.kind === 'greatMakerRide') { + decideGreatMakerRide(g,p,action); } else if (decision.kind === 'wormRide') { requireRule( typeof action.accept === 'boolean', @@ -19317,7 +19600,7 @@ function applyActionInner( const source = splitLocation(key); requireRule( source.territory === decision.territory && - validLocation(source.territory, source.sector), + validGameLocation(g, source.territory, source.sector), 'Ride only from the territory where the worm appeared.', ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); @@ -19535,7 +19818,7 @@ function applyActionInner( const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( - to !== MOBILE_STRONGHOLD && validLocation(to, sector), + to !== MOBILE_STRONGHOLD && validGameLocation(g, to, sector), 'Choose a printed board sector; the mobile stronghold is not yet placed.', ); requireRule( @@ -19583,7 +19866,7 @@ function applyActionInner( }; requireRule( FREMEN_START.includes(loc.territory) && - validLocation(loc.territory, loc.sector), + validGameLocation(g, loc.territory, loc.sector), 'Starting forces belong in sectors of Sietch Tabr, False Wall South or False Wall West.', ); return { ...loc, amount: integer(value, 0, 10, 'Starting forces') }; @@ -20452,7 +20735,7 @@ function applyActionInner( requireRule(!blocked, blocked ?? 'Guild Homeworld shipment is unavailable.'); requireRule(action.noField === undefined && (action.sector === undefined || action.sector === 0), 'A Homeworld arrival has no planet sector or concealed token.'); - const group = forceGroup(p, action); + const group = forceGroup(g, p, action); const sources = Object.fromEntries(group.group.map(([key, amount]) => [key, {normal: amount - (group.eliteGroup[key] ?? 0), elite: group.eliteGroup[key] ?? 0}])); declareHomeworldShipment(g, p, {route: 'arrakis', player: id, @@ -20503,7 +20786,7 @@ function applyActionInner( ), total: integer(action.amount, 1, p.reserves, 'Forces'), } - : forceGroup(p, action); + : forceGroup(g, p, action); requireRule( group.every(([key]) => splitLocation(key).sector !== g.storm), 'The source is in storm.', @@ -20662,7 +20945,7 @@ function applyActionInner( total: n, noField, sourceKeys, - } = forceGroup(p, action); + } = forceGroup(g, p, action); const to = stringField(action.territory), s = integer(action.sector, 0, 18, 'Sector'); const advisors = arrivalAsAdvisor(g, p, to, origin); @@ -21234,6 +21517,8 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { + discoveryIntegrity(state); + greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); @@ -21525,6 +21810,8 @@ export function viewGame(state: Game, id: string) { : null, techTokens: g.techTokens ?? null, strongholdCards: g.strongholdCards ?? null, + discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)), + greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null, nexusCards: projectedNexusCards(g, id), nexusChoamTrade: currentNexusChoamTrade(g, id), nexusMoritani: nexusMoritaniOffer(g,id), diff --git a/game/fremen-ambassador.ts b/game/fremen-ambassador.ts index 19367ea2..5f978942 100644 --- a/game/fremen-ambassador.ts +++ b/game/fremen-ambassador.ts @@ -4,7 +4,8 @@ import { location, MOBILE_STRONGHOLD, splitLocation, - validLocation, + TERRITORIES, + validGameLocation, } from './board'; import { arrivalAsAdvisor } from './advisors'; import { presenceAt } from './force-presence'; @@ -62,10 +63,17 @@ function usableLocation(g: Game, key: string) { const at = splitLocation(key); return ( location(at.territory, at.sector) === key && - validLocation(at.territory, at.sector) && + validGameLocation(g, at.territory, at.sector) && (at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location) ); } +function printedLocation(key: string) { + const at = splitLocation(key); + return TERRITORIES.some( + (territory) => + territory.id === at.territory && territory.sectors.includes(at.sector), + ); +} function playerFor(g: Game, id: string) { requireMove( Array.isArray(g.players) && @@ -85,8 +93,7 @@ function playerFor(g: Game, id: string) { requireMove( !g.mobileStronghold?.location || (usableLocation(g, g.mobileStronghold.location) && - splitLocation(g.mobileStronghold.location).territory !== - MOBILE_STRONGHOLD), + printedLocation(g.mobileStronghold.location)), 'The mobile stronghold must point to a board territory.', ); for (const p of g.players) { diff --git a/game/great-maker-options.ts b/game/great-maker-options.ts new file mode 100644 index 00000000..a122c59a --- /dev/null +++ b/game/great-maker-options.ts @@ -0,0 +1,66 @@ +import type { Action, GameView } from './engine'; + +export function greatMakerRideAction( + game: GameView, + territory: string, + sector: number, + amount: number, + elite: number, +): Action | null { + const d = game.decision, + offer = game.greatMaker?.ride; + if ( + d?.kind !== 'greatMakerRide' || + d.player !== game.me || + !offer || + game.truthtrance || + !Number.isSafeInteger(amount) || + amount < 1 || + amount > offer.max || + !Number.isSafeInteger(elite) || + elite < Math.max(0, amount - (offer.max - offer.eliteMax)) || + elite > Math.min(amount, offer.eliteMax) || + !offer.destinations.some( + (to) => to.territory === territory && to.sector === sector, + ) + ) + return null; + return { + type: 'decision', + event: d.event, + accept: true, + territory, + sector, + amount, + elite, + }; +} +export function greatMakerBotActions(game: GameView): Action[] { + const d = game.decision; + if (!d || d.player !== game.me || game.truthtrance) return []; + if (d.kind === 'greatMakerVote') + return [ + { + type: 'decision', + event: d.event, + yes: !game.players.find((p) => p.id === game.me)?.ally, + }, + ]; + if (d.kind !== 'greatMakerRide') return []; + const offer = game.greatMaker?.ride, + to = offer?.destinations.find((to) => to.territory !== 'polar_sink'); + const actions: Action[] = []; + if (offer && to) { + const amount = Math.min(5, offer.max), + elite = Math.min(amount, offer.eliteMax); + const action = greatMakerRideAction( + game, + to.territory, + to.sector, + amount, + elite, + ); + if (action) actions.push(action); + } + return [...actions, { type: 'decision', event: d.event, accept: false }]; +} diff --git a/game/great-maker.ts b/game/great-maker.ts new file mode 100644 index 00000000..85f494d2 --- /dev/null +++ b/game/great-maker.ts @@ -0,0 +1,97 @@ +export type GreatMaker = { + arrival: { + territory: string; + sector: number; + amount: number; + elite: number; + } | null; + event: string; + turn: number; + pile: 0 | 1; + index: number; + territory: string | null; + stage: 'worm' | 'vote' | 'ride' | 'arrival' | 'complete'; + order: string[]; + votes: { player: string; yes: boolean }[]; + ridesBefore: string[]; + signature: string; +}; +export function greatMakerSignature(frame: GreatMaker): string { + return JSON.stringify([ + frame.event, + frame.turn, + frame.pile, + frame.index, + frame.territory, + frame.stage, + frame.order, + frame.votes, + frame.ridesBefore, + frame.arrival, + ]); +} +export function validateGreatMaker( + frame: GreatMaker, + turn: number, + players: readonly { id: string }[], +) { + const ids = players.map((player) => player.id); + if ( + !frame || + Object.keys(frame).sort().join(',') !== + 'arrival,event,index,order,pile,ridesBefore,signature,stage,territory,turn,votes' || + typeof frame.event !== 'string' || + !frame.event || + !Number.isSafeInteger(frame.turn) || + frame.turn < 2 || + frame.turn > turn || + ![0, 1].includes(frame.pile) || + !Number.isSafeInteger(frame.index) || + frame.index < 0 || + !['worm', 'vote', 'ride', 'arrival', 'complete'].includes(frame.stage) || + !Array.isArray(frame.order) || + frame.order.length !== ids.length || + new Set(frame.order).size !== ids.length || + frame.order.some((id) => !ids.includes(id)) || + !Array.isArray(frame.votes) || + frame.votes.length > ids.length || + frame.votes.some( + (vote, index) => + !vote || + Object.keys(vote).sort().join(',') !== 'player,yes' || + vote.player !== frame.order[index] || + typeof vote.yes !== 'boolean', + ) || + !Array.isArray(frame.ridesBefore) || + frame.ridesBefore.some((id) => typeof id !== 'string') || + (frame.territory !== null && typeof frame.territory !== 'string') || + (frame.stage === 'arrival' && !frame.arrival) || + (!['arrival', 'complete'].includes(frame.stage) && + frame.arrival !== null) || + (frame.arrival !== null && + (!frame.arrival || + Object.keys(frame.arrival).sort().join(',') !== + 'amount,elite,sector,territory' || + typeof frame.arrival.territory !== 'string' || + !Number.isSafeInteger(frame.arrival.sector) || + frame.arrival.sector < 0 || + frame.arrival.sector > 18 || + !Number.isSafeInteger(frame.arrival.amount) || + frame.arrival.amount < 1 || + frame.arrival.amount > 20 || + !Number.isSafeInteger(frame.arrival.elite) || + frame.arrival.elite < 0 || + frame.arrival.elite > frame.arrival.amount)) || + (frame.stage === 'worm' && frame.votes.length !== 0) || + (frame.stage === 'vote' && frame.votes.length >= ids.length) || + (['ride', 'arrival', 'complete'].includes(frame.stage) && + frame.votes.length !== ids.length) || + frame.signature !== greatMakerSignature(frame) + ) + throw new Error( + 'The Great Maker has lost its original encounter or ordered vote.', + ); +} +export function greatMakerMajority(frame: GreatMaker): boolean { + return frame.votes.filter((vote) => vote.yes).length > frame.order.length / 2; +} diff --git a/game/guild-ambassador.ts b/game/guild-ambassador.ts index d81fb76e..80dc99d9 100644 --- a/game/guild-ambassador.ts +++ b/game/guild-ambassador.ts @@ -5,7 +5,8 @@ import { location, MOBILE_STRONGHOLD, splitLocation, - validLocation, + TERRITORIES, + validGameLocation, } from './board'; import { presenceAt } from './force-presence'; import { territoryEntryBlock } from './occupancy'; @@ -45,10 +46,17 @@ function usableLocation(g: Game, key: string) { const at = splitLocation(key); return ( location(at.territory, at.sector) === key && - validLocation(at.territory, at.sector) && + validGameLocation(g, at.territory, at.sector) && (at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location) ); } +function printedLocation(key: string) { + const at = splitLocation(key); + return TERRITORIES.some( + (territory) => + territory.id === at.territory && territory.sectors.includes(at.sector), + ); +} /** Only public board custody and the beneficiary's physical reserves are read. * The caller binds the Ambassador, event, actor and continuation timing. */ function beneficiary(g: Game, id: string) { @@ -65,8 +73,7 @@ function beneficiary(g: Game, id: string) { requireShipment( !g.mobileStronghold?.location || (usableLocation(g, g.mobileStronghold.location) && - splitLocation(g.mobileStronghold.location).territory !== - MOBILE_STRONGHOLD), + printedLocation(g.mobileStronghold.location)), 'The mobile stronghold must point to a board location.', ); for (const p of g.players) { @@ -285,7 +292,7 @@ export function quoteGuildAmbassadorAdvisor( typeof shipment.advisors === 'boolean' && typeof shipment.territory === 'string' && count(shipment.sector) && - validLocation(shipment.territory, shipment.sector) && + validGameLocation(g, shipment.territory, shipment.sector) && !Object.hasOwn(shipment, 'noField') && !Object.hasOwn(shipment, 'alliedNoField'), 'BG accompaniment needs the completed physical Guild Ambassador shipment by another off-planet faction.', diff --git a/game/guild-homeworld-shipment-options.ts b/game/guild-homeworld-shipment-options.ts index 402d5fd4..d1cf25a3 100644 --- a/game/guild-homeworld-shipment-options.ts +++ b/game/guild-homeworld-shipment-options.ts @@ -70,6 +70,7 @@ export function guildHomeworldShipmentChoice( advanced: view.advanced, storm: view.storm, mobileStronghold: view.mobileStronghold?.location ?? null, + discoveries: view.discoveries, players: view.players.map((player) => ({ id: player.id, faction: player.faction, diff --git a/game/guild-homeworld-shipment.ts b/game/guild-homeworld-shipment.ts index 17eb2252..4de5e096 100644 --- a/game/guild-homeworld-shipment.ts +++ b/game/guild-homeworld-shipment.ts @@ -1,4 +1,10 @@ -import { MOBILE_LOCATION, splitLocation, validLocation } from './board'; +import { + MOBILE_LOCATION, + splitLocation, + TERRITORIES, + validGameLocation, + type MobileBoard, +} from './board'; import { HomeworldCustodyError, homeworldForceGroups, @@ -16,6 +22,7 @@ export type GuildHomeworldShipmentContext = HomeworldShipmentContext & { >; /** The mobile stronghold's ordinary Arrakis pointer, not its interior. */ mobileStronghold?: string | null; + discoveries?: MobileBoard['discoveries']; }; export type GuildHomeworldShipmentIntent = { player: string; @@ -32,9 +39,21 @@ function requireShipment( ): asserts condition { if (!condition) throw new HomeworldCustodyError(message); } -function canonicalSector(key: string) { +function canonicalSector(context: GuildHomeworldShipmentContext, key: string) { const { territory, sector } = splitLocation(key); - return key === `${territory}:${sector}` && validLocation(territory, sector); + return ( + key === `${territory}:${sector}` && + validGameLocation({ discoveries: context.discoveries }, territory, sector) + ); +} +function printedSector(key: string) { + const { territory: id, sector } = splitLocation(key); + return ( + key === `${id}:${sector}` && + TERRITORIES.some( + (territory) => territory.id === id && territory.sectors.includes(sector), + ) + ); } /** Guild's own special shipment from one Arrakis territory to a Homeworld. @@ -92,14 +111,12 @@ export function quoteGuildHomeworldShipment( requireShipment( pointer === undefined || pointer === null || - (typeof pointer === 'string' && - canonicalSector(pointer) && - pointer !== MOBILE_LOCATION), + (typeof pointer === 'string' && printedSector(pointer)), 'The mobile stronghold needs a canonical Arrakis pointer.', ); for (const [key, amount] of Object.entries(board.forces)) requireShipment( - canonicalSector(key) && + canonicalSector(context, key) && whole(amount) && amount <= 20 && (key !== MOBILE_LOCATION || !!pointer), @@ -107,7 +124,7 @@ export function quoteGuildHomeworldShipment( ); for (const [key, amount] of Object.entries(board.eliteForces)) requireShipment( - canonicalSector(key) && whole(amount) && amount === 0, + canonicalSector(context, key) && whole(amount) && amount === 0, 'Guild counters cannot acquire special force identities.', ); const boardTotal = Object.values(board.forces).reduce((sum, n) => sum + n, 0); @@ -138,7 +155,7 @@ export function quoteGuildHomeworldShipment( }[] = []; for (const [key, selected] of entries) { requireShipment( - canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer), + canonicalSector(context, key) && (key !== MOBILE_LOCATION || !!pointer), 'Choose a canonical, active Arrakis source sector.', ); const source = splitLocation(key); diff --git a/game/junction-transport-options.ts b/game/junction-transport-options.ts index 732969e2..8eec2514 100644 --- a/game/junction-transport-options.ts +++ b/game/junction-transport-options.ts @@ -176,6 +176,7 @@ export function junctionTransportChoice( advanced: view.advanced, storm: view.storm, mobileStronghold: view.mobileStronghold?.location ?? null, + discoveries: view.discoveries, players: view.players.map((p) => ({ id: p.id, faction: p.faction, diff --git a/game/junction-transport.ts b/game/junction-transport.ts index 363de11c..50502b7a 100644 --- a/game/junction-transport.ts +++ b/game/junction-transport.ts @@ -1,8 +1,10 @@ import { MOBILE_LOCATION, splitLocation, + TERRITORIES, territory, - validLocation, + validGameLocation, + type MobileBoard, } from './board'; import { HomeworldCustodyError, @@ -28,6 +30,7 @@ export type JunctionTransportContext = HomeworldShipmentContext & { >; /** Ordinary Arrakis pointer, never the mobile interior itself. */ mobileStronghold?: string | null; + discoveries?: MobileBoard['discoveries']; }; export type JunctionTransportIntent = { player: string; @@ -51,9 +54,21 @@ function requireTransport( ): asserts condition { if (!condition) throw new HomeworldCustodyError(message); } -function canonicalSector(key: string) { +function canonicalSector(context: JunctionTransportContext, key: string) { const { territory: id, sector } = splitLocation(key); - return key === `${id}:${sector}` && validLocation(id, sector); + return ( + key === `${id}:${sector}` && + validGameLocation({ discoveries: context.discoveries }, id, sector) + ); +} +function printedSector(key: string) { + const { territory: id, sector } = splitLocation(key); + return ( + key === `${id}:${sector}` && + TERRITORIES.some( + (territory) => territory.id === id && territory.sectors.includes(sector), + ) + ); } const eliteLimit = (faction: string) => faction === 'emperor' @@ -133,15 +148,13 @@ export function quoteJunctionTransport( requireTransport( pointer === undefined || pointer === null || - (typeof pointer === 'string' && - canonicalSector(pointer) && - pointer !== MOBILE_LOCATION), + (typeof pointer === 'string' && printedSector(pointer)), 'The mobile stronghold needs a canonical Arrakis pointer.', ); const destinationWorld = worlds.find((w) => w.id === intent.destination); requireTransport( destinationWorld || - (canonicalSector(intent.destination) && + (canonicalSector(context, intent.destination) && (intent.destination !== MOBILE_LOCATION || !!pointer)), 'Choose an active Homeworld or canonical Arrakis destination.', ); @@ -175,7 +188,7 @@ export function quoteJunctionTransport( let boardElite = 0; for (const [key, count] of Object.entries(board.forces)) { requireTransport( - canonicalSector(key) && + canonicalSector(context, key) && whole(count) && count <= 20 && (key !== MOBILE_LOCATION || !!pointer), @@ -185,7 +198,7 @@ export function quoteJunctionTransport( } for (const [key, count] of Object.entries(board.eliteForces)) { requireTransport( - canonicalSector(key) && + canonicalSector(context, key) && whole(count) && count <= (Object.hasOwn(board.forces, key) ? board.forces[key] : 0), 'Special counters must be a subset of the physical force group.', @@ -260,7 +273,7 @@ export function quoteJunctionTransport( origin ??= key; } else { requireTransport( - canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer), + canonicalSector(context, key) && (key !== MOBILE_LOCATION || !!pointer), 'Choose a canonical, active Arrakis source sector.', ); const source = splitLocation(key); diff --git a/game/karama-no-field-cancellation.ts b/game/karama-no-field-cancellation.ts index dc8f2d68..0cf4f45b 100644 --- a/game/karama-no-field-cancellation.ts +++ b/game/karama-no-field-cancellation.ts @@ -1,5 +1,5 @@ import type { Game, Player, ResponseWindow } from './engine'; -import { MOBILE_STRONGHOLD, validLocation } from './board'; +import { MOBILE_STRONGHOLD, validGameLocation } from './board'; import { validateRicheseNoField } from './richese-no-field'; export class NoFieldCancellationError extends Error {} @@ -13,6 +13,7 @@ export type NoFieldCancellationContext = Pick< | 'richeseAllyOffer' | 'richeseAllyOpportunity' | 'richeseAllyBlocked' + | 'discoveries' > & { players: readonly Pick< Player, @@ -75,7 +76,7 @@ export function quoteNoFieldCancellation( ); requireDeclaration( shipment.territory !== MOBILE_STRONGHOLD && - validLocation(shipment.territory, shipment.sector) && + validGameLocation(g, shipment.territory, shipment.sector) && whole(shipment.amount) && shipment.amount <= 5 && whole(shipment.elite) && diff --git a/game/reference.ts b/game/reference.ts index da543d5a..88e35682 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -214,6 +214,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'automatic-decisions', 'ai-pacing', 'ecaz-modules', + 'discoveries', 'homeworlds', 'advanced-combat', ], @@ -3406,6 +3407,54 @@ export const RULE_TOPICS: RuleTopic[] = [ }, ], }, + { + id: 'discoveries', + title: 'Discoveries: first playable functions', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards and revealed locations. Remaining token abilities and normal public starts stay unavailable.', + searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', + steps: [ + 'The Discovery prototype starts through genuine base-faction setup in Basic or Advanced, with seven extra Spice Cards and eight physical tokens. Its normal public start gate stays closed while the remaining effects are connected and verified.', + 'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.', + 'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.', + 'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.', + 'Spice Stash grants seven bank spice and leaves play. Treachery Card Stash draws one private Treachery Card and leaves play; if the new hand is too large, choose any held card to discard, including the newly drawn card. The owned discard panel includes a card inspector.', + 'Revealing Ornithopter records who carries it and the turn it was gained. Its later-turn movement action remains in development; acquiring the token does not grant an immediate extra move.', + 'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.', + 'Next-turn free entry and all five special location benefits remain missing: Jacurutu battle income, Cistern income, Ecological Testing Station storm adjustment, Shrine card conversion and Orgiz collection transfer. Full token explanations describe these printed effects, but their action controls are not yet available.', + 'All four AI profiles use the same offered inspection, reveal, stash-discard and Great Maker choices as the player controls. Private knowledge and unfinished decisions survive saved JSON continuations. These focused paths do not establish complete variant compliance or combined-module acceptance.', + ], + related: ['ecaz-modules', 'spice-blow', 'collection', 'movement', 'mentat', 'implementation-checklist'], + checklist: [ + { + area: 'Implementation', status: 'Partial', + detail: 'First functions are prototyped: genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, carried custody and public nested locations. Free entry, special location benefits and Ornithopter spending remain missing.', + evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'], + }, + { + area: 'Player controls', status: 'Partial', + detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard with card inspection, and ordered Great Maker vote/typed reserve-ride controls. Remaining effects have no action controls.', + evidence: ['components/discoveries.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx'], + }, + { + area: 'AI', status: 'Partial', + detail: 'Four profiles share the private quoted token, discard, vote and reserve-ride actions. Complete Discovery strategy, combined games and strength calibration remain unfinished.', + evidence: ['game/discovery-options.ts', 'game/great-maker-options.ts', 'game/bots.ts'], + }, + { + area: 'Documentation', status: 'Partial', + detail: 'Sourced identities, connected behavior and the remaining dependency queue are recorded. Great Maker first-turn/Sandtrout handling and exhausted token supply remain identified interpretations for refinement.', + evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'], + }, + { + area: 'Verification', status: 'Partial', + detail: 'Focused offline checks cover physical custody, private views, legal human/AI actions, immutable rejection, board admission and saved JSON continuation. Broad checkpoint results and complete module/browser/multiplayer acceptance remain separate gates.', + evidence: ['tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts'], + }, + ], + }, { id: 'nexus-choam-trade', title: 'CHOAM Nexus: Collection trade', @@ -3514,6 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ...(id === 'ecaz-modules' ? [ 'Ecaz inventory, public Ambassador inspection, end-of-Revival placement, six entry effects with Bene Gesserit copies and direct Ecaz Duke acquisition have focused support. Storm/explosion token returns are supported. Other effects and competing arrival ordering remain unfinished. See the linked feature checklist; full expansion starts remain disabled.', + 'The first Discovery prototype connects seven Spice Cards, eight tokens, Great Maker, private Collection inspection/reveal, stash rewards and revealed nested locations. Free entry, special location benefits and Ornithopter spending remain missing; see the Discovery checklist.', ] : []), 'The expansion catalog is present. Expansion faction mechanics, remaining modules and their full interaction reference remain in development.', @@ -3530,6 +3580,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'moritani-terror', 'ecaz-ambassadors', 'homeworlds', + 'discoveries', 'implementation-checklist', ] : []), diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index 9c372047..07234a65 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -60,6 +60,9 @@ const DECISIONS = { stormLosses: true, wormPlacement: true, wormProtection: true, + discoveryDiscard: true, + greatMakerVote: true, + greatMakerRide: true, wormRide: true, moritaniRetention: true, battleCards: true, diff --git a/game/transport-quote.ts b/game/transport-quote.ts index aa3bdc25..06774394 100644 --- a/game/transport-quote.ts +++ b/game/transport-quote.ts @@ -1,7 +1,7 @@ import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options'; import { nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options'; import type { Action, GameView } from './engine'; -import { splitLocation, validLocation } from './board'; +import { splitLocation, validGameLocation } from './board'; import { botEntryAllowed, guildTransportCost } from './bot-mobility'; import { shipmentPaymentBounds } from './shipment-price'; @@ -86,7 +86,7 @@ export function guildTransportQuote(g: GameView, action: Action) { : []; for (const [key, count] of entries) { const source = splitLocation(key); - if (!validLocation(source.territory, source.sector)) { + if (!validGameLocation(g, source.territory, source.sector)) { validForces = false; continue; } @@ -123,7 +123,7 @@ export function guildTransportQuote(g: GameView, action: Action) { Number.isInteger(sector) && sector >= 0 && sector <= 18 && - (to === 'reserves' || validLocation(to, sector)); + (to === 'reserves' || validGameLocation(g, to, sector)); if (to === 'reserves' && p.faction !== 'guild' && !nexusAllowed) unavailableReasons.push('Only the Guild may return forces to reserves.'); if (to === 'reserves' && nexus && g.homeworlds?.worlds?.length) diff --git a/game/victory-progress.ts b/game/victory-progress.ts index 2e4b9c85..6fb38af2 100644 --- a/game/victory-progress.ts +++ b/game/victory-progress.ts @@ -8,6 +8,7 @@ import { ecazOccupancyIdentity } from './ecaz-occupy'; import { gameTerritories } from './board'; import { fighterCount } from './advisors'; import { TECH_TOKENS } from './tech-tokens'; +import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries'; export type StrongholdProgress = { player: string; @@ -34,6 +35,12 @@ function requireProgress( if (!condition) throw new VictoryProgressError(message); } +/** Discovery locations use stronghold shipping and occupancy rules, but only + * Jacurutu Sietch counts toward the normal stronghold victory. */ +export function countsForStrongholdVictory(id: string) { + return !isDiscoveryLocationId(id) || id === JACURUTU_SIETCH; +} + /** Public prospective stronghold victory only. No prediction, special endgame * victory, combat resolution, resource mutation or Stronghold Card ownership is * considered. Mandatory advisor releases are returned for the caller to commit. */ @@ -81,7 +88,9 @@ export function strongholdProgress( 'Victory progress requires the complete technology token ownership map.', ); } - const sites = boardTerritories.filter((t) => t.type === 'stronghold'); + const sites = boardTerritories.filter( + (t) => t.type === 'stronghold' && countsForStrongholdVictory(t.id), + ); const progress = g.order.map((id) => { const p = players.find((p) => p.id === id)!; const members = g.order.filter( diff --git a/game/victory-quote.ts b/game/victory-quote.ts index 025a7d61..50dfbc4a 100644 --- a/game/victory-quote.ts +++ b/game/victory-quote.ts @@ -11,7 +11,11 @@ import { type AdvisorRelease, } from './board-resolution-quote'; import { TECH_TOKENS, techStronghold } from './tech-tokens'; -import { strongholdProgress, VictoryProgressError } from './victory-progress'; +import { + countsForStrongholdVictory, + strongholdProgress, + VictoryProgressError, +} from './victory-progress'; import { settleStrongholdCards, strongholdControllers, @@ -146,6 +150,7 @@ export function quoteVictory(g: VictoryContext): VictoryQuote { gameTerritories(g).filter( (t) => t.type === 'stronghold' && + countsForStrongholdVictory(t.id) && ids.some((id) => fighterCount(player(id), t.id) > 0) && players.every((p) => ids.includes(p.id) || fighterCount(p, t.id) === 0), ).length; diff --git a/tests/discoveries.test.ts b/tests/discoveries.test.ts new file mode 100644 index 00000000..bc057292 --- /dev/null +++ b/tests/discoveries.test.ts @@ -0,0 +1,462 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DISCOVERY_CARD_PLACEMENTS, + DISCOVERY_LOCATION_BY_ID, + DISCOVERY_LOCATION_IDS, + DISCOVERY_LOCATIONS, + DISCOVERY_SPICE_CARDS, + DISCOVERY_TOKEN_BY_ID, + DISCOVERY_TOKENS, + JACURUTU_SIETCH, + clearNewlyRevealed, + consumeOrnithopter, + createDiscoveryState, + isDiscoveryLocationId, + isDiscoveryTokenId, + placeDiscovery, + projectDiscoveryState, + rememberDiscoveryFace, + revealDiscoveryToken, + validateDiscoveryState, + type DiscoveryState, + type DiscoveryTokenFace, +} from '../game/discoveries'; + +const ordered = () => createDiscoveryState(() => 0.999); +const reload = (state: DiscoveryState) => + JSON.parse(JSON.stringify(state)) as DiscoveryState; +const tokenWithFace = (state: DiscoveryState, face: DiscoveryTokenFace) => + state.tokens.find((token) => token.face === face)!; + +void test('printed cards and immutable token metadata preserve the sourced inventory and placements', () => { + assert.deepEqual(DISCOVERY_SPICE_CARDS, [ + { + territory: 'hagga_basin', + amount: 6, + sector: 12, + discovery: 'discovery-hagga-basin', + }, + { + territory: 'rock_outcroppings', + amount: 6, + sector: 13, + discovery: 'discovery-rock-outcroppings', + }, + { + territory: 'sihaya_ridge', + amount: 6, + sector: 9, + discovery: 'discovery-sihaya-ridge', + }, + { + territory: 'wind_pass_north', + amount: 6, + sector: 17, + discovery: 'discovery-wind-pass-north', + }, + { + territory: 'funeral_plain', + amount: 6, + sector: 15, + discovery: 'discovery-funeral-plain', + }, + { + territory: 'oh_gap', + amount: 6, + sector: 10, + discovery: 'discovery-old-gap', + }, + ]); + assert.deepEqual(DISCOVERY_CARD_PLACEMENTS, { + 'discovery-hagga-basin': { + type: 'hiereg', + territory: 'gara_kulon', + sector: 8, + }, + 'discovery-rock-outcroppings': { + type: 'hiereg', + territory: 'meridian', + sector: 1, + }, + 'discovery-sihaya-ridge': { + type: 'hiereg', + territory: 'cielago_east', + sector: 3, + }, + 'discovery-wind-pass-north': { + type: 'smuggler', + territory: 'plastic_basin', + sector: 13, + }, + 'discovery-funeral-plain': { + type: 'smuggler', + territory: 'pasty_mesa', + sector: 7, + }, + 'discovery-old-gap': { + type: 'smuggler', + territory: 'false_wall_west', + sector: 17, + }, + }); + assert.deepEqual( + DISCOVERY_TOKENS.map(({ id, type, kind }) => ({ id, type, kind })), + [ + { id: 'jacurutu-sietch', type: 'hiereg', kind: 'location' }, + { id: 'cistern', type: 'hiereg', kind: 'location' }, + { + id: 'ecological-testing-station', + type: 'hiereg', + kind: 'location', + }, + { id: 'shrine', type: 'hiereg', kind: 'location' }, + { + id: 'orgiz-processing-station', + type: 'smuggler', + kind: 'location', + }, + { id: 'treachery-card-stash', type: 'smuggler', kind: 'instant' }, + { id: 'spice-stash', type: 'smuggler', kind: 'instant' }, + { id: 'ornithopter', type: 'smuggler', kind: 'carried' }, + ], + ); + assert.equal(JACURUTU_SIETCH, 'jacurutu-sietch'); + assert.deepEqual(DISCOVERY_LOCATION_IDS, [ + 'jacurutu-sietch', + 'cistern', + 'ecological-testing-station', + 'shrine', + 'orgiz-processing-station', + ]); + assert.equal(DISCOVERY_LOCATION_BY_ID[JACURUTU_SIETCH].stronghold, true); + assert.equal(DISCOVERY_TOKEN_BY_ID.ornithopter.location, null); + assert.equal(isDiscoveryLocationId('cistern'), true); + assert.equal(isDiscoveryLocationId('ornithopter'), false); + assert.equal(isDiscoveryTokenId('discovery-token-8'), true); + assert.equal(isDiscoveryTokenId('discovery-token-9'), false); + assert.ok(Object.isFrozen(DISCOVERY_SPICE_CARDS)); + assert.ok(DISCOVERY_SPICE_CARDS.every(Object.isFrozen)); + assert.ok(Object.isFrozen(DISCOVERY_TOKENS)); + assert.ok(DISCOVERY_TOKENS.every(Object.isFrozen)); + assert.ok(Object.isFrozen(DISCOVERY_LOCATIONS)); + assert.ok(DISCOVERY_LOCATIONS.every(Object.isFrozen)); +}); + +void test('setup shuffles eight secret faces behind stable opaque physical IDs and survives JSON', () => { + let calls = 0; + const state = createDiscoveryState(() => { + calls++; + return 0.999; + }); + assert.equal(calls, 7); + assert.deepEqual( + state.tokens.map((token) => token.id), + Array.from({ length: 8 }, (_, index) => `discovery-token-${index + 1}`), + ); + assert.deepEqual( + state.tokens.map((token) => token.face), + DISCOVERY_TOKENS.map((token) => token.id), + ); + assert.ok( + state.tokens.every( + (token) => + token.status === 'supply' && + token.territory === null && + token.sector === null && + token.revealedTurn === null && + token.owner === null && + token.known.length === 0, + ), + ); + assert.notDeepEqual( + createDiscoveryState(() => 0).tokens.map((token) => token.face), + state.tokens.map((token) => token.face), + ); + validateDiscoveryState(reload(state)); + for (const draw of [-1, 1, NaN, Infinity]) + assert.throws(() => createDiscoveryState(() => draw), /random draws/); +}); + +void test('placement draws only the required type and uses the printed token destination without mutation', () => { + const initial = ordered(); + const before = reload(initial); + const hiereg = placeDiscovery(initial, 'discovery-rock-outcroppings', () => 0); + const placedHiereg = hiereg.tokens.find((token) => token.status === 'placed')!; + assert.deepEqual( + { + id: placedHiereg.id, + type: placedHiereg.type, + face: placedHiereg.face, + territory: placedHiereg.territory, + sector: placedHiereg.sector, + }, + { + id: 'discovery-token-1', + type: 'hiereg', + face: 'jacurutu-sietch', + territory: 'meridian', + sector: 1, + }, + ); + const smuggler = placeDiscovery(initial, 'discovery-funeral-plain', () => 0); + const placedSmuggler = smuggler.tokens.find( + (token) => token.status === 'placed', + )!; + assert.deepEqual( + [ + placedSmuggler.id, + placedSmuggler.type, + placedSmuggler.face, + placedSmuggler.territory, + placedSmuggler.sector, + ], + ['discovery-token-5', 'smuggler', 'orgiz-processing-station', 'pasty_mesa', 7], + ); + assert.deepEqual(initial, before); + assert.notEqual(hiereg.tokens, initial.tokens); + assert.notEqual(hiereg.tokens[0].known, initial.tokens[0].known); +}); + +void test('unavailable types and bad randomness reject before consuming custody', () => { + let state = ordered(); + for (const card of [ + 'discovery-hagga-basin', + 'discovery-rock-outcroppings', + 'discovery-sihaya-ridge', + 'discovery-hagga-basin', + ] as const) + state = placeDiscovery(state, card, () => 0); + const before = reload(state); + let calls = 0; + assert.throws( + () => + placeDiscovery(state, 'discovery-hagga-basin', () => { + calls++; + return 0; + }), + /No hiereg/, + ); + assert.equal(calls, 0); + assert.deepEqual(state, before); + const initial = ordered(); + for (const draw of [-0.1, 1, NaN, Infinity]) { + assert.throws( + () => placeDiscovery(initial, 'discovery-hagga-basin', () => draw), + /random draws/, + ); + assert.deepEqual(initial, ordered()); + } +}); + +void test('private projection hides supply order and hidden faces except for eligible or informed factions', () => { + let state = placeDiscovery( + ordered(), + 'discovery-rock-outcroppings', + () => 0, + ); + state = placeDiscovery(state, 'discovery-funeral-plain', () => 0); + const hiereg = state.tokens.find( + (token) => token.status === 'placed' && token.type === 'hiereg', + )!; + const smuggler = state.tokens.find( + (token) => token.status === 'placed' && token.type === 'smuggler', + )!; + const publicView = projectDiscoveryState(state); + assert.equal(publicView.supplyCount, 6); + assert.deepEqual( + publicView.tokens.map((token) => token.face), + [null, null], + ); + assert.equal('known' in publicView.tokens[0], false); + assert.equal(projectDiscoveryState(state, 'fremen').tokens[0].face, hiereg.face); + assert.equal(projectDiscoveryState(state, 'fremen').tokens[1].face, null); + assert.equal(projectDiscoveryState(state, 'guild').tokens[0].face, null); + assert.equal( + projectDiscoveryState(state, 'guild').tokens[1].face, + smuggler.face, + ); + const informed = rememberDiscoveryFace(state, hiereg.id, 'atreides'); + assert.equal( + projectDiscoveryState(informed, 'atreides').tokens[0].face, + hiereg.face, + ); + assert.equal(projectDiscoveryState(informed, 'harkonnen').tokens[0].face, null); + const reordered = reload(state); + reordered.tokens.reverse(); + assert.deepEqual( + projectDiscoveryState(reordered).tokens + .map((token) => ({ ...token, id: 'opaque' })) + .sort((a, b) => String(a.type).localeCompare(String(b.type))), + publicView.tokens + .map((token) => ({ ...token, id: 'opaque' })) + .sort((a, b) => String(a.type).localeCompare(String(b.type))), + ); +}); + +void test('location reveal remains on the board, becomes public, and tracks one migration window', () => { + const placed = placeDiscovery( + ordered(), + 'discovery-rock-outcroppings', + () => 0, + ); + const token = placed.tokens.find((candidate) => candidate.status === 'placed')!; + const before = reload(placed); + const revealed = revealDiscoveryToken(placed, token.id, 4); + assert.deepEqual(revealed.outcome, { + kind: 'location', + location: 'jacurutu-sietch', + }); + const face = revealed.state.tokens.find((candidate) => candidate.id === token.id)!; + assert.equal(face.status, 'placed'); + assert.equal(face.territory, 'meridian'); + assert.equal(face.sector, 1); + assert.equal(face.revealedTurn, 4); + assert.deepEqual(revealed.state.newlyRevealed, [token.id]); + assert.equal(projectDiscoveryState(revealed.state).tokens[0].face, token.face); + assert.deepEqual(placed, before); + assert.throws( + () => revealDiscoveryToken(revealed.state, token.id, 4), + /hidden placed/, + ); + const cleared = clearNewlyRevealed(revealed.state); + assert.deepEqual(cleared.newlyRevealed, []); + assert.equal(cleared.tokens.find((candidate) => candidate.id === token.id)!.revealedTurn, 4); + validateDiscoveryState(reload(cleared)); +}); + +void test('stash outcomes remove one face while Ornithopter transfers and consumes exact custody', () => { + const cases = [ + { + face: 'treachery-card-stash' as const, + draw: 0.3, + outcome: { kind: 'treachery-card-stash' }, + }, + { + face: 'spice-stash' as const, + draw: 0.6, + outcome: { kind: 'spice-stash', amount: 7 }, + }, + ]; + for (const entry of cases) { + const placed = placeDiscovery( + ordered(), + 'discovery-funeral-plain', + () => entry.draw, + ); + const token = tokenWithFace(placed, entry.face); + const result = revealDiscoveryToken(placed, token.id, 6); + assert.deepEqual(result.outcome, entry.outcome); + const removed = tokenWithFace(result.state, entry.face); + assert.equal(removed.status, 'removed'); + assert.equal(removed.territory, null); + assert.equal(removed.sector, null); + assert.equal(removed.revealedTurn, 6); + assert.equal(removed.acquiredTurn, 6); + validateDiscoveryState(reload(result.state)); + } + + const placed = placeDiscovery( + ordered(), + 'discovery-funeral-plain', + () => 0.999, + ); + const ornithopter = tokenWithFace(placed, 'ornithopter'); + const before = reload(placed); + assert.throws( + () => revealDiscoveryToken(placed, ornithopter.id, 7), + /receiving player/, + ); + assert.deepEqual(placed, before); + const carried = revealDiscoveryToken(placed, ornithopter.id, 7, 'player-a'); + assert.deepEqual(carried.outcome, { + kind: 'ornithopter', + owner: 'player-a', + }); + assert.deepEqual( + { + status: tokenWithFace(carried.state, 'ornithopter').status, + owner: tokenWithFace(carried.state, 'ornithopter').owner, + acquiredTurn: tokenWithFace(carried.state, 'ornithopter').acquiredTurn, + }, + { status: 'carried', owner: 'player-a', acquiredTurn: 7 }, + ); + const consumed = consumeOrnithopter(carried.state, 'player-a'); + assert.equal(tokenWithFace(consumed, 'ornithopter').status, 'removed'); + assert.equal(tokenWithFace(consumed, 'ornithopter').owner, null); + assert.throws( + () => consumeOrnithopter(consumed, 'player-a'), + /carry exactly one Ornithopter/, + ); + validateDiscoveryState(reload(consumed)); +}); + +void test('malformed census, private knowledge, placement and migration state reject unchanged', () => { + const mutations: ((state: DiscoveryState) => void)[] = [ + (state) => { + state.tokens.pop(); + }, + (state) => { + state.tokens[1].id = state.tokens[0].id; + }, + (state) => { + state.tokens[1].face = state.tokens[0].face; + }, + (state) => { + state.tokens[0].type = 'smuggler'; + }, + (state) => { + state.tokens[0].known = ['unknown' as never]; + }, + (state) => { + state.tokens[0].status = 'placed'; + }, + (state) => { + state.tokens[0].owner = 'player-a'; + }, + (state) => { + state.newlyRevealed.push('discovery-token-1'); + }, + (state) => { + Object.assign(state.tokens[0], { leakedFace: 'secret' }); + }, + (state) => { + state.version = 2 as 1; + }, + ]; + for (const mutate of mutations) { + const state = ordered(); + mutate(state); + const before = JSON.stringify(state); + assert.throws(() => validateDiscoveryState(state)); + let calls = 0; + assert.throws(() => + placeDiscovery(state, 'discovery-hagga-basin', () => { + calls++; + return 0; + }), + ); + assert.equal(calls, 0); + assert.equal(JSON.stringify(state), before); + } + + const frozen = placeDiscovery( + ordered(), + 'discovery-rock-outcroppings', + () => 0, + ); + frozen.tokens.forEach((token) => { + Object.freeze(token.known); + Object.freeze(token); + }); + Object.freeze(frozen.tokens); + Object.freeze(frozen.newlyRevealed); + Object.freeze(frozen); + const next = rememberDiscoveryFace( + frozen, + frozen.tokens.find((token) => token.status === 'placed')!.id, + 'atreides', + ); + validateDiscoveryState(next); + assert.notEqual(next.tokens, frozen.tokens); +}); diff --git a/tests/discovery-admission.test.ts b/tests/discovery-admission.test.ts new file mode 100644 index 00000000..13011b95 --- /dev/null +++ b/tests/discovery-admission.test.ts @@ -0,0 +1,327 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { + createDiscoveryState, + JACURUTU_SIETCH, + type DiscoveryState, +} from '../game/discoveries'; +import { + quoteGuildAmbassadorShipment, + GuildAmbassadorShipmentError, +} from '../game/guild-ambassador'; +import { + quoteFremenAmbassadorMove, + FremenAmbassadorMoveError, +} from '../game/fremen-ambassador'; +import { + quoteJunctionTransport, + type JunctionTransportContext, +} from '../game/junction-transport'; +import { + quoteGuildHomeworldShipment, + type GuildHomeworldShipmentContext, +} from '../game/guild-homeworld-shipment'; +import { + HomeworldCustodyError, + type HomeworldCustody, +} from '../game/homeworld-custody'; +import { createRicheseNoField } from '../game/richese-no-field'; +import { + quoteNoFieldCancellation, + NoFieldCancellationError, +} from '../game/karama-no-field-cancellation'; +import { guildTransportQuote } from '../game/transport-quote'; + +const discoveryKey = `${JACURUTU_SIETCH}:0`; + +function discoveries(revealed: boolean): DiscoveryState { + const state = createDiscoveryState(() => 0); + const token = state.tokens.find((entry) => entry.face === JACURUTU_SIETCH)!; + Object.assign(token, { + status: 'placed', + territory: 'meridian', + sector: 1, + revealedTurn: revealed ? 2 : null, + }); + return state; +} + +function ambassadorGame(): Game { + const g = createGame( + 'DISCOVERYADMISSION', + newPlayer('p', 'Beneficiary', 'ecaz'), + true, + ); + g.players.push( + newPlayer('q', 'Other', 'atreides'), + newPlayer('r', 'Third', 'emperor'), + ); + Object.assign(g, { + status: 'playing', + turn: 2, + phase: 5, + storm: 18, + active: 'q', + order: ['q', 'p', 'r'], + }); + for (const player of g.players) + Object.assign(player, { forces: {}, reserves: 20, hand: [] }); + return g; +} + +void test('Ambassador shipments and relocations admit a Discovery location only after its public reveal', () => { + const shipment: Action = { + type: 'decision', + event: 'ambassador-event', + amount: 1, + territory: JACURUTU_SIETCH, + sector: 0, + }; + const hiddenShipment = ambassadorGame(); + hiddenShipment.discoveries = discoveries(false); + assert.throws( + () => quoteGuildAmbassadorShipment(hiddenShipment, 'p', shipment), + GuildAmbassadorShipmentError, + ); + const publicShipment = ambassadorGame(); + publicShipment.discoveries = discoveries(true); + assert.equal( + quoteGuildAmbassadorShipment(publicShipment, 'p', shipment).territory, + JACURUTU_SIETCH, + ); + + const relocation: Action = { + type: 'decision', + event: 'ambassador-event', + forces: { 'wind_pass:14': 1 }, + territory: JACURUTU_SIETCH, + sector: 0, + }; + const hiddenMove = ambassadorGame(); + hiddenMove.players[0].forces = { 'wind_pass:14': 1 }; + hiddenMove.players[0].reserves = 19; + hiddenMove.discoveries = discoveries(false); + assert.throws( + () => quoteFremenAmbassadorMove(hiddenMove, 'p', relocation), + FremenAmbassadorMoveError, + ); + const publicMove = ambassadorGame(); + publicMove.players[0].forces = { 'wind_pass:14': 1 }; + publicMove.players[0].reserves = 19; + publicMove.discoveries = discoveries(true); + assert.equal( + quoteFremenAmbassadorMove(publicMove, 'p', relocation).to, + JACURUTU_SIETCH, + ); +}); + +function junctionFixture(revealed: boolean): { + context: JunctionTransportContext; + custody: HomeworldCustody; +} { + return { + context: { + advanced: true, + storm: 12, + discoveries: discoveries(revealed), + players: [ + { + id: 'g', + faction: 'guild', + reserves: 15, + eliteReserves: 0, + ally: null, + }, + { + id: 'e', + faction: 'emperor', + reserves: 12, + eliteReserves: 3, + ally: null, + }, + { + id: 'a', + faction: 'atreides', + reserves: 20, + eliteReserves: 0, + ally: null, + }, + ], + board: { + e: { + forces: { 'false_wall_south:4': 3 }, + eliteForces: { 'false_wall_south:4': 1 }, + }, + }, + }, + custody: { + visitors: { 'homeworld:atreides': { e: { normal: 2, elite: 1 } } }, + salusa: { normal: 0, elite: 3 }, + }, + }; +} + +void test('Junction rejects a forged hidden Discovery destination and accepts its public projection', () => { + const intent = { + player: 'e', + sponsor: 'g', + destination: discoveryKey, + rate: 'half' as const, + sources: { 'false_wall_south:4': { normal: 2, elite: 1 } }, + }; + const hidden = junctionFixture(false); + assert.throws( + () => quoteJunctionTransport(hidden.context, hidden.custody, intent), + HomeworldCustodyError, + ); + const revealed = junctionFixture(true); + assert.equal( + quoteJunctionTransport(revealed.context, revealed.custody, intent) + .destinationKind, + 'arrakis', + ); +}); + +function guildFixture(revealed: boolean): { + context: GuildHomeworldShipmentContext; + custody: HomeworldCustody; +} { + return { + context: { + advanced: true, + storm: 12, + discoveries: discoveries(revealed), + players: [ + { + id: 'g', + faction: 'guild', + reserves: 13, + eliteReserves: 0, + ally: null, + }, + { + id: 'a', + faction: 'atreides', + reserves: 20, + eliteReserves: 0, + ally: null, + }, + ], + board: { + g: { + forces: { [discoveryKey]: 5 }, + eliteForces: {}, + }, + }, + }, + custody: { + visitors: { 'homeworld:atreides': {} }, + salusa: null, + }, + }; +} + +void test('Guild Homeworld transport rejects hidden-location custody and accepts the revealed source', () => { + const intent = { + player: 'g', + destination: 'homeworld:atreides', + sources: { [discoveryKey]: { normal: 2, elite: 0 } }, + }; + const hidden = guildFixture(false); + assert.throws( + () => quoteGuildHomeworldShipment(hidden.context, hidden.custody, intent), + HomeworldCustodyError, + ); + const revealed = guildFixture(true); + assert.equal( + quoteGuildHomeworldShipment(revealed.context, revealed.custody, intent) + .origin, + JACURUTU_SIETCH, + ); +}); + +void test('Guild transport preview uses the projected public reveal when validating its destination', () => { + function preview(revealed: boolean) { + const g = ambassadorGame(); + g.players[0].faction = 'guild'; + g.active = 'p'; + g.players[0].spice = 20; + g.players[0].forces = { 'imperial_basin:10': 1 }; + g.players[0].reserves = 19; + g.discoveryEnabled = true; + g.discoveries = discoveries(revealed); + return guildTransportQuote(viewGame(g, 'p'), { + type: 'guildShip', + from: 'imperial_basin:10', + amount: 1, + territory: JACURUTU_SIETCH, + sector: 0, + }); + } + assert.ok( + preview(false).unavailableReasons.includes( + 'Choose a sector belonging to the destination.', + ), + ); + assert.ok( + !preview(true).unavailableReasons.includes( + 'Choose a sector belonging to the destination.', + ), + ); +}); + +void test('No-Field cancellation cannot authenticate a declaration into an unrevealed Discovery location', () => { + function declaration(revealed: boolean) { + const g = createGame( + 'DISCOVERYNOFIELD', + newPlayer('r', 'Richese', 'richese'), + true, + ); + Object.assign(g, { + status: 'playing', + phase: 5, + turn: 2, + active: 'r', + discoveries: discoveries(revealed), + pendingShipment: { + turn: 2, + player: 'r', + territory: JACURUTU_SIETCH, + sector: 0, + amount: 1, + elite: 0, + cost: 0, + allyPayment: 0, + advisors: false, + noField: { tokenId: 'opaque-zero', event: 'selection-2' }, + }, + response: { kind: 'richeseNoField', owner: 'r', passed: [] }, + }); + g.players[0].noField = createRicheseNoField([ + 'opaque-zero', + 'opaque-three', + 'opaque-five', + ]); + g.players[0].noFieldEvent = 'selection-2'; + return g; + } + const hidden = declaration(false); + assert.throws( + () => quoteNoFieldCancellation(hidden, hidden.response!), + NoFieldCancellationError, + ); + const revealed = declaration(true); + assert.deepEqual(quoteNoFieldCancellation(revealed, revealed.response!), { + kind: 'own', + player: 'r', + blockedTurn: 2, + pendingShipment: null, + }); +}); diff --git a/tests/discovery-board.test.ts b/tests/discovery-board.test.ts new file mode 100644 index 00000000..579bac8f --- /dev/null +++ b/tests/discovery-board.test.ts @@ -0,0 +1,276 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DISCOVERY_LOCATION_IDS, + JACURUTU_SIETCH, + type DiscoveryLocationId, +} from '../game/discoveries'; +import { + MOBILE_LOCATION, + TERRITORIES, + distance, + gameDistance, + gameTerritories, + location, + territory, + validGameLocation, + validLocation, + type MobileBoard, +} from '../game/board'; +import { + BoardResolutionError, + quoteBattleBoard, + type BoardContext, +} from '../game/board-resolution-quote'; +import { territoryEntryBlock } from '../game/occupancy'; +import { strongholdProgress } from '../game/victory-progress'; +import { quoteVictory, type VictoryContext } from '../game/victory-quote'; + +const INSIDE_JACURUTU = location(JACURUTU_SIETCH, 0); + +function boardWithLocation( + face: DiscoveryLocationId | null = JACURUTU_SIETCH, + revealedTurn: number | null = 2, + parent = 'meridian', + sector = 1, +): MobileBoard { + return { + discoveries: { + tokens: [ + { + face, + status: 'placed', + territory: parent, + sector, + revealedTurn, + }, + ], + }, + }; +} + +void test('all five location identities are static board addresses without mutating the printed territory list', () => { + const printed = TERRITORIES.length; + for (const id of DISCOVERY_LOCATION_IDS) { + assert.equal(territory(id).id, id); + assert.equal(territory(id).type, 'stronghold'); + assert.deepEqual(territory(id).sectors, [0]); + assert.equal(validLocation(id, 0), true); + assert.equal(validLocation(id, 1), false); + assert.equal( + TERRITORIES.some((entry) => entry.id === id), + false, + ); + } + assert.equal(TERRITORIES.length, printed); +}); + +void test('only publicly revealed placed locations join a room board', () => { + const hidden = boardWithLocation(JACURUTU_SIETCH, null); + Object.defineProperty(hidden.discoveries!.tokens[0], 'known', { + get() { + throw new Error('Private Discovery knowledge was read.'); + }, + }); + assert.equal( + gameTerritories(hidden).some((t) => t.id === JACURUTU_SIETCH), + false, + ); + assert.equal(validGameLocation(hidden, JACURUTU_SIETCH, 0), false); + + const redacted = boardWithLocation(null, 2); + assert.equal( + gameTerritories(redacted).some((t) => t.id === JACURUTU_SIETCH), + false, + ); + + const revealed = boardWithLocation(); + assert.equal( + gameTerritories(revealed).some((t) => t.id === JACURUTU_SIETCH), + true, + ); + assert.equal(validGameLocation(revealed, JACURUTU_SIETCH, 0), true); + assert.equal( + TERRITORIES.some((t) => t.id === JACURUTU_SIETCH), + false, + ); +}); + +void test('a revealed location is one extra territory from every sector of its parent and remains a dead end', () => { + const g = boardWithLocation(); + assert.equal(gameDistance(g, 'meridian:1', INSIDE_JACURUTU), 1); + assert.equal(gameDistance(g, 'meridian:2', INSIDE_JACURUTU), 1); + assert.equal(gameDistance(g, INSIDE_JACURUTU, 'meridian:1'), 1); + assert.equal(gameDistance(g, INSIDE_JACURUTU, INSIDE_JACURUTU), 0); + + const outside = 'polar_sink:0'; + const throughParent = Math.min( + distance(outside, 'meridian:1'), + distance(outside, 'meridian:2'), + ); + assert.equal(gameDistance(g, outside, INSIDE_JACURUTU), throughParent + 1); + assert.equal(gameDistance(g, INSIDE_JACURUTU, outside), throughParent + 1); + assert.equal( + gameDistance( + boardWithLocation(JACURUTU_SIETCH, null), + INSIDE_JACURUTU, + INSIDE_JACURUTU, + ), + Infinity, + ); +}); + +void test('storm obstruction applies outside a Discovery endpoint and existing mobile routes compose with it', () => { + const g = { + ...boardWithLocation(), + mobileStronghold: { location: 'polar_sink:0' }, + }; + const stormOne = (key: string) => key.endsWith(':1'); + assert.equal( + gameDistance(g, 'meridian:1', INSIDE_JACURUTU, stormOne), + Infinity, + ); + assert.equal(gameDistance(g, 'meridian:2', INSIDE_JACURUTU, stormOne), 1); + assert.equal(gameDistance(g, INSIDE_JACURUTU, 'meridian:2', stormOne), 1); + assert.equal( + gameDistance(g, INSIDE_JACURUTU, INSIDE_JACURUTU, () => true), + 0, + ); + assert.equal( + gameDistance(g, MOBILE_LOCATION, INSIDE_JACURUTU), + 2 + + Math.min( + distance('polar_sink:0', 'meridian:1'), + distance('polar_sink:0', 'meridian:2'), + ), + ); +}); + +function battleBoard(revealedTurn: number | null): BoardContext { + return { + ...boardWithLocation(JACURUTU_SIETCH, revealedTurn), + advanced: false, + storm: 18, + order: ['a', 'e'], + players: [ + { + id: 'a', + faction: 'atreides', + ally: null, + forces: { [INSIDE_JACURUTU]: 1 }, + }, + { + id: 'e', + faction: 'emperor', + ally: null, + forces: { [INSIDE_JACURUTU]: 1 }, + }, + ], + }; +} + +void test('public board resolution accepts revealed forces, rejects hidden-location forces and reuses stronghold occupancy', () => { + assert.deepEqual(quoteBattleBoard(battleBoard(2)).battles, [ + { territory: JACURUTU_SIETCH, attacker: 'a', defender: 'e' }, + ]); + assert.throws( + () => quoteBattleBoard(battleBoard(null)), + BoardResolutionError, + ); + const nestedPointer = battleBoard(2); + nestedPointer.mobileStronghold = { location: INSIDE_JACURUTU }; + assert.throws(() => quoteBattleBoard(nestedPointer), BoardResolutionError); + + const players = [ + { + id: 'a', + faction: 'atreides' as const, + ally: null, + forces: { [INSIDE_JACURUTU]: 1 }, + }, + { + id: 'e', + faction: 'emperor' as const, + ally: null, + forces: { [INSIDE_JACURUTU]: 1 }, + }, + { id: 'h', faction: 'harkonnen' as const, ally: null, forces: {} }, + ]; + assert.match( + territoryEntryBlock(players, 'h', JACURUTU_SIETCH) ?? '', + /three occupying factions/, + ); +}); + +type ProgressContext = Parameters[0]; +function progressWith(face: DiscoveryLocationId): ProgressContext { + const parent = + face === 'orgiz-processing-station' ? 'plastic_basin' : 'meridian'; + const sector = face === 'orgiz-processing-station' ? 13 : 1; + return { + ...boardWithLocation(face, 2, parent, sector), + advanced: false, + storm: 18, + order: ['a', 'e', 'h'], + players: [ + { + id: 'a', + faction: 'atreides', + ally: null, + forces: { + 'arrakeen:10': 1, + 'carthag:11': 1, + [location(face, 0)]: 1, + }, + }, + { id: 'e', faction: 'emperor', ally: null, forces: {} }, + { id: 'h', faction: 'harkonnen', ally: null, forces: {} }, + ], + }; +} + +void test('Jacurutu alone counts as a Discovery stronghold for normal and Ecaz victory progress', () => { + for (const id of DISCOVERY_LOCATION_IDS) { + const ordinary = progressWith(id); + const row = strongholdProgress(ordinary).progress[0]; + assert.equal(row.strongholds.includes(id), id === JACURUTU_SIETCH, id); + assert.equal(row.qualifies, id === JACURUTU_SIETCH, id); + + const ecaz = progressWith(id); + ecaz.players[0].faction = 'ecaz'; + ecaz.players[0].ally = 'e'; + ecaz.players[1].ally = 'a'; + ecaz.players[1].forces = { ...ecaz.players[0].forces }; + const joint = strongholdProgress(ecaz).progress[0]; + assert.equal( + joint.jointlyOccupied.includes(id), + id === JACURUTU_SIETCH, + id, + ); + assert.equal(joint.qualifies, id === JACURUTU_SIETCH, id); + } +}); + +function finalTurnWith(face: DiscoveryLocationId): VictoryContext { + const progress = progressWith(face); + progress.order = ['a', 'e']; + progress.players = progress.players.slice(0, 2); + progress.players[1].forces = { + 'sietch_tabr:14': 1, + 'tueks_sietch:5': 1, + }; + return { + ...progress, + turn: 10, + phase: 8, + status: 'playing', + winner: [], + techTokens: null, + strongholdCards: null, + }; +} + +void test('final-turn hold scoring includes Jacurutu and excludes the other four locations', () => { + assert.deepEqual(quoteVictory(finalTurnWith('cistern')).winner, ['a', 'e']); + assert.deepEqual(quoteVictory(finalTurnWith(JACURUTU_SIETCH)).winner, ['a']); +}); diff --git a/tests/discovery-controls.test.ts b/tests/discovery-controls.test.ts new file mode 100644 index 00000000..9a909527 --- /dev/null +++ b/tests/discovery-controls.test.ts @@ -0,0 +1,201 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame, + type Game, type GameView, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { + DISCOVERY_TOKENS, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, + type DiscoveryTokenFace, +} from '../game/discoveries'; +import { discoveryAction, discoveryBotActions, discoveryDiscardAction } from '../game/discovery-options'; +import { DiscoveryDiscardDecision, DiscoveryPanel } from '../components/discoveries'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +const html = (game: GameView, busy = false) => renderToStaticMarkup(createElement(DiscoveryPanel, { game, busy, act() {} })); +const discardHtml = (game: GameView, busy = false) => renderToStaticMarkup(createElement(DiscoveryDiscardDecision, { game, busy, act() {} })); + +/** Genuine setup, then conserved board/card staging for Collection controls. */ +function fixture() { + let game = createGame('DISCOVERYCONTROLS', newPlayer('p', 'Atreides', 'atreides')); + joinGame(game, newPlayer('f', 'Fremen', 'fremen')); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + game.discoveryEnabled = true; + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game = initializeDiscoveryGameForAudit(game); + for (let count = 0; game.status === 'setup' && count < 50; count++) { + const owner = viewGame(game, 'p').setupPending[0]; + assert.ok(owner); + const view = viewGame(game, owner); + view.players.find(player => player.id === owner)!.bot = 'Hard'; + const action = botActions(view)[0]; + assert.ok(action); + game = applyAction(game, owner, action); + } + assert.equal(game.status, 'playing'); + Object.assign(game, { phase: 7, turn: 2, active: null, ready: [], response: null, decision: null, phaseOpening: null }); + for (const player of game.players) { + game.deck.push(...player.hand.splice(0)); + player.forces = {}; + player.reserves = 20; + player.tanks = 0; + } + game.players[0].forces = { 'gara_kulon:8': 1, 'pasty_mesa:7': 1 }; + game.players[0].reserves = 18; + return game; +} + +function place(game: Game, face: DiscoveryTokenFace, known = false) { + const token = game.discoveries!.tokens.find(token => token.face === face)!; + const pool = game.discoveries!.tokens.filter(candidate => candidate.type === token.type && candidate.status === 'supply'); + const index = pool.findIndex(candidate => candidate.id === token.id); + assert.ok(index >= 0); + game.discoveries = placeDiscovery(game.discoveries!, + token.type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-funeral-plain', + () => (index + 0.5) / pool.length); + if (known) game.discoveries = rememberDiscoveryFace(game.discoveries, token.id, 'atreides'); + return token.id; +} + +void test('unknown backs expose only their type and placement; shared choices bind the quoted opaque token', () => { + const game = fixture(), hidden = place(game, 'spice-stash'), known = place(game, 'cistern', true); + const view = viewGame(game, 'p'), before = structuredClone(view); + assert.deepEqual(discoveryAction(view, hidden, false), { type: 'discovery', token: hidden, reveal: false }); + assert.equal(discoveryAction(view, hidden, true), null); + assert.equal(discoveryAction(view, known, false), null); + assert.deepEqual(discoveryAction(view, known, true), { type: 'discovery', token: known, reveal: true }); + assert.equal(discoveryAction(view, 'discovery-token-99', false), null); + const markup = html(view); + assert.match(markup, /Smuggler discovery/); + assert.match(markup, /Face down · unknown to you/); + assert.match(markup, /Pasty Mesa · sector 7/); + assert.doesNotMatch(markup, /Spice Stash|Receive seven spice/); + assert.match(markup, /Read Cistern rules/); + assert.match(markup, /Inspect Smuggler token privately/); + assert.match(markup, /Reveal Cistern to everyone/); + assert.deepEqual(discoveryBotActions(view).map(action => action.reveal), [false, true]); + assert.deepEqual(view, before); +}); + +void test('Fremen and Guild privileged faces remain distinct from another seat’s private inspection', () => { + const game = fixture(); + place(game, 'cistern'); + place(game, 'spice-stash'); + const fremen = viewGame(game, 'f'), guild = viewGame(game, 'g'), atreides = viewGame(game, 'p'); + assert.match(html(fremen), /Read Cistern rules/); + assert.doesNotMatch(html(fremen), /Read Spice Stash rules/); + assert.match(html(guild), /Read Spice Stash rules/); + assert.doesNotMatch(html(guild), /Read Cistern rules/); + assert.doesNotMatch(html(atreides), /Read (Cistern|Spice Stash) rules/); + assert.deepEqual(discoveryBotActions(fremen), []); + assert.deepEqual(discoveryBotActions(guild), []); +}); + +void test('all eight authorized faces have readable rules while shared location rules preserve parent-territory meaning', () => { + const game = fixture(); + for (const definition of DISCOVERY_TOKENS) place(game, definition.id, true); + const markup = html(viewGame(game, 'p')); + for (const definition of DISCOVERY_TOKENS) assert.ok(markup.includes(`Read ${definition.name} rules`), definition.name); + for (const text of ['opposing undialed force', 'two spice from the bank', 'Weather Control', + 'use a Truthtrance card as Karama', 'each spice blow that is collected', 'including the new card', + 'Receive seven spice', 'On a later turn', 'separate territory inside', 'At most two factions', + 'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text); + assert.doesNotMatch(markup, /href="https?:/); + assert.match(markup, /still in development/); +}); + +void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => { + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + let game = fixture(); + const token = place(game, 'spice-stash'), before = game.players[0].spice; + for (const reveal of [false, true]) { + const view = viewGame(game, 'p'); + view.players[0].bot = difficulty; + const preserved = structuredClone(view), actions = discoveryBotActions(view); + assert.deepEqual(actions, [{ type: 'discovery', token, reveal }]); + for (const action of actions) assert.doesNotThrow(() => applyAction(reload(game), 'p', action)); + assert.deepEqual(view, preserved); + game = applyAction(reload(game), 'p', actions[0]); + if (!reveal) { + assert.equal(game.players[0].spice, before); + assert.doesNotMatch(html(viewGame(game, 'f')), /Read Spice Stash rules/); + } + } + assert.equal(game.players[0].spice, before + 7); + assert.equal(game.discoveries!.tokens.find(candidate => candidate.id === token)!.status, 'removed'); + assert.deepEqual(discoveryBotActions(viewGame(game, 'p')), []); + assert.throws(() => applyAction(game, 'p', { type: 'discovery', token, reveal: true })); + } +}); + +void test('carried and resolved tokens show public custody without a repeated reveal action', () => { + const game = fixture(), carried = place(game, 'ornithopter'), removed = place(game, 'spice-stash'); + game.discoveries = revealDiscoveryToken(game.discoveries!, carried, game.turn, 'p').state; + game.discoveries = revealDiscoveryToken(game.discoveries, removed, game.turn, 'p').state; + assert.match(html(viewGame(game, 'p')), /Carried by you/); + const observer = html(viewGame(game, 'f')); + assert.match(observer, /Carried by Atreides/); + assert.match(observer, /Resolved and removed/); + assert.doesNotMatch(observer, /Reveal Ornithopter to everyone|Reveal Spice Stash to everyone/); +}); + +void test('a full-hand stash offers every owned card after its real draw, with private inspection and Worthless-first AI', () => { + let game = fixture(); + const stash = place(game, 'treachery-card-stash', true), owner = game.players[0]; + for (const kind of ['shield', 'projectile', 'poison', 'worthless']) { + const index = game.deck.findIndex(card => card.kind === kind); + assert.ok(index >= 0); + owner.hand.push(game.deck.splice(index, 1)[0]); + } + const newCard = game.deck[0].id, worthless = owner.hand.find(card => card.kind === 'worthless')!.id; + game = applyAction(game, 'p', discoveryAction(viewGame(game, 'p'), stash, true)!); + assert.equal(game.decision?.kind, 'discoveryDiscard'); + assert.equal(game.players[0].hand.length, 5); + const view = viewGame(game, 'p'), actions = discoveryBotActions(view), before = structuredClone(view); + assert.equal(actions.length, 5); + assert.equal(actions[0].card, worthless); + assert.equal(discoveryDiscardAction(view, 'not-owned'), null); + assert.ok(discoveryDiscardAction(view, newCard)); + for (const action of actions) { + const next = applyAction(reload(game), 'p', action); + assert.equal(next.players[0].hand.length, 4); + assert.equal(next.discard.filter(card => card.id === action.card).length, 1); + assert.equal(next.discoveryStash?.stage, 'complete'); + assert.equal(discoveryDiscardAction(viewGame(next, 'p'), String(action.card)), null); + } + const markup = discardHtml(view); + for (const card of game.players[0].hand) assert.ok(markup.includes(card.name), card.name); + assert.match(markup, /Card to discard/); + assert.match(markup, /Inspect card/); + assert.match(markup, /new card is already in your hand/); + const observer = viewGame(game, 'f'); + assert.equal(discardHtml(observer), ''); + assert.deepEqual(discoveryBotActions(observer), []); + assert.match(discardHtml(view, true).match(/]*>/)![0], /disabled=""/); + const autopilot = structuredClone(view); autopilot.players[0].autopilot = 'Hard'; + assert.match(discardHtml(autopilot).match(/]*>/)![0], /disabled=""/); + assert.deepEqual(view, before); +}); + +void test('quoted blocking and human busy or autopilot controls retain readable tokens without offering actions', () => { + const game = fixture(), token = place(game, 'cistern', true), view = viewGame(game, 'p'); + for (const busy of [true, false]) { + const locked = structuredClone(view); + if (!busy) locked.players[0].autopilot = 'Medium'; + const button = html(locked, busy).match(/]*>Reveal Cistern to everyone<\/button>/)![0]; + assert.match(button, /disabled=""/); + assert.match(html(locked, busy), /Read Cistern rules/); + } + const blocked = structuredClone(view); + blocked.discoveries!.blocked = 'Finish the current interaction first.'; + assert.equal(discoveryAction(blocked, token, true), null); + assert.deepEqual(discoveryBotActions(blocked), []); + assert.match(html(blocked), /Finish the current interaction first/); + assert.doesNotMatch(html(blocked), /Reveal Cistern to everyone/); + const later = structuredClone(view); later.phase = 8; + assert.equal(discoveryAction(later, token, true), null); + assert.deepEqual(discoveryBotActions(later), []); +}); diff --git a/tests/discovery-playthrough.test.ts b/tests/discovery-playthrough.test.ts new file mode 100644 index 00000000..72613568 --- /dev/null +++ b/tests/discovery-playthrough.test.ts @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runBots } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { baseDeck, spiceDeck } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { + DISCOVERY_SPICE_CARDS, + DISCOVERY_TOKENS, + validateDiscoveryState, +} from '../game/discoveries'; +import { + applyAction, + createGame, + initializeDiscoveryGameForAudit, + joinGame, + newPlayer, + type Game, +} from '../game/engine'; + +const ROSTER: [string, FactionId][] = [ + ['a', 'atreides'], + ['h', 'harkonnen'], + ['g', 'guild'], + ['f', 'fremen'], +]; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; + +function generator(seed: number) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let word = Math.imul(state ^ (state >>> 15), state | 1); + word ^= word + Math.imul(word ^ (word >>> 7), word | 61); + return (word ^ (word >>> 14)) >>> 0; + }; +} + +function withEngineSeed(seed: number, run: () => T): T { + const cryptoObject = globalThis.crypto; + const originalRandom = Object.getOwnPropertyDescriptor( + cryptoObject, + 'getRandomValues', + ); + const originalUuid = Object.getOwnPropertyDescriptor( + cryptoObject, + 'randomUUID', + ); + const randomWord = generator(seed); + const uuidWord = generator(seed ^ 0xa5a5a5a5); + Object.defineProperty(cryptoObject, 'getRandomValues', { + configurable: true, + value: (array: T): T => { + if (!(array instanceof Uint32Array)) + throw new Error( + `Discovery playthrough seed ${seed} supports only the engine Uint32Array RNG contract.`, + ); + for (let index = 0; index < array.length; index++) + array[index] = randomWord(); + return array; + }, + }); + Object.defineProperty(cryptoObject, 'randomUUID', { + configurable: true, + value: () => { + const hex = Array.from({ length: 4 }, () => + uuidWord().toString(16).padStart(8, '0'), + ) + .join('') + .split(''); + hex[12] = '4'; + hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16); + const id = hex.join(''); + return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`; + }, + }); + try { + return run(); + } finally { + if (originalRandom) + Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom); + else Reflect.deleteProperty(cryptoObject, 'getRandomValues'); + if (originalUuid) + Object.defineProperty(cryptoObject, 'randomUUID', originalUuid); + else Reflect.deleteProperty(cryptoObject, 'randomUUID'); + } +} + +function discoveryLobby(advanced: boolean): Game { + const [first, ...rest] = ROSTER; + let game = createGame( + 'DISCOVERYPLAYTHROUGH', + newPlayer(first[0], first[0], first[1]), + advanced, + ); + for (const [id, faction] of rest) joinGame(game, newPlayer(id, id, faction)); + for (const player of game.players) + game = applyAction(game, player.id, { type: 'ready' }); + game.discoveryEnabled = true; + return game; +} + +function finishGame(initial: Game, seed: number): Game { + let game = initial; + for (let batch = 0; batch < 240 && game.status !== 'finished'; batch++) { + game = runBots(reload(game)); + assert.ok( + game.status === 'finished' || game.botsPending, + `Discovery playthrough seed ${seed} stalled at turn ${game.turn}, phase ${game.phase}, setup ${game.setupStage ?? 'complete'}, decision ${game.decision?.kind ?? 'none'}.`, + ); + } + assert.equal( + game.status, + 'finished', + `Discovery playthrough seed ${seed} did not finish.`, + ); + assert.ok( + game.winner.length > 0, + `Discovery playthrough seed ${seed} has no winner.`, + ); + return reload(game); +} + +function assertTreacheryInventory(game: Game, seed: number) { + const cards = [ + ...game.deck, + ...game.discard, + ...game.players.flatMap((player) => player.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? []), + ]; + const expected = baseDeck() + .map((card) => card.id) + .sort(); + assert.equal( + cards.length, + expected.length, + `Treachery cards in seed ${seed}`, + ); + assert.equal( + new Set(cards.map((card) => card.id)).size, + expected.length, + `Distinct Treachery cards in seed ${seed}`, + ); + assert.deepEqual( + cards.map((card) => card.id).sort(), + expected, + `Treachery identities in seed ${seed}`, + ); +} + +function assertForceInventory(game: Game, seed: number) { + for (const player of game.players) + assert.equal( + player.reserves + + player.tanks + + Object.values(player.forces).reduce((sum, count) => sum + count, 0), + 20, + `${player.id} force inventory in seed ${seed}`, + ); +} + +function assertDiscoveryInventory(game: Game, seed: number) { + validateDiscoveryState(game.discoveries!); + assert.equal( + game.discoveries!.tokens.length, + DISCOVERY_TOKENS.length, + `Discovery tokens in seed ${seed}`, + ); + assert.deepEqual( + game.discoveries!.tokens.map((token) => token.face).sort(), + DISCOVERY_TOKENS.map((token) => token.id).sort(), + `Discovery token faces in seed ${seed}`, + ); + + const physicalSpiceCards = [ + ...game.spiceDeck, + ...game.spiceDiscard.flat(), + ].filter((card) => !('worm' in card && card.thumper)); + assert.equal( + physicalSpiceCards.length + Number(game.sandtrout), + spiceDeck().length + 7, + `Spice Card inventory in seed ${seed}`, + ); + const discoveryCards = physicalSpiceCards.flatMap((card) => + 'territory' in card && card.discovery ? [card.discovery] : [], + ); + assert.deepEqual( + discoveryCards.sort(), + DISCOVERY_SPICE_CARDS.map((card) => card.discovery).sort(), + `Discovery Spice Cards in seed ${seed}`, + ); + assert.equal( + physicalSpiceCards.filter((card) => 'worm' in card && card.greatMaker) + .length, + 1, + `Great Maker card in seed ${seed}`, + ); +} + +for (const [advanced, seed] of [ + [false, 2026091303], + [true, 2026091304], +] as const) + void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Discovery seed ${seed} completes through JSON bot batches with conserved pieces`, () => + withEngineSeed(seed, () => { + const lobby = discoveryLobby(advanced); + const publicBefore = structuredClone(lobby); + assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' })); + assert.deepEqual(lobby, publicBefore); + + let game = initializeDiscoveryGameForAudit(lobby); + game.players.forEach((player, index) => { + player.bot = DIFFICULTIES[index]; + }); + assert.equal(game.status, 'setup'); + assert.equal(game.expansions.length, 0); + assert.equal(game.discoveryEnabled, true); + assert.equal(game.discoveries?.tokens.length, DISCOVERY_TOKENS.length); + assert.equal(game.spiceDeck.length, spiceDeck().length + 7); + + game = finishGame(game, seed); + assertTreacheryInventory(game, seed); + assertForceInventory(game, seed); + assertDiscoveryInventory(game, seed); + })); diff --git a/tests/discovery-recovery.test.ts b/tests/discovery-recovery.test.ts new file mode 100644 index 00000000..7c2de50e --- /dev/null +++ b/tests/discovery-recovery.test.ts @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { unitStore } from './fixture-nexus-room-store'; +import { + discoveryFixture, + enterDiscoveryCollection, +} from './fixture-discovery'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { discoveryStashSignature } from '../game/discovery-actions'; +import { startPrototypeRoom } from '../tools/prototype-room'; +import type { RoomsClock } from '../db/rooms'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +void test('SQLite Discovery prototype preserves seats and competing stash reveals pay once; saved overflow discard resumes exactly once', async () => { + const store = unitStore(); + try { + const made = await store.rooms.createRoom( + 'Discovery SQL', + 'atreides', + false, + [], + ), + code = made.view.code; + const tokens = [made.token]; + for (const faction of ['guild', 'fremen'] as const) + tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); + const auths = await Promise.all( + tokens.map((token) => store.rooms.authenticate(code, token)), + ); + const ids = auths.map((auth) => auth.playerId) as [string, string, string]; + let lobby = await store.rooms.readRoom(code); + for (const p of lobby.players) + lobby = applyAction(lobby, p.id, { type: 'ready' }); + store.sqlite + .prepare('UPDATE rooms SET state = ? WHERE code = ?') + .run(JSON.stringify(lobby), code); + const seats = store.sqlite + .prepare('SELECT * FROM seats ORDER BY player_id') + .all(); + const begun = startPrototypeRoom( + store.sqlite, + code, + lobby.version, + 'discovery', + ); + assert.equal(begun.status, 'setup'); + assert.equal(begun.version, lobby.version + 1); + assert.throws(() => + startPrototypeRoom(store.sqlite, code, begun.version, 'discovery'), + ); + assert.deepEqual( + store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), + seats, + ); + let g = discoveryFixture(false, ids); + g.code = code; + g.version = begun.version; + const stash = enterDiscoveryCollection(g, 'spice-stash'); + g = applyAction(g, ids[0], { + type: 'discovery', + token: stash.id, + reveal: false, + }); + store.sqlite + .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + let arrivals = 0, + release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + store.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await gate; + }; + const timer = setTimeout(release, 2000); + const action = { type: 'discovery', token: stash.id, reveal: true }; + try { + const results = await Promise.allSettled( + [0, 1].map(() => + store.restart().act(code, auths[0], g.version, action, clock), + ), + ); + assert.equal(arrivals, 2); + assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); + } finally { + clearTimeout(timer); + delete store.hooks.beforeWrite; + } + const done = await store.restart().readRoom(code); + assert.equal(done.players[0].spice, g.players[0].spice + 7); + assert.equal(done.version, g.version + 1); + await assert.rejects( + store.restart().act(code, auths[0], done.version, action, clock), + ); + for (const [i, token] of tokens.entries()) { + const rooms = store.restart(), + auth = await rooms.authenticate(code, token); + assert.deepEqual( + await rooms.readSeatView(code, auth), + viewGame(done, ids[i]), + ); + } + g = done; + const cardToken = enterDiscoveryCollection(g, 'treachery-card-stash'); + while (g.players[0].hand.length < 4) + g.players[0].hand.push(g.deck.shift()!); + g = applyAction(g, ids[0], { + type: 'discovery', + token: cardToken.id, + reveal: false, + }); + g = applyAction(g, ids[0], { + type: 'discovery', + token: cardToken.id, + reveal: true, + }); + store.sqlite + .prepare('UPDATE rooms SET state = ? WHERE code = ?') + .run(JSON.stringify(g), code); + const restored = await store.restart().readRoom(code); + assert.equal(restored.players[0].hand.length, 5); + assert.equal(restored.decision?.kind, 'discoveryDiscard'); + const discard = restored.players[0].hand.at(-1)!; + await store + .restart() + .act( + code, + auths[0], + restored.version, + { + type: 'decision', + event: restored.discoveryStash!.event, + card: discard.id, + }, + clock, + ); + const finished = await store.restart().readRoom(code); + assert.equal(finished.discoveryStash!.stage, 'complete'); + assert.equal(finished.players[0].hand.length, 4); + const paused = JSON.parse(JSON.stringify(finished)) as Game, + record = paused.discoveryStash!; + record.stage = 'discard'; + record.signature = discoveryStashSignature(record); + const sequence = paused.treacheryDiscardSequence!; + paused.resolvedTreacheryDiscardSequence = sequence - 1; + paused.pendingTreacheryDiscard = { + sequence, + batch: { + event: `discard:${paused.turn}:${paused.phase}:${sequence}`, + turn: paused.turn, + phase: paused.phase, + cause: 'discovery:stash', + entries: [{ card: discard, discardedBy: ids[0], publicFace: true }], + }, + continuation: { + kind: 'discoveryStash', + event: record.event, + owner: ids[0], + card: discard.id, + }, + }; + store.sqlite + .prepare('UPDATE rooms SET state = ? WHERE code = ?') + .run(JSON.stringify(paused), code); + await store.restart().continueRoomAutomatic(code, clock); + const resumed = await store.restart().readRoom(code); + assert.equal(resumed.discoveryStash!.stage, 'complete'); + assert.deepEqual(resumed.players, finished.players); + assert.deepEqual(resumed.deck, finished.deck); + assert.deepEqual(resumed.discard, finished.discard); + const settled = JSON.stringify(resumed); + await store.restart().continueRoomAutomatic(code, clock); + assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled); + assert.deepEqual( + store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), + seats, + ); + } finally { + store.sqlite.close(); + } +}); diff --git a/tests/discovery-review.test.ts b/tests/discovery-review.test.ts new file mode 100644 index 00000000..0d02b3ef --- /dev/null +++ b/tests/discovery-review.test.ts @@ -0,0 +1,478 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeDiscoveryGameForAudit, + joinGame, + newPlayer, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import type { Card, SpiceCard } from '../game/cards'; +import { discoveryStashSignature } from '../game/discovery-actions'; +import { greatMakerSignature } from '../game/great-maker'; +import { isAdvisor } from '../game/advisors'; +import { + discoveryFixture, + enterDiscoveryCollection, +} from './fixture-discovery'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); + +function rejectUnchanged(game: Game, player: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, player, action)); + assert.deepEqual(game, before); +} + +function takeSpice( + game: Game, + predicate: (card: SpiceCard) => boolean, +): SpiceCard { + const index = game.spiceDeck.findIndex(predicate); + assert.ok(index >= 0, 'the physical spice card must remain in the deck'); + return game.spiceDeck.splice(index, 1)[0]; +} + +function giveCard( + game: Game, + owner: string, + predicate: (card: Card) => boolean, +): Card { + const zones = [ + game.deck, + game.discard, + ...game.players.map((player) => player.hand), + ]; + for (const zone of zones) { + const index = zone.findIndex(predicate); + if (index < 0) continue; + const [card] = zone.splice(index, 1); + game.players.find((player) => player.id === owner)!.hand.push(card); + return card; + } + assert.fail('the physical Treachery card must remain in a live zone'); +} + +function ready(game: Game): Game { + let next = game; + for (const player of next.players) + next = applyAction(next, player.id, { type: 'ready' }); + return next; +} + +function greatMakerSurvival(allied = false): { game: Game; karama: Card; source: string } { + const game = discoveryFixture(); + const greatMaker = takeSpice( + game, + (card) => 'worm' in card && card.greatMaker === true, + ); + const sourceCard = takeSpice( + game, + (card) => + 'territory' in card && + card.territory === 'broken_land' && + card.discovery === undefined, + ); + assert.ok('territory' in sourceCard); + const replacement = takeSpice( + game, + (card) => 'territory' in card && card.discovery === undefined, + ); + const karama = giveCard(game, 'a', (card) => card.effect === 'karama'); + Object.assign(game, { + turn: 2, + phase: 1, + storm: 18, + nexus: false, + summonedBeforeBlow: false, + wormRides: [], + spiceSequence: null, + spiceResolution: null, + spiceWindow: null, + response: null, + decision: null, + phaseOpening: null, + ready: [], + }); + for (const player of game.players) + Object.assign(player, { forces: {}, reserves: 20, tanks: 0, ally: null }); + const source = `${sourceCard.territory}:${sourceCard.sector}`; + const fremen = game.players.find((player) => player.id === 'f')!; + fremen.forces = { [source]: 4 }; + fremen.reserves = 16; + if (allied) { + const atreides = game.players.find((player) => player.id === 'a')!; + fremen.ally = atreides.id; + atreides.ally = fremen.id; + atreides.forces = { [source]: 2 }; + atreides.reserves = 18; + } + game.spice[source] = 5; + game.spiceDiscard = [[sourceCard], []]; + game.spiceDeck = [greatMaker, replacement, ...game.spiceDeck]; + const pending = ready(game); + assert.equal(pending.greatMaker?.stage, 'worm'); + assert.equal( + allied ? pending.decision?.kind : pending.response?.kind, + allied ? 'wormProtection' : 'wormSurvival', + ); + return { game: pending, karama, source }; +} + +function passCurrentResponse(game: Game, kind: NonNullable['kind']): Game { + let next = game; + while (next.response?.kind === kind) { + const player = next.players.find( + (candidate) => !next.response!.passed.includes(candidate.id), + ); + assert.ok(player); + next = applyAction(next, player.id, { type: 'passResponse' }); + } + return next; +} + +function advancedAdvisorGame(): Game { + let game = createGame( + 'GMADVISOR', + newPlayer('a', 'Atreides', 'atreides'), + true, + ); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + joinGame(game, newPlayer('f', 'Fremen', 'fremen')); + joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit')); + game.discoveryEnabled = true; + game = ready(game); + game = initializeDiscoveryGameForAudit(game); + game = applyAction(game, 'b', { + type: 'predict', + faction: 'atreides', + turn: 4, + }); + while (game.setupStage === 'traitors') { + const player = game.players.find( + (candidate) => candidate.traitorChoices.length, + ); + assert.ok(player); + game = applyAction(game, player.id, { + type: 'traitor', + leader: player.traitorChoices[0], + }); + } + game = applyAction(game, 'f', { + type: 'fremenSetup', + placements: { sietch_tabr: 10 }, + }); + game = applyAction(game, 'b', { + type: 'advisorSetup', + territory: 'imperial_basin', + sector: 10, + }); + assert.equal(game.status, 'playing'); + assert.equal( + isAdvisor( + game.players.find((player) => player.id === 'b')!, + 'imperial_basin', + ), + false, + ); + return game; +} + +function voteAll(game: Game, yes = false): Game { + let next = game; + for (let count = 0; count < next.players.length; count++) { + assert.ok(next.decision?.kind === 'greatMakerVote'); + next = applyAction(next, next.decision.player, { + type: 'decision', + event: next.decision.event, + yes, + }); + } + return next; +} + +void test('canceling Fremen worm survival destroys the board group but retains the Great Maker reserve ride', () => { + const encounter = greatMakerSurvival(); + let game = applyAction(encounter.game, 'a', { + type: 'card', + card: encounter.karama.id, + mode: 'cancel', + }); + const fremen = game.players.find((player) => player.id === 'f')!; + assert.equal(fremen.forces[encounter.source], undefined); + assert.equal(fremen.tanks, 4); + assert.equal(fremen.reserves, 16); + assert.equal(game.spice[encounter.source], undefined); + assert.deepEqual(game.wormRides, []); + assert.equal(game.greatMaker?.stage, 'vote'); + + game = voteAll(reload(game)); + assert.equal(game.decision?.kind, 'greatMakerRide'); + const event = game.greatMaker!.event; + game = applyAction(game, 'f', { + type: 'decision', + event, + accept: true, + territory: 'polar_sink', + sector: 0, + amount: 3, + elite: 0, + }); + const rider = game.players.find((player) => player.id === 'f')!; + assert.equal(rider.tanks, 4); + assert.equal(rider.reserves, 13); + assert.equal(rider.forces['polar_sink:0'], 3); + assert.equal(game.greatMaker?.stage, 'complete'); +}); + +void test('Fremen ally protection returns through survival to the Great Maker vote', () => { + const encounter = greatMakerSurvival(true); + let game = encounter.game; + assert.equal(game.decision?.kind, 'wormProtection'); + game = applyAction(game, 'f', { type: 'decision', accept: true }); + assert.equal(game.greatMaker?.stage, 'worm'); + assert.equal(game.response?.kind, 'wormAllyProtection'); + assert.doesNotThrow(() => viewGame(reload(game), 'a')); + + game = passCurrentResponse(game, 'wormAllyProtection'); + assert.equal(game.response?.kind, 'wormSurvival'); + assert.equal(game.response.recipient, 'a'); + assert.doesNotThrow(() => viewGame(reload(game), 'f')); + + game = passCurrentResponse(game, 'wormSurvival'); + const atreides = game.players.find((player) => player.id === 'a')!; + const fremen = game.players.find((player) => player.id === 'f')!; + assert.equal(atreides.forces[encounter.source], 2); + assert.equal(atreides.reserves, 18); + assert.equal(atreides.tanks, 0); + assert.equal(fremen.forces[encounter.source], 4); + assert.equal(fremen.reserves, 16); + assert.equal(fremen.tanks, 0); + assert.equal(game.spice[encounter.source], undefined); + assert.equal(game.greatMaker?.stage, 'vote'); + assert.equal(game.decision?.kind, 'greatMakerVote'); +}); + +void test('an Advanced Bene Gesserit intrusion returns from a restored Great Maker ride before spice continues', () => { + let game = advancedAdvisorGame(); + const greatMaker = takeSpice( + game, + (card) => 'worm' in card && card.greatMaker === true, + ); + const source = takeSpice( + game, + (card) => + 'territory' in card && + card.territory === 'broken_land' && + card.discovery === undefined, + ); + const replacement = takeSpice( + game, + (card) => 'territory' in card && card.discovery === undefined, + ); + assert.ok('territory' in replacement); + giveCard(game, 'a', (card) => card.effect === 'karama'); + Object.assign(game, { + turn: 2, + phase: 1, + storm: 18, + nexus: false, + summonedBeforeBlow: false, + wormRides: [], + spiceSequence: null, + spiceResolution: null, + spiceWindow: null, + response: null, + decision: null, + phaseOpening: null, + ready: [], + }); + game.spiceDiscard = [[source], []]; + game.spiceDeck = [greatMaker, replacement, ...game.spiceDeck]; + game = voteAll(ready(game)); + assert.ok(game.decision?.kind === 'greatMakerRide'); + game = applyAction(game, 'f', { + type: 'decision', + event: game.decision.event, + accept: true, + territory: 'imperial_basin', + sector: 10, + amount: 1, + elite: 0, + }); + game = reload(game); + assert.equal(game.greatMaker?.stage, 'arrival'); + assert.equal(game.decision?.kind, 'intrusion'); + assert.doesNotThrow(() => viewGame(game, 'b')); + const misbound = reload(game); + assert.ok(misbound.decision?.kind === 'intrusion'); + misbound.decision.territory = 'carthag'; + assert.throws( + () => viewGame(misbound, 'b'), + /Great Maker.*arrival|arrival.*territory/i, + ); + game = applyAction(game, 'b', { type: 'decision', accept: true }); + game = reload(game); + assert.equal(game.response?.kind, 'advisorFlip'); + assert.equal(game.response?.advisorResume, 'wormRide'); + assert.doesNotThrow(() => viewGame(game, 'f')); + while (game.response) { + const player = game.players.find( + (candidate) => !game.response!.passed.includes(candidate.id), + ); + assert.ok(player); + game = applyAction(game, player.id, { type: 'passResponse' }); + } + const beneGesserit = game.players.find((player) => player.id === 'b')!; + assert.equal(isAdvisor(beneGesserit, 'imperial_basin'), true); + assert.equal(game.greatMaker?.stage, 'complete'); + assert.equal(game.spiceWindow?.territory, replacement.territory); + assert.equal(game.decision, null); +}); + +void test('Great Maker integrity rejects orphaned, moduleless, and post-completion controls', () => { + const pending = greatMakerSurvival().game; + const wrongSurvivalOwner = structuredClone(pending); + assert.equal(wrongSurvivalOwner.response?.kind, 'wormSurvival'); + wrongSurvivalOwner.response.owner = 'a'; + assert.throws( + () => viewGame(wrongSurvivalOwner, 'a'), + /original Fremen response|pending worm survival/i, + ); + const vote = structuredClone(pending); + vote.response = null; + vote.greatMaker!.stage = 'vote'; + vote.greatMaker!.signature = greatMakerSignature(vote.greatMaker!); + vote.decision = { + kind: 'greatMakerVote', + player: vote.greatMaker!.order[0], + event: vote.greatMaker!.event, + }; + + const orphan = structuredClone(vote); + delete orphan.greatMaker; + assert.throws(() => viewGame(orphan, 'a'), /lost its original encounter/i); + const moduleless = structuredClone(vote); + delete moduleless.discoveryEnabled; + delete moduleless.discoveries; + assert.throws(() => viewGame(moduleless, 'a'), /physical Discovery module/i); + + let complete = voteAll(vote); + assert.ok(complete.decision?.kind === 'greatMakerRide'); + complete = applyAction(complete, 'f', { + type: 'decision', + event: complete.decision.event, + accept: false, + }); + assert.equal(complete.greatMaker?.stage, 'complete'); + const retained = structuredClone(complete); + retained.decision = { + kind: 'greatMakerRide', + player: 'f', + event: retained.greatMaker!.event, + }; + assert.throws(() => viewGame(retained, 'a'), /completed Great Maker/i); +}); + +void test('a pending Treachery Card Stash binds its exact private hand and rejects stale discard actions atomically', () => { + let game = discoveryFixture(); + const token = enterDiscoveryCollection(game, 'treachery-card-stash'); + while (game.players[0].hand.length < 4) + game.players[0].hand.push(game.deck.shift()!); + game = applyAction(game, 'a', { + type: 'discovery', + token: token.id, + reveal: false, + }); + game = applyAction(game, 'a', { + type: 'discovery', + token: token.id, + reveal: true, + }); + assert.ok(game.decision?.kind === 'discoveryDiscard'); + const decision = game.decision; + const selected = game.players[0].hand[0]; + rejectUnchanged(game, 'g', { + type: 'decision', + event: decision.event, + card: selected.id, + }); + rejectUnchanged(game, 'a', { + type: 'decision', + event: 'stale', + card: selected.id, + }); + rejectUnchanged(game, 'a', { + type: 'decision', + event: decision.event, + card: selected.id, + extra: true, + }); + + const lostControl = reload(game); + lostControl.decision = null; + assert.throws( + () => viewGame(lostControl, 'a'), + /lost its owned discard choice/i, + ); + const changedHand = reload(game); + const removed = changedHand.players[0].hand.pop()!; + changedHand.deck.push(removed); + assert.throws(() => viewGame(changedHand, 'a'), /finish the original.*hand/i); + const bypassedDiscard = reload(game); + bypassedDiscard.decision = null; + bypassedDiscard.discoveryStash!.stage = 'complete'; + bypassedDiscard.discoveryStash!.signature = discoveryStashSignature( + bypassedDiscard.discoveryStash!, + ); + assert.throws( + () => viewGame(bypassedDiscard, 'a'), + /stash.*discard|full.*hand/i, + ); + + const done = applyAction(reload(game), 'a', { + type: 'decision', + event: decision.event, + card: selected.id, + }); + assert.equal(done.discoveryStash?.stage, 'complete'); + assert.equal(done.players[0].hand.length, 4); + assert.equal( + done.discard.filter((card) => card.id === selected.id).length, + 1, + ); + assert.deepEqual(normalizeAutomaticGame(done), done); +}); + +void test('pre-Discovery saved games remain valid without synthesized component state', () => { + let game = createGame('OLDROOM1', newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + for (const player of game.players) + game = applyAction(game, player.id, { type: 'ready' }); + game = applyAction(game, 'a', { type: 'start' }); + while (game.status === 'setup') { + const player = game.players.find( + (candidate) => candidate.traitorChoices.length, + ); + assert.ok(player); + game = applyAction(game, player.id, { + type: 'traitor', + leader: player.traitorChoices[0], + }); + } + const restored = reload(game); + assert.equal(restored.discoveryEnabled, undefined); + assert.equal(restored.discoveries, undefined); + assert.equal(restored.discoveryStash, undefined); + assert.equal(restored.greatMaker, undefined); + const view = viewGame(restored, 'a'); + assert.equal(view.discoveries, null); + assert.equal(view.greatMaker, null); + assert.doesNotThrow(() => normalizeAutomaticGame(restored)); + assert.equal(restored.discoveries, undefined); + assert.equal(restored.greatMaker, undefined); +}); diff --git a/tests/discovery-runtime.test.ts b/tests/discovery-runtime.test.ts new file mode 100644 index 00000000..25479f23 --- /dev/null +++ b/tests/discovery-runtime.test.ts @@ -0,0 +1,227 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + initializeDiscoveryGameForAudit, + normalizeAutomaticGame, + viewGame, + type Game, +} from '../game/engine'; +import { baseDeck, spiceDeck } from '../game/cards'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { botActions } from '../game/bots'; +import { + discoveryFixture, + discoveryLobby, + enterDiscoveryCollection, +} from './fixture-discovery'; +import { + DISCOVERY_SPICE_CARDS, + validateDiscoveryState, +} from '../game/discoveries'; + +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +function custody(g: Game) { + assert.deepEqual( + [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)] + .map((c) => c.id) + .sort(), + baseDeck() + .map((c) => c.id) + .sort(), + ); + for (const p of g.players) + assert.equal( + p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), + 20, + ); + validateDiscoveryState(g.discoveries!); +} +void test('Discovery real setup deals seven spice cards and eight opaque tokens while normal starts remain gated', () => { + for (const advanced of [false, true]) { + const lobby = discoveryLobby(advanced), + before = structuredClone(lobby); + assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' })); + assert.deepEqual(lobby, before); + const g = initializeDiscoveryGameForAudit(lobby); + assert.equal(g.spiceDeck.length, spiceDeck().length + 7); + assert.equal( + g.spiceDeck.filter((card) => 'worm' in card && card.greatMaker).length, + 1, + ); + assert.deepEqual( + g.spiceDeck + .flatMap((card) => + 'territory' in card && card.discovery ? [card.discovery] : [], + ) + .sort(), + DISCOVERY_SPICE_CARDS.map((c) => c.discovery).sort(), + ); + assert.equal(g.discoveries!.tokens.length, 8); + assert.deepEqual(viewGame(g, lobby.host).discoveries!.tokens, []); + } +}); +void test('Collection look remains private, deferred reveal survives reload, and a Spice Stash pays exactly once', () => { + let g = discoveryFixture(); + const token = enterDiscoveryCollection(g, 'spice-stash'); + assert.equal(viewGame(g, 'a').discoveries!.tokens[0].face, null); + assert.equal(viewGame(g, 'g').discoveries!.tokens[0].face, 'spice-stash'); + assert.equal(viewGame(g, 'f').discoveries!.tokens[0].face, null); + const before = structuredClone(g); + assert.throws(() => + applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true }), + ); + assert.throws(() => + applyAction(g, 'g', { type: 'discovery', token: token.id, reveal: true }), + ); + assert.deepEqual(g, before); + g = applyAction(g, 'a', { + type: 'discovery', + token: token.id, + reveal: false, + }); + const v = viewGame(g, 'a'); + assert.equal(v.discoveries!.tokens[0].face, 'spice-stash'); + v.discoveries!.tokens[0].owner = 'forged'; + assert.equal( + g.discoveries!.tokens.find((t) => t.id === token.id)!.owner, + null, + ); + assert.equal(viewGame(g, 'f').discoveries!.tokens[0].face, null); + const spice = g.players[0].spice; + g = applyAction(reload(g), 'a', { + type: 'discovery', + token: token.id, + reveal: true, + }); + assert.equal(g.players[0].spice, spice + 7); + assert.equal( + g.discoveries!.tokens.find((t) => t.id === token.id)!.status, + 'removed', + ); + const done = structuredClone(g); + assert.throws(() => + applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true }), + ); + assert.deepEqual(g, done); + custody(g); +}); +void test('Treachery Card Stash draws past a full hand, keeps the draw private and accepts every held discard through recovery', () => { + let g = discoveryFixture(); + const token = enterDiscoveryCollection(g, 'treachery-card-stash'); + while (g.players[0].hand.length < 4) g.players[0].hand.push(g.deck.shift()!); + const original = g.players[0].hand.map((c) => c.id), + drawn = g.deck[0]; + g = applyAction(g, 'a', { + type: 'discovery', + token: token.id, + reveal: false, + }); + g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true }); + assert.equal(g.decision?.kind, 'discoveryDiscard'); + assert.equal(g.players[0].hand.length, 5); + assert.equal(JSON.stringify(viewGame(g, 'f')).includes(drawn.id), false); + assert.deepEqual( + g.players[0].hand.map((c) => c.id), + [...original, drawn.id], + ); + for (const card of g.players[0].hand) { + const done = applyAction(reload(g), 'a', { + type: 'decision', + event: g.discoveryStash!.event, + card: card.id, + }); + assert.equal(done.players[0].hand.length, 4); + assert.equal(done.discoveryStash!.stage, 'complete'); + assert.ok(done.discard.some((c) => c.id === card.id)); + custody(done); + assert.deepEqual(normalizeAutomaticGame(done), done); + } + for (const difficulty of DIFFICULTIES) { + const v = viewGame(g, 'a'); + v.players[0].bot = difficulty; + const actions = botActions(v); + assert.ok(actions.length); + for (const action of actions) + assert.doesNotThrow(() => applyAction(g, 'a', action)); + } +}); +void test('a revealed location is initially empty and becomes an ordinary stronghold-price destination', () => { + let g = discoveryFixture(); + const token = enterDiscoveryCollection(g, 'cistern'); + g = applyAction(g, 'a', { + type: 'discovery', + token: token.id, + reveal: false, + }); + g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true }); + assert.equal( + g.discoveries!.tokens.find((t) => t.id === token.id)!.revealedTurn, + g.turn, + ); + assert.ok(g.players.every((p) => !p.forces['cistern:0'])); + assert.equal( + viewGame(g, 'f').discoveries!.tokens.find((t) => t.id === token.id)!.face, + 'cistern', + ); + custody(g); +}); + +void test('each physical Discovery blow destroys the whole parent territory including Fremen before placing six new spice and a private token', () => { + for (const printed of DISCOVERY_SPICE_CARDS) { + let g = discoveryFixture(); + const index = g.spiceDeck.findIndex( + (card) => 'territory' in card && card.discovery === printed.discovery, + ); + assert.ok(index >= 0); + const card = g.spiceDeck.splice(index, 1)[0]; + g.spiceDeck.unshift(card); + Object.assign(g, { phase: 1, ready: [], storm: 18, stormPending: null }); + const key = `${printed.territory}:${printed.sector}`; + for (const p of [g.players[0], g.players[2]]) { + p.reserves -= 2; + p.forces[key] = 2; + } + g.spice[key] = 4; + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + assert.equal(g.spiceWindow?.territory, printed.territory); + assert.equal(g.spice[key], 6); + for (const p of [g.players[0], g.players[2]]) { + assert.equal(p.forces[key], undefined); + assert.equal(p.tanks, 2); + } + assert.equal( + g.discoveries!.tokens.filter((token) => token.status === 'placed').length, + 1, + ); + assert.equal(viewGame(g, 'a').discoveries!.tokens[0].face, null); + custody(g); + } +}); + +void test('Discovery spice still destroys old forces under the storm and does not discard the separately placed token', () => { + let g = discoveryFixture(); + const printed = DISCOVERY_SPICE_CARDS[0]; + const index = g.spiceDeck.findIndex( + (card) => 'territory' in card && card.discovery === printed.discovery, + ); + g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]); + Object.assign(g, { + phase: 1, + ready: [], + storm: printed.sector, + stormPending: null, + }); + const key = `${printed.territory}:${printed.sector}`; + g.players[0].reserves--; + g.players[0].forces[key] = 1; + g.spice[key] = 3; + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + assert.equal(g.spice[key], undefined); + assert.equal(g.players[0].tanks, 1); + assert.equal( + g.discoveries!.tokens.filter((token) => token.status === 'placed').length, + 1, + ); + custody(g); +}); diff --git a/tests/fixture-discovery.ts b/tests/fixture-discovery.ts new file mode 100644 index 00000000..18544597 --- /dev/null +++ b/tests/fixture-discovery.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeDiscoveryGameForAudit, + joinGame, + newPlayer, + viewGame, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { + placeDiscovery, + type DiscoverySpiceCardId, + type DiscoveryTokenFace, +} from '../game/discoveries'; + +export function discoveryLobby( + advanced = false, + ids: [string, string, string] = ['a', 'g', 'f'], +): Game { + let g = createGame( + 'DISCQA01', + newPlayer(ids[0], 'Atreides', 'atreides'), + advanced, + [], + ); + joinGame(g, newPlayer(ids[1], 'Guild', 'guild')); + joinGame(g, newPlayer(ids[2], 'Fremen', 'fremen')); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + g.discoveryEnabled = true; + return g; +} +export function discoveryFixture( + advanced = false, + ids: [string, string, string] = ['a', 'g', 'f'], +): Game { + let g = initializeDiscoveryGameForAudit(discoveryLobby(advanced, ids)); + for (let step = 0; step < 80 && g.status === 'setup'; step++) { + let next: Game | undefined; + for (const p of g.players) { + const v = viewGame(g, p.id); + v.players.find((seat) => seat.id === p.id)!.bot = 'Easy'; + const action = botActions(v)[0]; + if (action) { + next = applyAction(g, p.id, action); + break; + } + } + assert.ok( + next, + `Discovery setup stalled at ${g.setupStage}/${g.decision?.kind}`, + ); + g = next; + } + assert.equal(g.status, 'playing'); + return g; +} +export function putDiscovery(g: Game, face: DiscoveryTokenFace) { + const source = g.discoveries!.tokens.find((token) => token.face === face)!; + const card: DiscoverySpiceCardId = + source.type === 'hiereg' + ? 'discovery-hagga-basin' + : 'discovery-wind-pass-north'; + const eligible = g.discoveries!.tokens.filter( + (token) => token.type === source.type && token.status === 'supply', + ); + const index = eligible.findIndex((token) => token.face === face); + assert.ok(index >= 0); + g.discoveries = placeDiscovery( + g.discoveries!, + card, + () => (index + 0.1) / eligible.length, + ); + return g.discoveries.tokens.find((token) => token.face === face)!; +} +export function enterDiscoveryCollection( + g: Game, + face: DiscoveryTokenFace, + owner = g.players[0].id, +) { + const token = putDiscovery(g, face), + p = g.players.find((player) => player.id === owner)!; + const key = `${token.territory}:${token.sector}`; + p.reserves -= 2; + p.forces[key] = (p.forces[key] ?? 0) + 2; + Object.assign(g, { + phase: 7, + ready: [], + decision: null, + response: null, + phaseOpening: null, + storm: 18, + stormPending: null, + }); + return token; +} diff --git a/tests/great-maker.test.ts b/tests/great-maker.test.ts new file mode 100644 index 00000000..854b8051 --- /dev/null +++ b/tests/great-maker.test.ts @@ -0,0 +1,401 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeDiscoveryGameForAudit, + joinGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { spiceDeck } from '../game/cards'; + +const LAND = spiceDeck().filter((card) => 'territory' in card); +const SOURCE = LAND.find((card) => card.territory === 'broken_land')!; + +function fixture(): Game { + let g = createGame('GREATMAKER', newPlayer('f', 'Fremen', 'fremen')); + joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + joinGame(g, newPlayer('g', 'Guild', 'guild')); + joinGame(g, newPlayer('e', 'Emperor', 'emperor')); + g.discoveryEnabled = true; + for (const player of g.players) + g = applyAction(g, player.id, { type: 'ready' }); + g = initializeDiscoveryGameForAudit(g); + while (g.setupStage === 'traitors') { + const player = g.players.find( + (candidate) => candidate.traitorChoices.length, + ); + assert.ok(player); + g = applyAction(g, player.id, { + type: 'traitor', + leader: player.traitorChoices[0], + }); + } + g = applyAction(g, 'f', { + type: 'fremenSetup', + placements: { sietch_tabr: 10 }, + }); + assert.equal(g.status, 'playing'); + return g; +} + +function ready(state: Game): Game { + let g = state; + for (const player of g.players) + g = applyAction(g, player.id, { type: 'ready' }); + return g; +} + +function encounter(priorNexus = false): Game { + const g = fixture(); + const greatMakerIndex = g.spiceDeck.findIndex( + (card) => 'worm' in card && card.greatMaker, + ); + const sourceIndex = g.spiceDeck.findIndex( + (card) => + 'territory' in card && + card.territory === SOURCE.territory && + card.discovery === undefined, + ); + assert.ok(greatMakerIndex >= 0); + assert.ok(sourceIndex >= 0); + const [greatMaker] = g.spiceDeck.splice(greatMakerIndex, 1); + const adjustedSourceIndex = g.spiceDeck.findIndex( + (card) => + 'territory' in card && + card.territory === SOURCE.territory && + card.discovery === undefined, + ); + const [source] = g.spiceDeck.splice(adjustedSourceIndex, 1); + const replacementIndex = g.spiceDeck.findIndex( + (card) => 'territory' in card && card.discovery === undefined, + ); + assert.ok(replacementIndex >= 0); + const [replacement] = g.spiceDeck.splice(replacementIndex, 1); + assert.ok( + greatMaker, + 'the Discovery setup must create the physical Great Maker card', + ); + Object.assign(g, { + turn: 2, + phase: 1, + storm: 18, + nexus: false, + summonedBeforeBlow: priorNexus, + wormRides: priorNexus ? ['red_chasm'] : [], + spiceSequence: null, + spiceResolution: null, + spiceWindow: null, + response: null, + decision: null, + ready: [], + }); + for (const player of g.players) { + g.deck.push(...player.hand); + Object.assign(player, { + hand: [], + forces: {}, + reserves: 20, + tanks: 0, + ally: null, + }); + } + g.players.find((player) => player.id === 'f')!.forces = { + [`${SOURCE.territory}:${SOURCE.sector}`]: 4, + }; + g.players.find((player) => player.id === 'f')!.reserves = 16; + g.players.find((player) => player.id === 'a')!.forces = { + [`${SOURCE.territory}:${SOURCE.sector}`]: 3, + }; + g.players.find((player) => player.id === 'a')!.reserves = 17; + g.spice = { [`${SOURCE.territory}:${SOURCE.sector}`]: 7 }; + g.spiceDiscard = [[source], []]; + g.spiceDeck = [greatMaker, replacement, ...g.spiceDeck]; + const result = ready(g); + assert.equal(result.turn, 2); + assert.equal(result.phase, 1); + assert.equal(result.greatMaker?.stage, 'vote'); + assert.equal(result.decision?.kind, 'greatMakerVote'); + return result; +} + +function vote(state: Game, choices: readonly boolean[]): Game { + let g = state; + for (const yes of choices) { + assert.equal(g.decision?.kind, 'greatMakerVote'); + const decision = g.decision; + g = applyAction(g, decision.player, { + type: 'decision', + event: decision.event, + yes, + }); + } + return g; +} + +function rejectUnchanged( + state: Game, + player: string, + action: Action, + message?: RegExp, +) { + const before = structuredClone(state); + if (message) assert.throws(() => applyAction(state, player, action), message); + else assert.throws(() => applyAction(state, player, action)); + assert.deepEqual(state, before); +} + +void test('Great Maker waits for every storm-order vote, requires a strict majority, and a tie does not erase an existing Nexus', () => { + let g = encounter(true); + const order = [...g.order]; + assert.deepEqual(g.greatMaker?.order, order); + assert.equal(g.decision?.player, order[0]); + + rejectUnchanged(g, order[1], { + type: 'decision', + event: g.greatMaker!.event, + yes: true, + }); + g = vote(g, [true, false, true]); + assert.equal(g.greatMaker?.stage, 'vote'); + assert.equal(g.decision?.player, order[3]); + assert.equal( + g.nexus, + true, + 'the Nexus that existed before the blow remains active', + ); + g = vote(g, [false]); + assert.deepEqual( + g.greatMaker?.votes, + order.map((player, index) => ({ + player, + yes: [true, false, true, false][index], + })), + ); + assert.equal( + g.nexus, + true, + 'a tied Great Maker vote must not erase an earlier Nexus', + ); + assert.equal(g.greatMaker?.stage, 'ride'); + assert.equal(g.decision?.kind, 'greatMakerRide'); +}); + +void test('Great Maker creates a Nexus only after the complete ordered majority vote', () => { + let g = encounter(); + g = vote(g, [true, true, true]); + assert.equal( + g.nexus, + false, + 'even a secured majority waits for the remaining voter', + ); + assert.equal(g.greatMaker?.stage, 'vote'); + g = vote(g, [false]); + assert.equal(g.nexus, true); + assert.equal(g.greatMaker?.stage, 'ride'); + + let tied = encounter(); + tied = vote(tied, [true, false, true, false]); + assert.equal(tied.nexus, false); +}); + +void test('Great Maker resolves ordinary worm devastation, then rides only reserve Fremen forces for free', () => { + let g = encounter(); + const source = `${SOURCE.territory}:${SOURCE.sector}`; + const fremen = g.players.find((player) => player.id === 'f')!; + const atreides = g.players.find((player) => player.id === 'a')!; + assert.equal( + fremen.forces[source], + 4, + 'Fremen survive the ordinary worm effect', + ); + assert.equal(fremen.tanks, 0); + assert.equal(atreides.forces[source], undefined); + assert.equal(atreides.tanks, 3); + assert.equal(g.spice[source], undefined); + assert.deepEqual( + g.wormRides, + [], + 'the board army is not queued as this reserve ride', + ); + + g = vote(g, [false, false, false, false]); + assert.equal(g.decision?.kind, 'greatMakerRide'); + const event = g.greatMaker!.event; + const before = { + reserves: fremen.reserves, + spice: fremen.spice, + shipped: fremen.shipped, + moved: fremen.moved, + source: fremen.forces[source], + }; + g = applyAction(g, 'f', { + type: 'decision', + event, + accept: true, + territory: 'polar_sink', + sector: 0, + amount: 2, + elite: 0, + }); + const after = g.players.find((player) => player.id === 'f')!; + assert.equal(after.reserves, before.reserves - 2); + assert.equal(after.forces['polar_sink:0'], 2); + assert.equal( + after.forces[source], + before.source, + 'board forces stay at the worm territory', + ); + assert.equal(after.spice, before.spice); + assert.equal(after.shipped, before.shipped); + assert.equal(after.moved, before.moved); + assert.deepEqual(g.wormRides, []); + assert.equal(g.greatMaker?.stage, 'complete'); + assert.notEqual(g.decision?.kind, 'wormRide'); +}); + +void test('Great Maker rejects malformed, out-of-order, reserve-source and stale decisions without mutation', () => { + let g = encounter(); + assert.ok(g.decision?.kind === 'greatMakerVote'); + const current = g.decision; + const next = g.greatMaker!.order[1]; + rejectUnchanged(g, next, { + type: 'decision', + event: current.event, + yes: true, + }); + rejectUnchanged(g, current.player, { + type: 'decision', + event: 'stale', + yes: true, + }); + rejectUnchanged(g, current.player, { + type: 'decision', + event: current.event, + yes: true, + extra: true, + }); + rejectUnchanged(g, current.player, { + type: 'decision', + event: current.event, + yes: 'yes', + }); + + g = applyAction(g, current.player, { + type: 'decision', + event: current.event, + yes: true, + }); + rejectUnchanged(g, current.player, { + type: 'decision', + event: current.event, + yes: false, + }); + g = vote(g, [false, false, false]); + assert.ok(g.decision?.kind === 'greatMakerRide'); + const ride = g.decision; + rejectUnchanged(g, 'a', { + type: 'decision', + event: ride.event, + accept: false, + }); + rejectUnchanged(g, 'f', { + type: 'decision', + event: 'stale', + accept: false, + }); + rejectUnchanged(g, 'f', { + type: 'decision', + event: ride.event, + accept: false, + territory: 'polar_sink', + }); + rejectUnchanged(g, 'f', { + type: 'decision', + event: ride.event, + accept: true, + from: `${SOURCE.territory}:${SOURCE.sector}`, + territory: 'polar_sink', + sector: 0, + amount: 1, + elite: 0, + }); + const declined = applyAction(g, 'f', { + type: 'decision', + event: ride.event, + accept: false, + }); + rejectUnchanged(declined, 'f', { + type: 'decision', + event: ride.event, + accept: false, + }); +}); + +void test('Great Maker survives JSON restoration, exposes only public progress, detaches projections, and gives every AI profile legal choices', () => { + const restored = JSON.parse(JSON.stringify(encounter())) as Game; + const decision = restored.decision!; + for (const player of restored.players) { + const view = viewGame(restored, player.id); + assert.ok(view.greatMaker); + assert.equal(view.greatMaker.event, restored.greatMaker!.event); + assert.deepEqual(view.greatMaker.order, restored.order); + assert.deepEqual(view.greatMaker.votes, []); + assert.equal(view.greatMaker.ride, null); + assert.equal('territory' in view.greatMaker, false); + assert.equal('ridesBefore' in view.greatMaker, false); + assert.equal('signature' in view.greatMaker, false); + assert.equal('pile' in view.greatMaker, false); + assert.equal('index' in view.greatMaker, false); + } + + for (const difficulty of DIFFICULTIES) { + const view = viewGame(restored, decision.player); + view.players.find((player) => player.id === decision.player)!.bot = + difficulty; + const before = structuredClone(view); + const actions = botActions(view); + assert.deepEqual(view, before); + assert.equal(actions.length, 1); + assert.doesNotThrow(() => + applyAction(restored, decision.player, actions[0]), + ); + } + + const projected = viewGame(restored, decision.player); + const authoritative = structuredClone(restored); + projected.greatMaker!.votes.push({ player: 'outside', yes: true }); + projected.greatMaker!.order.reverse(); + assert.deepEqual( + restored, + authoritative, + 'a projected vote history must not alias authoritative state', + ); + + let ride = vote(restored, [true, false, true, false]); + ride = JSON.parse(JSON.stringify(ride)) as Game; + assert.equal(ride.decision?.kind, 'greatMakerRide'); + for (const player of ride.players) { + const view = viewGame(ride, player.id); + assert.equal(view.greatMaker?.ride !== null, player.faction === 'fremen'); + } + for (const difficulty of DIFFICULTIES) { + const view = viewGame(ride, 'f'); + view.players.find((player) => player.id === 'f')!.bot = difficulty; + const before = structuredClone(view); + const actions = botActions(view); + assert.deepEqual(view, before); + assert.ok(actions.some((action) => action.accept === true)); + assert.ok(actions.some((action) => action.accept === false)); + for (const action of actions) + assert.doesNotThrow( + () => applyAction(ride, 'f', action), + `${difficulty}: ${JSON.stringify(action)}`, + ); + } +}); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 4803b062..853424e6 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -1,5 +1,10 @@ import type { DatabaseSync } from 'node:sqlite'; -import { initializeIxGameForAudit, viewGame, type Game } from '../game/engine'; +import { + initializeIxGameForAudit, + initializeDiscoveryGameForAudit, + viewGame, + type Game, +} from '../game/engine'; /** Local development only. This cannot create a seat, change its owner, or * redeal a started game. The caller backs up the database before invoking it. */ @@ -7,6 +12,14 @@ export function startIxPrototypeRoom( db: DatabaseSync, code: string, expectedVersion: number, +) { + return startPrototypeRoom(db, code, expectedVersion, 'ix'); +} +export function startPrototypeRoom( + db: DatabaseSync, + code: string, + expectedVersion: number, + profile: 'ix' | 'discovery', ) { if ( !/^[A-Z0-9]{8}$/.test(code) || @@ -24,7 +37,12 @@ export function startIxPrototypeRoom( const initial = JSON.parse(row.state) as Game; if (initial.code !== code) throw new Error('The room identity is inconsistent.'); - const game = initializeIxGameForAudit(initial); + if (!['ix', 'discovery'].includes(profile)) + throw new Error('Unknown prototype profile.'); + const game = + profile === 'ix' + ? initializeIxGameForAudit(initial) + : initializeDiscoveryGameForAudit({ ...initial, discoveryEnabled: true }); // Exercise the same player projection before accepting the new saved state. for (const player of game.players) viewGame(game, player.id); const version = expectedVersion + 1; diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index ea5ec5c9..a19d9a65 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -4,13 +4,14 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { captureSavedGames } from './saved-game-verification'; -import { startIxPrototypeRoom } from './prototype-room'; +import { startPrototypeRoom } from './prototype-room'; import { sourceSnapshot } from './verification'; async function main() { const { values } = parseArgs({ options: { db: { type: 'string' }, + profile: { type: 'string', default: 'ix' }, room: { type: 'string' }, version: { type: 'string' }, out: { type: 'string' }, @@ -19,11 +20,12 @@ async function main() { }); if (values.help) { console.log( - 'Usage: node --import tsx tools/start-prototype.ts --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready Ix expansion lobby for local prototyping. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', + 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready Ix expansion or base-faction Discovery lobby for local prototyping. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', ); return; } if ( + !['ix', 'discovery'].includes(values.profile) || !values.db || !values.room || !values.out || @@ -44,14 +46,19 @@ async function main() { throw new Error('The requested lobby version is absent from the backup.'); const db = new DatabaseSync(values.db); try { - const result = startIxPrototypeRoom(db, values.room, version); + const result = startPrototypeRoom( + db, + values.room, + version, + values.profile as 'ix' | 'discovery', + ); writeFileSync( resolve(values.out, 'prototype.json'), JSON.stringify( { format: 1, startedAt: new Date().toISOString(), - profile: 'ix', + profile: values.profile, source, beforeVersion: version, ...result,