Files
Dune/tests/stone-burner-allocation-engine.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

204 lines
13 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 { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
ALLOCATION_MIRROR, ALLOCATION_STONE, actStoneBurnerAllocation, completeStoneBurnerAllocation,
createStoneBurnerAllocationFixture, stoneBurnerAllocationPlayer as player,
type StoneBurnerAllocationFixture,
} from './fixture-stone-burner-allocation';
function genuineSource(f: StoneBurnerAllocationFixture) {
assert.equal(f.beforePlans.battle!.stoneAllocationVersion, 1);
assert.ok(f.acquiredStone.players.find(seat => seat.id === f.owner)!.hand.some(card => card.id === ALLOCATION_STONE));
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.shipments.length, 2);
for (const shipment of f.shipments) {
assert.equal(player(shipment.after, shipment.step.actor).reserves,
player(shipment.before, shipment.step.actor).reserves - Number(shipment.step.action.amount));
assert.equal(player(shipment.after, shipment.step.actor).spice,
player(shipment.before, shipment.step.actor).spice - Number(shipment.step.action.amount),
'Each real off-planet stronghold shipment pays its original printed cost');
}
assert.equal(player(f.beforePlans, f.owner).forces[f.location], 6);
assert.equal(player(f.beforePlans, f.owner).elites!.forces[f.location], f.advanced ? f.ownElite : 2);
assert.equal(f.beforePlans.battle!.plans[f.owner], undefined, 'The new allocation is not an early Battle Plan field');
assert.equal(viewGame(f.revealed, f.owner).battle!.stoneAllocation, null,
'Revealed weapon modes and actual traitor decisions precede the new count commitment');
}
void test('genuine Basic mixed Ix Stone commits either legal physical total before deciding the winner', () => {
const f = createStoneBurnerAllocationFixture();
genuineSource(f);
assert.equal(f.game.decision?.kind, 'stoneAllocation');
assert.equal(f.game.decision?.player, f.owner);
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
assert.deepEqual(offer.totals, [2, 5]);
assert.equal(offer.selected, null);
assert.equal(viewGame(f.game, f.opponent).battle!.stoneAllocation, null,
'The other side receives no pending private commitment values');
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
for (const total of offer.totals) {
const { game, committed } = completeStoneBurnerAllocation(f, total), after = player(game, f.owner);
assert.equal(committed.length, 1);
assert.equal(committed[0].step.action.undialed, total);
assert.equal(game.lastBattleContext!.winner, total === 5 ? f.owner : f.opponent);
assert.equal(after.reserves, before.reserves, 'A count commitment neither spends nor returns a reserve counter');
assert.equal(after.forces['hidden_mobile_stronghold:0'], before.forces['hidden_mobile_stronghold:0']);
assert.equal(after.elites!.forces['hidden_mobile_stronghold:0'], before.elites!.forces['hidden_mobile_stronghold:0']);
assert.equal(after.tanks, before.tanks + (total === 5 ? 1 : 6));
assert.equal(after.elites!.tanks, before.elites!.tanks + (total === 5 ? 1 : 2));
assert.equal(after.forces[f.location] ?? 0, total === 5 ? 5 : 0);
assert.equal(after.elites!.forces[f.location] ?? 0, total === 5 ? 1 : 0);
assert.equal(player(game, f.opponent).tanks, enemy.tanks + (total === 5 ? 4 : 1));
assert.equal(game.discard.filter(card => card.id === ALLOCATION_STONE).length, total === 5 ? 0 : 1);
}
});
void test('genuine Advanced mixed Sardaukar Stone retains three-or-five-counter alternatives and settles the selected physical result', () => {
const f = createStoneBurnerAllocationFixture({ advanced: true });
genuineSource(f);
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
assert.deepEqual(offer.totals, [1, 2, 3]);
assert.equal(f.plans[0].action.dial, 3);
assert.equal(f.plans[0].action.support, 1);
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
for (const total of [1, 3]) {
const { game } = completeStoneBurnerAllocation(f, total), after = player(game, f.owner);
assert.equal(game.lastBattleContext!.winner, total === 3 ? f.owner : f.opponent);
assert.equal(after.tanks, before.tanks + (total === 3 ? 3 : 6));
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
assert.equal(after.forces[f.location] ?? 0, total === 3 ? 3 : 0);
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
assert.equal(player(game, f.opponent).tanks, enemy.tanks + (total === 3 ? 3 : 1));
assert.equal(after.spice, before.spice - 1 + (total === 3 ? 2 : 0),
'Original support is paid once, then the surviving city occupant earns its ordinary Collection income');
for (const plan of f.plans)
assert.equal(player(game, plan.actor).leaders.find(leader => leader.id === plan.action.leader)!.dead, false,
'The genuinely selected ignore mode leaves leaders alive when no other weapon kills them');
}
});
void test('normal and invariant-winner original mixed Ix battles keep their source-backed later typed casualty choice', () => {
for (const comparison of ['normal', 'invariant'] as const) {
const f = createStoneBurnerAllocationFixture({ comparison });
genuineSource(f);
assert.equal(f.game.decision?.kind, 'battleLosses');
assert.equal(f.game.decision?.player, f.owner);
assert.equal(viewGame(f.game, f.owner).battle?.stoneAllocation ?? null, null);
assert.ok(f.actions.every(step => step.action.type !== 'decision' || step.action.undialed === undefined));
const decision = f.game.decision!;
assert.ok(decision.kind === 'battleLosses');
const physical = decision.options.map(option => option.normal + option.elite);
assert.deepEqual([...new Set(physical)].sort((a, b) => a - b), [1, 4]);
for (const index of [0, decision.options.length - 1]) {
const { game, committed } = completeStoneBurnerAllocation(f, undefined, index);
assert.deepEqual(committed, []);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).tanks, player(f.game, f.owner).tanks + physical[index]);
assert.equal(player(game, f.owner).forces[f.location], 6 - physical[index]);
}
}
});
void test('actual paid Stone and next-turn native unbid Mirror share the same count protocol without fabricating either held card', () => {
const f = createStoneBurnerAllocationFixture({ mirror: true });
genuineSource(f);
assert.equal(f.acquiredMirror!.turn, 2);
assert.ok(player(f.acquiredMirror!, f.opponent).hand.some(card => card.id === ALLOCATION_MIRROR));
assert.ok(f.actions.some(step => step.actor === f.opponent && step.action.type === 'decision' && step.action.keep === true));
const choices = f.actions.filter(step => step.action.type === 'decision' && step.action.mode === 'ignore');
assert.equal(choices.length, 2);
assert.ok(choices.some(step => step.actor === f.owner));
assert.ok(choices.some(step => step.actor === f.opponent));
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
assert.deepEqual(offer.totals, [2, 5]);
for (const total of offer.totals) {
const { game } = completeStoneBurnerAllocation(f, total);
assert.equal(game.lastBattleContext!.winner, total === 5 ? f.owner : f.opponent);
assert.equal(game.discard.filter(card => card.id === ALLOCATION_STONE).length, total === 5 ? 0 : 1);
assert.equal(game.discard.filter(card => card.id === ALLOCATION_MIRROR).length, total === 5 ? 1 : 0);
}
});
void test('genuine two-sided mixed Stone counts stay sealed until both commit in either original seating order', () => {
const queues: string[][] = [];
for (const reverseSeats of [false, true]) {
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true, reverseSeats });
genuineSource(f);
const order = f.game.order.filter(actor => actor === f.owner || actor === f.opponent);
queues.push(order);
assert.equal(f.game.decision!.player, order[0]);
assert.equal(f.game.battle!.attacker, order[0]);
assert.deepEqual(viewGame(f.game, f.owner).battle!.stoneAllocation!.totals, [1, 2, 3]);
assert.deepEqual(viewGame(f.game, f.opponent).battle!.stoneAllocation!.totals, [2, 3]);
for (const selection of [{ emperor: 3, ixians: 2 }, { emperor: 1, ixians: 3 }]) {
const { game, committed } = completeStoneBurnerAllocation(f, selection);
assert.equal(committed.length, 2, 'Both original mixed sides commit even after a decisive first extreme');
assert.deepEqual(committed.map(entry => entry.step.actor), order);
const first = committed[0], waiting = first.after, next = order[1];
assert.equal(waiting.decision?.kind, 'stoneAllocation');
assert.equal(waiting.decision?.player, next);
const own = viewGame(waiting, order[0]).battle!.stoneAllocation!, rival = viewGame(waiting, next).battle!.stoneAllocation!;
assert.equal(own.selected, first.step.action.undialed);
assert.equal(rival.selected, null);
assert.deepEqual(Object.keys(rival).sort(), ['event', 'owner', 'player', 'selected', 'totals']);
const messages = waiting.log.slice(first.before.log.length).map(entry => entry.text);
assert.ok(messages.length > 0);
assert.ok(messages.every(message => !/\d/.test(message)), 'The first public commitment message reveals no selected count');
assert.deepEqual(player(waiting, f.owner).forces, player(f.game, f.owner).forces);
assert.deepEqual(player(waiting, f.opponent).forces, player(f.game, f.opponent).forces);
assert.equal(game.lastBattleContext!.winner, selection.emperor > selection.ixians ? f.owner : f.opponent);
const completedMessages = game.log.slice(f.game.log.length).map(entry => entry.text);
for (const actor of [f.owner, f.opponent])
assert.ok(completedMessages.some(message => message.includes(`${player(game, actor).name} ${selection[actor as keyof typeof selection]} undialed`)),
'Only the completed sealed queue publishes both original counts');
}
}
assert.deepEqual(queues, [['emperor', 'ixians'], ['ixians', 'emperor']],
'Actual reversed human joins change the original storm queue without a saved-order edit');
});
void test('all four minimal policies commit legal own counts from both sealed private views before original settlement', () => {
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
let game = structuredClone(f.game);
const owners: string[] = [];
for (let count = 0; count < 2; count++) {
const decision = game.decision; assert.ok(decision?.kind === 'stoneAllocation');
const view = viewGame(game, decision.player), offer = view.battle!.stoneAllocation!;
view.players.find(player => player.id === decision.player)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'decision' && action.event === offer.event &&
typeof action.undialed === 'number'); assert.ok(action, `${difficulty} must answer its current owned count`);
assert.ok(offer.totals.includes(Number(action.undialed)));
assert.equal(offer.selected, null);
owners.push(decision.player);
game = actStoneBurnerAllocation(game, decision.player, action);
}
assert.deepEqual(owners, ['emperor', 'ixians']);
const completed = completeStoneBurnerAllocation({ ...f, game });
assert.ok([f.owner, f.opponent].includes(completed.game.lastBattleContext!.winner!));
}
});
void test('a genuinely committed Stone total retains both later winner choices of ordinary and Sardaukar losses', () => {
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true, ownElite: 2 });
genuineSource(f);
assert.deepEqual(viewGame(f.game, f.owner).battle!.stoneAllocation!.totals, [2, 3, 4]);
const selection = { emperor: 3, ixians: 2 }, first = completeStoneBurnerAllocation(f, selection);
const later = first.committed.at(-1)!.after.decision;
assert.ok(later?.kind === 'battleLosses');
assert.equal(later.player, f.owner);
assert.deepEqual(later.options.map(option => [option.normal, option.elite]), [[2, 1], [1, 2]]);
const eliteLosses: number[] = [];
for (const index of [0, later.options.length - 1]) {
const { game } = completeStoneBurnerAllocation(f, selection, index), before = player(f.game, f.owner), after = player(game, f.owner);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(after.tanks, before.tanks + 3);
assert.equal(after.forces[f.location], 3);
assert.equal(after.elites!.tanks, before.elites!.tanks + later.options[index].elite);
eliteLosses.push(after.elites!.tanks - before.elites!.tanks);
}
assert.deepEqual(eliteLosses, [1, 2], 'The count commitment never preselects the winner’s typed physical loss mix');
});