Files
Dune/tests/richese-homeworld-marker-mixed-http.test.ts
Quintine 8fb9f59106
Verify and publish checkpoint container / container (push) Successful in 11m55s
Make mixed Homeworld HTTP fixture deterministic
2026-10-28 21:30:49 +11:00

174 lines
12 KiB
TypeScript

// @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;
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));
}
if (accepted) break;
}
assert.ok(accepted, 'Atreides can ship its physical reserves to an available stronghold.');
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.
const choices = botActions(local);
if (choices.length) { proposed = { cookie: seat.cookie, actor: seat.id, action: choices[0] }; 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.');
});