213 lines
15 KiB
TypeScript
213 lines
15 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { quoteEcazOccupyBattle, quoteEcazOccupyDial, type EcazOccupyBattleInput } from '../game/ecaz-occupy-battle';
|
|
import { ecazOccupyRicheseFixture, occupyRicheseSeat, openOccupyRichese,
|
|
nextOccupyRichese, finishOccupyRichese, type EcazOccupyRicheseFixture } from './fixture-ecaz-occupy-richese';
|
|
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
|
|
|
function conserve(game: Game): void {
|
|
for (const player of game.players) {
|
|
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20,
|
|
`${player.faction} counters retain their original physical owner.`);
|
|
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
|
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), player.faction === 'ixians' ? 7 : 5);
|
|
}
|
|
}
|
|
function resolve(fixture: EcazOccupyRicheseFixture, lead: 'ecaz' | 'ally', cancel = false) {
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
let game = openOccupyRichese(fixture, lead, cancel);
|
|
const before = structuredClone(game);
|
|
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
|
const variable = Math.min(2, profile.maxDial - profile.fixedEcazDial);
|
|
const support = profile.forces.normalFixedHalf ? 0 : variable;
|
|
const leader = occupyRicheseSeat(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
|
.sort((a, b) => b.strength - a.strength)[0];
|
|
const enemyLeader = occupyRicheseSeat(game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt)
|
|
.sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(leader && enemyLeader);
|
|
const concealed = structuredClone(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
|
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable, support, leader: leader.id });
|
|
assert.equal(game.battle!.plans[actor].leader, leader.id, 'The selected faction commits its real native leader.');
|
|
assert.equal(game.battle!.revealed, false);
|
|
assert.deepEqual(occupyRicheseSeat(game, fixture.richese).noField!.deployed, concealed,
|
|
'The first sealed plan cannot reveal its physical partner marker.');
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).reserves, occupyRicheseSeat(before, fixture.richese).reserves);
|
|
assert.equal(viewGame(game, fixture.opponent).battle!.plans[actor], undefined);
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id });
|
|
assert.equal(game.battle!.revealed, true);
|
|
assert.equal(game.battle!.plans[actor].leader, leader.id);
|
|
const revealed = structuredClone(game);
|
|
if (fixture.materialized !== null) {
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).noField!.deployed, null);
|
|
assert.equal(occupyRicheseSeat(before, fixture.richese).reserves - occupyRicheseSeat(game, fixture.richese).reserves,
|
|
fixture.materialized, 'Both sealed plans materialize only available owner reserves, once.');
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, fixture.materialized);
|
|
}
|
|
game = finishOccupyRichese(game);
|
|
assert.equal(game.battle, null);
|
|
assert.equal(game.decision, null);
|
|
assert.equal(game.lastBattleContext!.result, 'normal');
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.equal(occupyRicheseSeat(game, actor).leaders.find(l => l.id === leader.id)!.usedAt, fixture.territory);
|
|
assert.ok(game.phase >= 7, 'The mandatory coalition battle proceeds to actual collection.');
|
|
conserve(game);
|
|
return { game, before, revealed, actor, profile, variable, support };
|
|
}
|
|
|
|
const cityIncome = (game: Game, actor: string, location: string) =>
|
|
(occupyRicheseSeat(game, actor).forces[location] ?? 0) > 0 ? 2 : 0;
|
|
for (const lead of ['ecaz', 'ally'] as const) void test(`Six-native Richese-present Carthag: Ecaz+Ix vs CHOAM with ${lead} lead resolves`, () => {
|
|
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'ixians', opponentFaction: 'choam', omitPreview: true });
|
|
assert.deepEqual(fixture.initial.players.map(p => p.faction), ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']);
|
|
assert.equal(fixture.initial.status, 'setup');
|
|
assert.deepEqual(fixture.initial.richeseCache, richeseCards(), 'The fresh initializer creates the canonical native cache.');
|
|
assert.deepEqual([...fixture.initial.deck, ...(fixture.initial.ixSetupCards ?? []), ...fixture.initial.players.flatMap(p => p.hand)]
|
|
.map(c => c.id).sort(), treacheryDeck(fixture.initial.expansions).map(c => c.id).sort());
|
|
assert.ok(fixture.afterSetup.players.every(p => p.leaders.length > 0 &&
|
|
(p.faction === 'tleilaxu' ? p.faceDancers!.length > 0 : p.traitors.length > 0)));
|
|
assert.equal(fixture.beforeBattle.ecazOccupyPreview, undefined);
|
|
const { game, before, actor } = resolve(fixture, lead);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 2);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ally).forces[fixture.location] ?? 0, 0,
|
|
'Four unsupported original Suboids contribute two strength and all four are lost.');
|
|
assert.equal(occupyRicheseSeat(game, fixture.ally).tanks - occupyRicheseSeat(before, fixture.ally).tanks, 4);
|
|
assert.equal(occupyRicheseSeat(game, actor).spice,
|
|
occupyRicheseSeat(before, actor).spice + cityIncome(game, actor, fixture.location));
|
|
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks, 8);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const) for (const cancel of [false, true])
|
|
void test(`Ordinary Richese variable pool, ${lead} lead, Occupy ${cancel ? 'prevented' : 'active'} preserves native owners`, () => {
|
|
const fixture = ecazOccupyRicheseFixture();
|
|
const { game, before, actor } = resolve(fixture, lead, cancel);
|
|
const ecazLoss = cancel ? lead === 'ecaz' ? 2 : 0 : 3;
|
|
const allyLoss = cancel && lead === 'ecaz' ? 0 : 2;
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 5 - ecazLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, ecazLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ally).forces[fixture.location], 4 - allyLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ally).tanks - occupyRicheseSeat(before, fixture.ally).tanks, allyLoss);
|
|
assert.equal(occupyRicheseSeat(before, actor).spice - occupyRicheseSeat(game, actor).spice,
|
|
2 - cityIncome(game, actor, fixture.location));
|
|
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
|
assert.equal(occupyRicheseSeat(game, nonLead).spice,
|
|
occupyRicheseSeat(before, nonLead).spice + cityIncome(game, nonLead, fixture.location));
|
|
assert.equal(occupyRicheseSeat(game, fixture.opponent).forces[fixture.location] ?? 0, 0);
|
|
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks, 8);
|
|
});
|
|
|
|
for (const role of ['ally', 'opponent'] as const) for (const marker of [0, 3, 5] as const)
|
|
for (const reserves of [0, 2, 20]) for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Concealed Richese ${role} ${marker}, reserves ${reserves}, ${lead} lead resolves only after both seals`, () => {
|
|
const fixture = ecazOccupyRicheseFixture({ allyFaction: role === 'ally' ? 'richese' : 'guild',
|
|
opponentFaction: role === 'opponent' ? 'richese' : 'guild', marker, richeseReserves: reserves });
|
|
conserve(fixture.game);
|
|
const prepared = openOccupyRichese(fixture, lead);
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const rival = role === 'ally' ? fixture.opponent : actor;
|
|
assert.equal(viewGame(prepared, rival).battle!.opponentForces, null,
|
|
'The rival cannot receive a prospective denomination through its opponent pool.');
|
|
if (role === 'ally') {
|
|
assert.equal(viewGame(prepared, fixture.opponent).battle!.ecazOccupy!.profile, null,
|
|
'Rival max dial and physical ally counters must not disclose the marker.');
|
|
assert.equal(viewGame(prepared, actor).battle!.ownForces!.normal, Math.min(marker, reserves));
|
|
}
|
|
const { game, before, actor: selected, support } = resolve(fixture, lead);
|
|
const materialized = Math.min(marker, reserves);
|
|
const richeseLoss = role === 'opponent' ? materialized : Math.min(2, materialized);
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, materialized - richeseLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, richeseLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).reserves, reserves - materialized);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 2);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
|
assert.equal(occupyRicheseSeat(before, selected).spice - occupyRicheseSeat(game, selected).spice,
|
|
support - cityIncome(game, selected, fixture.location));
|
|
assert.equal(occupyRicheseSeat(game, fixture.opponent).forces[fixture.location] ?? 0, 0);
|
|
assert.equal(occupyRicheseSeat(game, fixture.opponent).tanks - occupyRicheseSeat(before, fixture.opponent).tanks,
|
|
role === 'opponent' ? materialized : 8);
|
|
});
|
|
|
|
void test('Original concealed Richese presence permits a zero prospective pool, never an ordinary empty ally', () => {
|
|
const forces = { normal: 0, elite: 0, eliteStrength: 2 as const, freeSupport: false };
|
|
const input: EcazOccupyBattleInput = { advanced: true, battleOrderActor: 'ecaz', lead: 'ecaz', canceled: false,
|
|
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'richese', forces: { ...forces, normal: 5 } },
|
|
ally: { id: 'richese', faction: 'richese', ally: 'ecaz', forces } };
|
|
const saved = structuredClone(input);
|
|
assert.throws(() => quoteEcazOccupyBattle(input), /storm-connected fighters/);
|
|
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, noFieldPresence: true } });
|
|
assert.equal(profile.maxDial, 3);
|
|
assert.equal(profile.maxSupport, 0);
|
|
assert.deepEqual(quoteEcazOccupyDial(profile, 3, 0).options, [{ normal: 0, elite: 0, paidNormal: 0, paidElite: 0 }]);
|
|
assert.throws(() => quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'guild', noFieldPresence: true } }),
|
|
/Only an original concealed Richese ally/);
|
|
assert.deepEqual(input, saved);
|
|
});
|
|
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const) void test(`Atreides ordinary Prescience cannot inspect concealed partner's ${lead} plan dial`, () => {
|
|
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
|
|
const event = fixture.game.decision.event;
|
|
let game = applyAction(fixture.game, fixture.ecaz, { type: 'decision', event, lead: actor });
|
|
for (let attempt = 0; attempt < 100 && game.battle?.preparation?.kind !== 'prescience'; attempt++)
|
|
game = nextOccupyRichese(game);
|
|
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
|
let saved = structuredClone(game);
|
|
// A random response window can still be open here; settle it before the
|
|
// inspection attempt so the rejection is the inspection authority itself.
|
|
for (let i = 0; game.response && i < 6; i++) {
|
|
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
|
|
assert.ok(responder, 'The open response window keeps a legal responder.');
|
|
game = applyAction(game, responder!.id, { type: 'passResponse' });
|
|
}
|
|
saved = structuredClone(game);
|
|
assert.throws(() => applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
|
assert.deepEqual(game, saved);
|
|
});
|
|
|
|
for (const marker of [0, 5] as const) for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Prevented Occupy with marker ${marker} retains ${lead}'s own pool and original physical reveal`, () => {
|
|
const fixture = ecazOccupyRicheseFixture({ marker, richeseReserves: 2 });
|
|
const { game, before, actor, support } = resolve(fixture, lead, true);
|
|
const materialized = Math.min(marker, 2);
|
|
const ecazLoss = lead === 'ecaz' ? 2 : 0;
|
|
const richeseLoss = lead === 'ally' ? materialized : 0;
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).forces[fixture.location], 5 - ecazLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, ecazLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).forces[fixture.location] ?? 0, materialized - richeseLoss);
|
|
assert.equal(occupyRicheseSeat(game, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, richeseLoss);
|
|
assert.equal(occupyRicheseSeat(before, actor).spice - occupyRicheseSeat(game, actor).spice,
|
|
support - cityIncome(game, actor, fixture.location));
|
|
});
|
|
|
|
void test('Prevented Occupy makes selected Ecaz own dial and whole plan inspectable without revealing Richese', () => {
|
|
const fixture = ecazOccupyRicheseFixture({ opponentFaction: 'atreides', marker: 5, richeseReserves: 2 });
|
|
const counter = 'choam';
|
|
const karama = stageEcazOccupyCard(fixture.game, counter, card => card.effect === 'karama');
|
|
assert.ok(fixture.game.decision?.kind === 'ecazBattleLead');
|
|
const event = fixture.game.decision.event;
|
|
let game = applyAction(fixture.game, fixture.ecaz, { type: 'decision', event, lead: fixture.ecaz });
|
|
game = applyAction(game, counter, { type: 'card', mode: 'cancel', card: karama });
|
|
for (let attempt = 0; attempt < 100 && game.battle?.preparation?.kind !== 'prescience'; attempt++)
|
|
game = nextOccupyRichese(game);
|
|
assert.equal(game.battle!.preparation!.kind, 'prescience');
|
|
const ownProfile = viewGame(game, fixture.ecaz).battle!.ecazOccupy!.profile!;
|
|
assert.equal(ownProfile.forceOwner, fixture.ecaz);
|
|
assert.equal(ownProfile.forces.normal, 5);
|
|
const asked = applyAction(game, fixture.opponent, { type: 'prescience', field: 'dial' });
|
|
assert.equal(asked.battle!.prescience!.field, 'dial');
|
|
assert.equal(asked.battle!.preparation!.owner, fixture.ecaz);
|
|
assert.ok(occupyRicheseSeat(asked, fixture.richese).noField!.deployed);
|
|
game = applyAction(game, fixture.opponent, { type: 'declineBattlePower' });
|
|
for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'fullPlanOffer'; attempt++) game = nextOccupyRichese(game);
|
|
assert.equal(game.decision!.kind, 'fullPlanOffer');
|
|
const inspectionCard = occupyRicheseSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama')!;
|
|
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'special', card: inspectionCard.id, target: fixture.ecaz });
|
|
assert.deepEqual(game.battle!.fullPlan, { owner: fixture.opponent, target: fixture.ecaz });
|
|
assert.ok(occupyRicheseSeat(game, fixture.richese).noField!.deployed);
|
|
});
|