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); });