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 { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { createSemutaIxAllyCostFixture, allowSemutaIxAllyCost } from './fixture-semuta-ix-ally-cost'; 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)]; } function label(node: unknown): string { if (Array.isArray(node)) return node.map(label).join(''); if (typeof node === 'string' || typeof node === 'number') return String(node); if (!node || typeof node !== 'object' || !('props' in node)) return ''; return label((node as ReactElement<{children?: unknown}>).props.children); } const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props)); function buttonAction(game: Game, actor: string, name: string, expected: Action): Action { const sent: Action[] = [], props: Props = {game:viewGame(game, actor), busy:false, act(action) { sent.push(action); }}; const button = controls(SemutaReaction(props)).find(item => label(item.children) === name); assert.ok(button, `Expected the ${name} control`); assert.equal(button.disabled, false); const busy = controls(SemutaReaction({...props, busy:true})).find(item => label(item.children) === name); assert.ok(busy); assert.equal(busy.disabled, true); busy.onClick(); assert.deepEqual(sent, []); button.onClick(); assert.deepEqual(sent, [expected]); return sent[0]; } void test('independent Ecaz-card Ixian replacement exposes a neutral offer and privately claims its single card', () => { const f = createSemutaIxAllyCostFixture({independent:true}); assert.equal(f.game.advanced, false); assert.equal(f.game.ecazTreachery, true); assert.equal(f.game.semutaRecruitsPreview, true); let game = allowSemutaIxAllyCost(structuredClone(f.game)); const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.equal(pending.continuation.kind, 'ixAllyCard'); assert.equal(pending.reaction.stage, 'offer'); const event = pending.batch.event, discarded = f.deliveredCard; assert.deepEqual(pending.batch.entries.map(row => ({id:row.card.id, owner:row.discardedBy, face:row.publicFace})), [{id:discarded.id, owner:f.buyer, face:false}]); const holder = viewGame(game, f.holder), observer = viewGame(game, f.owner); assert.equal(holder.semutaReaction?.canCommit, true); assert.equal(observer.semutaReaction?.canCommit, false); assert.deepEqual(holder.semutaReaction?.candidates, []); assert.deepEqual(observer.semutaReaction?.candidates, []); const holderHtml = markup({game:holder, act() {}, busy:false}); const observerHtml = markup({game:observer, act() {}, busy:false}); for (const html of [holderHtml, observerHtml]) { assert.match(html, /Fresh Treachery discard/); assert.match(html, />Continue<\/button>/); assert.ok(!html.includes(discarded.id)); assert.ok(!html.includes(discarded.name)); } assert.match(holderHtml, />Commit Semuta Drug<\/button>/); assert.doesNotMatch(observerHtml, /Commit Semuta Drug/); const pass = buttonAction(game, f.owner, 'Continue', {type:'semutaPass', event}); game = applyAction(game, f.owner, pass); assert.match(markup({game:viewGame(game, f.owner), act() {}, busy:false}), /You continued/); const commit = buttonAction(game, f.holder, 'Commit Semuta Drug', {type:'semutaCommit', event}); game = applyAction(game, f.holder, commit); assert.equal(game.pendingTreacheryDiscard, null, 'A sole physical candidate is claimed without an extra confirmation.'); const own = viewGame(game, f.holder), rival = viewGame(game, f.owner); assert.ok(own.players.find(player => player.id === f.holder)?.hand?.some(card => card.id === discarded.id)); assert.equal(rival.players.find(player => player.id === f.holder)?.hand, undefined); assert.equal(SemutaReaction({game:own, act() {}, busy:false}), null); const saved = structuredClone(game); assert.throws(() => applyAction(game, f.holder, commit)); assert.deepEqual(game, saved); });