268 lines
17 KiB
TypeScript
268 lines
17 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
|
|
import { occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
|
|
import { NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS } from './fixture-no-field-reinforcements';
|
|
import {
|
|
ecazOccupyMixedNoFieldFixture, sealEcazOccupyMixedNoField, finishEcazOccupyMixedNoField,
|
|
type EcazOccupyMixedNoFieldFixture,
|
|
} from './fixture-ecaz-occupy-mixed-no-field';
|
|
|
|
function assertCustody(fixture: EcazOccupyMixedNoFieldFixture, game: Game): void {
|
|
const cards = (state: Game) => [...state.deck, ...state.discard,
|
|
...(state.richeseCache ?? []), ...(state.richeseRemoved ?? []),
|
|
...state.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
|
assert.deepEqual(cards(game), cards(fixture.initial), 'Every original physical card retains custody.');
|
|
const traitors = (state: Game) => [...(state.traitorReserve ?? []),
|
|
...(state.ecazLoyalty?.card ? [state.ecazLoyalty.card] : []),
|
|
...state.players.flatMap(player => player.traitors)].sort();
|
|
assert.deepEqual(traitors(game), traitors(fixture.afterSetup));
|
|
for (const player of game.players) {
|
|
const counters = [player.reserves, player.tanks, ...Object.values(player.forces)];
|
|
assert.equal(counters.reduce((sum, count) => sum + count, 0), 20,
|
|
`${player.faction} keeps its own twenty physical counters.`);
|
|
assert.ok(counters.every(count => Number.isSafeInteger(count) && count >= 0));
|
|
assert.ok(player.hand.length <= 4, 'Conserved staging must not leave a native hand over its limit.');
|
|
}
|
|
}
|
|
function assertFirstSeal(fixture: EcazOccupyMixedNoFieldFixture, first: Game): void {
|
|
const before = occupyRicheseSeat(fixture.preplan, fixture.richese);
|
|
const sealed = occupyRicheseSeat(first, fixture.richese);
|
|
assert.equal(first.battle!.revealed, false);
|
|
assert.deepEqual(sealed.noField, fixture.noField);
|
|
assert.equal(sealed.noFieldEvent, before.noFieldEvent);
|
|
assert.equal(sealed.reserves, before.reserves);
|
|
assert.equal(sealed.forces[fixture.location], fixture.ordinary);
|
|
assert.equal(sealed.tanks, before.tanks);
|
|
assert.equal(viewGame(first, fixture.opponent).battle!.plans[fixture.owner], undefined);
|
|
assertCustody(fixture, first);
|
|
}
|
|
function assertReveal(fixture: EcazOccupyMixedNoFieldFixture, revealed: Game): void {
|
|
const richese = occupyRicheseSeat(revealed, fixture.richese);
|
|
const original = occupyRicheseSeat(fixture.preplan, fixture.richese);
|
|
assert.equal(revealed.battle!.revealed, true);
|
|
assert.equal(richese.noField!.deployed, null);
|
|
assert.equal(richese.noField!.lastShipped, fixture.noField.deployed!.tokenId);
|
|
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
|
|
assert.notEqual(richese.noFieldEvent, original.noFieldEvent);
|
|
assert.equal(richese.forces[fixture.location], fixture.ordinary + fixture.materializes,
|
|
'Both actual seals add native marker counters to, rather than replace, ordinary counters.');
|
|
assert.equal(richese.reserves, original.reserves - fixture.materializes);
|
|
assert.equal(richese.tanks, original.tanks);
|
|
assertCustody(fixture, revealed);
|
|
}
|
|
function assertHistory(fixture: EcazOccupyMixedNoFieldFixture, revealed: Game, done: Game): void {
|
|
const richese = occupyRicheseSeat(done, fixture.richese);
|
|
assert.equal(richese.noField!.deployed, null);
|
|
assert.deepEqual(richese.noField!.tokens, fixture.noField.tokens);
|
|
assert.equal(richese.noField!.lastShipped, fixture.noField.lastShipped);
|
|
assert.equal(richese.noFieldEvent, occupyRicheseSeat(revealed, fixture.richese).noFieldEvent,
|
|
'Withdrawal, losses and aftermath must not perform a second marker revelation.');
|
|
for (const actor of [fixture.ecaz, fixture.richese, fixture.opponent]) {
|
|
const history = viewGame(done, actor).players.find(player => player.id === fixture.richese)!.noField!;
|
|
assert.equal(history.deployed, null);
|
|
assert.equal(history.lastUsed, fixture.marker, 'The actual shipped denomination becomes native public history.');
|
|
}
|
|
assert.equal(done.battle, null);
|
|
assert.equal(done.decision, null);
|
|
assertCustody(fixture, done);
|
|
}
|
|
function assertUtilities(game: Game, used: readonly string[]): void {
|
|
for (const id of [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]) {
|
|
assert.equal(game.discard.filter(card => card.id === id).length, used.includes(id) ? 1 : 0);
|
|
assert.equal(game.players.flatMap(player => player.hand).filter(card => card.id === id).length,
|
|
used.includes(id) ? 0 : 1);
|
|
}
|
|
}
|
|
|
|
// Three denominations, one reserve-capped positive marker, and both selected
|
|
// plan actors. Every case retains positive ordinary Richese physical presence.
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
for (const [marker, reserves] of [[0, 0], [3, 6], [5, 2]] as const)
|
|
void test(`Mixed Richese variable ally: marker ${marker}, reserves ${reserves}, ${lead} lead seals and resolves native counters`, () => {
|
|
const fixture = ecazOccupyMixedNoFieldFixture({ lead, marker, richeseReserves: reserves });
|
|
const actorView = viewGame(fixture.preplan, fixture.owner).battle!;
|
|
const profile = actorView.ecazOccupy!.profile!;
|
|
assert.equal(profile.forceOwner, fixture.richese);
|
|
assert.equal(profile.payer, fixture.owner);
|
|
assert.equal(profile.fixedEcazDial, 3);
|
|
assert.equal(profile.maxDial, 3 + fixture.ordinary + fixture.materializes);
|
|
assert.equal(actorView.ownForces!.normal, fixture.ordinary + fixture.materializes);
|
|
assert.equal(actorView.mixedNoField, true,
|
|
'The selected Ecaz plan also uses its physical Richese variable owner, not the Ecaz counter pool.');
|
|
const rivalView = viewGame(fixture.preplan, fixture.opponent).battle!;
|
|
assert.equal(rivalView.opponentForces, null);
|
|
assert.equal(rivalView.ecazOccupy!.profile, null,
|
|
'Ordinary presence must not disclose the prospective concealed increment.');
|
|
assert.equal(rivalView.mixedNoField, undefined);
|
|
const first = applyAction(fixture.preplan, fixture.owner, fixture.plans.plain);
|
|
assertFirstSeal(fixture, first);
|
|
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
|
|
assertReveal(fixture, revealed);
|
|
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.result, 'normal');
|
|
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
|
const richese = occupyRicheseSeat(done, fixture.richese);
|
|
assert.equal(richese.forces[fixture.location] ?? 0, fixture.ordinary + fixture.materializes - fixture.variableDial);
|
|
assert.equal(richese.tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, fixture.variableDial);
|
|
assert.equal(richese.reserves, reserves - fixture.materializes);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.preplan, fixture.ecaz).tanks, 3);
|
|
assert.equal(occupyRicheseSeat(done, fixture.opponent).tanks - occupyRicheseSeat(fixture.preplan, fixture.opponent).tanks, 8);
|
|
for (const actor of [fixture.ecaz, fixture.richese])
|
|
assert.equal(occupyRicheseSeat(done, actor).spice,
|
|
occupyRicheseSeat(fixture.preplan, actor).spice - (actor === fixture.owner ? fixture.support : 0) +
|
|
((occupyRicheseSeat(done, actor).forces[fixture.location] ?? 0) > 0 ? 2 : 0));
|
|
assertUtilities(done, []);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
void test('Mixed capped ally uses actual unsupported physical losses, while the original Ecaz fixed commitment stays free', () => {
|
|
const fixture = ecazOccupyMixedNoFieldFixture({ lead: 'ecaz', marker: 5, richeseReserves: 2,
|
|
variableDial: 2, support: 0 });
|
|
const revealed = sealEcazOccupyMixedNoField(fixture);
|
|
assertReveal(fixture, revealed);
|
|
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, 4,
|
|
'Two unsupported strength consumes all four real ordinary-plus-revealed counters.');
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.preplan, fixture.ecaz).tanks, 3);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).spice,
|
|
occupyRicheseSeat(fixture.preplan, fixture.ecaz).spice + 2);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Mixed positive marker with ${lead}-held H/R returns only that card user's actual undialed army`, () => {
|
|
const fixture = ecazOccupyMixedNoFieldFixture({ lead });
|
|
const before = fixture.preplan;
|
|
const offer = viewGame(before, fixture.owner).battle!;
|
|
assert.equal(offer.harassWithdraw!.blocked, null);
|
|
assert.equal(offer.reinforcements!.blocked, null);
|
|
assert.equal(offer.reinforcements!.normal, 3);
|
|
if (lead === 'ally') {
|
|
assert.equal(offer.harassWithdraw!.forces.normal, fixture.ordinary + fixture.materializes);
|
|
assert.equal(offer.harassWithdraw!.locations[fixture.location].normal, fixture.ordinary + fixture.materializes);
|
|
assert.equal(offer.reinforcements!.noField!.reservesAfterReveal, 3);
|
|
} else assert.equal(offer.reinforcements!.noField, undefined);
|
|
const first = applyAction(before, fixture.owner, fixture.plans.both);
|
|
assertFirstSeal(fixture, first);
|
|
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
|
|
assertReveal(fixture, revealed);
|
|
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.result, 'normal');
|
|
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
|
const ecaz = occupyRicheseSeat(done, fixture.ecaz);
|
|
const richese = occupyRicheseSeat(done, fixture.richese);
|
|
const returned = lead === 'ecaz' ? 2 : fixture.ordinary + fixture.materializes - fixture.variableDial;
|
|
assert.ok(returned > 0);
|
|
assert.equal(occupyRicheseSeat(done, fixture.owner).reserves,
|
|
occupyRicheseSeat(before, fixture.owner).reserves - (lead === 'ally' ? fixture.materializes : 0) - 3 + returned,
|
|
'Three original reserves are spent before, and cannot be funded by, the future H return.');
|
|
assert.equal(ecaz.tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3 + (lead === 'ecaz' ? 3 : 0),
|
|
'Returning Ecaz undialed counters never reduces or recalculates ceil(original five / two).');
|
|
assert.equal(richese.tanks - occupyRicheseSeat(before, fixture.richese).tanks,
|
|
fixture.variableDial + (lead === 'ally' ? 3 : 0));
|
|
assert.equal(ecaz.forces[fixture.location] ?? 0, lead === 'ecaz' ? 0 : 2);
|
|
assert.equal(richese.forces[fixture.location] ?? 0, lead === 'ally' ? 0 : fixture.ordinary + fixture.materializes - fixture.variableDial);
|
|
const other = lead === 'ecaz' ? fixture.richese : fixture.ecaz;
|
|
assert.equal(occupyRicheseSeat(done, other).reserves,
|
|
occupyRicheseSeat(before, other).reserves - (other === fixture.richese ? fixture.materializes : 0));
|
|
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
|
occupyRicheseSeat(before, fixture.owner).spice - fixture.support);
|
|
assertUtilities(done, [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
void test('Mixed Richese Reinforcements alone spends post-reveal reserves without returning undialed board counters', () => {
|
|
const fixture = ecazOccupyMixedNoFieldFixture();
|
|
const revealed = sealEcazOccupyMixedNoField(fixture, 'reinforcements');
|
|
assertReveal(fixture, revealed);
|
|
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.winner, fixture.richese);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 0);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location], 2);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.preplan, fixture.richese).tanks, 6);
|
|
assertUtilities(done, [NO_FIELD_REINFORCEMENTS]);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
void test('Mixed Richese unfunded FIRST R and H/R plans reject immutably; a real H-only fallback remains legal', () => {
|
|
const fixture = ecazOccupyMixedNoFieldFixture({ marker: 5, richeseReserves: 7 });
|
|
const before = fixture.preplan;
|
|
const saved = structuredClone(before);
|
|
const offer = viewGame(before, fixture.owner).battle!;
|
|
assert.equal(offer.reinforcements!.normal, 2);
|
|
assert.equal(offer.reinforcements!.noField!.reservesAfterReveal, 2);
|
|
assert.ok(offer.reinforcements!.blocked);
|
|
assert.equal(offer.harassWithdraw!.blocked, null);
|
|
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
|
|
assert.throws(() => applyAction(before, fixture.owner, plan), RuleError);
|
|
assert.deepEqual(before, saved, 'No first seal, card custody, reserve debit or marker reveal survives rejection.');
|
|
assert.equal(before.battle!.plans[fixture.owner], undefined);
|
|
}
|
|
const first = applyAction(before, fixture.owner, fixture.plans.harass);
|
|
assertFirstSeal(fixture, first);
|
|
const revealed = applyAction(first, fixture.opponent, fixture.plans.opponent);
|
|
assertReveal(fixture, revealed);
|
|
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.winner, fixture.richese);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 7 + fixture.ordinary - fixture.variableDial);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 0);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(before, fixture.richese).tanks, fixture.variableDial);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
|
assertUtilities(done, [NO_FIELD_HARASS]);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
void test('Ecaz-held mixed H/R cannot spend its ally reserves or its own future Harass return', () => {
|
|
const fixture = ecazOccupyMixedNoFieldFixture({ lead: 'ecaz', richeseReserves: 8, ecazReserves: 2 });
|
|
const before = fixture.preplan;
|
|
const saved = structuredClone(before);
|
|
const offer = viewGame(before, fixture.ecaz).battle!.reinforcements!;
|
|
assert.equal(offer.normal, 2);
|
|
assert.equal(offer.noField, undefined);
|
|
assert.ok(offer.blocked);
|
|
assert.throws(() => applyAction(before, fixture.ecaz, fixture.plans.both), RuleError);
|
|
assert.deepEqual(before, saved);
|
|
const revealed = sealEcazOccupyMixedNoField(fixture, 'harass');
|
|
assertReveal(fixture, revealed);
|
|
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.winner, fixture.ecaz);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).reserves, 4);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(before, fixture.ecaz).tanks, 3);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, 5);
|
|
assertUtilities(done, [NO_FIELD_HARASS]);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Real opposing Traitor cancels ${lead}-held mixed Harass, but preserves native reveal and the original R cost`, () => {
|
|
const fixture = ecazOccupyMixedNoFieldFixture({ lead, opposingTraitor: true });
|
|
const before = fixture.preplan;
|
|
const leader = fixture.plans.both.leader;
|
|
assert.ok(typeof leader === 'string');
|
|
assert.ok(occupyRicheseSeat(before, fixture.opponent).traitors.includes(leader));
|
|
const revealed = sealEcazOccupyMixedNoField(fixture, 'both');
|
|
assertReveal(fixture, revealed);
|
|
const { game: done } = finishEcazOccupyMixedNoField(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.result, 'traitor');
|
|
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
|
|
assert.equal(occupyRicheseSeat(done, fixture.owner).leaders.find(face => face.id === leader)!.dead, true);
|
|
for (const actor of [fixture.ecaz, fixture.richese]) {
|
|
const original = occupyRicheseSeat(before, actor);
|
|
const player = occupyRicheseSeat(done, actor);
|
|
const materialized = actor === fixture.richese ? fixture.materializes : 0;
|
|
const reserveCost = actor === fixture.owner ? 3 : 0;
|
|
const destroyed = actor === fixture.ecaz ? 5 : fixture.ordinary + materialized;
|
|
assert.equal(player.forces[fixture.location] ?? 0, 0);
|
|
assert.equal(player.reserves, original.reserves - materialized - reserveCost,
|
|
'An opposing real Traitor prevents every promised H return, including ordinary counters.');
|
|
assert.equal(player.tanks - original.tanks, destroyed + reserveCost);
|
|
}
|
|
assertUtilities(done, [NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS]);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|