129 lines
9.6 KiB
TypeScript
129 lines
9.6 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 TruthtranceNativeFamilyPromisesKind = 'shipment' | 'movement';
|
|
export type TruthtranceNativeFamilyPromisesStep = TruthMovementStep;
|
|
export const actTruthtranceNativeFamilyPromises = actTruthMovement;
|
|
export const truthtranceNativeFamilyPromisesPlayer = truthMovementPlayer;
|
|
export const truthtranceNativeFamilyPromisesInventory = truthMovementInventory;
|
|
export const truthtranceNativeFamilyPromisesClean = truthMovementClean;
|
|
export const truthtranceNativeFamilyPromisesPhysical = (game: Game) => game.players.map(p => ({
|
|
id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
|
elites: p.elites, leaders: p.leaders, ally: p.ally, moved: p.moved, shipped: p.shipped,
|
|
}));
|
|
|
|
/** Native quiet controls only. The original question and Ecaz's actual
|
|
* shipment/movement remain caller-owned, including choosing to skip them. */
|
|
export function nextTruthtranceNativeFamilyPromises(game: Game): TruthMovementStep | null {
|
|
if (game.truthtrance) return null;
|
|
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
|
const decision = game.decision;
|
|
if (decision?.kind === 'moritaniSetup')
|
|
return { actor: decision.player, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
|
|
if (decision?.kind === 'ecazPlacement')
|
|
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
|
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
|
truthMovementPlayer(game, 'ecaz').reserves === 20)
|
|
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
|
}
|
|
if (game.status === 'playing' && game.phase === 5 && game.active === 'ecaz' && truthMovementClean(game)) return null;
|
|
return nextStrongholdFactionsNativeStep(game);
|
|
}
|
|
export const nextTruthtranceNativeFamilyPromisesStep = nextTruthtranceNativeFamilyPromises;
|
|
export function advanceTruthtranceNativeFamilyPromises(game: Game, until: (state: Game) => boolean,
|
|
actions: TruthMovementStep[] = []): Game {
|
|
for (let count = 0; count < 1800; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
const next = nextTruthtranceNativeFamilyPromises(game);
|
|
assert.ok(next, `Supply the original Truthtrance or Ecaz action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
game = actTruthMovement(game, next.actor, next.action, actions);
|
|
}
|
|
throw Error('The original E3 promise programme did not reach its finite boundary');
|
|
}
|
|
export function finishTruthtranceNativeFamilyPromises(game: Game, actions: TruthMovementStep[] = []): Game {
|
|
return advanceTruthtranceNativeFamilyPromises(game, truthMovementClean, actions);
|
|
}
|
|
|
|
/** Actual public Basic/Advanced native E3 setup, original undealt card
|
|
* positions and an unopened land Spice selection. No played custody is set. */
|
|
export function createTruthtranceNativeFamilyPromisesFixture(options: {
|
|
advanced?: boolean; kind?: TruthtranceNativeFamilyPromisesKind;
|
|
} = {}) {
|
|
const advanced = options.advanced ?? false, kind = options.kind ?? 'shipment';
|
|
const asker = 'atreides', target = 'ecaz', moritani = 'moritani';
|
|
let game = createGame(`TRUTHNATIVEFAMILYPROMISES${advanced ? 'ADV' : 'BASIC'}${kind.toUpperCase()}`,
|
|
newPlayer(asker, 'Atreides', 'atreides'), advanced, ['ecaz']);
|
|
joinGame(game, newPlayer(target, 'Ecaz', 'ecaz'));
|
|
joinGame(game, newPlayer(moritani, 'Moritani', 'moritani'));
|
|
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceNativeFamilyPromises(game, until, actions); };
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
const lobby = structuredClone(game);
|
|
const start: TruthMovementStep = { actor: game.host, action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
|
|
act(start.actor, start.action);
|
|
const setup = structuredClone(game);
|
|
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
|
|
assert.deepEqual(game.expansions, ['ecaz']);
|
|
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));
|
|
const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 33);
|
|
const truth = game.deck.find(card => card.effect === 'truthtrance');
|
|
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 2);
|
|
assert.ok(truth); assert.equal(ordinary.length, 2);
|
|
const selected = [truth, ...ordinary], deal = selected.map(card => card.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 { card: id, position, from };
|
|
});
|
|
const beforeStartingDeal = structuredClone(game), setupAt = actions.length;
|
|
reach(state => state.status === 'playing');
|
|
const afterSetup = structuredClone(game), setupSteps = structuredClone(actions.slice(setupAt));
|
|
const ecazSetup = setupSteps.find(step => step.action.type === 'ecazSetup'); assert.ok(ecazSetup);
|
|
const moritaniSetup = setupSteps.find(step => step.action.type === 'decision' && step.action.territory === 'oh_gap'); assert.ok(moritaniSetup);
|
|
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
|
assert.deepEqual(truthMovementPlayer(game, target).forces, { 'imperial_basin:10': 6 });
|
|
assert.equal(truthMovementPlayer(game, target).reserves, 14);
|
|
assert.equal(truthMovementPlayer(game, target).spice, 12);
|
|
assert.equal(truthMovementPlayer(game, asker).forces['arrakeen:10'], 10);
|
|
assert.deepEqual(truthMovementPlayer(game, moritani).forces, { 'oh_gap:9': 6 });
|
|
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) }], phaseAt = actions.length;
|
|
reach(state => state.turn === 1 && state.phase === 2 && truthMovementClean(state));
|
|
const nativePhases = { before: beforeSpiceOrder, steps: structuredClone(actions.slice(phaseAt)), after: structuredClone(game) };
|
|
assert.equal(game.advanced, advanced); assert.deepEqual(game.ready, []);
|
|
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises, undefined); assert.equal(game.movementPromises, undefined);
|
|
assert.equal(truthMovementPlayer(game, target).spice, 12);
|
|
assert.equal(truthMovementPlayer(game, target).reserves, 14);
|
|
assert.equal(truthMovementPlayer(game, target).forces['imperial_basin:10'], 6);
|
|
assert.deepEqual(truthMovementInventory(game), originalCards);
|
|
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
|
|
const question = { kind, target, territory: kind === 'shipment' ? 'carthag' : 'arrakeen', minimum: kind === 'shipment' ? 5 : 6 };
|
|
const declarationStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truth.id } };
|
|
const askStep: TruthMovementStep = { actor: asker, action: { type: 'truthAsk', question } };
|
|
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 0, allyPayment: 0 };
|
|
const move: Action = { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 6 };
|
|
return { game, source: structuredClone(game), advanced, kind, asker, target, owner: target, moritani,
|
|
truth: truth.id, card: truth.id, question, claim: question, declarationStep, askStep, shipment, move,
|
|
eventAction: kind === 'shipment' ? shipment : move, endMovement: { type: 'endMovement' } as Action,
|
|
initial, lobby, start, setup, beforeStartingDeal, afterSetup, setupSteps, ecazSetup, moritaniSetup,
|
|
beforeSpiceOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards, deal, dealOrder, nativePhases,
|
|
physical: structuredClone(truthtranceNativeFamilyPromisesPhysical(game)), actions, sourceActionCount: actions.length,
|
|
staging: [
|
|
`Original public ${advanced ? 'Advanced preview' : 'Basic'} Atreides/Ecaz/Moritani setup selects exact Ecaz and no optional module or independent Treachery variant. Original unopened starting positions deal Truthtrance to Atreides and ordinary cards to Ecaz/Moritani. Ecaz actually chooses six in Basin10, Moritani six in Oh Gap9, and Atreides retains its original Arrakeen10 garrison.`,
|
|
'Actual Storm/Spice and readiness reach first Charity before declaration. Ecaz retains printed12 cash,14 reserves and its native six-counter group. A future reserve-city5 shipment pays real5, or a future adjacent ground move sends the original six to Arrakeen beside original Atreides10. No hand, army, wallet, phase clock, promise, parent or completed event is installed.',
|
|
] };
|
|
}
|
|
export type TruthtranceNativeFamilyPromisesFixture = ReturnType<typeof createTruthtranceNativeFamilyPromisesFixture>;
|