Files
Dune/tests/stone-burner-allocation.test.ts
Quintine 6ccfaa1b4d Resolve ambiguous Stone battles with sealed physical totals
Add a provisional post-reveal physical-count commitment for new outcome-changing Basic/Advanced Stone and copied Mirror battles. Keep all original sides sealed until complete and retain matching later typed losses. Preserve legacy, ordinary and invariant-winner timing. Carry the same constraint through card cleanup and native casualty consumers.

Validation: frozen 16 files146/146, types/lint/build and14 original-action/rendered-control programmes; four minimal policies make both owned choices legally. Source timing is explicitly provisional. Wider combinations, assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
2026-10-18 12:13:06 +11:00

237 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { casualtyOptions, type CombatForces } from '../game/combat';
import {
quoteBattleResolution, StoneBurnerAllocationRequiredError, BattleResolutionQuoteError,
type BattleResolutionInput, type ResolutionCombatant,
} from '../game/battle-resolution-quote';
import { quoteStoneBurnerAllocations, stoneBurnerCommittedLosses } from '../game/stone-burner-allocation';
import { stoneBurnerPlanBlock, stoneBurnerCompulsionBlock } from '../game/stone-burner';
import { quoteEcazOccupyBattle } from '../game/ecaz-occupy-battle';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { casualtyConstraintFields, committedCasualtyOptions } from '../game/battle-casualty-commitment';
const catalog = (): Card[] => [...baseDeck(), ...ixBattleCards(), ...richeseCards(), ...ecazTreacheryCards()];
const ordinary = (normal: number, freeSupport = false): CombatForces => ({ normal, elite: 0, eliteStrength: 1, freeSupport });
const emperor: CombatForces = { normal: 4, elite: 2, eliteStrength: 2, freeSupport: false };
function combatant(id: string, faction: ResolutionCombatant['faction'], forces: CombatForces, dial: number, support: number): ResolutionCombatant {
const leader = { ...leaders(faction)[0] };
return { id, faction, forces: { ...forces }, spice: 10, hand: [], leader,
plan: { dial, support, leader: leader.id, weapon: null, defense: null } };
}
function fixture(): BattleResolutionInput {
const attacker = combatant('a', 'emperor', emperor, 3, 1);
const defender = combatant('d', 'guild', ordinary(4), 1, 1);
const cards = catalog(), stone = cards.find(card => card.effect === 'stoneBurner')!;
attacker.hand = [stone]; attacker.plan.weapon = stone.id; attacker.stoneMode = 'ignore';
return { advanced: true, turn: 2, territory: 'carthag', attacker, defender,
participants: [attacker, defender].map(side => ({ id: side.id, faction: side.faction })),
voters: [attacker, defender].map(side => ({ id: side.id, beneficiary: side.id, called: false, traitors: [] })),
physicalCards: cards, pendingAuditorPresent: false, pendingRetentionPresent: false };
}
void test('a committed Stone physical loss count survives card cleanup and native aftercare without reopening other typed totals', () => {
const input = fixture();
const result = quoteBattleResolution({...input,stoneAllocations:{a:3}});
const commitment = result.casualties!;
assert.equal(commitment.physicalLossCount, 3);
const saved = JSON.parse(JSON.stringify(commitment));
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,saved),saved.options);
assert.equal(new Set(saved.options.map((option: {normal:number;elite:number}) => option.normal + option.elite)).size,1);
assert.ok(saved.options.length > 1, 'Choosing a physical total still leaves the original typed winner choice');
assert.deepEqual(casualtyConstraintFields(saved),{physicalLossCount:3});
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support),
casualtyOptions(saved.forces,saved.dial,saved.support), 'Legacy unconstrained casualty choices remain complete');
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,{physicalLossCount:21}),[]);
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,{fullyCommitted:true,physicalLossCount:3}),[],
'A partial Stone commitment cannot replace the already fixed full Harass remainder');
});
void test('only outcome-ambiguous Stone asks for physical totals, allowing either winner and retaining typed alternatives within one committed total', () => {
const input = fixture(), before = structuredClone(input);
const choices = { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] };
assert.deepEqual(quoteStoneBurnerAllocations(input), choices);
assert.throws(() => quoteBattleResolution(input), StoneBurnerAllocationRequiredError);
const loser = quoteBattleResolution({ ...input, stoneAllocations: { a: 2 } });
assert.equal(loser.winner, 'd');
assert.deepEqual(loser.stone, { winner: 'defender', attacker: [2], defender: [3] });
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { a: 2 } }, 'a'), [
{ normal: 3, elite: 1, paidNormal: 1, paidElite: 0 },
]);
assert.equal(stoneBurnerCommittedLosses(input, 'a'), null);
assert.equal(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { a: 2 } }, 'd'), null);
const winner = quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } });
assert.equal(winner.winner, 'a');
assert.equal(winner.casualties!.physicalLossCount, 3);
assert.deepEqual(winner.stone, { winner: 'attacker', attacker: [3], defender: [3] });
assert.deepEqual(winner.casualties!.options, [
{ normal: 2, elite: 1, paidNormal: 0, paidElite: 1 },
{ normal: 1, elite: 2, paidNormal: 1, paidElite: 0 },
]);
for (const option of winner.casualties!.options) assert.equal(6 - option.normal - option.elite, 3);
assert.deepEqual(quoteStoneBurnerAllocations({ ...input, stoneAllocations: { a: 3 } }), choices);
assert.deepEqual(input, before);
});
void test('both variable sides must commit even when the first chosen total already fixes the winner', () => {
const input = fixture();
input.defender = { ...input.defender, forces: { ...emperor }, plan: { ...input.defender.plan, dial: 3, support: 1 } };
const required = { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }, { player: 'd', owner: 'd', totals: [2, 3, 4] }] };
assert.deepEqual(quoteStoneBurnerAllocations(input), required);
assert.deepEqual(quoteStoneBurnerAllocations({ ...input, stoneAllocations: { a: 4 } }), required);
assert.throws(() => quoteBattleResolution({ ...input, stoneAllocations: { a: 4 } }), StoneBurnerAllocationRequiredError);
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 4, d: 2 } }).winner, 'a');
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 2, d: 4 } }).winner, 'd');
input.aggressor = 'd';
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 3, d: 3 } }).winner, 'd');
});
void test('invariant Stone and ordinary battles keep their original winner-later allocation and gain no commitment roster', () => {
const input = fixture();
input.defender.forces = ordinary(1);
const options = casualtyOptions(input.attacker.forces, 3, 1);
assert.equal(quoteStoneBurnerAllocations(input), null);
assert.deepEqual(quoteBattleResolution(input).casualties!.options, options);
input.attacker.hand = []; input.attacker.plan.weapon = null; delete input.attacker.stoneMode;
assert.equal(quoteStoneBurnerAllocations(input), null);
const ordinaryResult = quoteBattleResolution(input);
assert.equal(ordinaryResult.stone, null);
assert.deepEqual(ordinaryResult.casualties!.options, options);
});
void test('Basic Cyborg/Suboid pools use the same physical commitment without granting Advanced strength or support', () => {
const input = fixture(); input.advanced = false;
input.attacker = combatant('a', 'ixians', { normal: 6, elite: 2, eliteStrength: 2, normalFixedHalf: true, freeSupport: true }, 2, 0);
const stone = catalog().find(card => card.effect === 'stoneBurner')!;
input.attacker.hand = [stone]; input.attacker.plan.weapon = stone.id; input.attacker.stoneMode = 'ignore';
input.defender = combatant('d', 'guild', ordinary(7, true), 1, 0);
input.participants = [input.attacker, input.defender].map(side => ({ id: side.id, faction: side.faction }));
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [4, 7] }] });
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 4 } }).winner, 'd');
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 7 } });
assert.equal(result.winner, 'a');
assert.deepEqual(result.casualties!.options, [{ normal: 0, elite: 1, paidNormal: 0, paidElite: 0 }]);
assert.deepEqual(result.payments, []);
});
void test('physical Mirror retains its own card while the copied Stone uses the same committed comparison', () => {
const input = fixture(), mirror = catalog().find(card => card.effect === 'mirrorWeapon')!;
input.defender.hand = [mirror]; input.defender.plan.weapon = mirror.id; input.defender.stoneMode = 'ignore';
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 2 } });
assert.equal(result.winner, 'd');
assert.ok(result.winnerCards.includes(mirror.id));
assert.ok(result.discarded.some(entry => entry.player === 'a' && entry.card === input.attacker.plan.weapon));
});
function occupyFixture(canceled = false): BattleResolutionInput {
const input = fixture();
const profile = quoteEcazOccupyBattle({ advanced: true, battleOrderActor: 'ecaz',
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'ally', forces: ordinary(5) },
ally: { id: 'ally', faction: 'emperor', ally: 'ecaz', forces: { ...emperor } },
lead: canceled ? 'ally' : 'ecaz', canceled });
const own = combatant(profile.lead, canceled ? 'emperor' : 'ecaz', profile.forces, profile.fixedEcazDial + 3, 1);
own.ally = canceled ? 'ecaz' : 'ally';
own.hand = input.attacker.hand; own.plan.weapon = input.attacker.plan.weapon; own.stoneMode = 'ignore';
input.attacker = own; input.ecazOccupy = profile;
input.defender = combatant('d', 'guild', ordinary(canceled ? 4 : 6), 1, 1);
input.participants = [{ id: 'ecaz', faction: 'ecaz', ally: 'ally' }, { id: 'ally', faction: 'emperor', ally: 'ecaz' }, { id: 'd', faction: 'guild' }];
input.voters = ['ecaz', 'ally'].map(id => ({ id, beneficiary: profile.lead, called: false, traitors: [] }))
.concat([{ id: 'd', beneficiary: 'd', called: false, traitors: [] }]);
return input;
}
void test('active Occupy asks the variable physical owner for the lead side total, retaining fixed Ecaz loss and same-total typed alternatives', () => {
const input = occupyFixture();
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'ecaz', owner: 'ally', totals: [4, 5, 6] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 5 } });
assert.equal(result.winner, 'ecaz');
assert.deepEqual(result.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
assert.equal(result.casualties!.owner, 'ally');
assert.equal(result.casualties!.physicalLossCount, 3, 'The fixed Ecaz losses are outside the committed variable loss count');
assert.equal(result.casualties!.options.length, 2);
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { ecaz: 5 } }, 'ecaz'), result.casualties!.options);
for (const option of result.casualties!.options) assert.equal(2 + 6 - option.normal - option.elite, 5);
assert.deepEqual(quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 4 } }).destroyedArmies, ['ecaz', 'ally']);
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations: { ally: 5 } }), /original side players/);
const canceled = occupyFixture(true);
assert.deepEqual(quoteStoneBurnerAllocations(canceled), { choices: [{ player: 'ally', owner: 'ally', totals: [2, 3, 4] }] });
assert.deepEqual(quoteBattleResolution({ ...canceled, stoneAllocations: { ally: 3 } }).fixedLosses, []);
});
void test('Ecaz Harass removes only its fixed undialed complement before variable totals, without changing the original mandatory loss', () => {
const input = occupyFixture(), own = input.attacker, profile = input.ecazOccupy!;
const harass = catalog().find(card => card.id === HARASS_WITHDRAW_CARD)!;
own.hand = [...own.hand, harass]; own.plan.defense = harass.id;
own.harassWithdraw = { blocked: null, forces: profile.ecazForces,
locations: { 'carthag:11': { normal: 5, elite: 0 } },
occupy: { cardUser: 'ecaz', ecaz: 'ecaz', ally: 'ally', canceled: false,
fixedEcazDial: 3, variableForces: profile.forces } };
input.defender.forces = ordinary(4);
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'ecaz', owner: 'ally', totals: [2, 3, 4] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 3 } });
assert.deepEqual(result.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(result.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
assert.equal(result.casualties!.options.length, 2);
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { ecaz: 3 } }, 'ecaz'), result.casualties!.options);
for (const option of result.casualties!.options) assert.equal(6 - option.normal - option.elite, 3);
});
void test('temporary elite roles commit physical normal totals and never manufacture starred losses', () => {
const input = fixture(); input.attacker.forces = { normal: 6, elite: 0, temporaryElite: 2, eliteStrength: 2, freeSupport: false };
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } });
assert.deepEqual(result.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 1, paidElite: 0 }]);
});
void test('native World and reconstructed visible Richese pools retain the same physical total rule', () => {
const input = fixture(); input.territory = 'homeworld:emperor';
input.homeworld = { native: 'a', card: 'kaitain', side: 'high', nativeForces: { normal: 4, elite: 2 } };
input.voters = [input.voters[0]];
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } }).winner, 'a');
const revealed = fixture();
revealed.defender = combatant('d', 'richese', ordinary(4), 1, 1);
revealed.defender.noFieldAtTerritory = false;
revealed.participants = [revealed.attacker, revealed.defender].map(side => ({ id: side.id, faction: side.faction, noFieldAtTerritory: false }));
assert.deepEqual(quoteStoneBurnerAllocations(revealed), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
});
void test('successful traitors and Lasgun–shield explosions have no allocation opportunity', () => {
const input = fixture();
input.voters = input.voters.map((voter, index) => index ? voter : {
...voter, called: true, traitors: [input.defender.leader!.id] });
assert.equal(quoteStoneBurnerAllocations(input), null);
assert.equal(quoteBattleResolution(input).result, 'traitor');
const explosion = fixture(), cards = catalog(), shield = cards.find(card => card.kind === 'shield')!, lasgun = cards.find(card => card.kind === 'lasgun')!;
explosion.attacker.hand = [...explosion.attacker.hand, shield]; explosion.attacker.plan.defense = shield.id;
explosion.defender.hand = [lasgun]; explosion.defender.plan.weapon = lasgun.id;
assert.equal(quoteStoneBurnerAllocations(explosion), null);
assert.equal(quoteBattleResolution(explosion).result, 'explosion');
});
void test('invalid totals, unrelated keys, missing modes and unfunded support fail before offering an allocation', () => {
const input = fixture();
const invalid: Record<string, number>[] = [{ a: 1 }, { a: 2.5 }, { d: 3 }, { unrelated: 2 }];
for (const stoneAllocations of invalid)
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations }), BattleResolutionQuoteError);
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations: Object.create({ a: 3 }) as Record<string, number> }), /original side players/);
input.attacker.spice = 0;
assert.throws(() => quoteStoneBurnerAllocations(input), /funded/);
input.attacker.spice = 10; delete input.attacker.stoneMode;
assert.throws(() => quoteStoneBurnerAllocations(input), /revealed mode/);
});
void test('new-battle precommit opt-in removes only the old timing boundary and retains invalid-force/dial/zero-spice plan checks', () => {
const input = fixture();
assert.ok(stoneBurnerPlanBlock(input.attacker.forces, 3, 1, input.defender.forces, 'attacker'));
assert.equal(stoneBurnerPlanBlock(input.attacker.forces, 3, 1, input.defender.forces, 'attacker', 'attacker', undefined, true), null);
assert.ok(stoneBurnerPlanBlock(input.attacker.forces, 99, 1, input.defender.forces, 'attacker', 'attacker', undefined, true));
assert.ok(stoneBurnerPlanBlock({ ...input.attacker.forces, normal: -1 }, 3, 1, input.defender.forces, 'attacker', 'attacker', undefined, true));
assert.ok(stoneBurnerCompulsionBlock(ordinary(0), emperor, 'attacker'));
assert.equal(stoneBurnerCompulsionBlock(ordinary(0), emperor, 'attacker', 'attacker', undefined, true), null);
});