Files
Dune/tests/stone-harass-runtime.test.ts
T
Quintine 19e554666e Admit Mirror and Stone in Basic native E3 battles
Reuse original copied-weapon and typed coalition accounting for bare Basic
Ecaz/Moritani and selected Ix. Preserve optional-module guards and physical
card identity, actual force-owner commitments and native continuations.
2026-10-24 16:34:19 +11:00

317 lines
19 KiB
TypeScript
Raw 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 test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, type Action, type Game } from '../game/engine';
import {
HARASS_CARD, MIRROR_CARD, STONE_CARD, finishStoneHarassBattle, holdStoneHarassTraitor,
sealStoneHarassPlans, stoneHarassFixture, stoneHarassOccupyFixture, stoneHarassPlayer,
} from './fixture-stone-harass';
const seat = (game: Game, id: string) => stoneHarassPlayer(game, id);
const territoryForces = (game: Game, id: string, territory: string) =>
Object.entries(seat(game, id).forces)
.filter(([key]) => key.startsWith(`${territory}:`))
.reduce((sum, [, amount]) => sum + amount, 0);
const discardCount = (game: Game, id: string) =>
game.discard.filter(card => card.id === id).length;
const handHas = (game: Game, id: string, card: string) =>
seat(game, id).hand.some(held => held.id === card);
const leaderState = (game: Game, id: string, leader: string) =>
seat(game, id).leaders.find(candidate => candidate.id === leader)!;
const planOf = (plans: readonly { actor: string; action: Action }[], actor: string) =>
plans.find(step => step.actor === actor)!.action;
void test('Stone Burner compares the counters the mandatory Harass return leaves, reversing the winner', () => {
for (const advanced of [false, true]) {
const options = { advanced, harass: 'defender' as const, stone: 'opponent' as const,
attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0 };
const plain = stoneHarassFixture({ ...options, playHarass: false });
const plainDone = finishStoneHarassBattle(sealStoneHarassPlans(plain));
assert.equal(plainDone.lastBattleContext?.winner, 'd',
'without the Harass commitment the defender’s five undialed counters decide the ordinary Stone battle');
assert.equal(territoryForces(plainDone, 'd', plain.territory), 5);
assert.equal(seat(plainDone, 'd').tanks, 0);
assert.equal(seat(plainDone, 'a').tanks, 5, 'the defeated attacker loses its whole committed army');
assert.equal(territoryForces(plainDone, 'a', plain.territory), 0);
assert.ok(handHas(plainDone, 'd', HARASS_CARD), 'an unused Harass card stays in its owner’s hand');
assert.equal(discardCount(plainDone, HARASS_CARD), 0);
const harassed = stoneHarassFixture(options);
const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
assert.equal(done.lastBattleContext?.winner, 'a',
'the same orders reverse once the mandatory return leaves the defender no physical counter');
assert.equal(seat(done, 'd').reserves, 20, 'every undialed defender counter returns to its reserves');
assert.equal(territoryForces(done, 'd', harassed.territory), 0);
assert.equal(seat(done, 'd').tanks, 0, 'returned counters are never casualties');
assert.equal(seat(done, 'a').tanks, 1, 'the winner still loses the counter it dialed');
assert.equal(territoryForces(done, 'a', harassed.territory), 4);
assert.ok(handHas(done, 'a', STONE_CARD), 'the winning attacker retains its physical Stone Burner');
assert.equal(discardCount(done, HARASS_CARD), 1,
'the used Harass & Withdraw card is discarded exactly once');
assert.equal(discardCount(done, STONE_CARD), 0);
}
});
void test('Stone Burner zero/zero comparison awards the aggressor tie to the native attacker', () => {
for (const advanced of [false, true]) {
const fixture = stoneHarassFixture({ advanced, harass: 'defender', stone: 'opponent',
attackerForces: 5, defenderForces: 5, attackerDial: 5, defenderDial: 0 });
const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture));
assert.equal(done.lastBattleContext?.winner, 'a',
'with both sides at zero undialed counters the native attacker wins the tie');
assert.equal(seat(done, 'a').tanks, 5, 'the winning attacker still loses every counter it dialed');
assert.equal(territoryForces(done, 'a', fixture.territory), 0);
assert.equal(seat(done, 'd').reserves, 20);
assert.equal(territoryForces(done, 'd', fixture.territory), 0);
assert.equal(seat(done, 'd').tanks, 0);
assert.equal(discardCount(done, HARASS_CARD), 1);
}
});
void test('Stone mode preserves Harass returns while deciding native leader deaths and bounty', () => {
for (const advanced of [false, true]) {
const options = { advanced, harass: 'attacker' as const, stone: 'opponent' as const,
attackerForces: 5, defenderForces: 5, attackerDial: 2, defenderDial: 0 };
const ignoredFixture = stoneHarassFixture({ ...options, mode: 'ignore' });
const ignored = finishStoneHarassBattle(sealStoneHarassPlans(ignoredFixture), { mode: 'ignore' });
assert.equal(ignored.lastBattleContext?.winner, 'd');
assert.equal(seat(ignored, 'a').tanks, 2);
assert.equal(seat(ignored, 'a').reserves, 18);
assert.equal(discardCount(ignored, HARASS_CARD), 1);
assert.equal(leaderState(ignored, ignoredFixture.attacker, ignoredFixture.leaders.a).dead, false,
'an ignored Stone keeps both participating leaders alive');
assert.equal(leaderState(ignored, ignoredFixture.defender, ignoredFixture.leaders.d).dead, false);
const killedFixture = stoneHarassFixture({ ...options, mode: 'kill' });
const killed = finishStoneHarassBattle(sealStoneHarassPlans(killedFixture), { mode: 'kill' });
assert.equal(killed.lastBattleContext?.winner, killedFixture.defender);
assert.equal(leaderState(killed, killedFixture.attacker, killedFixture.leaders.a).dead, true,
'a killing Stone kills both participating leaders');
assert.equal(leaderState(killed, killedFixture.defender, killedFixture.leaders.d).dead, true);
assert.equal(leaderState(killed, killedFixture.attacker, killedFixture.leaders.a).deaths, 1);
assert.equal(leaderState(killed, killedFixture.defender, killedFixture.leaders.d).deaths, 1);
assert.equal(seat(killed, killedFixture.defender).spice, seat(ignored, ignoredFixture.defender).spice +
leaderState(killedFixture.game, killedFixture.attacker, killedFixture.leaders.a).strength +
leaderState(killedFixture.game, killedFixture.defender, killedFixture.leaders.d).strength,
'the winner collects the bounty of both killed leaders');
assert.equal(seat(killed, 'a').reserves, 18);
assert.equal(seat(killed, 'a').tanks, 2);
assert.equal(discardCount(killed, HARASS_CARD), 1);
}
});
void test('a same-plan Stone and Harass commitment returns its owner’s counters before the comparison', () => {
for (const advanced of [false, true]) {
const options = { advanced, harass: 'defender' as const, stone: 'harass' as const,
attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0 };
const plain = finishStoneHarassBattle(sealStoneHarassPlans(
stoneHarassFixture({ ...options, playHarass: false })));
assert.equal(plain.lastBattleContext?.winner, 'd',
'the same physical Stone plan wins while its undialed counters still stand');
assert.equal(territoryForces(plain, 'd', 'arrakeen'), 5);
assert.equal(seat(plain, 'd').tanks, 0);
const harassed = stoneHarassFixture(options);
const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
assert.equal(done.lastBattleContext?.winner, 'a');
assert.equal(seat(done, 'd').reserves, 20);
assert.equal(territoryForces(done, 'd', harassed.territory), 0);
assert.equal(discardCount(done, HARASS_CARD), 1);
assert.equal(discardCount(done, STONE_CARD), 1,
'the same plan’s physical Stone is discarded with the defeated side');
}
});
void test('a Mirror Weapon copying the opposing Stone keeps the copied comparison and its own retention', () => {
for (const advanced of [false, true]) {
const options = { advanced, harass: 'attacker' as const, stone: 'opponent' as const, mirror: true,
attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 5 };
const fixture = stoneHarassFixture(options);
assert.equal(fixture.mirrorOwner, 'a');
const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture),
{ modes: { a: 'ignore', d: 'ignore' } });
assert.equal(done.lastBattleContext?.winner, 'a',
'the copied Stone uses the zero/zero aggressor tie after Harass returns the copier’s undialed counters');
assert.ok(handHas(done, 'a', MIRROR_CARD), 'the winning Mirror keeps its own physical card');
assert.equal(seat(done, 'a').reserves, 19);
assert.equal(seat(done, 'a').tanks, 1);
assert.equal(discardCount(done, HARASS_CARD), 1);
assert.equal(discardCount(done, MIRROR_CARD), 0);
assert.equal(discardCount(done, STONE_CARD), 1, 'the defeated original Stone is discarded');
assert.equal(leaderState(done, 'a', fixture.leaders.a).dead, false);
const killingFixture = stoneHarassFixture(options);
const killing = finishStoneHarassBattle(sealStoneHarassPlans(killingFixture),
{ modes: { a: 'kill', d: 'ignore' } });
assert.equal(leaderState(killing, 'a', killingFixture.leaders.a).dead, true,
'the independent Mirror mode kills both participating leaders');
assert.equal(leaderState(killing, 'd', killingFixture.leaders.d).dead, true);
}
});
void test('legacy unstamped Stone precommit stays public before and after hidden opposing plans', () => {
const messages: string[] = [];
for (const hiddenDial of [1, 5]) {
const ambiguous = stoneHarassFixture({ advanced: true, harass: 'defender', stone: 'opponent',
attackerForces: 2, attackerElite: 1, attackerDial: 2, attackerSupport: 1,
defenderForces: 5, defenderDial: hiddenDial });
// Retain the old unstamped guard here. Fresh v1 battles instead collect
// their actual physical-total commitments after revealed weapon choices.
delete ambiguous.game.battle!.stoneAllocationVersion;
const stonePlan = planOf(ambiguous.plans, 'a');
const hiddenPlan = planOf(ambiguous.plans, 'd');
const before = { spice: seat(ambiguous.game, 'a').spice, reserves: seat(ambiguous.game, 'a').reserves,
hand: seat(ambiguous.game, 'a').hand.map(card => card.id) };
const attempt = (state: Game) => {
try {
applyAction(state, 'a', stonePlan);
assert.fail('an allocation-dependent Stone commitment was accepted');
} catch (error) {
assert.match(String(error), /change the winner/);
return String(error);
}
};
const first = attempt(ambiguous.game);
assert.equal(ambiguous.game.battle?.plans.a, undefined,
'a rejected Stone commitment never becomes a hidden sealed plan');
assert.deepEqual({ spice: seat(ambiguous.game, 'a').spice,
reserves: seat(ambiguous.game, 'a').reserves,
hand: seat(ambiguous.game, 'a').hand.map(card => card.id) }, before);
const sealed = applyAction(ambiguous.game, 'd', hiddenPlan);
assert.notEqual(sealed.battle?.plans.d, undefined, 'the unrelated hidden plan seals natively');
assert.equal(attempt(sealed), first,
'the same commitment is rejected identically whatever the hidden opposing plan holds');
messages.push(first);
const legal = stoneHarassFixture({ advanced: true, harass: 'defender', stone: 'opponent',
attackerForces: 2, attackerElite: 1, attackerDial: 0, defenderForces: 5, defenderDial: hiddenDial });
const accepted = applyAction(legal.game, 'd', planOf(legal.plans, 'd'));
const committed = applyAction(accepted, 'a', planOf(legal.plans, 'a'));
assert.notEqual(committed.battle?.plans.a, undefined,
'a physical-commitment-safe Stone plan is accepted regardless of the same hidden opposing plans');
}
assert.equal(messages[0], messages[1], 'the public rejection never reads private hands or plans');
});
void test('active Occupy with the fixed Ecaz user returns only the fixed counters, flipping the Stone winner', () => {
const options = { lead: 'ecaz' as const, ecazForces: 5, allyForces: 4, opponentForces: 5, opponentDial: 0 };
const plain = stoneHarassOccupyFixture({ ...options, harass: false });
const plainDone = finishStoneHarassBattle(sealStoneHarassPlans(plain));
assert.equal(plainDone.lastBattleContext?.winner, plain.lead,
'without the return the combined fixed and variable counters decide the battle');
assert.equal(seat(plainDone, plain.opponent).tanks, 5, 'the defeated opponent loses its whole army');
assert.equal(territoryForces(plainDone, plain.ecaz, plain.territory), 2,
'the winning coalition still pays its original fixed Ecaz commitment');
assert.equal(territoryForces(plainDone, plain.ally, plain.territory), 4);
const harassed = stoneHarassOccupyFixture(options);
const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
assert.equal(done.lastBattleContext?.winner, harassed.opponent,
'returning only the fixed Ecaz counters reverses the Stone winner');
assert.equal(seat(done, harassed.ecaz).reserves - seat(harassed.game, harassed.ecaz).reserves, 2,
'the Ecaz card user returns exactly its own undialed counters');
assert.equal(seat(done, harassed.ally).reserves - seat(harassed.game, harassed.ally).reserves, 0,
'the ally’s variable army is untouched by the Ecaz user’s return');
assert.equal(territoryForces(done, harassed.ally, harassed.territory), 0,
'the unchanged ally army is destroyed with the defeated coalition');
assert.equal(discardCount(done, HARASS_CARD), 1);
assert.ok(handHas(done, harassed.opponent, STONE_CARD), 'the winning opponent retains the physical Stone');
});
void test('active Occupy with the variable ally user returns its own army and leaves the fixed counters', () => {
const options = { lead: 'ally' as const, ecazForces: 5, allyForces: 4, opponentForces: 3, opponentDial: 0 };
const plain = stoneHarassOccupyFixture({ ...options, harass: false });
const plainDone = finishStoneHarassBattle(sealStoneHarassPlans(plain));
assert.equal(plainDone.lastBattleContext?.winner, plain.lead);
assert.equal(seat(plainDone, plain.opponent).tanks, 3);
const harassed = stoneHarassOccupyFixture(options);
const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
assert.equal(done.lastBattleContext?.winner, harassed.opponent,
'only the fixed Ecaz counters remain once the ally returns its own variable army');
assert.equal(seat(done, harassed.ally).reserves - seat(harassed.game, harassed.ally).reserves, 4,
'the ally card user returns its own whole undialed army');
assert.equal(seat(done, harassed.ecaz).reserves - seat(harassed.game, harassed.ecaz).reserves, 0,
'the fixed Ecaz commitment is never recomputed into a return');
assert.equal(discardCount(done, HARASS_CARD), 1);
});
void test('the opposing active-Occupy Stone precommit never reads the coalition’s private hand', () => {
const sealed: boolean[] = [];
for (const holdsHarass of [true, false]) {
const fixture = stoneHarassOccupyFixture({ lead: 'ecaz', ecazForces: 5, allyForces: 4,
opponentForces: 5, harass: false });
if (!holdsHarass) {
const hand = seat(fixture.game, fixture.lead).hand;
const index = hand.findIndex(card => card.id === HARASS_CARD);
assert.ok(index >= 0, 'The fixture stages the physical Harass card in the lead’s hand.');
fixture.game.deck.push(hand.splice(index, 1)[0]);
}
const before = { spice: seat(fixture.game, fixture.lead).spice,
reserves: seat(fixture.game, fixture.lead).reserves };
const next = applyAction(fixture.game, fixture.opponent, planOf(fixture.plans, fixture.opponent));
assert.equal(next.battle?.revealed, false, 'the coalition’s own plan is still outstanding');
assert.deepEqual({ spice: seat(next, fixture.lead).spice, reserves: seat(next, fixture.lead).reserves },
before, 'sealing the opposing plan spends nothing of the coalition’s');
sealed.push(next.battle?.plans[fixture.opponent] !== undefined);
}
assert.deepEqual(sealed, [true, true],
'the same opposing Stone commitment is accepted with or without the coalition’s hidden card');
});
void test('a successful opposing traitor call cancels the withdrawal before any counter returns', () => {
for (const advanced of [false, true]) {
const options = { advanced, harass: 'defender' as const, stone: 'opponent' as const,
attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0,
traitorableLeaders: true };
const fixture = stoneHarassFixture(options);
holdStoneHarassTraitor(fixture.game, fixture.attacker, fixture.leaders[fixture.defender]);
const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture), { traitors: [fixture.attacker] });
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(done.lastBattleContext?.winner, fixture.attacker);
assert.equal(seat(done, fixture.defender).reserves, 15,
'an opposing successful call cancels the return entirely');
assert.equal(territoryForces(done, fixture.defender, fixture.territory), 0);
assert.equal(seat(done, fixture.defender).tanks, 5);
assert.equal(leaderState(done, fixture.defender, fixture.leaders[fixture.defender]).dead, true);
assert.equal(discardCount(done, HARASS_CARD), 1,
'the used Harass card is discarded even when its effect was canceled');
}
});
void test('an own successful traitor call leaves the withdrawal standing', () => {
for (const advanced of [false, true]) {
const options = { advanced, harass: 'defender' as const, stone: 'opponent' as const,
attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0,
traitorableLeaders: true };
const fixture = stoneHarassFixture(options);
holdStoneHarassTraitor(fixture.game, fixture.defender, fixture.leaders[fixture.attacker]);
const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture), { traitors: [fixture.defender] });
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(done.lastBattleContext?.winner, fixture.defender);
assert.equal(seat(done, fixture.defender).reserves, 20,
'the withdrawn counters still return for the called traitor’s own side');
assert.equal(territoryForces(done, fixture.defender, fixture.territory), 0);
assert.equal(seat(done, fixture.attacker).tanks, 5);
assert.equal(leaderState(done, fixture.attacker, fixture.leaders[fixture.attacker]).dead, true);
assert.equal(discardCount(done, HARASS_CARD), 1);
}
});
void test('a multi-sector withdrawal uses the native allocation decision and returns only undialed counters', () => {
const fixture = stoneHarassFixture({ advanced: true, harass: 'defender', stone: 'opponent',
attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 2, attackerSupport: 1,
territory: 'imperial_basin', sector: 9, secondSector: 11, split: 2 });
assert.deepEqual(seat(fixture.game, 'd').forces['imperial_basin:11'], 2);
const revealed = sealStoneHarassPlans(fixture);
assert.equal(revealed.decision?.kind, 'harassWithdraw',
'the native allocation decision is offered for a partial multi-sector return');
assert.equal(revealed.decision?.player, fixture.defender);
const done = finishStoneHarassBattle(revealed);
assert.equal(done.lastBattleContext?.winner, fixture.attacker);
assert.equal(seat(done, fixture.defender).reserves, 18, 'only the three undialed counters return');
assert.equal(seat(done, fixture.defender).tanks, 2, 'the two dialed counters are lost with the battle');
assert.equal(territoryForces(done, fixture.defender, fixture.territory), 0);
assert.equal(seat(done, fixture.attacker).tanks, 1);
assert.equal(discardCount(done, HARASS_CARD), 1);
});