import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Game, type GameView, } from '../game/engine'; import { richeseCards } from '../game/richese-cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; import type { FactionId } from '../game/catalog'; import { gameDistance, splitLocation } from '../game/board'; import { validateCohortSelection } from '../game/ornithopter'; import { createRicheseNoField, deployRicheseNoField, } from '../game/richese-no-field'; const cardId = 'richese-ornithopter', source = 'red_chasm:7'; function fixture(faction: FactionId = 'emperor', amount = 1) { const g = createGame('ORNIBOT', newPlayer('p', 'Pilot', faction), true); g.players.push( newPlayer('o', 'Observer', faction === 'atreides' ? 'emperor' : 'atreides'), ); g.status = 'playing'; g.phase = 5; g.turn = 2; g.active = 'p'; g.order = ['p', 'o']; g.movementRemaining = [...g.order]; g.storm = 18; for (const p of g.players) { p.hand = []; p.forces = {}; p.spice = 10; } g.players[0].hand = [richeseCards().find((c) => c.effect === 'ornithopter')!]; g.players[0].forces = { [source]: amount }; g.players[0].reserves = 20 - amount; g.players[0].shipped = true; return g; } function projection(g: Game, difficulty: Difficulty) { const v = viewGame(g, 'p'); v.players[0].bot = difficulty; return v; } const cardMoves = (v: GameView) => botActions(v).filter( (a) => a.type === 'move' && (a.movementCard || a.ornithopterEvent), ); const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); void test('every profile actually uses card range beyond normal access without a stronghold and discards once', () => { for (const difficulty of DIFFICULTIES) { const g = fixture(), v = projection(g, difficulty), before = structuredClone(v); const action = botActions(v)[0]; assert.equal(action.movementCard, cardId); assert.equal(action.ornithopter, 'range3'); const destination = `${action.territory as string}:${action.sector as number}`; const distance = gameDistance( g, source, destination, (k) => splitLocation(k).sector === g.storm, ); assert.ok(distance > 1 && distance <= 3); assert.deepEqual(v, before); const done = applyAction(g, 'p', action); assert.equal(done.players[0].forces[destination], 1); assert.equal(done.players[0].moved, 1); assert.equal(done.players[0].spice, 10); assert.equal(done.players[0].reserves, 19); assert.equal(done.discard.filter((c) => c.id === cardId).length, 1); assert.equal(done.ornithopter, null); assert.equal(cardMoves(projection(reload(done), difficulty)).length, 0); assert.throws(() => applyAction(done, 'p', action)); } }); void test('all profiles can split one origin into two different typed groups and finish with the exact restored event', () => { for (const difficulty of DIFFICULTIES) { const g = fixture('emperor', 5); g.players[0].elites = { forces: { [source]: 3 }, reserves: 2, tanks: 0, revived: 0, }; const first = cardMoves(projection(g, difficulty)).find( (a) => a.ornithopter === 'twoGroups', ); assert.ok(first, difficulty); assert.ok(Number(first.amount) < 5); const moved = applyAction(g, 'p', first), v = projection(reload(moved), difficulty); assert.equal(moved.ornithopter!.completed, 1); assert.equal(moved.discard.length, 0); const candidates = cardMoves(v); assert.ok(candidates.length); const cohort = v.ornithopter!.active!.cohort!; for (const action of candidates) { assert.equal(action.ornithopterEvent, moved.ornithopter!.event); assert.equal(action.movementCard, undefined); const from = String(action.from); validateCohortSelection( cohort, v.players[0].forces, v.players[0].elites!.forces, { [from]: Number(action.amount) }, { [from]: Number(action.elite) }, ); assert.notEqual( from, `${first.territory as string}:${first.sector as number}`, ); } const done = applyAction(moved, 'p', candidates[0]); assert.equal(done.players[0].moved, 2); assert.equal(done.ornithopter, null); assert.equal( Object.values(done.players[0].forces).reduce((a, b) => a + b, 0), 5, ); assert.equal( Object.values(done.players[0].elites!.forces).reduce((a, b) => a + b, 0), 3, ); assert.equal(done.discard.filter((c) => c.id === cardId).length, 1); } }); void test('merged first-group arrivals never expand the original normal or elite quota offered to the second group', () => { for (const difficulty of DIFFICULTIES) { const g = fixture('emperor', 5); const first = cardMoves(projection(g, difficulty)).find( (a) => a.ornithopter === 'twoGroups', )!; const destination = `${first.territory as string}:${first.sector as number}`; g.players[0].forces[destination] = 2; g.players[0].reserves -= 2; const moved = applyAction(g, 'p', first), v = projection(moved, difficulty); const cohort = v.ornithopter!.active!.cohort!; assert.equal(cohort.forces[destination], 2); for (const action of cardMoves(v)) { const from = String(action.from); assert.ok(Number(action.amount) <= (cohort.forces[from] ?? 0)); assert.doesNotThrow(() => applyAction(moved, 'p', action)); } } }); void test('concealed marker moves use separate exact events, never hidden value, and cannot be moved by both card groups', () => { for (const difficulty of DIFFICULTIES) { let firstShape: unknown; for (const value of [0, 3, 5] as const) { const g = fixture('richese', 2), p = g.players[0]; p.noField = createRicheseNoField(['zero', 'three', 'five']); const selected = p.noField.tokens.find((t) => t.value === value)!; p.noField = deployRicheseNoField(p.noField, { tokenId: selected.id, controller: 'p', location: { territory: 'red_chasm', sector: 7 }, }); p.noFieldEvent = 'marker-event'; const action = cardMoves(projection(g, difficulty)).find( (a) => a.noField && a.ornithopter === 'twoGroups', ); assert.ok(action); assert.equal(action.event, 'marker-event'); assert.equal(action.ornithopterEvent, undefined); assert.deepEqual(action.forces, {}); const shape = { ...action, noField: 'opaque' }; if (firstShape) assert.deepEqual(shape, firstShape); else firstShape = shape; const moved = applyAction(g, 'p', action); assert.equal(moved.ornithopter!.cohort!.noField, undefined); const next = cardMoves(projection(reload(moved), difficulty)); assert.ok(next.length); assert.ok( next.every( (a) => a.noField === undefined && a.ornithopterEvent === moved.ornithopter!.event, ), ); const done = applyAction(moved, 'p', next[0]); assert.equal(done.players[0].reserves, 18); assert.ok(done.players[0].noField!.deployed); } } }); void test('all profiles honor mode/ownership guards, existing interaction priority and finite no-group closure', () => { for (const difficulty of DIFFICULTIES) { for (const change of [ (g: Game) => { g.players[0].moved = 1; }, (g: Game) => { g.hajr = ['p']; g.players[0].moved = 2; }, (g: Game) => { g.active = 'o'; }, (g: Game) => { g.players[0].hand = []; }, ]) { const g = fixture(); change(g); assert.equal(cardMoves(projection(g, difficulty)).length, 0); } const v = projection(fixture(), difficulty); v.ornithopter!.blocked = 'Card committed.'; assert.equal(cardMoves(v).length, 0); const truth = projection(fixture(), difficulty); truth.truthtrance = { stage: 'priority', queue: [], passed: [], } as unknown as NonNullable; assert.equal(botActions(truth)[0].type, 'truthPass'); const response = projection(fixture(), difficulty); response.response = { kind: 'emperorIncome', owner: 'o', passed: [] }; assert.equal(cardMoves(response).length, 0); const g = fixture('emperor', 2), first = cardMoves(projection(g, difficulty)).find( (a) => a.ornithopter === 'twoGroups', )!; const moved = applyAction(g, 'p', first), blocked = projection(moved, difficulty); blocked.ornithopter!.active!.cohort = { forces: {}, elites: {} }; assert.equal(botActions(blocked)[0].type, 'endMovement'); const ended = applyAction(moved, 'p', botActions(blocked)[0]); assert.equal(ended.ornithopter, null); assert.equal(ended.discard.filter((c) => c.id === cardId).length, 1); } }); void test('Fremen and Ixian normal two-group ranges remain distinct from fixed card range without an Ix speed response', () => { for (const difficulty of DIFFICULTIES) for (const faction of ['fremen', 'ixians'] as const) { const g = fixture(faction, 5); if (faction === 'ixians') g.players[0].elites = { forces: { [source]: 3 }, reserves: 4, tanks: 0, revived: 0, }; const first = cardMoves(projection(g, difficulty)).find( (a) => a.ornithopter === 'twoGroups', ); assert.ok(first); const distance = gameDistance( g, source, `${first.territory as string}:${first.sector as number}`, (key) => splitLocation(key).sector === g.storm, ); assert.ok( distance <= (faction === 'fremen' || Number(first.elite) > 0 ? 2 : 1), ); const moved = applyAction(g, 'p', first); assert.equal(moved.players[0].moved, 1); const continued = cardMoves(projection(moved, difficulty)); assert.ok(continued.length); assert.doesNotThrow(() => applyAction(moved, 'p', continued[0])); if (faction === 'ixians') { const fixed = fixture('ixians', 1); fixed.players[0].elites = { forces: { [source]: 1 }, reserves: 6, tanks: 0, revived: 0, }; const action = cardMoves(projection(fixed, difficulty))[0]; assert.equal(action.ornithopter, 'range3'); assert.equal( gameDistance( fixed, source, `${action.territory as string}:${action.sector as number}`, (key) => splitLocation(key).sector === fixed.storm, ), 3, ); const done = applyAction(fixed, 'p', action); assert.equal(done.pendingIxMove, undefined); assert.equal(done.response, null); assert.equal(done.players[0].moved, 1); } } });