import test from 'node:test'; import assert from 'node:assert/strict'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { ecazInquiryAction } from '../game/nexus-ecaz-inquiry-options'; import { ecazInquiryAnswer } from '../game/nexus-ecaz-inquiry'; import { drawNexusCard } from '../game/nexus-cards'; import { NexusEcazInquiry } from '../components/nexus-ecaz-inquiry'; import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors'; import { unitStore } from './fixture-nexus-room-store'; import type { RoomsClock } from '../db/rooms'; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; const seat = (g: Game, id: string) => g.players.find(player => player.id === id)!; function setAnswer(g: Game, positive: boolean) { const [owner, target] = g.players; const native = new Set(owner.leaders.map(leader => leader.id)); const reserve = g.traitorReserve!; if (positive) { if (target.traitors.some(id => native.has(id))) return; const wanted = owner.leaders[0].id; const other = g.players.find(player => player.id !== target.id && player.traitors.includes(wanted)); if (other) { const index = other.traitors.indexOf(wanted); other.traitors[index] = target.traitors[0]; } else { const index = reserve.indexOf(wanted); assert.ok(index >= 0); reserve[index] = target.traitors[0]; } target.traitors[0] = wanted; } else { for (let i = 0; i < target.traitors.length; i++) { if (!native.has(target.traitors[i])) continue; const index = reserve.findIndex(id => !native.has(id)); assert.ok(index >= 0); [target.traitors[i], reserve[index]] = [reserve[index], target.traitors[i]]; } } nexusTraitorInventory(g); } function position(positive: boolean, advanced = false, ids: [string,string,string] = ['p','q','r']) { const [ownerId, targetId] = ids; const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'guild', advanced, phase: 2, seatIds: ids }); const cards = g.nexusCards!.cards!; const index = cards.deck.indexOf('ecaz'); assert.ok(index >= 0); cards.deck[index] = cards.hands[ownerId]!; cards.hands[ownerId] = 'ecaz'; setAnswer(g, positive); nexusTraitorInventory(g); const action = ecazInquiryAction(viewGame(g, ownerId), targetId); assert.ok(action); return { g, action }; } function reject(g: Game, id: string, action: {type: string;[key: string]: unknown}) { const before = reload(g); assert.throws(() => applyAction(g, id, action)); assert.deepEqual(g, before); } void test('Basic and Advanced inquiries spend one physical card and preserve only the owner’s historical yes/no', () => { for (const advanced of [false, true]) for (const positive of [false, true]) { const { g, action } = position(positive, advanced); const offer = viewGame(g, 'p').nexusEcazInquiry.offer!; assert.equal(offer.blocked, null); assert.deepEqual(offer.targets.map(target => target.id), ['p', 'q', 'r']); assert.equal(viewGame(g, 'q').nexusEcazInquiry.offer, null); assert.equal(JSON.stringify(offer).includes(seat(g, 'q').traitors[0]), false); const before = reload(g), beforeLog = g.log.length; const done = applyAction(g, 'p', action); assert.deepEqual(g, before); assert.equal(done.nexusCards!.cards!.hands.p, null); assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1); assert.equal(done.nexusEcazInquiries?.length, 1); assert.equal(done.nexusEcazInquiries![0].answer, positive); assert.equal(done.log.slice(beforeLog).some(row => /\b(?:Yes|No)\b/.test(row.text)), false); assert.equal(viewGame(done, 'p').nexusEcazInquiry.history[0].answer, positive); assert.deepEqual(viewGame(done, 'q').nexusEcazInquiry.history, []); assert.deepEqual(viewGame(done, 'r').nexusEcazInquiry.history, []); assert.equal(JSON.stringify(viewGame(done, 'q')).includes(done.nexusEcazInquiries![0].signature), false); assert.deepEqual(normalizeAutomaticGame(reload(done)), done); reject(done, 'p', action); nexusTraitorInventory(done); const changed = reload(done); setAnswer(changed, !positive); assert.equal(viewGame(changed, 'p').nexusEcazInquiry.history[0].answer, positive); } }); void test('self-targeting and later Nexus discard recycling preserve the recorded answer', () => { const { g } = position(false); const expected = ecazInquiryAnswer(seat(g, 'p'), seat(g, 'p')); const action = ecazInquiryAction(viewGame(g, 'p'), 'p'); assert.ok(action); const done = applyAction(g, 'p', action); assert.deepEqual(viewGame(done, 'p').nexusEcazInquiry.history[0], { event: action.event, target: 'p', turn: done.turn, phase: done.phase, answer: expected }); const recycled = reload(done), cards = recycled.nexusCards!.cards!; cards.discard.push(...cards.deck.splice(0)); recycled.nexusCards!.cards = drawNexusCard(cards, 'p', recycled.players, () => 0); assert.equal(recycled.nexusCards!.cards!.discard.includes('ecaz'), false); assert.equal(viewGame(recycled, 'p').nexusEcazInquiry.history[0].answer, expected); assert.deepEqual(normalizeAutomaticGame(reload(recycled)), recycled); }); void test('inquiry counts dead or captured native leaders but not foreign discs, Duke or Face Dancers', () => { const { g } = position(false); const owner = seat(g, 'p'), target = seat(g, 'q'); assert.equal(ecazInquiryAnswer(owner, target), false); const native = owner.leaders[0]; native.dead = true; native.capturedBy = 'r'; target.traitors.push(native.id); assert.equal(ecazInquiryAnswer(owner, target), true); target.traitors.pop(); owner.leaders.push({ ...native, id: 'duke-vidal' }); target.traitors.push('duke-vidal'); assert.equal(ecazInquiryAnswer(owner, target), false); target.traitors.pop(); target.faceDancers = [{ leader: native.id, revealed: false }]; assert.equal(ecazInquiryAnswer(owner, target), false); }); void test('stale, forged, foreign, interrupted and damaged saved inquiry rejects without changing a game', () => { const { g, action } = position(true); reject(g, 'q', action); reject(g, 'p', { ...action, event: 'stale' }); reject(g, 'p', { ...action, target: 'foreign' }); reject(g, 'p', { ...action, answer: true }); const busy = reload(g); busy.decision = { kind: 'choamMarket', player: 'q' }; assert.ok(viewGame(busy, 'p').nexusEcazInquiry.offer?.blocked); reject(busy, 'p', action); const done = applyAction(g, 'p', action); for (const damage of [ (state: Game) => { state.nexusEcazInquiries![0].answer = false; }, (state: Game) => { state.nexusEcazInquiries![0].target = 'r'; }, (state: Game) => { state.nexusEcazInquiryLast!.event = 'stale'; }, (state: Game) => { delete state.nexusEcazInquiries; }, ]) { const corrupt = reload(done); damage(corrupt); const before = reload(corrupt); assert.throws(() => viewGame(corrupt, 'p')); assert.deepEqual(corrupt, before); } }); void test('all four AI profiles inquire legally and the table keeps prior answers private after spending', () => { const { g, action } = position(true); for (const difficulty of ['Easy','Medium','Hard','Brutal'] as const) { const view = viewGame(g, 'p'); view.players.find(player => player.id === 'p')!.bot = difficulty; assert.deepEqual(botActions(view)[0], action); } const offerMarkup = renderToStaticMarkup(createElement(NexusEcazInquiry, { game: viewGame(g, 'p'), busy: false, act() {}, })); assert.match(offerMarkup, /Spend Ecaz Nexus · ask privately/); assert.equal(renderToStaticMarkup(createElement(NexusEcazInquiry, { game: viewGame(g, 'q'), busy: false, act() {}, })), ''); const done = applyAction(g, 'p', action); const historyMarkup = renderToStaticMarkup(createElement(NexusEcazInquiry, { game: viewGame(done, 'p'), busy: false, act() {}, })); assert.match(historyMarkup, /held one of your native leaders: Yes/); assert.equal(renderToStaticMarkup(createElement(NexusEcazInquiry, { game: viewGame(done, 'q'), busy: false, act() {}, })), ''); }); const clock: RoomsClock = {now: () => 10000, sleep: async () => {}}; void test('simultaneous saved inquiries commit once and keep answer private after authenticated recovery', async () => { const store = unitStore(); try { const made = await store.rooms.createRoom('Ecaz inquiry SQL', 'atreides', false, []); const code = made.view.code; const tokens = [made.token]; for (const faction of ['guild', 'fremen'] as const) tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); const ids = auths.map(auth => auth.playerId) as [string,string,string]; const { g } = position(true, false, ids); g.code = code; g.version = (await store.rooms.readRoom(code)).version; store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') .run(JSON.stringify(g), g.version, code); const action = ecazInquiryAction(viewGame(g, ids[0]), ids[1])!; let arrivals = 0, release!: () => void; const gate = new Promise(resolve => { release = resolve; }); store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; const timer = setTimeout(release, 2000); let results: PromiseSettledResult[]; try { results = await Promise.allSettled([ store.restart().act(code, auths[0], g.version, action, clock), store.restart().act(code, auths[0], g.version, action, clock), ]); } finally { clearTimeout(timer); delete store.hooks.beforeWrite; } assert.equal(arrivals, 2); assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); const done = await store.restart().readRoom(code); assert.equal(done.version, g.version + 1); assert.equal(done.nexusEcazInquiries?.length, 1); assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1); assert.equal((await store.restart().readSeatView(code, auths[0])).nexusEcazInquiry.history[0].answer, true); for (const auth of auths.slice(1)) assert.deepEqual((await store.restart().readSeatView(code, auth)).nexusEcazInquiry.history, []); reject(done, ids[0], action); } finally { store.sqlite.close(); } });