import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { TravelAnswers, TravelPromiseGuideButton, TravelPromises } from '../components/travel-promises'; import { baseDeck } from '../game/cards'; import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; import { createRevivalOrder } from '../game/revival-order'; import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction'; type Props = Parameters[0]; type ElementProps = { children?: ReactNode; [key: string]: unknown }; type Element = ReactElement; const event = 'controlled-early-travel'; /** Controlled personalized quotes test current UI ownership and dispatch only. * They do not establish native acquisition, answer feasibility or progress. */ function projected(phase: number, next: Action = { type: 'ready' }): GameView { const source = createGame('EARLYTRAVELCONTROLS', newPlayer('p', 'Respondent', 'emperor')); joinGame(source, newPlayer('a', 'Asker', 'atreides')); const game = viewGame(source, 'p'); game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a'; game.ready = []; game.travelPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: { kind: 'or', terms: [ { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 3 } }, { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 4 } }, ] }, answer: true, fulfilled: false, released: false }]; game.travelPromiseGuide = { event, action: { ...next, travelPromiseEvent: event, travelPromisePhase: phase } }; game.truthTravelAnswers = null; return game; } function auction(next: Action = { type: 'passBid' }): GameView { const game = projected(3, next); game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null }; if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; } return game; } function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView { const game = projected(4, next); game.active = 'p'; game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a'], ready: [] }); return game; } function richese(method: 'silent' | 'onceAround'): GameView { const native = createRicheseAuction({ event: 'controlled-native-lot', cardId: 'controlled-native-card', owner: 'a', source: 'cache', method, eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] }); const game = projected(3, { type: 'richeseBid', event: native.event, amount: method === 'silent' ? 0 : null, allyPayment: 0 }); game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null, ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null }; return game; } function guide(props: Props) { const button = TravelPromiseGuideButton(props); assert.ok(button); return button; } const press = (button: ReturnType) => button.props.onClick(); const markup = (game: GameView) => renderToStaticMarkup(createElement(TravelPromises, { game, busy: false, act() {} })); function elements(node: ReactNode): Element[] { if (Array.isArray(node)) return node.flatMap(elements); if (!isValidElement(node)) return []; return [node, ...elements(node.props.children)]; } function answer(game: GameView, send: (action: Action) => void) { const tree = TravelAnswers({ game, busy: false, act: send }); const button = elements(tree).find(element => element.props['data-travel-answer'] === 'yes'); assert.ok(button); return button; } const click = (button: Element) => (button.props.onClick as () => void)(); function answering(phase: number): GameView { const game = projected(phase); game.travelPromiseGuide = null; game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth' }], passed: [], question: { kind: 'travel', target: 'p', turn: 2, mixed: game.travelPromises[0].mixed } }; game.truthTravelAnswers = ['yes', 'no']; return game; } void test('early travel guide forwards a required native bid and rejects a changed ordinary opportunity', () => { const game = auction({ type: 'bid', amount: 8, allyPayment: 0 }), sent: Action[] = []; const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.match(markup(game), /Place the required bid of 8/); game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1); }); void test('early whole-travel history stays readable before Movement and forwards only current Charity, Ready or card preparation', () => { for (const [phase, action, label] of [ [2, { type: 'charity' }, /Claim Charity for your later shipment and movement/], [2, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/], [4, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/], [2, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/], [4, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/], ] satisfies [number, Action, RegExp][]) { const game = projected(phase, action), sent: Action[] = []; const props = { game, busy: false, act(value: Action) { sent.push(value); } }; const html = markup(game); assert.match(html, /Your Movement turn is still ahead/); assert.match(html, /Complete any offered current preparation below before confirming readiness/); assert.match(html, label); assert.doesNotMatch(html, />Ship \d|>Move \d/); const button = guide(props); assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.equal(sent[0], game.travelPromiseGuide!.action); props.busy = true; press(button); assert.equal(sent.length, 1); props.busy = false; game.ready.push('p'); press(button); assert.equal(sent.length, 1); } for (const phase of [0, 1]) { const game = projected(phase); game.travelPromiseGuide = null; assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /