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

+90
View File
@@ -14,6 +14,11 @@ import {
type HarassWithdrawContext,
type HarassWithdrawSelection,
} from '../game/harass-withdraw';
import { leaders } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { quoteBattleResolution, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
import type { StoneBurnerSide } from '../game/stone-burner';
const native = (normal: number): CombatForces => ({
normal,
@@ -194,3 +199,88 @@ void test('owner and funding boundary rejects invalid total plans before quoting
};
assert.throws(() => quoteHarassWithdraw(canceledWithFixed, 4, 1), HarassWithdrawError);
});
function stoneResolution(p: EcazOccupyBattleProfile, slot: StoneBurnerSide, otherUndialed: number): BattleResolutionInput {
const side = (id: string, faction: 'ecaz' | 'ixians' | 'guild', forces: CombatForces, dial: number): ResolutionCombatant => {
const leader = { ...leaders(faction)[0], strength: 0 };
return { id, faction, forces, leader, spice: 10, hand: [],
plan: { dial, support: 1, leader: leader.id, weapon: null, defense: null } };
};
const own = side(p.lead, p.lead === p.ecaz ? 'ecaz' : 'ixians', p.forces, p.fixedEcazDial + 1);
own.ally = p.lead === p.ecaz ? p.ally : p.ecaz;
const other = side('opponent', 'guild', native(otherUndialed + 1), 1);
const stone = richeseCards().find(card => card.effect === 'stoneBurner')!;
const harass = ecazTreacheryCards().find(card => card.effect === 'harassWithdraw')!;
own.hand = [stone, harass];
own.plan.weapon = stone.id;
own.plan.defense = harass.id;
own.stoneMode = 'ignore';
own.harassWithdraw = contextFor(p);
return {
ecazOccupy: p, advanced: true, turn: 2, territory: 'carthag', aggressor: p.lead,
attacker: slot === 'attacker' ? own : other,
defender: slot === 'defender' ? own : other,
participants: [
{ id: p.ecaz, faction: 'ecaz', ally: p.ally },
{ id: p.ally, faction: 'ixians', ally: p.ecaz },
{ id: other.id, faction: other.faction },
],
voters: [p.ecaz, p.ally].map(id => ({ id, beneficiary: p.lead, called: false, traitors: [] as string[] }))
.concat([{ id: other.id, beneficiary: other.id, called: false, traitors: [] }]),
physicalCards: [stone, harass], pendingAuditorPresent: false, pendingRetentionPresent: false,
};
}
void test('Stone and active Occupy Harass remove only the Ecaz or variable card user undialed pool while preserving original fixed costs', () => {
for (const ecazNormal of [5, 6])
for (const lead of ['ecaz-user', 'ally-user'])
for (const slot of ['attacker', 'defender'] as const) {
const p = profile(ecazNormal, lead);
const remainingUndialed = lead === p.ecaz ? 3 : Math.floor(ecazNormal / 2);
const input = stoneResolution(p, slot, remainingUndialed);
const before = structuredClone(input);
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, p.lead);
assert.deepEqual(quote.stone, {
winner: slot, attacker: [remainingUndialed], defender: [remainingUndialed],
});
assert.deepEqual(quote.harassWithdraw?.[0].returned, {
normal: lead === p.ecaz ? Math.floor(ecazNormal / 2) : 3, elite: 0,
});
assert.deepEqual(quote.fixedLosses, [{ owner: p.ecaz, normal: Math.ceil(ecazNormal / 2), elite: 0 }]);
assert.equal(quote.casualties?.owner, p.ally);
assert.equal(quote.casualties?.fullyCommitted, lead === p.ally ? true : undefined);
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
assert.deepEqual(quote.payments.map(payment => payment.ownPayment), [1, 1]);
assert.deepEqual(quote.winnerCards, [input[slot].plan.weapon]);
assert.deepEqual(quote.discarded, [{ player: p.lead, card: input[slot].plan.defense }]);
assert.deepEqual(input, before);
// The opposite successful call cancels Harass before comparing physical
// remainders, without reducing either original Occupy contribution.
const voter = input.voters.find(voter => voter.id === 'opponent')!;
voter.called = true;
voter.traitors = [input[slot].plan.leader!];
const canceled = quoteBattleResolution(input);
assert.equal(canceled.winner, 'opponent');
assert.equal(canceled.harassWithdraw, undefined);
assert.deepEqual(canceled.stone?.[slot], [Math.floor(ecazNormal / 2) + 3]);
assert.deepEqual(canceled.fixedLosses, []);
}
});
void test('canceled Occupy Harass uses the selected lead native pool with no fixed remainder or recomputed commitment', () => {
for (const lead of ['ecaz-user', 'ally-user'])
for (const slot of ['attacker', 'defender'] as const) {
const p = profile(5, lead, true);
const input = stoneResolution(p, slot, 0);
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, p.lead);
assert.deepEqual(quote.stone, { winner: slot, attacker: [0], defender: [0] });
assert.deepEqual(quote.harassWithdraw?.[0].returned, { normal: p.forces.normal - 1, elite: 0 });
assert.deepEqual(quote.fixedLosses, []);
assert.equal(quote.casualties?.owner, p.lead);
assert.equal(quote.casualties?.forces.normal, 1);
assert.equal(quote.casualties?.fullyCommitted, true);
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
}
});
+3 -3
View File
@@ -5,7 +5,7 @@ import { richeseCards } from '../game/richese-cards';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from '../game/combat';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from '../game/ecaz-occupy-battle';
import { quoteBattleResolution, BattleResolutionQuoteError, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { stoneBurnerComparison, stoneBurnerPlanBlock, stoneBurnerCompulsionBlock, type StoneBurnerContext, type StoneBurnerOccupyContext, type StoneBurnerSide } from '../game/stone-burner';
import { stoneBurnerComparison, stoneBurnerPlanBlock, stoneBurnerCompulsionBlock, type StoneBurnerContext, type StoneBurnerOccupyContext, type StoneBurnerSideContext, type StoneBurnerSide } from '../game/stone-burner';
const ordinary = (normal: number): CombatForces => ({
normal, elite: 0, eliteStrength: 1, freeSupport: false,
@@ -79,12 +79,12 @@ void test('public hypothetical native pools remain authoritative under the same
assert.deepEqual(stoneBurnerComparison(ordinary(4), 0, 0, concealedNormals, 3, 0, 'attacker', context).defender, [9]);
});
function physicalChoices(pool: CombatForces, dial: number, support: number, p?: StoneBurnerOccupyContext): number[] {
function physicalChoices(pool: CombatForces, dial: number, support: number, p?: StoneBurnerSideContext): number[] {
const offset = p?.ecazUndialed ?? 0;
return casualtyOptions(pool, dial - (p?.fixedEcazDial ?? 0), support)
.map(loss => offset + pool.normal + pool.elite - loss.normal - loss.elite);
}
function publicPlans(pool: CombatForces, p?: StoneBurnerOccupyContext): { dial: number; support: number; totals: number[] }[] {
function publicPlans(pool: CombatForces, p?: StoneBurnerSideContext): { dial: number; support: number; totals: number[] }[] {
const plans: { dial: number; support: number; totals: number[] }[] = [];
for (let half = 0; half <= maxCombatDial(pool) * 2; half++)
for (let support = 0; support <= maxCombatSupport(pool); support++) {
+2 -1
View File
@@ -72,7 +72,8 @@ function nativeStep(game: Game): Game {
}
if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, {type: 'passBid'});
if (game.phase === 5) return applyAction(game, game.active!, {type: 'endMovement'});
if (game.phase !== 6 || !game.active) {
// A native Richese lot can own Bidding without an ordinary auction row.
if (game.phase !== 3 && (game.phase !== 6 || !game.active)) {
const seat = game.players.find(p => !game.ready.includes(p.id));
if (seat) return applyAction(game, seat.id, {type: 'ready'});
}
+352
View File
@@ -0,0 +1,352 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck, type Card } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { defaultHarassWithdrawAllocation, type HarassWithdrawSelection } from '../game/harass-withdraw';
import { finishHarassPreparation, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
import {
chooseEcazOccupyLead, ecazOccupyFixture, openEcazOccupyPlans, type EcazOccupyFixture,
} from './fixture-ecaz-occupy';
import { finishOccupyCardsAftermath } from './fixture-ecaz-occupy-cards';
export const HARASS_CARD = 'ecaz-harass-withdraw';
export const STONE_CARD = 'richese-stone-burner';
export const MIRROR_CARD = 'richese-mirror-weapon';
export type StoneHarassMode = 'kill' | 'ignore';
export type StoneHarassSide = 'attacker' | 'defender';
export type StoneHarassStep = { actor: string; action: Action };
export function stoneHarassPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, 'The genuine setup must contain this physical owner.');
return player;
}
/** Move one original physical face, including the separate native Richese cache. */
export function takeStoneHarassCard(game: Game, owner: string, id: string): Card {
const player = stoneHarassPlayer(game, owner);
const held = player.hand.find(card => card.id === id);
if (held) return held;
for (const zone of [game.richeseCache ?? [], game.deck, game.discard,
...game.players.filter(candidate => candidate.id !== owner).map(candidate => candidate.hand)]) {
const index = zone.findIndex(card => card.id === id);
if (index < 0) continue;
const [card] = zone.splice(index, 1);
player.hand.push(card);
return card;
}
throw new Error(`Missing original physical Stone/Harass fixture card ${id}`);
}
/** Exchange one original held Traitor Card for another real identity. */
export function holdStoneHarassTraitor(game: Game, owner: string, identity: string): void {
const player = stoneHarassPlayer(game, owner);
if (player.traitors.includes(identity)) return;
assert.ok(player.traitors[0], 'An original Traitor Card must fund this exchange.');
const reserve = game.traitorReserve;
const index = reserve?.indexOf(identity) ?? -1;
if (reserve && index >= 0) {
reserve[index] = player.traitors[0];
player.traitors[0] = identity;
return;
}
const donor = game.players.find(candidate => candidate.id !== owner && candidate.traitors.includes(identity));
assert.ok(donor, 'A real non-loyal Traitor Card must exist in the original source.');
const donorIndex = donor.traitors.indexOf(identity);
donor.traitors[donorIndex] = player.traitors[0];
player.traitors[0] = identity;
}
/** Physical conservation across the native Richese cache and every ordinary zone. */
export function stoneHarassCustody(game: Game): void {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...(game.ixSetupCards ?? []),
...game.players.flatMap(player => player.hand)];
assert.deepEqual(cards.map(card => card.id).sort(),
[...treacheryDeck(game.expansions), ...ecazTreacheryCards(), ...richeseCards()]
.map(card => card.id).sort(),
'The original card family and the Richese cache must each retain every physical face.');
assert.deepEqual([
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []),
...(game.traitorReserve ?? []),
...game.players.flatMap(player => [...player.traitors,
...(player.faceDancers ?? []).map(card => card.leader)]),
].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
for (const player of game.players) {
assert.equal(player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
.every(amount => Number.isSafeInteger(amount) && amount >= 0));
if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0),
player.faction === 'emperor' ? 5 : player.faction === 'ixians' ? 7 : 3);
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
for (const [key, amount] of Object.entries(player.elites.forces))
assert.ok(amount <= (player.forces[key] ?? 0));
}
}
}
/** Every original physical traitor identity currently in play. */
export function traitorIdentities(game: Game): Set<string> {
return new Set([...(game.traitorReserve ?? []),
...game.players.flatMap(player => [...player.traitors,
...(player.faceDancers ?? []).map(card => card.leader)])]);
}
function restage(game: Game, id: string, total: number, location: string): void {
const player = stoneHarassPlayer(game, id);
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
player.forces = {};
player.reserves -= total;
if (total) player.forces[location] = total;
assert.equal(Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), total);
}
export type StoneHarassOptions = {
advanced?: boolean;
/** Which physical slot buys Harass & Withdraw; its owner plays the card. */
harass?: StoneHarassSide | null;
/** Who holds the physical Stone Burner: the Harass owner's opponent or itself. */
stone?: 'opponent' | 'harass' | null;
/** Hand the physical Mirror Weapon to the side without the Stone. */
mirror?: boolean;
/** Stage Harass in the owner's plan: false leaves the same card in hand. */
playHarass?: boolean;
/** Prefer plan leaders that also exist in the original physical traitor pool. */
traitorableLeaders?: boolean;
mode?: StoneHarassMode;
/** Ordinary counters staged for the native attacker after the original deal. */
attackerForces?: number;
attackerElite?: number;
defenderForces?: number;
attackerDial?: number;
defenderDial?: number;
attackerSupport?: number;
defenderSupport?: number;
/** Native battle location; the second sector enables the allocation decision. */
territory?: string;
sector?: number;
secondSector?: number;
split?: number;
};
export type StoneHarassCase = {
game: Game;
attacker: string;
defender: string;
harassOwner: string | null;
stoneOwner: string | null;
mirrorOwner: string | null;
territory: string;
sector: number;
location: string;
locations: string[];
leaders: Record<string, string>;
dials: Record<string, number>;
supports: Record<string, number>;
mode: StoneHarassMode;
plans: StoneHarassStep[];
};
/** Genuine Basic/Advanced three-seat native setup (classics plus Richese's own
* cache). Forces are restaged only as conserved physical counters of the real
* battle territory; the battle itself is the original native chooseBattle. */
export function stoneHarassFixture(options: StoneHarassOptions = {}): StoneHarassCase {
const harassOwner = options.harass === null ? null : options.harass === 'attacker' ? 'a' : 'd';
const stoneOwner = options.stone === null ? null : options.stone === 'harass'
? harassOwner : harassOwner === 'a' ? 'd' : 'a';
assert.ok(stoneOwner !== null || options.mirror !== true,
'A Mirror Weapon needs an opposing physical Stone Burner.');
const mirrorOwner = options.mirror
? stoneOwner === 'a' ? 'd' : 'a' : null;
const territory = options.territory ?? 'arrakeen';
const sector = options.sector ?? 10;
const game = harassWithdrawGame({ advanced: options.advanced ?? false,
factions: ['emperor', 'atreides', 'richese'],
normal: options.attackerForces ?? 5, elite: options.attackerElite ?? 0,
prepare: true, territory, sector });
const locations = [location(territory, sector)];
if ((options.defenderForces ?? 5) !== 5)
restage(game, 'd', options.defenderForces ?? 5, locations[0]);
if (options.split) {
assert.ok(options.secondSector !== undefined,
'A split staging needs the second native sector of this territory.');
const second = location(territory, options.secondSector);
for (const id of ['a', 'd'] as const) {
const player = stoneHarassPlayer(game, id);
const available = player.forces[locations[0]] ?? 0;
const elite = player.elites?.forces[locations[0]] ?? 0;
assert.ok(options.split <= available - elite,
'A split staging keeps every staged elite counter in its original sector.');
player.forces[locations[0]] = available - options.split;
player.forces[second] = (player.forces[second] ?? 0) + options.split;
}
locations.push(second);
}
if (harassOwner) takeHarassCard(game, harassOwner, HARASS_CARD);
if (stoneOwner) takeStoneHarassCard(game, stoneOwner, STONE_CARD);
if (mirrorOwner) takeStoneHarassCard(game, mirrorOwner, MIRROR_CARD);
const dials: Record<string, number> = { a: options.attackerDial ?? 0, d: options.defenderDial ?? 0 };
// Advanced ordinary counter pools require support equal to the dialed counters.
const supports: Record<string, number> = {
a: options.attackerSupport ?? (options.advanced ? dials.a : 0),
d: options.defenderSupport ?? (options.advanced ? dials.d : 0) };
const leaders: Record<string, string> = {};
const plans: StoneHarassStep[] = (['a', 'd'] as const).map(actor => {
const available = stoneHarassPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt);
const traitor = options.traitorableLeaders ? traitorIdentities(game) : null;
leaders[actor] = (traitor ? available.find(leader => traitor.has(leader.id)) : undefined)?.id
?? available[0].id;
const weapon = actor === stoneOwner ? STONE_CARD : actor === mirrorOwner ? MIRROR_CARD : undefined;
const playHarass = actor === harassOwner && (options.playHarass ?? true);
return { actor, action: { type: 'battlePlan', dial: dials[actor], support: supports[actor],
leader: leaders[actor],
...(weapon ? { weapon } : {}),
...(playHarass ? { [weapon ? 'defense' : 'weapon']: HARASS_CARD } : {}) } };
});
stoneHarassCustody(game);
return { game, attacker: 'a', defender: 'd', harassOwner, stoneOwner, mirrorOwner,
territory, sector, location: locations[0], locations, leaders, dials, supports,
mode: options.mode ?? 'ignore', plans };
}
function location(territory: string, sector: number): string {
return `${territory}:${sector}`;
}
/** Seal the original native plans in their staged actor order. */
export function sealStoneHarassPlans(fixture: Pick<StoneHarassCase, 'game' | 'plans'>): Game {
let game = fixture.game;
for (const step of fixture.plans) game = applyAction(game, step.actor, step.action);
assert.equal(game.battle?.revealed, true, 'Both original sealed plans must reveal.');
return game;
}
/** Answer only the native reveal offers; never a phase, receipt or mode of our own. */
export function chooseStoneHarassReveal(state: Game, options: {
mode?: StoneHarassMode; modes?: Record<string, StoneHarassMode>;
returns?: HarassWithdrawSelection;
} = {}): Game {
let game = state;
for (let attempt = 0; attempt < 10; attempt++) {
const decision = game.decision;
if (decision?.kind === 'harassWithdraw') {
const offer = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(offer, 'Choose only the actual revealed physical withdrawal allocation.');
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
returns: options.returns ?? defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support) });
} else if (decision?.kind === 'stoneBurner') {
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
mode: options.modes?.[decision.player] ?? options.mode ?? 'ignore' });
} else return game;
}
throw new Error('The native Stone/Harass reveal offers did not finish.');
}
/** Traitor declarations and the native aftermath; asserts physical conservation. */
export function finishStoneHarassBattle(state: Game, options: {
mode?: StoneHarassMode; modes?: Record<string, StoneHarassMode>;
returns?: HarassWithdrawSelection; traitors?: readonly string[];
} = {}): Game {
let game = chooseStoneHarassReveal(state, options);
for (let attempt = 0; game.battle && attempt < 10; attempt++) {
assert.equal(game.decision, null, 'Every required reveal choice precedes traitor declarations.');
const battle = viewGame(game, game.battle!.attacker).battle!;
const voter = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!voter) break;
game = applyAction(game, voter, { type: 'traitorCall',
call: options.traitors?.includes(voter) ?? false });
if (game.decision) break;
}
game = finishOccupyCardsAftermath(game);
stoneHarassCustody(game);
return game;
}
export type StoneHarassOccupyOptions = {
lead?: 'ecaz' | 'ally';
/** Stage Harass in the selected lead's plan; false keeps the same card in hand. */
harass?: boolean;
stone?: 'opponent' | 'harass';
mirror?: boolean;
variableDial?: number;
support?: number;
ecazForces?: number;
allyForces?: number;
opponentForces?: number;
opponentElite?: number;
opponentDial?: number;
mode?: StoneHarassMode;
};
export type StoneHarassOccupyCase = StoneHarassCase & {
occupy: EcazOccupyFixture;
ecaz: string;
ally: string;
opponent: string;
lead: string;
};
/** Genuine E3 advanced roster (Ecaz, its real ally, a classic opponent and
* Richese's own cache) through the original Nexus, shared shipment and Occupy
* lead choice. Only conserved card/force staging exposes the Stone rule. */
export function stoneHarassOccupyFixture(
options: StoneHarassOccupyOptions = {},
): StoneHarassOccupyCase {
const lead: 'ecaz' | 'ally' = options.lead ?? 'ecaz';
// The original four-seat advanced lobby supplies Ecaz, its real ally, a
// classic opponent and Richese's own card cache; every later step is native.
let initial = createGame('STONEOCCUPY', newPlayer('ecaz', 'ecaz', 'ecaz'), true, ['ecaz', 'choam']);
for (const faction of ['guild', 'emperor', 'richese'] as const)
joinGame(initial, newPlayer(faction, faction, faction));
for (const player of initial.players) initial = applyAction(initial, player.id, { type: 'ready' });
initial.ecazTreachery = true;
initial = initializeEcazOccupyGameForAudit(initial);
const occupy = ecazOccupyFixture({ initial, expansions: ['ecaz', 'choam'], ecazTreachery: true,
allyFaction: 'guild', opponentFaction: 'emperor',
ecazForces: options.ecazForces ?? 5, allyForces: options.allyForces ?? 4,
...(options.opponentForces === undefined ? {} : { opponentForces: options.opponentForces }),
...(options.opponentElite === undefined ? {} : { opponentElite: options.opponentElite }) });
const owner = lead === 'ecaz' ? occupy.ecaz : occupy.ally;
const harassOwner = options.harass === false ? null : owner;
const stoneOwner = options.stone === 'harass' ? owner : occupy.opponent;
const mirrorOwner = options.mirror ? stoneOwner === owner ? occupy.opponent : owner : null;
if (owner) takeHarassCard(occupy.game, owner, HARASS_CARD);
takeStoneHarassCard(occupy.game, stoneOwner, STONE_CARD);
if (mirrorOwner) takeStoneHarassCard(occupy.game, mirrorOwner, MIRROR_CARD);
const game = finishHarassPreparation(openEcazOccupyPlans(chooseEcazOccupyLead(occupy, lead)));
const profile = viewGame(game, owner).battle?.ecazOccupy?.profile;
assert.ok(profile, 'The native Occupy plan must quote its physical profile.');
const variableDial = options.variableDial ?? 0;
const leaders: Record<string, string> = {
[owner]: stoneHarassPlayer(game, owner).leaders.find(leader => !leader.dead && !leader.usedAt)!.id,
[occupy.opponent]: stoneHarassPlayer(game, occupy.opponent)
.leaders.find(leader => !leader.dead && !leader.usedAt)!.id,
};
const plans: StoneHarassStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
support: options.support ?? variableDial, leader: leaders[owner],
...(stoneOwner === owner ? { weapon: STONE_CARD } : {}),
...(mirrorOwner === owner ? { weapon: MIRROR_CARD } : {}),
...(harassOwner && options.harass !== false ? { defense: HARASS_CARD } : {}) } },
{ actor: occupy.opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0,
support: 0, leader: leaders[occupy.opponent],
...(stoneOwner === occupy.opponent ? { weapon: STONE_CARD } : {}),
...(mirrorOwner === occupy.opponent ? { weapon: MIRROR_CARD } : {}) } },
];
stoneHarassCustody(game);
return { game, plans, occupy, ecaz: occupy.ecaz, ally: occupy.ally, opponent: occupy.opponent,
lead: owner, harassOwner, stoneOwner, mirrorOwner,
attacker: game.battle!.attacker, defender: game.battle!.defender,
territory: occupy.territory, sector: Number(occupy.location.split(':')[1]),
location: occupy.location, locations: [occupy.location],
leaders, dials: { [owner]: profile.fixedEcazDial + variableDial,
[occupy.opponent]: options.opponentDial ?? 0 },
supports: { [owner]: options.support ?? 0, [occupy.opponent]: 0 },
mode: options.mode ?? 'ignore' };
}
+1 -9
View File
@@ -659,15 +659,7 @@ void test('Basic and Advanced Ixian half-dial withdrawal preserves all seven phy
}
});
void test('public Richese and optional-module contexts remain guarded', () => {
const richese = harassWithdrawGame({
factions: ['emperor', 'atreides', 'richese'],
});
assert.match(
viewGame(richese, 'a').battle!.harassWithdraw!.blocked!,
/Richese card family/,
);
reject(richese, 'a', ownPlan(richese), /Richese card family/);
void test('additional optional-module contexts remain guarded', () => {
const g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'choam'] });
assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null);
const forged = structuredClone(g);
+68
View File
@@ -5,6 +5,7 @@ import {
stoneBurnerComparison,
stoneBurnerPlanBlock,
stoneBurnerCompulsionBlock,
stoneBurnerWithdrawalContext,
type StoneBurnerSide,
} from '../game/stone-burner';
@@ -316,3 +317,70 @@ void test('public compulsion matches existence of a supported zero-spice plan wi
/physical force pools/,
);
});
void test('post-withdrawal comparison resolves original typed ambiguity without skipping original plan legality', () => {
assert.equal(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0).winner, null);
const context = stoneBurnerWithdrawalContext(undefined, 'attacker');
assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0, 'attacker', context), {
winner: 'defender', attacker: [0], defender: [2],
});
assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(3), 'attacker', 'defender', context), null);
for (const [dial, support] of [[9, 0], [3, 7], [0.25, 0], [0, 1]]) {
assert.deepEqual(stoneBurnerComparison(emperor, dial, support, ordinary(1), 0, 0, 'attacker', context).attacker, []);
assert.notEqual(stoneBurnerPlanBlock(emperor, dial, support, ordinary(1), 'attacker', 'attacker', context), null);
}
});
void test('both returned undialed pools tie at zero for the actual aggressor', () => {
const context = stoneBurnerWithdrawalContext(
stoneBurnerWithdrawalContext(undefined, 'attacker'), 'defender',
);
for (const aggressor of ['attacker', 'defender'] as const)
assert.deepEqual(stoneBurnerComparison(ordinary(5), 3, 0, ordinary(3), 1, 0, aggressor, context), {
winner: aggressor, attacker: [0], defender: [0],
});
});
void test('active Occupy withdrawal removes only the named owner contribution and preserves the original fixed dial', () => {
const original = { attacker: { fixedEcazDial: 3, ecazUndialed: 2 } };
const before = structuredClone(original);
const ecaz = stoneBurnerWithdrawalContext(original, 'attacker', {
canceled: false, cardUser: 'ecaz', ecaz: 'ecaz',
});
const variable = stoneBurnerWithdrawalContext(original, 'attacker', {
canceled: false, cardUser: 'ally', ecaz: 'ecaz',
});
assert.deepEqual(stoneBurnerComparison(emperor, 6, 1, ordinary(3), 1, 0, 'attacker', ecaz).attacker, [1, 2, 3]);
assert.deepEqual(stoneBurnerComparison(emperor, 6, 1, ordinary(3), 1, 0, 'attacker', variable).attacker, [2]);
assert.deepEqual(stoneBurnerComparison(emperor, 2, 0, ordinary(3), 1, 0, 'attacker', variable).attacker, []);
const canceled = stoneBurnerWithdrawalContext(undefined, 'attacker', {
canceled: true, cardUser: 'ecaz', ecaz: 'ecaz',
});
assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0, 'attacker', canceled).attacker, [0]);
assert.deepEqual(original, before);
});
void test('public possible opposing Ecaz withdrawal checks both scenarios without conflating their winners or cached totals', () => {
for (const side of ['attacker', 'defender'] as const) {
const opposingSide = side === 'attacker' ? 'defender' : 'attacker';
const fixed = { fixedEcazDial: 4, ecazUndialed: 4 };
const context = { [opposingSide]: fixed };
assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, context), null);
assert.notEqual(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, {
[opposingSide]: { ...fixed, possibleEcazWithdrawal: true },
}), null);
assert.notEqual(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side,
stoneBurnerWithdrawalContext(context, opposingSide, { canceled: false, cardUser: 'ecaz', ecaz: 'ecaz' }),
), null);
assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side,
stoneBurnerWithdrawalContext(context, opposingSide),
), null);
// Populate/use the original cache again after the actual withdrawal modes.
assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, context), null);
// A revealed return can change the winner, but neither scenario has an
// unresolved physical allocation when both sides have deterministic totals.
assert.equal(stoneBurnerPlanBlock(ordinary(2), 0, 0, ordinary(0), side, side, {
[opposingSide]: { ...fixed, possibleEcazWithdrawal: true },
}), null);
}
});
+181
View File
@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { type CombatForces } from '../game/combat';
import { HARASS_WITHDRAW_CARD, type HarassWithdrawContext } from '../game/harass-withdraw';
import { stoneBurnerComparison, type StoneBurnerSide } from '../game/stone-burner';
import {
quoteBattleResolution,
BattleResolutionQuoteError,
type BattleResolutionInput,
type ResolutionCombatant,
} from '../game/battle-resolution-quote';
const catalog = (): Card[] => [...baseDeck(), ...ixBattleCards(), ...richeseCards(), ...ecazTreacheryCards()];
const opposite = (side: StoneBurnerSide): StoneBurnerSide => side === 'attacker' ? 'defender' : 'attacker';
function withdrawal(forces: CombatForces): HarassWithdrawContext {
return {
blocked: null, forces,
locations: { 'carthag:10': { normal: forces.normal, elite: forces.elite } },
};
}
function fixture(harassSide: StoneBurnerSide = 'attacker', samePlan = true, advanced = true): BattleResolutionInput {
const side = (id: string, faction: 'emperor' | 'guild', normal: number): ResolutionCombatant => {
const leader = { ...leaders(faction)[0], strength: 0 };
return {
id, faction, spice: 10, hand: [], leader,
forces: { normal, elite: 0, eliteStrength: 1, freeSupport: !advanced },
plan: { dial: 1, support: advanced ? 1 : 0, leader: leader.id, weapon: null, defense: null },
};
};
const attacker = side('a', 'emperor', harassSide === 'attacker' ? 5 : 3);
const defender = side('d', 'guild', harassSide === 'defender' ? 5 : 3);
const input: BattleResolutionInput = {
advanced, turn: 2, territory: 'carthag', attacker, defender,
voters: [attacker, defender].map(s => ({ id: s.id, beneficiary: s.id, called: false, traitors: [] })),
participants: [attacker, defender].map(s => ({ id: s.id, faction: s.faction })),
physicalCards: catalog(), pendingAuditorPresent: false, pendingRetentionPresent: false,
};
const harass = input.physicalCards.find(c => c.id === HARASS_WITHDRAW_CARD)! as Card;
const stone = input.physicalCards.find(c => (c as Card).effect === 'stoneBurner')! as Card;
input[harassSide].hand = [harass];
input[harassSide].plan.defense = harass.id;
input[harassSide].harassWithdraw = withdrawal(input[harassSide].forces);
const stoneSide = samePlan ? harassSide : opposite(harassSide);
input[stoneSide].hand = [...input[stoneSide].hand, stone];
input[stoneSide].plan.weapon = stone.id;
input[stoneSide].stoneMode = 'ignore';
return input;
}
void test('same-plan and opposing Stone compare only counters left after Harass, reversing the original winner in either slot and rules mode', () => {
for (const advanced of [false, true])
for (const harassSide of ['attacker', 'defender'] as const)
for (const samePlan of [false, true]) {
const input = fixture(harassSide, samePlan, advanced);
const otherSide = opposite(harassSide);
const original = stoneBurnerComparison(input.attacker.forces, 1, advanced ? 1 : 0,
input.defender.forces, 1, advanced ? 1 : 0);
assert.equal(original.winner, harassSide);
const before = structuredClone(input);
const quote = quoteBattleResolution(input);
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, input[otherSide].id);
assert.deepEqual(quote.stone?.[harassSide], [0]);
assert.deepEqual(quote.stone?.[otherSide], [2]);
assert.deepEqual(quote.scores, { attacker: 1, defender: 1 });
assert.deepEqual(quote.leaderDeaths, { attacker: false, defender: false });
assert.deepEqual(quote.harassWithdraw?.[0].returned, { normal: 4, elite: 0 });
assert.deepEqual(quote.destroyedArmies, [input[harassSide].id]);
assert.deepEqual(quote.payments.map(p => p.ownPayment), advanced ? [1, 1] : []);
assert.ok(quote.discarded.some(c => c.player === input[harassSide].id && c.card === HARASS_WITHDRAW_CARD));
const stoneSide = samePlan ? harassSide : otherSide;
assert.equal(quote.winnerCards.includes(input[stoneSide].plan.weapon!), !samePlan);
if (advanced)
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
else assert.equal(quote.basicWinnerLosses, 1);
assert.deepEqual(input, before);
}
});
void test('post-withdrawal zero ties honor the actual aggressor and a Harass winner keeps only Stone with its original commitment', () => {
for (const harassSide of ['attacker', 'defender'] as const)
for (const aggressor of ['attacker', 'defender'] as const) {
const input = fixture(harassSide);
input[opposite(harassSide)].forces.normal = 1;
input.aggressor = input[aggressor].id;
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, input[aggressor].id);
assert.deepEqual(quote.stone, { winner: aggressor, attacker: [0], defender: [0] });
if (aggressor === harassSide) {
assert.deepEqual(quote.winnerCards, [input[harassSide].plan.weapon]);
assert.equal(quote.casualties?.fullyCommitted, true);
assert.equal(quote.casualties?.forces.normal, 1);
assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
}
}
});
function typedFixture(): BattleResolutionInput {
const input = fixture('attacker', false);
input.attacker.forces = { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false };
input.attacker.plan.dial = 3;
input.attacker.harassWithdraw = withdrawal(input.attacker.forces);
// Original legal commitments leave one, two or three counters. This return
// selects three normal and one elite fighter; all undialed counters leave.
input.attacker.harassSelection = { 'carthag:10': { normal: 2, elite: 0 } };
return input;
}
void test('a selected typed Harass commitment resolves the previously ambiguous Stone winner, not the original dial strength', () => {
const input = typedFixture();
assert.equal(stoneBurnerComparison(input.attacker.forces, 3, 1, input.defender.forces, 1, 1).winner, null);
const quote = quoteBattleResolution(input);
assert.deepEqual(quote.stone, { winner: 'defender', attacker: [0], defender: [2] });
assert.deepEqual(quote.harassWithdraw?.[0].remaining, { ...input.attacker.forces, normal: 3, elite: 1 });
assert.deepEqual(quote.scores, { attacker: 3, defender: 1 });
assert.equal(quote.winner, 'd');
for (const [dial, support] of [[9, 1], [3, 7]]) {
const invalid = structuredClone(input);
invalid.attacker.plan.dial = dial;
invalid.attacker.plan.support = support;
assert.throws(() => quoteBattleResolution(invalid), BattleResolutionQuoteError);
}
});
void test('validated opposing and mutual traitors cancel Harass before Stone counts while traitor outcomes bypass allocation ambiguity', () => {
for (const mutual of [false, true]) {
const input = typedFixture();
input.voters[1].called = true;
input.voters[1].traitors = [input.attacker.plan.leader!];
if (mutual) {
input.voters[0].called = true;
input.voters[0].traitors = [input.defender.plan.leader!];
}
const before = structuredClone(input);
const quote = quoteBattleResolution(input);
assert.equal(quote.harassWithdraw, undefined);
assert.deepEqual(quote.stone, { winner: null, attacker: [1, 2, 3], defender: [2] });
assert.equal(quote.result, mutual ? 'mutualTraitors' : 'traitor');
assert.equal(quote.winner, mutual ? null : 'd');
assert.equal(quote.casualties, null);
assert.deepEqual(input, before);
}
const ownCall = typedFixture();
ownCall.voters[0].called = true;
ownCall.voters[0].traitors = [ownCall.defender.plan.leader!];
const quote = quoteBattleResolution(ownCall);
assert.equal(quote.result, 'traitor');
assert.equal(quote.winner, 'a');
assert.deepEqual(quote.stone?.attacker, [0]);
assert.equal(quote.harassWithdraw?.length, 1);
assert.equal(quote.payments[0].freeByTraitor, true);
});
void test('Mirror-copied Stone uses resolved weapons after Harass and retains original mode, leader death and bounty rules', () => {
const input = fixture();
const mirror = catalog().find(c => c.effect === 'mirrorWeapon')!;
input.defender.hand = [mirror];
input.defender.plan.weapon = mirror.id;
input.defender.stoneMode = 'kill';
input.attacker.leader!.strength = 3;
input.defender.leader!.strength = 2;
const quote = quoteBattleResolution(input);
assert.equal(quote.winner, 'd');
assert.deepEqual(quote.stone, { winner: 'defender', attacker: [0], defender: [2] });
assert.deepEqual(quote.leaderDeaths, { attacker: true, defender: true });
assert.deepEqual(quote.scores, { attacker: 1, defender: 1 });
assert.deepEqual(quote.bounty, { player: 'd', amount: 5 });
assert.deepEqual(quote.winnerCards, [mirror.id]);
assert.deepEqual(quote.discarded, [
{ player: 'a', card: input.attacker.plan.weapon },
{ player: 'a', card: HARASS_WITHDRAW_CARD },
]);
delete input.defender.stoneMode;
assert.throws(() => quoteBattleResolution(input), BattleResolutionQuoteError);
const invalidVoter = fixture();
invalidVoter.voters[1].called = true;
assert.throws(() => quoteBattleResolution(invalidVoter), BattleResolutionQuoteError);
});
+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);
});