import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TupileIntelligence } from '../components/tupile-intelligence';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import {
tupileIntelligenceActions,
tupileIntelligenceCanAct,
tupileIntelligenceChoice,
} from '../game/tupile-intelligence-options';
/** Projected facts isolate client/policy behavior. The engine supplies contact,
* lifetime use, timing and private answer custody; the client cannot infer them. */
function fixture(): GameView {
const g = createGame('TUPILECONTROLS', newPlayer('c', 'CHOAM', 'choam'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 2, turn: 3, active: 'a', order: ['a', 'e', 'c'], storm: 18 });
const view = viewGame(g, 'c');
view.tupileIntelligence = {
owner: 'c', blocked: null,
targets: [
{ player: 'a', faction: 'atreides', contact: ['homeworld:atreides'], blocked: null },
{ player: 'e', faction: 'emperor', contact: ['homeworld:emperor:salusa'], blocked: null },
],
receipts: [],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(TupileIntelligence, { game, busy, act() {} }));
}
void test('Tupile choice emits exactly one selected category for a current eligible target without mutation', () => {
const view = fixture();
const before = structuredClone(view);
for (const category of ['weapons', 'defenses'] as const)
assert.deepEqual(tupileIntelligenceChoice(view, 'e', category), {
blocked: null, action: { type: 'tupileIntelligence', target: 'e', category },
});
for (const target of ['c', 'missing'])
assert.equal(tupileIntelligenceChoice(view, target, 'weapons').action, null);
assert.equal(tupileIntelligenceChoice(view, 'a', 'both' as never).action, null);
assert.deepEqual(view, before);
view.tupileIntelligence!.targets.push({ ...view.tupileIntelligence!.targets[0] });
assert.equal(tupileIntelligenceChoice(view, 'a', 'weapons').action, null);
});
void test('all policy profiles skip spent or ineligible factions and obey the server timing block', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = fixture();
view.players.find((player) => player.id === view.me)!.bot = profile;
view.tupileIntelligence!.targets[0].blocked = 'Already used against Atreides in this game.';
assert.deepEqual(tupileIntelligenceActions(view), [
{ type: 'tupileIntelligence', target: 'e', category: 'weapons' },
]);
view.tupileIntelligence!.blocked = 'Finish the current response before requesting intelligence.';
assert.equal(tupileIntelligenceCanAct(view), false);
assert.deepEqual(tupileIntelligenceActions(view), []);
assert.equal(tupileIntelligenceChoice(view, 'e', 'defenses').blocked, view.tupileIntelligence!.blocked);
view.tupileIntelligence!.blocked = null;
view.tupileIntelligence!.targets[1].blocked = 'No current Homeworld contact.';
assert.deepEqual(tupileIntelligenceActions(view), []);
}
});
void test('owned controls expose accessible opponent/category selection and one request button', () => {
const html = markup(fixture());
assert.match(html, /aria-label="Opposing faction"/);
assert.match(html, /aria-label="Card count"/);
assert.match(html, /value="weapons" selected=""/);
assert.match(html, /value="defenses"/);
assert.match(html, />Atreides<\/option>/);
assert.match(html, />Emperor<\/option>/);
assert.equal((html.match(/Request intelligence<\/button>/);
assert.doesNotMatch(html, /confirm|Reveal hand|Your private observations/i);
});
void test('private historical observations remain readable during a block and after JSON restoration', () => {
const view = fixture();
view.tupileIntelligence!.blocked = 'Occupation must be resolved before another request.';
view.tupileIntelligence!.receipts = [
{ event: 'old-a', target: 'a', faction: 'atreides', category: 'weapons', spice: 13, count: 2, turn: 1, phase: 3 },
{ event: 'old-e', target: 'e', faction: 'emperor', category: 'defenses', spice: 0, count: 1, turn: 2, phase: 7 },
];
const before = structuredClone(view);
const html = markup(JSON.parse(JSON.stringify(view)));
assert.match(html, /Your private observations/);
assert.match(html, /13 spice, 2 weapons/);
assert.match(html, /0 spice, 1 defense/);
assert.match(html, /Turn 1 · Bidding/);
assert.match(html, /Turn 2 · Spice collection/);
assert.ok(html.indexOf('0 spice, 1 defense') < html.indexOf('13 spice, 2 weapons'));
assert.match(html, /do not update/);
assert.match(html, /Occupation must be resolved/);
assert.match(html, /