Preserve World occupation capabilities while Sandtrout waits

This commit is contained in:
Quintine committed 2026-10-26 12:18:06 +11:00
1 parent 09eb857583
commit 4ab20a2838
12 files changed
+403 -15

No files matched your search

+25
View File
@@ -16,6 +16,31 @@ open mode gates.
## Current checkpoint and work
**World occupation / live Sandtrout — 26 October. Prototyped / Partial.**
A real original Basic World/Tech draw reproduced an occupation-context rejection.
Clean World and World/all14 profiles now retain canonical pending Sandtrout as
live card state; original Nexus/Discovery contexts also reject malformed flags.
Start/role limits and printed effects remain unchanged.
Root profile/pure integration and one disjoint native lane pass **11 files,
70/70**, types/lint/build and **three native paths**,19 GameTable SSR renders.
Basic and first-turn ignored-worm sources use25+66 actions; Advanced all14/Tech/
Strongholds uses30+71. Original A/E/Ix +Ix setup retains47 Treachery,22 Spice and
optional14 Skills. Actual A2 Caladan→Broken Land costs4; waiting Sandtrout spans
Collection/Mentat/Storm, then suppresses the real next worm without killing that
army or removing its ground spice. Only immediate Hagga doubles; Advanced's
second ordinary pile remains undoubled. Ix Heighliners1, deferred E Banker1 at
Mentat and earned Strongholds stay separate. Four minimal policies each submit
a legal next action per source. No played flag/card/force/wallet/clock is installed.
The persistent waiting notice appears at the source, Collection, Mentat and next
Storm, then clears after suppression. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-world-sandtrout-proof-v1/`;
journeys/HTML: adjacent `20261026-world-sandtrout-runtime/`.
No saved-room/D1/server, reset/restart, deployment or assurance campaign.
SSR/public-action evidence leaves browser/mobile/deployed, wider combinations
and complete-mode acceptance open. [Source and contract](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026).
**Homeworld occupation / original Nexus and Discovery — 26 October. Prototyped / Partial.**
Shared fresh World setup now initializes mandatory occupation benefits in its
existing Nexus and/or Discovery envelopes, optionally Skills and phase modules.
+44 -2
View File
@@ -2,6 +2,47 @@
Source audit, 9 September 2026. No runtime activation. The primary module source is the [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), especially printed pp.4, 9–10 and the Homeworld FAQ on pp.15–16. The local publisher-text transcription is `/tmp/dune-rules/ecaz-audit.txt`; fresh official indexed pp.9, 10 and 15 were checked against it. Direct PDF retrieval currently returns HTTP 403. Physical card faces are separately audited in [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); a rulebook statement and a card-face statement are not interchangeable evidence.
## Live World Sandtrout — 26 October 2026
An actual original Sandtrout draw exposed a context error in clean World/Tech:
the pending printed Spice Card was treated as an unrelated module, causing
occupation validation to reject the draw. Clean World and World/all14 profiles
now retain their original capability while the canonical boolean card flag is
pending. Original World Nexus/Discovery profiles retain the same live behavior;
malformed non-boolean effect flags are rejected. Fresh setup still requires
unused gameplay state, so no pre-existing effect can be installed at Start.
The [existing original Spice contract](IX_PROTOTYPE.md#original-spice-cards-and-selected-policy--24-october-2026)
remains authoritative: break alliances, hold the card across turns, preserve
the provisional first-turn ignoring priority, suppress the next active worm,
and double only its immediate territory replacement. No numerical, timing,
roster, role or module selection is changed by this fix. Existing World/Skill
benefits and original minimal participation remain available while it waits.
Frozen integration passes **11 files,70/70**, types/lint/build and three native
runtime paths with19 GameTable SSR renders. Original public A/E/Ix +Ix World/
Tech starts retain47 Treachery and22 Spice; Advanced adds original14 training
and Strongholds. Basic and first-turn ignored-worm paths use25+66 actions;
Advanced uses30+71. Only original unopened lotteries are selected.
Actual A2 Caladan→Broken Land shipment pays4 and supplies a real later worm
victim. Waiting Sandtrout spans Collection/Mentat/Storm; its later suppression
preserves both that army and its ground spice, grants no Nexus/ride, and doubles
only immediate Hagga. Advanced's next ordinary blow remains undoubled. Ix's
Heighliners1 pays at phase end; E Banker1 waits for Mentat, beside earned first
end-Mentat Strongholds. Four minimal policies each submit a legal next action
per source. No played card, force, wallet, effect flag, occupation or clock is
installed. All original Spice cards remain, including the one set aside while
pending and the same physical discard after suppression.
Actual table SSR retains the existing waiting notice at the source, Collection,
Mentat and next Storm, then clears it after suppression. Report/source manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-world-sandtrout-proof-v1/`;
journeys/HTML: adjacent `20261026-world-sandtrout-runtime/`.
No saved-room/D1/server, reset/restart, deployment or separate assurance campaign.
SSR/public actions leave broader combinations, browser/mobile/deployed and
complete-mode acceptance open.
## Original World Nexus and Discovery occupation — 26 October 2026
Every original admitted fresh World setup now initializes occupation benefit
@@ -62,8 +103,9 @@ Fresh World/all14 Skills starts now initialize the existing occupation benefit
state whenever the unchanged registered World/Skills admission accepts that
clean context. Original selected families, native role exclusions, Tech3+ and
Advanced Strongholds2+ remain; optional independent cards remain optional.
This introduces no new start permission. Nexus/Discovery/Sandtrout/Semuta and
other reaction contexts retain their own existing scopes.
This introduces no new start permission. Nexus/Discovery/Semuta and other
reaction contexts retain their own existing scopes. Pending printed Sandtrout
uses the [live-state clarification](#live-world-sandtrout--26-october-2026) above.
Shared fresh state includes qualification history, occupied Collection and
percentage ledgers, native CHOAM Tupile and native Ecaz World victory. Skills
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 26 October 2026 — Live World Sandtrout
**Prototyped / Partial.** A real original Basic
World/Tech Sandtrout draw reproduced an occupation-context rejection. Clean
World and World/all14 selectors now treat the canonical pending card flag as
live state, preserving the existing module/role admission and fresh-start rules.
Original Nexus/Discovery contexts also reject malformed effect flags. Printed
mechanics and the existing provisional timing policy remain unchanged.
Root profile/pure integration and a disjoint native lane pass **11 files,70/70**,
types/lint/build and **three native paths**,19 GameTable SSR renders. Basic and
first-turn ignored-worm paths use25+66 actions; Advanced World/all14/Tech/
Strongholds uses30+71. Actual A2 Caladan→Broken Land pays4, producing a real
later worm victim. Pending Sandtrout spans Collection/Mentat/Storm; suppression
preserves that army and ground spice, doubles only immediate Hagga and gives no
Nexus/ride. Advanced's next ordinary pile stays undoubled. Original47 Treachery,
22 Spice and optional14 Skills remain. Ix Heighliners1, deferred E Banker1 and
earned Strongholds retain their separate payouts. Four minimal policies each
submit a legal next action per source; no played state is installed.
The persistent notice appears through waiting phases and clears after
suppression. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-world-sandtrout-proof-v1/`;
journeys/HTML: adjacent `20261026-world-sandtrout-runtime/`.
No saved-room/D1/server/reset/restart/deployment or assurance campaign.
Broader combinations, browser/mobile/deployed and complete-mode acceptance remain.
[Source and scope](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026).
## 26 October 2026 — Original World Nexus and Discovery occupation
**Prototyped / Partial.** Shared fresh World setup
+5
View File
@@ -1,5 +1,10 @@
# Ixians & Tleilaxu functional prototype
The [26 October live World follow-up](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026)
keeps occupation capabilities available while an original Sandtrout waits.
It corrects a module-context rejection; the original Spice effects and
provisional compositions below retain their existing source and timing.
## Original Spice cards and selected policy — 24 October 2026
Normal lobbies now offer labelled Basic/Advanced expansion previews through
+1
View File
@@ -1457,6 +1457,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Topic | Decision to preserve | Detailed authority and evidence |
| --- | --- | --- |
| Live World Sandtrout | The pending original Spice Card is runtime state, not a new module selection. Original clean World and World/all14 occupation contexts stay available across its real draw and waiting interval, as original Nexus/Discovery contexts already did. Canonical boolean validation and unused fresh setup remain; printed suppression/doubling, provisional first-turn precedence and all roster/role limits are unchanged. | [Observed draw failure and current contract](HOMEWORLD_RULES.md#live-world-sandtrout--26-october-2026) |
| 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) |
+7 -6
View File
@@ -49,13 +49,14 @@ const optionalFlag = (value: unknown) => value === undefined || typeof value ===
/** Configuration permission only: original setup validates freshness, and live
* custody/history validators retain their own physical and occupation rules.
* Optional independent cards do not require either phase module. */
* Optional independent cards do not require either phase module. Sandtrout is
* a live printed-card flag; fresh setup checks that no effect is already held. */
export function homeworldPhaseModulesProfile(game: HomeworldPhaseModulesProfile): boolean {
if (!game || typeof game !== 'object' || !game.homeworlds || typeof game.advanced !== 'boolean' ||
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) ||
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) || !optionalFlag(game.sandtrout) ||
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.leaderSkills || game.nexusCards || game.discoveryEnabled || game.discoveries ||
game.discoveryStash || game.greatMaker || game.sandtrout || game.spiceBankerIncomePreview ||
game.discoveryStash || game.greatMaker || game.spiceBankerIncomePreview ||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
@@ -75,10 +76,10 @@ export function homeworldPhaseModulesProfile(game: HomeworldPhaseModulesProfile)
* only selects the clean occupation context and accepts its native stamps. */
export function homeworldOccupationLeaderSkillsProfile(game: HomeworldPhaseModulesProfile): boolean {
if (!game || typeof game !== 'object' || !game.homeworlds || !game.leaderSkills ||
typeof game.advanced !== 'boolean' || !registeredRoster(game) || !optionalFlag(game.ecazTreachery) ||
typeof game.advanced !== 'boolean' || !registeredRoster(game) || !optionalFlag(game.ecazTreachery) || !optionalFlag(game.sandtrout) ||
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.nexusCards || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.sandtrout || game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
if (![game.ecazOccupyPreview, game.moritaniAssassinatePreview, game.homeworldOccupationPreview,
@@ -98,7 +99,7 @@ export function homeworldOccupationLeaderSkillsProfile(game: HomeworldPhaseModul
* live effect stamps and Discovery frames are not replacement module choices. */
export function homeworldOccupationModulesProfile(game: HomeworldPhaseModulesProfile): boolean {
if (!game || typeof game !== 'object' || !game.homeworlds || typeof game.advanced !== 'boolean' ||
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) ||
!registeredRoster(game) || !optionalFlag(game.ecazTreachery) || !optionalFlag(game.sandtrout) ||
(game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) ||
(!game.nexusCards && game.discoveryEnabled !== true) ||
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
+2 -1
View File
@@ -2347,6 +2347,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'Homeworld occupation and Leader Skills retain their ordinary benefits while Sandtrout is waiting. Its later worm suppression and immediate-replacement doubling follow the same printed effect.',
'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: [
@@ -5010,7 +5011,7 @@ export const RULE_TOPICS: RuleTopic[] = [
status: 'Partial',
detail:
"Basic Tupile current-sole follow-up passes70/70 cases in10 files, types/lint/build, two original public gameplay/rendering programmes and four legal cleanup profiles. Its provisional policy and broader acceptance limits stay explicit. Earlier mixed E1/E2 and paired E3 Nexus added31 original physical programmes plus2 Loyalty-stock regressions. Final489/489 affected checks across42 files pass with types/lint. All64 original families finish258/288 six-seat games with338931 accepted/339426 attempted actions and9013 JSON across three frozen trees and preserved-prefix continuations. All30 remaining source-guard traces are read. Actual390px original Ixian offer/training, typed native and foreign-world payment/free Suboid plan and paired Ecaz foreign entry/living shared Duke preserve20 physical counters. Human Ixian loses, so no winning Suk rescue is claimed. Earlier qualified evidence remains canonical; full rules, assurance, calibration and deployed acceptance remain open. Current Junction follow-up passes131/131 affected cases and14 runtime/policy paths (11 original/3 controlled), with four actual original-action390px journeys. No saved/deployed acceptance is claimed.",
evidence: ['tests/homeworld-occupation-modules-profile.test.ts', 'tests/world-module-occupation-runtime.test.ts', 'tests/world-module-occupation-controls.test.tsx', 'tests/world-module-occupation-income.test.ts', 'tests/homeworld-occupation-skills-profile.test.ts', 'tests/world-skills-occupation-runtime.test.ts', 'tests/world-skills-occupation-controls.test.tsx', 'tests/world-skills-occupation-income.test.ts', 'tests/homeworld-module-profile.test.ts', 'tests/homeworld-phase-modules-runtime.test.ts', 'tests/homeworld-phase-modules-controls.test.tsx', 'tests/homeworld-phase-occupation-income.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-classic-skills-runtime.test.ts',
evidence: ['tests/world-sandtrout-runtime.test.ts', 'tests/homeworld-occupation-modules-profile.test.ts', 'tests/world-module-occupation-runtime.test.ts', 'tests/world-module-occupation-controls.test.tsx', 'tests/world-module-occupation-income.test.ts', 'tests/homeworld-occupation-skills-profile.test.ts', 'tests/world-skills-occupation-runtime.test.ts', 'tests/world-skills-occupation-controls.test.tsx', 'tests/world-skills-occupation-income.test.ts', 'tests/homeworld-module-profile.test.ts', 'tests/homeworld-phase-modules-runtime.test.ts', 'tests/homeworld-phase-modules-controls.test.tsx', 'tests/homeworld-phase-occupation-income.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-classic-skills-runtime.test.ts',
'tests/homeworld-classic-discovery-runtime.test.ts',
'tests/homeworld-skills-discovery-runtime.test.ts',
'tests/homeworld-optional-modules-runtime.test.ts',
+163
View File
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { spiceDeck } from '../game/cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
actMixedPhaseModules, createMixedPhaseModulesFixture, mixedPhaseModulesClean,
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
import { beforeIxSpiceDraw, orderIxSpiceCards, type IxSpicePick } from './fixture-ix-spice-cards';
export type WorldSandtroutStep = MixedPhaseModulesStep;
export type WorldSandtroutTransition = MixedPhaseModulesTransition;
export const WORLD_SANDTROUT_TRAINING = { atreides: 'warmaster', emperor: 'spice-banker', ixians: 'mentat' } as const;
export const actWorldSandtrout = actMixedPhaseModules;
export const nextWorldSandtrout = nextMixedPhaseModules;
export const worldSandtroutClean = mixedPhaseModulesClean;
export const worldSandtroutPlayer = mixedPhaseModulesPlayer;
export const worldSandtroutInventory = mixedPhaseModulesInventory;
export function advanceWorldSandtrout(game: Game, until: (state: Game) => boolean,
actions: WorldSandtroutStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original World Sandtrout source ended before its native control');
const next = nextWorldSandtrout(game); assert.ok(next);
game = actWorldSandtrout(game, next.actor, next.action, actions);
}
throw Error('The original World Sandtrout source did not reach its finite boundary');
}
export function finishWorldSandtrout(game: Game, actions: WorldSandtroutStep[] = []): Game {
const turn = game.turn;
return advanceWorldSandtrout(game, state => state.turn === turn && state.phase === 2 && worldSandtroutClean(state), actions);
}
export function worldSandtroutDeferred(game: Game, owner = 'emperor') {
return viewGame(game, owner).spiceBankerIncome?.deferred.find(row => row.owner === owner)?.amount ?? 0;
}
/** Sandtrout is set aside while active: count its live boolean once, then its
* physical discard once after suppression. Suppression annotates an original
* worm; it does not mint another Spice Card. */
export function worldSandtroutSpice(game: Game) {
const cards = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceSequence?.skipped ?? [])]
.filter(card => !('worm' in card && card.thumper));
const inventory = cards.map(card => JSON.stringify('worm' in card ? { worm: true } : card));
if (game.sandtrout) inventory.push(JSON.stringify({ sandtrout: true }));
assert.equal(cards.filter(card => 'worm' in card).length, 6);
assert.equal(cards.filter(card => 'territory' in card).length, 15);
assert.equal(cards.filter(card => 'sandtrout' in card).length + Number(!!game.sandtrout), 1);
assert.deepEqual(inventory.sort(), spiceDeck(true).map(card => JSON.stringify(card)).sort());
return inventory.sort();
}
export function worldSandtroutSkills(game: Game) {
const skills = game.leaderSkills ? [...game.leaderSkills.deck,
...game.leaderSkills.assignments.map(assignment => assignment.skill),
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : [];
if (game.leaderSkills) assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
return skills;
}
export function worldSandtroutStocks(game: Game) {
return game.players.map(player => ({ player: player.id, reserves: player.reserves, tanks: player.tanks,
forces: structuredClone(player.forces), eliteReserves: player.elites?.reserves ?? 0,
eliteTanks: player.elites?.tanks ?? 0, eliteForces: structuredClone(player.elites?.forces ?? {}) }));
}
/** Genuine public A/E/Ix +Ix World/Tech setup. Advanced additionally trains
* three original all14 offers and enables original Strongholds. Only the
* original scalar/Skill lottery and unopened Spice prefix are selected. No
* played Sandtrout flag, card, force, wallet, occupation or clock is installed.
* Stop at the actual first blow with Sandtrout physically waiting. */
export function createWorldSandtroutFixture(options: { advanced?: boolean; firstTurnWorm?: boolean } = {}) {
const advanced = options.advanced ?? false, firstTurnWorm = options.firstTurnWorm ?? false;
const source = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'ixians'], expansions: ['ix'],
advanced, homeworlds: true, tech: true, strongholds: advanced, skills: advanced,
training: advanced ? WORLD_SANDTROUT_TRAINING : undefined });
let game = structuredClone(source.game);
const actions = [...source.actions];
assert.ok(beforeIxSpiceDraw(game));
const picks: IxSpicePick[] = ['sandtrout', ...(firstTurnWorm ? ['worm' as const] : []),
{ territory: 'broken_land' }, ...(advanced ? [{ territory: 'funeral_plain' }] : [])];
const spiceOrder = orderIxSpiceCards(game, picks);
const beforeDraw = structuredClone(game), from = actions.length;
game = advanceWorldSandtrout(game, state => !!state.spiceWindow && worldSandtroutClean(state), actions);
const draw: WorldSandtroutTransition = { before: beforeDraw, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(game.sandtrout, true); assert.equal(game.spiceWindow!.territory, 'broken_land');
assert.equal(game.nexus, false);
const originalSpice = worldSandtroutSpice(source.setup), originalSkills = worldSandtroutSkills(source.setup);
assert.deepEqual(worldSandtroutSpice(game), originalSpice);
assert.deepEqual(worldSandtroutInventory(game), source.originalInventory);
homeworldGameIntegrity(game);
return { ...source, game, sourceGame: structuredClone(game), advanced, firstTurnWorm,
actor: 'atreides', owner: 'atreides', action: { type: 'ready' } as Action, banker: advanced ? 'emperor' : null,
origin: 'homeworld:atreides', target: game.spiceWindow!.territory, targetSector: game.spiceWindow!.sector,
targetLocation: `${game.spiceWindow!.territory}:${game.spiceWindow!.sector}`,
spiceOrder, beforeDraw, draw, originalSpice, originalSkills,
training: advanced ? WORLD_SANDTROUT_TRAINING : null,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Original public A/E/Ix Worlds/Tech and optional Advanced all14/Strongholds use actual controls. Only original scalar/Skill lotteries and unopened Sandtrout/territory/worm positions are selected; no played flags, hands, armies, wallets, occupation state or turn is installed. First-turn ignoring and immediate-only doubling retain the provisional source policy in docs/IX_PROTOTYPE.md.' };
}
export type WorldSandtroutFixture = ReturnType<typeof createWorldSandtroutFixture>;
/** An actual paid native A2 arrival becomes the later worm's physical source.
* The active original Sandtrout survives real Collection, Mentat and Storm;
* the next drawn worm is suppressed and only its immediate territory doubles. */
export function completeWorldSandtrout(f: WorldSandtroutFixture, state = f.game) {
let game = state;
const actions: WorldSandtroutStep[] = [];
const act = (actor: string, action: Action) => { game = actWorldSandtrout(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceWorldSandtrout(game, until, actions); };
const transition = (run: () => void): WorldSandtroutTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const firstSpice = transition(() => { game = finishWorldSandtrout(game, actions); });
reach(state => state.turn === 1 && state.phase === 5 && state.active === f.actor && worldSandtroutClean(state));
const shipmentSources = nativeShipmentSources(viewGame(game, f.actor), 2, 0); assert.ok(shipmentSources);
const shipmentAction: Action = { type: 'ship', territory: f.target, sector: f.targetSector, amount: 2, elite: 0,
allyPayment: 0, homeworldSources: shipmentSources };
const shipment = transition(() => { act(f.actor, shipmentAction); reach(worldSandtroutClean); });
assert.equal(worldSandtroutPlayer(shipment.before, f.actor).spice - worldSandtroutPlayer(game, f.actor).spice, 4);
assert.equal(worldSandtroutPlayer(game, f.actor).forces[f.targetLocation], 2);
reach(state => state.phase === 5 && state.active === state.order.at(-1) && worldSandtroutClean(state));
const techOwner = game.techTokens!.heighliners.owner; assert.ok(techOwner);
const techAmount = game.techTokens!.heighliners.spice;
const collection = transition(() => {
act(game.active!, { type: 'endMovement' });
reach(state => state.phase === 7 && worldSandtroutClean(state));
});
assert.equal(game.sandtrout, true);
const mentat = transition(() => {
reach(state => state.turn === 2 && state.phase === 0 && worldSandtroutClean(state));
});
assert.equal(game.sandtrout, true);
const storm = transition(() => { reach(state => state.turn === 2 && beforeIxSpiceDraw(state)); });
assert.equal(game.sandtrout, true);
assert.equal(worldSandtroutPlayer(game, f.actor).forces[f.targetLocation], 2);
const secondSpiceOrder = orderIxSpiceCards(game, ['worm', { territory: 'hagga_basin' },
...(f.advanced ? [{ territory: 'rock_outcroppings' }] : [])]);
const beforeSuppression = structuredClone(game);
const suppression = transition(() => { reach(state => !!state.spiceWindow && worldSandtroutClean(state)); });
const revealed = structuredClone(game);
assert.equal(game.sandtrout, false); assert.equal(game.nexus, false);
assert.equal(game.spiceWindow!.territory, 'hagga_basin');
let secondWindow: Game | null = null;
if (f.advanced) {
reach(state => !!state.spiceWindow && state.spiceSequence?.pile === 1 && worldSandtroutClean(state));
secondWindow = structuredClone(game);
}
const finishedSpice = transition(() => { game = finishWorldSandtrout(game, actions); });
homeworldGameIntegrity(game);
assert.deepEqual(worldSandtroutSpice(game), f.originalSpice);
assert.deepEqual(worldSandtroutInventory(game), f.originalInventory);
assert.deepEqual(worldSandtroutSkills(game), f.originalSkills);
const bankerSource = shipment.after.spiceBankerIncome?.sources.find(receipt => receipt.source.kind === 'shipment' &&
receipt.source.bankLegs.some(leg => leg.payer === f.actor && leg.amount === 4)) ?? null;
const bankerCollection = bankerSource ? game.spiceBankerIncome?.collections.find(receipt =>
receipt.turn === 1 && receipt.grants.includes(bankerSource.cursor)) ?? null : null;
return { game, firstSpice, shipmentSources, shipmentAction, shipment, techOwner, techAmount,
collection, mentat, storm, secondSpiceOrder, beforeSuppression, suppression, revealed,
secondWindow, finishedSpice, bankerSource: structuredClone(bankerSource),
bankerCollection: structuredClone(bankerCollection), actions, actionCount: actions.length };
}
+10 -2
View File
@@ -61,15 +61,23 @@ void test('independent-card selection is an exact optional boolean rather than a
for (const ecazTreachery of [null, 0, 1, 'true', {}]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), false);
});
void test('Skills, Nexus, Discovery, active Sandtrout and unrelated reaction selectors remain outside the clean World scope', () => {
void test('Skills, Nexus, Discovery and unrelated reaction selectors remain outside the clean World scope', () => {
for (const field of [
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'sandtrout',
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
});
void test('a pending original Sandtrout preserves the live World context without becoming a module selection', () => {
const game = profile(['ixians', 'emperor', 'atreides'], ['ix'], {
homeworldOccupationPreview: true, techTokens: createTechTokens(),
});
for (const sandtrout of [undefined, false, true]) assert.equal(supported({ ...game, sandtrout }), true);
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
});
void test('canonical occupation and native benefit stamps retain their actual seated source and required parent', () => {
const native = profile(['ecaz', 'moritani', 'choam'], ['choam', 'ecaz'], { advanced: true,
ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true,
@@ -135,7 +135,7 @@ void test('unrelated previews and mismatched native, parent, reaction or Banker
assert.equal(supported({ ...game, [field]: 'forged' }), false, field);
});
void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral without changing clean helpers', () => {
void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral in original module contexts', () => {
const custody = { visitors: { 'homeworld:ixians': { emperor: { normal: 1, elite: 1 } } } };
const discoveries = { turn: 2, tokens: [], encounters: [] };
const game = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus(), discoveryEnabled: true,
@@ -148,7 +148,8 @@ void test('live native Discovery, active printed Sandtrout and occupied custody
const plain = profile(); const trained = { ...plain, leaderSkills: true };
assert.equal(clean(plain), true); assert.equal(cleanSkills(trained), true);
assert.equal(supported(plain), false); assert.equal(supported(trained), false);
assert.equal(clean({ ...plain, sandtrout: true }), false); assert.equal(cleanSkills({ ...trained, sandtrout: true }), false);
assert.equal(clean({ ...plain, sandtrout: true }), true); assert.equal(cleanSkills({ ...trained, sandtrout: true }), true);
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
for (const value of [null, {}, { ...game, expansions: 'ix' }, { ...game, players: [{ faction: 'ixians' }, null] }])
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
});
@@ -57,9 +57,9 @@ void test('required modules, physical seat bounds and existing phase limits do n
]) assert.equal(supported(game), false);
});
void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and unrelated reaction selectors without changing library admission', () => {
void test('occupation benefit context excludes Nexus, Discovery and unrelated reaction selectors without changing library admission', () => {
for (const field of ['nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'sandtrout', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
const nexus = profile(undefined, undefined, { nexusCards: { cards: null, phase: null } });
@@ -67,6 +67,19 @@ void test('occupation benefit context excludes Nexus, Discovery, Sandtrout and u
assert.equal(supported(nexus), false, 'This clean benefit slice does not claim that separate combination');
});
void test('pending printed Sandtrout retains occupation and all14 roles under the same original World admission', () => {
const game = profile(['ixians', 'emperor', 'atreides'], ['ix'], { advanced: true,
homeworldOccupationPreview: true, spiceBankerIncomePreview: true,
techTokens: createTechTokens(), strongholdCards: createStrongholdCards(),
});
for (const sandtrout of [undefined, false, true]) {
const active = { ...game, sandtrout };
assert.equal(admitted(active), true);
assert.equal(supported(active), true);
}
for (const sandtrout of [null, 0, 1, 'true', {}]) assert.equal(supported({ ...game, sandtrout }), false);
});
void test('canonical Banker and native occupation stamps require their actual parent factions and retain the Advanced assassination band', () => {
const native = profile(['ecaz', 'moritani', 'choam'], ['ecaz', 'choam'], { advanced: true,
homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true,
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { TECH_TOKENS } from '../game/tech-tokens';
import {
completeWorldSandtrout, createWorldSandtroutFixture, worldSandtroutDeferred as deferred,
worldSandtroutInventory as inventory, worldSandtroutPlayer as player,
worldSandtroutSkills as skills, worldSandtroutSpice as spice, worldSandtroutStocks as stocks,
} from './fixture-world-sandtrout';
for (const advanced of [false, true]) {
void test(`native ${advanced ? 'Advanced Worlds Skills Tech Strongholds' : 'Basic Worlds Tech'} Sandtrout waits through real phases and suppresses only the later worm`, () => {
const f = createWorldSandtroutFixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.players.map(seat => seat.faction), ['atreides', 'emperor', 'ixians']);
assert.deepEqual(f.game.expansions, ['ix']); assert.ok(f.game.homeworlds && f.game.techTokens);
assert.ok(f.game.homeworldOccupationPreview && f.game.homeworldOccupationHistory);
assert.equal(!!f.game.leaderSkills, advanced); assert.equal(!!f.game.strongholdCards, advanced);
assert.ok(!f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, 47); assert.equal(f.originalSkills.length, advanced ? 14 : 0);
assert.equal(f.originalSpice.length, 22); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.beforeDraw.sandtrout, false); assert.equal(f.game.sandtrout, true);
assert.equal(viewGame(f.game, f.actor).sandtrout, true);
assert.deepEqual(stocks(f.game), stocks(f.beforeDraw));
assert.deepEqual(f.game.players.map(seat => seat.spice), f.beforeDraw.players.map(seat => seat.spice));
assert.deepEqual(inventory(f.game), f.originalInventory); assert.deepEqual(spice(f.game), f.originalSpice);
if (advanced) for (const [owner, skill] of Object.entries(f.training!)) {
const assignment = f.afterSetup.leaderSkills!.assignments.find(row => row.owner === owner); assert.ok(assignment);
assert.equal(assignment.skill, skill);
assert.ok(f.sourceActions.some(step => step.actor === owner && step.action.type === 'leaderSkill' &&
step.action.skill === skill && step.action.leader === assignment.leader));
}
const before = structuredClone(f.game), result = completeWorldSandtrout(f);
assert.deepEqual(f.game, before);
assert.equal(result.firstSpice.after.sandtrout, true); assert.equal(result.firstSpice.after.phase, 2);
assert.deepEqual(result.shipmentSources, { 'homeworld:atreides': { normal: 2, elite: 0 } });
assert.equal(player(result.shipment.after, f.actor).reserves, player(result.shipment.before, f.actor).reserves - 2);
assert.equal(player(result.shipment.after, f.actor).forces[f.targetLocation], 2);
assert.equal(player(result.shipment.before, f.actor).spice - player(result.shipment.after, f.actor).spice, 4);
const ordinary = quoteSpiceCollection(result.collection.before).receipts;
for (const seat of result.collection.before.players) {
const receipt = ordinary.find(row => row.player === seat.id);
assert.equal(player(result.collection.after, seat.id).spice - seat.spice,
(receipt ? receipt.strongholds + receipt.collected : 0) + (seat.id === result.techOwner ? result.techAmount : 0));
}
assert.equal(result.collection.after.sandtrout, true);
assert.ok(result.techAmount > 0);
assert.equal(result.collection.after.techTokens!.heighliners.spice, 0);
assert.equal(result.mentat.after.turn, 2); assert.equal(result.mentat.after.phase, 0);
assert.equal(result.mentat.after.sandtrout, true); assert.equal(result.storm.after.sandtrout, true);
assert.equal(player(result.mentat.after, 'emperor').spice - player(result.mentat.before, 'emperor').spice, advanced ? 1 : 0);
if (advanced) {
assert.deepEqual(result.bankerSource!.source.bankLegs, [{ payer: f.actor, amount: 4 }]);
assert.equal(result.bankerSource!.grant!.owner, f.banker);
assert.equal(deferred(result.collection.after), 1);
assert.deepEqual(result.bankerCollection!.credits, [{ owner: f.banker, amount: 1 }]);
assert.equal(result.mentat.after.strongholdCards!.claimedTurn, 1);
assert.equal(result.mentat.after.strongholdCards!.owners.arrakeen, f.actor);
assert.equal(result.mentat.after.strongholdCards!.owners.hidden_mobile_stronghold, 'ixians');
} else assert.equal(result.bankerSource, null);
assert.equal(deferred(result.mentat.after), 0);
const land = result.secondSpiceOrder[1].card; assert.ok('territory' in land);
assert.equal(result.revealed.spiceWindow!.amount, land.amount * 2);
assert.equal(result.revealed.spice[`${land.territory}:${land.sector}`] -
(result.beforeSuppression.spice[`${land.territory}:${land.sector}`] ?? 0), land.amount * 2);
assert.equal(result.revealed.sandtrout, false); assert.equal(result.revealed.nexus, false);
assert.deepEqual(stocks(result.revealed), stocks(result.beforeSuppression), 'The suppressed worm destroys no physical force');
assert.equal(result.revealed.spice[f.targetLocation], result.beforeSuppression.spice[f.targetLocation],
'The suppressed worm leaves its actual previous pile intact');
assert.deepEqual(result.revealed.players.map(seat => seat.spice), result.beforeSuppression.players.map(seat => seat.spice));
assert.equal(result.revealed.decision, null); assert.deepEqual(result.revealed.wormRides, []);
assert.equal(result.revealed.spiceDiscard.flat().filter(card => 'worm' in card && card.suppressed).length, 1);
assert.equal(result.revealed.spiceDiscard.flat().filter(card => 'sandtrout' in card).length, 1);
if (advanced) {
const next = result.secondSpiceOrder[2].card; assert.ok('territory' in next);
assert.equal(result.secondWindow!.spiceWindow!.territory, next.territory);
assert.equal(result.secondWindow!.spiceWindow!.amount, next.amount, 'Doubling is limited to the immediate replacement');
}
assert.equal(result.game.phase, 2); assert.equal(result.game.turn, 2); assert.equal(result.game.sandtrout, false);
for (const { id } of TECH_TOKENS) assert.equal(result.game.techTokens![id].owner, result.beforeSuppression.techTokens![id].owner);
assert.deepEqual(inventory(result.game), f.originalInventory); assert.deepEqual(skills(result.game), f.originalSkills);
assert.deepEqual(spice(result.game), f.originalSpice);
});
}
void test('native Basic World Sandtrout keeps the original ignored first-turn worm and waits for its later encounter', () => {
const f = createWorldSandtroutFixture({ firstTurnWorm: true });
assert.equal(f.game.turn, 1); assert.equal(f.game.sandtrout, true); assert.equal(f.game.nexus, false);
const land = f.spiceOrder[2].card; assert.ok('territory' in land);
assert.equal(f.game.spiceWindow!.amount, land.amount);
assert.equal(f.game.spiceSequence!.skipped.filter(card => 'worm' in card).length, 1);
assert.ok(f.game.log.some(entry => /first-turn worm rule leaves Sandtrout waiting/.test(entry.text)));
assert.deepEqual(spice(f.game), f.originalSpice);
const result = completeWorldSandtrout(f);
assert.equal(result.beforeSuppression.sandtrout, true); assert.equal(result.beforeSuppression.turn, 2);
assert.equal(result.revealed.nexus, false); assert.equal(result.revealed.sandtrout, false);
assert.equal(result.game.phase, 2); assert.deepEqual(spice(result.game), f.originalSpice);
});