import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { PublicDealList, PublicDeals } from '../components/public-deals'; import { bribeAction } from '../game/bribe-options'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!; /** The group mode has no lobby entry. Stage a Basic Nexus and wallets, then * form the pair and unanimous group using the actual engine actions. */ function fixture(): Game { const game = createGame('GROUPDEALS', newPlayer('a', 'Atreides', 'atreides'), false); game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'), newPlayer('h', 'Harkonnen', 'harkonnen')); Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','e','f','h'], ready:[], response:null, decision:null, phaseOpening:null}); game.coalitionState = emptyCoalitionState(); for (const player of game.players) player.spice = 10; return game; } function pair(game: Game): Game { game = act(game,'a',{type:'alliance',target:'e'}); return act(game,'e',{type:'alliance',target:'a'}); } function group(game: Game): Game { game = pair(game); const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original); game = act(game,'a',{type:'coalitionPropose',groupId:original.id,entrant:'f', expectedRevision:game.coalitionState!.revision}); for (const id of ['e','f']) { const proposal = game.coalitionState!.proposal!; game = act(game,id,{type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus, turn:proposal.turn,accept:true,expectedRevision:game.coalitionState!.revision}); } assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','e','f']); return game; } function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before); } function offer(game: Game, actor: string, target: string) { const next = act(game,actor,{type:'dealOffer',target,terms:'Share information this turn.'}); const deal = next.deals!.at(-1)!; return {game:next,id:deal.id}; } void test('group bribe projections and native payments exclude both allies, but retain outsiders', () => { let game = group(fixture()); Object.assign(game,{phase:3,nexus:false,active:'h'}); for (const actor of ['a','e','f']) { const allies = ['a','e','f'].filter(id => id !== actor); const options = viewGame(game,actor).bribeOptions; assert.equal(options.blocked,null); assert.deepEqual(options.targets.map(target => target.id),['h']); for (const target of allies) { assert.equal(bribeAction(options,target,1),null); reject(game,actor,{type:'bribe',target,amount:1}); } } assert.deepEqual(viewGame(game,'h').bribeOptions.targets.map(target => target.id),['a','e','f']); game = act(game,'a',{type:'bribe',target:'h',amount:3}); assert.equal(seat(game,'a').spice,7); assert.equal(seat(game,'h').spice,10); assert.equal(seat(game,'h').bribes,3); }); void test('pair bribe rules and outsider group payments remain available', () => { let game = pair(fixture()); Object.assign(game,{phase:3,nexus:false,active:'h'}); assert.deepEqual(viewGame(game,'a').bribeOptions.targets.map(target => target.id),['f','h']); reject(game,'a',{type:'bribe',target:'e',amount:1}); game = act(game,'a',{type:'bribe',target:'f',amount:2}); assert.equal(seat(game,'a').spice,8); assert.equal(seat(game,'f').bribes,2); }); void test('group public offers require every pair of participants to be nonallied', () => { const game = group(fixture()); for (const target of ['e','f']) reject(game,'a',{type:'dealOffer',target,terms:'Share information.'}); reject(game,'h',{type:'dealOffer',targets:['a','e'],terms:'All three share information.'}); reject(game,'h',{type:'dealOffer',targets:['e','f'],terms:'All three share information.'}); const offered = offer(game,'a','h'); const accepted = act(offered.game,'h',{type:'dealRespond',deal:offered.id,accept:true}); assert.equal(accepted.deals!.at(-1)!.status,'active'); }); void test('an unanswered multiparty offer cannot be accepted after its parties form a group, but can be declined', () => { let game = fixture(); game = act(game,'h',{type:'dealOffer',targets:['a','e'],terms:'Share information.'}); const deal = game.deals!.at(-1)!.id; game = group(game); reject(game,'a',{type:'dealRespond',deal,accept:true}); const declined = act(game,'a',{type:'dealRespond',deal,accept:false}); assert.equal(declined.deals!.at(-1)!.status,'declined'); }); void test('an already active agreement remains releasable after group formation', () => { const offered = offer(fixture(),'a','e'); let game = act(offered.game,'e',{type:'dealRespond',deal:offered.id,accept:true}); game = group(game); assert.equal(game.deals!.at(-1)!.status,'active'); game = act(game,'a',{type:'dealRelease',deal:offered.id,agree:true}); game = act(game,'e',{type:'dealRelease',deal:offered.id,agree:true}); assert.equal(game.deals!.at(-1)!.status,'released'); }); void test('a deferred payment becomes unavailable between new group allies while another outsider payment remains possible', () => { let game = fixture(); game = act(game,'a',{type:'dealOffer',target:'e',terms:'Pay for information.', payment:{kind:'deferredBribe',payer:'a',recipient:'e',amount:2,dueTurn:3}}); const alliedDeal = game.deals!.at(-1)!.id; game = act(game,'e',{type:'dealRespond',deal:alliedDeal,accept:true}); game = act(game,'a',{type:'dealOffer',target:'h',terms:'Pay for information.', payment:{kind:'deferredBribe',payer:'a',recipient:'h',amount:3,dueTurn:3}}); const outsideDeal = game.deals!.at(-1)!.id; game = act(game,'h',{type:'dealRespond',deal:outsideDeal,accept:true}); game = group(game); Object.assign(game,{phase:3,nexus:false,active:'h'}); assert.equal(game.deals!.find(deal => deal.id === alliedDeal)!.paymentStatus,'pending'); assert.ok(viewGame(game,'a').deferredDealPaymentOptions[alliedDeal]?.blocked); assert.equal(viewGame(game,'a').deferredDealPaymentOptions[outsideDeal]?.blocked,null); reject(game,'a',{type:'dealPay',deal:alliedDeal}); assert.equal(seat(game,'a').spice,10); assert.equal(seat(game,'e').bribes,0); game = act(game,'a',{type:'dealPay',deal:outsideDeal}); assert.equal(seat(game,'a').spice,7); assert.equal(seat(game,'h').bribes,3); assert.equal(game.deals!.find(deal => deal.id === outsideDeal)!.paymentStatus,'paid'); assert.equal(game.deals!.find(deal => deal.id === alliedDeal)!.paymentStatus,'pending'); game = act(game,'a',{type:'dealRelease',deal:alliedDeal,agree:true}); game = act(game,'e',{type:'dealRelease',deal:alliedDeal,agree:true}); const released = game.deals!.find(deal => deal.id === alliedDeal)!; assert.equal(released.status,'released'); assert.equal(released.paymentStatus,'released'); }); void test('public deal controls show group allies as unavailable and still allow decline', () => { let game = fixture(); game = act(game,'h',{type:'dealOffer',targets:['a','e'],terms:'Share information.'}); game = group(game); const own = viewGame(game,'a'); const list = renderToStaticMarkup(createElement(PublicDealList,{game:own,disabled:false,act() {}})); assert.match(list,/Acceptance is unavailable while any participants are allied/); assert.match(list,/]*disabled=""[^>]*>Accept deal/); const decline = list.match(/]*>Decline deal<\/button>/)?.[0]; assert.ok(decline); assert.doesNotMatch(decline, /\sdisabled=""/); const form = renderToStaticMarkup(createElement(PublicDeals,{game:own,busy:false,act() {}})); assert.match(form,/Emperor[^<]* · Emperor \(allied with a selected party\)/); assert.match(form,/Fremen[^<]* · Fremen \(allied with a selected party\)/); });