Files
Dune/tests/fixture-diplomat-late-journey.ts
T

242 lines
15 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import {
advancedNativePlayer, initializeAdvancedNativeSkillsSetup,
} from './fixture-advanced-native-skills';
import { nextSkillsTechBattleStep, type SkillsTechBattleStep } from './fixture-skills-tech-battle';
export type DiplomatLateJourneyStep = SkillsTechBattleStep;
export type DiplomatLateJourneyTransition = {
before: Game; step: DiplomatLateJourneyStep; after: Game;
};
export type DiplomatLateJourney = {
initial: Game; beforeSetup: Game; afterSetup: Game;
training: DiplomatLateJourneyTransition[];
shipments: DiplomatLateJourneyTransition[];
movements: DiplomatLateJourneyTransition[];
beforeCards: Game; beforeBattle: Game; game: Game;
revealed: Game; beforeLate: Game; offered: Game;
owner: string; opponent: string; observer: string;
trainer: string; opposingLeader: string; territory: string; key: string; event: string;
ownPoison: string; opposingPoison: string; worthless: string; portable: string;
plans: [DiplomatLateJourneyStep, DiplomatLateJourneyStep];
ownerVote: DiplomatLateJourneyStep; portableAction: DiplomatLateJourneyStep;
copyAction: DiplomatLateJourneyStep; declineAction: DiplomatLateJourneyStep;
remainingVote: DiplomatLateJourneyStep;
expected: Record<'copy' | 'decline', {
winner: string; trainerDead: boolean; ownForces: number; opposingForces: number;
ownTanks: number; opposingTanks: number; ownPoisonDiscards: number;
opposingPoisonDiscards: number; portableDiscards: number; worthlessDiscards: number;
mandatoryWorthless: boolean; collection: number; remainingVoters: string[];
}>;
steps: DiplomatLateJourneyStep[]; staging: string[];
};
const quiet = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
const plansReady = (game: Game) => quiet(game) && !!game.battle &&
!game.battle.preparation && game.battle.preLeader?.closed !== false;
const act = (game: Game, step: DiplomatLateJourneyStep, steps: DiplomatLateJourneyStep[]) => {
steps.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
};
/** Native no-bid programme; leave the original Portable in its cache until the
* explicitly disclosed transfer. Hidden, unselected other trainers do not lend
* their battle skills to this two-actor poison comparison. */
export function nextDiplomatLateJourneyStep(game: Game): DiplomatLateJourneyStep {
if (game.decision?.kind === 'leaderSkillVisibility')
return { actor: game.decision.player, action: { type: 'leaderSkillVisibility',
event: game.decision.event, hide: true } };
if (game.decision?.kind === 'richeseBlackMarket')
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, decline: true } };
if (game.decision?.kind === 'richeseDeclaration')
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, position: 'first' } };
if (game.decision?.kind === 'richeseCache') {
const card = game.richeseCache!.find(c => c.effect !== 'portableSnooper'); assert.ok(card);
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'onceAround', direction: 'clockwise' } };
}
if (game.decision?.kind === 'richeseUnbid')
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
const lot = game.richeseAuction;
if (lot && !lot.outcome && quiet(game)) {
const actor = lot.method === 'silent'
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor);
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
return nextSkillsTechBattleStep(game);
}
export function advanceDiplomatLateJourney(
state: Game, until: (game: Game) => boolean, steps: DiplomatLateJourneyStep[] = [],
): Game {
let game = state;
for (let n = 0; !until(game) && n < 1000; n++) {
assert.equal(game.status, 'playing');
assert.equal(game.turn, 1, 'Continue this original first-turn programme only.');
game = act(game, nextDiplomatLateJourneyStep(game), steps);
}
assert.ok(until(game), 'The original Diplomat programme must reach its requested action window.');
return game;
}
/** Fresh native Richese/Emperor/Guild Advanced all14 setup, actual Diplomat
* offer/training, real zero first-Storm dials, paid two-counter South Mesa
* shipments and actual movement into a native eight-spice Red Chasm blow.
* No live forces, wallets, phase, order, ownership, plan or receipt are assigned.
* Only original unopened cards and conserved hand/cache stock are staged below. */
export function createDiplomatLateJourney(options: { initial?: Game } = {}): DiplomatLateJourney {
let initial: Game;
if (options.initial) initial = structuredClone(options.initial);
else {
initial = createGame('DIPLOMATLATE', newPlayer('emperor', 'Emperor', 'emperor'), true, ['choam']);
joinGame(initial, newPlayer('richese', 'Richese', 'richese'));
joinGame(initial, newPlayer('guild', 'Guild', 'guild'));
}
assert.equal(initial.status, 'lobby');
const setupOptions = { family: 'richese' as const, requestedSkill: 'diplomat' as const,
skillOwner: 'emperor' as const, opponents: ['guild'] as const, initial };
let game = initializeAdvancedNativeSkillsSetup(setupOptions);
const beforeSetup = structuredClone(game);
const owner = advancedNativePlayer(game, 'emperor').id;
const opponent = advancedNativePlayer(game, 'guild').id;
const observer = advancedNativePlayer(game, 'richese').id;
const steps: DiplomatLateJourneyStep[] = [], training: DiplomatLateJourneyTransition[] = [];
for (let n = 0; game.status === 'setup' && n < 200; n++) {
let step: DiplomatLateJourneyStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill = actor === owner ? 'diplomat' : offer.cards.find(c => !view.unavailableSkills?.[c]);
assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
const eligible = game.players.find(p => p.id === actor)!.leaders
.filter(l => view.eligibleLeaders.some(candidate => candidate.id === l.id))
.sort((a, b) => b.strength - a.strength);
assert.ok(eligible[0]);
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: eligible[0].id } };
} else step = nextDiplomatLateJourneyStep(game);
const before = structuredClone(game);
game = act(game, step, steps);
if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const staging = [
'Control only the first original all14 shuffle so Emperor receives Diplomat; train its strongest original eligible disc with the real leaderSkill action. Other native setup and Traitor choices remain actions.',
'Reorder original unopened Red Chasm and Broken Land non-worm Spice Cards before the first Advanced Spice Blow. Red Chasm receives its printed eight spice through the native phase, not an assigned pile.',
];
for (const [position, territory] of ['red_chasm', 'broken_land'].entries()) {
const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c && c.territory === territory);
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
const territory = 'red_chasm', key = 'red_chasm:7';
const shipments: DiplomatLateJourneyTransition[] = [], movements: DiplomatLateJourneyTransition[] = [];
game = advanceDiplomatLateJourney(game, g => g.phase === 5 && quiet(g), steps);
assert.equal(game.spice[key], 8);
while (game.phase === 5) {
if (!quiet(game)) { game = act(game, nextDiplomatLateJourneyStep(game), steps); continue; }
const actor = game.active!;
if ([owner, opponent].includes(actor)) {
const before = structuredClone(game);
const shipment: DiplomatLateJourneyStep = { actor, action: {
type: 'ship', territory: 'south_mesa', sector: 5, amount: 2, elite: 0, allyPayment: 0,
} };
game = act(game, shipment, steps);
game = advanceDiplomatLateJourney(game, quiet, steps);
shipments.push({ before, step: shipment, after: structuredClone(game) });
const beforeMove = structuredClone(game);
const movement: DiplomatLateJourneyStep = { actor, action: {
type: 'move', from: 'south_mesa:5', territory, sector: 7, amount: 2, elite: 0,
} };
game = act(game, movement, steps);
game = advanceDiplomatLateJourney(game, quiet, steps);
movements.push({ before: beforeMove, step: movement, after: structuredClone(game) });
}
game = act(game, { actor, action: { type: 'endMovement' } }, steps);
}
game = advanceDiplomatLateJourney(game, g => g.phase === 6 && quiet(g), steps);
const beforeCards = structuredClone(game);
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const take = (actor: string, kind: 'poison' | 'worthless') => {
const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === actor)!.hand.push(card); return card.id;
};
const ownPoison = take(owner, 'poison'), opposingPoison = take(opponent, 'poison'), worthless = take(owner, 'worthless');
const portableIndex = game.richeseCache!.findIndex(c => c.effect === 'portableSnooper');
assert.ok(portableIndex >= 0);
const portableCard = game.richeseCache!.splice(portableIndex, 1)[0], portable = portableCard.id;
advancedNativePlayer(game, 'guild').hand.push(portableCard);
staging.push(`Return every original starting hand to undealt stock, then conservedly transfer original poison ${ownPoison} and Worthless ${worthless} to Emperor, poison ${opposingPoison} to Guild, and original Portable Snooper ${portable} from the still-unbought Richese cache to Guild. No card is created; no paid card acquisition is claimed.`);
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'diplomat')!.leader;
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const opposing = advancedNativePlayer(game, 'guild').leaders
.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(opposing);
assert.ok(advancedNativePlayer(game, 'emperor').leaders.find(l => l.id === trainer)!.strength > opposing.strength);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === territory);
assert.ok(choice);
game = act(game, { actor: choice.chooser, action: { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, steps);
game = advanceDiplomatLateJourney(game, plansReady, steps);
const controls = structuredClone(game), event = game.battle!.event!;
const plans: DiplomatLateJourney['plans'] = [
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: ownPoison, defense: worthless } },
{ actor: opponent, action: { type: 'battlePlan', leader: opposing.id, dial: 0, support: 0, weapon: opposingPoison } },
];
for (const plan of plans) {
game = act(game, plan, steps); game = advanceDiplomatLateJourney(game, plansReady, steps);
}
assert.ok(game.battle!.revealed);
const revealed = structuredClone(game);
const ownerVote: DiplomatLateJourneyStep = { actor: owner, action: { type: 'traitorCall', call: false } };
game = act(game, ownerVote, steps);
const beforeLate = structuredClone(game);
const portableAction: DiplomatLateJourneyStep = { actor: opponent, action: { type: 'portableSnooper', card: portable, event } };
game = act(game, portableAction, steps);
assert.equal(game.decision?.kind, 'diplomatDefense');
assert.ok(game.decision?.kind === 'diplomatDefense');
const copyAction: DiplomatLateJourneyStep = { actor: owner,
action: { type: 'decision', event: game.decision.event, card: worthless } };
const declineAction: DiplomatLateJourneyStep = { actor: owner,
action: { type: 'decision', event: game.decision.event, card: null } };
const remainingVote: DiplomatLateJourneyStep = { actor: opponent, action: { type: 'traitorCall', call: false } };
return { initial, beforeSetup, afterSetup, training, shipments, movements, beforeCards, beforeBattle,
game: controls, revealed, beforeLate, offered: game, owner, opponent, observer, trainer,
opposingLeader: opposing.id, territory, key, event, ownPoison, opposingPoison, worthless, portable,
plans, ownerVote, portableAction, copyAction, declineAction, remainingVote,
expected: {
copy: { winner: owner, trainerDead: false, ownForces: 2, opposingForces: 0, ownTanks: 0, opposingTanks: 2,
ownPoisonDiscards: 0, opposingPoisonDiscards: 1, portableDiscards: 1, worthlessDiscards: 1,
mandatoryWorthless: true, collection: 4, remainingVoters: [opponent] },
decline: { winner: opponent, trainerDead: true, ownForces: 0, opposingForces: 2, ownTanks: 2, opposingTanks: 0,
ownPoisonDiscards: 1, opposingPoisonDiscards: 0, portableDiscards: 0, worthlessDiscards: 1,
mandatoryWorthless: false, collection: 4, remainingVoters: [opponent] },
}, steps, staging };
}
/** Resolve only the original still-unsubmitted Guild vote. Emperor's earlier
* false declaration is never replayed. Stop at real winner-card cleanup. */
export function settleDiplomatLateJourney(
state: Game, fixture: DiplomatLateJourney, steps: DiplomatLateJourneyStep[] = [],
) {
assert.equal(state.battle!.traitorCalls[fixture.owner], false);
assert.equal(state.battle!.traitorCalls[fixture.opponent], undefined);
assert.notEqual(state.decision?.kind, 'diplomatDefense');
const beforeResolution = structuredClone(state);
const afterVote = act(state, fixture.remainingVote, steps);
const cleanup = advanceDiplomatLateJourney(afterVote, g => g.decision?.kind === 'battleCards' && !g.response && !g.phaseOpening, steps);
return { beforeResolution, afterVote, cleanup };
}
/** Keep all optional winner cards; advance original phase-opening passes until
* ordinary Collection has run, without manufacturing another battle or vote. */
export function finishDiplomatLateJourney(state: Game, steps: DiplomatLateJourneyStep[] = []): Game {
assert.equal(state.decision?.kind, 'battleCards');
const game = act(state, { actor: state.decision!.player, action: { type: 'decision', discard: [] } }, steps);
return advanceDiplomatLateJourney(game, g => g.phase === 7 && quiet(g), steps);
}