196 lines
12 KiB
TypeScript
196 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
|
import { ecazOccupyRicheseFixture, occupyRicheseSeat, openOccupyRichese,
|
|
finishOccupyRichese, type EcazOccupyRicheseFixture } from './fixture-ecaz-occupy-richese';
|
|
|
|
type Lead = 'ecaz' | 'ally';
|
|
const seat = occupyRicheseSeat;
|
|
const at = (game: Game, actor: string, fixture: EcazOccupyRicheseFixture) =>
|
|
seat(game, actor).forces[fixture.location] ?? 0;
|
|
const income = (game: Game, actor: string, fixture: EcazOccupyRicheseFixture) =>
|
|
at(game, actor, fixture) > 0 ? 2 : 0;
|
|
|
|
function conserved(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} retains twenty counters with its original physical owner.`);
|
|
}
|
|
}
|
|
|
|
function leader(game: Game, actor: string, strongest: boolean) {
|
|
const available = seat(game, actor).leaders.filter(candidate => !candidate.dead && !candidate.usedAt)
|
|
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
|
assert.ok(available[0]);
|
|
return available[0];
|
|
}
|
|
|
|
/** Both plans use native leaders without weapons; native aftermath declines
|
|
* Traitor calls rather than altering the original deal's card custody. */
|
|
function settle(fixture: EcazOccupyRicheseFixture, lead: Lead, cancel: boolean, enemyFirst: boolean) {
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const originalMarker = structuredClone(seat(fixture.game, fixture.richese).noField!);
|
|
assert.ok(originalMarker.deployed, 'The fixture shipped an actual original No-Field token.');
|
|
assert.equal(originalMarker.lastShipped, originalMarker.deployed.tokenId);
|
|
const karama = seat(fixture.game, fixture.opponent).hand.find(card => card.effect === 'karama');
|
|
assert.ok(karama);
|
|
let game = openOccupyRichese(fixture, lead, cancel);
|
|
const before = structuredClone(game);
|
|
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
|
|
assert.ok(profile);
|
|
assert.equal(profile.canceled, cancel);
|
|
assert.equal(profile.payer, actor);
|
|
assert.equal(profile.planOwner, actor);
|
|
assert.equal(profile.forceOwner, cancel ? actor : fixture.ally);
|
|
assert.equal(profile.fixedEcazDial, cancel ? 0 : 3,
|
|
'The original five Ecaz counters contribute their fixed ceiling, independent of the selected lead.');
|
|
assert.deepEqual(seat(game, fixture.richese).noField, originalMarker);
|
|
if (cancel) {
|
|
assert.ok(!seat(game, fixture.opponent).hand.some(card => card.id === karama.id));
|
|
assert.ok(game.discard.some(card => card.id === karama.id),
|
|
'Prevention spends the opposing seat\'s actual original Karama in the native response.');
|
|
}
|
|
|
|
const variable = cancel && actor === fixture.richese ? profile.maxDial
|
|
: cancel && fixture.opponent === fixture.richese && lead === 'ecaz' ? 3 : 2;
|
|
const support = variable;
|
|
const enemyPool = viewGame(game, fixture.opponent).battle!.ownForces!.normal;
|
|
const enemyDial = fixture.opponent === fixture.richese ? enemyPool : 0;
|
|
const enemySupport = enemyDial;
|
|
const ownLeader = leader(game, actor, true);
|
|
const enemyLeader = leader(game, fixture.opponent, false);
|
|
const dial = profile.fixedEcazDial + variable;
|
|
assert.ok(dial + ownLeader.strength > enemyDial + enemyLeader.strength,
|
|
'The original native leaders make this controlled battle a normal coalition victory.');
|
|
const ownPlan = { type: 'battlePlan' as const, dial, support, leader: ownLeader.id };
|
|
const enemyPlan = { type: 'battlePlan' as const, dial: enemyDial, support: enemySupport, leader: enemyLeader.id };
|
|
const first = enemyFirst ? fixture.opponent : actor;
|
|
const second = enemyFirst ? actor : fixture.opponent;
|
|
game = applyAction(game, first, enemyFirst ? enemyPlan : ownPlan);
|
|
assert.equal(game.battle!.revealed, false);
|
|
assert.equal(viewGame(game, second).battle!.plans[first], undefined,
|
|
'The first native seal does not expose the other side\'s plan.');
|
|
assert.deepEqual(seat(game, fixture.richese).noField, originalMarker);
|
|
assert.equal(seat(game, fixture.richese).reserves, seat(before, fixture.richese).reserves);
|
|
assert.equal(at(game, fixture.richese, fixture), at(before, fixture.richese, fixture),
|
|
'No prospective counters are placed after only one seal.');
|
|
if (fixture.opponent === fixture.richese) {
|
|
assert.equal(viewGame(game, actor).battle!.opponentForces, null);
|
|
assert.equal(viewGame(game, actor).richeseNoField!.private, null);
|
|
} else if (lead === 'ally') {
|
|
assert.equal(viewGame(game, fixture.opponent).battle!.opponentForces, null);
|
|
assert.equal(viewGame(game, fixture.opponent).battle!.ecazOccupy!.profile, null,
|
|
'A selected Richese plan cannot expose its mixed private maximum before native reveal.');
|
|
}
|
|
conserved(game);
|
|
|
|
game = applyAction(game, second, enemyFirst ? ownPlan : enemyPlan);
|
|
assert.equal(game.battle!.revealed, true);
|
|
assert.equal(game.battle!.plans[actor].leader, ownLeader.id);
|
|
assert.equal(game.battle!.plans[fixture.opponent].dial, enemyDial);
|
|
assert.equal(seat(game, fixture.richese).noField!.deployed, null);
|
|
assert.deepEqual(seat(game, fixture.richese).noField!.tokens, originalMarker.tokens);
|
|
assert.equal(seat(game, fixture.richese).noField!.lastShipped, originalMarker.lastShipped);
|
|
assert.equal(at(game, fixture.richese, fixture), at(before, fixture.richese, fixture) + fixture.materialized!,
|
|
'Only the second original seal adds the reserve-capped marker to the existing ordinary army.');
|
|
assert.equal(seat(before, fixture.richese).reserves - seat(game, fixture.richese).reserves, fixture.materialized);
|
|
if (fixture.opponent === fixture.richese) {
|
|
assert.equal(viewGame(game, actor).battle!.opponentForces!.normal, enemyPool,
|
|
'The opposing mixed total becomes public at its original reveal, not before.');
|
|
}
|
|
const revealed = structuredClone(game);
|
|
conserved(revealed);
|
|
game = finishOccupyRichese(game);
|
|
assert.equal(game.battle, null);
|
|
assert.equal(game.lastBattleContext!.result, 'normal');
|
|
assert.equal(game.lastBattleContext!.winner, actor);
|
|
assert.equal(seat(game, actor).leaders.find(candidate => candidate.id === ownLeader.id)!.usedAt, fixture.territory);
|
|
assert.deepEqual(seat(game, fixture.richese).noField, seat(revealed, fixture.richese).noField,
|
|
'Native settlement cannot restore the marker or reset its no-repeat shipment history.');
|
|
assert.equal(seat(before, actor).spice - seat(game, actor).spice, support - income(game, actor, fixture),
|
|
'Only the selected lead funds the variable support; the fixed Ecaz contribution is free.');
|
|
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
|
assert.equal(seat(game, nonLead).spice, seat(before, nonLead).spice + income(game, nonLead, fixture));
|
|
assert.equal(seat(before, fixture.opponent).spice - seat(game, fixture.opponent).spice, enemySupport);
|
|
conserved(game);
|
|
return { game, before, profile, actor };
|
|
}
|
|
|
|
const opposingPools = [
|
|
{ marker: 0, richeseReserves: 4, mixedRicheseForces: 2 },
|
|
{ marker: 5, richeseReserves: 2, mixedRicheseForces: 2 },
|
|
{ marker: 3, richeseReserves: 0, mixedRicheseForces: 2 },
|
|
{ marker: 3, richeseReserves: 4, mixedRicheseForces: 1 },
|
|
] as const;
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const) for (const pool of opposingPools)
|
|
void test(`Mixed opposing Richese ${pool.mixedRicheseForces}+No-Field ${pool.marker}, reserves ${pool.richeseReserves}, ${lead} lead settles natively`, () => {
|
|
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'guild', opponentFaction: 'richese', ...pool });
|
|
assert.equal(fixture.game.decision?.kind, 'ecazBattleLead', 'Actual chooseBattle admits the mixed opposing army.');
|
|
conserved(fixture.game);
|
|
const prepared = openOccupyRichese(fixture, lead);
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const total = pool.mixedRicheseForces + Math.min(pool.marker, pool.richeseReserves);
|
|
assert.equal(viewGame(prepared, fixture.richese).battle!.ownForces!.normal, total);
|
|
assert.equal(viewGame(prepared, actor).battle!.opponentForces, null,
|
|
'Visible ordinary counters do not authorize disclosure of the hidden opposing maximum.');
|
|
assert.equal(viewGame(prepared, actor).battle!.ecazOccupy!.profile!.maxDial, 7,
|
|
'The ordinary Guild ally, not the opposing prospective army, supplies the variable Occupy pool.');
|
|
const { game, before } = settle(fixture, lead, false, pool.marker !== 0);
|
|
assert.equal(at(game, fixture.ecaz, fixture), 2);
|
|
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 3);
|
|
assert.equal(at(game, fixture.ally, fixture), 2);
|
|
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 2);
|
|
assert.equal(at(game, fixture.richese, fixture), 0);
|
|
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, total,
|
|
'Normal defeat destroys both the original ordinary counters and the actual reserve-limited reveal.');
|
|
assert.equal(seat(game, fixture.richese).reserves, pool.richeseReserves - fixture.materialized!);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Prevented Occupy against mixed opposing Richese leaves only selected ${lead} losses and payment`, () => {
|
|
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'guild', opponentFaction: 'richese',
|
|
marker: 5, richeseReserves: 2, mixedRicheseForces: 2 });
|
|
const { game, before, profile, actor } = settle(fixture, lead, true, true);
|
|
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : 4);
|
|
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : 4);
|
|
const ecazLoss = lead === 'ecaz' ? 3 : 0;
|
|
const guildLoss = lead === 'ally' ? 2 : 0;
|
|
assert.equal(at(game, fixture.ecaz, fixture), 5 - ecazLoss);
|
|
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazLoss);
|
|
assert.equal(at(game, fixture.ally, fixture), 4 - guildLoss);
|
|
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, guildLoss);
|
|
assert.equal(at(game, fixture.richese, fixture), 0);
|
|
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, 4);
|
|
assert.equal(profile.payer, actor);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const) for (const marker of [0, 5] as const)
|
|
void test(`Prevented Occupy with mixed allied Richese No-Field ${marker} preserves selected ${lead}'s own army`, () => {
|
|
const fixture = ecazOccupyRicheseFixture({ allyFaction: 'richese', opponentFaction: 'guild',
|
|
marker, richeseReserves: 2, mixedRicheseForces: 2 });
|
|
const prepared = openOccupyRichese(fixture, lead, true);
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const total = 2 + Math.min(marker, 2);
|
|
assert.equal(viewGame(prepared, actor).battle!.ownForces!.normal, lead === 'ecaz' ? 5 : total);
|
|
if (lead === 'ecaz') {
|
|
assert.equal(viewGame(prepared, fixture.opponent).battle!.opponentForces!.normal, 5,
|
|
'Prevention restores Ecaz\'s own inspectable army without borrowing any Richese counters.');
|
|
} else {
|
|
assert.equal(viewGame(prepared, fixture.opponent).battle!.ecazOccupy!.profile, null);
|
|
assert.equal(viewGame(prepared, fixture.opponent).battle!.opponentForces, null);
|
|
}
|
|
const { game, before, profile } = settle(fixture, lead, true, lead === 'ecaz');
|
|
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : total);
|
|
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : total);
|
|
const ecazLoss = lead === 'ecaz' ? 2 : 0;
|
|
const richeseLoss = lead === 'ally' ? total : 0;
|
|
assert.equal(at(game, fixture.ecaz, fixture), 5 - ecazLoss);
|
|
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazLoss);
|
|
assert.equal(at(game, fixture.richese, fixture), total - richeseLoss,
|
|
'An unselected Richese army still reveals natively but loses no counters for Ecaz\'s dial.');
|
|
assert.equal(seat(game, fixture.richese).tanks - seat(before, fixture.richese).tanks, richeseLoss);
|
|
assert.equal(seat(game, fixture.richese).reserves, 2 - Math.min(marker, 2));
|
|
assert.equal(seat(game, fixture.opponent).tanks - seat(before, fixture.opponent).tanks, 8);
|
|
});
|