import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import * as jsxRuntime from 'react/jsx-runtime'; import ts from 'typescript'; import * as board from '../game/board'; import * as catalog from '../game/catalog'; import * as coalitionAlliance from '../game/coalition-alliance'; import * as publicDeals from '../game/public-deals'; import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals'; import { TravelPromises } from '../components/travel-promises'; import type { Action, GameView } from '../game/engine'; type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>; function elements(node: ReactNode): Element[] { if (Array.isArray(node)) return node.flatMap(elements); if (!isValidElement<{children?:ReactNode; [key:string]:unknown}>(node)) return []; return [node, ...elements(node.props.children)]; } const travel = {kind:'ship', territory:'carthag', minimum:3, answer:true} as const; const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const; const deal = {id:'deal', from:'a', to:'b', terms:'The agreed journey.', turn:2, status:'active', releaseVotes:[], travel}; const base = {status:'playing', turn:2, me:'b', roomControl:null, coalitionState:null, players:[{id:'a',name:'Alice',faction:'atreides',ally:null}, {id:'b',name:'Basil',faction:'harkonnen',ally:null}], deals:[deal], dealTravelStatus:{deal:'pending'}} as unknown as GameView; const markup = (game:GameView, disabled = false) => renderToStaticMarkup(createElement(PublicDealList, {game, act() {}, disabled})); void test('the offer packet retains legacy two-party terms and validates an explicitly named group traveler', () => { assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', undefined, 'a', 2, travel), {type:'dealOffer', target:'b', terms:'Terms', travel}); const avoid = {...travel, kind:'move' as const, answer:false, minimum:20}; assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid), {type:'dealOffer', target:'b', terms:'Avoid', travel:avoid}); assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null); assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'c'}), {type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel, actor:'c'}}); assert.deepEqual(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn:2}), {type:'dealOffer', target:'b', terms:'Terms', travel}, 'Current-turn packet keeps the legacy shape.'); assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, {...travel,dueTurn:4}), {type:'dealOffer', target:'b', terms:'Terms', payment, travel:{...travel,dueTurn:4}}); assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel,actor:'c',dueTurn:4}), {type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel,actor:'c',dueTurn:4}}); for (const dueTurn of [0, 1, 2.5, Number.NaN, undefined]) assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn}), null); assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'a'}), null); assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'d'}), null); assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel, actor:'b'}), null); assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, {...travel, actor:'c'}), {type:'dealOffer', targets:['b','c'], terms:'Terms', payment, travel:{...travel, actor:'c'}}); assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', {...payment,payer:'b',recipient:'c'}, 'a', 2, {...travel, actor:'c'}), {type:'dealOffer', targets:['b','c'], terms:'Terms', payment:{...payment,payer:'b',recipient:'c'}, travel:{...travel, actor:'c'}}); assert.equal(publicDealOfferAction(['b','c'], 'Terms', {kind:'deferredBribe',payer:'a',recipient:'b',amount:2,dueTurn:2}, 'a', 2, {...travel, actor:'c'}), null); assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel), {type:'dealOffer', target:'b', terms:'Terms', payment, travel}); const reverse = {...payment,payer:'b',recipient:'a'}; assert.deepEqual(publicDealOfferAction(['b'], 'Terms', reverse, 'a', 2, travel), {type:'dealOffer', target:'b', terms:'Terms', payment:reverse, travel}); assert.equal(publicDealOfferAction(['b'], 'Terms', {kind:'deferredBribe',payer:'a',recipient:'b',amount:2,dueTurn:2}, 'a', 2, travel), null); assert.equal(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, travel), null); assert.equal(publicDealOfferAction(['a'], 'Terms', undefined, 'a', 2, travel), null); assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, undefined, 2, travel), null); for (const invalid of [ {...travel, minimum:0}, {...travel, minimum:21}, {...travel, minimum:1.5}, {...travel, territory:'not-a-territory'}, {...travel, answer:'yes'}, {...travel, kind:'ride'}, {...travel, extra:true}, ]) assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, invalid as unknown as typeof travel), null); // Older callers retain their original exact wire packets. assert.deepEqual(publicDealOfferAction(['b'], ' Terms '), {type:'dealOffer', target:'b', terms:'Terms'}); assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms'), {type:'dealOffer', targets:['b','c'], terms:'Terms'}); }); void test('the combined offer explains payment timing and the recipient can accept or decline', () => { const offered = {...base, deals:[{...deal,status:'offered',payment}], dealTravelStatus:{}, bribeOptions:{blocked:null}} as unknown as GameView; const html = markup(offered); assert.match(html, /Alice \(Atreides\) pays 2 spice to Basil \(Harkonnen\) on final acceptance/); assert.match(html, /recipient collects their share at the next Mentat opening/); assert.match(html, /cannot spend this payment before its next Mentat collection/); const sent:Action[] = []; const tree = PublicDealList({game:offered, disabled:false, act(action) {sent.push(action);}}); const accept = elements(tree).find(node => node.props.children === 'Accept deal'); const decline = elements(tree).find(node => node.props.children === 'Decline deal'); assert.ok(accept); assert.ok(decline); assert.equal(accept.props.disabled, false); assert.equal(decline.props.disabled, false); (accept.props.onClick as () => void)(); assert.deepEqual(sent, [{type:'dealRespond',deal:'deal',accept:true}]); const busyTree = PublicDealList({game:offered, disabled:true, act() {}}); assert.equal(elements(busyTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true); }); /** Stateful VM exercises the actual authoring callbacks without claiming browser hydration. */ function combinedForm(gameOverride?: GameView) { const states:unknown[] = [], sent:Action[] = []; let cursor = 0; const exported:{PublicDeals?:typeof PublicDeals} = {}; runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url),'utf8'), { compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}, }).outputText, {exports:exported, require(name:string) { if (name === 'react/jsx-runtime') return jsxRuntime; if (name === 'react') return {useState(initial:unknown) { const index = cursor++; if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial; return [states[index], (next:unknown) => {states[index] = typeof next === 'function' ? next(states[index]) : next;}]; }, useRef:() => ({current:null}), useEffect:() => {}}; if (name === '@/components/ui/button') return {Button:'button'}; if (name === '@/game/board') return board; if (name === '@/game/catalog') return catalog; if (name === '@/game/coalition-alliance') return coalitionAlliance; if (name === '@/game/public-deals') return publicDeals; throw new Error(`Unexpected import ${name}`); }}); assert.ok(exported.PublicDeals); const game = gameOverride ?? {...base, me:'a', deals:[]} as GameView; const render = () => {cursor = 0; return elements(exported.PublicDeals!({game,busy:false,act(action) {sent.push(action);}}));}; const check = (index:number) => { const node = render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index]; assert.ok(node); assert.equal(node.props.disabled,false); (node.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:true}}); }; const select = (index:number,value:string) => { const node = render().filter(element => element.type === 'select')[index]; assert.ok(node); (node.props.onChange as (event:{target:{value:string}}) => void)({target:{value}}); }; return {render, check, select, sent}; } void test('the authoring controls compose a proposer payment with recipient travel and reject invalid combinations', () => { const ui = combinedForm(); ui.check(0); // Select recipient. ui.check(1); // Include payment. ui.select(1,'a'); ui.select(2,'b'); ui.check(2); // Include travel while payment is selected. ui.select(5,'carthag'); const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms); (terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}}); const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit'); assert.equal(button()?.props.disabled,false); const form = ui.render().find(node => node.type === 'form'); assert.ok(form); (form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}}); assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',target:'b',terms:'Travel for spice', payment:{kind:'immediateBribe',payer:'a',recipient:'b',amount:1}, travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}}); ui.select(0,'deferredBribe'); assert.equal(button()?.props.disabled,true); ui.select(0,'immediateBribe'); ui.select(1,'b'); ui.select(2,'a'); assert.equal(button()?.props.disabled,false); const reverseForm = ui.render().find(node => node.type === 'form'); assert.ok(reverseForm); (reverseForm.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}}); assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',target:'b',terms:'Travel for spice', payment:{kind:'immediateBribe',payer:'b',recipient:'a',amount:1}, travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}}); }); void test('a payment can also be added after selecting the travel term', () => { const ui = combinedForm(); ui.check(0); ui.check(2); ui.select(2,'carthag'); ui.check(1); ui.select(1,'a'); ui.select(2,'b'); const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms); (terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Travel for spice'}}); const submit = ui.render().find(node => node.type === 'button' && node.props.type === 'submit'); assert.equal(submit?.props.disabled,false); }); void test('the public deal shows the exact recipient, travel intent, turn and authoritative status', () => { const html = markup(base); assert.match(html, /Current-turn travel term/); assert.match(html, /Basil \(Harkonnen\) agrees to make a reserve shipment of at least 3 physical forces in one action to Carthag during turn 2/); assert.match(html, /Travel status: pending/); const avoid = {...base, me:'a', deals:[{...deal, travel:{kind:'move',territory:'arrakeen',minimum:2,answer:false}}], dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView; assert.match(markup(avoid), /agrees to avoid a ground movement of at least 2 physical forces in one action to Arrakeen/); assert.match(markup(avoid), /Travel status: fulfilled/); for (const status of ['void','released'] as const) assert.match(markup({...base, dealTravelStatus:{deal:status}} as unknown as GameView), new RegExp(`Travel status: ${status}`)); }); void test('group offers name the traveler, require their last vote and still permit declining early', () => { const players = [...base.players, {id:'c',name:'Cora',faction:'emperor',ally:null}, {id:'d',name:'Dara',faction:'guild',ally:null}]; const group = {...deal, participants:['a','b','c','d'], acceptVotes:['a','b'], status:'offered', travel:{...travel,actor:'c'}}; const game = {...base, players, deals:[group], me:'c', dealTravelStatus:{}} as unknown as GameView; assert.match(markup(game), /Cora \(Emperor\) would make a reserve shipment/); assert.match(markup(game), /Cora \(Emperor\) must accept last/); const actions:Action[] = []; const early = elements(PublicDealList({game,disabled:false,act(action) {actions.push(action);}})); assert.equal(early.find(node => node.props.children === 'Accept deal')?.props.disabled,true); const decline = early.find(node => node.props.children === 'Decline deal'); assert.ok(decline); assert.equal(decline.props.disabled,false); (decline.props.onClick as () => void)(); assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:false}]); assert.deepEqual(publicDealRequests(game), [['offer','deal','waiting']], 'The traveler can see and decline the offer before other votes are in.'); const ordinary = {...game,me:'d'}; assert.deepEqual(publicDealRequests(ordinary), [['offer','deal']]); assert.equal(elements(PublicDealList({game:ordinary,disabled:false,act() {}})) .find(node => node.props.children === 'Accept deal')?.props.disabled,false); const final = {...game,deals:[{...group,acceptVotes:['a','b','d']}]} as unknown as GameView; assert.deepEqual(publicDealRequests(final), [['offer','deal','ready']], 'A changed request reopens the panel for the final vote.'); const finalActions:Action[] = []; const accept = elements(PublicDealList({game:final,disabled:false,act(action) {finalActions.push(action);}})) .find(node => node.props.children === 'Accept deal'); assert.ok(accept); assert.equal(accept.props.disabled,false); (accept.props.onClick as () => void)(); assert.deepEqual(finalActions.at(-1),{type:'dealRespond',deal:'deal',accept:true}); const paid = {...final, bribeOptions:{blocked:'Your own funds are committed'}, deals:[{...group,acceptVotes:['a','b','d'],payment:{kind:'immediateBribe',payer:'b',recipient:'d',amount:2}}]} as unknown as GameView; assert.equal(elements(PublicDealList({game:paid,disabled:false,act() {}})) .find(node => node.props.children === 'Accept deal')?.props.disabled,false, 'The traveler’s own bribe quote cannot reject another participant’s payment.'); const active = {...game, deals:[{...group,status:'active',acceptVotes:['a','b','c','d']}], dealTravelStatus:{deal:'pending'}} as unknown as GameView; assert.deepEqual(publicDealRequests(active), [['travel','deal']]); assert.deepEqual(publicDealRequests({...active,me:'b'}), []); }); void test('the group offer form selects an invited traveler and revalidates participant changes', () => { const game = {...base, me:'a', deals:[], players:[...base.players, {id:'c',name:'Cora',faction:'emperor',ally:null}, {id:'d',name:'Dara',faction:'guild',ally:null}]} as unknown as GameView; const ui = combinedForm(game); ui.check(0); ui.check(1); ui.check(4); // Basil, Cora, then travel. const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit'); ui.select(0,'c'); ui.select(3,'carthag'); const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms); (terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Group travel'}}); assert.equal(button()?.props.disabled,false); const form = ui.render().find(node => node.type === 'form'); assert.ok(form); (form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}}); assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',targets:['b','c'],terms:'Group travel', travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,actor:'c'}}); const cora = ui.render().filter(node => node.type === 'input' && node.props.type === 'checkbox')[1]; (cora.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:false}}); ui.check(2); // Add Dara after removing the chosen traveler. assert.equal(button()?.props.disabled,true); ui.select(0,'d'); assert.equal(button()?.props.disabled,false); ui.check(3); assert.equal(button()?.props.disabled,true, 'The payer and recipient must be selected.'); ui.select(1,'b'); ui.select(2,'d'); assert.equal(button()?.props.disabled,false); const paidForm = ui.render().find(node => node.type === 'form'); assert.ok(paidForm); (paidForm.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}}); assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',targets:['b','d'],terms:'Group travel', payment:{kind:'immediateBribe',payer:'b',recipient:'d',amount:1}, travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,actor:'d'}}); ui.select(0,'deferredBribe'); assert.equal(button()?.props.disabled,true, 'Deferred group payment remains unavailable.'); }); void test('the author chooses a future travel turn and can return to the legacy current-turn packet', () => { const ui = combinedForm(); ui.check(0); ui.check(2); ui.select(2,'carthag'); const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms); (terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Later journey'}}); const deadline = () => ui.render().find(node => node.type === 'input' && node.props.type === 'number' && node.props.min === 2); const submit = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit'); assert.ok(deadline()); (deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'4'}}); const form = ui.render().find(node => node.type === 'form'); assert.ok(form); assert.equal(submit()?.props.disabled,false); (form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}}); assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',target:'b',terms:'Later journey', travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,dueTurn:4}}); (deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'1'}}); assert.equal(submit()?.props.disabled,true); (deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'2'}}); assert.equal(submit()?.props.disabled,false); const current = ui.render().find(node => node.type === 'form'); assert.ok(current); (current.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}}); assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',target:'b',terms:'Later journey', travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}}); }); void test('future offers stay actionable until the selected turn and scheduled terms do not prompt the traveler early', () => { const offered = {...base,turn:3, deals:[{...deal,status:'offered',travel:{...travel,dueTurn:4}}], dealTravelStatus:{}} as unknown as GameView; assert.match(markup(offered), /during turn 4/); assert.match(markup(offered), /Before turn 4, this term reserves no forces or spice/); const accept = (game:GameView) => elements(PublicDealList({game,disabled:false,act() {}})) .find(node => node.props.children === 'Accept deal'); assert.equal(accept(offered)?.props.disabled,false); assert.equal(accept({...offered,turn:4})?.props.disabled,false); const expired = {...offered,turn:5}; assert.equal(accept(expired)?.props.disabled,true); assert.match(markup(expired), /travel term’s turn has passed/); const scheduled = {...offered, deals:[{...deal,travel:{...travel,dueTurn:4}}], dealTravelStatus:{deal:'scheduled'}} as unknown as GameView; assert.match(markup(scheduled), /Travel status: scheduled/); assert.deepEqual(publicDealRequests(scheduled), []); assert.deepEqual(publicDealRequests({...scheduled,turn:4,dealTravelStatus:{deal:'pending'}} as unknown as GameView), [['travel','deal']]); }); void test('only the obligated recipient gets a persistent pending reminder', () => { assert.deepEqual(publicDealRequests(base), [['travel','deal']]); assert.match(renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})), /Public deals · travel term pending/); assert.deepEqual(publicDealRequests({...base,me:'a'}), []); assert.deepEqual(publicDealRequests({...base,dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView), []); assert.deepEqual(publicDealRequests({...base,deals:[{...deal,status:'released'}]} as unknown as GameView), []); }); void test('an offered travel term remains actionable to the recipient until its current turn has passed', () => { const offered = {...base,deals:[{...deal,status:'offered'}],dealTravelStatus:{}} as unknown as GameView; const actions:Action[] = []; const tree = PublicDealList({game:offered,disabled:false,act(action) {actions.push(action);}}); const accept = elements(tree).find(node => node.props.children === 'Accept deal'); assert.ok(accept); assert.equal(accept.props.disabled,false); (accept.props.onClick as () => void)(); assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:true}]); const expired = {...offered,turn:3}; assert.match(markup(expired), /travel term’s turn has passed/); const expiredTree = PublicDealList({game:expired,disabled:false,act() {}}); assert.equal(elements(expiredTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true); assert.equal(elements(expiredTree).find(node => node.props.children === 'Decline deal')?.props.disabled,false); }); void test('declined and withdrawn travel offers do not claim to be awaiting acceptance', () => { for (const status of ['declined', 'withdrawn'] as const) { const closed = {...base, deals:[{...deal, status}], dealTravelStatus:{}} as unknown as GameView; const html = markup(closed); assert.match(html, /The term did not become binding/); assert.match(html, /Basil \(Harkonnen\) would make a reserve shipment/); assert.doesNotMatch(html, /Basil \(Harkonnen\) agrees to/); assert.doesNotMatch(html, /The term becomes binding on final acceptance/); } }); void test('the recipient guide distinguishes a void deal from unanimous release', () => { // Staged public projections isolate presentation; native creation and lifecycle // are exercised by the separate runtime integration cases. const promise = {event:'travel-event', sourceDeal:'deal', player:'b', asker:'a', turn:2, mixed:{kind:'action', action:{kind:'ship', territory:'carthag', minimum:3}}, answer:true, fulfilled:false, released:true}; for (const [status, expected, absent] of [ ['void', /Void: this agreement is no longer possible/, /Released: this whole commitment/], ['released', /Released: this whole commitment is no longer binding/, /Void: this agreement/], ] as const) { const game = {...base, allLeaders:[], combatLocations:[{id:'carthag',name:'Carthag'}], travelPromises:[promise], dealTravelStatus:{deal:status}} as unknown as GameView; const html = renderToStaticMarkup(createElement(TravelPromises,{game, busy:false, act() {}})); assert.match(html, /Public deal: agreed Yes/); assert.match(html, expected); assert.doesNotMatch(html, absent); assert.match(html, /after the answer or deal acceptance/); assert.equal(TravelPromises({game:{...game, me:'a'}, busy:false, act() {}}), null); } }); void test('the real offer form asks for recipients before a travel term is selected', () => { const html = renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})); assert.match(html, /Include a travel term/); assert.match(html, /Select at least one human recipient to add a travel term/); assert.match(html, /cannot automatically enforce free-text promises/); });