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.
188 lines
13 KiB
TypeScript
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>;
|