Integrate sourced Treachery cards with Advanced Occupy

This commit is contained in:
Quintine committed 2026-10-02 17:53:24 +10:00
1 parent 85caee6c83
commit cd532222a5
21 files changed
+1226 -99

No files matched your search

+377
View File
@@ -0,0 +1,377 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { splitLocation } from '../game/board';
import {
createEcazOccupySetup, ecazOccupyTerritoryForces, type EcazOccupyFixtureOptions,
} from './fixture-ecaz-occupy';
import {
ecazOccupyCardsCase, finishOccupyCardsAftermath, occupyCardsPlayer as seat,
type OccupyCardsCase,
} from './fixture-ecaz-occupy-cards';
import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const forces = (result: OccupyCardsCase, id: string, game = result.game) =>
ecazOccupyTerritoryForces(game, id, result.fixture.territory);
const reserveChange = (result: OccupyCardsCase, id: string) =>
seat(result.game, id).reserves - seat(result.before, id).reserves;
const tankChange = (result: OccupyCardsCase, id: string) =>
seat(result.game, id).tanks - seat(result.before, id).tanks;
function physicalSource(game: Game, count: number): void {
const source = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
...game.players.flatMap(p => p.hand)];
assert.equal(source.length, count);
assert.deepEqual(source.map(c => c.id).sort(),
[...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])]
.map(c => c.id).sort(), 'The admitted original family retains its canonical physical faces, including pending Ix offers.');
}
function discardedOnce(result: OccupyCardsCase): void {
assert.equal(result.game.discard.filter(card => card.id === result.card).length, 1);
assert.ok(!seat(result.game, result.holder).hand.some(card => card.id === result.card));
}
function collection(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 40 && game.phase === 6; attempt++) {
const player = game.players.find(p => !game.ready.includes(p.id));
assert.ok(player, 'The actual Battle phase must offer its native continuation.');
game = applyAction(game, player.id, { type: 'ready' });
}
assert.equal(game.phase, 7, 'The original battle continues to native Spice Collection.');
return game;
}
function pendingIxSetup(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'ixSetup'; attempt++) {
assert.equal(game.status, 'setup', 'Stop at the original starting-card offer, never redeal.');
let next: Game | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = applyAction(game, player.id, action); break; }
}
assert.ok(next, 'Original faction setup must expose an actual native choice.');
game = next;
}
assert.equal(game.decision?.kind, 'ixSetup');
assert.ok(game.ixSetupCards?.length, 'The physical starting cards are still in the pending offer.');
return game;
}
const sources: { name: string; count: number; options: EcazOccupyFixtureOptions }[] = [
{ name: 'E3', count: 36, options: { allyFaction: 'guild', opponentFaction: 'emperor', expansions: ['ecaz'] } },
{ name: 'Ix', count: 50, options: { allyFaction: 'ixians', opponentFaction: 'emperor', expansions: ['ecaz', 'ix'] } },
{ name: 'CHOAM', count: 38, options: { allyFaction: 'choam', opponentFaction: 'emperor', expansions: ['ecaz', 'choam'] } },
{ name: 'Ix and CHOAM', count: 50, options: { allyFaction: 'ixians', opponentFaction: 'choam', expansions: ['ecaz', 'ix', 'choam'] } },
];
for (const source of sources) {
void test(`Independent cards preserve the original ${source.name} physical source through native setup and battle`, () => {
let initial = createEcazOccupySetup({ ...source.options, ecazTreachery: true });
physicalSource(initial, source.count);
if (source.options.allyFaction === 'ixians') initial = pendingIxSetup(initial);
physicalSource(initial, source.count);
const result = ecazOccupyCardsCase({ ...source.options, initial,
card: 'ecaz-reinforcements', variableDial: 0, support: 0 });
physicalSource(result.fixture.afterSetup, source.count);
physicalSource(result.game, source.count);
assert.equal(result.game.lastBattleContext?.winner, result.actor);
discardedOnce(result);
collection(result.game);
});
}
for (const lead of ['ecaz', 'ally'] as const) for (const slot of ['weapon', 'defense'] as const) {
void test(`Reinforcements in ${slot}, ${lead} lead: own three reserves buy the decisive +2, not ally support or arrivals`, () => {
const result = ecazOccupyCardsCase({ lead, slot, card: 'ecaz-reinforcements',
equalLeaders: true, opponentDial: 6 });
const { fixture, actor, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor,
'Equal leaders: five physical dial strength beats six only with the card’s +2.');
assert.equal(reserveChange(result, actor), -3);
assert.equal(tankChange(result, fixture.ecaz), lead === 'ecaz' ? 6 : 3);
assert.equal(tankChange(result, fixture.ally), lead === 'ally' ? 5 : 2);
assert.equal(reserveChange(result, lead === 'ecaz' ? fixture.ally : fixture.ecaz), 0);
assert.equal(forces(result, fixture.ecaz), 2);
assert.equal(forces(result, fixture.ally), 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2,
'Three free fixed Ecaz fighters and the score modifier incur no additional support.');
discardedOnce(result);
collection(game);
});
void test(`Harass in ${slot}, ${lead} lead: only actual owner undialed forces return, original fixed loss stays three`, () => {
const result = ecazOccupyCardsCase({ lead, slot });
const { fixture, actor, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(reserveChange(result, actor), 2);
assert.equal(reserveChange(result, lead === 'ecaz' ? fixture.ally : fixture.ecaz), 0);
assert.equal(tankChange(result, fixture.ecaz), 3);
assert.equal(tankChange(result, fixture.ally), 2);
assert.equal(forces(result, fixture.ecaz), lead === 'ecaz' ? 0 : 2);
assert.equal(forces(result, fixture.ally), lead === 'ally' ? 0 : 2);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2,
'Support remains the selected payer’s variable ally payment, never an Ecaz fixed-pool charge.');
discardedOnce(result);
collection(game);
});
}
for (const lead of ['ecaz', 'ally'] as const) {
for (const outcome of ['loss', 'soleTraitor', 'opposingTraitor', 'mutualTraitors', 'explosion'] as const) {
void test(`Harass ${lead} lead, actual ${outcome}: returns precede losses except an opposing successful traitor`, () => {
const result = ecazOccupyCardsCase({ lead, outcome, slot: 'defense' });
const { fixture, actor, game } = result;
const canceledReturn = outcome === 'opposingTraitor' || outcome === 'mutualTraitors';
assert.equal(reserveChange(result, actor), canceledReturn ? 0 : 2);
assert.equal(reserveChange(result, actor === fixture.ecaz ? fixture.ally : fixture.ecaz), 0);
assert.equal(game.lastBattleContext?.result,
outcome === 'loss' ? 'normal' : outcome === 'soleTraitor' || outcome === 'opposingTraitor' ? 'traitor' : outcome);
if (outcome === 'soleTraitor') {
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(tankChange(result, fixture.ecaz), 0);
assert.equal(tankChange(result, fixture.ally), 0);
assert.equal(forces(result, fixture.ecaz), lead === 'ecaz' ? 3 : 5);
assert.equal(forces(result, fixture.ally), lead === 'ally' ? 2 : 4);
} else {
assert.equal(game.lastBattleContext?.winner,
outcome === 'loss' || outcome === 'opposingTraitor' ? fixture.opponent : null);
assert.equal(forces(result, fixture.ecaz), 0);
assert.equal(forces(result, fixture.ally), 0);
assert.equal(tankChange(result, fixture.ecaz), !canceledReturn && lead === 'ecaz' ? 3 : 5);
assert.equal(tankChange(result, fixture.ally), !canceledReturn && lead === 'ally' ? 2 : 4);
}
discardedOnce(result);
collection(game);
});
void test(`Reinforcements ${lead} lead, actual ${outcome}: provisional own reserve cost and single disposal survive every reveal`, () => {
const result = ecazOccupyCardsCase({ lead, outcome, card: 'ecaz-reinforcements', slot: 'defense' });
const { fixture, actor, game } = result;
assert.equal(reserveChange(result, actor), -3);
assert.equal(reserveChange(result, actor === fixture.ecaz ? fixture.ally : fixture.ecaz), 0);
const preserved = outcome === 'soleTraitor';
assert.equal(tankChange(result, fixture.ecaz), (preserved ? 0 : 5) + (lead === 'ecaz' ? 3 : 0));
assert.equal(tankChange(result, fixture.ally), (preserved ? 0 : 4) + (lead === 'ally' ? 3 : 0));
assert.equal(forces(result, fixture.ecaz), preserved ? 5 : 0);
assert.equal(forces(result, fixture.ally), preserved ? 4 : 0);
assert.equal(game.lastBattleContext?.winner,
preserved ? actor : outcome === 'loss' || outcome === 'opposingTraitor' ? fixture.opponent : null);
discardedOnce(result);
collection(game);
});
}
void test(`Karama-canceled Occupy with ${lead} Harass uses only its ordinary native own pool`, () => {
const result = ecazOccupyCardsCase({ lead, canceled: true });
const { fixture, actor, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(reserveChange(result, actor), lead === 'ecaz' ? 3 : 2);
assert.equal(tankChange(result, actor), 2);
assert.equal(forces(result, actor), 0);
assert.equal(forces(result, actor === fixture.ecaz ? fixture.ally : fixture.ecaz), lead === 'ecaz' ? 4 : 5);
discardedOnce(result);
collection(game);
});
for (const slot of ['weapon', 'defense'] as const) {
void test(`Opposing physical Reinforcements in ${slot} defeats the ${lead} coalition by its own +2`, () => {
const result = ecazOccupyCardsCase({ lead, slot, holder: 'opponent', card: 'ecaz-reinforcements',
equalLeaders: true, opponentDial: 4 });
assert.equal(result.game.lastBattleContext?.winner, result.fixture.opponent);
assert.equal(reserveChange(result, result.holder), -3);
assert.equal(tankChange(result, result.holder), 7);
assert.equal(forces(result, result.holder), 4);
assert.equal(reserveChange(result, result.actor), 0);
discardedOnce(result);
collection(result.game);
});
void test(`Opposing Harass in ${slot} withdraws only its six undialed forces against ${lead} Occupy`, () => {
const result = ecazOccupyCardsCase({ lead, slot, holder: 'opponent', opponentDial: 2 });
assert.equal(result.game.lastBattleContext?.winner, result.actor);
assert.equal(reserveChange(result, result.holder), 6);
assert.equal(tankChange(result, result.holder), 2);
assert.equal(forces(result, result.fixture.ecaz), 2);
assert.equal(forces(result, result.fixture.ally), 2);
discardedOnce(result);
collection(result.game);
});
}
}
void test('Ecaz Harass returns floor(E/2) even when no ally fighters are dialed or paid', () => {
const result = ecazOccupyCardsCase({ variableDial: 0, support: 0 });
assert.equal(reserveChange(result, result.fixture.ecaz), 2);
assert.equal(tankChange(result, result.fixture.ecaz), 3);
assert.equal(forces(result, result.fixture.ecaz), 0);
assert.equal(forces(result, result.fixture.ally), 4);
assert.equal(seat(result.game, result.actor).spice, seat(result.before, result.actor).spice);
discardedOnce(result);
});
void test('Even Ecaz army keeps its original two mandatory losses after returning its two undialed forces', () => {
const result = ecazOccupyCardsCase({ ecazForces: 4 });
assert.equal(result.game.lastBattleContext?.winner, result.actor);
assert.equal(reserveChange(result, result.fixture.ecaz), 2);
assert.equal(tankChange(result, result.fixture.ecaz), 2);
assert.equal(forces(result, result.fixture.ecaz), 0);
assert.equal(forces(result, result.fixture.ally), 2);
discardedOnce(result);
});
void test('Selected Ixian Harass returns a chosen typed undialed pool, never the fixed Ecaz fighters', () => {
const result = ecazOccupyCardsCase({ lead: 'ally', allyFaction: 'ixians', allyForces: 8, allyElite: 2,
variableDial: 3, support: 0, returnedForces: { normal: 2, elite: 1 },
losses: { normal: 4, elite: 1 } });
assert.equal(result.game.lastBattleContext?.winner, result.actor);
assert.equal(reserveChange(result, result.fixture.ally), 3);
assert.equal(seat(result.game, result.fixture.ally).elites!.reserves -
seat(result.before, result.fixture.ally).elites!.reserves, 1);
assert.equal(tankChange(result, result.fixture.ally), 5);
assert.equal(seat(result.game, result.fixture.ally).elites!.tanks -
seat(result.before, result.fixture.ally).elites!.tanks, 1);
assert.equal(forces(result, result.fixture.ally), 0);
assert.equal(forces(result, result.fixture.ecaz), 2);
assert.equal(tankChange(result, result.fixture.ecaz), 3);
discardedOnce(result);
collection(result.game);
});
for (const lead of ['ecaz', 'ally'] as const) {
void test(`Native Ix substitution and selected ${lead} cleanup follow physical Reinforcements`, () => {
const result = ecazOccupyCardsCase({ lead, allyFaction: 'ixians', allyForces: 8, allyElite: 2,
card: 'ecaz-reinforcements', variableDial: 4, support: 1,
losses: { normal: 2, elite: 2 }, substitute: 2, cleanupShield: true });
const { fixture, actor, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(reserveChange(result, actor), -3);
assert.equal(forces(result, fixture.ecaz), 2);
assert.equal(forces(result, fixture.ally), 4);
assert.equal(seat(game, fixture.ally).elites!.forces[fixture.location], 2);
assert.equal(tankChange(result, fixture.ally), 4 + (lead === 'ally' ? 3 : 0));
assert.equal(seat(game, fixture.ally).elites!.tanks, seat(before, fixture.ally).elites!.tanks);
assert.equal(seat(before, actor).spice - seat(game, actor).spice, 1);
assert.ok(game.discard.some(card => card.id === result.shield));
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
for (const card of seat(before, nonLead).hand)
assert.ok(seat(game, nonLead).hand.some(held => held.id === card.id));
discardedOnce(result);
collection(game);
});
void test(`Native CHOAM and ${lead} card payer preserve free fixed strength and actual support income`, () => {
const result = ecazOccupyCardsCase({ lead, allyFaction: 'choam', card: 'ecaz-reinforcements',
variableDial: 3, support: 3, opponentDial: 3 });
const { fixture, actor, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(seat(game, fixture.ally).spice - seat(before, fixture.ally).spice,
lead === 'ally' ? -2 : 2,
'CHOAM receives only floor halves from actual other payers, never fixed strength or Reinforcements.');
if (lead === 'ecaz') assert.equal(seat(before, actor).spice - seat(game, actor).spice, 3);
assert.equal(reserveChange(result, actor), -3);
assert.equal(forces(result, fixture.ecaz), 2);
assert.equal(forces(result, fixture.ally), 1);
discardedOnce(result);
collection(game);
});
void test(`Native CHOAM fixed-only ${lead} Reinforcements creates no support payment or income`, () => {
const result = ecazOccupyCardsCase({ lead, allyFaction: 'choam', card: 'ecaz-reinforcements',
variableDial: 0, support: 0 });
assert.equal(seat(result.game, result.fixture.ally).spice, seat(result.before, result.fixture.ally).spice);
assert.equal(seat(result.game, result.fixture.ecaz).spice, seat(result.before, result.fixture.ecaz).spice);
assert.equal(forces(result, result.fixture.ecaz), 2);
assert.equal(forces(result, result.fixture.ally), 4);
discardedOnce(result);
});
void test(`Actual Face Dance after ${lead} Harass cannot recapture returned own reserves`, () => {
const result = ecazOccupyCardsCase({ lead, allyFaction: 'guild', opponentFaction: 'emperor',
roster: ['ecaz', 'guild', 'emperor', 'tleilaxu'], expansions: ['ecaz', 'ix'],
faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'guild', stopAtFaceDance: true });
const { fixture, actor, before } = result;
const dancer = result.game.players.find(p => p.faction === 'tleilaxu');
assert.ok(dancer);
const decision = result.game.decision;
assert.ok(decision?.kind === 'faceDance');
assert.equal(decision.winner, actor);
assert.equal(decision.leader, result.actorLeader);
assert.equal(reserveChange(result, actor), 2);
const other = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
assert.equal(forces(result, actor), 0);
assert.equal(forces(result, other), 2);
assert.throws(() => applyAction(result.game, dancer.id, { type: 'decision', reveal: true,
sources: { reserves: 3 }, sector: splitLocation(fixture.location).sector }), /more forces/,
'The two withdrawn forces are outside the surviving coalition replacement limit.');
let game = applyAction(result.game, dancer.id, { type: 'decision', reveal: true,
sources: { reserves: 2 }, sector: splitLocation(fixture.location).sector });
game = finishOccupyCardsAftermath(game);
assert.equal(seat(game, actor).reserves, seat(before, actor).reserves + 2);
assert.equal(seat(game, other).reserves, seat(before, other).reserves + 2);
assert.equal(ecazOccupyTerritoryForces(game, dancer.id, fixture.territory), 2);
assert.equal(seat(game, actor).leaders.find(l => l.id === result.actorLeader)!.dead, true);
assert.equal(game.discard.filter(card => card.id === result.card).length, 1);
collection(game);
});
}
for (const lead of ['ecaz', 'ally'] as const) {
void test(`Selected ${lead} Harass winner performs ordinary own-card cleanup after returning forces`, () => {
const result = ecazOccupyCardsCase({ lead, cleanupShield: true });
const { fixture, actor, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(reserveChange(result, actor), 2);
assert.ok(game.discard.some(card => card.id === result.shield));
assert.ok(!seat(game, actor).hand.some(card => card.id === result.shield));
const other = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
for (const card of seat(before, other).hand)
assert.ok(seat(game, other).hand.some(held => held.id === card.id));
discardedOnce(result);
collection(game);
});
void test(`Native CHOAM ${lead} Harass pays only variable support after its own withdrawal`, () => {
const result = ecazOccupyCardsCase({ lead, allyFaction: 'choam',
variableDial: 3, support: 3, opponentDial: 3 });
const { fixture, actor, before, game } = result;
assert.equal(game.lastBattleContext?.winner, actor);
assert.equal(seat(game, fixture.ally).spice - seat(before, fixture.ally).spice,
lead === 'ally' ? -2 : 2);
assert.equal(reserveChange(result, actor), lead === 'ecaz' ? 2 : 1);
assert.equal(tankChange(result, fixture.ecaz), 3);
assert.equal(tankChange(result, fixture.ally), 3);
assert.equal(forces(result, fixture.ecaz), lead === 'ecaz' ? 0 : 2);
assert.equal(forces(result, fixture.ally), lead === 'ally' ? 0 : 1);
discardedOnce(result);
collection(game);
});
}
void test('selected typed Harass losses survive an opposing mandatory Reinforcements discard without reopening allocation', () => {
const result = ecazOccupyCardsCase({
lead: 'ally', allyFaction: 'emperor', opponentFaction: 'guild', allyForces: 6, allyElite: 2,
variableDial: 3.5, support: 2,
returnedForces: { normal: 1, elite: 1 },
losses: { normal: 3, elite: 1 },
opponentCard: 'ecaz-reinforcements',
});
assert.equal(result.game.lastBattleContext?.winner, result.actor);
assert.equal(forces(result, result.fixture.ally), 0);
assert.equal(forces(result, result.fixture.ecaz), 2);
assert.equal(reserveChange(result, result.actor), 2);
assert.equal(tankChange(result, result.actor), 4);
assert.ok(result.game.discard.some(card => card.id === 'ecaz-reinforcements'));
collection(result.game);
});
void test('Basic co-present Ecaz card armies remain unsupported instead of admitting an ordinary single-army outcome', () => {
for (const card of ['ecaz-harass-withdraw', 'ecaz-reinforcements']) {
const game = harassWithdrawGame({ advanced: false, factions: ['guild', 'emperor', 'ecaz'] });
const guild = game.players[0], ecaz = game.players[2];
// Labelled co-side rule-unit staging on the genuine ordinary variant setup.
guild.ally = ecaz.id;
ecaz.ally = guild.id;
ecaz.forces = { 'arrakeen:10': 2 };
ecaz.reserves -= 2;
takeHarassCard(game, guild.id, card);
assert.throws(() => applyAction(game, guild.id,
{ type: 'battlePlan', dial: 1, leader: 'guild-0', weapon: card }),
/supported Advanced Occupy battle profile/);
assert.equal(game.battle?.plans[guild.id], undefined);
}
});
+195
View File
@@ -0,0 +1,195 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { casualtyOptions, type CombatForces } from '../game/combat';
import {
quoteEcazOccupyBattle,
type EcazOccupyBattleProfile,
} from '../game/ecaz-occupy-battle';
import {
defaultHarassWithdrawAllocation,
harassWithdrawCommitments,
harassWithdrawNeedsAllocation,
quoteHarassWithdraw,
HarassWithdrawError,
type HarassWithdrawContext,
type HarassWithdrawSelection,
} from '../game/harass-withdraw';
const native = (normal: number): CombatForces => ({
normal,
elite: 0,
eliteStrength: 1,
freeSupport: false,
});
function profile(
ecazNormal = 5,
lead = 'ecaz-user',
canceled = false,
allyForces = native(4),
): EcazOccupyBattleProfile {
return quoteEcazOccupyBattle({
battleOrderActor: 'ecaz-user',
ecaz: {
id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user',
forces: native(ecazNormal),
},
ally: {
id: 'ally-user', faction: 'ixians', ally: 'ecaz-user',
forces: allyForces,
},
lead,
canceled,
});
}
function contextFor(p: EcazOccupyBattleProfile): HarassWithdrawContext {
const forces = p.lead === p.ecaz ? p.ecazForces : p.allyForces;
return {
blocked: null,
forces,
locations: { 'imperial_basin:9': { normal: forces.normal, elite: forces.elite } },
occupy: {
cardUser: p.lead,
ecaz: p.ecaz,
ally: p.ally,
canceled: p.canceled,
fixedEcazDial: p.fixedEcazDial,
variableForces: p.forces,
},
};
}
void test('Ecaz card user returns only its own floor(E/2), while ally support never charges the free fixed fighters', () => {
for (const ecazNormal of [5, 6]) {
const p = profile(ecazNormal);
const context = contextFor(p);
const before = structuredClone(context);
for (const [variableDial, support] of [[0, 0], [0.5, 0], [2, 2]]) {
const dial = p.fixedEcazDial + variableDial;
assert.deepEqual(harassWithdrawCommitments(context, dial, support), [
{ normal: Math.ceil(ecazNormal / 2), elite: 0 },
]);
assert.equal(harassWithdrawNeedsAllocation(context, dial, support), false);
const quote = quoteHarassWithdraw(context, dial, support);
assert.deepEqual(quote.returned, { normal: Math.floor(ecazNormal / 2), elite: 0 });
assert.deepEqual(quote.remaining, {
...context.forces, normal: Math.ceil(ecazNormal / 2), freeSupport: true,
});
assert.deepEqual(casualtyOptions(quote.remaining, p.fixedEcazDial, 0), [
{ normal: p.fixedEcazDial, elite: 0, paidNormal: 0, paidElite: 0 },
]);
}
assert.deepEqual(context, before);
}
});
void test('original mandatory Ecaz commitment survives a changed on-board pool after the physical return', () => {
const p = profile(5);
const context = contextFor(p);
const returned = quoteHarassWithdraw(context, 4, 1);
const afterReturn: HarassWithdrawContext = {
...context,
forces: returned.remaining,
locations: { 'imperial_basin:9': { normal: 3, elite: 0 } },
};
const remaining = quoteHarassWithdraw(afterReturn, 4, 1);
assert.deepEqual(remaining.returned, { normal: 0, elite: 0 });
assert.equal(remaining.remaining.normal, 3);
assert.equal(remaining.remaining.freeSupport, true);
});
void test('Ecaz sector choices allocate only its own undialed counters, never ally counters', () => {
const context: HarassWithdrawContext = {
...contextFor(profile()),
locations: {
'imperial_basin:9': { normal: 2, elite: 0 },
'imperial_basin:10': { normal: 3, elite: 0 },
},
};
assert.equal(harassWithdrawNeedsAllocation(context, 4, 1), true);
const selected: HarassWithdrawSelection = {
'imperial_basin:10': { normal: 2, elite: 0 },
};
assert.deepEqual(quoteHarassWithdraw(context, 4, 1, selected).returned, { normal: 2, elite: 0 });
const fallback = defaultHarassWithdrawAllocation(context, 4, 1);
assert.deepEqual(quoteHarassWithdraw(context, 4, 1, fallback).returned, { normal: 2, elite: 0 });
assert.throws(() => quoteHarassWithdraw(context, 4, 1, {
'ally-location': { normal: 2, elite: 0 },
}), HarassWithdrawError);
assert.throws(() => quoteHarassWithdraw(context, 4, 1, {
'imperial_basin:10': { normal: 1, elite: 0 },
}), HarassWithdrawError);
});
void test('ally card user subtracts fixed strength and uses native Ix half-normal/double-elite spice commitments', () => {
const ix: CombatForces = {
normal: 3, elite: 2, eliteStrength: 2,
normalFixedHalf: true, freeSupport: false,
};
const context = contextFor(profile(5, 'ally-user', false, ix));
// Three fixed Ecaz strength plus 2.5 variable strength: one half-normal
// and one supported double-elite, not 2.5 or 5.5 physical counters.
assert.deepEqual(harassWithdrawCommitments(context, 5.5, 1), [{ normal: 1, elite: 1 }]);
const quote = quoteHarassWithdraw(context, 5.5, 1);
assert.deepEqual(quote.returned, { normal: 2, elite: 1 });
assert.deepEqual(quote.remaining, { ...ix, normal: 1, elite: 1 });
// Without spice the same strength admits two genuinely different physical
// commitments; selecting the elite-heavy one returns only two own normals.
assert.equal(harassWithdrawNeedsAllocation(context, 5.5, 0), true);
const selected = defaultHarassWithdrawAllocation(context, 5.5, 0, { normal: 1, elite: 2 });
assert.deepEqual(quoteHarassWithdraw(context, 5.5, 0, selected).returned, { normal: 2, elite: 0 });
});
void test('ordinary Advanced ally returns physical undialed counters instead of numeric dial complement', () => {
const context = contextFor(profile(5, 'ally-user'));
assert.deepEqual(quoteHarassWithdraw(context, 4, 0).returned, { normal: 2, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 4, 1).returned, { normal: 3, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 3, 0).returned, { normal: 4, elite: 0 });
});
void test('canceled Occupy restores either selected lead own native commitment with zero fixed strength', () => {
for (const lead of ['ecaz-user', 'ally-user']) {
const p = profile(5, lead, true);
const context = contextFor(p);
const quote = quoteHarassWithdraw(context, 1, 0);
assert.deepEqual(quote.returned, { normal: context.forces.normal - 2, elite: 0 });
assert.deepEqual(quote.remaining, { ...context.forces, normal: 2 });
assert.equal(quote.remaining.freeSupport, false);
const all = quoteHarassWithdraw(context, 0, 0);
assert.equal(all.returned.normal, context.forces.normal);
}
});
void test('ordinary opponent keeps native dial/support semantics without Occupy metadata', () => {
const context: HarassWithdrawContext = {
blocked: null,
forces: native(5),
locations: { 'imperial_basin:9': { normal: 5, elite: 0 } },
};
assert.deepEqual(quoteHarassWithdraw(context, 1, 0).returned, { normal: 3, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 1, 1).returned, { normal: 4, elite: 0 });
assert.deepEqual(quoteHarassWithdraw(context, 0, 0).returned, { normal: 5, elite: 0 });
});
void test('owner and funding boundary rejects invalid total plans before quoting any return', () => {
const context = contextFor(profile());
for (const [dial, support] of [[2, 0], [3, 1], [8, 0], [3.25, 0], [4, -1]]) {
assert.throws(() => quoteHarassWithdraw(context, dial, support), HarassWithdrawError);
}
assert.ok(context.occupy);
const wrongOwner: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, cardUser: 'opponent' },
};
assert.throws(() => quoteHarassWithdraw(wrongOwner, 4, 1), HarassWithdrawError);
const missingFixed: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, fixedEcazDial: 0 },
};
assert.throws(() => quoteHarassWithdraw(missingFixed, 4, 1), HarassWithdrawError);
const mixedAlly: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, cardUser: 'ally-user' },
};
assert.throws(() => quoteHarassWithdraw(mixedAlly, 4, 1), HarassWithdrawError);
const canceledWithFixed: HarassWithdrawContext = {
...context, occupy: { ...context.occupy, canceled: true },
};
assert.throws(() => quoteHarassWithdraw(canceledWithFixed, 4, 1), HarassWithdrawError);
});
+231
View File
@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
import type { EcazTreacheryCardId } from '../game/ecaz-cards';
import { defaultHarassWithdrawAllocation, type HarassWithdrawForces, type HarassWithdrawSelection } from '../game/harass-withdraw';
import {
ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
allowEcazOccupyResponses, stageEcazOccupyCard, type EcazOccupyFixture,
type EcazOccupyFixtureOptions,
} from './fixture-ecaz-occupy';
export const occupyCardsPlayer = (game: Game, id: string): Player => {
const player = game.players.find(p => p.id === id);
assert.ok(player, 'The original roster must contain the physical owner.');
return player;
};
function stageCard(game: Game, owner: string, predicate: (card: Player['hand'][number]) => boolean): string {
const id = stageEcazOccupyCard(game, owner, predicate);
const hand = occupyCardsPlayer(game, owner).hand;
// Keep the selected physical face when the second conserved slot is staged.
const index = hand.findIndex(card => card.id === id);
hand.push(hand.splice(index, 1)[0]);
return id;
}
/** Exchange one original held Traitor Card for a physical reserve/held identity. */
export function holdOccupyCardsTraitor(game: Game, owner: string, identity: string): void {
const player = occupyCardsPlayer(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;
} else {
const donor = game.players.find(p => p.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;
}
}
function leader(game: Game, owner: string, strongest: boolean, traitorable = false): string {
const physical = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)]);
const available = occupyCardsPlayer(game, owner).leaders.filter(l =>
!l.dead && !l.usedAt && (!traitorable || physical.has(l.id)));
available.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
assert.ok(available[0], 'Use an available original physical leader.');
return available[0].id;
}
export type OccupyCardsOutcome = 'win' | 'loss' | 'soleTraitor' | 'opposingTraitor' |
'mutualTraitors' | 'explosion';
export type OccupyCardsOptions = EcazOccupyFixtureOptions & {
lead?: 'ecaz' | 'ally';
holder?: 'lead' | 'opponent';
card?: Extract<EcazTreacheryCardId, 'ecaz-reinforcements' | 'ecaz-harass-withdraw'>;
opponentCard?: Extract<EcazTreacheryCardId, 'ecaz-reinforcements'>;
slot?: 'weapon' | 'defense';
outcome?: OccupyCardsOutcome;
canceled?: boolean;
variableDial?: number;
support?: number;
opponentDial?: number;
opponentSupport?: number;
/** Find real equal-strength leaders for a decisive +2 score comparison. */
equalLeaders?: boolean;
actorLeader?: string;
returns?: HarassWithdrawSelection;
/** One native battle location, selected only after the fixture chooses its storm-safe sector. */
returnedForces?: HarassWithdrawForces;
losses?: { normal: number; elite: number };
substitute?: number;
funding?: number;
cleanupShield?: boolean;
/** Return at the actual Face Dance offer, after ordinary cleanup. */
stopAtFaceDance?: boolean;
};
export type OccupyCardsCase = {
fixture: EcazOccupyFixture;
actor: string;
holder: string;
card: string;
shield?: string;
actorLeader: string;
opponentLeader: string;
before: Game;
revealed: Game;
afterVotes: Game;
game: Game;
};
/** Consume native aftermath offers only; never manufacture a phase or receipt. */
export function finishOccupyCardsAftermath(state: Game, options: Pick<OccupyCardsOptions,
'losses' | 'substitute' | 'stopAtFaceDance'> = {}, discardCard?: string): Game {
let game = state;
for (let attempt = 0; attempt < 100; attempt++) {
const decision = game.decision;
if (game.response) game = allowEcazOccupyResponses(game);
else if (decision?.kind === 'battleLosses') {
const choice = options.losses ? decision.options.findIndex(loss =>
loss.normal === options.losses!.normal && loss.elite === options.losses!.elite) : 0;
assert.ok(choice >= 0, 'The original typed commitment must offer this physical loss allocation.');
game = applyAction(game, decision.player, { type: 'decision', choice });
} else if (decision?.kind === 'ixSubstitution') {
const count = options.substitute ?? 0;
const key = Object.keys(decision.losses).find(location => decision.losses[location] >= count);
if (count) {
assert.ok(key, 'A real lost Cyborg sector must fund substitution.');
game = applyAction(game, decision.player, { type: 'decision',
sources: { [key]: count }, recover: { [key]: count } });
} else game = applyAction(game, decision.player, { type: 'decision', decline: true });
} else if (decision?.kind === 'battleCards') {
const discard = discardCard && decision.cards.includes(discardCard) ? [discardCard] : [];
game = applyAction(game, decision.player, { type: 'decision', discard });
} else if (decision?.kind === 'choamMarket') {
game = applyAction(game, decision.player, { type: 'decision', done: true });
} else if (game.pendingTreacheryDiscard) {
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
} else if (decision?.kind === 'faceDance' && options.stopAtFaceDance) return game;
else {
assert.equal(game.battle, null, 'The original sealed plans and votes must resolve.');
assert.equal(game.decision, null, 'Every required native aftermath offer must finish.');
return game;
}
}
throw new Error('The original card battle aftermath did not finish.');
}
/** Original family deal, real Nexus alliance, shared entry and actual lead choice.
* Only conserved physical hand/Traitor Card exchanges expose each printed rule. */
export function ecazOccupyCardsCase(options: OccupyCardsOptions = {}): OccupyCardsCase {
const lead = options.lead ?? 'ecaz';
const outcome = options.outcome ?? 'win';
const slot = options.slot ?? 'weapon';
const cardId = options.card ?? 'ecaz-harass-withdraw';
const fixture = ecazOccupyFixture({ ...options,
ecazTreachery: options.initial ? options.initial.ecazTreachery === true : true,
ecazForces: options.ecazForces ?? 5, allyFaction: options.allyFaction ?? 'guild',
opponentFaction: options.opponentFaction ?? 'emperor' });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const holder = options.holder === 'opponent' ? fixture.opponent : actor;
assert.equal(fixture.initial.ecazTreachery, true, 'This case requires an originally selected physical card variant.');
const card = stageCard(fixture.game, holder, c => c.id === cardId);
let game = options.canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
game = allowEcazOccupyResponses(game);
if (game.decision?.kind === 'choamBattleFunding')
game = applyAction(game, game.decision.player, { type: 'decision', amount: options.funding ?? 0 });
else assert.equal(options.funding ?? 0, 0, 'Funding requires the genuine CHOAM offer.');
game = openEcazOccupyPlans(game);
const profile = viewGame(game, actor).battle?.ecazOccupy?.profile;
assert.ok(profile);
let actorLeader = options.actorLeader ?? leader(game, actor, outcome !== 'loss',
outcome === 'opposingTraitor' || outcome === 'mutualTraitors');
let opponentLeader = leader(game, fixture.opponent, outcome === 'loss',
outcome === 'soleTraitor' || outcome === 'mutualTraitors');
if (options.stopAtFaceDance && !options.actorLeader) {
const dancers = game.players.find(p => p.faction === 'tleilaxu')?.faceDancers;
const candidate = occupyCardsPlayer(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
dancers?.some(c => !c.revealed && c.leader === l.id));
assert.ok(candidate, 'Use the original first-draw Face Dancer of the real selected leader.');
actorLeader = candidate.id;
}
if (options.equalLeaders) {
const own = occupyCardsPlayer(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
occupyCardsPlayer(game, fixture.opponent).leaders.some(other =>
!other.dead && !other.usedAt && other.strength === l.strength));
assert.ok(own, 'The actual faction rosters must supply equal-strength leaders.');
actorLeader = own.id;
opponentLeader = occupyCardsPlayer(game, fixture.opponent).leaders.find(l =>
!l.dead && !l.usedAt && l.strength === own.strength)!.id;
}
const voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
if (outcome === 'soleTraitor' || outcome === 'mutualTraitors')
holdOccupyCardsTraitor(game, voter, opponentLeader);
if (outcome === 'opposingTraitor' || outcome === 'mutualTraitors')
holdOccupyCardsTraitor(game, fixture.opponent, actorLeader);
const ownCards: { weapon?: string; defense?: string } = {};
const enemyCards: { weapon?: string; defense?: string } = {};
(holder === actor ? ownCards : enemyCards)[slot] = card;
if (options.opponentCard)
enemyCards.weapon = stageCard(game, fixture.opponent, c => c.id === options.opponentCard);
let shield: string | undefined;
if (options.cleanupShield) {
assert.equal(slot, 'weapon', 'The actual shield occupies the other legal slot.');
shield = stageCard(game, actor, c => c.kind === 'shield');
ownCards.defense = shield;
}
if (outcome === 'explosion') {
const lasgunOwner = slot === 'defense' ? holder : holder === actor ? fixture.opponent : actor;
const shieldOwner = lasgunOwner === actor ? fixture.opponent : actor;
(lasgunOwner === actor ? ownCards : enemyCards).weapon =
stageCard(game, lasgunOwner, c => c.kind === 'lasgun');
(shieldOwner === actor ? ownCards : enemyCards).defense =
stageCard(game, shieldOwner, c => c.kind === 'shield');
}
const variable = options.variableDial ?? 2;
const free = occupyCardsPlayer(game, profile.forceOwner).faction === 'fremen';
const support = options.support ?? (free ? 0 : variable);
const opponentDial = options.opponentDial ?? (outcome === 'loss' ? 8 : 0);
const before = structuredClone(game);
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
support, allyPayment: options.funding ?? 0, leader: actorLeader, ...ownCards });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentDial,
support: options.opponentSupport ?? opponentDial, leader: opponentLeader, ...enemyCards });
const revealed = structuredClone(game);
if (game.decision?.kind === 'harassWithdraw') {
const decision = game.decision;
const offer = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(offer, 'Use only the actual revealed physical allocation offer.');
const returns = options.returns ?? (options.returnedForces ?
{ [fixture.location]: options.returnedForces } :
defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support));
game = applyAction(game, decision.player, { type: 'decision', event: decision.event, returns });
}
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
const battle = viewGame(game, actor).battle;
assert.ok(battle);
const next = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!next) break;
const call = next === voter && (outcome === 'soleTraitor' || outcome === 'mutualTraitors') ||
next === fixture.opponent && (outcome === 'opposingTraitor' || outcome === 'mutualTraitors');
game = applyAction(game, next, { type: 'traitorCall', call });
}
const afterVotes = structuredClone(game);
game = finishOccupyCardsAftermath(game, options, shield);
return { fixture, actor, holder, card, shield, actorLeader, opponentLeader,
before, revealed, afterVotes, game };
}
+13 -4
View File
@@ -6,12 +6,15 @@ import { TERRITORIES, location as locationKey, splitLocation } from '../game/boa
import { finishToMovement, advanceToNextStorm } from './fixture-advanced-source';
import type { FactionId } from '../game/catalog';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { traitorDeck } from '../game/traitors';
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
export type EcazOccupyFixtureFaction = Exclude<FactionId, 'richese'>;
export type EcazOccupyFixtureOptions = {
initial?: Game;
/** Independent physical three-card variant, chosen before the original deal. */
ecazTreachery?: boolean;
ecazForces?: number;
allyFaction?: EcazOccupyFixtureFaction;
order?: 'ecaz-first' | 'ally-first' | 'opponent-first';
@@ -105,7 +108,7 @@ export function finishEcazOccupySetup(state: Game, faceDancerFaction?: EcazOccup
export function settleEcazOccupyArrival(state: Game, accompany = false): Game {
let game = state;
for (let attempt = 0; attempt < 50 && (game.response || game.decision); attempt++) {
if (accompany && game.decision?.kind === 'advisor')
if (accompany && !game.response && game.decision?.kind === 'advisor')
game = applyAction(game, game.decision.player,
{ type: 'decision', accept: true, accompany: true, amount: 1 });
else game = step(game);
@@ -178,14 +181,17 @@ export function createEcazOccupySetup(options: EcazOccupyFixtureOptions = {}): G
if (options.expansions) assert.deepEqual(setup.expansions, options.expansions);
assert.ok(setup.expansions.includes('ecaz') && new Set(setup.expansions).size === setup.expansions.length);
assert.equal(setup.turn, 1, 'Captured Occupy input is never a started-game retrofit.');
assert.ok(!setup.ecazTreachery && !setup.homeworlds && !setup.nexusCards &&
if (options.ecazTreachery !== undefined)
assert.equal(setup.ecazTreachery === true, options.ecazTreachery);
assert.ok(!setup.homeworlds && !setup.nexusCards &&
!setup.leaderSkills && !setup.strongholdCards && !setup.techTokens &&
!setup.discoveryEnabled && !setup.discoveries && !setup.discoveryStash &&
!setup.greatMaker && !setup.moritaniAssassinatePreview,
'Captured Occupy inputs admit the explicit ordinary Advanced profile only.');
'Captured Occupy inputs admit only the selected original Advanced family and independent card variant.');
assert.deepEqual([...setup.deck, ...setup.discard, ...(setup.ixSetupCards ?? []),
...setup.players.flatMap(p => p.hand)].map(card => card.id).sort(),
treacheryDeck(setup.expansions).map(card => card.id).sort(),
[...treacheryDeck(setup.expansions), ...(setup.ecazTreachery ? ecazTreacheryCards() : [])]
.map(card => card.id).sort(),
'The original captured deal must retain every selected physical card exactly once.');
} else {
const roster: EcazOccupyFixtureFaction[] = options.roster ?? ['ecaz', allyFaction, opponentFaction];
@@ -206,6 +212,9 @@ export function createEcazOccupySetup(options: EcazOccupyFixtureOptions = {}): G
if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + firstCircle)
setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + firstCircle });
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
// Label the independent variant in this fresh audit configuration only,
// immediately before its one original setup/deal; never retrofit a capture.
if (options.ecazTreachery) setup.ecazTreachery = true;
setup = initializeEcazOccupyGameForAudit(setup);
}
assert.ok(setup.players.some(p => p.faction === 'ecaz') &&
+4 -7
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, viewGame, RuleError, type Action, type Game } from '../game/engine';
import { quoteHarassWithdraw } from '../game/harass-withdraw';
import {
quoteBattleResolution,
@@ -421,11 +421,7 @@ void test('co-present reciprocal Ecaz allies are publicly guarded before sealing
g.players[2].ally = g.players[side].id;
g.players[2].forces = { 'arrakeen:10': 3 };
g.players[2].reserves = 17;
assert.match(
viewGame(g, 'a').battle!.harassWithdraw!.blocked!,
/combined-army/,
);
reject(g, 'a', ownPlan(g), /combined-army/);
assert.throws(() => applyAction(g, 'a', ownPlan(g)), RuleError);
// The guard depends on the visible coalition, not its cards or leader choice.
const hidden = structuredClone(g);
takeHarassCard(
@@ -440,7 +436,8 @@ void test('co-present reciprocal Ecaz allies are publicly guarded before sealing
g.players[2].forces = {};
g.players[2].reserves = 20;
assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null);
assert.doesNotThrow(() => applyAction(g, 'a', ownPlan(g)));
const sealed = applyAction(g, 'a', ownPlan(g));
assert.ok(viewGame(sealed, 'a').battle!.submitted.includes('a'));
harassCustody(g);
}
});
+8 -13
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, viewGame, RuleError, type Action, type Game } from '../game/engine';
import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const cardId = 'ecaz-reinforcements';
@@ -56,15 +56,12 @@ void test('Reinforcements consumes exactly three own normal reserves in either c
type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
leader: 'atreides-0', weapon: null, defense: null,
});
assert.equal(viewGame(sealed, 'a').battle?.reinforcements?.blocked,
'Choose Reinforcements before sealing your Battle Plan.');
const done = resolve(sealed);
assert.equal(done.battle, null);
assert.equal(done.players[0].reserves, 12);
assert.equal(done.players[0].tanks >= 3, true);
assert.equal(done.players[0].battleLosses >= 3, true);
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces')).length, 1);
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)) as Game, 'a'), viewGame(done, 'a'));
}
});
@@ -93,7 +90,7 @@ void test('paired Ecaz/Moritani battles spend one physical Reinforcements cost w
}
});
void test('co-present Ecaz allied armies do not open the unintegrated Reinforcements battle path', () => {
void test('co-present Ecaz card armies outside the opted-in Advanced profile remain unsupported', () => {
const g = harassWithdrawGame({ advanced: true, factions: ['moritani', 'atreides', 'ecaz'] });
g.players[0].ally = 't';
g.players[2].ally = 'a';
@@ -101,10 +98,9 @@ void test('co-present Ecaz allied armies do not open the unintegrated Reinforcem
g.players[2].reserves = 19;
takeHarassCard(g, 'a', cardId);
harassCustody(g);
assert.match(viewGame(g, 'a').battle?.reinforcements?.blocked ?? '', /co-present Ecaz allies/);
reject(g, 'a', {
assert.throws(() => applyAction(g, 'a', {
type: 'battlePlan', dial: 1, support: 1, leader: 'moritani-0', weapon: cardId,
}, /co-present Ecaz allies/);
}), RuleError);
});
void test('opposing physical Harass plan resolves with Reinforcements instead of trapping both revealed plans', () => {
@@ -145,7 +141,6 @@ void test('Reinforcements normal-first cost preserves elite subpool and is paid
assert.equal(done.players[0].elites!.reserves, 0);
assert.equal(done.players[0].elites!.tanks, 5);
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces (2 elite)')).length, 1);
});
void test('insufficient reserves, unsupported module, and a paired special reject without mutating the original game', () => {
@@ -165,16 +160,16 @@ void test('insufficient reserves, unsupported module, and a paired special rejec
reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId,
defense: 'ecaz-harass-withdraw' }, /cannot share/);
g.sandtrout = {} as Game['sandtrout'];
reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }, /standalone Ecaz card variant/);
assert.throws(() => applyAction(g, 'a',
{ type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }), RuleError);
});
void test('other expansion factions remain ineligible for the paired Ecaz card prototype', () => {
for (const owner of ['ixians', 'choam'] as const) {
const g = harassWithdrawGame({ advanced: true, factions: [owner, 'atreides', 'harkonnen'] });
takeHarassCard(g, 'a', cardId);
assert.match(viewGame(g, 'a').battle?.reinforcements?.blocked ?? '', /classic or Ecaz\/Moritani/);
reject(g, 'a', {
assert.throws(() => applyAction(g, 'a', {
type: 'battlePlan', dial: 0, support: 0, leader: `${owner}-0`, weapon: cardId,
}, /classic or Ecaz\/Moritani/);
}), RuleError);
}
});