import { runBots } from '../game/bots'; import { createTerrorState, placeTerror } from '../game/moritani-terror'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createGame, newPlayer, applyAction, viewGame, type Game, type Action, RuleError, } from '../game/engine'; import { createRicheseNoField, deployRicheseNoField, } from '../game/richese-no-field'; import { baseDeck } from '../game/cards'; function fixture(advanced = false) { const g = createGame( 'ALLYNOFIELD', newPlayer('r', 'Richese', 'richese'), advanced, ['choam'], ); g.players.push( newPlayer('a', 'Atreides', 'atreides'), newPlayer('e', 'Emperor', 'emperor'), ); g.status = 'playing'; g.phase = 5; g.turn = 2; g.active = 'a'; g.order = ['r', 'a', 'e']; g.movementRemaining = ['a', 'r', 'e']; g.storm = 18; g.deck = baseDeck(); for (const p of g.players) { p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 10; p.hand = []; p.traitorChoices = []; p.traitors = []; } g.players[0].ally = 'a'; g.players[1].ally = 'r'; g.players[0].noField = createRicheseNoField(['zero', 'three', 'five']); g.players[0].noFieldEvent = 'event-a'; return g; } const send = (g: Game, id: string, a: Action) => applyAction(g, id, a); const offer = ( g: Game, token = 'five', payer = 'r', territory = 'arrakeen', sector = 10, ) => send(g, 'r', { type: 'offerRicheseNoField', token, event: g.players[0].noFieldEvent, payer, territory, sector, }); const accept = (g: Game, elite = 0) => send(g, 'a', { type: 'decision', event: g.richeseAllyOffer!.event, accept: true, elite, }); function hold(g: Game, id: string, name: string) { const i = g.deck.findIndex((c) => c.name === name); assert.ok(i >= 0); const c = g.deck.splice(i, 1)[0]; g.players.find((p) => p.id === id)!.hand.push(c); return c.id; } function conserve(g: Game) { for (const p of g.players) assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, ); } void test('allied tokens0/3/5 materialize immediately for either full payer at exact one/two spice price', () => { for (const token of ['zero', 'three', 'five']) for (const payer of ['r', 'a']) for (const to of ['arrakeen', 'imperial_basin']) { let g = fixture(); g = offer(g, token, payer, to); assert.equal(g.decision?.kind, 'richeseAllyShipment'); assert.equal(g.players[0].spice, 10); assert.equal(g.players[0].noField!.deployed, null); g = accept(g); const n = token === 'zero' ? 0 : token === 'three' ? 3 : 5, cost = to === 'arrakeen' ? 1 : 2; assert.equal(g.players[1].forces[`${to}:10`] ?? 0, n); assert.equal(g.players[1].reserves, 20 - n); assert.equal(g.players[0].reserves, 20); assert.equal(g.players.find((p) => p.id === payer)!.spice, 10 - cost); assert.equal(g.players[1].shipped, true); assert.equal(g.players[0].shipped, false); assert.equal(g.players[0].noField!.deployed, null); assert.equal(g.players[0].noField!.lastShipped, token); conserve(g); } }); void test('private offers are identical to nonallies across denominations and preserve exact recipient consent', () => { const views = []; for (const token of ['zero', 'three', 'five']) { const g = offer(fixture(), token); views.push(viewGame(g, 'e')); assert.equal(viewGame(g, 'e').richeseNoField!.allyOffer, null); assert.equal(viewGame(g, 'a').richeseNoField!.private, null); assert.ok(viewGame(g, 'a').richeseNoField!.allyOffer); assert.equal(JSON.stringify(viewGame(g, 'a')).includes('event-a'), true); assert.throws( () => send(g, 'e', { type: 'decision', accept: true, event: g.richeseAllyOffer!.event, }), RuleError, ); const declined = send(g, 'a', { type: 'decision', decline: true, event: g.richeseAllyOffer!.event, }); assert.equal(declined.players[1].shipped, false); assert.equal(declined.players[0].noField!.lastShipped, null); assert.equal(declined.richeseAllyOffer, null); } // Event and action logs use the same public text and positions; only private offer data differs. for (const v of views) { v.log = v.log.map((l) => ({ ...l, time: '' })); } assert.deepEqual(views[0], views[1]); assert.deepEqual(views[1], views[2]); }); void test('existing own marker reveals only when shipment commits and shared last-use history cannot repeat', () => { let g = fixture(); g.players[0].noField = deployRicheseNoField(g.players[0].noField!, { tokenId: 'three', controller: 'r', location: { territory: 'imperial_basin', sector: 10 }, }); assert.throws(() => offer(g, 'three'), /twice in a row/); g = offer(g); assert.ok(g.players[0].noField!.deployed); g = accept(g); assert.equal(g.players[0].forces['imperial_basin:10'], 3); assert.equal(g.players[0].reserves, 17); assert.equal(g.players[0].noField!.lastShipped, 'five'); assert.equal(g.players[1].forces['arrakeen:10'], 5); conserve(g); g.active = 'r'; assert.throws( () => send(g, 'r', { type: 'ship', noField: 'five', event: g.players[0].noFieldEvent, territory: 'sietch_tabr', sector: 14, }), /twice in a row/, ); }); void test('recipient selects scarce-reserve elite mix with physical token conservation', () => { let g = fixture(true); // Swap the native seats, retaining recipient/observer IDs and the alliance. // Two Emperor factions would not be a legal shipment roster. const recipient = g.players[1]; g.players[1] = { ...g.players[2], id: recipient.id, ally: recipient.ally }; g.players[2] = { ...recipient, id: 'e', ally: null }; g.players[1].reserves = 3; g.players[1].tanks = 17; g.players[1].elites = { reserves: 2, tanks: 3, forces: {}, revived: 0 }; g = offer(g); const q = viewGame(g, 'a').richeseNoField!.allyOffer!; assert.equal(q.amount, 3); assert.equal(q.eliteMin, 2); assert.equal(q.eliteMax, 2); assert.throws(() => accept(g, 1), RuleError); g = accept(JSON.parse(JSON.stringify(g)), 2); assert.equal(g.players[1].reserves, 0); assert.equal(g.players[1].elites!.reserves, 0); assert.equal(g.players[1].elites!.forces['arrakeen:10'], 2); conserve(g); }); void test('Karama cancellation preserves prior hidden marker and permits ordinary allied shipment', () => { let g = fixture(); const k = hold(g, 'e', 'Karama'); g.players[0].noField = deployRicheseNoField(g.players[0].noField!, { tokenId: 'three', controller: 'r', location: { territory: 'imperial_basin', sector: 10 }, }); g = accept(offer(g)); assert.equal(g.response?.kind, 'richeseNoField'); assert.equal(g.players[0].noField!.deployed!.tokenId, 'three'); g = send(JSON.parse(JSON.stringify(g)), 'e', { type: 'card', mode: 'cancel', card: k, }); assert.equal(g.players[0].noField!.deployed!.tokenId, 'three'); assert.equal(g.players[0].spice, 10); assert.equal(g.players[1].shipped, false); assert.throws(() => offer(g), /prevented No-Field/); g = send(g, 'a', { type: 'ship', amount: 2, territory: 'arrakeen', sector: 10, }); assert.equal(g.players[1].forces['arrakeen:10'], 2); conserve(g); }); void test('Guild stopping allied shipment exposes only one marker and retains payer/token custody', () => { let g = fixture(true); g.players[2].faction = 'guild'; const k = hold(g, 'e', 'Karama'); g = accept(offer(g)); g = send(g, 'e', { type: 'passResponse' }); assert.equal(g.decision?.kind, 'guildShipment'); assert.equal(g.decision?.kind === 'guildShipment' ? g.decision.amount : 0, 1); assert.equal(g.players[0].noField!.lastShipped, null); g = send(g, 'e', { type: 'card', mode: 'special', card: k }); assert.equal(g.players[1].shipped, true); assert.equal(g.players[0].spice, 10); assert.equal(g.players[0].noField!.lastShipped, null); conserve(g); }); void test('Guild receives actual payer spice and Heighliner triggers for the ally recipient', () => { let g = fixture(); g.players[2].faction = 'guild'; g.techTokens = { axlotl: { owner: 'e', spice: 0 }, production: { owner: 'r', spice: 0 }, heighliners: { owner: 'r', spice: 0 }, }; g = accept(offer(g, 'five', 'a', 'imperial_basin')); assert.equal(g.players[2].spice, 12); assert.equal(g.players[1].spice, 8); assert.equal(g.players[0].spice, 10); assert.equal(g.techTokens!.heighliners.spice, 2); conserve(g); }); void test('stale acceptance and changed pending reserve allocation cannot cause payment or token replay', () => { let g = offer(fixture()); assert.throws( () => send(g, 'a', { type: 'decision', accept: true, event: 'old-event' }), /no longer current/, ); const k = hold(g, 'e', 'Karama'); g = accept(g); g.players[1].reserves = 2; g.players[1].tanks = 18; g = send(g, 'e', { type: 'passResponse' }); assert.equal(g.response, null); assert.equal(g.players[1].shipped, false); assert.equal(g.players[0].noField!.lastShipped, null); assert.equal(g.players[0].spice, 10); assert.ok(k); conserve(g); }); void test('invalid payer, Advanced allied occupancy, wrong turn and on-planet Fremen reserves reject atomically', () => { const g = fixture(), before = structuredClone(g); assert.throws(() => offer(g, 'five', 'e'), /ally payer/); assert.deepEqual(g, before); g.players[1].faction = 'fremen'; assert.throws(() => offer(g), /on the planet/); g.players[1].faction = 'atreides'; g.active = 'r'; assert.throws(() => offer(g), /ally’s unused/); g.active = 'a'; g.players[0].forces = { 'arrakeen:10': 1 }; g.players[0].reserves = 19; g.advanced = true; assert.throws(() => offer(g), /occupied by your ally/); }); void test('allied two-spice shipments may split payment equally with private recipient consent', () => { let g = fixture(); g.players[0].spice = 1; g.players[1].spice = 1; g = offer(g, 'five', 'both', 'imperial_basin'); assert.equal(viewGame(g, 'a').richeseNoField!.allyOffer!.ownerPayment, 1); assert.equal(viewGame(g, 'a').richeseNoField!.allyOffer!.recipientPayment, 1); g = accept(g); assert.equal(g.players[0].spice, 0); assert.equal(g.players[1].spice, 0); assert.equal(g.players[1].forces['imperial_basin:10'], 5); conserve(g); assert.throws(() => offer(fixture(), 'five', 'both'), /two-spice/); const poor = fixture(); poor.players[1].spice = 0; const offered = offer(poor, 'five', 'both', 'imperial_basin'); assert.equal(viewGame(offered, 'r').richeseNoField!.allyOffer!.blocked, null); assert.ok(viewGame(offered, 'a').richeseNoField!.allyOffer!.blocked); assert.throws(() => accept(offered), /recipient needs/); }); void test('unsupported simultaneous arrivals fail before consent is committed and leave decline available', () => { let g = fixture(); g.players[2].faction = 'moritani'; const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit'); g.players.push(bg); g.order.push('b'); g.moritaniTerror = createTerrorState(() => 0); g.moritaniTerror = placeTerror( g.moritaniTerror, g.moritaniTerror.tokens[0].id, 'arrakeen', 1, ); hold(g, 'e', 'Karama'); g = offer(g); // The consent commits its real shipment; the committed Terror entry waits // for the arrival's own reaction instead of failing the whole consent. const committed = accept(g); // The consent commits its shipment and opens the No-Field response instead of // rejecting the whole consent because a Terror token shares the destination. assert.equal(committed.response?.kind, 'richeseNoField'); assert.deepEqual(committed.players[1].forces, {}, 'No force moves before the response settles.'); conserve(committed); // Once the response settles, the committed arrival still offers its Terror entry. let arrived = committed; for (let i = 0; arrived.response && i < 8; i++) { const passer = arrived.players.find(p => { const controls = viewGame(arrived, p.id).responseControls; return controls && controls.cancelCards.length && !controls.hasPassed; }); assert.ok(passer, 'The No-Field response keeps a legal passer.'); arrived = send(arrived, passer!.id, { type: 'passResponse' }); } assert.ok((arrived.pendingArrivalReaction?.length ?? 0) > 0 || arrived.pendingTerrorEntry !== null || arrived.decision?.kind === 'moritaniTerror', 'The committed Terror entry is offered or waits behind the arrival reaction.'); conserve(arrived); const automated = structuredClone(g); automated.players[1].bot = 'Medium'; const declined = runBots(automated, 1); assert.equal(declined.richeseAllyOffer, null); assert.equal(declined.players[1].shipped, false); g = send(g, 'a', { type: 'decision', event: g.richeseAllyOffer!.event, decline: true, }); assert.equal(g.pendingShipment, undefined); assert.equal(g.response, null); assert.equal(g.players[1].shipped, false); assert.equal(g.players[0].spice, 10); });