1244 lines
42 KiB
TypeScript
1244 lines
42 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { webcrypto } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import {
|
|
baseDeck,
|
|
ixBattleCards,
|
|
leaders,
|
|
createAuditorLeader,
|
|
type Card,
|
|
type Leader,
|
|
} from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
|
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
import { createGame, newPlayer, applyAction, type Game } from '../game/engine';
|
|
import {
|
|
quoteBattleResolution,
|
|
BattleResolutionQuoteError,
|
|
type BattleResolutionInput,
|
|
type ResolutionCombatant,
|
|
} from '../game/battle-resolution-quote';
|
|
import type { CombatForces } from '../game/combat';
|
|
import type { StrongholdId } from '../game/stronghold-cards';
|
|
const catalog = () => [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
|
|
function input(): BattleResolutionInput {
|
|
const side = (
|
|
id: string,
|
|
faction: 'emperor' | 'guild',
|
|
strength: number,
|
|
): ResolutionCombatant => {
|
|
const leader = { ...leaders(faction)[0], strength };
|
|
return {
|
|
id,
|
|
faction,
|
|
spice: 10,
|
|
hand: [],
|
|
leader,
|
|
plan: {
|
|
dial: 2,
|
|
support: 2,
|
|
leader: leader.id,
|
|
weapon: null,
|
|
defense: null,
|
|
},
|
|
forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false },
|
|
};
|
|
};
|
|
return {
|
|
advanced: true,
|
|
turn: 2,
|
|
territory: 'arrakeen',
|
|
attacker: side('a', 'emperor', 3),
|
|
defender: side('d', 'guild', 2),
|
|
voters: [
|
|
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
|
|
{ id: 'd', beneficiary: 'd', called: false, traitors: [] },
|
|
],
|
|
participants: [
|
|
{ id: 'a', faction: 'emperor' },
|
|
{ id: 'd', faction: 'guild' },
|
|
],
|
|
physicalCards: catalog(),
|
|
pendingAuditorPresent: false,
|
|
pendingRetentionPresent: false,
|
|
};
|
|
}
|
|
function play(
|
|
g: BattleResolutionInput,
|
|
side: 'attacker' | 'defender',
|
|
slot: 'weapon' | 'defense' | 'leader' | 'lateDefense',
|
|
predicate: (card: Card) => boolean,
|
|
) {
|
|
const taken = new Set(
|
|
[...g.attacker.hand, ...g.defender.hand].map((c) => c.id),
|
|
);
|
|
const card = catalog().find((c) => !taken.has(c.id) && predicate(c));
|
|
assert.ok(card);
|
|
g[side].hand = [...g[side].hand, card];
|
|
if (slot === 'lateDefense') g[side].lateDefense = card.id;
|
|
else g[side].plan[slot] = card.id;
|
|
if (slot === 'leader') delete g[side].leader;
|
|
return card;
|
|
}
|
|
function call(g: BattleResolutionInput, side: 'attacker' | 'defender') {
|
|
const own = g[side],
|
|
other = side === 'attacker' ? g.defender : g.attacker;
|
|
const voter = g.voters.find((v) => v.id === own.id)!;
|
|
voter.called = true;
|
|
voter.traitors = [
|
|
other.hand.some((c) => c.id === other.plan.leader && c.kind === 'hero')
|
|
? CHEAP_HERO_TRAITOR
|
|
: other.plan.leader!,
|
|
];
|
|
}
|
|
function reject(g: BattleResolutionInput, pattern?: RegExp) {
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() => quoteBattleResolution(g),
|
|
pattern ?? BattleResolutionQuoteError,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
void test('normal quote computes scores, both support splits, bounty and typed winner casualties without mutation', () => {
|
|
const g = input();
|
|
play(g, 'defender', 'weapon', (c) => c.kind === 'worthless');
|
|
const before = structuredClone(g),
|
|
q = quoteBattleResolution(g);
|
|
assert.equal(q.result, 'normal');
|
|
assert.equal(q.winner, 'a');
|
|
assert.deepEqual(q.scores, { attacker: 5, defender: 4 });
|
|
assert.deepEqual(q.leaderDeaths, { attacker: false, defender: false });
|
|
assert.deepEqual(
|
|
q.payments.map((p) => [p.player, p.ownPayment, p.allyPayment]),
|
|
[
|
|
['a', 2, 0],
|
|
['d', 2, 0],
|
|
],
|
|
);
|
|
assert.deepEqual(q.casualties!.options, [
|
|
{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 },
|
|
]);
|
|
assert.deepEqual(q.destroyedArmies, ['d']);
|
|
assert.equal(q.basicWinnerLosses, null);
|
|
assert.deepEqual(q.discarded, [
|
|
{ player: 'd', card: g.defender.plan.weapon },
|
|
]);
|
|
q.casualties!.forces.normal = 0;
|
|
q.played.defender.length = 0;
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('traitors precede explosion, grant sole-winner support exemption and kill only the losing leader', () => {
|
|
for (const votes of ['attacker', 'defender', 'both'] as const) {
|
|
const g = input();
|
|
play(g, 'attacker', 'weapon', (c) => c.kind === 'lasgun');
|
|
play(g, 'defender', 'defense', (c) => c.kind === 'shield');
|
|
if (votes !== 'defender') call(g, 'attacker');
|
|
if (votes !== 'attacker') call(g, 'defender');
|
|
const q = quoteBattleResolution(g);
|
|
assert.equal(q.explosion, true);
|
|
assert.equal(q.effects, null);
|
|
assert.equal(q.casualties, null);
|
|
assert.equal(q.result, votes === 'both' ? 'mutualTraitors' : 'traitor');
|
|
assert.equal(
|
|
q.winner,
|
|
votes === 'both' ? null : votes === 'attacker' ? 'a' : 'd',
|
|
);
|
|
assert.deepEqual(q.leaderDeaths, {
|
|
attacker: votes !== 'attacker',
|
|
defender: votes !== 'defender',
|
|
});
|
|
assert.equal(
|
|
q.payments.filter((p) => p.freeByTraitor).length,
|
|
votes === 'both' ? 0 : 1,
|
|
);
|
|
assert.equal(
|
|
q.bounty?.amount ?? null,
|
|
votes === 'both' ? null : votes === 'attacker' ? 2 : 3,
|
|
);
|
|
}
|
|
});
|
|
void test('allied Harkonnen cancellation changes actual result and support obligations; Cheap Hero uses its shared traitor identity', () => {
|
|
const g = input();
|
|
g.participants = [
|
|
...g.participants,
|
|
{ id: 'h', faction: 'harkonnen', ally: 'd' },
|
|
];
|
|
g.defender.ally = 'h';
|
|
g.voters = [
|
|
...g.voters,
|
|
{
|
|
id: 'h',
|
|
beneficiary: 'd',
|
|
called: true,
|
|
traitors: [g.attacker.plan.leader!],
|
|
},
|
|
];
|
|
assert.equal(quoteBattleResolution(g).winner, 'd');
|
|
const canceled = structuredClone(g);
|
|
canceled.voters[2].called = false;
|
|
const q = quoteBattleResolution(canceled);
|
|
assert.equal(q.winner, 'a');
|
|
assert.equal(q.result, 'normal');
|
|
assert.equal(
|
|
q.payments.every((p) => !p.freeByTraitor),
|
|
true,
|
|
);
|
|
g.defender.spice = 0;
|
|
assert.equal(quoteBattleResolution(g).winner, 'd');
|
|
canceled.defender.spice = 0;
|
|
reject(canceled, /funded/);
|
|
const hero = input();
|
|
play(hero, 'defender', 'leader', (c) => c.kind === 'hero');
|
|
call(hero, 'attacker');
|
|
assert.deepEqual(quoteBattleResolution(hero).revelations, [
|
|
{ player: 'a', identity: CHEAP_HERO_TRAITOR },
|
|
]);
|
|
assert.equal(quoteBattleResolution(hero).bounty!.amount, 0);
|
|
});
|
|
void test('KH prevents an invalid stored true call and adds two only to an unstunned surviving leader', () => {
|
|
const g = input();
|
|
g.defender.plan.kwisatz = true;
|
|
assert.equal(quoteBattleResolution(g).scores!.defender, 6);
|
|
call(g, 'attacker');
|
|
reject(g, /Kwisatz/);
|
|
g.voters[0].called = false;
|
|
play(g, 'attacker', 'weapon', (c) => c.kind === 'artillery');
|
|
const q = quoteBattleResolution(g);
|
|
assert.equal(q.scores!.defender, 2);
|
|
assert.equal(q.bounty, null);
|
|
});
|
|
void test('Stone validates revealed mode before traitors and uses undialed tokens rather than leader scores', () => {
|
|
const g = input();
|
|
play(g, 'attacker', 'weapon', (c) => c.effect === 'stoneBurner');
|
|
g.attacker.stoneMode = 'ignore';
|
|
g.attacker.plan.dial = g.attacker.plan.support = 4;
|
|
const q = quoteBattleResolution(g);
|
|
assert.equal(q.winner, 'd');
|
|
assert.deepEqual(q.stone, {
|
|
winner: 'defender',
|
|
attacker: [1],
|
|
defender: [3],
|
|
});
|
|
g.attacker.stoneMode = 'kill';
|
|
assert.deepEqual(quoteBattleResolution(g).leaderDeaths, {
|
|
attacker: true,
|
|
defender: true,
|
|
});
|
|
delete g.attacker.stoneMode;
|
|
call(g, 'attacker');
|
|
reject(g, /revealed mode/);
|
|
});
|
|
void test('copied Stone needs each revealed mode and keeps physical Mirror custody on victory', () => {
|
|
const g = input();
|
|
const mirror = play(g, 'attacker', 'weapon', (card) => card.effect === 'mirrorWeapon');
|
|
const original = play(g, 'defender', 'weapon', (card) => card.effect === 'stoneBurner');
|
|
g.defender.plan.dial = g.defender.plan.support = 4;
|
|
reject(g, /revealed mode/);
|
|
g.attacker.stoneMode = 'ignore';
|
|
reject(g, /revealed mode/);
|
|
g.defender.stoneMode = 'ignore';
|
|
const quote = quoteBattleResolution(g);
|
|
assert.equal(quote.stone?.winner, 'attacker');
|
|
assert.equal(quote.winner, 'a');
|
|
assert.ok(quote.winnerCards.includes(mirror.id));
|
|
assert.ok(quote.discarded.some((entry) => entry.card === original.id));
|
|
assert.ok(!quote.discarded.some((entry) => entry.card === mirror.id));
|
|
g.attacker.stoneMode = 'kill';
|
|
assert.deepEqual(quoteBattleResolution(g).leaderDeaths, {
|
|
attacker: true,
|
|
defender: true,
|
|
});
|
|
});
|
|
void test('victorious physical Mirror retains after copying activated Tooth or Artillery', () => {
|
|
for (const copiedKind of ['poisonTooth', 'artillery'] as const) {
|
|
const g = input();
|
|
const mirror = play(g, 'attacker', 'weapon', (card) => card.effect === 'mirrorWeapon');
|
|
const original = play(g, 'defender', 'weapon', (card) => card.kind === copiedKind);
|
|
if (copiedKind === 'poisonTooth') {
|
|
g.attacker.poisonTooth = true;
|
|
g.defender.poisonTooth = true;
|
|
}
|
|
const quote = quoteBattleResolution(g);
|
|
assert.equal(quote.winner, 'a', copiedKind);
|
|
assert.ok(quote.winnerCards.includes(mirror.id), copiedKind);
|
|
assert.ok(!quote.discarded.some((entry) => entry.card === mirror.id), copiedKind);
|
|
assert.ok(quote.discarded.some((entry) => entry.card === original.id), copiedKind);
|
|
}
|
|
});
|
|
void test('Habbanya selects ordinary and Stone ties; canceled free-support and elite bonuses remain captured in casualty choices', () => {
|
|
const g = input();
|
|
g.defender.leader!.strength = 3;
|
|
g.defender.stronghold = 'habbanya_ridge_sietch';
|
|
assert.equal(quoteBattleResolution(g).winner, 'd');
|
|
play(g, 'attacker', 'weapon', (c) => c.effect === 'stoneBurner');
|
|
g.attacker.stoneMode = 'ignore';
|
|
assert.equal(quoteBattleResolution(g).winner, 'd');
|
|
const canceled = input();
|
|
canceled.attacker.forces = {
|
|
normal: 2,
|
|
elite: 1,
|
|
eliteStrength: 1,
|
|
freeSupport: false,
|
|
};
|
|
canceled.attacker.plan.dial = 1.5;
|
|
canceled.attacker.plan.support = 0;
|
|
const q = quoteBattleResolution(canceled);
|
|
assert.deepEqual(q.casualties!.forces, canceled.attacker.forces);
|
|
assert.deepEqual(q.casualties!.options, [
|
|
{ normal: 2, elite: 1, paidNormal: 0, paidElite: 0 },
|
|
]);
|
|
});
|
|
void test('CHOAM quotes half of the total other-faction support with one final floor', () => {
|
|
const g = input();
|
|
g.participants = [...g.participants, { id: 'c', faction: 'choam' }];
|
|
g.attacker.plan.dial = g.attacker.plan.support = 3;
|
|
g.defender.plan.dial = g.defender.plan.support = 5;
|
|
const before = structuredClone(g), q = quoteBattleResolution(g);
|
|
assert.deepEqual(q.choamIncome, { owner: 'c', amount: 4 });
|
|
assert.deepEqual(q.payments.map(payment => payment.ownPayment), [3, 5]);
|
|
assert.deepEqual(q.payments.map(payment => payment.bankSupport), [0, 0]);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('funding consumes explicit current escrow and includes Stronghold bank support in CHOAM income without debiting the bank or donor twice', () => {
|
|
const g = input();
|
|
g.participants = [
|
|
...g.participants,
|
|
{ id: 'c', faction: 'choam', ally: 'a' },
|
|
];
|
|
g.attacker.stronghold = 'arrakeen';
|
|
g.attacker.spice = 0;
|
|
g.attacker.plan.dial = g.attacker.plan.support = 4;
|
|
g.attacker.plan.allyPayment = 2;
|
|
g.attacker.aid = { donor: 'c', amount: 2 };
|
|
g.attacker.ally = 'c';
|
|
const before = structuredClone(g),
|
|
q = quoteBattleResolution(g);
|
|
assert.deepEqual(q.payments[0], {
|
|
player: 'a',
|
|
cost: 2,
|
|
ownPayment: 0,
|
|
allyPayment: 2,
|
|
donor: 'c',
|
|
bankSupport: 2,
|
|
freeByTraitor: false,
|
|
});
|
|
assert.deepEqual(q.choamIncome, { owner: 'c', amount: 2 });
|
|
assert.deepEqual(g, before);
|
|
g.attacker.aid.amount = 1;
|
|
reject(g, /funded/);
|
|
g.attacker.aid.amount = 2;
|
|
g.attacker.plan.allyPayment = 3;
|
|
reject(g, /split/);
|
|
});
|
|
void test('Carthag, activated Tooth, Artillery and double-traitor Stronghold income retain their existing precedence', () => {
|
|
const g = input();
|
|
g.attacker.stronghold = 'carthag';
|
|
play(g, 'attacker', 'defense', (c) => c.kind === 'shield');
|
|
play(g, 'defender', 'weapon', (c) => c.kind === 'poison');
|
|
assert.equal(quoteBattleResolution(g).leaderDeaths.attacker, false);
|
|
const tooth = input();
|
|
play(tooth, 'attacker', 'weapon', (c) => c.kind === 'poisonTooth');
|
|
tooth.attacker.poisonTooth = false;
|
|
assert.deepEqual(quoteBattleResolution(tooth).leaderDeaths, {
|
|
attacker: false,
|
|
defender: false,
|
|
});
|
|
tooth.attacker.poisonTooth = true;
|
|
assert.deepEqual(quoteBattleResolution(tooth).leaderDeaths, {
|
|
attacker: true,
|
|
defender: true,
|
|
});
|
|
const rich = input();
|
|
rich.attacker.stronghold = 'sietch_tabr';
|
|
rich.defender.stronghold = 'tueks_sietch';
|
|
play(rich, 'defender', 'weapon', (c) => c.kind === 'worthless');
|
|
assert.deepEqual(quoteBattleResolution(rich).strongholdIncome, [
|
|
{ player: 'a', amount: 2 },
|
|
{ player: 'd', amount: 2 },
|
|
]);
|
|
call(rich, 'attacker');
|
|
call(rich, 'defender');
|
|
assert.deepEqual(quoteBattleResolution(rich).strongholdIncome, []);
|
|
});
|
|
void test('Auditor and Moritani receipts are evaluated after leader effects but before any disposal and reject existing active receipts', () => {
|
|
const g = input();
|
|
g.attacker.faction = 'choam';
|
|
g.participants[0].faction = 'choam';
|
|
g.attacker.leader = createAuditorLeader();
|
|
g.attacker.plan.leader = g.attacker.leader.id;
|
|
const late = play(
|
|
g,
|
|
'defender',
|
|
'lateDefense',
|
|
(c) => c.effect === 'portableSnooper',
|
|
);
|
|
const original = play(g, 'defender', 'defense', (c) => c.kind === 'shield');
|
|
const q = quoteBattleResolution(g);
|
|
assert.deepEqual(q.auditor, {
|
|
owner: 'a',
|
|
opponent: 'd',
|
|
territory: 'arrakeen',
|
|
turn: 2,
|
|
survived: true,
|
|
usedCards: [original.id, late.id],
|
|
stage: 'offer',
|
|
});
|
|
const duplicateAuditHand = structuredClone(g);
|
|
const unplayed = catalog().find((c) => c.kind === 'worthless')!;
|
|
duplicateAuditHand.defender.hand = [
|
|
...duplicateAuditHand.defender.hand,
|
|
unplayed,
|
|
unplayed,
|
|
];
|
|
reject(duplicateAuditHand, /unique physical cards/);
|
|
g.pendingAuditorPresent = true;
|
|
reject(g, /preceding audit/);
|
|
const r = input();
|
|
r.participants = [
|
|
...r.participants,
|
|
{ id: 'm', faction: 'moritani', ally: 'd' },
|
|
];
|
|
r.defender.ally = 'm';
|
|
play(r, 'defender', 'weapon', (c) => c.kind === 'projectile');
|
|
play(r, 'attacker', 'defense', (c) => c.kind === 'shield');
|
|
const retention = quoteBattleResolution(r);
|
|
assert.deepEqual(retention.retention!.played, [r.defender.plan.weapon]);
|
|
assert.equal(
|
|
retention.discarded.some((c) => c.player === 'd'),
|
|
false,
|
|
);
|
|
r.pendingRetentionPresent = true;
|
|
reject(r, /previous alliance/);
|
|
});
|
|
void test('all selected physical roles including reserved late defense require exact global custody; no rejected input is mutated', () => {
|
|
const original = input();
|
|
const late = play(
|
|
original,
|
|
'attacker',
|
|
'lateDefense',
|
|
(c) => c.effect === 'portableSnooper',
|
|
);
|
|
const mutations: ((g: BattleResolutionInput) => void)[] = [
|
|
(g) => {
|
|
g.attacker.hand = [];
|
|
},
|
|
(g) => {
|
|
g.physicalCards = [...g.physicalCards, late];
|
|
},
|
|
(g) => {
|
|
g.attacker.hand = [...g.attacker.hand, late];
|
|
},
|
|
(g) => {
|
|
g.attacker.plan.defense = late.id;
|
|
},
|
|
(g) => {
|
|
g.attacker.plan.leader = 'missing-disc';
|
|
},
|
|
(g) => {
|
|
g.attacker.plan.dial = 100;
|
|
},
|
|
(g) => {
|
|
g.attacker.plan.support = Number.NaN;
|
|
},
|
|
(g) => {
|
|
g.attacker.forces.elite = -1;
|
|
},
|
|
(g) => {
|
|
g.voters[0].called = true;
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const g = structuredClone(original);
|
|
mutate(g);
|
|
reject(g);
|
|
}
|
|
});
|
|
void test('No-Field guard applies only to actual unsupported destruction targets, including noncombatants during explosion', () => {
|
|
const g = input();
|
|
g.participants = [
|
|
...g.participants,
|
|
{ id: 'r', faction: 'richese', noFieldAtTerritory: true },
|
|
];
|
|
assert.equal(quoteBattleResolution(g).winner, 'a');
|
|
play(g, 'attacker', 'weapon', (c) => c.kind === 'lasgun');
|
|
play(g, 'defender', 'defense', (c) => c.kind === 'shield');
|
|
reject(g, /concealed No-Field/);
|
|
call(g, 'attacker');
|
|
assert.equal(quoteBattleResolution(g).result, 'traitor');
|
|
g.participants[1].noFieldAtTerritory = true;
|
|
reject(g, /concealed No-Field/);
|
|
});
|
|
void test('Basic ordinary winner uses immediate dial losses; Basic Ix winner keeps half-strength typed allocations', () => {
|
|
const basic = input();
|
|
basic.advanced = false;
|
|
for (const s of [basic.attacker, basic.defender]) {
|
|
s.forces.freeSupport = true;
|
|
s.plan.support = 0;
|
|
}
|
|
const q = quoteBattleResolution(basic);
|
|
assert.deepEqual(q.payments, []);
|
|
assert.equal(q.basicWinnerLosses, 2);
|
|
assert.equal(q.casualties, null);
|
|
basic.attacker.faction = 'ixians';
|
|
basic.participants[0].faction = 'ixians';
|
|
basic.attacker.forces.normalFixedHalf = true;
|
|
const ix = quoteBattleResolution(basic);
|
|
assert.equal(ix.basicWinnerLosses, null);
|
|
assert.deepEqual(ix.casualties!.options, [
|
|
{ normal: 4, elite: 0, paidNormal: 0, paidElite: 0 },
|
|
]);
|
|
});
|
|
|
|
// Observation-only exports appended to the unchanged production source. This
|
|
// supplies the same private normalization adapter and resolver to a comparison;
|
|
// no test export or bypass is added to production.
|
|
const observed = {} as {
|
|
resolveBattle: (g: Game) => void;
|
|
combatForces: (
|
|
g: Game,
|
|
p: Game['players'][number],
|
|
t: string,
|
|
other: Game['players'][number],
|
|
) => CombatForces;
|
|
controlledLeaders: (g: Game, p: Game['players'][number]) => Leader[];
|
|
strongholdEffect: (g: Game, id: string) => StrongholdId | null;
|
|
aidFor: (
|
|
g: Game,
|
|
p: Game['players'][number],
|
|
) => { amount: number } | undefined;
|
|
};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
|
'\nexport { resolveBattle, combatForces, controlledLeaders, strongholdEffect, aidFor };',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
crypto: webcrypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
function productionBattle() {
|
|
let g = createGame('QUOTE', newPlayer('a', 'Attacker', 'emperor'), true);
|
|
g.players.push(newPlayer('d', 'Defender', 'guild'));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'a',
|
|
order: ['a', 'd'],
|
|
deck: catalog(),
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
});
|
|
for (const p of g.players) {
|
|
p.spice = 20;
|
|
p.forces = { 'arrakeen:10': 5 };
|
|
p.reserves = 15;
|
|
p.hand = [];
|
|
p.traitors = [];
|
|
}
|
|
for (const [index, kind] of [
|
|
[0, 'projectile'],
|
|
[1, 'worthless'],
|
|
] as const) {
|
|
const at = g.deck.findIndex((c) => c.kind === kind);
|
|
g.players[index].hand.push(g.deck.splice(at, 1)[0]);
|
|
}
|
|
g = applyAction(g, 'a', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'd',
|
|
});
|
|
while (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
for (let i = 0; g.battle?.preparation && i < 20; i++) {
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
while (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
}
|
|
for (const [index, dial] of [
|
|
[0, 3],
|
|
[1, 1],
|
|
] as const)
|
|
g = applyAction(g, g.players[index].id, {
|
|
type: 'battlePlan',
|
|
dial,
|
|
support: dial,
|
|
leader: g.players[index].leaders[0].id,
|
|
weapon: g.players[index].hand[0].id,
|
|
});
|
|
assert.equal(g.battle!.revealed, true);
|
|
// Snapshot of the complete choices immediately before resolveBattle; no
|
|
// resources or physical cards are changed between quote and real resolver.
|
|
g.battle!.traitorCalls = { a: false, d: false };
|
|
return g;
|
|
}
|
|
function normalize(g: Game): BattleResolutionInput {
|
|
const b = g.battle!;
|
|
const side = (id: string, otherId: string): ResolutionCombatant => {
|
|
const p = g.players.find((s) => s.id === id)!,
|
|
other = g.players.find((s) => s.id === otherId)!;
|
|
const aid = observed.aidFor(g, p);
|
|
return {
|
|
id,
|
|
faction: p.faction,
|
|
ally: p.ally,
|
|
spice: p.spice,
|
|
hand: p.hand,
|
|
plan: b.plans[id],
|
|
leader: observed
|
|
.controlledLeaders(g, p)
|
|
.find((l) => l.id === b.plans[id].leader),
|
|
forces: observed.combatForces(g, p, b.territory, other),
|
|
stronghold: observed.strongholdEffect(g, id),
|
|
...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}),
|
|
lateDefense: b.lateDefense?.[id],
|
|
stoneMode: b.stoneBurner?.[id],
|
|
poisonTooth: b.poisonTooth?.[id],
|
|
};
|
|
};
|
|
return {
|
|
advanced: g.advanced,
|
|
turn: g.turn,
|
|
territory: b.territory,
|
|
attacker: side(b.attacker, b.defender),
|
|
defender: side(b.defender, b.attacker),
|
|
voters: [b.attacker, b.defender].map((id) => ({
|
|
id,
|
|
beneficiary: id,
|
|
called: !!b.traitorCalls[id],
|
|
traitors: g.players.find((p) => p.id === id)!.traitors,
|
|
})),
|
|
participants: g.players.map((p) => ({
|
|
id: p.id,
|
|
faction: p.faction,
|
|
ally: p.ally,
|
|
noFieldAtTerritory:
|
|
!!p.noField?.deployed && 'territory' in p.noField.deployed.location &&
|
|
p.noField.deployed.location.territory === b.territory,
|
|
})),
|
|
physicalCards: [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
],
|
|
pendingAuditorPresent: !!g.pendingAuditor,
|
|
pendingRetentionPresent: !!g.moritaniRetention,
|
|
};
|
|
}
|
|
void test('a genuine revealed production battle consumes the same score, losses, funding, bounty and mandatory card quote', () => {
|
|
const g = productionBattle(),
|
|
before = structuredClone(g),
|
|
normalized = normalize(g),
|
|
q = quoteBattleResolution(normalized);
|
|
const actual = structuredClone(g);
|
|
observed.resolveBattle(actual);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(actual.lastBattleContext!.winner, q.winner);
|
|
assert.equal(actual.lastBattleContext!.result, q.result);
|
|
for (const [index, label] of [
|
|
[0, 'attacker'],
|
|
[1, 'defender'],
|
|
] as const) {
|
|
const p = actual.players[index],
|
|
previous = before.players[index];
|
|
const payment =
|
|
q.payments.find((pay) => pay.player === p.id)?.ownPayment ?? 0;
|
|
const bounty = q.bounty?.player === p.id ? q.bounty.amount : 0;
|
|
assert.equal(p.spice, previous.spice - payment + bounty);
|
|
assert.equal(
|
|
p.leaders.find((l) => l.id === normalized[label].leader!.id)!.dead,
|
|
q.leaderDeaths[label],
|
|
);
|
|
}
|
|
assert.equal(
|
|
actual.pendingTreacheryDiscard!.continuation.kind,
|
|
'battleResolved',
|
|
);
|
|
const frame = actual.pendingTreacheryDiscard!.continuation;
|
|
if (frame.kind === 'battleResolved') {
|
|
assert.deepEqual(frame.casualties, q.casualties);
|
|
assert.deepEqual(frame.cards, q.winnerCards);
|
|
}
|
|
assert.deepEqual(
|
|
actual.pendingTreacheryDiscard!.batch.entries.map((e) => ({
|
|
player: e.discardedBy,
|
|
card: e.card.id,
|
|
})),
|
|
q.discarded,
|
|
);
|
|
});
|
|
void test('repeated quote calls allocate no UUID, consume no RNG and keep input and output independent', (t) => {
|
|
const g = input(),
|
|
before = structuredClone(g);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('No random selection during a quote');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('No event allocation during a quote');
|
|
});
|
|
const first = quoteBattleResolution(g);
|
|
for (let i = 0; i < 20; i++)
|
|
assert.deepEqual(quoteBattleResolution(g), first);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
|
|
/** Every response here follows real chooseBattle/plans/traitorCall actions.
|
|
* Only the initial bounded board position is staged; cards have one live owner. */
|
|
function alliedCallFixture(
|
|
variant: 'ordinary' | 'auditor' | 'retention' | 'explosion' = 'ordinary',
|
|
partial = false,
|
|
) {
|
|
let g = createGame(
|
|
'HARKQUOTE',
|
|
newPlayer('a', 'Attacker', variant === 'auditor' ? 'choam' : 'guild'),
|
|
true,
|
|
['choam', 'ecaz'],
|
|
);
|
|
g.players.push(
|
|
newPlayer('d', 'Defender', 'emperor'),
|
|
newPlayer('h', 'Harkonnen', 'harkonnen'),
|
|
newPlayer('b', 'BG', 'beneGesserit'),
|
|
);
|
|
if (variant === 'retention')
|
|
g.players.push(newPlayer('m', 'Moritani', 'moritani'));
|
|
if (variant === 'explosion')
|
|
g.players.push(newPlayer('r', 'Richese', 'richese'));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 6,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'a',
|
|
order: g.players.map((p) => p.id),
|
|
deck: catalog(),
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 20;
|
|
p.forces = ['a', 'd'].includes(p.id) ? { 'false_wall_south:4': 5 } : {};
|
|
p.reserves = ['a', 'd'].includes(p.id) ? 15 : 20;
|
|
p.traitors = [];
|
|
for (const l of p.leaders) l.strength = 0;
|
|
}
|
|
const seat = (id: string) => g.players.find((p) => p.id === id)!;
|
|
seat('d').ally = 'h';
|
|
seat('h').ally = 'd';
|
|
seat('d').elites = {
|
|
reserves: 4,
|
|
tanks: 0,
|
|
forces: { 'false_wall_south:4': 1 },
|
|
revived: 0,
|
|
};
|
|
if (variant === 'retention') {
|
|
seat('a').ally = 'm';
|
|
seat('m').ally = 'a';
|
|
}
|
|
if (variant === 'auditor') seat('a').leaders.push(createAuditorLeader());
|
|
function hold(id: string, predicate: (c: Card) => boolean) {
|
|
const at = g.deck.findIndex(predicate);
|
|
assert.ok(at >= 0);
|
|
const card = g.deck.splice(at, 1)[0];
|
|
seat(id).hand.push(card);
|
|
return card;
|
|
}
|
|
const worthless = hold('b', (c) => c.kind === 'worthless');
|
|
const printed = hold('b', (c) => c.effect === 'karama');
|
|
hold('h', (c) => c.effect === 'karama');
|
|
const aCard = hold('a', (c) =>
|
|
variant === 'explosion' ? c.kind === 'lasgun' : c.kind === 'shield',
|
|
);
|
|
const dCard = hold('d', (c) =>
|
|
variant === 'explosion' ? c.kind === 'shield' : c.kind === 'worthless',
|
|
);
|
|
const leader =
|
|
variant === 'auditor'
|
|
? seat('a').leaders.find((l) => l.id === 'choam-auditor')!.id
|
|
: seat('a').leaders[0].id;
|
|
if (variant === 'auditor') {
|
|
seat('d').ally = null;
|
|
seat('a').ally = 'h';
|
|
seat('h').ally = 'a';
|
|
seat('h').traitors = [seat('d').leaders[0].id];
|
|
} else seat('h').traitors = [leader];
|
|
const allow = () => {
|
|
for (let i = 0; g.response && i < 30; i++)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
assert.equal(g.response, null);
|
|
};
|
|
g = applyAction(g, 'a', {
|
|
type: 'chooseBattle',
|
|
territory: 'false_wall_south',
|
|
target: 'd',
|
|
});
|
|
allow();
|
|
for (let i = 0; g.battle?.preparation && i < 20; i++) {
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
allow();
|
|
}
|
|
if (g.battle?.preLeader && !g.battle.preLeader.closed)
|
|
for (const id of ['a', 'd'])
|
|
g = applyAction(g, id, {
|
|
type: 'battlePreparationReady',
|
|
event: g.battle!.preLeader!.event,
|
|
});
|
|
g = applyAction(g, 'a', {
|
|
type: 'battlePlan',
|
|
dial: variant === 'retention' ? 1 : 3,
|
|
support: variant === 'retention' ? 1 : 3,
|
|
leader,
|
|
...(variant === 'explosion' ? { weapon: aCard.id } : { defense: aCard.id }),
|
|
});
|
|
g = applyAction(g, 'd', {
|
|
type: 'battlePlan',
|
|
dial: variant === 'retention' ? 3 : 1,
|
|
support: variant === 'retention' ? 3 : 1,
|
|
leader: seat('d').leaders[0].id,
|
|
...(variant === 'explosion' ? { defense: dCard.id } : { weapon: dCard.id }),
|
|
});
|
|
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
|
|
if (!partial) g = applyAction(g, 'd', { type: 'traitorCall', call: false });
|
|
g = applyAction(g, 'h', { type: 'traitorCall', call: true });
|
|
assert.equal(g.response?.kind, 'harkonnenTraitor');
|
|
return { g, worthless, printed };
|
|
}
|
|
function allowConversion(g: Game) {
|
|
for (let i = 0; g.response?.kind === 'worthlessKarama' && i < 20; i++)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
return g;
|
|
}
|
|
void test('real printed and BG allied-traitor cancellation use the same current support, typed losses and played cards', () => {
|
|
for (const form of ['printed', 'worthless'] as const) {
|
|
const f = alliedCallFixture();
|
|
const before = structuredClone(f.g);
|
|
let done = applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f[form].id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.deepEqual(f.g, before);
|
|
if (form === 'worthless') {
|
|
assert.equal(done.response?.kind, 'worthlessKarama');
|
|
assert.equal(done.battle!.traitorCalls.h, true);
|
|
done = allowConversion(JSON.parse(JSON.stringify(done)));
|
|
}
|
|
assert.equal(done.lastBattleContext!.result, 'normal');
|
|
assert.equal(done.lastBattleContext!.winner, 'a');
|
|
assert.deepEqual(
|
|
done.players.slice(0, 2).map((p) => p.spice),
|
|
[17, 19],
|
|
);
|
|
assert.equal(done.players[0].tanks, 3);
|
|
assert.equal(done.players[1].tanks, 5);
|
|
assert.equal(done.players[1].elites!.tanks, 1);
|
|
assert.equal(done.discard.filter((c) => c.id === f[form].id).length, 1);
|
|
assert.deepEqual(done.players[2].revealedTraitors ?? [], []);
|
|
assert.equal(done.players[0].hand.length, 1);
|
|
assert.equal(done.players[1].hand.length, 0);
|
|
}
|
|
});
|
|
void test('partial traitor voters allow either cancellation form without resolving or spending battle support early', () => {
|
|
for (const form of ['printed', 'worthless'] as const) {
|
|
const f = alliedCallFixture('ordinary', true);
|
|
let g = applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f[form].id,
|
|
mode: 'cancel',
|
|
});
|
|
if (form === 'worthless') g = allowConversion(g);
|
|
assert.ok(g.battle);
|
|
assert.equal(g.battle.traitorCalls.h, false);
|
|
assert.equal(g.battle.traitorCalls.d, undefined);
|
|
assert.deepEqual(
|
|
g.players.slice(0, 2).map((p) => [p.spice, p.tanks]),
|
|
[
|
|
[20, 0],
|
|
[20, 0],
|
|
],
|
|
);
|
|
g = applyAction(g, 'd', { type: 'traitorCall', call: false });
|
|
assert.equal(g.lastBattleContext!.winner, 'a');
|
|
}
|
|
});
|
|
void test('actual allied-traitor cost rejects changed card custody, funding and typed casualty commitments without any randomness', (t) => {
|
|
const f = alliedCallFixture();
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.deck.push(g.players[1].hand.pop()!);
|
|
},
|
|
(g) => {
|
|
g.deck.push(structuredClone(g.players[1].hand[0]));
|
|
},
|
|
(g) => {
|
|
g.players[1].spice = 0;
|
|
},
|
|
(g) => {
|
|
g.battle!.plans.a.dial = 20;
|
|
},
|
|
];
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('Pre-cost validation must not draw');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('Pre-cost validation must not create events');
|
|
});
|
|
for (const mutate of mutations)
|
|
for (const form of ['printed', 'worthless'] as const) {
|
|
const g = structuredClone(f.g);
|
|
mutate(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'b', {
|
|
type: 'card',
|
|
card: f[form].id,
|
|
mode: 'cancel',
|
|
}),
|
|
/custody|available|funded|casualty/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(
|
|
g.players[3].hand.some((c) => c.id === f[form].id),
|
|
true,
|
|
);
|
|
}
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
void test('final BG allowance rechecks live funding and physical custody without a second cost or partial battle effects', () => {
|
|
const f = alliedCallFixture();
|
|
const paid = applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
});
|
|
for (const mutate of [
|
|
(g: Game) => {
|
|
g.players[1].spice = 0;
|
|
},
|
|
(g: Game) => {
|
|
g.deck.push(g.players[1].hand.pop()!);
|
|
},
|
|
(g: Game) => {
|
|
g.deck.push(structuredClone(g.players[1].hand[0]));
|
|
},
|
|
]) {
|
|
const g = structuredClone(paid);
|
|
mutate(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => allowConversion(g), /funded|custody|available/);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(g.discard.filter((c) => c.id === f.worthless.id).length, 1);
|
|
assert.equal(g.battle!.traitorCalls.h, true);
|
|
assert.equal(g.players[0].tanks, 0);
|
|
}
|
|
});
|
|
void test('per-location elite corruption cannot be hidden by a valid aggregate force pool before BG cost', () => {
|
|
const f = alliedCallFixture();
|
|
const g = structuredClone(f.g);
|
|
g.players[1].forces = { 'false_wall_south:4': 1, 'false_wall_south:5': 4 };
|
|
g.players[1].elites!.forces = { 'false_wall_south:4': 2 };
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/casualt|elite|physical/i,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('actual canceled battle cannot overwrite a preceding Auditor or Moritani receipt before cost or at final BG allowance', () => {
|
|
for (const variant of ['auditor', 'retention'] as const) {
|
|
const completedFixture = alliedCallFixture(variant);
|
|
const completed = allowConversion(
|
|
applyAction(completedFixture.g, 'b', {
|
|
type: 'card',
|
|
card: completedFixture.worthless.id,
|
|
mode: 'cancel',
|
|
}),
|
|
);
|
|
const prior =
|
|
variant === 'auditor'
|
|
? completed.pendingAuditor
|
|
: completed.moritaniRetention;
|
|
assert.ok(prior, `Genuine completed ${variant} receipt`);
|
|
for (const final of [false, true]) {
|
|
const f = alliedCallFixture(variant);
|
|
const source = final
|
|
? applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
})
|
|
: f.g;
|
|
const bad = structuredClone(source);
|
|
if (variant === 'auditor')
|
|
bad.pendingAuditor = structuredClone(prior) as Game['pendingAuditor'];
|
|
else
|
|
bad.moritaniRetention = structuredClone(
|
|
prior,
|
|
) as Game['moritaniRetention'];
|
|
const before = structuredClone(bad);
|
|
assert.throws(
|
|
() =>
|
|
final
|
|
? allowConversion(bad)
|
|
: applyAction(bad, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/audit|alliance|cleanup/i,
|
|
);
|
|
assert.deepEqual(bad, before);
|
|
assert.equal(
|
|
bad.discard.filter((c) => c.id === f.worthless.id).length,
|
|
final ? 1 : 0,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
void test('per-location and safe-counter removal checks cover both combatants before cost and final allowance', () => {
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.players[1].forces = {
|
|
'false_wall_south:4': 1,
|
|
'false_wall_south:5': 4,
|
|
};
|
|
g.players[1].elites!.forces = { 'false_wall_south:4': 2 };
|
|
},
|
|
(g) => {
|
|
g.players[1].forces = {
|
|
'false_wall_south:4': -1,
|
|
'false_wall_south:5': 6,
|
|
};
|
|
g.players[1].elites!.forces = { 'false_wall_south:5': 1 };
|
|
},
|
|
(g) => {
|
|
g.players[0].forces = {
|
|
'false_wall_south:4': -1,
|
|
'false_wall_south:5': 6,
|
|
};
|
|
},
|
|
(g) => {
|
|
g.players[1].tanks = Number.MAX_SAFE_INTEGER;
|
|
},
|
|
(g) => {
|
|
g.players[0].battleLosses = Number.MAX_SAFE_INTEGER;
|
|
},
|
|
(g) => {
|
|
g.players[1].elites!.tanks = 1;
|
|
},
|
|
];
|
|
const f = alliedCallFixture(),
|
|
paid = applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
});
|
|
for (const final of [false, true])
|
|
for (const mutate of mutations) {
|
|
const bad = structuredClone(final ? paid : f.g);
|
|
mutate(bad);
|
|
const before = structuredClone(bad);
|
|
assert.throws(
|
|
() =>
|
|
final
|
|
? allowConversion(bad)
|
|
: applyAction(bad, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/forces|casualt|counter|tank/i,
|
|
);
|
|
assert.deepEqual(bad, before);
|
|
assert.equal(
|
|
bad.discard.filter((c) => c.id === f.worthless.id).length,
|
|
final ? 1 : 0,
|
|
);
|
|
}
|
|
});
|
|
void test('canceling an allied traitor into a real explosion checks uninvolved forces and concealed No-Fields before cost and final allowance', (t) => {
|
|
const f = alliedCallFixture('explosion');
|
|
const paid = applyAction(f.g, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
});
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
const r = g.players.find((p) => p.id === 'r')!;
|
|
r.forces = { 'false_wall_south:4': -1, 'false_wall_south:5': 1 };
|
|
},
|
|
(g) => {
|
|
const r = g.players.find((p) => p.id === 'r')!;
|
|
r.forces = { 'false_wall_south:4': 1 };
|
|
r.tanks = Number.MAX_SAFE_INTEGER;
|
|
},
|
|
(g) => {
|
|
const r = g.players.find((p) => p.id === 'r')!;
|
|
r.noField = deployRicheseNoField(
|
|
createRicheseNoField(['zero', 'three', 'five']),
|
|
{
|
|
tokenId: 'three',
|
|
controller: 'r',
|
|
location: { territory: 'false_wall_south', sector: 5 },
|
|
},
|
|
);
|
|
r.noFieldEvent = 'restored-marker-event';
|
|
},
|
|
];
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('No preflight randomness');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('No preflight event allocation');
|
|
});
|
|
for (const final of [false, true])
|
|
for (const mutate of mutations) {
|
|
const bad = structuredClone(final ? paid : f.g);
|
|
mutate(bad);
|
|
const before = structuredClone(bad);
|
|
assert.throws(
|
|
() =>
|
|
final
|
|
? allowConversion(bad)
|
|
: applyAction(bad, 'b', {
|
|
type: 'card',
|
|
card: f.worthless.id,
|
|
mode: 'cancel',
|
|
}),
|
|
/forces|casualt|counter|tank|No-Field/i,
|
|
);
|
|
assert.deepEqual(bad, before);
|
|
assert.equal(
|
|
bad.discard.filter((c) => c.id === f.worthless.id).length,
|
|
final ? 1 : 0,
|
|
);
|
|
}
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
|
|
function independentPlanetologistRole(
|
|
effect: 'reinforcements' | 'harassWithdraw' | 'recruits', alternate = false,
|
|
): BattleResolutionInput {
|
|
const game = input(), cards = ecazTreacheryCards();
|
|
const weapon = cards.find(card => card.effect === effect)!;
|
|
const defense = cards.find(card => card.effect === (effect === 'harassWithdraw' ? 'reinforcements' : 'harassWithdraw'))!;
|
|
game.physicalCards = [...baseDeck(), ...cards];
|
|
game.attacker.hand = [weapon, defense];
|
|
game.attacker.plan.weapon = weapon.id;
|
|
game.attacker.plan.defense = defense.id;
|
|
if (alternate) game.attacker.plan.planetologistSpecial = true;
|
|
game.attacker.leaderSkills = [{
|
|
skill: 'planetologist', leader: game.attacker.leader!.id, faceUp: false, captured: false,
|
|
}];
|
|
if ((effect === 'reinforcements' && !alternate) || defense.effect === 'reinforcements')
|
|
game.attacker.reinforcementsReserves = { normal: 3, elite: 0 };
|
|
if ((effect === 'harassWithdraw' && !alternate) || defense.effect === 'harassWithdraw')
|
|
game.attacker.harassWithdraw = { blocked: null, forces: game.attacker.forces,
|
|
locations: { 'arrakeen:10': { normal: 5, elite: 0 } } };
|
|
return game;
|
|
}
|
|
|
|
void test('Planetologist chooses between native Reinforcements and its green Special role without combining fees or bonuses', () => {
|
|
const native = quoteBattleResolution(independentPlanetologistRole('reinforcements'));
|
|
const alternate = quoteBattleResolution(independentPlanetologistRole('reinforcements', true));
|
|
assert.equal(native.scores!.attacker, 7);
|
|
assert.equal(alternate.scores!.attacker, 7);
|
|
assert.deepEqual(native.reinforcements, [{ player: 'a', card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
|
|
assert.deepEqual(alternate.reinforcements ?? [], []);
|
|
assert.equal(native.leaderSkillBonuses?.attacker.bonus ?? 0, 0);
|
|
assert.equal(alternate.leaderSkillBonuses!.attacker.bonus, 2);
|
|
for (const quote of [native, alternate]) {
|
|
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 3, elite: 0 });
|
|
assert.equal(quote.payments.find(payment => payment.player === 'a')!.ownPayment, 2);
|
|
assert.deepEqual(quote.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
|
|
assert.ok(quote.discarded.some(card => card.player === 'a' && card.card === 'ecaz-reinforcements'));
|
|
}
|
|
});
|
|
|
|
void test('Planetologist Harass substitution suppresses withdrawal but leaves a distinct defense-slot Reinforcements native', () => {
|
|
const quote = quoteBattleResolution(independentPlanetologistRole('harassWithdraw', true));
|
|
assert.equal(quote.scores!.attacker, 9);
|
|
assert.equal(quote.leaderSkillBonuses!.attacker.bonus, 2);
|
|
assert.deepEqual(quote.harassWithdraw ?? [], []);
|
|
assert.deepEqual(quote.reinforcements, [{ player: 'a', card: 'ecaz-reinforcements', normal: 3, elite: 0 }]);
|
|
assert.deepEqual(quote.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]);
|
|
});
|
|
|
|
void test('green Recruits uses the ordinary Planetologist substitution and a killed trainer gains no alternate strength', () => {
|
|
const recruits = quoteBattleResolution(independentPlanetologistRole('recruits'));
|
|
assert.equal(recruits.leaderSkillBonuses!.attacker.bonus, 2);
|
|
assert.equal(recruits.scores!.attacker, 7);
|
|
assert.ok(recruits.discarded.some(card => card.card === 'ecaz-recruits'));
|
|
const doomed = independentPlanetologistRole('reinforcements', true);
|
|
play(doomed, 'defender', 'weapon', card => card.kind === 'poison');
|
|
const death = quoteBattleResolution(doomed);
|
|
assert.equal(death.leaderDeaths.attacker, true);
|
|
assert.equal(death.leaderSkillBonuses?.attacker.bonus ?? 0, 0);
|
|
assert.equal(death.scores!.attacker, 2);
|
|
assert.deepEqual(death.reinforcements ?? [], []);
|
|
assert.deepEqual(death.harassWithdraw![0].returned, { normal: 3, elite: 0 });
|
|
});
|
|
|
|
void test('an explicit Planetologist alternate role cannot be forged without its actual trainer', () => {
|
|
const game = independentPlanetologistRole('reinforcements', true);
|
|
game.attacker.leaderSkills = [];
|
|
reject(game);
|
|
const malformed = independentPlanetologistRole('reinforcements', true);
|
|
Object.assign(malformed.attacker.plan, { planetologistSpecial: false });
|
|
reject(malformed);
|
|
});
|