Expand Basic Richese and Terror interactions
Verify and publish checkpoint container / container (push) Successful in 11m25s

This commit is contained in:
Quintine committed 2026-10-29 09:59:45 +11:00
1 parent 605cb2a32f
commit 9ab9b9a613
10 files changed
+426 -9

No files matched your search

+14
View File
@@ -17,6 +17,20 @@ open mode gates.
## Current checkpoint and work
**Basic Ecaz/Moritani Richese interactions — 29 October. Prototyped / Partial.**
Previously supported clean No-Field interruptions now work with selected
Ecaz/Moritani factions and their native deck. Semuta also receives Sabotage,
Sabotage-gift overflow and Robbery-overflow discards from a native Fremen worm
ride; the original gift or already completed draw and remaining ride queue
continue without replay.
Native-action fixtures cover placed Ambassador/Terror tokens, zero and positive
No-Fields and all three discard paths; gift overflow cards and the second ride
are explicitly staged while preserving physical custody. Sixteen related files
pass 87 cases; types/lint and production build pass. Active competing E3
reactions, optional modules, broader rules and deployed acceptance remain open.
[Reveal scope](RICHESE_NO_FIELD_RULES.md#basic-ecazmoritani-native-tables--29-october-2026);
[Semuta scope](SEMUTA_SOURCE_UPDATE_20260907.md#basic-terror-discards-during-a-worm-ride--29-october-2026).
**Basic Richese movement and worm-ride interruptions — 29 October. Prototyped /
Partial.** Richese can reveal its No-Field while another faction's ordinary
movement waits for supported Fremen, Ixian or CHOAM reactions. Semuta may also
+9
View File
@@ -2,6 +2,15 @@
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
## Basic Ecaz/Moritani native tables — 29 October 2026
The previously supported clean Basic reveal interruptions also work when the
Ecaz/Moritani expansion deck and its native factions are selected. An existing
Ambassador or Terror marker does not trigger again when Richese reveals a
No-Field already in its territory. An active Ecaz or Moritani child reaction,
Truthtrance commitments and unsupported optional modules retain their existing
guards. The interruption order remains a provisional online interpretation.
## Basic foreign-movement interruptions — 29 October 2026
In the supported Basic native Ix/CHOAM roster, Richese may also reveal its
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index
## Basic Ecaz/Moritani Richese interactions — 29 October 2026
Supported clean No-Field reveal interruptions extend to the native Ecaz and
Moritani deck; existing markers do not create a new entry on revelation.
Semuta also receives eligible Terror discards from a native Fremen worm ride.
These online continuations are provisional and active competing reactions stay
guarded. [Reveal scope](RICHESE_NO_FIELD_RULES.md#basic-ecazmoritani-native-tables--29-october-2026);
[Semuta scope](SEMUTA_SOURCE_UPDATE_20260907.md#basic-terror-discards-during-a-worm-ride--29-october-2026).
## Basic Richese movement and worm-ride interruptions — 29 October 2026
Voluntary No-Field reveal can interrupt supported foreign ordinary movement
+12
View File
@@ -1,5 +1,17 @@
# Semuta Drug source update and implementation boundary
## Basic Terror discards during a worm ride — 29 October 2026
**Prototyped / Partial.** A native Fremen worm ride into Sabotage or Robbery
can now offer the victim's random Sabotage discard, a subsequent Sabotage-gift
overflow, or Moritani's Robbery-draw overflow to Semuta. Each private discard
has its own original continuation; the gift or draw is not repeated, and the
remaining worm queue resumes once. The queue is bound to the saved reaction.
The focused fixture uses real setup, placement and riding, and stages conserved
cards and a second ride to exercise the gift and queue. Paired Ambassadors,
summoned worms and other nested parents remain guarded; ordering is an online
interpretation, not a new publisher ruling.
## Basic Ambassador discards during a worm ride — 29 October 2026
**Prototyped / Partial.** A native Fremen worm ride into a CHOAM or Ixian
+21 -6
View File
@@ -2017,6 +2017,7 @@ export type Game = {
owner: string;
entry: NonNullable<Game['pendingTerrorEntry']>;
discardedHandSize: number;
wormCursor?: string;
}
| {
kind: 'terrorAtomics';
@@ -9627,7 +9628,7 @@ function supportedRicheseTruthLot(g: Game): boolean {
!g.currentAuctionSale && !g.auction && !g.ixAuction;
}
/** Bind a parked native ride to its original remaining Spice Blow sequence. */
function ambassadorWormDiscardCursor(g: Game): string {
function wormDiscardCursor(g: Game): string {
return JSON.stringify([g.turn, g.phase, g.wormRides, g.spiceSequence,
g.spiceResolution, g.nexus, g.summonedNexusBeforeRides, g.greatMaker]);
}
@@ -9734,7 +9735,7 @@ function semutaOfferSupported(
ambassadorTerrorEntryMatches(g, continuation.entry);
const ambassadorWormDiscard = ambassadorDiscard && continuation.entry.resume === 'wormRide' &&
continuation.entry.arrivalCause === 'wormRide' && g.phase === 1 &&
!!continuation.wormCursor && continuation.wormCursor === ambassadorWormDiscardCursor(g) &&
!!continuation.wormCursor && continuation.wormCursor === wormDiscardCursor(g) &&
validAmbassadorResume(g, continuation.entry) && !g.pendingArrivalOverlap &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction);
if (ambassadorDiscard &&
@@ -9879,14 +9880,22 @@ function semutaOfferSupported(
(continuation.kind === 'battleResolved' && !bareBasicSemutaProfile(g) &&
!basicIndependentRecruitsSemutaProfile(g)))) ||
g.pendingIxSubstitution)) return false;
const terrorWormDiscard = continuation.kind === 'terrorDiscard' &&
(bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g)) &&
continuation.entry.cause === 'wormRide' && continuation.entry.resume === 'wormRide' &&
continuation.entry.turn === g.turn && continuation.entry.phase === 1 && g.phase === 1 &&
g.players.some(player => player.id === continuation.entry.entrant && player.faction === 'fremen') &&
!!continuation.wormCursor && continuation.wormCursor === wormDiscardCursor(g) &&
!g.pendingArrivalOverlap && !g.pendingAmbassador && !g.pendingArrivalReaction?.length &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction);
if (continuation.kind === 'terrorDiscard' &&
(!childTerrorDiscard && (continuation.entry.resume !== 'none' ||
(!childTerrorDiscard && !terrorWormDiscard && (continuation.entry.resume !== 'none' ||
(continuation.entry.cause !== 'shipment' && continuation.entry.cause !== 'movement') ||
(g.pendingArrivalOverlap && (!bareBasicSemutaProfile(g) ||
!overlapOwnsTerrorDiscard(g, continuation.entry, continuation.owner)))) ||
g.pendingChoamMove || g.pendingIxMove ||
g.pendingFremenMove || g.ornithopter || g.summonedWorm ||
g.wormRides.length > 0)) return false;
!terrorWormDiscard && g.wormRides.length > 0)) return false;
if (continuation.kind === 'terrorAtomics' &&
(g.advanced || g.phase !== 5 || continuation.entry.resume !== 'none' ||
!['shipment', 'movement'].includes(continuation.entry.cause) ||
@@ -27439,7 +27448,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
kind: 'ambassador',
entry,
...(entry.resume === 'wormRide' && entry.arrivalCause === 'wormRide'
? { wormCursor: ambassadorWormDiscardCursor(g) } : {}),
? { wormCursor: wormDiscardCursor(g) } : {}),
});
}
}
@@ -27854,6 +27863,8 @@ function decideTerror(g: Game, p: Player, action: Action) {
owner: owner.id,
entry,
discardedHandSize,
...(entry.cause === 'wormRide' && entry.resume === 'wormRide'
? { wormCursor: wormDiscardCursor(g) } : {}),
},
);
} else {
@@ -27878,7 +27889,9 @@ function decideTerror(g: Game, p: Player, action: Action) {
g.pendingTerrorEntry = null;
stageTreacheryDiscard(g, 'terror:sabotageGiftOverflow',
[{ card, discardedBy: p.id, publicFace: false }],
{ kind: 'terrorDiscard', source: 'sabotageGiftOverflow', owner: owner.id, entry, discardedHandSize });
{ kind: 'terrorDiscard', source: 'sabotageGiftOverflow', owner: owner.id, entry, discardedHandSize,
...(entry.cause === 'wormRide' && entry.resume === 'wormRide'
? { wormCursor: wormDiscardCursor(g) } : {}) });
return;
}
if (entry.stage === 'sneakAttack') {
@@ -28013,6 +28026,8 @@ function decideTerror(g: Game, p: Player, action: Action) {
owner: owner.id,
entry,
discardedHandSize,
...(entry.cause === 'wormRide' && entry.resume === 'wormRide'
? { wormCursor: wormDiscardCursor(g) } : {}),
},
);
} else {
+2
View File
@@ -1132,6 +1132,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In supported Basic native games, voluntary revelation is also available during an Ix phase opening or selected auction and market decisions without replacing that decision. Other interruption windows remain unfinished.',
'In supported Basic games, your voluntary reveal can also interrupt a clean Emperor allied spice gift, Tleilaxu revival-income response, Atreides Movement-phase Spice forecast without Bene Gesserit, or Guild’s own ordinary reserve-shipment rate response when the shipment enters a different territory from the marker. The original response and payment continue once; other interruptions remain guarded.',
'During supported Basic games you can also reveal while another faction’s ordinary movement waits for a Fremen or Ixian response or a CHOAM decision. The original move and its response remain pending; a zero No-Field disappears without creating physical forces.',
'These supported clean Basic interruptions also work with the Ecaz/Moritani deck and native factions. A token already present does not enter again when it is revealed; pending Ambassador and Terror reactions keep their own controls.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. Fresh placement from the screen to your own Homeworld and optional-module combinations remain unavailable; an Advanced native return uses the provisional physical-reserve interpretation described below. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
@@ -1409,6 +1410,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A clean proactive Trip to Gamont also pauses for a selected elite or concealed No-Field. Claim or Continue returns the exact selected physical counter, or performs the native No-Field reveal and returns at most one actual force after reserve-capped materialization. A zero token reveals without inventing a force; taking the used card does not undo that effect or spend revival usage.',
'In supported bare Basic tables with Richese, one entry may earn both an Ambassador and Terror opportunity. Their existing provisional storm order stays fixed while CHOAM/Ix or Sabotage/Robbery discards offer their separate Semuta responses. Complete each fresh group before its original effect continues; the other earned opportunity waits. The table shows when the discard response is pending. Credit, replacement, gift and physical entry are never repeated.',
'In supported Basic games, CHOAM and Ixian Ambassador discards from an ordinary Fremen worm ride, including Bene Gesserit copies, also offer Semuta before the original ride continues. CHOAM’s credit and the Ixian replacement are applied once; overlapping Terror and summoned-worm continuations remain guarded.',
'During a supported Basic Fremen worm ride, Sabotage, Sabotage-gift overflow and Robbery overflow discards can also offer Semuta. A pending gift then continues, an already completed gift or draw is not repeated, and remaining worm rides resume once.',
'Trip to Gamont in CHOAM’s final Mentat opportunity also offers the used card. Claim or Continue completes the selected return and restores that same CHOAM-owned opportunity. It does not check victory or advance the turn: CHOAM must still choose Finish and check victory, using the changed board.',
'Fresh bare public Basic Richese previews now initialize Semuta before genuine setup with selected native E1/E2/E3 families. Older games are unchanged; Advanced and optional modules retain their existing separate entries. Clean ordinary shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: CHOAM keeps its already-earned credit and Ixians draw one replacement after the reaction. A multiple-card private group requires commitment before exactly one selection. Original entrant actions then resume once. Existing Basic Sabotage/Robbery overflow keeps the original victim/draw, removed token, payment, forces and optional gift. Nested arrivals, completed movement-card provenance, wider module coverage and complete modes remain unfinished. The table labels the preview and its limits.',
] : []),
+3 -2
View File
@@ -4,7 +4,7 @@ import { richeseRevealForeignMovementPending } from './richese-reveal-foreign-mo
const factions = new Set([
'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen',
'richese', 'choam', 'ixians', 'tleilaxu',
'richese', 'choam', 'ixians', 'tleilaxu', 'ecaz', 'moritani',
]);
/** A deliberately bounded admission for an existing before-Battle parent.
@@ -14,10 +14,11 @@ export function richeseRevealInterruptSupported(g: Game): boolean {
if (g.status !== 'playing' || g.advanced || !Number.isSafeInteger(g.phase) ||
g.phase < 0 || g.phase >= 6 || !Number.isSafeInteger(g.turn) || g.turn < 1 ||
!Array.isArray(g.expansions) || !g.expansions.includes('choam') ||
g.expansions.some(deck => deck !== 'choam' && deck !== 'ix') ||
g.expansions.some(deck => deck !== 'choam' && deck !== 'ix' && deck !== 'ecaz') ||
new Set(g.expansions).size !== g.expansions.length ||
!Array.isArray(g.players) || !g.players.every(player => factions.has(player.faction)) ||
g.players.some(player => (player.faction === 'ixians' || player.faction === 'tleilaxu') && !g.expansions.includes('ix')) ||
g.players.some(player => (player.faction === 'ecaz' || player.faction === 'moritani') && !g.expansions.includes('ecaz')) ||
g.coalitionState || g.coalitionPreview || g.techTokens || g.strongholdCards || g.leaderSkills ||
g.homeworlds || g.nexusCards || g.discoveryEnabled || g.discoveries || g.discoveryStash ||
g.greatMaker || g.sandtrout || g.ecazTreachery || g.truthtrance ||
@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, handLimit, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { territory } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorPlayer as player,
basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const moritani = 'moritani', richese = 'richese', rider = 'fremen', holder = 'guild';
/** Real public setup, native Terror placement, Spice Blow, Nexus and worm ride.
* Physical cards and two Fremen forces are moved between their original zones
* to give the chosen branch a deterministic victim, holder and second ride;
* only unopened Spice positions are selected. No pending event is staged. */
function source(kind: 'sabotage' | 'robbery') {
let g = createGame('SEMUTATERRORWORM', newPlayer(moritani, 'Moritani', 'moritani'), false, ['ecaz', 'choam']);
for (const faction of [richese, rider, holder] as const) joinGame(g, newPlayer(faction, faction, faction));
for (const p of g.players) g = act(g, p.id, {type:'ready'});
g = act(g, moritani, {type:'start'});
assert.equal(g.status, 'setup'); assert.equal(g.semutaPreview, true);
const step = () => {
if (g.status === 'setup' && g.setupStage === 'forces' && player(g, rider).reserves === 20) {
g = act(g, rider, {type:'fremenSetup', placements:{'sietch_tabr:14':10}}); return;
}
if (g.status === 'setup' && !g.setupStage && g.decision?.kind === 'moritaniSetup') {
g = act(g, moritani, {type:'decision', territory:'oh_gap', sector:9}); return;
}
const next = nextRicheseStrongholdsNativeStep(g);
assert.ok(next, `Missing native action at ${g.status}/${g.turn}/${g.phase}/${g.decision?.kind}`);
g = act(g, next.actor, next.action);
};
const reach = (condition: (state: Game) => boolean) => {
for (let n = 0; n < 2000; n++) {
if (condition(g)) return;
assert.notEqual(g.status, 'finished'); step();
}
throw Error('Native Terror worm source did not reach the requested phase');
};
reach(state => state.status === 'playing');
const f = player(g, rider);
const originalForce = Object.keys(f.forces).find(key => f.forces[key] >= 2)!;
assert.ok(originalForce);
f.forces[originalForce] -= 2;
if (!f.forces[originalForce]) delete f.forces[originalForce];
f.forces['the_great_flat:15'] = 1;
f.forces['hagga_basin:12'] = 1;
for (const [position, predicate] of [
[0, (c: Game['spiceDeck'][number]) => 'territory' in c && c.territory === 'the_great_flat'],
[1, (c: Game['spiceDeck'][number]) => 'worm' in c && !c.greatMaker],
] as const) {
const index = g.spiceDeck.findIndex((card, i) => i >= position && predicate(card));
assert.ok(index >= position); [g.spiceDeck[position], g.spiceDeck[index]] = [g.spiceDeck[index], g.spiceDeck[position]];
}
reach(state => state.phase === 8 && !state.response && state.decision?.kind === 'moritaniPlacement');
const token = g.moritaniTerror!.tokens.find(t => t.kind === kind)!;
assert.ok(token);
const destination = ['carthag', 'arrakeen', 'habbanya_ridge_sietch', 'tueks_sietch']
.find(id => territory(id).sectors.some(sector => sector !== g.storm))!;
g = act(g, moritani, {type:'decision', token:token.id, territory:destination});
reach(state => state.turn === 2 && state.decision?.kind === 'wormRide' && state.decision.player === rider);
assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory, 'the_great_flat');
const sector = territory(destination).sectors.find(value => value !== g.storm)!;
assert.ok(sector !== undefined); assert.notEqual(g.storm, 15); assert.notEqual(g.storm, 12);
g = act(g, rider, {type:'decision', accept:true, territory:destination, sector,
forces:{'the_great_flat:15':1}});
assert.equal(g.pendingTerrorEntry?.cause, 'wormRide');
assert.equal(g.pendingTerrorEntry?.resume, 'wormRide');
// Supplement the real first ride with one conserved, staged later ride.
g.wormRides.push('hagga_basin');
const semutaAt = g.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(semutaAt >= 0 && player(g, holder).hand.length > 0);
const semuta = g.richeseCache!.splice(semutaAt, 1)[0];
g.deck.push(player(g, holder).hand[0]); player(g, holder).hand[0] = semuta;
for (const id of [moritani, rider]) {
const seat = player(g, id); g.deck.push(...seat.hand); seat.hand = [];
}
const card = () => {
const index = g.deck.findIndex(c => ['projectile','poison','shield','snooper','worthless'].includes(c.kind));
assert.ok(index >= 0); return g.deck.splice(index, 1)[0];
};
const gift = card();
if (kind === 'sabotage') {
player(g, moritani).hand.push(gift);
player(g, rider).hand.push(card());
} else {
g.deck.push(gift);
for (let n = 0; n < handLimit(player(g, moritani)); n++) player(g, moritani).hand.push(card());
}
const original = inventory(g);
return {g, token:token.id, destination, sector, gift:gift.id, original};
}
function pass(g: Game) {
const event = g.pendingTreacheryDiscard!.batch.event;
for (const seat of g.players) g = act(g, seat.id, {type:'semutaPass', event});
return g;
}
function finishSemuta(g: Game, claim: boolean) {
const batch = g.pendingTreacheryDiscard!.batch;
const selected = batch.entries[0].card.id;
if (claim) {
g = act(g, holder, {type:'semutaCommit', event:batch.event});
if (g.pendingTreacheryDiscard?.reaction?.stage === 'select')
g = act(g, holder, {type:'semutaSelect', event:batch.event, card:selected});
} else g = pass(g);
return {g, selected, event:batch.event};
}
for (const cause of ['sabotage', 'sabotageGiftOverflow', 'robberyOverflow'] as const)
for (const claim of [false, true]) void test(
`Basic ${cause} during worm ride preserves Semuta ${claim ? 'claim' : 'pass'} and original continuation`, () => {
const f = source(cause === 'robberyOverflow' ? 'robbery' : 'sabotage');
let g = act(f.g, moritani, {type:'decision', reveal:true});
if (cause === 'robberyOverflow') {
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
const deck = g.deck.map(card => card.id);
g = act(g, moritani, {type:'decision', choice:'card'});
assert.equal(g.pendingTerrorEntry?.stage, 'discard');
assert.ok(player(g, moritani).hand.some(card => card.id === deck[0]));
g = act(g, moritani, {type:'decision', card:player(g, moritani).hand[0].id});
} else if (cause === 'sabotageGiftOverflow') {
assert.equal(g.pendingTreacheryDiscard?.batch.cause, 'terror:sabotage');
g = pass(g);
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
// A conserved intervening hand refill tests the distinct second discard.
while (player(g, rider).hand.length < handLimit(player(g, rider))) {
const card = g.deck.pop(); assert.ok(card); player(g, rider).hand.push(card);
}
g = act(g, moritani, {type:'decision', card:f.gift});
assert.equal(g.pendingTerrorEntry?.stage, 'giftDiscard');
g = act(g, rider, {type:'decision', card:f.gift});
}
const pending = g.pendingTreacheryDiscard;
assert.equal(pending?.continuation.kind, 'terrorDiscard');
assert.equal(pending?.reaction?.stage, 'offer');
assert.equal(pending?.batch.cause, `terror:${cause}`);
assert.equal(pending?.batch.entries.length, 1);
assert.equal(pending?.batch.entries[0].discardedBy, cause === 'robberyOverflow' ? moritani : rider);
assert.equal(pending?.batch.entries[0].publicFace, false);
assert.equal(player(g, rider).forces[`${f.destination}:${f.sector}`], 1);
assert.deepEqual(g.wormRides, ['hagga_basin']);
for (const p of g.players) {
const view = viewGame(g, p.id);
assert.deepEqual(view.semutaReaction?.candidates, []);
assert.equal(view.semutaReaction?.canCommit, p.id === holder);
}
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, holder); view.players.find(p => p.id === holder)!.bot = difficulty;
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
}
const result = finishSemuta(g, claim); g = result.g;
assert.equal(player(g, holder).hand.some(card => card.id === result.selected), claim);
assert.equal(g.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
if (cause === 'sabotage') {
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
g = act(g, moritani, {type:'decision', card:f.gift});
assert.ok(player(g, rider).hand.some(card => card.id === f.gift));
}
assert.equal(g.pendingTerrorEntry ?? null, null);
assert.equal(g.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed');
assert.deepEqual(g.decision, {kind:'wormRide', player:rider, territory:'hagga_basin'});
assert.deepEqual(g.wormRides, []);
assert.equal(player(g, rider).forces[`${f.destination}:${f.sector}`], 1);
assert.deepEqual(inventory(g), f.original);
assert.throws(() => act(g, holder, {type:'semutaCommit', event:result.event}));
g = act(g, rider, {type:'decision', accept:false});
assert.notEqual(g.decision?.kind, 'wormRide'); assert.deepEqual(g.wormRides, []);
assert.deepEqual(inventory(g), f.original);
},
);
+179
View File
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { ambassadorPlacementDestinations } from '../game/ecaz-ambassador-placement';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
const seat = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing ${id}`);
return player;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
/** Genuine public Basic setup and live declarations. Original unopened Spice
* card positions are selected to avoid random worms; first-turn marker shipment,
* Mentat Terror and second-turn Ambassador use the normal player actions. */
function source(value: 0 | 3) {
let game = createGame(`REVEALE3${value}`, newPlayer('richese', 'Richese', 'richese'), false, ['choam', 'ecaz']);
for (const faction of ['ecaz', 'moritani', 'fremen', 'choam'] as const)
joinGame(game, newPlayer(faction, faction, faction));
for (const p of game.players) game = act(game, p.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
assert.equal(game.status, 'setup');
// Select one unopened original card for Ecaz so the CHOAM Charity power
// retains its real optional Karama response instead of auto-passing.
const karama = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karama >= 1);
[game.deck[1], game.deck[karama]] = [game.deck[karama], game.deck[1]];
let ecazWindow: Game | null = null;
let charityWindow: Game | null = null;
let ambassadorDeclared = false;
let terrorDeclared = false;
for (let n = 0; n < 2400; n++) {
if (game.status === 'playing' && game.turn === 2 && game.phase === 5 &&
game.active === 'fremen' && clean(game) && seat(game, 'richese').noField?.deployed) break;
assert.notEqual(game.status, 'finished');
if (!charityWindow && game.turn === 2 && game.response?.kind === 'choamCharity')
charityWindow = structuredClone(game);
if (game.status === 'setup' && game.setupStage === 'forces' && seat(game, 'fremen').reserves === 20 &&
viewGame(game, 'fremen').setupPending.includes('fremen')) {
game = act(game, 'fremen', {type:'fremenSetup', placements:{'sietch_tabr:14':10}});
continue;
}
if (game.status === 'playing' && game.turn === 1 && game.phase === 5 && game.active === 'richese' &&
clean(game) && !seat(game, 'richese').shipped && !seat(game, 'richese').noField?.deployed) {
const richese = seat(game, 'richese'), token = richese.noField!.tokens.find(t => t.value === value);
assert.ok(token && richese.noFieldEvent);
game = act(game, 'richese', {type:'ship', noField:token.id, event:richese.noFieldEvent,
territory:'polar_sink', sector:0, allyPayment:0});
continue;
}
if (game.decision?.kind === 'ecazPlacement' && !game.response) {
if (game.turn === 2 && !ambassadorDeclared) {
ecazWindow = structuredClone(game);
const token = game.ecazAmbassadors!.tokens.find(t => t.zone === 'supply');
const destination = ambassadorPlacementDestinations(game)[0];
assert.ok(token && destination);
game = act(game, 'ecaz', {type:'decision', token:token.id, territory:destination.id});
ambassadorDeclared = true;
} else game = act(game, 'ecaz', {type:'decision', decline:true});
continue;
}
if (game.decision?.kind === 'moritaniPlacement' && !game.response) {
if (game.turn === 1 && !terrorDeclared) {
const token = game.moritaniTerror!.tokens.find(t => t.status === 'available');
assert.ok(token);
game = act(game, 'moritani', {type:'decision', token:token.id, territory:'carthag'});
terrorDeclared = true;
} else game = act(game, 'moritani', {type:'decision', decline:true});
continue;
}
if (game.status === 'playing' && game.phase === 5 && game.active && clean(game)) {
game = act(game, game.active, {type:'endMovement'});
continue;
}
const next = nextTruthMovementNativeEarlyStep(game);
assert.ok(next, `Missing native continuation ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, next.actor, next.action.type === 'stormDial'
? {...next.action, amount:game.turn === 1 ? 0 : 1} : next.action);
if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.spiceDeck.length) {
for (let pos = 0; pos < 2; pos++) {
const at = game.spiceDeck.findIndex((card, index) => index >= pos && 'territory' in card && !card.discovery);
assert.ok(at >= pos);
[game.spiceDeck[pos], game.spiceDeck[at]] = [game.spiceDeck[at], game.spiceDeck[pos]];
}
}
}
assert.ok(game.turn === 2 && game.phase === 5 && game.active === 'fremen' && clean(game));
assert.ok(ecazWindow && charityWindow && ambassadorDeclared && terrorDeclared,
`Missing native window: ${JSON.stringify({ecazWindow:!!ecazWindow,charityWindow:!!charityWindow,ambassadorDeclared,terrorDeclared})}`);
assert.ok(game.ecazAmbassadors!.tokens.some(t => t.zone === 'placed'));
assert.ok(game.moritaniTerror!.tokens.some(t => t.status === 'placed'));
const richese = seat(game, 'richese'), deployed = richese.noField!.deployed;
assert.ok(deployed && richese.noFieldEvent);
const origin = 'sietch_tabr:14';
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s)))
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === 2);
assert.ok(destination);
const beforeMove = structuredClone(game);
game = act(game, 'fremen', {type:'move', forces:{[origin]:1}, ...splitLocation(destination)});
assert.equal(game.response?.kind, 'fremenMovement');
return {game, beforeMove, ecazWindow, charityWindow, token:deployed.tokenId, event:richese.noFieldEvent, origin, destination};
}
function passResponse(game: Game) {
for (let n = 0; game.response && n < 12; n++) {
const p = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(p);
game = act(game, p.id, {type:'passResponse'});
}
assert.equal(game.response, null);
return game;
}
for (const value of [0, 3] as const) void test(
`Basic Ecaz/Moritani native tokens preserve foreign movement after No-Field ${value} reveal`, () => {
const f = source(value), before = structuredClone(f.game);
const response = structuredClone(before.response), move = structuredClone(before.pendingFremenMove);
const ambassadors = structuredClone(before.ecazAmbassadors), terror = structuredClone(before.moritaniTerror);
assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, true);
const reveal: Action = {type:'revealNoField', token:f.token, event:f.event};
assert.throws(() => act(before, 'ecaz', reveal));
const after = act(before, 'richese', reveal);
assert.deepEqual(after.response, response);
assert.deepEqual(after.pendingFremenMove, move);
assert.deepEqual(after.ecazAmbassadors, ambassadors);
assert.deepEqual(after.moritaniTerror, terror);
assert.equal(seat(after, 'richese').noField?.deployed, null);
assert.equal(seat(after, 'richese').reserves, seat(before, 'richese').reserves - value);
assert.equal(seat(after, 'richese').forces['polar_sink:0'] ?? 0, value);
const control = passResponse(reload(before)), done = passResponse(reload(after));
assert.deepEqual(seat(done, 'fremen'), seat(control, 'fremen'));
assert.equal(seat(done, 'fremen').forces[f.destination], 1);
assert.equal(seat(done, 'fremen').forces[f.origin], seat(f.beforeMove, 'fremen').forces[f.origin] - 1);
assert.equal(seat(done, 'fremen').moved, seat(f.beforeMove, 'fremen').moved + 1);
assert.deepEqual(done.ecazAmbassadors, ambassadors);
assert.deepEqual(done.moritaniTerror, terror);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(after, 'ecaz');
view.players.find(p => p.id === 'ecaz')!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(reload(after), 'ecaz', action));
}
},
);
void test('a live native Ecaz placement still blocks an unrelated voluntary reveal', () => {
const {ecazWindow, token, event} = source(3);
assert.ok(ecazWindow);
const before = structuredClone(ecazWindow);
assert.equal(before.decision?.kind, 'ecazPlacement');
assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, false);
assert.throws(() => act(before, 'richese', {type:'revealNoField', token, event}));
assert.deepEqual(before, ecazWindow);
});
void test('Basic E3 native Terror remains placed while CHOAM Charity continues after a reveal', () => {
const {charityWindow, token, event} = source(3);
assert.ok(charityWindow);
const before = structuredClone(charityWindow), response = structuredClone(before.response);
const terror = structuredClone(before.moritaniTerror);
assert.equal(response?.kind, 'choamCharity');
assert.ok(terror?.tokens.some(t => t.status === 'placed'));
assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, true);
const after = act(before, 'richese', {type:'revealNoField', token, event});
assert.deepEqual(after.response, response);
assert.deepEqual(after.moritaniTerror, terror);
const original = passResponse(reload(before)), done = passResponse(reload(after));
assert.equal(seat(done, 'choam').spice, seat(original, 'choam').spice);
assert.deepEqual(done.moritaniTerror, terror);
assert.equal(seat(done, 'richese').forces['polar_sink:0'], 3);
});
@@ -148,7 +148,7 @@ void test('a legacy table without public Truthtrance history keeps its earlier i
void test('profile and nested-interaction boundaries do not admit another parent or module', () => {
const patches: Array<Partial<Game>> = [
{advanced:true}, {status:'setup'}, {phase:6}, {phase:-1}, {turn:0},
{expansions:['choam','ecaz']}, {expansions:['ix']},
{expansions:['choam','unknown']}, {expansions:['ix']},
{response:{kind:'richeseAuction', owner:'r', passed:[]}},
{pendingNullentropy:{} as NonNullable<Game['pendingNullentropy']>},
{pendingTreacheryDiscard:{} as NonNullable<Game['pendingTreacheryDiscard']>},
@@ -170,6 +170,9 @@ void test('profile and nested-interaction boundaries do not admit another parent
const wrongSeat = parent('auctionPayment'); wrongSeat.auction!.bidder = 'r'; assert.equal(supported(wrongSeat), false);
const native = parent('auctionPayment'); native.players.push(newPlayer('i','Ixians','ixians'));
native.expansions = ['choam']; assert.equal(supported(native), false);
const e3 = parent('auctionPayment'); e3.players.push(newPlayer('z','Ecaz','ecaz'));
e3.expansions = ['choam']; assert.equal(supported(e3), false);
e3.expansions = ['choam','ecaz']; assert.equal(supported(e3), true);
});
void test('live current-turn commitments block the interruption; settled and previous-turn records do not', () => {