Files
Dune/tests/fixture-truth-movement-native-early.ts
Quintine c311ed4251 Support native early Basic movement promises
Price native Once Around and Silent funding in early movement forecasts, retaining full sealed donor locks and exact paid-sale boundaries. Add current zero/pass guidance, fixed non-CHOAM preopening Charity entitlement and shared owned normal-revival projection while actual counters remain pending. Preserve the mandatory Ix phase-opening continuation.

Validation: affected rules/control tests, types/lint, production build and six gameplay/control/table-rendering programmes with eight existing-profile native runs. Wider preparations, CHOAM own unresolved opening, modules/Advanced, full rules and deployed acceptance remain open. No saved-room, database, restart or deployment operation.
2026-10-24 16:05:41 +11:00

188 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type TruthMovementNativeEarlyStep = TruthMovementStep;
export type TruthMovementNativeEarlyRoster = 'richese-choam' | 'ecaz-moritani' | 'ixians-tleilaxu';
export type TruthMovementNativeEarlyAuction = {
position?: 'first' | 'last';
method?: 'onceAround' | 'silent';
direction?: 'clockwise' | 'counterclockwise';
card?: string;
keepUnbid?: boolean;
};
export const actTruthMovementNativeEarly = actTruthMovement;
export const truthMovementNativeEarlyClean = truthMovementClean;
export const truthMovementNativeEarlyPlayer = truthMovementPlayer;
export const truthMovementNativeEarlyInventory = truthMovementInventory;
/** Consume live native controls only. Callers own Truthtrance, paid bids and
* Emperor's shipment/movement; cache defaults are actual zero/pass actions. */
export function nextTruthMovementNativeEarlyStep(game: Game,
auction: TruthMovementNativeEarlyAuction = {}): TruthMovementNativeEarlyStep | null {
if (game.truthtrance) return null;
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision) {
const actor = decision.player, event = game.richeseBidding?.event;
if (decision.kind === 'richeseBlackMarket')
return { actor, action: { type: 'decision', event, decline: true } };
if (decision.kind === 'richeseDeclaration')
return { actor, action: { type: 'decision', event, position: auction.position ?? 'first' } };
if (decision.kind === 'richeseCacheTerms') return { actor, action: {
type: 'decision', event, method: auction.method ?? 'onceAround',
direction: auction.direction ?? 'clockwise',
} };
if (decision.kind === 'richeseCache') {
const card = game.richeseCache?.find(card => auction.card ? card.id === auction.card : true);
assert.ok(card, 'Choose an original card still held in the native Richese cache');
return { actor, action: { type: 'decision', event, card: card.id,
...(actor === game.richeseBidding?.owner ? {
method: auction.method ?? 'onceAround', direction: auction.direction ?? 'clockwise',
} : {}),
} };
}
if (decision.kind === 'richeseUnbid')
return { actor, action: { type: 'decision', event, keep: auction.keepUnbid ?? false } };
if (decision.kind === 'ecazPlacement' || decision.kind === 'choamMovement')
return { actor, action: { type: 'decision', decline: true } };
if (decision.kind === 'moritaniSetup')
return { actor, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
if (decision.kind === 'ixSetup') {
const card = game.ixSetupCards?.find(card => card.kind === 'shield');
assert.ok(card, 'Ixians must actually choose the original Shield from their starting-card inspection');
return { actor, action: { type: 'decision', card: card.id } };
}
}
const ecaz = game.players.find(player => player.faction === 'ecaz');
if (!decision && game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
}
if (truthMovementNativeEarlyClean(game)) {
if (game.richeseAuction && !game.richeseAuction.outcome) {
const lot = game.richeseAuction;
const actor = lot.method === 'silent'
? lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor, 'An unfinished native cache lot needs its next eligible bidder');
return { actor, action: { type: 'richeseBid',
event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
if (game.status === 'playing' && game.phase === 5 && game.active === 'emperor') return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthMovementNativeEarly(game: Game, until: (state: Game) => boolean,
actions: TruthMovementNativeEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original native source ended before its requested control');
const step = nextTruthMovementNativeEarlyStep(game, auction);
assert.ok(step, 'Supply the real Truthtrance, bid or Emperor movement choice before continuing');
game = actTruthMovementNativeEarly(game, step.actor, step.action, actions);
}
throw Error('The original native early movement source did not reach its finite boundary');
}
/** Fresh public Basic expansion setup, stopped at clean Charity before any
* question or Bidding. Select only original unopened deal/Spice positions and
* scalar lotteries; original setup controls supply every physical counter. */
export function createTruthMovementNativeEarlyFixture(options: {
roster?: TruthMovementNativeEarlyRoster;
} = {}) {
const roster = options.roster ?? 'richese-choam', asker = 'atreides', target = 'emperor';
const native = roster === 'richese-choam' ? ['richese', 'choam'] as const
: roster === 'ecaz-moritani' ? ['ecaz', 'moritani'] as const : ['ixians', 'tleilaxu'] as const;
const expansions = roster === 'richese-choam' ? ['choam'] : roster === 'ecaz-moritani' ? ['ecaz'] : ['ix'];
let game = createGame(`TRUTHMOVEMENTEARLY${roster.toUpperCase()}`,
newPlayer(asker, 'Atreides', 'atreides'), false, expansions);
for (const faction of [target, ...native] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: TruthMovementNativeEarlyStep[] = [];
const act = (actor: string, action: Action) => {
game = actTruthMovementNativeEarly(game, actor, action, actions);
};
const reach = (until: (state: Game) => boolean) => {
game = advanceTruthMovementNativeEarly(game, until, actions);
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game), lobbyActionCount = actions.length;
act(game.host, { type: 'start' });
const setup = structuredClone(game), setupActionCount = actions.length;
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, expansions);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0), 'Choose only an unopened original deal');
const originalMainDeck = game.deck.map(card => card.id), originalCache = (game.richeseCache ?? []).map(card => card.id);
const originalCards = truthMovementNativeEarlyInventory(game);
if (roster === 'richese-choam') {
assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
}
const truth = game.deck.find(card => card.effect === 'truthtrance');
const baliset = game.deck.find(card => card.name === 'Baliset');
const shield = game.deck.find(card => card.kind === 'shield');
const snooper = game.deck.find(card => card.kind === 'snooper');
assert.ok(truth && baliset && shield && snooper);
const deal = [truth.id, baliset.id, shield.id, snooper.id];
const dealOrder = deal.map((id, position) => {
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]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
for (const [index, player] of game.players.entries())
assert.deepEqual(player.hand.map(card => card.id), [deal[index]]);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const beforeSpiceOrder = structuredClone(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const land = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(land);
const spiceOrder = [{ position: 0, from, card: structuredClone(land) }];
reach(state => state.turn === 1 && state.phase === 2 && truthMovementNativeEarlyClean(state));
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
assert.deepEqual(game.ready, []); assert.equal(game.auction ?? null, null);
assert.equal(game.richeseBidding ?? null, null); assert.equal(game.richeseAuction ?? null, null);
assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined);
const emperor = truthMovementNativeEarlyPlayer(game, target);
assert.equal(emperor.spice, 10); assert.equal(emperor.reserves, 20); assert.equal(emperor.tanks, 0);
assert.deepEqual(emperor.forces, {}); assert.equal(emperor.shipped, false); assert.equal(emperor.moved, 0);
assert.ok(truthMovementNativeEarlyPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.deepEqual(truthMovementNativeEarlyInventory(game), originalCards);
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
const claim = { kind: 'movement' as const, target, territory: 'carthag', minimum: 8 };
return {
game, source: structuredClone(game), roster, expansions, asker, target, native,
richese: roster === 'richese-choam' ? 'richese' : null,
choam: roster === 'richese-choam' ? 'choam' : null,
ecaz: roster === 'ecaz-moritani' ? 'ecaz' : null,
moritani: roster === 'ecaz-moritani' ? 'moritani' : null,
ixians: roster === 'ixians-tleilaxu' ? 'ixians' : null,
tleilaxu: roster === 'ixians-tleilaxu' ? 'tleilaxu' : null,
lobby, lobbyActionCount, setup, setupActionCount, beforeStartingDeal, afterSetup, afterSetupActionCount,
beforeSpiceOrder, spiceOrder, deal, dealOrder, startingDealSelection: dealOrder,
originalMainDeck, originalCache, originalCards, originalTreachery: originalCards, originalSpice,
actions, sourceActionCount: actions.length,
sourceCounts: { lobby: lobbyActionCount, setup: setupActionCount, storm: afterSetupActionCount, charity: actions.length },
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
card: truth.id, truth: truth.id, claim, question: claim,
declarationStep: { actor: asker, action: { type: 'card', card: truth.id } } satisfies TruthMovementNativeEarlyStep,
askStep: { actor: asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementNativeEarlyStep,
bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
payment: { type: 'decision', karama: false } satisfies Action,
purchaseCost: 2,
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 } satisfies Action,
shipmentCost: 8,
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 } satisfies Action,
endMovement: { type: 'endMovement' } satisfies Action,
staging: [
`Original public bare Basic Atreides/Emperor/${native.join('/')} setup uses four original unopened deal positions, one ordinary unopened Spice position and native scalar lotteries. Actual setup deals Truthtrance to Atreides and Baliset to Emperor, and actual Storm/Spice controls reach clean turn-1 Charity in exactly ${actions.length} recorded actions. Emperor retains its printed ten spice, twenty reserves, zero board forces and unused movement. ${roster === 'richese-choam' ? 'The original 35-card main deck and ten-card cache are conserved; native first/last Once Around/Silent auctions remain future controls.' : roster === 'ecaz-moritani' ? 'Ecaz actually places six in Imperial Basin and Moritani actually places six in empty Oh Gap; neither spends Emperor resources.' : 'Ixians actually inspect the four original starting cards and choose the original Shield; the remaining Truthtrance/Baliset/Snooper are natively shuffled and dealt to Atreides/Emperor/Tleilaxu. The native mobile-stronghold placement retains its original six-counter garrison, and Tleilaxu receives its actual original Face Dancers.'} The constructor stops before any Truthtrance declaration, answer, auction declaration, bid or payment. Future purchase2, city shipment8, ground movement8 and ending movement remain caller actions; no dealt hand, wallet, force, clock, answer, promise or completed receipt is installed.`,
],
};
}
export type TruthMovementNativeEarlyFixture = ReturnType<typeof createTruthMovementNativeEarlyFixture>;