Restore fresh Ecaz coalition and Advanced Moritani initialization; retain printed native continuations and selected-family card eligibility. Validate Mentat fallback hands against explicitly selected independent cards. Frozen25-file274/274, types/lint/build and genuine runtime/mobile entry proofs pass. No save retrofit or deployment.
363 lines
20 KiB
TypeScript
363 lines
20 KiB
TypeScript
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 { pinDeterministicRandom } from './deterministic-random';
|
||
import {
|
||
createEcazOccupySetup, ecazOccupyTerritoryForces, type EcazOccupyFixtureOptions,
|
||
} from './fixture-ecaz-occupy';
|
||
import {
|
||
ecazOccupyCardsCase, finishOccupyCardsAftermath, occupyCardsPlayer as seat,
|
||
type OccupyCardsCase,
|
||
} from './fixture-ecaz-occupy-cards';
|
||
|
||
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`, (t) => {
|
||
pinDeterministicRandom(t, 62);
|
||
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);
|
||
});
|
||
|