Integrate sourced Treachery cards with Advanced Occupy
This commit is contained in:
1 parent
85caee6c83
commit
cd532222a5
21 files changed
+1226
-99
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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') &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user