Files
Dune/tests/fixture-smuggler-artillery-journey.ts
T

181 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import {
advancedNativePlayer, completeAdvancedNativeSkillsSetup, initializeAdvancedNativeSkillsSetup,
} from './fixture-advanced-native-skills';
import { takeHarassCard } from './fixture-harass-withdraw';
import { nextSkillsTechBattleStep, type SkillsTechBattleStep } from './fixture-skills-tech-battle';
import { quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
export type SmugglerArtilleryJourneyOptions = { weapon?: 'artillery' | 'stunner'; ownTraitor?: boolean };
export type SmugglerArtilleryJourney = {
initial: Game; afterSetup: Game; beforeBattle: Game; game: Game;
owner: string; opponent: string; observer: string; leader: string; opposingLeader: string;
territory: string; key: string; printedAmount: number; cap: number;
weapon: string; shields: [string, string]; ownTraitor: boolean;
plans: [SkillsTechBattleStep, SkillsTechBattleStep];
shipments: { before: Game; action: Action; actor: string; after: Game; price: number }[];
movements: { before: Game; action: Action; actor: string; after: Game }[];
steps: SkillsTechBattleStep[]; staging: string[];
};
export const smugglerArtilleryPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
const act = (game: Game, actor: string, action: Action, steps: SkillsTechBattleStep[]) => {
steps.push({ actor, action: structuredClone(action) }); return applyAction(game, actor, action);
};
function next(game: Game): SkillsTechBattleStep {
if (game.decision?.kind === 'leaderSkillVisibility')
return { actor: game.decision.player, action: { type: 'leaderSkillVisibility',
event: game.decision.event, hide: game.decision.player === 'emperor' } };
return nextSkillsTechBattleStep(game);
}
function advance(state: Game, until: (game: Game) => boolean, steps: SkillsTechBattleStep[]): Game {
let game = state;
for (let n = 0; !until(game) && n < 1000; n++) {
assert.equal(game.turn, 1, 'Continue only this original first-turn programme');
assert.equal(game.status, 'playing');
assert.notEqual(game.decision?.kind, 'smugglerCollection', 'This single printed pile requires no allocation choice');
const step = next(game); game = act(game, step.actor, step.action, steps);
}
assert.ok(until(game), 'Reach the exact original native action boundary'); return game;
}
/** Fresh native Ix original47 + all14 setup, Emperor's actual Smuggler offer and
* trainer choice, zero first-Storm dials, real eight-spice Red Chasm blow. Both
* combatants pay to ship two normal counters to South Mesa, then really move
* them one territory into Red Chasm. No live army, wallet, phase, order, owner,
* skill assignment or receipt is assigned. Physical staging is listed below.
*/
export function createSmugglerArtilleryJourney(
options: SmugglerArtilleryJourneyOptions = {},
): SmugglerArtilleryJourney {
const owner = 'emperor', opponent = 'guild', observer = 'ixians';
const initial = createGame('SMUGGLERARTILLERY', newPlayer(owner, owner, 'emperor'), true, ['ix']);
joinGame(initial, newPlayer(observer, observer, 'ixians'));
joinGame(initial, newPlayer(opponent, opponent, 'guild'));
const setupOptions = { family: 'ixians' as const, requestedSkill: 'smuggler' as const,
skillOwner: 'emperor' as const, opponents: ['guild'] as const, initial };
let game = completeAdvancedNativeSkillsSetup(initializeAdvancedNativeSkillsSetup(setupOptions), setupOptions);
const afterSetup = structuredClone(game), steps: SkillsTechBattleStep[] = [];
const staging = [
'Control only the first original all14 shuffle so Emperor is offered Smuggler; choose its original eligible Hasimir Fenring disc through the setup action.',
'Position original unopened Red Chasm (8 spice, sector 7) and Broken Land non-worm Spice Cards before the first native Spice Blow; no pile is assigned.',
];
for (const [position, territory] of ['red_chasm', 'broken_land'].entries()) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.territory === territory);
assert.ok(index >= 0); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
const territory = 'red_chasm', key = 'red_chasm:7', printedAmount = 8;
const shipments: SmugglerArtilleryJourney['shipments'] = [];
const movements: SmugglerArtilleryJourney['movements'] = [];
game = advance(game, g => g.phase === 5 && clean(g), steps);
assert.equal(game.spice[key], printedAmount);
while (game.phase === 5) {
if (!clean(game)) { const step = next(game); game = act(game, step.actor, step.action, steps); continue; }
const actor = game.active!;
if ([owner, opponent].includes(actor)) {
const before = structuredClone(game);
const action: Action = { type: 'ship', territory: 'south_mesa', sector: 5, amount: 2, elite: 0, allyPayment: 0 };
game = act(game, actor, action, steps);
game = advance(game, clean, steps);
const price = actor === owner ? 4 : 2;
assert.equal(smugglerArtilleryPlayer(before, actor).spice - smugglerArtilleryPlayer(game, actor).spice, price);
shipments.push({ before, action, actor, after: structuredClone(game), price });
const beforeMove = structuredClone(game);
const movement: Action = { type: 'move', from: 'south_mesa:5', territory, sector: 7, amount: 2, elite: 0 };
game = act(game, actor, movement, steps);
game = advance(game, clean, steps);
movements.push({ before: beforeMove, action: movement, actor, after: structuredClone(game) });
assert.equal(smugglerArtilleryPlayer(game, actor).forces[key], 2);
}
game = act(game, actor, { type: 'endMovement' }, steps);
}
game = advance(game, g => g.phase === 6 && clean(g), steps);
// Return starting hands, including the native Ix setup stock, before taking
// canonical physical cards. These are conserved transfers, not purchases.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const physicalWeapon = treacheryDeck(['ix']).find(card => options.weapon === 'stunner'
? card.name === 'Stunner' : card.kind === 'artillery');
assert.ok(physicalWeapon);
const weapon = takeHarassCard(game, owner, physicalWeapon.id).id;
const shieldFaces = game.deck.filter(card => card.kind === 'shield').slice(0, 2);
assert.equal(shieldFaces.length, 2);
const shields: [string, string] = [takeHarassCard(game, owner, shieldFaces[0].id).id,
takeHarassCard(game, opponent, shieldFaces[1].id).id];
staging.push(`Conserve all original starting hands back to undealt stock, then transfer original ${physicalWeapon.name} (${weapon}) and Shield ${shields[0]} to Emperor, Shield ${shields[1]} to Guild. No card is created or purchased.`);
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'smuggler');
assert.ok(assignment);
const leader = assignment.leader;
const cap = advancedNativePlayer(game, 'emperor').leaders.find(l => l.id === leader)!.strength;
assert.equal(cap, 6);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const opposing = smugglerArtilleryPlayer(game, opponent).leaders.filter(l => !l.dead && !trained.has(l.id))
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(opposing);
if (options.ownTraitor && !smugglerArtilleryPlayer(game, owner).traitors.includes(opposing.id)) {
const own = smugglerArtilleryPlayer(game, owner), returned = own.traitors[0];
const index = game.traitorReserve!.indexOf(opposing.id);
const donor = game.players.find(p => p.id !== owner && p.traitors.includes(opposing.id));
assert.ok(returned && (index >= 0 || donor));
if (index >= 0) game.traitorReserve![index] = returned;
else donor!.traitors[donor!.traitors.indexOf(opposing.id)] = returned;
own.traitors[0] = opposing.id;
staging.push(`Conserve original Traitor ${opposing.id}: exchange Emperor's ${returned} with ${donor?.id ?? 'original reserve'}; no reveal history is assigned.`);
}
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === territory);
assert.ok(choice);
game = act(game, choice.chooser, { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, steps);
game = advance(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, steps);
const plans: SmugglerArtilleryJourney['plans'] = [
{ actor: owner, action: { type: 'battlePlan', leader, dial: 1, support: 1, weapon, defense: shields[0] } },
{ actor: opponent, action: { type: 'battlePlan', leader: opposing.id, dial: 0, support: 0, defense: shields[1] } },
];
assert.deepEqual(game.battle!.plans, {});
assert.equal(game.spice[key], printedAmount);
return { initial, afterSetup, beforeBattle, game, owner, opponent, observer, leader, opposingLeader: opposing.id,
territory, key, printedAmount, cap, weapon, shields, ownTraitor: options.ownTraitor ?? false,
plans, shipments, movements, steps, staging };
}
export function revealSmugglerArtilleryJourney(f: SmugglerArtilleryJourney, steps: SkillsTechBattleStep[] = []): Game {
let game = structuredClone(f.game);
for (const plan of f.plans) {
game = act(game, plan.actor, plan.action, steps);
game = advance(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, steps);
}
assert.ok(game.battle?.revealed); return game;
}
/** Original own call first; the other seat explicitly declines. Stop at the
* winner's real optional card cleanup, after payout but before Collection.
* The independent native quote uses the actual own call and the pending other
* seat's no-call, never a rewritten live battle or a synthetic weapon effect.
*/
export function settleSmugglerArtilleryJourney(state: Game, f: SmugglerArtilleryJourney,
steps: SkillsTechBattleStep[] = []) {
let game = advance(state, g => !!g.battle?.revealed && clean(g), steps);
game = act(game, f.owner, { type: 'traitorCall', call: f.ownTraitor }, steps);
game = advance(game, g => !!g.battle?.revealed && clean(g), steps);
const beforeResolution = structuredClone(game);
const resolution = quoteSpiceBankerIncomeBattleEconomics(beforeResolution, beforeResolution).resolution;
game = act(game, f.opponent, { type: 'traitorCall', call: false }, steps);
game = advance(game, g => g.decision?.kind === 'battleCards' && !g.response && !g.phaseOpening, steps);
assert.equal(game.decision?.player, f.owner);
assert.equal(game.phase, 6);
assert.ok(game.lastBattleContext?.smugglerCollection);
return { game, beforeResolution, resolution };
}
/** Keep the winner's optional cards and resume the real next Collection. */
export function finishSmugglerArtilleryJourney(state: Game, steps: SkillsTechBattleStep[] = []): Game {
assert.equal(state.decision?.kind, 'battleCards');
const game = act(state, state.decision!.player, { type: 'decision', discard: [] }, steps);
return advance(game, g => g.phase === 7 && clean(g), steps);
}