// @dune-suite integration import assert from 'node:assert/strict'; import test from 'node:test'; import { TERRITORIES } from '../game/board'; import { botActions } from '../game/bots'; import { homeworldShipmentChoice, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options'; import type { Action, GameView } from '../game/engine'; const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000'; async function request(path: string, cookie?: string, body?: unknown) { const response = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000), headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...(cookie ? { cookie } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0], data: await response.json() as GameView & { error?: string } }; } /** A fresh room uses real human seat credentials and native legal actions. * No server phase, currency, force, token, or card state is manufactured. */ void test('authenticated mixed transfer moves a concealed marker and physical visitors together', async () => { const host = await request('/api/rooms', undefined, { name: 'Mixed transfer origin', faction: 'atreides', advanced: false, expansions: ['choam'], }); assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie); const path = `/api/rooms/${host.data.code}`; const owner = await request(path, undefined, { type: 'join', name: 'Mixed transfer owner', faction: 'richese' }); assert.equal(owner.status, 200, JSON.stringify(owner.data)); assert.ok(owner.cookie); const guild = await request(path, undefined, { type: 'join', name: 'Mixed transfer destination', faction: 'guild' }); assert.equal(guild.status, 200, JSON.stringify(guild.data)); assert.ok(guild.cookie); const seats = [host, owner, guild].map(result => ({ cookie: result.cookie!, id: result.data.me })); const ownerSeat = seats[1], hostSeat = seats[0], guildSeat = seats[2]; let view = guild.data; const act = async (cookie: string, action: Action) => { const next = await request(path, cookie, { version: view.version, action }); assert.equal(next.status, 200, `Rejected ${action.type}: ${JSON.stringify(next.data)}`); view = next.data; return next.data; }; const read = async (cookie: string) => { const result = await request(path, cookie); assert.equal(result.status, 200, JSON.stringify(result.data)); view = result.data; return result.data; }; await act(hostSeat.cookie, { type: 'homeworlds', enabled: true }); for (const seat of seats) await act(seat.cookie, { type: 'ready' }); await act(hostSeat.cookie, { type: 'start' }); async function advance(until: (ownerView: GameView) => boolean) { for (let count = 0; count < 800; count++) { const currentOwner = await read(ownerSeat.cookie); if (until(currentOwner)) return currentOwner; assert.notEqual(currentOwner.status, 'finished', 'The QA game ended before marker transfer.'); // Atreides sends its actual reserves away from its World, so the // concealed marker can remain there through the next Battle phase. if (currentOwner.status === 'playing' && currentOwner.phase === 5 && currentOwner.active === hostSeat.id && !currentOwner.response && !currentOwner.decision && !currentOwner.phaseOpening && !currentOwner.truthtrance) { const at = await read(hostSeat.cookie); const me = at.players.find(player => player.id === hostSeat.id)!; if (!me.shipped && me.reserves > 0) { let accepted = false; const rejections: string[] = []; for (const territory of TERRITORIES.filter(item => item.type === 'stronghold')) { for (const sector of territory.sectors) { if (sector === at.storm) continue; const attempt = await request(path, hostSeat.cookie, { version: view.version, action: { type: 'ship', territory: territory.id, sector, amount: me.reserves, elite: 0, allyPayment: 0, homeworldSources: { 'homeworld:atreides': { normal: me.reserves, elite: 0 } }, } }); if (attempt.status === 200) { view = attempt.data; accepted = true; break; } assert.equal(attempt.status, 409, JSON.stringify(attempt.data)); rejections.push(`${territory.id}:${sector}: ${attempt.data.error ?? 'unknown rejection'}`); } if (accepted) break; } const source = at.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[hostSeat.id]; assert.ok(accepted, `Atreides cannot ship ${me.reserves} reserves with ${me.spice} spice ` + `from Caladan ${JSON.stringify(source ?? null)}; storm ${at.storm}. Rejections: ${rejections.join('; ')}`); continue; } } let proposed: { cookie: string; actor: string; action: Action } | null = null; for (const seat of seats) { const own = await read(seat.cookie); const local = structuredClone(own); const self = local.players.find(player => player.id === seat.id); assert.ok(self); self.bot = 'Easy'; // Client-side adapter only; authoritative seat stays human. if (seat.id === hostSeat.id && self.hand) { // The scenario needs Caladan empty until the marker leaves. Decline // optional Ghola in this local action selector; the real hand stays // unchanged and every submitted action remains server-validated. self.hand = self.hand.filter(card => card.effect !== 'ghola'); } const choices = botActions(local); if (choices.length) { // Initial Storm can kill Atreides forces on Arrakeen. Reviving them // before this scenario's first shipment adds reserves, while paid // revival also consumes its exactly funded starting spice. Declining // optional revival preserves the native empty-Caladan setup. const action = seat.id === hostSeat.id && own.phase === 4 ? choices.find(candidate => candidate.type === 'ready') ?? choices[0] : choices[0]; proposed = { cookie: seat.cookie, actor: seat.id, action }; break; } } assert.ok(proposed, `No native continuation at ${currentOwner.status}/${currentOwner.phase}/${currentOwner.decision?.kind ?? 'no decision'}.`); if (view.response && !view.response.passed.includes(view.me)) proposed.action = { type: 'passResponse' }; // Keep the actual Atreides shipment affordable and reserve every // Richese movement turn for the explicit shipments below. Richese may // act before Atreides empties Caladan; letting its bot ship on that // earlier turn would add unrelated visitors to the test's source pool. else if ((proposed.actor === hostSeat.id || proposed.actor === ownerSeat.id) && proposed.action.type === 'bid') proposed.action = { type: 'passBid' }; else if ((proposed.actor === hostSeat.id || proposed.actor === ownerSeat.id) && proposed.action.type === 'richeseBid') proposed.action = { type: 'richeseBid', event: proposed.action.event, amount: 0, allyPayment: 0 }; else if (view.phase === 5 && view.active === proposed.actor && !view.response && !view.decision && !view.truthtrance && !view.phaseOpening && (proposed.actor === guildSeat.id || proposed.actor === ownerSeat.id)) proposed.action = { type: 'endMovement' }; await act(proposed.cookie, proposed.action); } throw Error('The dedicated HTTP room did not reach the native marker transfer boundary.'); } const first = await advance(own => own.status === 'playing' && own.phase === 5 && own.active === own.me && !own.players.find(player => player.id === own.me)!.shipped && own.players.find(player => player.id === hostSeat.id)!.reserves === 0 && !!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.canShip); const beforePhysical = first.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[first.me]; assert.equal(beforePhysical?.normal ?? 0, 0, 'No unrelated Richese visit precedes the scripted shipment.'); assert.equal(beforePhysical?.elite ?? 0, 0, 'No unrelated Richese elite visit precedes the scripted shipment.'); const physical = homeworldShipmentChoice(first, 'homeworld:atreides', { 'homeworld:richese': { normal: 2, elite: 0 } }); assert.ok(physical.action, physical.blocked ?? 'The physical shipment is available.'); await act(ownerSeat.cookie, physical.action); const firstLanded = await advance(own => own.phase === 5 && own.players.find(player => player.id === own.me)?.shipped === true); assert.deepEqual(firstLanded.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[firstLanded.me], { normal: 2, elite: 0 }, 'The scripted shipment adds exactly two visitors.'); const physicalTurn = firstLanded.turn; const markerTurn = await advance(own => own.status === 'playing' && own.turn > physicalTurn && own.phase === 5 && own.active === own.me && !own.players.find(player => player.id === own.me)!.shipped && !!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.canShip); assert.deepEqual(markerTurn.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[markerTurn.me], { normal: 2, elite: 0 }); const markerInfo = markerTurn.richeseNoField!; assert.ok(markerInfo.private); const token = markerInfo.private.tokens.find(candidate => candidate.id !== markerInfo.private!.lastShipped); assert.ok(token); const ids = markerInfo.private.tokens.map(candidate => candidate.id); const initial = richeseHomeworldMarkerChoice(markerTurn, 'homeworld:atreides', token.id); assert.ok(initial.action, initial.blocked ?? 'The initial marker shipment is available.'); await act(ownerSeat.cookie, initial.action); const landed = await advance(own => own.phase === 5 && !!own.richeseNoField?.private?.deployed && own.players.find(player => player.id === own.me)?.shipped === true); assert.deepEqual(landed.richeseNoField!.private!.deployed!.location, { homeworld: 'homeworld:atreides' }); const firstTurn = landed.turn; const beforeTransfer = await advance(own => own.status === 'playing' && own.turn > firstTurn && own.phase === 5 && own.active === own.me && !own.players.find(player => player.id === own.me)!.shipped && !!own.homeworldShipment && !own.homeworldShipment.blocked && !!own.richeseNoField?.private?.deployed); const mover = beforeTransfer.players.find(player => player.id === beforeTransfer.me)!; const oldSpice = mover.spice, oldReserves = mover.reserves; assert.deepEqual(beforeTransfer.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[beforeTransfer.me], { normal: 2, elite: 0 }); const transfer = richeseHomeworldMarkerChoice(beforeTransfer, 'homeworld:guild', token.id, 0, 'homeworld:atreides', { normal: 1, elite: 0 }); assert.ok(transfer.action, transfer.blocked ?? 'The marker transfer is available.'); assert.equal(transfer.action.noFieldFrom, 'homeworld:atreides'); const stale = await request(path, ownerSeat.cookie, { version: view.version, action: { ...transfer.action, noFieldEvent: 'stale-marker-event' } }); assert.equal(stale.status, 409, JSON.stringify(stale.data)); const unchanged = await read(ownerSeat.cookie); assert.equal(unchanged.version, beforeTransfer.version); assert.deepEqual(unchanged.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[unchanged.me], { normal: 2, elite: 0 }); await act(ownerSeat.cookie, transfer.action); const after = await advance(own => own.phase === 5 && own.players.find(player => player.id === own.me)?.shipped === true && !!own.richeseNoField?.private?.deployed && 'homeworld' in own.richeseNoField.private.deployed.location && own.richeseNoField.private.deployed.location.homeworld === 'homeworld:guild'); const result = after.players.find(player => player.id === after.me)!; assert.equal(result.spice, oldSpice! - 2); assert.equal(result.reserves, oldReserves); assert.deepEqual(after.homeworlds?.worlds?.find(world => world.id === 'homeworld:atreides')?.forces[after.me], { normal: 1, elite: 0 }); assert.deepEqual(after.homeworlds?.worlds?.find(world => world.id === 'homeworld:guild')?.forces[after.me], { normal: 1, elite: 0 }); assert.equal(after.richeseNoField!.private!.deployed!.tokenId, token.id); assert.equal(after.richeseNoField!.private!.lastShipped, token.id); const other = await read(hostSeat.cookie); assert.equal(other.richeseNoField!.private, null); assert.deepEqual(other.richeseNoField!.public.deployed!.location, { homeworld: 'homeworld:guild' }); for (const id of ids) assert.equal(JSON.stringify(other).includes(id), false, 'A foreign seat cannot inspect marker identity.'); });