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.
317 lines
19 KiB
TypeScript
317 lines
19 KiB
TypeScript
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);
|
||
});
|