import test from 'node:test'; import assert from 'node:assert/strict'; import { casualtyOptions, type CombatForces } from '../game/combat'; import { stoneBurnerComparison, stoneBurnerPlanBlock, stoneBurnerCompulsionBlock, stoneBurnerWithdrawalContext, type StoneBurnerSide, } from '../game/stone-burner'; const ordinary = (normal: number, freeSupport = true): CombatForces => ({ normal, elite: 0, eliteStrength: 1, freeSupport, }); const emperor: CombatForces = { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false, }; void test('printed Emperor dial3 support1 yields both three and one undialed tokens and can change the winner', () => { assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0), { winner: null, attacker: [1, 2, 3], defender: [2], }); assert.match( stoneBurnerPlanBlock(emperor, 3, 1, ordinary(3), 'attacker')!, /different legal force allocations/, ); }); void test('different physical allocations remain legal when every pair gives the same winner, including tie boundaries', () => { assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(1), 1, 0), { winner: 'attacker', attacker: [1, 2, 3], defender: [0], }); assert.equal( stoneBurnerComparison(emperor, 3, 1, ordinary(5), 0, 0).winner, 'defender', ); assert.equal( stoneBurnerComparison(emperor, 3, 1, ordinary(1), 0, 0).winner, 'attacker', ); assert.equal( stoneBurnerComparison(emperor, 3, 1, ordinary(1), 0, 0, 'defender').winner, null, ); assert.equal( stoneBurnerPlanBlock(emperor, 3, 1, ordinary(0), 'attacker'), null, ); }); void test('Basic Ix mixed units use physical tokens while Advanced ordinary support does not create spurious ambiguity', () => { const ix: CombatForces = { normal: 6, elite: 2, eliteStrength: 2, normalFixedHalf: true, freeSupport: true, }; assert.deepEqual(stoneBurnerComparison(ix, 2, 0, ordinary(7), 1, 0), { winner: null, attacker: [4, 7], defender: [6], }); assert.match( stoneBurnerPlanBlock(ix, 2, 0, ordinary(7), 'attacker')!, /allocation timing/, ); assert.deepEqual( stoneBurnerComparison(ordinary(10, false), 4, 2, ordinary(6, false), 2, 1), { winner: 'attacker', attacker: [4], defender: [3] }, ); assert.equal( stoneBurnerPlanBlock( ordinary(10, false), 4, 2, ordinary(6, false), 'attacker', ), null, ); }); void test('aggressor identity determines physical ties independently of card-holder side', () => { for (const aggressor of ['attacker', 'defender'] as const) { assert.equal( stoneBurnerComparison(ordinary(5), 3, 0, ordinary(3), 1, 0, aggressor) .winner, aggressor, ); for (const side of ['attacker', 'defender'] as const) assert.equal( stoneBurnerPlanBlock(ordinary(5), 3, 0, ordinary(3), side, aggressor), null, ); } assert.equal( stoneBurnerComparison(ordinary(1), 0, 0, emperor, 3, 1, 'defender').winner, 'defender', ); assert.equal( stoneBurnerComparison(ordinary(1), 0, 0, emperor, 3, 1, 'attacker').winner, null, ); }); void test('preflight matches an exhaustive public opposing-plan oracle for all force modes and both roles', () => { for (const freeSupport of [true, false]) for (const normalFixedHalf of [true, false]) for (const eliteStrength of [1, 2] as const) { const opponent: CombatForces = { normal: 3, elite: 2, eliteStrength, freeSupport, normalFixedHalf, }; const own = ordinary(4, false); for (const side of ['attacker', 'defender'] as const) for (const aggressor of ['attacker', 'defender'] as const) { let ambiguous = false; for (let halfDial = 0; halfDial <= 14; halfDial++) for (let support = 0; support <= 5; support++) { if (!casualtyOptions(opponent, halfDial / 2, support).length) continue; const comparison = side === 'attacker' ? stoneBurnerComparison( own, 2, 0, opponent, halfDial / 2, support, aggressor, ) : stoneBurnerComparison( opponent, halfDial / 2, support, own, 2, 0, aggressor, ); if (comparison.winner === null) ambiguous = true; } assert.equal( stoneBurnerPlanBlock(own, 2, 0, opponent, side, aggressor) !== null, ambiguous, JSON.stringify({ opponent, side, aggressor }), ); } } }); void test('no hidden budget or selected opponent plan enters preflight and cached repeated JSON inputs remain immutable', () => { const own = ordinary(3), opponent = { ...emperor }; const original = structuredClone({ own, opponent }); const block = stoneBurnerPlanBlock(own, 1, 0, opponent, 'attacker'); assert.ok(block); for (let i = 0; i < 10; i++) assert.equal( stoneBurnerPlanBlock( JSON.parse(JSON.stringify(own)), 1, 0, JSON.parse(JSON.stringify(opponent)), 'attacker', ), block, ); const comparison = stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0); comparison.attacker.push(999); assert.deepEqual( stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0).attacker, [1, 2, 3], ); assert.deepEqual({ own, opponent }, original); }); void test('invalid plans, malformed restored pools and unsupported huge token counts fail closed without enumeration', () => { for (const [dial, support] of [ [NaN, 0], [Infinity, 0], [-1, 0], [0.25, 0], [2, -1], [2, 0.5], [2, NaN], [2, 4], [9, 0], ]) { assert.equal( stoneBurnerComparison(ordinary(3), dial, support, ordinary(3), 0, 0) .winner, null, ); assert.match( stoneBurnerPlanBlock( ordinary(3), dial, support, ordinary(3), 'attacker', )!, /legal Stone Burner force allocation/, ); } for (const patch of [ { normal: -1 }, { elite: 0.5 }, { normal: Number.MAX_SAFE_INTEGER }, { normal: 21 }, { eliteStrength: 3 }, { freeSupport: undefined }, { normalFixedHalf: 'yes' }, ]) { const bad = { ...ordinary(3), ...patch } as CombatForces; assert.deepEqual(stoneBurnerComparison(bad, 0, 0, ordinary(1), 0, 0), { winner: null, attacker: [], defender: [1], }); assert.match( stoneBurnerPlanBlock(ordinary(3), 0, 0, bad, 'attacker')!, /physical force pools/, ); } assert.equal( stoneBurnerComparison( ordinary(1), 0, 0, ordinary(1), 0, 0, 'bad' as StoneBurnerSide, ).winner, null, ); assert.match( stoneBurnerPlanBlock( ordinary(1), 0, 0, ordinary(1), 'bad' as StoneBurnerSide, )!, /roles/, ); }); void test('public compulsion matches existence of a supported zero-spice plan without assuming any hidden budget', () => { for (const own of [ordinary(0), ordinary(2), ordinary(4, false), emperor]) for (const opponent of [ ordinary(2), emperor, { normal: 6, elite: 2, eliteStrength: 2, normalFixedHalf: true, freeSupport: true, } as CombatForces, ]) for (const side of ['attacker', 'defender'] as const) for (const aggressor of ['attacker', 'defender'] as const) { const any = Array.from( { length: own.normal * 2 + own.elite * 4 + 1 }, (_, halfDial) => halfDial / 2, ).some( (dial) => casualtyOptions(own, dial, 0).length && stoneBurnerPlanBlock(own, dial, 0, opponent, side, aggressor) === null, ); assert.equal( stoneBurnerCompulsionBlock(own, opponent, side, aggressor) === null, any, ); } assert.equal( stoneBurnerCompulsionBlock( ordinary(4, false), ordinary(4, false), 'attacker', ), null, ); assert.match( stoneBurnerCompulsionBlock(ordinary(0), emperor, 'attacker')!, /no supported zero-spice plan/, ); assert.equal( stoneBurnerCompulsionBlock(ordinary(0), emperor, 'defender'), null, ); assert.match( stoneBurnerCompulsionBlock( { ...ordinary(1), normal: 21 }, emperor, 'attacker', )!, /physical force pools/, ); }); void test('post-withdrawal comparison resolves original typed ambiguity without skipping original plan legality', () => { assert.equal(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0).winner, null); const context = stoneBurnerWithdrawalContext(undefined, 'attacker'); assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0, 'attacker', context), { winner: 'defender', attacker: [0], defender: [2], }); assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(3), 'attacker', 'defender', context), null); for (const [dial, support] of [[9, 0], [3, 7], [0.25, 0], [0, 1]]) { assert.deepEqual(stoneBurnerComparison(emperor, dial, support, ordinary(1), 0, 0, 'attacker', context).attacker, []); assert.notEqual(stoneBurnerPlanBlock(emperor, dial, support, ordinary(1), 'attacker', 'attacker', context), null); } }); void test('both returned undialed pools tie at zero for the actual aggressor', () => { const context = stoneBurnerWithdrawalContext( stoneBurnerWithdrawalContext(undefined, 'attacker'), 'defender', ); for (const aggressor of ['attacker', 'defender'] as const) assert.deepEqual(stoneBurnerComparison(ordinary(5), 3, 0, ordinary(3), 1, 0, aggressor, context), { winner: aggressor, attacker: [0], defender: [0], }); }); void test('active Occupy withdrawal removes only the named owner contribution and preserves the original fixed dial', () => { const original = { attacker: { fixedEcazDial: 3, ecazUndialed: 2 } }; const before = structuredClone(original); const ecaz = stoneBurnerWithdrawalContext(original, 'attacker', { canceled: false, cardUser: 'ecaz', ecaz: 'ecaz', }); const variable = stoneBurnerWithdrawalContext(original, 'attacker', { canceled: false, cardUser: 'ally', ecaz: 'ecaz', }); assert.deepEqual(stoneBurnerComparison(emperor, 6, 1, ordinary(3), 1, 0, 'attacker', ecaz).attacker, [1, 2, 3]); assert.deepEqual(stoneBurnerComparison(emperor, 6, 1, ordinary(3), 1, 0, 'attacker', variable).attacker, [2]); assert.deepEqual(stoneBurnerComparison(emperor, 2, 0, ordinary(3), 1, 0, 'attacker', variable).attacker, []); const canceled = stoneBurnerWithdrawalContext(undefined, 'attacker', { canceled: true, cardUser: 'ecaz', ecaz: 'ecaz', }); assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0, 'attacker', canceled).attacker, [0]); assert.deepEqual(original, before); }); void test('public possible opposing Ecaz withdrawal checks both scenarios without conflating their winners or cached totals', () => { for (const side of ['attacker', 'defender'] as const) { const opposingSide = side === 'attacker' ? 'defender' : 'attacker'; const fixed = { fixedEcazDial: 4, ecazUndialed: 4 }; const context = { [opposingSide]: fixed }; assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, context), null); assert.notEqual(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, { [opposingSide]: { ...fixed, possibleEcazWithdrawal: true }, }), null); assert.notEqual(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, stoneBurnerWithdrawalContext(context, opposingSide, { canceled: false, cardUser: 'ecaz', ecaz: 'ecaz' }), ), null); assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, stoneBurnerWithdrawalContext(context, opposingSide), ), null); // Populate/use the original cache again after the actual withdrawal modes. assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, context), null); // A revealed return can change the winner, but neither scenario has an // unresolved physical allocation when both sides have deterministic totals. assert.equal(stoneBurnerPlanBlock(ordinary(2), 0, 0, ordinary(0), side, side, { [opposingSide]: { ...fixed, possibleEcazWithdrawal: true }, }), null); } });