Seal exact native Homeworld Reinforcements costs and plan sources
This commit is contained in:
1 parent
05c4c8c7fb
commit
c64c08af89
32 files changed
+1484
-119
No files matched your search
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Game,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { type NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
|
||||
import {
|
||||
advanceHarassHomeworldArrakis, createHarassHomeworldArrakisFixture,
|
||||
harassHomeworldArrakisClean, harassHomeworldArrakisPlayer,
|
||||
nextHarassHomeworldArrakisStep, type HarassHomeworldArrakisStep,
|
||||
} from './fixture-harass-homeworld-arrakis';
|
||||
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
|
||||
import { assertRecruitsHomeworldInventory } from './fixture-recruits-homeworld';
|
||||
|
||||
export type ReinforcementsHomeworldArrakisStep = HarassHomeworldArrakisStep;
|
||||
export type ReinforcementsHomeworldArrakisOptions = {
|
||||
advanced?: boolean;
|
||||
slot?: 'weapon' | 'defense';
|
||||
/** Emperor ships 3N4E; Fremen free-ship 6N, 7N or 7N1E respectively. */
|
||||
reserves?: 'normal' | 'mixed' | 'elite' | 'shortage';
|
||||
outcome?: 'winner' | 'loser';
|
||||
harass?: boolean;
|
||||
traitor?: 'owner' | 'opponent' | 'both';
|
||||
explosion?: boolean;
|
||||
seatIds?: [string, string];
|
||||
/** Fresh undealt classic Emperor/Guild or Fremen/Guild lobby. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type ReinforcementsHomeworldArrakisFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeBattle: Game;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
planActions: [ReinforcementsHomeworldArrakisStep, ReinforcementsHomeworldArrakisStep];
|
||||
battleStep: ReinforcementsHomeworldArrakisStep;
|
||||
shipments: { before: Game; after: Game; step: ReinforcementsHomeworldArrakisStep }[];
|
||||
nativeSteps: ReinforcementsHomeworldArrakisStep[];
|
||||
staging: string[];
|
||||
fee: { normal: number; elite: number };
|
||||
sources: NativeReserveSelections;
|
||||
};
|
||||
export const reinforcementsHomeworldArrakisPlayer = harassHomeworldArrakisPlayer;
|
||||
export const reinforcementsHomeworldArrakisClean = harassHomeworldArrakisClean;
|
||||
|
||||
/** All counters remain in original native homes, original Arrakis armies or
|
||||
* Tanks. This programme has no visitors. Includes the original three Fedaykin
|
||||
* (not five invented stars), Sardaukar, physical 36-card deck and Traitor pack. */
|
||||
export function assertReinforcementsHomeworldArrakisInventory(game: Game): void {
|
||||
assertRecruitsHomeworldInventory(game);
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors, {});
|
||||
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])].sort(),
|
||||
traitorDeck(game.players).sort());
|
||||
}
|
||||
|
||||
function initialize(game: Game): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
|
||||
};
|
||||
try { return initializeEcazTreacheryGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = reinforcementsHomeworldArrakisPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0]; assert.ok(returned);
|
||||
const index = game.traitorReserve!.indexOf(identity);
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(index >= 0 || donor, 'Only exchange an original physical Traitor');
|
||||
if (index >= 0) game.traitorReserve![index] = returned;
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved original Traitor ${identity} exchanged with ${returned} into ${actor}'s hand.`);
|
||||
}
|
||||
|
||||
function freshFremenProgramme(options: ReinforcementsHomeworldArrakisOptions) {
|
||||
const ids = options.seatIds ?? ['fremen', 'guild'];
|
||||
let game = options.initial ? structuredClone(options.initial) :
|
||||
createGame('REINFORCEMENTSWORLDARRAKIS', newPlayer(ids[0], 'Fremen', 'fremen'), options.advanced ?? false);
|
||||
if (!options.initial) joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
assert.equal(game.status, 'lobby');
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['fremen', 'guild']);
|
||||
assert.equal(game.advanced, options.advanced ?? game.advanced);
|
||||
const nativeSteps: ReinforcementsHomeworldArrakisStep[] = [];
|
||||
const act = (step: ReinforcementsHomeworldArrakisStep) => {
|
||||
nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
};
|
||||
if (!game.homeworlds) act({ actor: game.host, action: { type: 'homeworlds', enabled: true } });
|
||||
for (const p of game.players) if (!p.ready) act({ actor: p.id, action: { type: 'ready' } });
|
||||
const initial = structuredClone(game);
|
||||
game = initialize(game);
|
||||
for (let n = 0; n < 180 && game.status !== 'playing'; n++) {
|
||||
const step = nextHarassHomeworldArrakisStep(game); assert.ok(step);
|
||||
if (step.action.type === 'fremenSetup') step.action = { type: 'fremenSetup',
|
||||
placements: { [`sietch_tabr:${territory('sietch_tabr').sectors[0]}`]: 10 }, elitePlacements: {} };
|
||||
act(step);
|
||||
}
|
||||
assert.equal(game.status, 'playing', 'Finish every original setup choice');
|
||||
const afterSetup = structuredClone(game);
|
||||
const owner = game.players.find(p => p.faction === 'fremen')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const staging = ['Original classic Fremen/Guild Homeworld setup; only conserved hands and unopened Spice Cards are positioned. No counters, wallets, phases, history or receipts are assigned.'];
|
||||
// Keep turn-one Blow away from unrelated worm-riding decisions by positioning
|
||||
// original unopened non-worm Spice Cards, as in the native Recruits programme.
|
||||
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
|
||||
const at = game.spiceDeck.findIndex((card, index) => index >= i && 'territory' in card && !card.discovery);
|
||||
assert.ok(at >= 0); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
staging.push('Position unopened original first-Blow non-worm Spice Cards; no draw, phase or army is fabricated.');
|
||||
const locationTerritory = 'sietch_tabr', sector = territory(locationTerritory).sectors[0];
|
||||
const location = `${locationTerritory}:${sector}`;
|
||||
const shipments: ReinforcementsHomeworldArrakisFixture['shipments'] = [];
|
||||
const waiting = new Set([owner, opponent]);
|
||||
const until = (predicate: (state: Game) => boolean) => {
|
||||
game = advanceHarassHomeworldArrakis(game, predicate, nativeSteps);
|
||||
};
|
||||
until(g => g.phase === 5 && harassHomeworldArrakisClean(g));
|
||||
while (waiting.size) {
|
||||
const actor = game.active!; assert.ok(waiting.has(actor));
|
||||
const isFremen = actor === owner;
|
||||
const amount = isFremen ? options.reserves === 'mixed' ? 6 : options.reserves === 'shortage' ? 8 : 7
|
||||
: options.outcome === 'loser' ? 3 : 2;
|
||||
const elite = isFremen && options.reserves === 'shortage' ? 1 : 0;
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), amount, elite); assert.ok(sources);
|
||||
const step: ReinforcementsHomeworldArrakisStep = { actor, action: { type: 'ship',
|
||||
territory: locationTerritory, sector, amount, elite, homeworldSources: sources, allyPayment: 0 } };
|
||||
const before = structuredClone(game); act(step); until(harassHomeworldArrakisClean);
|
||||
shipments.push({ before, after: structuredClone(game), step });
|
||||
waiting.delete(actor); act({ actor, action: { type: 'endMovement' } });
|
||||
until(g => (g.phase === 5 || g.phase === 6) && harassHomeworldArrakisClean(g));
|
||||
}
|
||||
until(g => g.phase === 6 && harassHomeworldArrakisClean(g));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === locationTerritory); assert.ok(choice);
|
||||
const battleStep: ReinforcementsHomeworldArrakisStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: locationTerritory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep); game = finishHarassPreparation(game);
|
||||
return { initial, afterSetup, beforeBattle, game, owner, opponent, territory: locationTerritory,
|
||||
location, battleStep, shipments, nativeSteps, staging };
|
||||
}
|
||||
|
||||
/** Real turn-one native setup, shipment and battle admission. Emperor uses the
|
||||
* existing genuine 3N4E Tuek programme. Fremen free-shipping removes real normal
|
||||
* reserves and leaves real Fedaykin in their native World; never assign a pool.
|
||||
* All physical hand/Traitor staging is explicit and conserved. */
|
||||
export function createReinforcementsHomeworldArrakisFixture(
|
||||
options: ReinforcementsHomeworldArrakisOptions = {},
|
||||
): ReinforcementsHomeworldArrakisFixture {
|
||||
const base = options.reserves && options.reserves !== 'normal' ? freshFremenProgramme(options) :
|
||||
createHarassHomeworldArrakisFixture({ advanced: options.advanced, initial: options.initial,
|
||||
seatIds: options.seatIds, outcome: options.outcome, slot: options.slot });
|
||||
const { owner, opponent, location } = base;
|
||||
const game = base.game;
|
||||
const staging = [...base.staging];
|
||||
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
|
||||
takeHarassCard(game, owner, REINFORCEMENTS_CARD);
|
||||
if (options.harass) takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
|
||||
staging.push(`After genuine shipments/battle admission, conserve original hands into the remaining deck and place physical Reinforcements${options.harass ? ' plus Harass & Withdraw' : ''} into ${owner}'s hand.`);
|
||||
const own = reinforcementsHomeworldArrakisPlayer(game, owner);
|
||||
const other = reinforcementsHomeworldArrakisPlayer(game, opponent);
|
||||
const ownLeader = own.leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
const otherLeader = other.leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
||||
if (options.traitor === 'owner' || options.traitor === 'both') exchangeTraitor(game, owner, otherLeader.id, staging);
|
||||
if (options.traitor === 'opponent' || options.traitor === 'both') exchangeTraitor(game, opponent, ownLeader.id, staging);
|
||||
const normal = Math.min(3, own.reserves - (own.elites?.reserves ?? 0));
|
||||
const fee = { normal, elite: 3 - normal };
|
||||
const sources: NativeReserveSelections = { [`homeworld:${own.faction}`]: { ...fee } };
|
||||
const slot = options.slot ?? 'weapon';
|
||||
const planActions: ReinforcementsHomeworldArrakisFixture['planActions'] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: 2, support: game.advanced && own.faction !== 'fremen' ? 1 : 0,
|
||||
leader: ownLeader.id, weapon: slot === 'weapon' ? REINFORCEMENTS_CARD : options.harass ? HARASS_WITHDRAW_CARD : null,
|
||||
defense: slot === 'defense' ? REINFORCEMENTS_CARD : options.harass ? HARASS_WITHDRAW_CARD : null,
|
||||
reinforcementsSources: sources } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: options.outcome === 'loser' ? 3 : 0,
|
||||
support: options.outcome === 'loser' && game.advanced ? 3 : 0, leader: otherLeader.id } },
|
||||
];
|
||||
if (options.explosion) {
|
||||
// The opposing side can naturally play its own Lasgun + Shield together,
|
||||
// leaving both of the utility user's physical slots available for R + H.
|
||||
for (const kind of ['lasgun', 'shield'] as const) {
|
||||
const card = game.deck.find(c => c.kind === kind); assert.ok(card);
|
||||
takeHarassCard(game, opponent, card.id);
|
||||
planActions[1].action[kind === 'lasgun' ? 'weapon' : 'defense'] = card.id;
|
||||
}
|
||||
staging.push('Conserve the original Lasgun and Shield from remaining deck into the opposing hand for an actual explosion plan.');
|
||||
}
|
||||
assertReinforcementsHomeworldArrakisInventory(game);
|
||||
return { ...base, game, planActions, staging, fee, sources, location };
|
||||
}
|
||||
|
||||
export function sealReinforcementsHomeworldArrakisPlan(game: Game, step: ReinforcementsHomeworldArrakisStep): Game {
|
||||
return finishHarassPreparation(applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function revealReinforcementsHomeworldArrakisBattle(fixture: ReinforcementsHomeworldArrakisFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealReinforcementsHomeworldArrakisPlan(game, step);
|
||||
assert.equal(game.battle?.revealed, true);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Keeps Harass allocation and typed losses explicit human choices; normal
|
||||
* native continuation handles actual calls, retention and mandatory disposal. */
|
||||
export function finishReinforcementsHomeworldArrakisBattle(state: Game,
|
||||
options: { callers?: string[]; returns?: HarassWithdrawSelection; losses?: { normal: number; elite: number } } = {},
|
||||
actions?: ReinforcementsHomeworldArrakisStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (!game.battle && harassHomeworldArrakisClean(game)) return game;
|
||||
let step: ReinforcementsHomeworldArrakisStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
|
||||
assert.ok(options.returns, 'Choose actual revealed physical Harass returns');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
|
||||
} else if (!game.response && !game.phaseOpening && game.decision?.kind === 'battleLosses' && options.losses) {
|
||||
const choice = game.decision.options.findIndex(c => c.normal === options.losses!.normal && c.elite === options.losses!.elite);
|
||||
assert.ok(choice >= 0, 'Selected typed casualties must be an actual original commitment');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', choice } };
|
||||
} else step = nextHarassHomeworldArrakisStep(game);
|
||||
assert.ok(step);
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Native Arrakis Reinforcements aftermath did not finish');
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import type { HomeworldForces } from '../game/homeworld-custody';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||||
import {
|
||||
actHarassHomeworld, advanceHarassHomeworld, assertHarassHomeworldCustody,
|
||||
createHarassHomeworldSetup, harassHomeworldClean, harassHomeworldPlayer,
|
||||
harassHomeworldPool, nextHarassHomeworldStep, sealHarassHomeworldPlan,
|
||||
type HarassHomeworldSetup, type HarassHomeworldStep,
|
||||
} from './fixture-harass-homeworld';
|
||||
|
||||
export { REINFORCEMENTS_CARD };
|
||||
export const REINFORCEMENTS_KAITAIN = 'homeworld:emperor';
|
||||
export const REINFORCEMENTS_SALUSA = 'homeworld:emperor:salusa';
|
||||
export type ReinforcementsHomeworldStep = HarassHomeworldStep;
|
||||
export type ReinforcementsHomeworldOptions = {
|
||||
advanced?: boolean;
|
||||
initial?: Game;
|
||||
seatIds?: [string, string, string];
|
||||
role?: 'visitor' | 'native';
|
||||
secondary?: boolean;
|
||||
slot?: 'weapon' | 'defense';
|
||||
outcome?: 'win' | 'loss' | 'traitor' | 'explosion';
|
||||
/** Real first-turn Kaitain → Salusa movement, never force staging. */
|
||||
nativeTransfer?: number;
|
||||
sources?: NativeReserveSelections;
|
||||
ownerDial?: number;
|
||||
ownerSupport?: number;
|
||||
};
|
||||
export type ReinforcementsHomeworldFixture = HarassHomeworldSetup & {
|
||||
options: ReinforcementsHomeworldOptions;
|
||||
role: 'visitor' | 'native';
|
||||
location: string;
|
||||
native: string;
|
||||
visitor: string;
|
||||
beforeNativeMove: Game;
|
||||
nativeMove: ReinforcementsHomeworldStep | null;
|
||||
afterNativeMove: Game;
|
||||
beforeShipment: Game;
|
||||
shipmentStep: ReinforcementsHomeworldStep;
|
||||
afterShipment: Game;
|
||||
beforeBattle: Game;
|
||||
battleStep: ReinforcementsHomeworldStep;
|
||||
game: Game;
|
||||
planActions: [ReinforcementsHomeworldStep, ReinforcementsHomeworldStep];
|
||||
sources: NativeReserveSelections;
|
||||
staging: string[];
|
||||
};
|
||||
export const reinforcementsHomeworldPlayer = harassHomeworldPlayer;
|
||||
export const reinforcementsHomeworldPool = harassHomeworldPool;
|
||||
export const assertReinforcementsHomeworldCustody = assertHarassHomeworldCustody;
|
||||
export const advanceReinforcementsHomeworld = advanceHarassHomeworld;
|
||||
export const reinforcementsHomeworldClean = harassHomeworldClean;
|
||||
export const sealReinforcementsHomeworldPlan = sealHarassHomeworldPlan;
|
||||
|
||||
/** Explicit normal-only fee alternatives; source keys are full native World IDs. */
|
||||
export function reinforcementsHomeworldSources(salusaNormal = 0): NativeReserveSelections {
|
||||
assert.ok(Number.isSafeInteger(salusaNormal) && salusaNormal >= 0 && salusaNormal <= 3);
|
||||
return {
|
||||
...(salusaNormal < 3 ? { [REINFORCEMENTS_KAITAIN]: { normal: 3 - salusaNormal, elite: 0 } } : {}),
|
||||
...(salusaNormal > 0 ? { [REINFORCEMENTS_SALUSA]: { normal: salusaNormal, elite: 0 } } : {}),
|
||||
};
|
||||
}
|
||||
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = harassHomeworldPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0]; assert.ok(returned);
|
||||
const reserveIndex = game.traitorReserve!.indexOf(identity);
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(reserveIndex >= 0 || donor, 'The original Traitor identity must have actual custody');
|
||||
if (reserveIndex >= 0) game.traitorReserve!.splice(reserveIndex, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved original Traitor ${identity}: ${donor?.id ?? 'undealt reserve'} exchanged for ${returned}.`);
|
||||
}
|
||||
|
||||
/** Original independent 36-card classic setup, native movement, paid typed
|
||||
* invasion and actual chooseBattle/preparation. Only conserved original hands,
|
||||
* Traitor identities and unopened Spice order are positioned and labeled.
|
||||
* No forces, wallet, phase, history, battle receipt or World face is assigned.
|
||||
* Fresh authenticated lobby/seat IDs are accepted for mobile and policy runs. */
|
||||
export function createReinforcementsHomeworldFixture(
|
||||
options: ReinforcementsHomeworldOptions = {},
|
||||
): ReinforcementsHomeworldFixture {
|
||||
const setup = createHarassHomeworldSetup(options);
|
||||
const role = options.role ?? 'visitor', outcome = options.outcome ?? 'loss';
|
||||
assert.ok(!options.secondary || role === 'native' && setup.afterSetup.advanced);
|
||||
assert.ok(outcome !== 'explosion' || options.slot !== 'weapon', 'Lasgun occupies the weapon slot');
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const staging: string[] = [];
|
||||
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
|
||||
const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery);
|
||||
assert.ok(at >= i); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
staging.push('Position unopened original non-worm Spice Cards for the first native Spice Blow; no phase or storm assignment.');
|
||||
const act = (step: ReinforcementsHomeworldStep) => {
|
||||
game = actHarassHomeworld(game, step, setup.nativeSteps);
|
||||
};
|
||||
const reach = (until: (g: Game) => boolean) => {
|
||||
game = advanceHarassHomeworld(game, until, setup.nativeSteps);
|
||||
};
|
||||
reach(g => g.phase === 5 && g.active === setup.owner && harassHomeworldClean(g));
|
||||
let beforeNativeMove = structuredClone(game), afterNativeMove = structuredClone(game);
|
||||
let nativeMove: ReinforcementsHomeworldStep | null = null;
|
||||
const moveNative = () => {
|
||||
beforeNativeMove = structuredClone(game);
|
||||
if (game.advanced) {
|
||||
const normal = options.nativeTransfer ?? (role === 'visitor' || options.secondary ? 2 : 10);
|
||||
const movement = viewGame(game, setup.owner).homeworldMove; assert.ok(movement && !movement.blocked);
|
||||
nativeMove = { actor: setup.owner, action: { type: 'emperorHomeworldMove', event: movement.event,
|
||||
origin: REINFORCEMENTS_KAITAIN, normal, elite: 0 } };
|
||||
act(nativeMove);
|
||||
} else assert.equal(options.nativeTransfer ?? 0, 0, 'Basic has no inter-World movement');
|
||||
afterNativeMove = structuredClone(game);
|
||||
};
|
||||
// Moving begins movement and consumes the unused shipment opportunity.
|
||||
// A visiting Emperor must therefore invade first, then move his reserves.
|
||||
if (role === 'native') moveNative();
|
||||
const native = role === 'visitor' ? setup.opponent : setup.owner;
|
||||
const visitor = role === 'visitor' ? setup.owner : setup.opponent;
|
||||
const location = role === 'visitor' ? 'homeworld:guild'
|
||||
: options.secondary ? REINFORCEMENTS_SALUSA : REINFORCEMENTS_KAITAIN;
|
||||
reach(g => g.phase === 5 && g.active === visitor && harassHomeworldClean(g));
|
||||
const beforeShipment = structuredClone(game);
|
||||
const amount = role === 'visitor' ? 7 : 5, elite = role === 'visitor' ? 4 : 0;
|
||||
const shipmentSources = nativeShipmentSources(viewGame(game, visitor), amount, elite); assert.ok(shipmentSources);
|
||||
const choice = homeworldShipmentChoice(viewGame(game, visitor), location,
|
||||
Object.fromEntries(Object.entries(shipmentSources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(choice.action, choice.blocked ?? 'The original typed invasion is available');
|
||||
const shipmentStep = { actor: visitor, action: choice.action }; act(shipmentStep);
|
||||
reach(g => !g.pendingHomeworldShipment && !g.pendingShipment && harassHomeworldClean(g));
|
||||
const afterShipment = structuredClone(game);
|
||||
assert.deepEqual(combatArmy(game, visitor, location), role === 'visitor'
|
||||
? { normal: 3, elite: 4 } : { normal: 5, elite: 0 });
|
||||
if (role === 'visitor') moveNative();
|
||||
reach(g => g.phase === 6 && harassHomeworldClean(g) && !!g.active);
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
takeHarassCard(game, setup.owner, REINFORCEMENTS_CARD);
|
||||
let laser: string | undefined, shield: string | undefined;
|
||||
if (outcome === 'explosion') {
|
||||
laser = game.deck.find(c => c.kind === 'lasgun')!.id;
|
||||
shield = game.deck.find(c => c.kind === 'shield')!.id;
|
||||
takeHarassCard(game, setup.owner, laser); takeHarassCard(game, setup.opponent, shield);
|
||||
}
|
||||
staging.push('Return original hands to undealt stock, then conserve the physical Reinforcements and (explosion only) Lasgun/Shield into their owners’ hands; no card is manufactured.');
|
||||
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const ownLeader = outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
|
||||
const otherLeader = outcome === 'win' ? living(setup.opponent)[0] : living(setup.opponent).at(-1)!;
|
||||
if (outcome === 'traitor') conserveTraitor(game, native,
|
||||
native === setup.owner ? otherLeader.id : ownLeader.id, staging);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const battleChoice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location);
|
||||
assert.ok(battleChoice);
|
||||
const battleStep: ReinforcementsHomeworldStep = { actor: battleChoice.chooser,
|
||||
action: { type: 'chooseBattle', territory: location,
|
||||
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
|
||||
act(battleStep);
|
||||
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
|
||||
assert.equal(Object.keys(game.battle!.plans).length, 0);
|
||||
const sources = structuredClone(options.sources ?? reinforcementsHomeworldSources());
|
||||
const dial = options.ownerDial ?? (role === 'visitor' || outcome === 'win' ? 2 : 0);
|
||||
const support = options.ownerSupport ?? (game.advanced ? dial : 0);
|
||||
const slot = options.slot ?? 'defense';
|
||||
const opponentSupport = game.advanced && outcome !== 'win' && outcome !== 'explosion'
|
||||
? Math.min(role === 'visitor' ? 3 : 5, harassHomeworldPlayer(game, setup.opponent).spice) : 0;
|
||||
const opponentDial = outcome === 'win' || outcome === 'explosion' ? 0 : role === 'visitor' ? 3
|
||||
: game.advanced ? (5 + opponentSupport) / 2 : 5;
|
||||
const planActions: ReinforcementsHomeworldFixture['planActions'] = [
|
||||
{ actor: setup.owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
|
||||
weapon: slot === 'weapon' ? REINFORCEMENTS_CARD : laser ?? null,
|
||||
defense: slot === 'defense' ? REINFORCEMENTS_CARD : null,
|
||||
reinforcementsSources: sources } },
|
||||
{ actor: setup.opponent, action: { type: 'battlePlan',
|
||||
dial: opponentDial, support: opponentSupport,
|
||||
leader: otherLeader.id, defense: shield ?? null } },
|
||||
];
|
||||
assertHarassHomeworldCustody(game);
|
||||
return { ...setup, options, role, location, native, visitor, beforeNativeMove, nativeMove, afterNativeMove,
|
||||
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, game, planActions, sources, staging };
|
||||
}
|
||||
export function revealReinforcementsHomeworldBattle(fixture: ReinforcementsHomeworldFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealHarassHomeworldPlan(game, step);
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
/** Human-owned native Traitor/explosion choices remain explicit. Real mandatory
|
||||
* discard, typed winner casualties and legal aftercare use existing controls. */
|
||||
export function finishReinforcementsHomeworldBattle(game: Game, options: {
|
||||
callers?: string[];
|
||||
explosionLosses?: HomeworldForces;
|
||||
actions?: ReinforcementsHomeworldStep[];
|
||||
} = {}): Game {
|
||||
for (let n = 0; n < 240; n++) {
|
||||
if (!game.battle && !game.homeworldBattleLoss && harassHomeworldClean(game)) return game;
|
||||
let step: ReinforcementsHomeworldStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'homeworldExplosion' && options.explosionLosses) {
|
||||
const d = game.decision;
|
||||
const choice = d.options.findIndex(f => f.normal === options.explosionLosses!.normal && f.elite === options.explosionLosses!.elite);
|
||||
assert.ok(choice >= 0, 'Choose a real native typed explosion option');
|
||||
step = { actor: d.player, action: { type: 'decision', event: d.event, choice } };
|
||||
} else step = nextHarassHomeworldStep(game);
|
||||
assert.ok(step, 'Both genuine plans must already be sealed');
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
game = actHarassHomeworld(game, step, options.actions);
|
||||
}
|
||||
throw Error('Original Reinforcements Homeworld battle did not complete');
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
weaponKills,
|
||||
} from '../game/battle-cards';
|
||||
import { portableSnooperPlanBlock } from '../game/portable-snooper';
|
||||
import { parsePlanClaim } from '../game/battle-promises';
|
||||
import { TRUTH_CARD_NAMES } from '../game/truthtrance';
|
||||
import type { Plan } from '../game/engine';
|
||||
|
||||
const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
|
||||
@@ -110,13 +108,3 @@ void test('late eligibility obeys forbidden poison defense while preserving unre
|
||||
null,
|
||||
);
|
||||
});
|
||||
void test('structured Truthtrance and plan questions include canonical Richese card identities', () => {
|
||||
assert.ok(TRUTH_CARD_NAMES.includes(portable.name));
|
||||
assert.deepEqual(
|
||||
parsePlanClaim({ kind: 'defense', name: portable.name }, []),
|
||||
{ kind: 'defense', name: portable.name },
|
||||
);
|
||||
assert.throws(() =>
|
||||
parsePlanClaim({ kind: 'defense', name: 'Invented Snooper' }, []),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { harassHomeworldArrakisPool as pool } from './fixture-harass-homeworld-arrakis';
|
||||
import {
|
||||
assertReinforcementsHomeworldArrakisInventory,
|
||||
createReinforcementsHomeworldArrakisFixture,
|
||||
finishReinforcementsHomeworldArrakisBattle,
|
||||
reinforcementsHomeworldArrakisClean,
|
||||
reinforcementsHomeworldArrakisPlayer as player,
|
||||
revealReinforcementsHomeworldArrakisBattle,
|
||||
sealReinforcementsHomeworldArrakisPlan,
|
||||
type ReinforcementsHomeworldArrakisFixture,
|
||||
} from './fixture-reinforcements-homeworld-arrakis';
|
||||
|
||||
function assertDisposed(game: Game, fixture: ReinforcementsHomeworldArrakisFixture, harass: boolean): void {
|
||||
for (const id of harass ? [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD] : [REINFORCEMENTS_CARD]) {
|
||||
assert.equal(game.discard.filter(c => c.id === id).length, 1, 'Used physical utility cards discard even for the winner');
|
||||
assert.ok(!player(game, fixture.owner).hand.some(c => c.id === id));
|
||||
}
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(reinforcementsHomeworldArrakisClean(game));
|
||||
assertReinforcementsHomeworldArrakisInventory(game);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const slot of ['weapon', 'defense'] as const) {
|
||||
for (const outcome of ['winner', 'loser'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${slot} ${outcome}: native fee is not dial, support or an army`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves: 'normal', outcome, harass: false });
|
||||
const before = player(f.game, f.owner);
|
||||
const revealed = revealReinforcementsHomeworldArrakisBattle(f);
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealed,
|
||||
{ losses: outcome === 'winner' ? { normal: 1, elite: 1 } : { normal: 3, elite: 0 } });
|
||||
const own = player(done, f.owner), other = player(done, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, outcome === 'winner' ? f.owner : f.opponent);
|
||||
assert.equal(own.reserves, 10, 'Thirteen original native reserves pay exactly three normal counters');
|
||||
assert.equal(own.elites!.reserves, 1);
|
||||
assert.equal(own.tanks, outcome === 'winner' ? 5 : 10, 'Fee3 plus the original two committed counters, or the original losing army7');
|
||||
assert.equal(own.elites!.tanks, outcome === 'winner' ? 1 : 4);
|
||||
assert.equal(own.battleLosses, own.tanks);
|
||||
assert.equal(own.forces[f.location] ?? 0, outcome === 'winner' ? 5 : 0);
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, outcome === 'winner' ? 3 : 0);
|
||||
const collection = advanced && done.phase >= 7 && outcome === 'winner' ? 1 : 0;
|
||||
assert.equal(own.spice, before.spice - (advanced ? 1 : 0) + collection, 'Only the original sealed support is charged; later stronghold Collection is separate');
|
||||
assert.equal(other.reserves, 13, 'The enemy native reserve pool never pays this fee');
|
||||
assert.equal(other.tanks, outcome === 'winner' ? 7 : 3);
|
||||
assert.equal(other.forces[f.location] ?? 0, outcome === 'winner' ? 0 : 4);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: 9, elite: 0 } : { normal: 9, elite: 1 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 1 });
|
||||
assertDisposed(done, f, false);
|
||||
|
||||
if (outcome === 'winner') {
|
||||
// The same physical armies, leaders and dial lose without precisely
|
||||
// the utility's +2: Bashar2 + dial2 =4, against Staban Tuek5.
|
||||
const plain = structuredClone(f.planActions[0]);
|
||||
plain.action.weapon = null; plain.action.defense = null;
|
||||
delete plain.action.reinforcementsSources;
|
||||
let control = sealReinforcementsHomeworldArrakisPlan(structuredClone(f.game), plain);
|
||||
control = sealReinforcementsHomeworldArrakisPlan(control, f.planActions[1]);
|
||||
control = finishReinforcementsHomeworldArrakisBattle(control);
|
||||
assert.equal(control.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(player(control, f.owner).reserves, 13);
|
||||
assert.equal(player(control, f.owner).tanks, 7);
|
||||
assertReinforcementsHomeworldArrakisInventory(control);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const reserves of ['mixed', 'elite'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${slot}: real Fremen ${reserves} fee follows native normal-first custody`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves, outcome: 'winner', harass: false });
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealReinforcementsHomeworldArrakisBattle(f),
|
||||
{ losses: { normal: 2, elite: 0 } });
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(own.reserves, reserves === 'mixed' ? 1 : 0);
|
||||
assert.equal(own.elites!.reserves, reserves === 'mixed' ? 1 : 0);
|
||||
assert.equal(own.tanks, 5, 'Three native fee counters plus two original on-board normal casualties');
|
||||
assert.equal(own.elites!.tanks, reserves === 'mixed' ? 2 : 3);
|
||||
assert.equal(own.battleLosses, 5);
|
||||
assert.equal(own.forces[f.location], reserves === 'mixed' ? 14 : 15);
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, 0);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), reserves === 'mixed'
|
||||
? { normal: 0, elite: 1 } : { normal: 0, elite: 0 });
|
||||
assert.equal(player(done, f.opponent).reserves, player(f.game, f.opponent).reserves);
|
||||
assert.equal(player(done, f.opponent).tanks, 2);
|
||||
assertDisposed(done, f, false);
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${slot}: same-plan Harass returns cannot replace the original real Fedaykin fee`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves: 'elite', outcome: 'winner', harass: true });
|
||||
const revealed = revealReinforcementsHomeworldArrakisBattle(f);
|
||||
const returns = { [f.location]: { normal: 15, elite: 0 } };
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealed, { returns });
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(own.reserves, 15, 'Three real native elites pay before fifteen real normals return');
|
||||
assert.equal(own.elites!.reserves, 0);
|
||||
assert.equal(own.tanks, 5);
|
||||
assert.equal(own.elites!.tanks, 3, 'Newly returned normal reserves cannot replace the sealed elite payment');
|
||||
assert.equal(own.battleLosses, 5);
|
||||
assert.equal(own.forces[f.location] ?? 0, 0);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), { normal: 15, elite: 0 });
|
||||
assertDisposed(done, f, true);
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} same-plan Harass preserves the mixed native fee before returning normals`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'defense', reserves: 'mixed', outcome: 'winner', harass: true });
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealReinforcementsHomeworldArrakisBattle(f),
|
||||
{ returns: { [f.location]: { normal: 14, elite: 0 } } });
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(own.reserves, 15);
|
||||
assert.equal(own.elites!.reserves, 1);
|
||||
assert.equal(own.tanks, 5);
|
||||
assert.equal(own.elites!.tanks, 2);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), { normal: 14, elite: 1 });
|
||||
assertDisposed(done, f, true);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} future Harass returns cannot fund two native reserves, and sources cannot bypass normal-first`, () => {
|
||||
const short = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'weapon', reserves: 'shortage', outcome: 'winner', harass: true });
|
||||
assert.equal(player(short.game, short.owner).reserves, 2);
|
||||
assert.equal(player(short.game, short.owner).elites!.reserves, 2);
|
||||
assert.equal(player(short.game, short.owner).forces[short.location], 18);
|
||||
assert.equal(player(short.game, short.owner).elites!.forces[short.location], 1, 'A real free shipment moved the eighth physical counter, one original Fedaykin');
|
||||
const before = structuredClone(short.game);
|
||||
assert.throws(() => applyAction(short.game, short.owner, short.planActions[0].action));
|
||||
assert.deepEqual(short.game, before, 'Underfunded physical utility commitment is atomic');
|
||||
const preview = viewGame(short.game, short.owner).battle!.reinforcements;
|
||||
assert.ok(preview?.blocked, 'A large possible later Harass return does not fund the current fee');
|
||||
assertReinforcementsHomeworldArrakisInventory(short.game);
|
||||
|
||||
const mixed = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'defense', reserves: 'mixed', outcome: 'winner', harass: false });
|
||||
const mixedBefore = JSON.stringify(mixed.game);
|
||||
const wrong = structuredClone(mixed.planActions[0]);
|
||||
wrong.action.reinforcementsSources = { 'homeworld:fremen': { normal: 0, elite: 3 } };
|
||||
assert.throws(() => applyAction(mixed.game, mixed.owner, wrong.action), 'Three elites exist, but the one native normal must pay first');
|
||||
const foreign = structuredClone(mixed.planActions[0]);
|
||||
foreign.action.reinforcementsSources = { 'homeworld:guild': { normal: 1, elite: 2 } };
|
||||
assert.throws(() => applyAction(mixed.game, mixed.owner, foreign.action), 'An opposing World cannot fund the selected native fee');
|
||||
assert.equal(JSON.stringify(mixed.game), mixedBefore);
|
||||
assertReinforcementsHomeworldArrakisInventory(mixed.game);
|
||||
});
|
||||
|
||||
for (const resolution of ['owner', 'opponent', 'both', 'explosion'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${resolution}: revealed native fee and mandatory disposal survive exceptional outcomes`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'weapon', reserves: 'normal',
|
||||
outcome: 'winner', harass: true, traitor: resolution === 'explosion' ? undefined : resolution,
|
||||
explosion: resolution === 'explosion' });
|
||||
const revealed = revealReinforcementsHomeworldArrakisBattle(f);
|
||||
const callers = resolution === 'both' ? [f.owner, f.opponent] : resolution === 'owner' ? [f.owner]
|
||||
: resolution === 'opponent' ? [f.opponent] : [];
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealed,
|
||||
{ callers, returns: { [f.location]: { normal: 2, elite: 3 } } });
|
||||
const canceled = resolution === 'opponent' || resolution === 'both';
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.result, resolution === 'both' ? 'mutualTraitors' : resolution === 'explosion' ? 'explosion' : 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, resolution === 'owner' ? f.owner : resolution === 'opponent' ? f.opponent : null);
|
||||
assert.equal(own.reserves, canceled ? 10 : 15, 'Every revealed outcome pays the original native fee; only opposing Traitor cancels Harass returns');
|
||||
assert.equal(own.elites!.reserves, canceled ? 1 : 4);
|
||||
assert.equal(own.tanks, canceled ? 10 : resolution === 'owner' ? 3 : 5);
|
||||
assert.equal(own.elites!.tanks, canceled ? 4 : resolution === 'owner' ? 0 : 1);
|
||||
assert.equal(own.battleLosses, own.tanks);
|
||||
assert.equal(own.forces[f.location] ?? 0, resolution === 'owner' ? 2 : 0, 'Only the own-Traitor winner retains its original committed physical complement');
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, resolution === 'owner' ? 1 : 0);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: canceled ? 9 : 11, elite: 0 }
|
||||
: { normal: canceled ? 9 : 11, elite: canceled ? 1 : 4 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: canceled ? 1 : 4 });
|
||||
assertDisposed(done, f, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { combatArmy, homeworldBattleLocation } from '../game/combat-location';
|
||||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||||
import {
|
||||
REINFORCEMENTS_CARD, REINFORCEMENTS_KAITAIN as K, REINFORCEMENTS_SALUSA as S,
|
||||
advanceReinforcementsHomeworld, assertReinforcementsHomeworldCustody,
|
||||
createReinforcementsHomeworldFixture, finishReinforcementsHomeworldBattle,
|
||||
reinforcementsHomeworldClean, reinforcementsHomeworldPlayer as player,
|
||||
reinforcementsHomeworldPool as pool, reinforcementsHomeworldSources as sources,
|
||||
revealReinforcementsHomeworldBattle, sealReinforcementsHomeworldPlan,
|
||||
type ReinforcementsHomeworldFixture, type ReinforcementsHomeworldStep,
|
||||
} from './fixture-reinforcements-homeworld';
|
||||
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.equal(JSON.stringify(game), before, 'Rejected source or dial must not mutate custody, hand or sealed plans');
|
||||
}
|
||||
function assertFinished(game: Game, f: ReinforcementsHomeworldFixture): void {
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(!game.homeworldBattleLoss && !game.pendingTreacheryDiscard && reinforcementsHomeworldClean(game));
|
||||
assert.equal(game.lastBattleContext!.territory, f.location);
|
||||
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.ok(!player(game, f.owner).hand.some(c => c.id === REINFORCEMENTS_CARD));
|
||||
assertReinforcementsHomeworldCustody(game);
|
||||
}
|
||||
function canonicalSources(allocation: NativeReserveSelections): string {
|
||||
return JSON.stringify(Object.entries(allocation).filter(([, n]) => n.normal + n.elite > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const slot of ['weapon', 'defense'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} visiting ${slot} pays three native normals, not its foreign army, on loss`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, slot });
|
||||
const preview = viewGame(f.game, f.owner).battle!.reinforcements!;
|
||||
assert.equal(preview.blocked, null);
|
||||
assert.equal(viewGame(f.game, f.opponent).battle!.reinforcements, null);
|
||||
assert.equal(viewGame(f.game, f.observer).battle!.reinforcements, null);
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
|
||||
assert.equal(viewGame(game, f.opponent).battle!.plans[f.owner], undefined);
|
||||
assert.equal(viewGame(game, f.observer).battle!.plans[f.owner], undefined);
|
||||
assert.deepEqual(game.homeworlds, f.game.homeworlds, 'Sealing is not a premature reserve payment');
|
||||
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: false });
|
||||
const actions: ReinforcementsHomeworldStep[] = [];
|
||||
const done = finishReinforcementsHomeworldBattle(game, { actions });
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(after.reserves, before.reserves - 3);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves);
|
||||
assert.equal(after.tanks, before.tanks + 10, 'Three fee counters plus the seven actual invaders');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 4);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 10);
|
||||
assert.equal(after.spice, before.spice - (advanced ? 2 : 0), 'Only the original two supported physical normals cost spice');
|
||||
assert.deepEqual(pool(done, f.owner, K), advanced ? { normal: 7, elite: 0 } : { normal: 9, elite: 1 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), { normal: 2, elite: 1 });
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
assert.equal(after.revived, before.revived); assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
}
|
||||
|
||||
void test('actual Advanced native movement exposes all three legal fee allocations and each charges its chosen World after reload', () => {
|
||||
for (const salusaNormal of [0, 1, 2]) {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, sources: sources(salusaNormal) });
|
||||
const choices = viewGame(f.game, f.owner).battle!.reinforcements!.homeworldSources!;
|
||||
assert.deepEqual(choices.map(canonicalSources).sort(), [0, 1, 2].map(n => canonicalSources(sources(n))).sort());
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
|
||||
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 7 + salusaNormal, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 2 - salusaNormal, elite: 1 });
|
||||
assert.equal(player(done, f.owner).tanks, 10);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 4);
|
||||
assertFinished(done, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ambiguous missing, foreign, wrong-type, nonphysical and overdrawn native fee sources reject without changing a real battle', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true });
|
||||
const missing = { ...f.planActions[0].action }; delete missing.reinforcementsSources;
|
||||
rejectsUnchanged(f.game, f.owner, missing);
|
||||
for (const allocation of [
|
||||
{},
|
||||
{ 'homeworld:guild': { normal: 3, elite: 0 } },
|
||||
{ kaitain: { normal: 3, elite: 0 } },
|
||||
{ [K]: { normal: 2, elite: 1 } },
|
||||
{ [S]: { normal: 0, elite: 3 } },
|
||||
{ [S]: { normal: 3, elite: 0 } },
|
||||
{ [K]: { normal: 4, elite: 0 } },
|
||||
{ [K]: { normal: 2, elite: 0 } },
|
||||
{ [K]: { normal: 3 } },
|
||||
{ [K]: { normal: '3', elite: 0 } },
|
||||
{ [K]: { normal: 3, elite: 0, visitor: true } },
|
||||
]) rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, reinforcementsSources: allocation } as Action);
|
||||
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, weapon: null, defense: null });
|
||||
assert.equal(Object.keys(f.game.battle!.plans).length, 0);
|
||||
assertReinforcementsHomeworldCustody(f.game);
|
||||
});
|
||||
|
||||
void test('a unique Basic World fee derives its exact source instead of demanding a redundant selector', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: false });
|
||||
const action = { ...f.planActions[0].action }; delete action.reinforcementsSources;
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner, action });
|
||||
game = sealReinforcementsHomeworldPlan(game, f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 9, elite: 1 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} winner pays its physical dial separately and must discard Reinforcements`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'win', slot: 'weapon' });
|
||||
const before = player(f.game, f.owner);
|
||||
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f));
|
||||
const after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, no virtual bonus casualties');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 5);
|
||||
assert.equal(after.spice, before.spice - (advanced ? 2 : 0));
|
||||
if (role === 'visitor') {
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 1, elite: 4 });
|
||||
assert.equal(after.reserves, before.reserves - 3);
|
||||
} else {
|
||||
assert.deepEqual(pool(done, f.owner, K), advanced ? { normal: 0, elite: 0 } : { normal: 10, elite: 5 });
|
||||
assert.equal(after.reserves, before.reserves - 5);
|
||||
}
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
|
||||
assertFinished(done, f);
|
||||
const continued = advanceReinforcementsHomeworld(done, g => g.phase === 7 && reinforcementsHomeworldClean(g));
|
||||
assert.equal(continued.turn, f.game.turn);
|
||||
assertFinished(continued, f);
|
||||
});
|
||||
}
|
||||
|
||||
void test('own-World fee removes its counters from dial/support availability, while a fee on the other native World does not', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', nativeTransfer: 12 });
|
||||
assert.deepEqual(pool(f.game, f.owner, K), { normal: 3, elite: 0 });
|
||||
assert.deepEqual(pool(f.game, f.owner, S), { normal: 12, elite: 5 });
|
||||
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, dial: 1, support: 1 });
|
||||
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, dial: 0.5, support: 0 });
|
||||
const onWorld = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
|
||||
assert.deepEqual(onWorld.homeworlds, f.game.homeworlds);
|
||||
const offWorld = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner,
|
||||
action: { ...f.planActions[0].action, dial: 3, support: 3, reinforcementsSources: sources(3) } });
|
||||
const offDone = finishReinforcementsHomeworldBattle(
|
||||
sealReinforcementsHomeworldPlan(offWorld, f.planActions[1]));
|
||||
assert.equal(offDone.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(offDone, f.owner).tanks, 6, 'The off-World fee leaves three real natives available to win and pay their dial');
|
||||
assert.deepEqual(pool(offDone, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(offDone, f.owner, S), { normal: 9, elite: 5 });
|
||||
assertFinished(offDone, f);
|
||||
const done = finishReinforcementsHomeworldBattle(
|
||||
sealReinforcementsHomeworldPlan(onWorld, f.planActions[1]));
|
||||
assert.equal(player(done, f.owner).tanks, 3, 'No fee counter is destroyed a second time as a native loser');
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 12, elite: 5 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
void test('own-World loss retains original high-face strength, not a retroactive low-face bonus after its fee', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native' });
|
||||
assert.deepEqual(pool(f.game, f.owner, K), { normal: 5, elite: 0 });
|
||||
assert.equal(homeworldBattleLocation(f.game, K)!.side, 'high');
|
||||
assert.equal(homeworldBattleLocation(f.game, K)!.nativeBattleStrength, 2);
|
||||
const opposingLeader = player(f.game, f.opponent).leaders.find(l => l.strength === 3)!;
|
||||
const game = sealReinforcementsHomeworldPlan(
|
||||
sealReinforcementsHomeworldPlan(f.game, f.planActions[0]),
|
||||
{ actor: f.opponent, action: { ...f.planActions[1].action, leader: opposingLeader.id, dial: 3.5, support: 2 } });
|
||||
// Emperor: original printed 2 + Bashar 2 + Reinforcements 2 = 6.
|
||||
// Guild: leader 3 + five normals with two support = 6.5.
|
||||
// Re-reading Kaitain's post-fee low face would illegally give Emperor 7.
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(player(done, f.owner).tanks, 5, 'Three original fees and the two remaining physical natives');
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 0);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 10, elite: 5 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} pays its original native fee on the sole native's genuine Traitor call`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'traitor' });
|
||||
const revealed = revealReinforcementsHomeworldBattle(f);
|
||||
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(revealed, f.visitor, { type: 'traitorCall', call: true });
|
||||
const actions: ReinforcementsHomeworldStep[] = [];
|
||||
const done = finishReinforcementsHomeworldBattle(revealed, { callers: [f.native], actions });
|
||||
assert.equal(done.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, f.native);
|
||||
assert.equal(player(done, f.owner).tanks, role === 'visitor' ? 10 : 3);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, role === 'visitor' ? 4 : 0);
|
||||
assert.equal(player(done, f.owner).reserves, player(f.game, f.owner).reserves - 3);
|
||||
assert.deepEqual(pool(done, f.owner, K), role === 'visitor'
|
||||
? advanced ? { normal: 7, elite: 0 } : { normal: 9, elite: 1 }
|
||||
: advanced ? { normal: 2, elite: 0 } : { normal: 12, elite: 5 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
const betrayed = f.native === f.owner ? f.opponent : f.owner;
|
||||
assert.equal(player(done, betrayed).leaders.find(l => l.id === revealed.battle!.plans[betrayed].leader)!.dead, true);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} explosion pays the fee first and preserves the native printed casualty exception`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'explosion' });
|
||||
const nativeBefore = combatArmy(f.game, f.native, f.location);
|
||||
const printed = homeworldBattleLocation(f.game, f.location)!.nativeBattleStrength;
|
||||
const revealed = revealReinforcementsHomeworldBattle(f);
|
||||
const own = revealed.battle!.plans[f.owner], other = revealed.battle!.plans[f.opponent];
|
||||
const done = finishReinforcementsHomeworldBattle(revealed, { explosionLosses: { normal: printed, elite: 0 } });
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(done.lastBattleContext!.winner, null);
|
||||
assert.equal(player(done, f.owner).tanks, role === 'visitor' ? 10 : 3 + printed);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, role === 'visitor' ? 4 : 0);
|
||||
assert.deepEqual(combatArmy(done, f.native, f.location), {
|
||||
normal: nativeBefore.normal - printed - (role === 'native' ? 3 : 0), elite: nativeBefore.elite,
|
||||
});
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
|
||||
for (const [actor, plan] of [[f.owner, own], [f.opponent, other]] as const)
|
||||
assert.equal(player(done, actor).leaders.find(l => l.id === plan.leader)!.dead, true);
|
||||
for (const id of [REINFORCEMENTS_CARD, own.weapon, other.defense])
|
||||
assert.equal(done.discard.filter(c => c.id === id).length, 1);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native Salusa explosion uses exact remaining Sardaukar custody after a split normal-first fee', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', secondary: true,
|
||||
outcome: 'explosion', sources: sources(2) });
|
||||
assert.deepEqual(pool(f.game, f.owner, K), { normal: 13, elite: 0 });
|
||||
assert.deepEqual(pool(f.game, f.owner, S), { normal: 2, elite: 5 });
|
||||
assert.equal(homeworldBattleLocation(f.game, S)!.nativeBattleStrength, 3);
|
||||
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f), {
|
||||
explosionLosses: { normal: 0, elite: 3 },
|
||||
});
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(player(done, f.owner).reserves, player(f.game, f.owner).reserves - 6);
|
||||
assert.equal(player(done, f.owner).elites!.reserves, 2);
|
||||
assert.equal(player(done, f.owner).tanks, 6);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 3);
|
||||
assert.equal(player(done, f.owner).battleLosses, 6);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 12, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 0, elite: 2 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
void test('a split fee crossing Kaitain low retains the original printed explosion losses', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native',
|
||||
outcome: 'explosion', sources: sources(2) });
|
||||
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f), {
|
||||
explosionLosses: { normal: 2, elite: 0 },
|
||||
});
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(player(done, f.owner).tanks, 5, 'Three reserve fees plus the original high-face two, not the later low-face three');
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 0);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 2, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 8, elite: 5 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
void test('a real named Reinforcements promise finds and executes the off-World fee needed for its fixed native dial', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', nativeTransfer: 12 });
|
||||
let game = f.game;
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(truth);
|
||||
takeHarassCard(game, f.observer, truth.id);
|
||||
game = applyAction(game, f.observer, { type: 'card', card: truth.id });
|
||||
while (game.truthtrance!.stage === 'priority') {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'truthPass' });
|
||||
}
|
||||
game = applyAction(game, f.observer, { type: 'truthAsk', question: {
|
||||
kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
|
||||
{ kind: 'weapon', name: 'Reinforcements' }, { kind: 'dial', compare: 'eq', value: 3 },
|
||||
] },
|
||||
} });
|
||||
assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'));
|
||||
game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const witness = viewGame(game, f.owner).battle!.compliantPlan; assert.ok(witness);
|
||||
rejectsUnchanged(game, f.owner, { type: 'battlePlan', ...witness, reinforcementsSources: sources(0) });
|
||||
game = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
|
||||
game = sealReinforcementsHomeworldPlan(game, f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(done, f.owner).tanks, 6);
|
||||
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - 3);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 9, elite: 5 });
|
||||
assert.equal(done.discard.filter(card => card.id === truth.id).length, 1);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { FACTIONS } from '../game/catalog';
|
||||
import { truthFactAnswer, truthQuestionText, type TruthFact } from '../game/truthtrance';
|
||||
import { truthFactAnswer, type TruthFact } from '../game/truthtrance';
|
||||
import { knowledgeFactAnswer, parseKnowledgeFact, truthKnowledgeOf, type KnowledgeFact } from '../game/truthtrance-knowledge';
|
||||
import { knowledgeGame, knownForecastGame, askKnowledge, rejectKnowledge, knowledgePhysical, knowledgeBotActions } from './truthtrance-knowledge-fixture';
|
||||
|
||||
@@ -98,6 +98,4 @@ void test('unknown stored knowledge retains the real card and permits a differen
|
||||
const retry=applyAction(unknown,'a',{type:'truthAsk',question:{kind:'fact',target:'b',fact:{kind:'prediction',field:'turn',compare:'eq',value:7}}});
|
||||
const done=applyAction(retry,'b',{type:'truthAnswer',answer:'yes'});assert.equal(done.truthtrance,null);
|
||||
assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
|
||||
const wording=truthQuestionText({kind:'fact',target:'b',fact:{kind:'prediction',field:'faction',faction:'guild'}},id=>id);
|
||||
assert.match(wording,/recorded.*prediction/);assert.doesNotMatch(wording,/will you win|must win/i);
|
||||
});
|
||||
@@ -13,9 +13,7 @@ import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
truthFactAnswer,
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
type TruthQuestion,
|
||||
} from '../game/truthtrance';
|
||||
|
||||
function fixture(spice = 7) {
|
||||
@@ -392,14 +390,5 @@ void test('a current-spice answer records a fact rather than forbidding a subseq
|
||||
paid.battle?.truthPromises ?? [],
|
||||
done.battle?.truthPromises ?? [],
|
||||
);
|
||||
const q: TruthQuestion = {
|
||||
kind: 'fact',
|
||||
target: 'e',
|
||||
fact: spiceFact('gte', 41),
|
||||
};
|
||||
assert.match(
|
||||
truthQuestionText(q, () => 'unused'),
|
||||
/currently hold at least 41 spice/,
|
||||
);
|
||||
assert.deepEqual(cards(paid), cards(initial));
|
||||
});
|
||||
Reference in new issue
Block a user