import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actTruthMovement as act, truthMovementClean as clean, truthMovementInventory as inventory, truthMovementPlayer as player } from './fixture-truth-movement'; import { nextStrongholdFactionsNativeStep as next } from './fixture-stronghold-factions'; const proposer = 'atreides', traveler = 'emperor', first = 'harkonnen', second = 'fremen'; const shipment: Action = {type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0}; const movement: Action = {type:'move', from:'polar_sink:0', territory:'imperial_basin', sector:9, amount:1}; type Term = {actor:string; kind:'ship'|'move'; territory:string; minimum:number; answer:boolean; dueTurn?:number}; function reach(game: Game, until: (game: Game) => boolean): Game { for (let count = 0; count < 1200; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished', 'The native game ended before the travel opportunity.'); const step = next(game); assert.ok(step, `Supply native continuation at ${game.turn}/${game.phase}`); game = act(game, step.actor, step.action); } throw Error('The native travel opportunity was not reached.'); } function settle(game: Game): Game { return reach(game, state => clean(state) && !state.pendingShipment); } /** Four actual human Basic seats. Reorder only original unopened starting * cards and Spice cards, and use native setup and phase actions to reach the * Emperor's unused first-turn shipment. No resource, force, promise, vote or * pending decision is installed by the test. */ function nativeSource(): Game { let game = createGame('GROUPTRAVEL', newPlayer(proposer, 'Atreides', 'atreides'), false, []); for (const [id, faction] of [[traveler,'emperor'], [first,'harkonnen'], [second,'fremen']] as const) joinGame(game, newPlayer(id, id, faction)); for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); game = act(game, proposer, {type:'start'}); assert.equal(game.status, 'setup'); const cards = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 4); assert.equal(cards.length, 4); for (const [position, card] of cards.entries()) { const from = game.deck.findIndex(row => row.id === card.id); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; } game = reach(game, state => state.status === 'playing'); assert.equal(game.status, 'playing'); const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]); game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === traveler && clean(state)); assert.equal(player(game, traveler).shipped, false); assert.ok(player(game, traveler).spice >= 2); assert.ok(!game.travelPromises?.length); return game; } let source: Game | undefined; const fixture = () => structuredClone(source ??= nativeSource()); function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before, 'Rejected deal and travel actions preserve the table.'); } function offer(game: Game, targets: string[], travel: Term) { const nextGame = act(game, proposer, {type:'dealOffer', targets, terms:'A shared current-turn journey.', travel}); const deal = nextGame.deals?.at(-1); assert.ok(deal); return {game:nextGame, id:deal.id}; } function respond(game: Game, actor: string, id: string, accept = true) { return act(game, actor, {type:'dealRespond', deal:id, accept}); } function status(game: Game, actor: string, id: string) { return viewGame(game, actor).dealTravelStatus[id]; } function receipt(game: Game, id: string) { const row = game.travelPromises?.find(promise => promise.sourceDeal === id); assert.ok(row); return row; } void test('a named traveler accepts last and performs the real shipment for a three-party deal', () => { const initial = fixture(), originalCards = inventory(initial); const offered = offer(initial, [first, traveler], {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true}); let game = offered.game; assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true}); game = respond(game, first, offered.id); assert.equal(game.deals!.at(-1)!.status, 'offered'); assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); game = respond(game, traveler, offered.id); assert.equal(game.deals!.at(-1)!.status, 'active'); assert.equal(receipt(game, offered.id).player, traveler); assert.equal(receipt(game, offered.id).asker, proposer); for (const viewer of [proposer, first, traveler, second]) assert.equal(status(game, viewer, offered.id), 'pending'); reject(game, traveler, {type:'endMovement'}); const completed = settle(act(game, traveler, shipment)); assert.equal(player(completed, traveler).forces['polar_sink:0'], 1); assert.equal(player(completed, traveler).reserves, player(initial, traveler).reserves - 1); for (const viewer of [proposer, first, traveler]) assert.equal(status(completed, viewer, offered.id), 'fulfilled'); assert.equal(completed.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1); assert.deepEqual(inventory(completed), originalCards); }); void test('four-party consent requires all other recipients first and release requires every participant', () => { const offered = offer(fixture(), [traveler, first, second], {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true}); let game = offered.game; game = respond(game, second, offered.id); reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true}); game = respond(game, first, offered.id); game = respond(game, traveler, offered.id); assert.equal(game.deals!.at(-1)!.status, 'active'); for (const actor of [proposer, second, first]) { game = act(game, actor, {type:'dealRelease', deal:offered.id, agree:true}); assert.equal(status(game, traveler, offered.id), 'pending'); } reject(game, traveler, {type:'endMovement'}); game = act(game, traveler, {type:'dealRelease', deal:offered.id, agree:true}); assert.equal(status(game, traveler, offered.id), 'released'); assert.equal(receipt(game, offered.id).releasedByDeal, true); game = settle(act(game, traveler, {type:'endMovement'})); assert.equal(status(game, first, offered.id), 'released'); }); void test('a four-party future shipment stays scheduled until the due turn and follows the named traveler', () => { const initial = fixture(), dueTurn = initial.turn + 1, originalCards = inventory(initial); const offered = offer(initial, [first, traveler, second], {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn}); let game = offered.game; reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true}); game = respond(game, second, offered.id); reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true}); game = respond(game, first, offered.id); game = respond(game, traveler, offered.id); assert.equal(game.deals!.at(-1)!.status, 'active'); assert.equal(receipt(game, offered.id).player, traveler); assert.equal(receipt(game, offered.id).turn, dueTurn); for (const viewer of [proposer, first, second, traveler]) assert.equal(status(game, viewer, offered.id), 'scheduled'); assert.equal(viewGame(game, traveler).travelPromiseGuide ?? null, null); game = settle(act(game, traveler, {type:'endMovement'})); game = reach(game, state => state.turn === dueTurn && state.phase === 5 && state.active === traveler && clean(state) && !state.pendingShipment); assert.equal(status(game, traveler, offered.id), 'pending'); reject(game, traveler, {type:'endMovement'}); game = settle(act(game, traveler, shipment)); for (const viewer of [proposer, first, second, traveler]) assert.equal(status(game, viewer, offered.id), 'fulfilled'); assert.equal(player(game, traveler).forces['polar_sink:0'], 1); assert.deepEqual(inventory(game), originalCards); }); for (const answer of [true, false]) void test(`group movement ${answer ? 'requires' : 'forbids'} one real matching action`, () => { const moved = settle(act(fixture(), traveler, shipment)); const offered = offer(moved, [first, traveler], {actor:traveler, kind:'move', territory:'imperial_basin', minimum:1, answer}); let game = respond(offered.game, first, offered.id); game = respond(game, traveler, offered.id); if (answer) { reject(game, traveler, {type:'endMovement'}); game = settle(act(game, traveler, movement)); assert.equal(player(game, traveler).forces['imperial_basin:9'], 1); } else { reject(game, traveler, movement); game = settle(act(game, traveler, {type:'endMovement'})); assert.equal(player(game, traveler).forces['imperial_basin:9'] ?? 0, 0); } assert.equal(status(game, first, offered.id), 'fulfilled'); }); void test('the traveler may decline early; malformed or uninvited actors and payment combinations reject', () => { const initial = fixture(), term:Term = {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true}; for (const bad of [proposer, second, 'unknown']) reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:{...term, actor:bad}}); reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:term, payment:{kind:'deferredBribe', payer:proposer, recipient:traveler, amount:1, dueTurn:initial.turn}}); const offered = offer(initial, [first, traveler], term); const declined = respond(offered.game, traveler, offered.id, false); assert.equal(declined.deals!.at(-1)!.status, 'declined'); assert.equal(declined.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); }); void test('an unavailable journey rejects only the final traveler consent and leaves the group offer open', () => { const alreadyShipped = settle(act(fixture(), traveler, shipment)); const offered = offer(alreadyShipped, [first, traveler], {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true}); const waiting = respond(offered.game, first, offered.id); assert.equal(waiting.deals!.at(-1)!.status, 'offered'); assert.equal(waiting.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); reject(waiting, traveler, {type:'dealRespond', deal:offered.id, accept:true}); assert.equal(viewGame(waiting, first).deals.at(-1)!.status, 'offered'); }); void test('the legacy two-person packet still binds the recipient without a named actor', () => { const game = fixture(); let nextGame = act(game, proposer, {type:'dealOffer', target:traveler, terms:'Two-player travel.', travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true}}); const deal = nextGame.deals!.at(-1)!; nextGame = respond(nextGame, traveler, deal.id); assert.equal(receipt(nextGame, deal.id).player, traveler); assert.equal(status(nextGame, first, deal.id), 'pending'); }); void test('all minimal autopilot profiles can complete the named group travel obligation', () => { const offered = offer(fixture(), [first, traveler], {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true}); const accepted = respond(respond(offered.game, first, offered.id), traveler, offered.id); for (const difficulty of DIFFICULTIES) { let game = act(accepted, traveler, {type:'setAutopilot', difficulty}); for (let step = 0; step < 8 && status(game, traveler, offered.id) === 'pending'; step++) { const actions = botActions(viewGame(game, traveler)); assert.ok(actions.length, `${difficulty} needs a legal native continuation`); game = settle(act(game, traveler, actions[0])); } assert.equal(status(game, traveler, offered.id), 'fulfilled', difficulty); } });