import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement, type ReactElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { SemutaReaction } from '../components/semuta-reaction'; import { viewGame, type Action, type Game } from '../game/engine'; import { createSemutaMarketGholaFixture, actSemutaMarketGhola as act, allowSemutaMarketGholaIncome, passSemutaMarketGhola, semutaMarketGholaPlayer as player, } from './fixture-semuta-market-ghola'; type Props = Parameters[0]; type Control = { children?: unknown; disabled?: boolean; onClick: () => void }; function controls(node: unknown): Control[] { if (Array.isArray(node)) return node.flatMap(controls); if (!node || typeof node !== 'object' || !('props' in node)) return []; const props = (node as ReactElement<{children?: unknown; disabled?: boolean; onClick?: () => void}>).props; return [...(props.onClick ? [{...props, onClick: props.onClick}] : []), ...controls(props.children)]; } const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props)); function control(game: Game, actor: string, label: 'Continue' | 'Commit Semuta Drug', busy = false) { const sent: Action[] = [], props = {game:viewGame(game, actor), busy, act(action: Action) {sent.push(action);}}; const button = controls(SemutaReaction(props)).find(candidate => candidate.children === label); assert.ok(button, `The actual fresh discard offers ${label}`); assert.equal(button.disabled, busy); button.onClick(); if (busy) { assert.deepEqual(sent, []); return null; } assert.equal(sent.length, 1); assert.deepEqual(sent[0], {type:label === 'Continue' ? 'semutaPass' : 'semutaCommit', event:props.game.semutaReaction!.event}); return sent[0]; } function parked(game: Game) { const pending = game.pendingTreacheryDiscard, market = game.pendingChoamMarketGhola; assert.ok(pending?.continuation.kind === 'ordinaryCardDiscard'); assert.equal(pending.continuation.effect, 'ghola'); assert.ok(market); assert.equal(market.response.kind, 'choamSale'); assert.deepEqual(game.choamMarket, market.market); const income = pending.continuation.resume.response; assert.ok(income?.kind === 'revivalIncome'); assert.equal(game.response, null); return {event:pending.batch.event, response:structuredClone(market.response), market:structuredClone(market.market), income:structuredClone(income)}; } void test('every seat sees a neutral persistent market-Ghola offer; a pass leaves the parent and income parked', () => { const f = createSemutaMarketGholaFixture(), game = f.game; const original = parked(game), before = structuredClone(game); for (const seat of game.players) { const view = viewGame(game, seat.id), reaction = view.semutaReaction; assert.ok(reaction); assert.equal(reaction.stage, 'offer'); assert.deepEqual(reaction.candidates, []); const html = markup({game:view, busy:false, act() {}}); assert.match(html, /Fresh Treachery discard/); assert.match(html, /Continue when you are ready/); assert.doesNotMatch(html, /Ghola/); } assert.deepEqual(game, before, 'Rendering does not acknowledge or resume the parent.'); assert.equal(control(game, f.owner, 'Continue', true), null); const pass = control(game, f.owner, 'Continue'); assert.ok(pass); assert.throws(() => act(game, f.owner, {...pass,event:'stale-market-ghola'})); assert.deepEqual(game, before, 'A stale event cannot change the pending response.'); const next = act(game, f.owner, pass); assert.deepEqual(parked(next), original); assert.match(markup({game:viewGame(next, f.owner), busy:false, act() {}}), /You continued. Waiting for other seats/); const continued = passSemutaMarketGhola(next); assert.equal(continued.pendingTreacheryDiscard, null); assert.deepEqual(continued.response, original.income); assert.deepEqual(continued.pendingChoamMarketGhola?.response, original.response); const restored = allowSemutaMarketGholaIncome(continued); assert.equal(restored.pendingChoamMarketGhola, null); assert.deepEqual(restored.response, original.response); assert.deepEqual(restored.choamMarket, original.market); }); for (const fullHand of [false, true]) void test( `the market-Ghola claim resumes the same income and sale${fullHand ? ' with a full hand' : ''}`, () => { const f = createSemutaMarketGholaFixture({fullHand}), game = f.game; const original = parked(game), before = structuredClone(game); const view = viewGame(game, f.claimant); assert.equal(view.semutaReaction?.canCommit, true); const html = markup({game:view,busy:false,act() {}}); assert.match(html, /Commit Semuta Drug/); assert.doesNotMatch(html, /Ghola/); assert.equal(control(game, f.claimant, 'Commit Semuta Drug', true), null); const commit = control(game, f.claimant, 'Commit Semuta Drug'); assert.ok(commit); assert.throws(() => act(game, f.claimant, {...commit,event:'stale-market-ghola'})); assert.deepEqual(game, before); let next = act(game, f.claimant, commit); if (next.pendingTreacheryDiscard?.reaction?.stage === 'select') { const selection = viewGame(next, f.claimant).semutaReaction; assert.ok(selection && selection.stage === 'select'); assert.deepEqual(selection.candidates.map(card => card.id), [f.ghola]); assert.deepEqual(viewGame(next, f.owner).semutaReaction?.candidates, []); next = act(next, f.claimant, {type:'semutaSelect',event:original.event,card:f.ghola}); } assert.equal(next.pendingTreacheryDiscard, null); assert.deepEqual(next.response, original.income); assert.deepEqual(next.choamMarket, original.market); assert.deepEqual(next.pendingChoamMarketGhola?.response, original.response); assert.ok(player(next, f.claimant).hand.some(card => card.id === f.ghola)); assert.ok(!player(next, f.claimant).hand.some(card => card.id === f.semuta)); next = allowSemutaMarketGholaIncome(next); assert.equal(next.pendingChoamMarketGhola, null); assert.deepEqual(next.response, original.response); assert.deepEqual(next.choamMarket, original.market); }, );