260 lines
17 KiB
TypeScript
260 lines
17 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
|
import { nextOccupyRichese, occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
|
|
import {
|
|
noFieldFullPlanOccupyFixture, offerNoFieldFullPlanOccupy, inspectNoFieldFullPlanOccupy,
|
|
finishNoFieldFullPlanOccupy, type NoFieldFullPlanOccupyFixture,
|
|
} from './fixture-no-field-full-plan-occupy';
|
|
|
|
function assertCustody(fixture: NoFieldFullPlanOccupyFixture, 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 dealer/cache/removed face 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} retains twenty counters of its original physical ownership.`);
|
|
assert.ok(counters.every(count => Number.isSafeInteger(count) && count >= 0));
|
|
assert.ok(player.hand.length <= 4);
|
|
}
|
|
}
|
|
|
|
function assertNativeMarker(fixture: NoFieldFullPlanOccupyFixture, game: Game): void {
|
|
const before = occupyRicheseSeat(fixture.prescience, fixture.richese);
|
|
const now = occupyRicheseSeat(game, fixture.richese);
|
|
assert.deepEqual(now.noField, before.noField);
|
|
assert.equal(now.noFieldEvent, before.noFieldEvent);
|
|
assert.equal(now.reserves, before.reserves);
|
|
assert.equal(now.forces[fixture.location] ?? 0, fixture.ordinary);
|
|
assert.equal(now.tanks, before.tanks);
|
|
}
|
|
|
|
function inspectAndSeal(fixture: NoFieldFullPlanOccupyFixture, offered = offerNoFieldFullPlanOccupy(fixture)): Game {
|
|
assert.equal(offered.decision?.kind, 'fullPlanOffer');
|
|
const inspection = inspectNoFieldFullPlanOccupy(fixture, offered);
|
|
assert.equal(occupyRicheseSeat(inspection, fixture.atreides).specialKaramaUsed, true);
|
|
assert.equal(occupyRicheseSeat(inspection, fixture.atreides).hand.some(card => card.id === fixture.inspectionCard), false);
|
|
assert.equal(inspection.discard.filter(card => card.id === fixture.inspectionCard).length, 1);
|
|
assertNativeMarker(fixture, inspection);
|
|
const saved = structuredClone(inspection);
|
|
assert.throws(() => applyAction(inspection, fixture.atreides, fixture.atreidesPlan), /requested by special/);
|
|
assert.deepEqual(inspection, saved, 'The actual selected lead must seal before the inspecting opponent.');
|
|
const first = applyAction(inspection, fixture.owner, fixture.targetPlan);
|
|
assert.equal(first.battle!.revealed, false);
|
|
assert.equal(first.decision, null, 'Reading the plan needs no acknowledgement or fabricated continuation.');
|
|
assertNativeMarker(fixture, first);
|
|
for (const actor of [fixture.ecaz, fixture.richese, fixture.atreides]) {
|
|
const before = occupyRicheseSeat(fixture.prescience, actor);
|
|
const sealed = occupyRicheseSeat(first, actor);
|
|
assert.equal(sealed.spice, before.spice, 'The private read is not a battle payment.');
|
|
assert.equal(sealed.reserves, before.reserves, 'R cannot spend the card holder reserves before the second seal.');
|
|
assert.equal(sealed.tanks, before.tanks);
|
|
assert.deepEqual(sealed.forces, before.forces, 'H cannot return any physical counter before the second seal.');
|
|
}
|
|
const privateView = viewGame(first, fixture.atreides).battle!;
|
|
const insight = privateView.fullPlanInsight!;
|
|
assert.ok(insight);
|
|
assert.equal(insight.target, fixture.owner);
|
|
assert.equal(insight.plan.dial, fixture.fixedEcazDial + fixture.variableDial);
|
|
assert.equal(insight.plan.support, fixture.support);
|
|
assert.equal(insight.plan.leader, fixture.targetPlan.leader);
|
|
assert.equal(insight.plan.weapon, fixture.targetPlan.weapon);
|
|
assert.equal(insight.plan.defense, fixture.targetPlan.defense);
|
|
assert.deepEqual(insight.cards, occupyRicheseSeat(first, fixture.owner).hand.filter(card =>
|
|
[fixture.targetPlan.weapon, fixture.targetPlan.defense].includes(card.id)),
|
|
'The entitlement supplies the actual committed original physical card faces.');
|
|
assert.equal(privateView.plans[fixture.owner], undefined, 'The special read does not turn the seal into an ordinary visible plan.');
|
|
const concealed = occupyRicheseSeat(first, fixture.richese).noField!.deployed!;
|
|
assert.equal(JSON.stringify(insight).includes(concealed.tokenId), false,
|
|
'Complete plan entitlement is not ownership of the physical token identity.');
|
|
for (const player of first.players) if (player.id !== fixture.atreides)
|
|
assert.equal(viewGame(first, player.id).battle!.fullPlanInsight, null);
|
|
assertCustody(fixture, first);
|
|
return first;
|
|
}
|
|
|
|
function secondSeal(fixture: NoFieldFullPlanOccupyFixture, first: Game): Game {
|
|
const revealed = applyAction(first, fixture.atreides, fixture.atreidesPlan);
|
|
const original = occupyRicheseSeat(fixture.prescience, fixture.richese);
|
|
const richese = occupyRicheseSeat(revealed, fixture.richese);
|
|
assert.equal(revealed.battle!.revealed, true);
|
|
assert.equal(richese.noField!.deployed, null);
|
|
assert.equal(richese.noField!.lastShipped, original.noField!.deployed!.tokenId);
|
|
assert.deepEqual(richese.noField!.tokens, original.noField!.tokens);
|
|
assert.notEqual(richese.noFieldEvent, original.noFieldEvent);
|
|
assert.equal(richese.forces[fixture.location] ?? 0, fixture.ordinary + fixture.materializes,
|
|
'Both seals add the reserve-capped marker to the original ordinary counters, without replacing them.');
|
|
assert.equal(richese.reserves, original.reserves - fixture.materializes);
|
|
assert.equal(richese.tanks, original.tanks);
|
|
for (const player of revealed.players) assert.equal(viewGame(revealed, player.id).battle!.fullPlanInsight, null);
|
|
const saved = structuredClone(revealed);
|
|
assert.throws(() => applyAction(revealed, fixture.atreides, fixture.atreidesPlan));
|
|
assert.deepEqual(revealed, saved, 'A repeated seal cannot materialize a marker twice.');
|
|
assertCustody(fixture, revealed);
|
|
return revealed;
|
|
}
|
|
|
|
function assertHistory(fixture: NoFieldFullPlanOccupyFixture, revealed: Game, done: Game): void {
|
|
assert.equal(done.battle, null);
|
|
assert.equal(done.decision, null);
|
|
const richese = occupyRicheseSeat(done, fixture.richese);
|
|
assert.equal(richese.noField!.deployed, null);
|
|
assert.equal(richese.noFieldEvent, occupyRicheseSeat(revealed, fixture.richese).noFieldEvent,
|
|
'Payments, losses and aftermath cannot perform another marker reveal.');
|
|
for (const player of done.players) {
|
|
const history = viewGame(done, player.id).players.find(seat => seat.id === fixture.richese)!.noField!;
|
|
assert.equal(history.deployed, null);
|
|
assert.equal(history.lastUsed, fixture.marker);
|
|
}
|
|
assertCustody(fixture, done);
|
|
}
|
|
|
|
function ordinaryDialRemainsBlocked(fixture: NoFieldFullPlanOccupyFixture): void {
|
|
const before = fixture.prescience;
|
|
assert.equal(before.battle!.preparation!.kind, 'prescience');
|
|
const saved = structuredClone(before);
|
|
assert.throws(() => applyAction(before, fixture.atreides, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
|
assert.deepEqual(before, saved);
|
|
assertNativeMarker(fixture, before);
|
|
const profile = viewGame(before, fixture.owner).battle!.ecazOccupy!.profile!;
|
|
assert.equal(profile.forceOwner, fixture.richese,
|
|
'Ordinary dial protection follows physical Richese ownership even when Ecaz forms the plan.');
|
|
assert.equal(profile.fixedEcazDial, 3);
|
|
assert.equal(profile.forces.normal, fixture.ordinary + fixture.materializes);
|
|
assert.equal(profile.maxDial, 3 + fixture.ordinary + fixture.materializes);
|
|
const rival = viewGame(before, fixture.atreides).battle!;
|
|
assert.equal(rival.opponentForces, null);
|
|
assert.equal(rival.ecazOccupy!.profile, null,
|
|
'The distinct complete-plan entitlement does not broaden ordinary concealed force disclosure.');
|
|
}
|
|
|
|
const cityIncome = (game: Game, actor: string, fixture: NoFieldFullPlanOccupyFixture) =>
|
|
(occupyRicheseSeat(game, actor).forces[fixture.location] ?? 0) > 0 ? 2 : 0;
|
|
|
|
// One zero, one full positive and one capped positive marker, with both actual
|
|
// selected faction leads. The capped Ecaz case also distinguishes paid strength
|
|
// from unsupported physical losses without creating a cross-product of options.
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
for (const [marker, reserves] of [[0, 0], [3, 6], [5, 2]] as const)
|
|
void test(`Private complete coalition plan: mixed marker ${marker}, reserves ${reserves}, ${lead} lead`, () => {
|
|
const unsupported = lead === 'ecaz' && marker === 5;
|
|
const fixture = noFieldFullPlanOccupyFixture({ lead, marker, richeseReserves: reserves,
|
|
...(unsupported ? { variableDial: 2, support: 0 } : {}) });
|
|
ordinaryDialRemainsBlocked(fixture);
|
|
const first = inspectAndSeal(fixture);
|
|
assert.equal(viewGame(first, fixture.atreides).battle!.opponentForces, null);
|
|
assert.equal(viewGame(first, fixture.atreides).battle!.ecazOccupy!.profile, null);
|
|
const revealed = secondSeal(fixture, first);
|
|
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.result, 'normal');
|
|
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
|
const losses = unsupported ? fixture.variableDial * 2 : fixture.variableDial;
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0,
|
|
fixture.ordinary + fixture.materializes - losses);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks, losses);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).reserves, reserves - fixture.materializes);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.prescience, fixture.ecaz).tanks, 3);
|
|
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
|
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support + cityIncome(done, fixture.owner, fixture));
|
|
const nonLead = fixture.owner === fixture.ecaz ? fixture.richese : fixture.ecaz;
|
|
assert.equal(occupyRicheseSeat(done, nonLead).spice,
|
|
occupyRicheseSeat(fixture.prescience, nonLead).spice + cityIncome(done, nonLead, fixture));
|
|
assert.equal(occupyRicheseSeat(done, fixture.atreides).forces[fixture.location] ?? 0, 0);
|
|
assert.equal(occupyRicheseSeat(done, fixture.atreides).tanks - occupyRicheseSeat(fixture.prescience, fixture.atreides).tanks, 8);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
for (const [lead, marker, reserves] of [['ecaz', 0, 0], ['ally', 5, 2]] as const)
|
|
void test(`Marker-only ${lead} coalition still permits a private full plan, including marker ${marker}`, () => {
|
|
const fixture = noFieldFullPlanOccupyFixture({ lead, marker, richeseReserves: reserves, ordinary: 0 });
|
|
ordinaryDialRemainsBlocked(fixture);
|
|
const revealed = secondSeal(fixture, inspectAndSeal(fixture));
|
|
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks,
|
|
fixture.variableDial);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location], 2);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
for (const lead of ['ecaz', 'ally'] as const)
|
|
void test(`Distinct real CHOAM prevention leaves ${lead}'s own plan inspectable with native later marker reveal`, () => {
|
|
const fixture = noFieldFullPlanOccupyFixture({ lead, marker: 5, richeseReserves: 2, prevented: true });
|
|
assert.notEqual(fixture.preventionCard, fixture.inspectionCard);
|
|
assert.equal(fixture.prescience.discard.filter(card => card.id === fixture.preventionCard).length, 1);
|
|
assert.equal(occupyRicheseSeat(fixture.prescience, fixture.atreides).hand.some(card => card.id === fixture.inspectionCard), true);
|
|
const profile = viewGame(fixture.prescience, fixture.owner).battle!.ecazOccupy!.profile!;
|
|
assert.equal(profile.canceled, true);
|
|
assert.equal(profile.forceOwner, fixture.owner);
|
|
assert.equal(profile.fixedEcazDial, 0);
|
|
assert.equal(profile.forces.normal, lead === 'ecaz' ? 5 : 4);
|
|
assert.equal(profile.maxDial, lead === 'ecaz' ? 5 : 4);
|
|
let offered: Game;
|
|
if (lead === 'ecaz') {
|
|
let asked = applyAction(fixture.prescience, fixture.atreides, { type: 'prescience', field: 'dial' });
|
|
assert.equal(asked.battle!.preparation!.owner, fixture.ecaz);
|
|
assertNativeMarker(fixture, asked);
|
|
while (asked.response) asked = nextOccupyRichese(asked);
|
|
const answered = applyAction(asked, fixture.ecaz, { type: 'prescienceAnswer', value: fixture.targetPlan.dial! });
|
|
assert.equal(viewGame(answered, fixture.atreides).battle!.insight!.value, fixture.targetPlan.dial);
|
|
assertNativeMarker(fixture, answered);
|
|
offered = answered;
|
|
} else {
|
|
assert.throws(() => applyAction(fixture.prescience, fixture.atreides, { type: 'prescience', field: 'dial' }), /may not inspect/);
|
|
offered = offerNoFieldFullPlanOccupy(fixture);
|
|
}
|
|
const revealed = secondSeal(fixture, inspectAndSeal(fixture, offered));
|
|
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
|
const ecazLoss = lead === 'ecaz' ? fixture.variableDial : 0;
|
|
const richeseLoss = lead === 'ally' ? fixture.variableDial : 0;
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).forces[fixture.location] ?? 0, 5 - ecazLoss);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).forces[fixture.location] ?? 0, 4 - richeseLoss);
|
|
assert.equal(occupyRicheseSeat(done, fixture.ecaz).tanks - occupyRicheseSeat(fixture.prescience, fixture.ecaz).tanks, ecazLoss);
|
|
assert.equal(occupyRicheseSeat(done, fixture.richese).tanks - occupyRicheseSeat(fixture.prescience, fixture.richese).tanks, richeseLoss);
|
|
assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
|
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support + cityIncome(done, fixture.owner, fixture));
|
|
assertHistory(fixture, revealed, done);
|
|
});
|
|
|
|
for (const [lead, opposingTraitor] of [['ally', false], ['ecaz', true]] as const)
|
|
void test(`Inspected actual ${lead}-held H/R ${opposingTraitor ? 'is canceled by a real opposing Traitor' : 'settles its original owner returns and costs'}`, () => {
|
|
const fixture = noFieldFullPlanOccupyFixture({ lead, utilities: true, opposingTraitor });
|
|
ordinaryDialRemainsBlocked(fixture);
|
|
const revealed = secondSeal(fixture, inspectAndSeal(fixture));
|
|
const done = finishNoFieldFullPlanOccupy(fixture, revealed);
|
|
assert.equal(done.lastBattleContext!.result, opposingTraitor ? 'traitor' : 'normal');
|
|
assert.equal(done.lastBattleContext!.winner, opposingTraitor ? fixture.atreides : fixture.owner);
|
|
for (const card of ['ecaz-reinforcements', 'ecaz-harass-withdraw'])
|
|
assert.equal(done.discard.filter(face => face.id === card).length, 1);
|
|
for (const actor of [fixture.ecaz, fixture.richese]) {
|
|
const before = occupyRicheseSeat(fixture.prescience, actor);
|
|
const after = occupyRicheseSeat(done, actor);
|
|
const materialized = actor === fixture.richese ? fixture.materializes : 0;
|
|
const reserveCost = actor === fixture.owner ? 3 : 0;
|
|
const returned = !opposingTraitor && actor === fixture.owner ? 2 : 0;
|
|
assert.equal(after.reserves, before.reserves - materialized - reserveCost + returned,
|
|
'R spends the card holder reserves; an opposing real Traitor cancels every H return.');
|
|
const losses = opposingTraitor ? actor === fixture.ecaz ? 5 : fixture.ordinary + materialized
|
|
: actor === fixture.ecaz ? 3 : fixture.variableDial;
|
|
assert.equal(after.tanks - before.tanks, losses + reserveCost);
|
|
const remaining = opposingTraitor || actor === fixture.owner ? 0 : 2;
|
|
assert.equal(after.forces[fixture.location] ?? 0, remaining);
|
|
}
|
|
if (opposingTraitor) {
|
|
const committedLeader = fixture.targetPlan.leader;
|
|
assert.ok(typeof committedLeader === 'string');
|
|
assert.ok(occupyRicheseSeat(fixture.prescience, fixture.atreides).traitors.includes(committedLeader));
|
|
assert.equal(occupyRicheseSeat(done, fixture.owner).leaders.find(face => face.id === committedLeader)!.dead, true);
|
|
} else assert.equal(occupyRicheseSeat(done, fixture.owner).spice,
|
|
occupyRicheseSeat(fixture.prescience, fixture.owner).spice - fixture.support);
|
|
assertHistory(fixture, revealed, done);
|
|
});
|