import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Game } from '../game/engine'; import { homeworldShipmentActions, homeworldShipmentChoice, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options'; import { finishHarassPreparation } from './fixture-harass-withdraw'; import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; import { homeworldForceGroups } from '../game/homeworld-custody'; import { currentHomeworldOccupationSnapshot, homeworldOccupationMarker, homeworldOccupationSnapshotPresence } from '../game/homeworld-occupation-history'; import { createRicheseHomeworldShipmentFixture as fixture, passRicheseHomeworldShipment as pass, richeseHomeworldShipmentInventory as inventory, richeseHomeworldShipmentPlayer as player, } from './fixture-richese-homeworld-shipment'; const owner = 'richese', foreign = 'homeworld:emperor', native = 'homeworld:richese'; function group(game: Game, location: string) { const world = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!) .find(item => item.id === location); assert.ok(world); return world.forces[owner] ?? {normal:0, elite:0}; } /** Start from real public Advanced setup and native phase actions. Only a later * fresh shipment opportunity and wallet are staged after the real first entry; * the intervening battle is intentionally skipped, so this is not a full game. */ function stageNewTurn(game: Game) { game.turn++; Object.assign(game, { phase:5, phaseOpening:null, response:null, decision:null, active:owner, order:game.players.map(p=>p.id), movementRemaining:game.players.map(p=>p.id), ready:[] }); for (const p of game.players) { p.shipped=false; p.moved=0; } player(game, owner).spice = 8; homeworldGameIntegrity(game); } for (const value of [0,3,5] as const) void test(`Advanced concealed ${value} marker returns to its native World without materializing reserves`, () => { const f = fixture({advanced:true, companions:2, value}); let game = f.game; const initialInventory = inventory(game), original = structuredClone(game); const view = viewGame(game,owner), token = view.richeseNoField!.private!.tokens.find(row=>row.value===value); assert.ok(token); const fresh = richeseHomeworldMarkerChoice(view, foreign, token.id, 0, undefined, {normal:2, elite:0}); assert.ok(fresh.action, fresh.blocked ?? 'The actual foreign entry must be available'); assert.equal(fresh.cost,3); game = pass(applyAction(game,owner,fresh.action)); assert.ok(!game.pendingHomeworldShipment && player(game,owner).shipped); assert.deepEqual(player(game,owner).noField!.deployed!.location,{homeworld:foreign}); assert.deepEqual(group(game,foreign),{normal:2,elite:0}); assert.equal(player(game,owner).reserves,player(original,owner).reserves-2); assert.deepEqual(inventory(game),initialInventory); stageNewTurn(game); const before = structuredClone(game), beforeOwner = viewGame(game,owner); const returning = richeseHomeworldMarkerChoice(beforeOwner,native,token.id,0,foreign,{normal:1,elite:0}); assert.ok(returning.action,returning.blocked ?? 'A distinct foreign-to-native return must be offered'); assert.equal(returning.amount,2); assert.equal(returning.cost,2); let returned = pass(applyAction(game,owner,returning.action)); assert.deepEqual(game,before); assert.equal(player(returned,owner).spice,player(before,owner).spice-2); assert.equal(player(returned,owner).reserves,player(before,owner).reserves+1); assert.deepEqual(group(returned,foreign),{normal:1,elite:0}); assert.equal(player(returned,owner).noField!.deployed!.tokenId,token.id); assert.deepEqual(player(returned,owner).noField!.deployed!.location,{homeworld:native}); assert.equal(player(returned,owner).noField!.lastShipped,token.id); const observer = viewGame(returned,'emperor').richeseNoField!; assert.equal(observer.private,null); assert.deepEqual(observer.public.deployed!.location,{homeworld:native}); assert.equal(observer.public.deployed!.effectiveForces,1); assert.equal(JSON.stringify(observer.public).includes(token.id),false); assert.deepEqual(inventory(returned),initialInventory); homeworldGameIntegrity(returned); // A later ordinary shipment can move the same still-concealed marker back // out of its native World with another physically paid companion. let outgoing = structuredClone(returned); stageNewTurn(outgoing); const departure = richeseHomeworldMarkerChoice(viewGame(outgoing,owner),foreign,token.id,0,native, {normal:1,elite:0}); assert.ok(departure.action, departure.blocked ?? 'The marker can depart its own World'); assert.equal(departure.cost,2); const nativeBeforeDeparture = player(outgoing,owner).reserves; outgoing = pass(applyAction(outgoing,owner,departure.action)); assert.deepEqual(player(outgoing,owner).noField!.deployed!.location,{homeworld:foreign}); assert.equal(player(outgoing,owner).reserves,nativeBeforeDeparture-1); assert.deepEqual(group(outgoing,foreign),{normal:2,elite:0}); assert.deepEqual(inventory(outgoing),initialInventory); homeworldGameIntegrity(outgoing); const reserves = player(returned,owner).reserves, own = player(returned,owner); returned = pass(applyAction(returned,owner,{type:'revealNoField',token:token.id,event:own.noFieldEvent})); assert.equal(player(returned,owner).noField!.deployed,null); assert.equal(player(returned,owner).reserves,reserves, 'Revealing at home does not withdraw or duplicate the native reserve pool.'); assert.deepEqual(group(returned,foreign),{normal:1,elite:0}); assert.deepEqual(inventory(returned),initialInventory); homeworldGameIntegrity(returned); }); /** Stages the later phase opportunities, but uses real native shipments, * battle selection, preparation and plan actions. The native reserves and the * invading group remain the original physical counters. */ for (const value of [0,3,5] as const) void test(`Advanced native battle counts a home No-Field ${value} without duplicating reserves`, () => { const f = fixture({advanced:true, companions:2, value}); let game = f.game; const originalInventory = inventory(game), own = viewGame(game,owner); const token = own.richeseNoField!.private!.tokens.find(row=>row.value===value); assert.ok(token); const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0}); assert.ok(first.action,first.blocked ?? 'The original marker deployment must be legal'); game = pass(applyAction(game,owner,first.action)); stageNewTurn(game); const back = richeseHomeworldMarkerChoice(viewGame(game,owner),native,token.id,0,foreign,{normal:1,elite:0}); assert.ok(back.action,back.blocked ?? 'The native return must be legal'); game = pass(applyAction(game,owner,back.action)); const nativeReserves = player(game,owner).reserves; const nativePhysical = group(game,native); assert.equal(nativePhysical.normal,nativeReserves); // Stage Emperor's next real World shipment from its original reserve pool. game.turn++; Object.assign(game,{phase:5,phaseOpening:null,response:null,decision:null, active:'emperor',order:game.players.map(p=>p.id), movementRemaining:game.players.map(p=>p.id),ready:[]}); for (const p of game.players) {p.shipped=false;p.moved=0;} player(game,'emperor').spice = 10; const invasion = homeworldShipmentChoice(viewGame(game,'emperor'),native, {'homeworld:emperor':{normal:1,elite:0}}); assert.ok(invasion.action,invasion.blocked ?? 'An actual native World invasion must be legal'); game = pass(applyAction(game,'emperor',invasion.action)); assert.deepEqual(player(game,owner).noField!.deployed!.location,{homeworld:native}); homeworldGameIntegrity(game); // The Battle phase is staged, then its ordinary chooser and simultaneous // plans execute natively rather than constructing a saved battle object. Object.assign(game,{phase:6,active:owner,ready:[],response:null,decision:null,phaseOpening:null}); const choice = viewGame(game,owner).battleChoices.find(row=>row.territory===native); assert.ok(choice, 'The public home marker and invader create a native battle'); const target = choice.attacker === owner ? choice.defender : choice.attacker; game = applyAction(game,owner,{type:'chooseBattle',territory:native,target}); const preview = viewGame(game,owner).battle!.ownForces!; assert.equal(preview.normal,nativePhysical.normal, 'The native army is its existing physical pool, regardless of hidden marker value.'); assert.equal(preview.elite,nativePhysical.elite); game = finishHarassPreparation(game); for (const actor of [owner,'emperor']) { const leader = player(game,actor).leaders.find(row=>!row.dead && (!row.usedAt || row.usedAt===native)); assert.ok(leader); game = applyAction(game,actor,{type:'battlePlan',dial:0,support:0,leader:leader.id,weapon:null,defense:null}); } assert.equal(game.battle!.revealed,true); assert.equal(player(game,owner).noField!.deployed,null); assert.equal(player(game,owner).reserves,nativeReserves); assert.deepEqual(group(game,native),nativePhysical); assert.deepEqual(inventory(game),originalInventory); homeworldGameIntegrity(game); }); void test('a concealed native zero marker with no reserves creates public battle presence but no phantom force', () => { const f = fixture({advanced:true, companions:2, value:0}); let game = f.game; const originalInventory = inventory(game), own = viewGame(game,owner); const token = own.richeseNoField!.private!.tokens.find(row=>row.value===0); assert.ok(token); const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0}); assert.ok(first.action, first.blocked ?? 'The initial deployment must be legal'); game = pass(applyAction(game,owner,first.action)); stageNewTurn(game); const back = richeseHomeworldMarkerChoice(viewGame(game,owner),native,token.id,0,foreign,{normal:1,elite:0}); assert.ok(back.action, back.blocked ?? 'The native return must be legal'); game = pass(applyAction(game,owner,back.action)); // Explicit physical staging: move all native reserves into the Tanks while // preserving the real returned marker and the one foreign garrison counter. const richese = player(game,owner); richese.tanks += richese.reserves; richese.reserves = 0; assert.deepEqual(group(game,native),{normal:0,elite:0}); assert.deepEqual(inventory(game),originalInventory); homeworldGameIntegrity(game); game.turn++; Object.assign(game,{phase:5,phaseOpening:null,response:null,decision:null, active:'emperor',order:game.players.map(p=>p.id), movementRemaining:game.players.map(p=>p.id),ready:[]}); for (const p of game.players) {p.shipped=false;p.moved=0;} player(game,'emperor').spice = 10; const invasion = homeworldShipmentChoice(viewGame(game,'emperor'),native, {'homeworld:emperor':{normal:1,elite:0}}); assert.ok(invasion.action,invasion.blocked ?? 'The real invading force must be legal'); game = pass(applyAction(game,'emperor',invasion.action)); assert.deepEqual(group(game,native),{normal:0,elite:0}); assert.equal(viewGame(game,'emperor').richeseNoField!.public.deployed!.effectiveForces,1); const withMarker = currentHomeworldOccupationSnapshot(homeworldContext(game), game.homeworlds!.custody!, homeworldOccupationMarker(game.players)); assert.deepEqual(homeworldOccupationSnapshotPresence(withMarker,native),['emperor','richese'], 'The native marker keeps the invading force from being alone before reveal.'); Object.assign(game,{phase:6,active:owner,ready:[],response:null,decision:null,phaseOpening:null}); const choice = viewGame(game,owner).battleChoices.find(row=>row.territory===native); assert.ok(choice, 'The concealed marker remains a public battle participant even with zero native reserves'); const target = choice.attacker === owner ? choice.defender : choice.attacker; game = applyAction(game,owner,{type:'chooseBattle',territory:native,target}); assert.equal(viewGame(game,owner).battle!.ownForces!.normal,0); assert.equal(viewGame(game,owner).battle!.ownForces!.elite,0); game = finishHarassPreparation(game); for (const actor of [owner,'emperor']) { const leader = player(game,actor).leaders.find(row=>!row.dead && (!row.usedAt || row.usedAt===native)); assert.ok(leader); game = applyAction(game,actor,{type:'battlePlan',dial:0,support:0,leader:leader.id,weapon:null,defense:null}); } assert.equal(game.battle!.revealed,true); assert.equal(player(game,owner).noField!.deployed,null); assert.equal(player(game,owner).reserves,0); assert.deepEqual(group(game,native),{normal:0,elite:0}); const afterReveal = currentHomeworldOccupationSnapshot(homeworldContext(game), game.homeworlds!.custody!, homeworldOccupationMarker(game.players)); assert.deepEqual(homeworldOccupationSnapshotPresence(afterReveal,native),['emperor'], 'The removed zero marker creates no physical native presence after reveal.'); assert.deepEqual(inventory(game),originalInventory); homeworldGameIntegrity(game); // Resolve the native Homeworld's actual Traitor decision and shared final // opportunity, rather than stopping when the zero marker is revealed. game = applyAction(game,owner,{type:'traitorCall',call:false}); while (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) { const window = game.battle.outcomeWindow; const actor = [window.attacker,window.defender].find(id=>!window.ready.includes(id)); assert.ok(actor, 'The final battle opportunity must have an unfinished combatant'); game = applyAction(game,actor,{type:'battleOutcomeReady',event:window.event}); } assert.equal(game.battle,null, 'The zero-reserve native battle must finish'); assert.equal(game.lastBattleContext!.territory,native); assert.equal(player(game,owner).noField!.deployed,null); assert.equal(player(game,owner).reserves,0); assert.deepEqual(group(game,native),{normal:0,elite:0}); assert.deepEqual(inventory(game),originalInventory); homeworldGameIntegrity(game); }); void test('all four legal bot profiles offer a foreign-to-native concealed marker return', () => { const f = fixture({advanced:true, companions:2, value:3}); let game = f.game; const own = viewGame(game,owner), token = own.richeseNoField!.private!.tokens.find(row=>row.value===3); assert.ok(token); const first = richeseHomeworldMarkerChoice(own,foreign,token.id,0,undefined,{normal:2,elite:0}); assert.ok(first.action, first.blocked ?? 'The original marker deployment must be legal'); game = pass(applyAction(game,owner,first.action)); stageNewTurn(game); for (let level=0; level<4; level++) { const view = viewGame(game,owner); const action = homeworldShipmentActions(view,level).find(candidate => candidate.type === 'homeworldShip' && candidate.destination === native && candidate.noFieldFrom === foreign && candidate.noField === token.id); assert.ok(action, `Profile ${level} should offer the legal marker return`); assert.doesNotThrow(() => applyAction(game,owner,action)); } });