Compose Harass withdrawal with provisional Stone Burner timing
This commit is contained in:
1 parent
5d13374f7e
commit
feb2b03251
23 files changed
+1362
-124
No files matched your search
@@ -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 }]);
|
||||
}
|
||||
});
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user