Support Truthtrance questions about battle prescience

This commit is contained in:
Quintine committed 2026-10-28 18:34:11 +11:00
1 parent 7d26929abd
commit ee3e1a0ab2
10 files changed
+395 -11

No files matched your search

@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
import * as board from '../game/board';
import * as catalog from '../game/catalog';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import * as knowledge from '../game/truthtrance-knowledge';
import * as cardNames from '../game/truthtrance-card-names';
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
import { TruthFactFields, invalidTruthFact } from '../components/mixed-shipment-fields';
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
import type { TruthFact } from '../game/truthtrance';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
void test('single and compound current-fact editors offer named Prescience fields', () => {
const g = createGame('PRESCIENCEUI', newPlayer('ask', 'Asker', 'emperor'));
joinGame(g, newPlayer('target', 'Target', 'atreides'));
const view = viewGame(g, 'ask');
const fact: TruthFact = { kind: 'presciencePeek', field: 'leader', name: 'Duncan Idaho' };
const single = renderToStaticMarkup(createElement(TruthFactFields, {
value: fact, game: view, target: 'target', onChange() {},
}));
assert.match(single, /value="presciencePeek" selected/);
assert.match(single, /value="Duncan Idaho" selected/);
assert.match(single, /value="weapon"/);
assert.match(single, /value="defense"/);
assert.match(single, /value="dial"/);
assert.match(single, /If that field is unknown or no longer available/);
assert.match(single, /Only the answer is published/);
const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, {
value: compound, game: view, target: 'target', disabled: false, onChange() {},
}));
assert.match(grouped, /Inspected battle plan field/);
assert.match(grouped, /value="Duncan Idaho" selected/);
assert.equal(invalidTruthFact(fact, view, 'target'), false);
assert.equal(invalidTruthFact({ kind: 'presciencePeek', field: 'dial', value: -1 }, view, 'target'), true);
});
void test('Prescience field callbacks select printed cards, None and half-strength dial values', () => {
const exports: { KnowledgeFactFields?: typeof KnowledgeFactFields } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/truthtrance-knowledge.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return { useId: () => 'prescience-test' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/board') return board;
if (name === '@/game/truthtrance-knowledge') return knowledge;
if (name === '@/game/truthtrance-card-names') return cardNames;
if (name === '@/components/ui/input') return { Input: 'input' };
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exports.KnowledgeFactFields);
let fact: knowledge.KnowledgeFact = { kind: 'presciencePeek', field: 'leader', name: 'None' };
const render = () => elements(exports.KnowledgeFactFields!({ value: fact, onChange(value) { fact = value; } }));
const change = (element: Element, value: string) =>
(element.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
change(render().filter(element => element.type === 'select')[0], 'weapon');
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'presciencePeek', field: 'weapon', name: 'None' });
change(render().filter(element => element.type === 'select')[1], 'Shield');
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'presciencePeek', field: 'weapon', name: 'Shield' });
change(render().filter(element => element.type === 'select')[0], 'dial');
const input = render().find(element => element.type === 'input'); assert.ok(input);
assert.equal(input.props.step, 0.5);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: 2.5 } });
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'presciencePeek', field: 'dial', value: 2.5 });
assert.equal(knowledge.knowledgeFactInputError(fact), null);
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { createGame, newPlayer, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import {
knowledgeFactAnswer, knowledgeFactText, parseKnowledgeFact, truthKnowledgeOf,
PRESCIENCE_LEADER_NAMES,
} from '../game/truthtrance-knowledge';
function fixture() {
const game = createGame('PRESTEST', newPlayer('a', 'Atreides', 'atreides'));
const target = newPlayer('e', 'Emperor', 'emperor');
const outsider = newPlayer('g', 'Guild', 'guild');
game.players.push(target, outsider);
game.phase = 6;
const shield = baseDeck().find(card => card.name === 'Shield')!;
target.hand.push(shield);
game.battle = {
territory: 'arrakeen', attacker: 'a', defender: 'e', plans: {}, revealed: false,
traitorCalls: {}, prescience: { player: 'a', field: 'weapon', value: shield.id },
} as NonNullable<Game['battle']>;
return { game, owner: game.players[0], target, outsider, shield };
}
void test('Prescience questions validate public faces and normalize their input', () => {
assert.ok(PRESCIENCE_LEADER_NAMES.includes('Hasimir Fenring'));
assert.deepEqual(parseKnowledgeFact({ kind: 'presciencePeek', field: 'leader', name: 'Hasimir Fenring', extra: true }),
{ kind: 'presciencePeek', field: 'leader', name: 'Hasimir Fenring' });
assert.deepEqual(parseKnowledgeFact({ kind: 'presciencePeek', field: 'dial', value: 2.5, extra: true }),
{ kind: 'presciencePeek', field: 'dial', value: 2.5 });
for (const fact of [
{ kind: 'presciencePeek', field: 'leader', name: 'secret' },
{ kind: 'presciencePeek', field: 'weapon', name: 'secret' },
{ kind: 'presciencePeek', field: 'dial', value: -1 },
{ kind: 'presciencePeek', field: 'dial', value: Infinity },
{ kind: 'presciencePeek', field: 'anything', name: 'None' },
]) assert.throws(() => parseKnowledgeFact(fact));
assert.match(knowledgeFactText({ kind: 'presciencePeek', field: 'dial', value: 2.5 }), /dial.*2.5/);
});
void test('only an entitled viewer answers from the answered live battle element', () => {
const { game, owner, target, outsider, shield } = fixture();
const fact = { kind: 'presciencePeek', field: 'weapon', name: shield.name } as const;
const different = { kind: 'presciencePeek', field: 'weapon', name: 'None' } as const;
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, owner)), 'yes');
assert.equal(knowledgeFactAnswer(different, truthKnowledgeOf(game, owner)), 'no');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, target)), 'yes');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, outsider)), 'unknown');
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: shield.name }, truthKnowledgeOf(game, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, phase: 7 }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, battle: null }, owner)), 'unknown');
const pending = { ...game.battle!, prescience: { player: 'a', field: 'weapon' as const } };
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, battle: pending }, owner)), 'unknown');
const canceled = { ...game.battle!, prescience: undefined };
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, battle: canceled }, owner)), 'unknown');
});
void test('dial, leader, absent card and suppressed Ecaz card follow visible battle insight', () => {
const { game, owner, target } = fixture();
game.battle!.prescience = { player: 'a', field: 'dial', value: 2.5 };
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'dial', value: 2.5 }, truthKnowledgeOf(game, owner)), 'yes');
game.battle!.prescience = { player: 'a', field: 'leader', value: target.leaders[0].id };
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'leader', name: target.leaders[0].name }, truthKnowledgeOf(game, owner)), 'yes');
game.battle!.prescience = { player: 'a', field: 'defense', value: null };
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: 'None' }, truthKnowledgeOf(game, owner)), 'yes');
const harass = ecazTreacheryCards().find(card => card.effect === 'harassWithdraw')!;
target.hand.push(harass);
game.battle!.prescience = { player: 'a', field: 'defense', value: harass.id };
const knowledge = truthKnowledgeOf(game, owner);
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: 'None' }, knowledge), 'yes');
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: harass.name }, knowledge), 'no');
assert.equal(truthFactAnswer(owner, { kind: 'and', terms: [
{ kind: 'presciencePeek', field: 'defense', name: 'None' }, { kind: 'spice', compare: 'gte', value: 0 },
] }, knowledge), 'yes');
});
void test('non-entitled players never inspect the concealed answer', () => {
const { game, outsider } = fixture();
game.battle!.prescience = {
player: 'a', field: 'weapon',
get value(): never { throw new Error('Unentitled prescience access'); },
};
assert.equal(truthKnowledgeOf(game, outsider).presciencePeek, null);
});
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { KnowledgeFact } from '../game/truthtrance-knowledge';
import {
actTruthTurnPower, advanceTruthTurnPower, createTruthTurnPowerFixture,
declareTruthTurnPower, finishTruthTurnPowerBattle, truthTurnPowerInventory,
truthTurnPowerPlayer,
} from './fixture-truth-turn-power';
/** Reach the original Basic battle through real setup, bidding and shipment,
* then actually declare and resolve the selected Prescience power. The source
* fixture selects unopened deck order but does not stage hands or combatants. */
function inspected(field: 'leader' | 'weapon' | 'defense' | 'dial') {
const fixture = createTruthTurnPowerFixture({ power: 'prescience' });
let game = actTruthTurnPower(fixture.game, fixture.atreides, { type: 'prescience', field });
game = advanceTruthTurnPower(game, state => !state.response &&
state.battle?.preparation?.kind === 'prescienceAnswer', [], true);
const respondent = game.battle!.preparation!.owner;
const subject = truthTurnPowerPlayer(game, respondent);
const leader = subject.leaders.find(item => !item.dead && !item.usedAt);
const weapon = subject.hand.find(item => item.kind === 'projectile');
assert.ok(leader); assert.ok(weapon);
const value = field === 'leader' ? leader.id : field === 'weapon' ? weapon.id :
field === 'defense' ? null : 0;
const name = field === 'leader' ? leader.name : field === 'weapon' ? weapon.name : 'None';
game = actTruthTurnPower(game, respondent, { type: 'prescienceAnswer', value });
assert.equal(game.battle?.prescience?.player, fixture.atreides);
assert.equal(game.battle?.prescience?.value, value);
return { fixture, game, respondent, name };
}
function ask(fixture: ReturnType<typeof createTruthTurnPowerFixture>, source: Game, fact: KnowledgeFact) {
const declared = declareTruthTurnPower(fixture, source).game;
return actTruthTurnPower(declared, fixture.asker, { type: 'truthAsk',
question: { kind: 'fact', target: fixture.atreides, fact } });
}
for (const field of ['leader', 'weapon', 'defense', 'dial'] as const) {
void test(`Basic native ${field} Prescience supports an actual truthful question and battle continuation`, () => {
const { fixture, game: source, name } = inspected(field);
const fact: KnowledgeFact = field === 'dial'
? { kind: 'presciencePeek', field, value: 0 }
: { kind: 'presciencePeek', field, name };
const asked = ask(fixture, source, fact);
assert.equal(viewGame(asked, fixture.atreides).truthAnswer, 'yes');
assert.equal(viewGame(asked, fixture.asker).truthAnswer, null);
assert.equal(viewGame(asked, fixture.asker).battle?.insight, null);
assert.throws(() => applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'no' }));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(asked, fixture.atreides);
view.players.find(player => player.id === fixture.atreides)!.bot = difficulty;
const actions = botActions(view);
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]);
const done = applyAction(asked, fixture.atreides, actions[0]);
assert.equal(done.truthHistory?.at(-1)?.answer, 'yes');
assert.equal(done.truthtrance, null);
assert.deepEqual(truthTurnPowerInventory(done), fixture.originalCards);
}
const answered = applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'yes' });
const settled = finishTruthTurnPowerBattle(answered, fixture);
assert.notEqual(settled.battle?.event, fixture.event);
assert.deepEqual(truthTurnPowerInventory(settled), fixture.originalCards);
});
}
void test('a different, uninspected element remains unknown in a real battle', () => {
const { fixture, game } = inspected('dial');
const asked = ask(fixture, game, { kind: 'presciencePeek', field: 'weapon', name: 'None' });
assert.equal(viewGame(asked, fixture.atreides).truthAnswer, 'unknown');
assert.throws(() => applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'yes' }));
const answered = applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'unknown' });
assert.equal(answered.truthtrance?.stage, 'unknown');
assert.deepEqual(truthTurnPowerInventory(answered), fixture.originalCards);
});