import assert from 'node:assert/strict'; import test from 'node:test'; import { type Game, viewGame } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { stormSectorAfter } from '../game/disaster-rules'; import { actIndependentOrdinary as act, createIndependentOrdinaryFixture as fixture, advanceIndependentOrdinary as advance, independentOrdinaryInventory as inventory, independentOrdinaryPlayer as player } from './fixture-semuta-independent-ordinary'; function react(game: Game, holder: string, event: string, claim: boolean) { if (claim) return act(game, holder, {type: 'semutaCommit', event}); for (const seat of game.players) game = act(game, seat.id, {type: 'semutaPass', event}); return game; } for (const effect of ['hajr', 'ghola', 'weather', 'harvester', 'atomics'] as const) for (const claim of [false, true]) { void test(`Basic independent cards: ${effect} offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => { const f = fixture(effect), before = structuredClone(f.game); const oldPlayer = player(f.game, f.owner); const pending = act(f.game, f.owner, f.play); assert.deepEqual(f.game, before); const frame = pending.pendingTreacheryDiscard; assert.ok(frame); assert.equal(frame.reaction?.stage, 'offer'); assert.equal(frame.continuation.kind, 'ordinaryCardDiscard'); assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [f.card]); assert.ok(viewGame(pending, f.holder).semutaReaction?.canCommit); if (effect === 'hajr') assert.ok(pending.hajr.includes(f.owner)); if (effect === 'ghola') { assert.equal(player(pending, f.owner).reserves, oldPlayer.reserves + 3); assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks - 3); } if (effect === 'weather') assert.equal(pending.stormPending, 1); if (effect === 'atomics') { assert.equal(pending.shieldWallDestroyed, true); assert.equal(player(pending, f.owner).forces[f.casualtyLocation!], undefined); assert.equal(player(pending, f.owner).tanks, oldPlayer.tanks + 3); } if (effect === 'harvester') { assert.equal(pending.spiceWindow!.amount, f.game.spiceWindow!.amount * 2); assert.equal(pending.spiceWindow!.harvested, true); } const committed = {phase: pending.phase, turn: pending.turn, storm: pending.storm, stormPending: pending.stormPending, hajr: pending.hajr, spice: pending.spice, spiceWindow: pending.spiceWindow, shieldWallDestroyed: pending.shieldWallDestroyed, forces: player(pending, f.owner).forces, reserves: player(pending, f.owner).reserves, tanks: player(pending, f.owner).tanks}; const done = react(pending, f.holder, frame.batch.event, claim); assert.equal(done.pendingTreacheryDiscard, null); assert.deepEqual({phase: done.phase, turn: done.turn, storm: done.storm, stormPending: done.stormPending, hajr: done.hajr, spice: done.spice, spiceWindow: done.spiceWindow, shieldWallDestroyed: done.shieldWallDestroyed, forces: player(done, f.owner).forces, reserves: player(done, f.owner).reserves, tanks: player(done, f.owner).tanks}, committed); assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), claim); assert.equal(done.discard.some(card => card.id === f.card), !claim); assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); assert.deepEqual(inventory(done), f.originalInventory); assert.throws(() => act(done, f.holder, {type: 'semutaCommit', event: frame.batch.event})); if (effect === 'hajr') { const next = act(done, f.owner, {type: 'endMovement'}); assert.ok(!next.movementRemaining?.includes(f.owner)); } else if (effect === 'weather' || effect === 'atomics') { const next = advance(done, state => state.turn === done.turn && state.phase === 1); assert.equal(next.storm, stormSectorAfter(done.storm, done.stormPending!)); if (effect === 'atomics') assert.equal(next.shieldWallDestroyed, true); } else if (effect === 'harvester') { const next = advance(done, state => state.turn === done.turn && state.phase === 2); const blow = pending.spiceWindow!; const key = `${blow.territory}:${blow.sector}`; assert.equal(next.spice[key] ?? 0, committed.spice[key] ?? 0); } else { const next = advance(done, state => state.turn === done.turn && state.phase === 5); assert.equal(player(next, f.owner).reserves, committed.reserves); assert.equal(player(next, f.owner).tanks, committed.tanks); } }); } void test('all AI profiles can legally respond to an independent-card Hajr discard', () => { const f = fixture('hajr'), pending = act(f.game, f.owner, f.play); for (const difficulty of DIFFICULTIES) { const game = structuredClone(pending); player(game, f.holder).bot = difficulty; const response = botActions(viewGame(game, f.holder)).find(action => action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.ok(response); assert.doesNotThrow(() => act(game, f.holder, response)); } });