import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { homeworldShipmentActions, homeworldShipmentChoice } from '../game/homeworld-shipment-options'; import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; import { homeworldForceGroups } from '../game/homeworld-custody'; const owner = (g: Game) => g.players.find(p => p.id === 'emperor')!; const groups = (g: Game) => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!); const forces = (g: Game, id: string) => groups(g).find(world => world.id === id)!.forces.emperor ?? {normal:0, elite:0}; const total = (g: Game) => { const p = owner(g); return p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0) + groups(g).filter(world => world.native !== p.id).reduce((sum, world) => { const f = world.forces[p.id]; return sum + (f ? f.normal + f.elite : 0); }, 0); }; /** Advance genuine Homeworld setup; only the Shipment and Movement opportunity * is staged. The foreign garrison is created through an actual shipment. */ function setup(advanced: boolean, actor: 'emperor' | 'guild' = 'emperor') { let game = createGame('RETURNWORLD', newPlayer(actor, actor, actor), advanced); joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen')); game = applyAction(game, actor, {type:'homeworlds', enabled:true}); for (const seat of game.players) game = applyAction(game, seat.id, {type:'ready'}); game = initializeHomeworldGameForAudit(game); for (let step = 0; game.status === 'setup' && step < 80; step++) { let next: Game | undefined; for (const seat of game.players) { const view = viewGame(game, seat.id); view.players.find(p => p.id === seat.id)!.bot = 'Easy'; const action = botActions(view)[0]; if (action) { next = applyAction(game, seat.id, action); break; } } assert.ok(next, 'Native setup needs a legal next action'); game = next; } assert.equal(game.status, 'playing'); for (const p of game.players) game.deck.push(...p.hand.splice(0)); const stageOpportunity = (state: Game) => { Object.assign(state, {phase:5, phaseOpening:null, response:null, decision:null, active:actor, order:state.players.map(p=>p.id), movementRemaining:state.players.map(p=>p.id), ready:[], storm:18}); for (const p of state.players) {p.shipped=false; p.moved=0;} }; stageOpportunity(game); const initialReserves = game.players.find(p => p.id === actor)!.reserves; const first = homeworldShipmentChoice(viewGame(game,actor),'homeworld:atreides', {[`homeworld:${actor}`]:{normal:4,elite:0}}); assert.ok(first.action, first.blocked ?? 'The initial invasion must be legal'); game = applyAction(game,actor,first.action); assert.deepEqual(groups(game).find(w => w.id === 'homeworld:atreides')!.forces[actor],{normal:4,elite:0}); assert.equal(game.players.find(p => p.id === actor)!.reserves,initialReserves - 4); if (actor === 'emperor') assert.equal(total(game),20); game.turn++; stageOpportunity(game); homeworldGameIntegrity(game); return game; } for (const advanced of [false,true]) void test(`${advanced ? 'Advanced' : 'Basic'} foreign Homeworld return to native reserves`, () => { const before = setup(advanced); const source = {'homeworld:atreides':{normal:2,elite:0}}; const raw: Action = {type:'homeworldShip', event:viewGame(before,'emperor').homeworldShipment!.event, destination:'homeworld:emperor', sources:source}; const offer = homeworldShipmentChoice(viewGame(before,'emperor'), 'homeworld:emperor', source); if (!advanced) { assert.equal(offer.action,null); const snapshot = structuredClone(before); assert.throws(() => applyAction(before,'emperor',raw)); assert.deepEqual(before,snapshot); return; } assert.ok(offer.action, offer.blocked ?? 'Advanced return should be offered'); assert.equal(offer.cost,2); const original = structuredClone(before); let after = applyAction(before,'emperor',offer.action); assert.deepEqual(before,original); assert.equal(owner(after).spice,owner(before).spice-2); assert.equal(owner(after).reserves,owner(before).reserves+2); assert.deepEqual(forces(after,'homeworld:atreides'),{normal:2,elite:0}); assert.deepEqual(forces(after,'homeworld:emperor'),{normal:13,elite:0}); assert.equal(owner(after).shipped,true); assert.equal(owner(after).moved,0); assert.equal(total(after),20); homeworldGameIntegrity(after); assert.throws(() => applyAction(after,'emperor',raw), 'The used/stale shipment cannot be replayed'); after = applyAction(after,'emperor',{type:'endMovement'}); assert.notEqual(after.active,'emperor'); homeworldGameIntegrity(after); }); void test('all bot profiles retain legal movement participation in the Advanced return opportunity', () => { const game = setup(true); for (const [level, difficulty] of DIFFICULTIES.entries()) { const view = viewGame(game,'emperor'); view.players.find(p=>p.id==='emperor')!.bot=difficulty; const action = botActions(view)[0]; assert.ok(action, `${difficulty} must have a legal action`); assert.doesNotThrow(() => applyAction(game,'emperor',action)); const returned = homeworldShipmentActions(view,level).find(candidate => candidate.type === 'homeworldShip' && candidate.destination === 'homeworld:emperor'); assert.ok(returned, `${difficulty} must offer a legal native return`); assert.doesNotThrow(() => applyAction(game,'emperor',returned)); } }); for (const advanced of [false,true]) for (const canceled of [false,true]) void test(`${advanced ? 'Advanced' : 'Basic'} Guild native return ${canceled ? 'with canceled transport' : 'with native transport'}`, () => { const game = setup(advanced,'guild'); if (canceled) game.guildTransportBlocked = {turn:game.turn,player:'guild',opportunity:'ordinary'}; const view = viewGame(game,'guild'); const source = {'homeworld:atreides':{normal:2,elite:0}}; const offer = homeworldShipmentChoice(view, 'homeworld:guild', source); const raw: Action = {type:'homeworldShip',event:view.homeworldShipment!.event, destination:'homeworld:guild',sources:source}; if (canceled && !advanced) { assert.equal(offer.action,null); const snapshot=structuredClone(game); assert.throws(() => applyAction(game,'guild',raw)); assert.deepEqual(game,snapshot); return; } assert.ok(offer.action,offer.blocked ?? 'Guild return should be offered'); assert.equal(offer.cost,1, 'Native Guild rate remains distinct from transport permission'); const before = structuredClone(game); const result = applyAction(game,'guild',offer.action); assert.deepEqual(game,before); const guild = result.players.find(p=>p.id==='guild')!; assert.equal(guild.reserves,game.players.find(p=>p.id==='guild')!.reserves+2); assert.equal(guild.spice,game.players.find(p=>p.id==='guild')!.spice-1); assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0}); assert.equal(guild.shipped,true); homeworldGameIntegrity(result); }); void test('an already-open native Guild Homeworld return response survives the Advanced cutover', () => { // The response is obtained through the native Basic declaration, then staged // into an otherwise real Advanced game. Rebind only its source event to the // Advanced context; no counters, spice or physical cards are manufactured. const withKarama = (g: Game) => { const card = g.deck.find(c => c.effect === 'karama'); assert.ok(card); g.deck.splice(g.deck.indexOf(card),1); g.players.find(p => p.id === 'atreides')!.hand.push(card); return card.id; }; const basic = setup(false,'guild'); withKarama(basic); const offer = homeworldShipmentChoice(viewGame(basic,'guild'), 'homeworld:guild', {'homeworld:atreides':{normal:2,elite:0}}); assert.ok(offer.action,offer.blocked ?? 'The original native return must be available'); const declared = applyAction(basic,'guild',offer.action); assert.equal(declared.response?.kind,'guildTransportPermission'); assert.ok(declared.pendingHomeworldShipment?.guildTransportEvent); const advanced = setup(true,'guild'); withKarama(advanced); const event = viewGame(advanced,'guild').homeworldShipment!.event; advanced.pendingHomeworldShipment = structuredClone(declared.pendingHomeworldShipment!); advanced.pendingHomeworldShipment.event = event; advanced.response = structuredClone(declared.response!); let result = JSON.parse(JSON.stringify(advanced)) as Game; for (let n=0; result.response && n<20; n++) { const seat = result.players.find(p => !result.response!.passed.includes(p.id)); assert.ok(seat, 'The saved native response must be passable'); result = applyAction(result,seat.id,{type:'passResponse'}); } assert.equal(result.response,null); assert.equal(result.pendingHomeworldShipment,null); assert.equal(result.players.find(p=>p.id==='guild')!.reserves, advanced.players.find(p=>p.id==='guild')!.reserves+2); assert.equal(result.players.find(p=>p.id==='guild')!.spice, advanced.players.find(p=>p.id==='guild')!.spice-1); assert.deepEqual(groups(result).find(w=>w.id==='homeworld:atreides')!.forces.guild,{normal:2,elite:0}); homeworldGameIntegrity(result); });