Compose Harass withdrawal with provisional Stone Burner timing

This commit is contained in:
Quintine committed 2026-10-16 06:35:04 +11:00
1 parent 5d13374f7e
commit feb2b03251
23 files changed
+1362 -124

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
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('Stone Burner precommit is public: the same commitment is decided 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 });
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);
});