242 lines
15 KiB
TypeScript
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);
|
|
}
|