Prototype modifier-aware Smuggler battle collection

This commit is contained in:
Quintine committed 2026-10-17 11:33:07 +11:00
1 parent d6d71c7c02
commit 63298be1e1
22 files changed
+836 -84

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
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);
}
+252
View File
@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
advancedNativePlayer, assertAdvancedNativeCustody, initializeAdvancedNativeSkillsSetup,
} from './fixture-advanced-native-skills';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type SmugglerKwisatzJourneyStep = { actor: string; action: Action };
export type SmugglerKwisatzJourneyTransition = {
before: Game; step: SmugglerKwisatzJourneyStep; after: Game;
};
export type SmugglerKwisatzJourneyOptions = {
/** A genuine fresh undealt Advanced Ix/Atreides/Guild lobby; retain its seat IDs. */
initial?: Game;
/** Both are original Spice Blow cards: six at Wind Pass North, eight at Broken Land. */
pile?: 'short' | 'ample';
};
export type SmugglerKwisatzJourneyFixture = {
beforeSetup: Game;
training: SmugglerKwisatzJourneyTransition[];
afterSetup: Game;
firstBattle: {
before: Game;
game: Game;
planActions: [SmugglerKwisatzJourneyStep, SmugglerKwisatzJourneyStep];
revealed: Game;
settled: Game;
steps: SmugglerKwisatzJourneyStep[];
};
shipments: SmugglerKwisatzJourneyTransition[];
/** Second-turn original Battle controls, neither plan sealed. */
game: Game;
owner: string;
opponent: string;
observer: string;
trainer: string;
shield: string;
territory: string;
key: string;
planActions: [SmugglerKwisatzJourneyStep, SmugglerKwisatzJourneyStep];
steps: SmugglerKwisatzJourneyStep[];
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;
/** Native no-bid/no-call policy; all preparation and optional card cleanup remain actions. */
export function nextSmugglerKwisatzJourneyStep(game: Game): SmugglerKwisatzJourneyStep {
if (game.decision?.kind === 'battleCards')
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
if (game.decision?.kind === 'leaderSkillVisibility')
return { actor: game.decision.player, action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: true } };
if (!game.response && !game.decision && !game.phaseOpening && game.battle?.preLeader?.closed === false) {
const battle = game.battle;
return { actor: battle.preLeader!.ready.includes(battle.attacker) ? battle.defender : battle.attacker,
action: { type: 'battlePreparationReady', event: battle.preLeader!.event } };
}
const step = nextSpiceBankerIncomeNativeStep(game);
assert.ok(step, 'The original native programme must retain a continuation.');
return step;
}
export function advanceSmugglerKwisatzJourney(
state: Game, until: (game: Game) => boolean, steps: SmugglerKwisatzJourneyStep[] = [],
): Game {
let game = state;
for (let i = 0; !until(game) && i < 1000; i++) {
assert.equal(game.status, 'playing');
const step = nextSmugglerKwisatzJourneyStep(game);
steps.push(step); game = applyAction(game, step.actor, step.action);
}
assert.ok(until(game), 'The native programme did not reach its requested action window.');
return game;
}
/** No live force, wallet, turn, phase, initiative, loss count, KH or receipt is staged.
* A paid Guild attack meets the original ten Arrakeen forces. Atreides really
* supports/dials seven with another disc, wins, and loses seven forces to the Tanks.
* Turn two has exactly one conflict, on an original, genuinely blown spice pile.
* The trained Smuggler + legally earned KH uses a conserved original Shield;
* its winner-card decision exposes the single collection before ordinary Collection.
*/
export function createSmugglerKwisatzJourney(
options: SmugglerKwisatzJourneyOptions = {},
): SmugglerKwisatzJourneyFixture {
let game = initializeAdvancedNativeSkillsSetup({ family: 'ixians', requestedSkill: 'smuggler',
skillOwner: 'atreides', opponents: ['guild'], initial: options.initial });
const beforeSetup = structuredClone(game);
const owner = advancedNativePlayer(game, 'atreides').id;
const opponent = advancedNativePlayer(game, 'guild').id;
const observer = advancedNativePlayer(game, 'ixians').id;
const steps: SmugglerKwisatzJourneyStep[] = [];
const training: SmugglerKwisatzJourneyTransition[] = [];
const act = (step: SmugglerKwisatzJourneyStep) => {
steps.push(step); game = applyAction(game, step.actor, step.action); return game;
};
for (let i = 0; game.status === 'setup' && i < 200; i++) {
let step: SmugglerKwisatzJourneyStep;
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player, card = viewGame(game, actor).ixTechnology!.setup![0];
assert.ok(card); step = { actor, action: { type: 'decision', card: card.id } };
} else if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
const skill = actor === owner ? 'smuggler' : offer.cards.find(card => !view.unavailableSkills?.[card]);
assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
const eligible = game.players.find(p => p.id === actor)!.leaders
.filter(leader => view.eligibleLeaders.some(candidate => candidate.id === leader.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 = nextSmugglerKwisatzJourneyStep(game);
const before = structuredClone(game); act(step);
if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'smuggler')!.leader;
const staging = [
'Only the original all14 shuffle is controlled by the native setup helper; Ix starting-card choice, physical offers, leader assignments and Traitor choices are original actions.',
'Reorder four original unopened Spice Cards: Hagga Basin, Funeral Plain, Wind Pass North, Broken Land. Both turn-two piles come from the original Advanced Spice Blow; no pile is assigned.',
'First Storm dials are genuine zero actions. Later Advanced Storm entropy is untouched; its one-to-six sectors cannot reach either second-turn battle pile.',
];
for (const [position, territory] of ['hagga_basin', 'funeral_plain', 'wind_pass_north', 'broken_land'].entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && card.territory === territory);
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
const reach = (until: (game: Game) => boolean) => {
game = advanceSmugglerKwisatzJourney(game, until, steps); return game;
};
const shipments: SmugglerKwisatzJourneyTransition[] = [];
const ship = (actor: string, territory: string, sector: number) => {
const before = structuredClone(game);
const step: SmugglerKwisatzJourneyStep = { actor, action: {
type: 'ship', territory, sector, amount: 1, allyPayment: 0,
} };
act(step); reach(g => quiet(g) && !g.pendingShipment && !g.pendingHomeworldShipment);
shipments.push({ before, step, after: structuredClone(game) });
};
const movement = (turn: number, territory: string, sector: number, actors: string[]) => {
reach(g => g.turn === turn && g.phase === 5 && quiet(g));
const shipped = new Set<string>();
while (game.turn === turn && game.phase === 5) {
if (quiet(game) && actors.includes(game.active!) && !shipped.has(game.active!)) {
const actor = game.active!;
assert.equal(game.players.find(p => p.id === actor)!.shipped, false);
ship(actor, territory, sector); shipped.add(actor);
} else act(nextSmugglerKwisatzJourneyStep(game));
}
assert.deepEqual([...shipped].sort(), [...actors].sort());
reach(g => g.turn === turn && g.phase === 6 && quiet(g));
};
const open = (territory: string) => {
const before = structuredClone(game), chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(c => c.territory === territory &&
[c.attacker, c.defender].includes(owner) && [c.attacker, c.defender].includes(opponent));
assert.ok(choice && choice.chooser === chooser);
act({ actor: chooser, action: { type: 'chooseBattle', territory,
target: choice.attacker === chooser ? choice.defender : choice.attacker } });
reach(plansReady); assert.deepEqual(game.battle!.plans, {});
return before;
};
const untrained = (actor: string) => {
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const leader = game.players.find(p => p.id === actor)!.leaders.filter(l => !trained.has(l.id) && !l.dead && !l.usedAt)
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader); return leader.id;
};
movement(1, 'arrakeen', 10, [opponent]);
assert.equal(advancedNativePlayer(game, 'atreides').forces['arrakeen:10'], 10);
assert.equal(advancedNativePlayer(game, 'atreides').battleLosses, 0);
const firstStart = steps.length;
const firstBefore = open('arrakeen'), firstGame = structuredClone(game);
const firstPlans: SmugglerKwisatzJourneyFixture['firstBattle']['planActions'] = [
{ actor: owner, action: { type: 'battlePlan', leader: untrained(owner), dial: 7, support: 7 } },
{ actor: opponent, action: { type: 'battlePlan', leader: untrained(opponent), dial: 0, support: 0 } },
];
for (const step of firstPlans) { act(step); reach(plansReady); }
assert.ok(game.battle?.revealed);
const firstRevealed = structuredClone(game), firstEvent = game.battle.event;
reach(g => !g.battle && !g.decision && !g.response && !g.pendingTreacheryDiscard && g.lastBattleContext?.event === firstEvent);
const firstSettled = structuredClone(game);
assert.equal(game.lastBattleContext!.winner, owner);
assert.equal(advancedNativePlayer(game, 'atreides').battleLosses, 7);
assert.equal(advancedNativePlayer(game, 'atreides').tanks, 7);
assert.equal(advancedNativePlayer(game, 'atreides').forces['arrakeen:10'], 3);
const firstSteps = steps.slice(firstStart);
const territory = options.pile === 'ample' ? 'broken_land' : 'wind_pass_north';
const sector = options.pile === 'ample' ? 12 : 17, key = `${territory}:${sector}`;
movement(2, territory, sector, [owner, opponent]);
assert.equal(game.spice[key], options.pile === 'ample' ? 8 : 6);
// Exchange, rather than duplicate or enlarge a hand: this is explicitly not a purchase.
const own = advancedNativePlayer(game, 'atreides');
let shield = own.hand.find(card => card.kind === 'shield');
if (!shield) {
const index = game.deck.findIndex(card => card.kind === 'shield');
assert.ok(index >= 0 && own.hand.length > 0);
shield = game.deck[index]; game.deck[index] = own.hand[0]; own.hand[0] = shield;
staging.push('At quiet turn-two Battle entry, exchange one original Atreides hand card with an original Shield in undealt stock, conserving hand size and every physical card. No purchase is claimed.');
}
open(territory);
const enemyLeader = untrained(opponent);
const planActions: SmugglerKwisatzJourneyFixture['planActions'] = [
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, defense: shield.id, kwisatz: true } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader, dial: 0, support: 0 } },
];
assert.equal(game.turn, 2);
assert.equal(advancedNativePlayer(game, 'atreides').battleLosses, 7);
assert.ok(!advancedNativePlayer(game, 'atreides').kwisatz?.dead);
assert.equal(viewGame(game, owner).players.find(p => p.id === owner)!.kwisatz!.active, true);
assertAdvancedNativeCustody(game);
return { beforeSetup, training, afterSetup,
firstBattle: { before: firstBefore, game: firstGame, planActions: firstPlans,
revealed: firstRevealed, settled: firstSettled, steps: firstSteps },
shipments, game, owner, opponent, observer, trainer, shield: shield.id, territory, key,
planActions, steps, staging };
}
/** Submit the actual own KH=true action before the other still-unsealed plan. */
export function revealSmugglerKwisatzJourney(
fixture: SmugglerKwisatzJourneyFixture, steps: SmugglerKwisatzJourneyStep[] = [],
): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.planActions) {
steps.push(step); game = applyAction(game, step.actor, step.action);
game = advanceSmugglerKwisatzJourney(game, plansReady, steps);
}
assert.ok(game.battle?.revealed); return game;
}
/** Payout-stage API: resolve original no-call actions, stopping before the winner's
* optional Shield cleanup. This is still Battle, not ordinary Spice Collection. */
export function settleSmugglerKwisatzJourney(
state: Game, steps: SmugglerKwisatzJourneyStep[] = [],
): Game {
return advanceSmugglerKwisatzJourney(state, game => game.decision?.kind === 'battleCards', steps);
}
/** Finish the real Shield choice and Ix phase-opening passes, then stop after Collection. */
export function finishSmugglerKwisatzJourney(
state: Game, steps: SmugglerKwisatzJourneyStep[] = [],
): Game {
assert.equal(state.decision?.kind, 'battleCards');
const step: SmugglerKwisatzJourneyStep = { actor: state.decision!.player, action: { type: 'decision', discard: [] } };
steps.push(step);
const game = applyAction(state, step.actor, step.action);
return advanceSmugglerKwisatzJourney(game, next => next.phase === 7 && quiet(next), steps);
}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createSmugglerArtilleryJourney, finishSmugglerArtilleryJourney, revealSmugglerArtilleryJourney,
settleSmugglerArtilleryJourney, smugglerArtilleryPlayer as player,
} from './fixture-smuggler-artillery-journey';
for (const scenario of [
{ weapon: 'artillery', ownTraitor: false, stunned: true, transfer: 0 },
{ weapon: 'artillery', ownTraitor: true, stunned: false, transfer: 6 },
{ weapon: 'stunner', ownTraitor: false, stunned: false, transfer: 6 },
] as const) {
void test(`genuine native Ix Smuggler: shielded ${scenario.weapon}, own Traitor ${scenario.ownTraitor}`, () => {
const f = createSmugglerArtilleryJourney(scenario);
for (const shipment of f.shipments) {
assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice,
shipment.actor === f.owner ? 4 : 2);
assert.equal(player(shipment.before, shipment.actor).reserves - player(shipment.after, shipment.actor).reserves, 2);
assert.equal(player(shipment.after, shipment.actor).forces['south_mesa:5'], 2);
}
for (const movement of f.movements) {
assert.equal(player(movement.after, movement.actor).forces['south_mesa:5'] ?? 0, 0);
assert.equal(player(movement.after, movement.actor).forces[f.key], 2);
assert.equal(player(movement.after, movement.actor).spice, player(movement.before, movement.actor).spice);
}
const piles = { ...f.game.spice };
const wallets = Object.fromEntries(f.game.players.map(p => [p.id, p.spice]));
const revealed = revealSmugglerArtilleryJourney(f);
assert.equal(revealed.battle!.smugglerCollection!.strength, f.cap);
assert.equal(revealed.battle!.smugglerCollection!.amount, f.cap);
assert.equal(revealed.battle!.smugglerCollection!.stage, 'pending');
assert.deepEqual(revealed.spice, piles);
assert.equal(player(revealed, f.owner).spice, wallets[f.owner], 'Reveal-time collection is not yet spendable');
const { game: settled, resolution } = settleSmugglerArtilleryJourney(revealed, f);
const receipt = settled.lastBattleContext!.smugglerCollection!;
assert.equal(settled.decision!.kind, 'battleCards', 'Real winner cleanup exposes payout before ordinary Collection');
assert.equal(settled.phase, 6);
assert.equal(settled.lastBattleContext!.winner, f.owner);
assert.equal(settled.lastBattleContext!.result, scenario.ownTraitor ? 'traitor' : 'normal');
assert.equal(resolution.effects?.stunned ?? false, scenario.stunned);
assert.equal(player(settled, f.owner).leaders.find(l => l.id === f.leader)!.dead, false);
assert.equal(player(settled, f.opponent).leaders.find(l => l.id === f.opposingLeader)!.dead, scenario.ownTraitor);
assert.equal(receipt.stage, scenario.stunned ? 'void' : 'collected');
assert.equal(receipt.strength, f.cap, 'Actual zeroing does not rewrite the reveal-time cap');
assert.equal(receipt.allocations, undefined, 'No pile-selection action was needed');
assert.deepEqual(settled.spice, { ...piles, [f.key]: f.printedAmount - scenario.transfer });
const opposingStrength = player(f.game, f.opponent).leaders.find(l => l.id === f.opposingLeader)!.strength;
assert.equal(player(settled, f.owner).spice,
wallets[f.owner] + scenario.transfer + (scenario.ownTraitor ? opposingStrength : -1),
'Only actual Smuggler transfer, paid support, and a real Traitor bounty affect this wallet');
assert.equal(player(settled, f.opponent).spice, wallets[f.opponent]);
assert.equal(player(settled, f.owner).forces[f.key], scenario.ownTraitor ? 2 : 1);
assert.equal(player(settled, f.opponent).forces[f.key] ?? 0, 0);
if (scenario.ownTraitor) {
assert.equal(resolution.effects, null, 'The genuine sole own Traitor bypasses Artillery entirely');
assert.equal(resolution.scores, null);
assert.ok(player(settled, f.owner).revealedTraitors?.includes(f.opposingLeader));
} else {
const ownSide = revealed.battle!.attacker === f.owner ? 'attacker' : 'defender';
const otherSide = ownSide === 'attacker' ? 'defender' : 'attacker';
assert.equal(resolution.scores![ownSide], scenario.stunned ? 1 : f.cap + 1);
if (scenario.stunned) assert.equal(resolution.scores![otherSide], 0, 'Both living leaders really contribute zero');
}
const continued = finishSmugglerArtilleryJourney(settled);
assert.equal(continued.turn, 1);
assert.equal(continued.phase, 7, 'Keep the original optional cards and continue to native Collection');
assert.equal(continued.battle, null);
});
}
+29 -9
View File
@@ -178,13 +178,34 @@ void test('changed reveal receipts, spice, plans or skill identity are rejected
assert.equal(viewGame(smugglerBattle(), 'd').battle!.smugglerCollection, null);
assert.equal('frame' in viewGame(pending, 'd').battle!.smugglerCollection!, false);
});
void test('own modified plans fail before sealing without changing any resources', () => {
const game = smugglerBattle({ advanced: true, atreides: true });
game.players[0].battleLosses = 7;
reject(game, { type: 'battlePlan', leader: 'atreides-0', dial: 0, support: 0, kwisatz: true });
const trainer = smugglerBattle({ captured: true, hide: false });
setOtherSkill(trainer, 'warmaster', 'a');
reject(trainer, { type: 'battlePlan', leader: 'guild-0', dial: 0, weapon: trainer.players[0].hand[0].id });
void test('Kwisatz increases the reveal-bound Smuggler cap without crediting pending spice', () => {
for (const amount of [8, 3]) {
const game = smugglerBattle({ advanced: true, atreides: true, amount });
game.players[0].battleLosses = 7;
const strength = game.players[0].leaders[0].strength + 2;
const expected = Math.min(strength, amount);
const pending = revealSmuggler(game, { dial: 0, enemyDial: 5, kwisatz: true });
assert.equal(pending.battle!.smugglerCollection!.strength, strength);
assert.equal(pending.battle!.smugglerCollection!.amount, expected);
assert.equal(pending.players[0].spice, 20);
assert.equal(pending.spice['wind_pass:14'], amount);
const done = finishSmuggler(pending);
assert.equal(done.players[0].spice, 20 + expected);
assert.equal(done.spice['wind_pass:14'], amount - expected);
assert.equal(done.lastBattleContext!.smugglerCollection!.stage, 'collected');
}
});
void test('a captured Smuggler collects its normal trainer bonus for its controller, not its original owner', () => {
const game = smugglerBattle({ captured: true, hide: false });
setOtherSkill(game, 'warmaster', 'a');
const strength = game.players[1].leaders[0].strength + 1;
const pending = revealSmuggler(game, { dial: 0, enemyDial: 5, weapon: game.players[0].hand[0].id });
assert.equal(pending.battle!.smugglerCollection!.strength, strength);
assert.equal(pending.players[0].spice, 20);
const done = finishSmuggler(pending);
assert.equal(done.players[0].spice, 20 + strength);
assert.equal(done.players[1].spice, 20);
assert.equal(done.spice['wind_pass:14'], 8 - strength);
});
void test('the independent Ecaz card variant is gated before a Smuggler plan is sealed', () => {
// The Smuggler battle lives in the Leader Skills profile, which never admits
@@ -200,7 +221,7 @@ void test('the independent Ecaz card variant is gated before a Smuggler plan is
assert.throws(() => viewGame(staged, 'a'));
reject(staged, { type: 'battlePlan' as const, leader: 'emperor-0', dial: 0 });
});
void test('all four actual bot profiles retain a legal unmodified path and finish pending collection', () => {
void test('all four actual bot profiles finish collection with the available modified leader plans', () => {
for (const difficulty of DIFFICULTIES) {
let game = smugglerBattle({ advanced: true, atreides: true });
game.players[0].battleLosses = 7;
@@ -213,7 +234,6 @@ void test('all four actual bot profiles retain a legal unmodified path and finis
});
assert.ok(candidates.length, difficulty);
const { id, action } = candidates[0];
if (id === 'a' && action.type === 'battlePlan') assert.ok(!action.kwisatz);
game = applyAction(game, id, action);
game = JSON.parse(JSON.stringify(game));
}
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame } from '../game/engine';
import { advancedNativePlayer, assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
import {
createSmugglerKwisatzJourney, finishSmugglerKwisatzJourney,
revealSmugglerKwisatzJourney, settleSmugglerKwisatzJourney,
} from './fixture-smuggler-kwisatz-journey';
void test('native Atreides earns KH with seven paid Arrakeen casualties, then its original Smuggler collects a short real Spice Blow', () => {
const fixture = createSmugglerKwisatzJourney();
const { game, owner, opponent, trainer, key, firstBattle } = fixture;
assert.equal(advancedNativePlayer(fixture.afterSetup, 'atreides').forces['arrakeen:10'], 10);
assert.equal(viewGame(firstBattle.game, owner).players.find(p => p.id === owner)!.kwisatz!.active, false);
assert.notEqual(firstBattle.revealed.battle!.plans[owner].leader, trainer);
assert.equal(firstBattle.revealed.battle!.plans[owner].dial, 7);
assert.equal(firstBattle.revealed.battle!.plans[owner].support, 7);
assert.equal(firstBattle.settled.lastBattleContext!.winner, owner);
const veteran = advancedNativePlayer(firstBattle.settled, 'atreides');
assert.equal(veteran.battleLosses, 7);
assert.equal(veteran.tanks, 7);
assert.equal(veteran.forces['arrakeen:10'], 3);
assert.equal(veteran.spice, advancedNativePlayer(firstBattle.revealed, 'atreides').spice - 7,
'The actual supported battle pays seven before the pending first Collection opens.');
assert.equal(viewGame(game, owner).players.find(p => p.id === owner)!.kwisatz!.active, true);
for (const shipment of fixture.shipments) {
const payerBefore = shipment.before.players.find(p => p.id === shipment.step.actor)!;
const payerAfter = shipment.after.players.find(p => p.id === shipment.step.actor)!;
assert.equal(payerBefore.spice - payerAfter.spice, shipment.step.actor === owner ? 2 : 1);
assert.equal(payerBefore.reserves - payerAfter.reserves, 1);
}
assert.equal(game.turn, 2);
assert.equal(game.phase, 6);
assert.deepEqual(game.battle!.plans, {});
assert.equal(game.spice[key], 6);
const wallet = advancedNativePlayer(game, 'atreides').spice;
const ownSealed = applyAction(game, owner, fixture.planActions[0].action);
assert.equal(ownSealed.battle!.plans[owner].kwisatz, true);
assert.equal(ownSealed.battle!.plans[owner].defense, fixture.shield);
assert.deepEqual(viewGame(ownSealed, opponent).battle!.plans, {});
assert.equal(advancedNativePlayer(ownSealed, 'atreides').spice, wallet);
const pending = revealSmugglerKwisatzJourney(fixture);
const printed = advancedNativePlayer(game, 'atreides').leaders.find(l => l.id === trainer)!.strength;
assert.equal(pending.battle!.smugglerCollection!.strength, printed + 2);
assert.equal(pending.battle!.smugglerCollection!.amount, Math.min(printed + 2, 6));
assert.equal(pending.battle!.smugglerCollection!.stage, 'pending');
assert.equal(advancedNativePlayer(pending, 'atreides').spice, wallet);
assert.equal(pending.spice[key], 6);
const paid = settleSmugglerKwisatzJourney(pending);
assert.equal(paid.phase, 6);
assert.equal(paid.decision?.player, owner);
assert.equal(paid.lastBattleContext!.smugglerCollection!.stage, 'collected');
assert.equal(advancedNativePlayer(paid, 'atreides').spice, wallet + 6);
assert.equal(paid.spice[key], 0);
assert.equal(advancedNativePlayer(paid, 'atreides').leaders.find(l => l.id === trainer)!.dead, false);
assert.equal(advancedNativePlayer(paid, 'atreides').kwisatz!.usedAt, fixture.territory);
assertAdvancedNativeCustody(paid);
});
void test('earned KH adds two beyond printed Smuggler strength, pays once before cleanup, and leaves ordinary Collection separate', () => {
const fixture = createSmugglerKwisatzJourney({ pile: 'ample' });
const wallet = advancedNativePlayer(fixture.game, 'atreides').spice;
const pending = revealSmugglerKwisatzJourney(fixture);
const printed = advancedNativePlayer(pending, 'atreides').leaders.find(l => l.id === fixture.trainer)!.strength;
const amount = printed + 2;
assert.ok(amount < pending.spice[fixture.key]);
assert.equal(pending.battle!.smugglerCollection!.amount, amount);
assert.equal(advancedNativePlayer(pending, 'atreides').spice, wallet);
const paid = settleSmugglerKwisatzJourney(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))));
assert.equal(paid.phase, 6);
assert.equal(paid.decision?.kind, 'battleCards');
assert.equal(paid.lastBattleContext!.winner, fixture.owner);
assert.equal(paid.lastBattleContext!.smugglerCollection!.stage, 'collected');
assert.equal(advancedNativePlayer(paid, 'atreides').spice, wallet + amount);
assert.equal(paid.spice[fixture.key], 8 - amount);
assert.equal(advancedNativePlayer(paid, 'atreides').tanks, 7);
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(paid)));
assert.equal(advancedNativePlayer(restored, 'atreides').spice, wallet + amount);
assert.equal(restored.spice[fixture.key], 8 - amount);
const beforeDuplicate = structuredClone(restored);
assert.throws(() => applyAction(restored, fixture.opponent, { type: 'traitorCall', call: false }));
assert.deepEqual(restored, beforeDuplicate);
const collected = finishSmugglerKwisatzJourney(restored);
assert.equal(collected.phase, 7);
assert.equal(collected.spice[fixture.key], 0);
assert.equal(advancedNativePlayer(collected, 'atreides').spice, wallet + amount + (8 - amount) + 2,
'Ordinary Collection separately gathers the remainder and native Arrakeen income; it does not pay the skill again.');
assert.equal(collected.lastBattleContext!.smugglerCollection!.amount, amount);
assert.equal(collected.lastBattleContext!.smugglerCollection!.stage, 'collected');
assertAdvancedNativeCustody(collected);
});