From c95e2948908a853289a0c9b0c09143fb41e37fa8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:32:13 +1100 Subject: [PATCH] Document original Ix Spice card policies and readable effects Retain the current first-turn worm precedence, later Thumper/Sandtrout equivalence and sequential physical Harvester doubling under explicit provisional preview interpretations. Record photographed component and publisher-source provenance, authorized Advanced corroboration, and unresolved wider timing boundaries. No new numerical rule or publisher clarification is claimed. Warn persistently before first-turn Thumper disposal and explain waiting Sandtrout replacements. Public chronicle entries explain ignored first-turn Thumper and Sandtrout encounters. Correct stale preview gates in the relevant reference topics. Genuine public Basic/Advanced selected-Ix programmes use original setup, unopened deal and Spice lotteries, real intervening turns and actual held physical cards. Separate assertions prove phase continuation, Nexus/doubling behavior, ready reset and retained physical stock; four existing minimal profiles legally accept a harvested window. Focused tests, types/lint/build and actual GameTable rendering are recorded in docs/IX_PROTOTYPE.md. This remains bounded prototype evidence, not full native factions, optional combinations, browser/deployed or final assurance. No strategic AI refinement, state version, saved-room/database/reset/restart/deployment operation. --- components/game-table.tsx | 10 +- docs/CURRENT_STATUS.md | 19 +++ docs/IMPLEMENTATION_STATUS.md | 20 ++++ docs/IX_PROTOTYPE.md | 92 +++++++++++++++ docs/RULE_DECISIONS.md | 1 + game/engine.ts | 6 +- game/reference.ts | 24 ++-- tests/fixture-ix-spice-cards.ts | 174 ++++++++++++++++++++++++++++ tests/ix-spice-cards-engine.test.ts | 73 ++++++++++++ 9 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 tests/fixture-ix-spice-cards.ts create mode 100644 tests/ix-spice-cards-engine.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 101315d1..212bce0e 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -5999,8 +5999,14 @@ export function GameTable({ {c.effect === 'thumper' && (

Play before the first spice draw to resolve Shai-Hulud - without drawing a card. Fremen protection, rides, Nexus - and a waiting Sandtrout apply. + without drawing a card. On later turns, Fremen protection, + rides, Nexus and a waiting Sandtrout apply.{' '} + {g.turn === 1 ? <>First-turn preview policy: spending + Thumper discards it, but its worm encounter is ignored. + Normal spice drawing continues. : g.sandtrout ? <> + A waiting Sandtrout will suppress this encounter. Only + an immediate territory replacement receives double spice; + another worm instead resolves normally. : null}

)} {c.effect === 'ghola' && ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e521fa0f..77fdcb50 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,25 @@ open mode gates. ## Current checkpoint and work +**Original Ix Spice cards — 24 October. Prototyped / Partial.** The historical +Thumper/Sandtrout/Harvester audit now records explicit provisional first-turn +precedence, later generated-worm suppression and sequential physical doubling. +The source review found no reason to change the existing numerical mechanics. +Held Thumper warnings explain first-turn disposal and immediate replacements; +public logs explain ignored encounters. Relevant reference topics now describe +the actual preview controls instead of stale public-start prohibitions. + +A disjoint genuine-fixture lane, bounded source review and owner integration +pass **4 files47/47**, types/lint/build, twelve original Basic/Advanced gameplay/ +rendering programmes and four minimal legal profiles. Actual starting cards, +native Sandtrout across a real turn, Nexus and both Harvesters produce fresh8, +32 or enhanced64 as selected, reset readiness and finish the actual phase while +retaining original physical stock. A private rendering assertion was corrected +for the collapsed chronicle; runtime alone reran with source unchanged. No new +publisher clarification, module opening, broad assurance or native-faction/full- +mode/browser/deployed certification. [Sources, exact proof and remaining scope](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026). +No saved-room/database/reset/restart/deployment operation in this rule batch. + **Basic Tupile slot lifecycle — 24 October. Prototyped / Partial.** The existing provisional Basic current-sole policy now supplies Tupile's slot and normal-limit cleanup instead of stopping on its conservative lifecycle guard. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index bf016d35..3abcbc3c 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,25 @@ # Dune implementation status +## 24 October 2026 — Original Ix Spice card interpretations + +Retain existing Thumper/Sandtrout/Harvester mechanics after bounded printed-source +review, with explicit provisional first-turn precedence, later generated-worm +suppression and sequential physical doubling. Persistent held-card warnings +explain first-turn disposal and replacement behavior; public logs explain ignored +encounters. Correct stale preview-gate claims in the relevant reference topics. + +A disjoint genuine original-source fixture lane, bounded source review and owner +integration pass4-file47/47, types/lint/build, twelve original Basic/Advanced +gameplay/rendering programmes and four legal policies. Actual original Thumper, +both distinct Harvesters and native Sandtrout across a real turn finish the phase +with expected8/32/enhanced64, Nexus membership, readiness reset and physical stock. +The only final feedback repair was a private rendering assertion about the +default-collapsed chronicle; source-identical runtime alone reran. [Canonical +sources, selected policy, action counts and frozen evidence](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026). +No new numerical ruling, module opening, strategy calibration, wider native- +faction/combination/browser/deployed or final assurance claim. No saved-room/ +database/reset/restart/deployment operation in this rule batch. + ## 24 October 2026 — Basic occupied Tupile lifecycle Extend the existing provisional Basic current-sole policy to one-slot grants and diff --git a/docs/IX_PROTOTYPE.md b/docs/IX_PROTOTYPE.md index 3904e84e..16ab9e74 100644 --- a/docs/IX_PROTOTYPE.md +++ b/docs/IX_PROTOTYPE.md @@ -1,5 +1,97 @@ # Ixians & Tleilaxu functional prototype +## Original Spice cards and selected policy — 24 October 2026 + +Normal lobbies now offer labelled Basic/Advanced expansion previews through +the [current entry controls](EXPANSION_FACTIONS_PROTOTYPE.md). The September +initializer and evidence below are historical; complete expansion rules and +publication acceptance remain open. + +This bounded source review covers original Thumper, Sandtrout and the two +physical Harvesters. It retains existing mechanics under the standing +14 October choose-recommended instruction, with these explicit interpretations: + +- Thumper creates an encounter without consuming a real Spice Card. The + [original photographed card faces](https://static.lelekan.ua/image/cache/catalog/igri/dune-ixians-tleilaxu-expansion-eng/dune-ixians-tleilaxu-expansion-gale-force-nine-eng-lelekan-components-2-800x800.jpg) + replace the reveal with Shai-Hulud. The [GF9 November FAQ, printed page 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8) + confirms Nexus and Advanced subsequent-worm placement; it does not expressly + decide first-turn precedence. +- Provisionally give the [base first-turn rule, printed page 8](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=8) + precedence over both generated and drawn worms. Thumper is discarded but its + first-turn encounter is ignored; a drawn first-turn worm does not consume + waiting Sandtrout or earn doubling. Real ignored worms return to the original + deck, while the virtual encounter never becomes an extra physical card. +- On later turns, waiting Sandtrout suppresses Thumper by the card's equivalence + to Shai-Hulud. This composition is provisional, not a named publisher ruling. + The [E1 rulebook, printed page 11](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=11) + limits doubling to the immediate replacement territory. Another worm instead + resolves normally, including its Nexus; a later territory receives no bonus. +- Each original Harvester doubles the current fresh blow while its window + remains open. Two distinct copies therefore give four times the original + blow, or eight times when an immediate Sandtrout replacement already doubled + it. Pre-existing ground spice is unchanged. This is the recommended sequential + reading of the photographed faces, not an independently confirmed FAQ result. + +The photograph shows original components but is hosted by a retailer. Direct +official PDF retrieval returned 403; publisher-domain indexed excerpts and those +photographed faces supplied the bounded review. The authorized local Advanced +PDF corroborates first-turn ignoring on physical page 7 (printed 5), Thumper on +physical 41 (printed 39), and Sandtrout on physical 42 (printed 40). It does not +settle the stated ambiguities and supplies no new Basic authority. + +The held Thumper explanation persistently warns about first-turn disposal before +the player spends it. Waiting Sandtrout also has an immediate-replacement warning. +Public chronicle entries explain ignored first-turn encounters. These are +readable controls and feedback; no extra confirmation, timing restriction or +numerical effect was introduced. + +### Focused proof and limits + +`tests/fixture-ix-spice-cards.ts` starts actual three-seat public Basic/Advanced +selected-Ix games with Atreides, Harkonnen and Fremen. Only four unopened original +Treachery deal positions and specified unopened Spice prefixes are selected; +native setup, phase openings and intervening turns actually execute. No dealt +hand, force, wallet, mode, turn or effect flag is installed. The independent +`tests/ix-spice-cards-engine.test.ts` assertions cover first/later Thumper, +first-turn Sandtrout/worm precedence, an immediate doubled replacement, a normal +replacement worm and both original Harvesters. All twelve programmes finish the +actual Spice phase, retain all 47 physical Treachery identities and preserve the +six real worms, fifteen territories and one physical/waiting Sandtrout. A second +Harvester clears earlier readiness and spends its own original physical card. + +Frozen integration passes **4 files, 47/47 tests**, types/lint and production +build. Twelve original gameplay programmes also render the actual GameTable: +held-card warnings appear before disposal, and public projected logs retain the +new ignored-encounter explanation. Four existing minimal difficulty profiles +each submit one legal acceptance from the actual harvested window and finish +the phase; this is participation evidence, not strategy calibration. + +| Programme | Basic source + continuation actions | Advanced source + continuation actions | Actual fresh-blow outcome | +| --- | --- | --- | --- | +| First-turn Thumper | 18 + 7 | 20 + 10 | 8; no Nexus | +| Later ordinary Thumper | 78 + 10 | 83 + 13 | 8; Nexus | +| First-turn Sandtrout then worm | 18 + 9 | 20 + 12 | 8; Sandtrout still waiting | +| Two original Harvesters | 18 + 12 | 20 + 15 | 8 → 16 → 32 | +| Waiting Sandtrout then Thumper and two Harvesters | 18 + 70 | 20 + 76 | 8 → 16 → 32 → 64; no Nexus | +| Waiting Sandtrout then Thumper and replacement worm | 18 + 70 | 20 + 76 | 8; Nexus, no delayed doubling | + +Source identity: base `6fabe6aa627845a8521049ea217faf9f9cc54826`, frozen tree +`c4fa1d22163a3b0ebd00e5107ddef4a4b76fed00c0d8a3e36a5fbe8671ae96c5`, +2,603 tracked/nonignored files. Private reports, original failed rendering check, +repaired runtime, HTML and proof manifest live outside Git under +`20261024-ix-spice-cards/`. The initial rendering check incorrectly expected text +inside the default-collapsed chronicle. Only that private assertion changed to +check the public projected log; runtime alone reran on the identical source. +The final aggregate retains the original passing tests/types/lint/build with +their logs and links the repaired runtime explicitly. Subsequent checkpoint +prose/reference changes are separately reviewed; runtime source stays frozen. + +These source programmes do not certify native Ixian/Tleilaxu decisions, all +phase-opening priorities, optional-module combinations, full modes, save/recovery +assurance, browser visuals or deployed acceptance. Those requirements remain due. + +## Historical prototype — 13 September 2026 + 13 September 2026. This connects the existing expansion mechanics to genuine setup and an ordinary saved browser table. It is a **working prototype**, with rules coverage still partial. Normal Advanced/expansion start gates and the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 46e9c0b2..a4637753 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -803,6 +803,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Topic | Decision to preserve | Detailed authority and evidence | | --- | --- | --- | +| Original Ix Spice cards | Under standing choose-recommended authority, retain first-turn ignoring ahead of Thumper/Sandtrout, later generated-worm suppression and sequential doubling by each distinct Harvester. First-turn Thumper remains spendable with a persistent warning. Immediate replacement-worm behavior follows the E1 source; the stated precedence/equivalence/multiple-copy compositions remain provisional, not newly confirmed GF9 rulings. No numerical change or wider module certification. | [Sources, policy and original Basic/Advanced proof](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026) | | Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic uses the visibly provisional current-sole policy recorded in the Basic table above, including Tupile through observed continuous activation sources and original normal-limit cleanup. Turn boundaries do not stack slots; reacquisition cannot erase an owed discard. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026), [Tupile Basic lifecycle and original consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#basic-current-sole-slot-lifecycle--24-october-2026) | | Printed high Ecaz Homeworld victory | Fresh native Ecaz/Homeworld entry only: current high7+, reciprocal alliance jointly holds one actual uncontested stronghold and proved physically held worlds of two other native factions. Separate source joins ordinary winner union before BG prediction/final fallback; no ordinary point inflation, Tech substitute or Duke permission. Basic holdings now follow the provisional current-sole policy, so a repopulated, contested or departed world simply stops counting instead of blocking the win; old/public profiles unchanged. | [Card contract and actual original win](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026) | | Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) | diff --git a/game/engine.ts b/game/engine.ts index 043af044..5796fb73 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -12147,7 +12147,11 @@ function continueSpice(g: Game, injected?: SpiceCard) { } doubleNext = false; if (g.turn === 1) { - if (!card.thumper) sequence.skipped.push(card); + if (!card.thumper) { + sequence.skipped.push(card); + if (g.sandtrout) + log(g, 'The first-turn worm rule leaves Sandtrout waiting; this ignored encounter adds no double-spice bonus.'); + } else log(g, 'The first-turn worm rule ignores this Thumper encounter; the normal Spice Card draw continues.'); } else if (g.sandtrout) { g.sandtrout = false; g.spiceDiscard[sequence.pile].push( diff --git a/game/reference.ts b/game/reference.ts index afebc3d7..b97650ee 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1856,16 +1856,16 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The expansion includes Poison Blade, Shield Snooper, Weirding Way, Chemistry, Poison Tooth, Artillery Strike, Thumper and Amal, plus the six ordinary card types below.', 'Hunter Seeker is a projectile weapon and Basilia Weapon is a poison weapon. Normal matching defenses and Voice categories apply. Basilia Weapon belongs to this expansion; Ellaca Drug is the base-game poison card.', 'The additional Shield and Snooper behave like their base copies, including the Shield’s lasgun interaction. Kull Wahad is a Worthless card and can be played in a battle plan or converted with advanced Bene Gesserit’s Karama advantage.', - 'The second Harvester is a separate card. Each available copy can be played while the fresh blow window remains open; the current implementation doubles that blow again, without multiplying pre-existing spice. Neither copy can restore a worm-destroyed blow or create spice in storm. Multiple-copy timing and multiplier interpretation remain under card-face audit.', + 'The second Harvester is a separate card. The provisional preview interpretation permits each available copy during the open fresh-blow window, doubling its current amount again: both copies yield four times the fresh blow without multiplying pre-existing spice. Neither copy can restore a worm-destroyed blow or create spice in storm.', 'With this expansion the combined deck has forty-seven distinct card identities: five ordinary projectile weapons, five ordinary poison weapons, five ordinary Shields, five ordinary Snoopers, six Worthless cards and two Harvesters, in addition to the other base and special expansion cards.', - 'The local development prototype uses genuine setup with the complete deck, Sandtrout, Ixians and Tleilaxu in Basic or Advanced play. Starting cards, private choices, controls, AI and saved continuation use the normal game paths. Public expansion starts remain disabled pending complete rule and interaction acceptance.', + 'Labelled Basic and Advanced expansion previews use genuine setup with the complete deck, Sandtrout and the selected factions. Starting cards, private choices, controls and minimal legal AI use the normal paths. Preview access does not certify complete expansion rules or combinations.', ], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Working prototype with the actual 47-card setup, both expansion factions and Sandtrout. Optional module combinations and remaining printed-card interpretations are unfinished.', evidence: ['game/cards.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Implemented', detail: 'Prototype rooms use the existing private setup, faction, battle-card and special-card controls. Local development initialization preserves the normal release gate.', evidence: ['components/game-table.tsx', 'tools/start-prototype.ts'] }, - { area: 'AI', status: 'Partial', detail: 'All four profiles share the existing legal private actions. Complete-game prototype runs exercise both expansion factions; comprehensive strength calibration remains later work.', evidence: ['game/bots.ts', 'tests/ix-prototype.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Actual 47-card setup and original Sandtrout/Thumper/Harvester mechanics retain explicit provisional first-turn precedence and sequential doubling. Wider faction/module interactions remain unfinished.', evidence: ['game/cards.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Implemented', detail: 'Normal preview setup and held-card controls include persistent first-turn Thumper and waiting-Sandtrout explanations before spending. Public ignored-encounter feedback explains the result.', evidence: ['components/game-table.tsx', 'docs/EXPANSION_FACTIONS_PROTOTYPE.md'] }, + { area: 'AI', status: 'Partial', detail: 'All four existing profiles legally accept an actual harvested window and finish the phase. Historical complete-game samples separately exercise native expansion factions; strategic refinement and strength calibration remain deferred.', evidence: ['game/bots.ts', 'tests/ix-spice-cards-engine.test.ts', 'tests/ix-prototype.test.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'The prototype setup record separates functional progress, source interpretations and final acceptance.', evidence: ['docs/IX_PROTOTYPE.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Genuine setup, complete prototype games, private views, physical-card custody and saved continuation have targeted checks. This does not certify every combination.', evidence: ['tests/ix-prototype.test.ts', 'tests/prototype-room.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Affected4-file47/47, types/lint/build, twelve original three-classic-seat Basic/Advanced gameplay/rendering programmes and four minimal legal profiles pass. First/later Thumper, first-turn Sandtrout, replacement worms and two physical Harvesters retain original card stock. Historical native-faction samples remain separate; full combinations, browser/deployed and final assurance remain open.', evidence: ['tests/fixture-ix-spice-cards.ts', 'tests/ix-spice-cards-engine.test.ts', 'tests/ix-prototype.test.ts', 'tests/prototype-room.test.ts'] }, ], related: [ 'ix-battle-cards', @@ -2118,14 +2118,14 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', summary: - 'Start the spice phase with a worm encounter without consuming the next spice card. Tested support exists; expansion decks remain gated.', + 'Start the spice phase with a worm encounter without consuming the next spice card. First-turn precedence is an explicit provisional preview policy.', steps: [ 'Play Thumper at the opening of Spice Blow, before the first reveal. Discard the treachery card. Resolve a Shai-Hulud encounter against the previous territory in the first spice discard pile.', 'Fremen survival, optional allied protection, cancellation responses, Nexus and rides resolve through the ordinary worm sequence. If protection needs a decision, the actual next spice card stays in the deck until that decision finishes.', 'Continue drawing afterward to reach a territory. In the advanced game, another worm drawn after Thumper is an additional worm and can offer Fremen placement. The second spice blow then resolves normally against its separate pile.', - 'A waiting Sandtrout suppresses the encounter. Its immediate territory replacement receives double spice; a replacement worm instead resolves normally.', + 'After the first turn, a waiting Sandtrout suppresses the encounter under the provisional preview interpretation. Its immediate territory replacement receives double spice; a replacement worm instead resolves normally.', 'Thumper does not add a Shai-Hulud card to the spice deck. The generated encounter is remembered in the discard history for timing but disappears when the deck is reshuffled.', - 'The implementation currently treats first-turn Thumper as an ignored worm, retaining exactly the original worm-card count. The precise first-turn allowance, initial-phase timing and Sandtrout interaction remain printed-card audit items before live expansion starts are enabled.', + 'First-turn preview policy: Thumper may be spent and discarded, but its worm encounter is ignored and normal spice drawing continues. The held-card explanation warns before spending. This applies the ordinary first-turn rule by inference; it is not a separately confirmed publisher ruling. Broader phase-opening priorities remain under review.', ], related: ['spice-blow', 'sandtrout', 'advanced-storm-spice', 'ix-modules'], }, @@ -2135,14 +2135,14 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', summary: - 'A pending spice-card effect that can cross phases and turns. It is included in local Ix prototype setup; normal expansion starts remain gated.', + 'A pending spice-card effect that can cross phases and turns. Original selected-Ix previews retain explicit provisional first-turn and Thumper interpretations.', steps: [ 'Drawing Sandtrout immediately breaks existing alliances and clears pending alliance offers. It is then held aside while spice draws continue. A territory drawn before the next active Shai-Hulud receives its usual spice amount.', 'The next active Shai-Hulud drawn is suppressed: it does not devour the previous territory, offer Fremen protection or a ride, or trigger its own Nexus. Discard the consumed Sandtrout with that suppressed worm.', 'If the immediate replacement is a territory, double its spice. Harvester can then double that enhanced blow again. Spice still cannot appear in the storm.', 'If the immediate replacement is another Shai-Hulud, resolve it normally and do not double a later territory. A normal worm can trigger a Nexus, and further normal worms can offer advanced Fremen placement.', 'The pending Sandtrout effect survives until consumed, including between the two advanced spice blows and across turns. Its status is public. A still-hidden Sandtrout at the top of the deck is visible only to authorized Atreides foresight.', - 'The engine preserves first-turn ignored worms without consuming Sandtrout, and treats a summoned Fremen worm separately from drawn Shai-Hulud. Those timing interpretations and later expansion interactions still require a full printed-card audit before expansion starts are enabled.', + 'First-turn preview policy gives ignored worms precedence: Sandtrout remains waiting and no doubling is earned. Later Thumper equivalence lets Sandtrout suppress that generated encounter. Both compositions are labelled provisional. A separately summoned Fremen worm and wider module/phase-opening interactions retain their separate contracts and audit boundaries.', ], related: [ 'spice-blow', @@ -2586,7 +2586,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Play after the territory card is revealed and before the table accepts the blow.', 'Only the new blow is doubled; existing spice is unchanged. Spice under the storm still does not appear.', - 'With the Ixian expansion there are two separate Harvester cards. The current implementation permits both on an open blow, doubling its current amount each time; multiple-copy timing and multiplier interpretation remain under card-face audit.', + 'With the Ixian expansion there are two separate Harvester cards. The provisional preview interpretation applies each physical card to the current fresh blow: both produce four times that blow, or eight times the printed amount after an immediate Sandtrout doubling. This sequential reading is not a separately confirmed publisher ruling.', 'A worm that destroys the fresh blow closes its Harvester window. This does not increase your forces’ collection rate.', 'A saved play preserves the doubled blow and its existing ground spice. Recovery does not add the spice again or consume another Harvester.', ], diff --git a/tests/fixture-ix-spice-cards.ts b/tests/fixture-ix-spice-cards.ts new file mode 100644 index 00000000..7766cb8f --- /dev/null +++ b/tests/fixture-ix-spice-cards.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { spiceDeck, treacheryDeck, type SpiceCard } from '../game/cards'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +export type IxSpiceStep = { actor: string; action: Action }; +export const ixSpicePlayer = (game: Game, actor = 'atreides') => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const ixSpiceClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; +export const beforeIxSpiceDraw = (game: Game) => game.status === 'playing' && game.phase === 1 && + ixSpiceClean(game) && !game.spiceSequence && !game.spiceResolution && !game.spiceWindow && !game.nexus; +export function actIxSpice(game: Game, actor: string, action: Action, actions: IxSpiceStep[] = []): Game { + const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} +/** Original controls, without strategic card use or a scored movement policy. + * The real Fremen setup puts its printed ten ordinary forces in Sietch Tabr; + * Advanced starred counters remain in the original reserve pool. */ +export function nextIxSpiceStep(game: Game): IxSpiceStep | null { + if (game.status === 'setup' && game.setupStage === 'forces' && ixSpicePlayer(game, 'fremen').reserves === 20 && + !game.response && !game.decision && !game.phaseOpening) + return { actor: 'fremen', action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } }; + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + const decision = game.decision; + if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement') + return { actor: decision.player, action: { type: 'decision', accept: false } }; + if (decision?.kind === 'wormProtection') + return { actor: decision.player, action: { type: 'decision', accept: true } }; + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceIxSpice(game: Game, until: (state: Game) => boolean, actions: IxSpiceStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Ix Spice programme ended before its next control'); + const step = nextIxSpiceStep(game); assert.ok(step, 'The original native control requires its actual owner'); + game = actIxSpice(game, step.actor, step.action, actions); + } + throw Error('The original Ix Spice programme did not reach its finite boundary'); +} + +export type IxSpicePick = 'land' | 'worm' | 'sandtrout' | { territory: string }; +/** Explicit unopened-deck lottery selection, not a draw/effect or source grant. + * Every chosen card already occupies the remaining original Spice deck. The + * helper preserves all physical cards and changes only its requested prefix. */ +export function orderIxSpiceCards(game: Game, picks: readonly IxSpicePick[]) { + assert.ok(beforeIxSpiceDraw(game), 'Order only the unopened deck before its first actual Spice Blow draw'); + const original = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const selected: { position: number; from: number; card: SpiceCard }[] = []; + for (const [position, pick] of picks.entries()) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && + (typeof pick === 'object' ? 'territory' in card && card.territory === pick.territory + : pick === 'land' ? 'territory' in card && !card.discovery + : pick === 'worm' ? 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper + : 'sandtrout' in card)); + assert.ok(from >= position, 'The requested encounter must remain in the unopened original Spice deck'); + const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); + selected.push({ position, from, card: structuredClone(card) }); + } + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), original); + return selected; +} + +/** Public original selected-Ix setup, not a Basic game reflagged Advanced or + * a hand/force scenario. Original unopened deal positions select the physical + * Thumper, both distinct Harvesters and Harkonnen's Karama. Native setup and + * all original Ix phase-opening confirmations actually execute. */ +export function createIxSpiceFixture(options: { advanced?: boolean; turn?: 1 | 2 } = {}) { + const advanced = options.advanced ?? false, turn = options.turn ?? 1; + let game = createGame('IXSPICECARDS', newPlayer('atreides', 'Atreides', 'atreides'), advanced, ['ix']); + for (const faction of ['harkonnen', 'fremen'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: IxSpiceStep[] = []; + const act = (actor: string, action: Action) => { game = actIxSpice(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceIxSpice(game, until, actions); }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); + assert.ok(game.players.every(player => player.hand.length === 0)); + assert.equal(game.deck.length, 47); + assert.equal(game.spiceDeck.length, 22); + const thumper = game.deck.find(card => card.effect === 'thumper'); + const harvesters = game.deck.filter(card => card.effect === 'harvester'); + const karama = game.deck.find(card => card.effect === 'karama'); + assert.ok(thumper && karama && harvesters.length === 2); + const nativeHarvester = harvesters.find(card => !card.id.startsWith('ix-')); + const ixHarvester = harvesters.find(card => card.id === 'ix-harvester'); + assert.ok(nativeHarvester && ixHarvester); + const startingDeal = [thumper.id, nativeHarvester.id, karama.id, ixHarvester.id]; + for (const [position, id] of startingDeal.entries()) { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + } + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.ok(ixSpicePlayer(game).hand.some(card => card.id === thumper.id)); + assert.ok(ixSpicePlayer(game, 'harkonnen').hand.some(card => card.id === nativeHarvester.id)); + assert.ok(ixSpicePlayer(game, 'harkonnen').hand.some(card => card.id === karama.id)); + assert.ok(ixSpicePlayer(game, 'fremen').hand.some(card => card.id === ixHarvester.id)); + const originalTreachery = treacheryDeck(['ix']).map(card => card.id).sort(); + assert.equal(originalTreachery.length, 47); + assert.equal(spiceDeck(true).length, 22); + reach(state => state.phase === 1 && !!state.phaseOpening); + const firstOpening = structuredClone(game); + reach(beforeIxSpiceDraw); + const firstBlow = structuredClone(game); + let firstTurnSpiceOrder: ReturnType = [], secondOpening: Game | null = null; + if (turn === 2) { + firstTurnSpiceOrder = orderIxSpiceCards(game, Array.from({ length: advanced ? 2 : 1 }, () => 'land' as const)); + reach(state => state.turn === 2 && state.phase === 1 && !!state.phaseOpening); + secondOpening = structuredClone(game); + reach(state => state.turn === 2 && beforeIxSpiceDraw(state)); + } + assert.equal(game.turn, turn); + assert.deepEqual(game.ready, []); + assert.equal(viewGame(game, 'atreides').beforeSpiceDraw, true); + assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)] + .map(card => card.id).sort(), originalTreachery); + for (const [actor, id] of [ + ['atreides', thumper.id], ['harkonnen', nativeHarvester.id], ['harkonnen', karama.id], ['fremen', ixHarvester.id], + ]) assert.ok(ixSpicePlayer(game, actor).hand.some(card => card.id === id), 'Actual intervening actions retain the original card'); + return { game, lobby, setup, beforeStartingDeal, afterSetup, firstOpening, firstBlow, secondOpening, + firstTurnSpiceOrder, advanced, turn, startingDeal, thumper: thumper.id, + harvesters: { harkonnen: nativeHarvester.id, fremen: ixHarvester.id }, karama: karama.id, + thumperHolder: 'atreides', counterHolder: 'harkonnen', originalTreachery, + sourceActionCount: actions.length, actions, + staging: ['Only four unopened original starting-deal positions and scalar lotteries are selected. Turn2 additionally selects only the unopened first-turn ordinary land Spice positions. Public original Basic/Advanced Ix-deck setup actually deals all physical cards; explicit native Fremen setup, phase openings, readiness, passed bank lots and ended movement turns produce the saved boundary. No dealt hand, army, wallet, phase, turn, Nexus or effect flag is installed. This fixture establishes the original pre-draw source; independent engine/runtime callers assert the explicit provisional policies documented in docs/IX_PROTOTYPE.md.'] }; +} +export type IxSpiceFixture = ReturnType; + +export type IxSpiceProgramme = 'first-thumper' | 'normal-thumper' | 'first-trout-worm' | + 'double-harvester' | 'waiting-thumper' | 'replacement-worm'; +/** Actual original controls implement each selected source interpretation. + * Effect assertions live in the independent engine cases/runtime caller. */ +export function runIxSpiceProgramme(mode: IxSpiceProgramme, advanced = false) { + const source = createIxSpiceFixture({ advanced, turn: mode === 'normal-thumper' ? 2 : 1 }); + let game = structuredClone(source.game); + const actions: IxSpiceStep[] = [], orders: ReturnType[] = []; + const act = (actor: string, action: Action) => { game = actIxSpice(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceIxSpice(game, until, actions); }; + const waiting = mode === 'waiting-thumper' || mode === 'replacement-worm'; + let waitingSource: Game | null = null; + if (waiting) { + orders.push(orderIxSpiceCards(game, ['sandtrout', 'land', ...(advanced ? ['land' as const] : [])])); + reach(state => !!state.spiceWindow); + waitingSource = structuredClone(game); + reach(state => state.turn === 2 && beforeIxSpiceDraw(state)); + } + const picks: IxSpicePick[] = mode === 'first-trout-worm' ? ['sandtrout', 'worm', 'land'] + : mode === 'replacement-worm' ? ['worm', 'land'] : ['land']; + if (advanced) picks.push('land'); + const prefix = orderIxSpiceCards(game, picks); orders.push(prefix); + const before = structuredClone(game), usesThumper = ['first-thumper', 'normal-thumper', 'waiting-thumper', 'replacement-worm'].includes(mode); + if (usesThumper) act(source.thumperHolder, { type: 'card', card: source.thumper }); + reach(state => !!state.spiceWindow && ixSpiceClean(state)); + const revealed = structuredClone(game); + let firstHarvester: Game | null = null, passed: Game | null = null, secondHarvester: Game | null = null; + if (mode === 'double-harvester' || mode === 'waiting-thumper') { + act('harkonnen', { type: 'card', card: source.harvesters.harkonnen }); + firstHarvester = structuredClone(game); + act('atreides', { type: 'ready' }); passed = structuredClone(game); + act('fremen', { type: 'card', card: source.harvesters.fremen }); + secondHarvester = structuredClone(game); + } + reach(state => state.phase === 2 && ixSpiceClean(state)); + return { mode, advanced, source, game, actions, orders, prefix, before, revealed, + waitingSource, firstHarvester, passed, secondHarvester, usesThumper }; +} diff --git a/tests/ix-spice-cards-engine.test.ts b/tests/ix-spice-cards-engine.test.ts new file mode 100644 index 00000000..855c17b2 --- /dev/null +++ b/tests/ix-spice-cards-engine.test.ts @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { botActions } from '../game/bots'; +import { viewGame, type Game } from '../game/engine'; +import { spiceDeck } from '../game/cards'; +import { actIxSpice, advanceIxSpice, ixSpiceClean, ixSpicePlayer as player, + runIxSpiceProgramme, type IxSpiceProgramme } from './fixture-ix-spice-cards'; + +const cardStock = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)] + .map(card => card.id).sort(); +function physicalSpice(game: Game) { + return [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceSequence?.skipped ?? [])] + .filter(card => !('worm' in card && card.thumper)); +} + +for (const advanced of [false, true]) for (const mode of [ + 'first-thumper', 'normal-thumper', 'first-trout-worm', 'double-harvester', 'waiting-thumper', 'replacement-worm', +] as IxSpiceProgramme[]) void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Ix Spice ${mode} follows the selected source policy through the actual phase`, () => { + const done = runIxSpiceProgramme(mode, advanced), f = done.source; + assert.equal(f.game.advanced, advanced); assert.deepEqual(f.game.expansions, ['ix']); + assert.ok(f.actions.some(step => step.action.type === 'start')); + assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0)); + assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.startingDeal); + assert.ok(player(f.afterSetup).hand.some(card => card.id === f.thumper)); + assert.ok(player(f.afterSetup, 'harkonnen').hand.some(card => card.id === f.harvesters.harkonnen)); + assert.ok(player(f.afterSetup, 'fremen').hand.some(card => card.id === f.harvesters.fremen)); + const land = done.prefix.find(row => 'territory' in row.card)!.card; assert.ok('territory' in land); + const amount = land.amount * (mode === 'waiting-thumper' ? 2 : 1); + assert.equal(done.revealed.spiceWindow!.territory, land.territory); + assert.equal(done.revealed.spiceWindow!.amount, amount); + assert.equal(done.revealed.nexus, mode === 'normal-thumper' || mode === 'replacement-worm'); + assert.equal(done.game.phase, 2); + assert.equal(done.game.sandtrout, mode === 'first-trout-worm'); + assert.deepEqual(cardStock(done.game), f.originalTreachery); + const physical = physicalSpice(done.game); + assert.equal(physical.filter(card => 'worm' in card).length, 6); + assert.equal(physical.filter(card => 'territory' in card).length, 15); + assert.equal(physical.filter(card => 'sandtrout' in card).length + (done.game.sandtrout ? 1 : 0), 1); + assert.equal(physical.length + (done.game.sandtrout ? 1 : 0), spiceDeck(true).length); + assert.equal(done.game.discard.filter(card => card.id === f.thumper).length, done.usesThumper ? 1 : 0); + if (mode === 'first-thumper') { + assert.ok(done.game.log.some(entry => /first-turn worm rule ignores this Thumper/.test(entry.text))); + assert.equal(done.game.spiceDiscard.flat().some(card => 'worm' in card && card.thumper), false); + } + if (mode === 'first-trout-worm') + assert.ok(done.game.log.some(entry => /first-turn worm rule leaves Sandtrout waiting/.test(entry.text))); + if (done.waitingSource) { + assert.equal(done.waitingSource.sandtrout, true); + assert.equal(done.before.turn, 2); assert.equal(done.before.sandtrout, true); + } + if (done.secondHarvester) { + assert.equal(done.firstHarvester!.spiceWindow!.amount, amount * 2); + assert.deepEqual(done.passed!.ready, ['atreides']); + assert.deepEqual(done.secondHarvester.ready, []); + assert.equal(done.secondHarvester.spiceWindow!.amount, amount * 4); + assert.equal(done.secondHarvester.spiceWindow!.harvesters, 2); + for (const [actor, card] of Object.entries(f.harvesters)) { + assert.equal(done.game.discard.filter(held => held.id === card).length, 1); + assert.equal(player(done.game, actor).hand.some(held => held.id === card), false); + } + } +}); + +void test('all four minimal profiles accept an actual harvested source window with a legal native continuation', () => { + const done = runIxSpiceProgramme('double-harvester', true), original = done.secondHarvester!; + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(original, 'fremen'); view.players.find(player => player.id === 'fremen')!.bot = difficulty; + const action = botActions(view)[0]; assert.deepEqual(action, { type: 'ready' }); + let game = actIxSpice(original, 'fremen', action); + game = advanceIxSpice(game, game => game.phase === 2 && ixSpiceClean(game)); + assert.equal(game.phase, 2); assert.deepEqual(cardStock(game), done.source.originalTreachery); + } +});