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.
204 lines
13 KiB
TypeScript
204 lines
13 KiB
TypeScript
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');
|
||
});
|