import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, RuleError, viewGame, type Game } from '../game/engine'; import { spiceBankerControlState } from '../components/spice-banker'; import { createNativeWorldMarkerFixture, nativeWorldMarkerInventory, nativeWorldMarkerPlan, type NativeWorldMarkerFixture, } from './fixture-native-world-marker'; import { finishRicheseReinforcementsBattle, prepareRicheseReinforcementsPlan, RICHESE_HARASS, RICHESE_REINFORCEMENTS, richeseReinforcementsPlayer, sealRicheseReinforcementsPlan, } from './fixture-richese-reinforcements'; function physicalCards(game: Game) { return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.ixSetupCards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); } function primaryPool(game: Game, fixture: NativeWorldMarkerFixture) { const home = viewGame(game, fixture.richese).homeworlds?.worlds ?.find(world => world.id === fixture.source.homeworld); assert.ok(home); return home.forces[fixture.richese]; } function assertConserved(fixture: NativeWorldMarkerFixture, game: Game) { assert.deepEqual(physicalCards(game), physicalCards(fixture.initial), 'Every original selected-deck/cache face retains one physical identity.'); for (const player of game.players) { const before = nativeWorldMarkerInventory(fixture.afterSetup, player.id); const after = nativeWorldMarkerInventory(game, player.id); assert.deepEqual([after.total, after.normal, after.elite], [before.total, before.normal, before.elite], `${player.faction}'s native reserves count once; special counters remain their own physical type.`); assert.equal(after.total, 20); if (player.faction === 'ixians') assert.equal(after.elite, 7); assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4)); } const original = richeseReinforcementsPlayer(fixture.afterSetup, fixture.richese).noField!; const current = richeseReinforcementsPlayer(game, fixture.richese).noField!; assert.deepEqual(current.tokens, original.tokens, 'No token is minted, replaced or relabeled.'); assert.equal(current.lastShipped, fixture.token.id, 'Reveal preserves the last real shipment identity.'); } function assertSpent(game: Game, card: string) { assert.equal(game.discard.filter(face => face.id === card).length, 1); assert.ok(game.players.every(player => player.hand.every(face => face.id !== card))); assert.ok(game.deck.every(face => face.id !== card)); } /** Preview and the first seal must not perform the eventual native reveal. */ function assertStillConcealed(fixture: NativeWorldMarkerFixture, game: Game) { const owner = richeseReinforcementsPlayer(game, fixture.richese); const before = richeseReinforcementsPlayer(fixture.game, fixture.richese); assert.equal(game.battle!.revealed, false); assert.deepEqual(owner.noField, before.noField); assert.equal(owner.noFieldEvent, before.noFieldEvent); assert.equal(owner.reserves, fixture.source.reservesBeforeReveal); assert.deepEqual(primaryPool(game, fixture), { normal: owner.reserves, elite: 0 }); assert.equal(owner.forces[fixture.location] ?? 0, 0, 'A concealed marker is not a physical counter.'); assert.equal(owner.tanks, before.tanks); } for (const family of ['choam', 'ix+choam'] as const) { // Opposite representative slots cover both paths without a Cartesian matrix. const slots = [family === 'choam' ? 'weapon' : 'defense'] as const; for (const slot of slots) void test(`${family} World marker three funds R in the ${slot} slot only after its real primary reserve debit`, () => { const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 }); const original = structuredClone(fixture.game); const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; assert.deepEqual(fixture.game, original, 'The owner forecast does not materialize forces or update history.'); assert.equal(preview.blocked, null); assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 3 }); assertStillConcealed(fixture, fixture.game); const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot)); assertStillConcealed(fixture, first); const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); const owner = richeseReinforcementsPlayer(revealed, fixture.richese); assert.equal(revealed.battle!.revealed, true); assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 }); assert.equal(owner.reserves, 3, 'Materialization spends the same native primary counters once.'); assert.equal(owner.forces[fixture.location], 3); assert.equal(owner.tanks, 0, 'The provisional fee settles at the actual result, not at first seal.'); assert.equal(owner.noField!.deployed, null); assertConserved(fixture, revealed); const done = finishRicheseReinforcementsBattle(revealed); const after = richeseReinforcementsPlayer(done, fixture.richese); assert.equal(done.lastBattleContext!.result, 'normal'); assert.equal(done.lastBattleContext!.winner, fixture.opponent); assert.deepEqual(primaryPool(done, fixture), { normal: 0, elite: 0 }); assert.equal(after.reserves, 0); assert.equal(after.tanks, 6, 'Three materialized casualties plus the three-reserve fee, not nine counters.'); assert.equal(after.battleLosses, 6); assert.equal(after.forces[fixture.location] ?? 0, 0); assertSpent(done, RICHESE_REINFORCEMENTS); assertConserved(fixture, done); }); for (const [marker, reserves, materializes] of [[0, 6, 0], [5, 8, 5]] as const) void test(`${family} World funded marker ${marker} keeps its physical token and pays from the remaining native reserves`, () => { const fixture = createNativeWorldMarkerFixture({ family, marker, reserves }); const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; assert.equal(preview.blocked, null); assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes }); const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture)); assertStillConcealed(fixture, first); const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 }); assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, materializes); const done = finishRicheseReinforcementsBattle(revealed); const after = richeseReinforcementsPlayer(done, fixture.richese); assert.equal(after.reserves, reserves - materializes - 3); assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 }); assert.equal(after.tanks, materializes + 3); assertSpent(done, RICHESE_REINFORCEMENTS); assertConserved(fixture, done); }); for (const [marker, reserves, materializes] of [[3, 5, 3], [5, 3, 3]] as const) for (const slot of slots) void test(`${family} World marker ${marker}/${reserves} refuses short R in ${slot} unchanged, without borrowing a future H return`, () => { const fixture = createNativeWorldMarkerFixture({ family, marker, reserves }); const before = structuredClone(fixture.game); const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; assert.ok(preview.blocked); assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes }); assert.equal(preview.normal + preview.elite, reserves - materializes); assert.equal(viewGame(fixture.game, fixture.richese).battle!.harassWithdraw!.blocked, null); assert.deepEqual(fixture.game, before); // This exact original marker-three/reserves-five native position used // to accept the raw first seal even though its owner preview refused it. const rawR = nativeWorldMarkerPlan(fixture, 'reinforcements', slot); delete rawR.action.reinforcementsSources; for (const plan of [rawR, nativeWorldMarkerPlan(fixture, 'both', slot)]) { assert.throws(() => applyAction(fixture.game, plan.actor, plan.action), RuleError); assert.deepEqual(fixture.game, before, 'Rejection changes no wallet, cards, token, history, counters or windows.'); assert.deepEqual(fixture.game.battle!.plans, {}); } assertStillConcealed(fixture, fixture.game); const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'harass', slot)); assertStillConcealed(fixture, first); const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 }); assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location], materializes, 'A five marker capped at three reserves reveals three, not five.'); const done = finishRicheseReinforcementsBattle(revealed); const after = richeseReinforcementsPlayer(done, fixture.richese); assert.equal(after.reserves, reserves - materializes + 1); assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 }, 'The real undialed counter deposits in the native primary World, not a foreign visitor pool.'); assert.equal(after.tanks, 2, 'Only the dialed counters die under H-only fallback.'); assertSpent(done, RICHESE_HARASS); assert.ok(after.hand.some(card => card.id === RICHESE_REINFORCEMENTS), 'The rejected R remains unspent.'); assertConserved(fixture, done); }); for (const slot of slots) void test(`${family} World R+H in ${slot} charges native reserves before depositing the real undialed survivor`, () => { const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 }); const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both', slot)); assertStillConcealed(fixture, first); const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 }); const done = finishRicheseReinforcementsBattle(revealed); const after = richeseReinforcementsPlayer(done, fixture.richese); assert.deepEqual(primaryPool(done, fixture), { normal: 1, elite: 0 }); assert.equal(after.reserves, 1, 'Three pay first; the one real undialed survivor returns afterwards.'); assert.equal(after.tanks, 5); assert.equal(after.battleLosses, 5); assertSpent(done, RICHESE_REINFORCEMENTS); assertSpent(done, RICHESE_HARASS); assertConserved(fixture, done); }); for (const marker of [0, 3, 5] as const) for (const slot of slots) void test(`${family} Advanced World marker ${marker} pays R from its funded turn-one primary World in ${slot}`, () => { const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker }); const before = richeseReinforcementsPlayer(fixture.game, fixture.richese); const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; assert.equal(preview.blocked, null); assert.deepEqual(preview.noField, { materializes: marker, reservesAfterReveal: 20 - marker }); const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot)); assertStillConcealed(fixture, first); const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, marker); assert.deepEqual(primaryPool(revealed, fixture), { normal: 20 - marker, elite: 0 }); const done = finishRicheseReinforcementsBattle(revealed); const after = richeseReinforcementsPlayer(done, fixture.richese); assert.equal(after.reserves, 17 - marker); assert.deepEqual(primaryPool(done, fixture), { normal: 17 - marker, elite: 0 }); assert.equal(after.tanks, marker + 3); assert.equal(after.spice, before.spice - Number(fixture.plans.reinforcements.action.support), 'The original physical Advanced support is still paid, not increased by two score strength.'); assertSpent(done, RICHESE_REINFORCEMENTS); assertConserved(fixture, done); }); void test(`${family} Advanced World marker five settles R then deposits four H survivors with original support`, () => { const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker: 5 }); const before = richeseReinforcementsPlayer(fixture.game, fixture.richese); const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both')); const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); assert.deepEqual(primaryPool(revealed, fixture), { normal: 15, elite: 0 }); const done = finishRicheseReinforcementsBattle(revealed); const after = richeseReinforcementsPlayer(done, fixture.richese); assert.equal(after.reserves, 16, 'Fifteen minus the fee plus four actual undialed survivors.'); assert.deepEqual(primaryPool(done, fixture), { normal: 16, elite: 0 }); assert.equal(after.tanks, 4); assert.equal(after.spice, before.spice - 1); assertSpent(done, RICHESE_REINFORCEMENTS); assertSpent(done, RICHESE_HARASS); assertConserved(fixture, done); }); } void test('all14 mixed native World marker keeps Banker funding private and separate from R fee and H return', () => { const f = createNativeWorldMarkerFixture({ family: 'ix+choam', advanced: true, marker: 5, skills: true, skilled: true, training: { richese: 'spice-banker' }, bankerSpice: 1, opponentDial: 8 }); const view = viewGame(f.game, f.richese), owner = richeseReinforcementsPlayer(f.game, f.richese); const leader = String(f.plans.plain.action.leader); assert.deepEqual(spiceBankerControlState(view, leader, 1), { eligible: true, maximum: 3 }); assert.deepEqual(spiceBankerControlState(view, leader, 2), { eligible: true, maximum: 2 }); assert.deepEqual(spiceBankerControlState(viewGame(f.game, f.opponent), leader, 1), { eligible: false, maximum: 0 }, 'Another viewer cannot commit the native owner’s skill or wallet.'); assert.deepEqual(view.battle!.reinforcements!.noField, { materializes: 5, reservesAfterReveal: 15 }); const plan = nativeWorldMarkerPlan(f, 'both', 'defense'); assert.deepEqual(plan.action.reinforcementsSources, { [f.source.homeworld]: { normal: 3, elite: 0 } }); const before = structuredClone(f.game); assert.throws(() => applyAction(f.game, f.richese, { ...plan.action, dial: 2, support: 2, bankerSpice: 3 }), RuleError); assert.deepEqual(f.game, before, 'Reserve funding and later H returns do not pay an overcommitted own-spice budget.'); const first = prepareRicheseReinforcementsPlan(f.game, plan); assertStillConcealed(f, first); assert.equal(richeseReinforcementsPlayer(first, f.richese).spice, owner.spice); assert.equal(viewGame(first, f.richese).battle!.plans[f.richese].bankerSpice, 1); assert.deepEqual(viewGame(first, f.observer).battle!.plans, {}); const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent); assert.deepEqual(primaryPool(revealed, f), { normal: 15, elite: 0 }); const done = finishRicheseReinforcementsBattle(revealed); const after = richeseReinforcementsPlayer(done, f.richese); assert.equal(done.lastBattleContext!.winner, f.opponent); assert.deepEqual(primaryPool(done, f), { normal: 16, elite: 0 }, 'Five materialize, three pay R, then four actual undialed H survivors return to the same primary World.'); assert.equal(after.reserves, 16); assert.equal(after.tanks, 4); assert.equal(after.spice, owner.spice - 2, 'One own support plus one Banker commitment, not a reserve-fee subsidy.'); assert.equal(done.phase, 7, 'The original battle continues into collection with no synthetic history.'); assertSpent(done, RICHESE_REINFORCEMENTS); assertSpent(done, RICHESE_HARASS); assertConserved(f, done); }); void test('all14 capped World marker refuses R before a skilled Mentat or future H return, then uses legal H', () => { const f = createNativeWorldMarkerFixture({ marker: 5, reserves: 3, skills: true, skilled: true, training: { richese: 'mentat' }, opponentDial: 8 }); const before = structuredClone(f.game), preview = viewGame(f.game, f.richese).battle!; assert.equal(f.game.battle!.leaderSkillHidden![f.richese], true); assert.deepEqual(preview.reinforcements!.noField, { materializes: 3, reservesAfterReveal: 0 }); assert.ok(preview.reinforcements!.blocked); const raw = nativeWorldMarkerPlan(f); delete raw.action.reinforcementsSources; for (const plan of [raw, nativeWorldMarkerPlan(f, 'both')]) { assert.throws(() => applyAction(f.game, f.richese, plan.action), RuleError); assert.deepEqual(f.game, before, 'The refused first seal spends no skill, cards, token, wallet or native stock.'); } const first = prepareRicheseReinforcementsPlan(f.game, nativeWorldMarkerPlan(f, 'harass')); assertStillConcealed(f, first); const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent); assert.deepEqual(primaryPool(revealed, f), { normal: 0, elite: 0 }); assert.equal(richeseReinforcementsPlayer(revealed, f.richese).forces[f.location], 3, 'The actual five-token reveal is capped by its three original native counters.'); const done = finishRicheseReinforcementsBattle(revealed); assert.equal(done.lastBattleContext!.winner, f.opponent); assert.deepEqual(primaryPool(done, f), { normal: 1, elite: 0 }); assert.equal(richeseReinforcementsPlayer(done, f.richese).tanks, 2); assert.ok(richeseReinforcementsPlayer(done, f.richese).hand.some(card => card.id === RICHESE_REINFORCEMENTS)); assertSpent(done, RICHESE_HARASS); assertConserved(f, done); });