Files
Dune/tests/ecaz-occupy-harass-quote.test.ts

287 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { casualtyOptions, type CombatForces } from '../game/combat';
import {
quoteEcazOccupyBattle,
type EcazOccupyBattleProfile,
} from '../game/ecaz-occupy-battle';
import {
defaultHarassWithdrawAllocation,
harassWithdrawCommitments,
harassWithdrawNeedsAllocation,
quoteHarassWithdraw,
HarassWithdrawError,
type HarassWithdrawContext,
type HarassWithdrawSelection,
} from '../game/harass-withdraw';
import { leaders } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { quoteBattleResolution, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
import type { StoneBurnerSide } from '../game/stone-burner';
const native = (normal: number): CombatForces => ({
normal,
elite: 0,
eliteStrength: 1,
freeSupport: false,
});
function profile(
ecazNormal = 5,
lead = 'ecaz-user',
canceled = false,
allyForces = native(4),
): EcazOccupyBattleProfile {
return quoteEcazOccupyBattle({
advanced: true,
battleOrderActor: 'ecaz-user',
ecaz: {
id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user',
forces: native(ecazNormal),
},
ally: {
id: 'ally-user', faction: 'ixians', ally: 'ecaz-user',
forces: allyForces,
},
lead,
canceled,
});
}
function contextFor(p: EcazOccupyBattleProfile): HarassWithdrawContext {
const forces = p.lead === p.ecaz ? p.ecazForces : p.allyForces;
return {
blocked: null,
forces,
locations: { 'imperial_basin:9': { normal: forces.normal, elite: forces.elite } },
occupy: {
cardUser: p.lead,
ecaz: p.ecaz,
ally: p.ally,
canceled: p.canceled,
fixedEcazDial: p.fixedEcazDial,
variableForces: p.forces,
},
};
}
void test('Ecaz card user returns only its own floor(E/2), while ally support never charges the free fixed fighters', () => {
for (const ecazNormal of [5, 6]) {
const p = profile(ecazNormal);
const context = contextFor(p);
const before = structuredClone(context);
for (const [variableDial, support] of [[0, 0], [0.5, 0], [2, 2]]) {
const dial = p.fixedEcazDial + variableDial;
assert.deepEqual(harassWithdrawCommitments(context, dial, support), [
{ normal: Math.ceil(ecazNormal / 2), elite: 0 },
]);
assert.equal(harassWithdrawNeedsAllocation(context, dial, support), false);
const quote = quoteHarassWithdraw(context, dial, support);
assert.deepEqual(quote.returned, { normal: Math.floor(ecazNormal / 2), elite: 0 });
assert.deepEqual(quote.remaining, {
...context.forces, normal: Math.ceil(ecazNormal / 2), freeSupport: true,
});
assert.deepEqual(casualtyOptions(quote.remaining, p.fixedEcazDial, 0), [
{ normal: p.fixedEcazDial, elite: 0, paidNormal: 0, paidElite: 0 },
]);
}
assert.deepEqual(context, before);
}
});
void test('original mandatory Ecaz commitment survives a changed on-board pool after the physical return', () => {
const p = profile(5);
const context = contextFor(p);
const returned = quoteHarassWithdraw(context, 4, 1);
const afterReturn: HarassWithdrawContext = {
...context,
forces: returned.remaining,
locations: { 'imperial_basin:9': { normal: 3, elite: 0 } },
};
const remaining = quoteHarassWithdraw(afterReturn, 4, 1);
assert.deepEqual(remaining.returned, { normal: 0, elite: 0 });
assert.equal(remaining.remaining.normal, 3);
assert.equal(remaining.remaining.freeSupport, true);
});
void test('Ecaz sector choices allocate only its own undialed counters, never ally counters', () => {
const context: HarassWithdrawContext = {
...contextFor(profile()),
locations: {
'imperial_basin:9': { normal: 2, elite: 0 },
'imperial_basin:10': { normal: 3, elite: 0 },
},
};
assert.equal(harassWithdrawNeedsAllocation(context, 4, 1), true);
const selected: HarassWithdrawSelection = {
'imperial_basin:10': { normal: 2, elite: 0 },
};
assert.deepEqual(quoteHarassWithdraw(context, 4, 1, selected).returned, { normal: 2, elite: 0 });
const fallback = defaultHarassWithdrawAllocation(context, 4, 1);
assert.deepEqual(quoteHarassWithdraw(context, 4, 1, fallback).returned, { normal: 2, elite: 0 });
assert.throws(() => quoteHarassWithdraw(context, 4, 1, {
'ally-location': { normal: 2, elite: 0 },
}), HarassWithdrawError);
assert.throws(() => quoteHarassWithdraw(context, 4, 1, {
'imperial_basin:10': { normal: 1, elite: 0 },
}), HarassWithdrawError);
});
void test('ally card user subtracts fixed strength and uses native Ix half-normal/double-elite spice commitments', () => {
const ix: CombatForces = {
normal: 3, elite: 2, eliteStrength: 2,
normalFixedHalf: true, freeSupport: false,
};
const context = contextFor(profile(5, 'ally-user', false, ix));
// Three fixed Ecaz strength plus 2.5 variable strength: one half-normal
// and one supported double-elite, not 2.5 or 5.5 physical counters.
assert.deepEqual(harassWithdrawCommitments(context, 5.5, 1), [{ normal: 1, elite: 1 }]);
const quote = quoteHarassWithdraw(context, 5.5, 1);
assert.deepEqual(quote.returned, { normal: 2, elite: 1 });
assert.deepEqual(quote.remaining, { ...ix, normal: 1, elite: 1 });
// Without spice the same strength admits two genuinely different physical
// commitments; selecting the elite-heavy one returns only two own normals.
assert.equal(harassWithdrawNeedsAllocation(context, 5.5, 0), true);
const selected = defaultHarassWithdrawAllocation(context, 5.5, 0, { normal: 1, elite: 2 });
assert.deepEqual(quoteHarassWithdraw(context, 5.5, 0, selected).returned, { normal: 2, elite: 0 });
});
void test('ordinary Advanced ally returns physical undialed counters instead of numeric dial complement', () => {
const context = contextFor(profile(5, 'ally-user'));
assert.deepEqual(quoteHarassWithdraw(context, 4, 0).returned, { normal: 2, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 4, 1).returned, { normal: 3, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 3, 0).returned, { normal: 4, elite: 0 });
});
void test('canceled Occupy restores either selected lead own native commitment with zero fixed strength', () => {
for (const lead of ['ecaz-user', 'ally-user']) {
const p = profile(5, lead, true);
const context = contextFor(p);
const quote = quoteHarassWithdraw(context, 1, 0);
assert.deepEqual(quote.returned, { normal: context.forces.normal - 2, elite: 0 });
assert.deepEqual(quote.remaining, { ...context.forces, normal: 2 });
assert.equal(quote.remaining.freeSupport, false);
const all = quoteHarassWithdraw(context, 0, 0);
assert.equal(all.returned.normal, context.forces.normal);
}
});
void test('ordinary opponent keeps native dial/support semantics without Occupy metadata', () => {
const context: HarassWithdrawContext = {
blocked: null,
forces: native(5),
locations: { 'imperial_basin:9': { normal: 5, elite: 0 } },
};
assert.deepEqual(quoteHarassWithdraw(context, 1, 0).returned, { normal: 3, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 1, 1).returned, { normal: 4, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 0, 0).returned, { normal: 5, elite: 0 });
});
void test('owner and funding boundary rejects invalid total plans before quoting any return', () => {
const context = contextFor(profile());
for (const [dial, support] of [[2, 0], [3, 1], [8, 0], [3.25, 0], [4, -1]]) {
assert.throws(() => quoteHarassWithdraw(context, dial, support), HarassWithdrawError);
}
assert.ok(context.occupy);
const wrongOwner: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, cardUser: 'opponent' },
};
assert.throws(() => quoteHarassWithdraw(wrongOwner, 4, 1), HarassWithdrawError);
const missingFixed: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, fixedEcazDial: 0 },
};
assert.throws(() => quoteHarassWithdraw(missingFixed, 4, 1), HarassWithdrawError);
const mixedAlly: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, cardUser: 'ally-user' },
};
assert.throws(() => quoteHarassWithdraw(mixedAlly, 4, 1), HarassWithdrawError);
const canceledWithFixed: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, canceled: true },
};
assert.throws(() => quoteHarassWithdraw(canceledWithFixed, 4, 1), HarassWithdrawError);
});
function stoneResolution(p: EcazOccupyBattleProfile, slot: StoneBurnerSide, otherUndialed: number): BattleResolutionInput {
const side = (id: string, faction: 'ecaz' | 'ixians' | 'guild', forces: CombatForces, dial: number): ResolutionCombatant => {
const leader = { ...leaders(faction)[0], strength: 0 };
return { id, faction, forces, leader, spice: 10, hand: [],
plan: { dial, support: 1, leader: leader.id, weapon: null, defense: null } };
};
const own = side(p.lead, p.lead === p.ecaz ? 'ecaz' : 'ixians', p.forces, p.fixedEcazDial + 1);
own.ally = p.lead === p.ecaz ? p.ally : p.ecaz;
const other = side('opponent', 'guild', native(otherUndialed + 1), 1);
const stone = richeseCards().find(card => card.effect === 'stoneBurner')!;
const harass = ecazTreacheryCards().find(card => card.effect === 'harassWithdraw')!;
own.hand = [stone, harass];
own.plan.weapon = stone.id;
own.plan.defense = harass.id;
own.stoneMode = 'ignore';
own.harassWithdraw = contextFor(p);
return {
ecazOccupy: p, advanced: true, turn: 2, territory: 'carthag', aggressor: p.lead,
attacker: slot === 'attacker' ? own : other,
defender: slot === 'defender' ? own : other,
participants: [
{ id: p.ecaz, faction: 'ecaz', ally: p.ally },
{ id: p.ally, faction: 'ixians', ally: p.ecaz },
{ id: other.id, faction: other.faction },
],
voters: [p.ecaz, p.ally].map(id => ({ id, beneficiary: p.lead, called: false, traitors: [] as string[] }))
.concat([{ id: other.id, beneficiary: other.id, called: false, traitors: [] }]),
physicalCards: [stone, harass], pendingAuditorPresent: false, pendingRetentionPresent: false,
};
}
void test('Stone and active Occupy Harass remove only the Ecaz or variable card user undialed pool while preserving original fixed costs', () => {
for (const ecazNormal of [5, 6])
for (const lead of ['ecaz-user', 'ally-user'])
for (const slot of ['attacker', 'defender'] as const) {
const p = profile(ecazNormal, lead);
const remainingUndialed = lead === p.ecaz ? 3 : Math.floor(ecazNormal / 2);
const input = stoneResolution(p, slot, remainingUndialed);
const before = structuredClone(input);
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, p.lead);
assert.deepEqual(quote.stone, {
winner: slot, attacker: [remainingUndialed], defender: [remainingUndialed],
});
assert.deepEqual(quote.harassWithdraw?.[0].returned, {
normal: lead === p.ecaz ? Math.floor(ecazNormal / 2) : 3, elite: 0,
});
assert.deepEqual(quote.fixedLosses, [{ owner: p.ecaz, normal: Math.ceil(ecazNormal / 2), elite: 0 }]);
assert.equal(quote.casualties?.owner, p.ally);
assert.equal(quote.casualties?.fullyCommitted, lead === p.ally ? true : undefined);
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
assert.deepEqual(quote.payments.map(payment => payment.ownPayment), [1, 1]);
assert.deepEqual(quote.winnerCards, [input[slot].plan.weapon]);
assert.deepEqual(quote.discarded, [{ player: p.lead, card: input[slot].plan.defense }]);
assert.deepEqual(input, before);
// The opposite successful call cancels Harass before comparing physical
// remainders, without reducing either original Occupy contribution.
const voter = input.voters.find(voter => voter.id === 'opponent')!;
voter.called = true;
voter.traitors = [input[slot].plan.leader!];
const canceled = quoteBattleResolution(input);
assert.equal(canceled.winner, 'opponent');
assert.equal(canceled.harassWithdraw, undefined);
assert.deepEqual(canceled.stone?.[slot], [Math.floor(ecazNormal / 2) + 3]);
assert.deepEqual(canceled.fixedLosses, []);
}
});
void test('canceled Occupy Harass uses the selected lead native pool with no fixed remainder or recomputed commitment', () => {
for (const lead of ['ecaz-user', 'ally-user'])
for (const slot of ['attacker', 'defender'] as const) {
const p = profile(5, lead, true);
const input = stoneResolution(p, slot, 0);
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, p.lead);
assert.deepEqual(quote.stone, { winner: slot, attacker: [0], defender: [0] });
assert.deepEqual(quote.harassWithdraw?.[0].returned, { normal: p.forces.normal - 1, elite: 0 });
assert.deepEqual(quote.fixedLosses, []);
assert.equal(quote.casualties?.owner, p.lead);
assert.equal(quote.casualties?.forces.normal, 1);
assert.equal(quote.casualties?.fullyCommitted, true);
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
}
});