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.
This commit is contained in:
Quintine committed 2026-10-24 12:32:13 +11:00
1 parent 6fabe6aa62
commit c95e294890
9 files changed
+404 -15

No files matched your search

+8 -2
View File
@@ -5999,8 +5999,14 @@ export function GameTable({
{c.effect === 'thumper' && (
<p className="fine">
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}
</p>
)}
{c.effect === 'ghola' && (
+19
View File
@@ -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.
+20
View File
@@ -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
+92
View File
@@ -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
+1
View File
@@ -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) |
+5 -1
View File
@@ -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(
+12 -12
View File
@@ -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.',
],
+174
View File
@@ -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<typeof orderIxSpiceCards> = [], 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<typeof createIxSpiceFixture>;
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<typeof orderIxSpiceCards>[] = [];
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 };
}
+73
View File
@@ -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);
}
});