287 lines
13 KiB
TypeScript
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 }]);
|
|
}
|
|
});
|