Prototype modifier-aware Smuggler battle collection
This commit is contained in:
1 parent
d6d71c7c02
commit
63298be1e1
22 files changed
+836
-84
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user