Support Truthtrance on Atreides spice foresight

This commit is contained in:
Quintine committed 2026-10-27 23:53:13 +11:00
1 parent 24255905ed
commit 235c2eb03f
11 files changed
+255 -16

No files matched your search

+28 -1
View File
@@ -3,10 +3,13 @@ import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
import { knowledgeFactInputError } from '../game/truthtrance-knowledge';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import type { TruthFact } from '../game/truthtrance';
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' }>) {
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' | 'spicePeek' }>) {
return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} }));
}
@@ -33,6 +36,30 @@ void test('knowledge controls expose full public faction registry and bounded st
assert.match(forecast, /If no such value is available/);
});
void test('spice peek controls offer public card identities with private-knowledge guidance', () => {
const markupText = markup({ kind: 'spicePeek', value: 'worm' });
assert.match(markupText, /value="worm"/);
assert.match(markupText, /Sandworm/);
assert.match(markupText, /value="sandtrout"/);
assert.match(markupText, /value="the_great_flat"/);
assert.match(markupText, /private Spice Blow peek/);
assert.match(markupText, /Only the answer is published/);
});
void test('standalone and compound fact editors both expose the spice peek question', () => {
const game = createGame('PEEKCONTROLS', newPlayer('a', 'Asker', 'emperor'));
joinGame(game, newPlayer('b', 'Target', 'atreides'));
const view = viewGame(game, 'a');
const fact: TruthFact = { kind: 'spicePeek', value: 'broken_land' };
const single = renderToStaticMarkup(createElement(TruthFactFields, { value: fact, game: view, target: 'b', onChange() {} }));
assert.match(single, /value="spicePeek" selected/);
assert.match(single, /value="broken_land" selected/);
const compound: TruthFact = { kind: 'and', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, { value: compound, game: view, target: 'b', disabled: false, onChange() {} }));
assert.match(grouped, /Known next Spice Blow card/);
assert.match(grouped, /value="broken_land" selected/);
});
void test('knowledge helper rejects invalid stored bounds', () => {
assert.ok(knowledgeFactInputError({ kind: 'prediction', field: 'turn', compare: 'eq', value: 0 }));
assert.ok(knowledgeFactInputError({ kind: 'prediction', field: 'turn', compare: 'eq', value: 11 }));
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer } from '../game/engine';
import { SPICE_CARDS } from '../game/cards';
import { truthFactAnswer } from '../game/truthtrance';
import {
SPICE_PEEK_VALUES, knowledgeFactAnswer, knowledgeFactText,
parseKnowledgeFact, truthKnowledgeOf,
} from '../game/truthtrance-knowledge';
void test('Spice knowledge questions accept only printed territories, worm and Sandtrout', () => {
assert.deepEqual(SPICE_PEEK_VALUES, ['worm', 'sandtrout', ...SPICE_CARDS.map(([id]) => id)]);
for (const value of SPICE_PEEK_VALUES)
assert.deepEqual(parseKnowledgeFact({ kind: 'spicePeek', value, extra: 'discarded' }), { kind: 'spicePeek', value });
for (const value of ['', 'arrakeen', 'unknown', 7, null, {}, ['worm']])
assert.throws(() => parseKnowledgeFact({ kind: 'spicePeek', value }));
assert.match(knowledgeFactText({ kind: 'spicePeek', value: 'worm' }), /sandworm/);
assert.match(knowledgeFactText({ kind: 'spicePeek', value: 'hagga_basin' }), /Hagga Basin/);
});
void test('only the Atreides respondent with a current successful peek can answer', () => {
const g = createGame('PEEKTEST', newPlayer('a', 'Atreides', 'atreides'));
const atreides = g.players[0];
const other = newPlayer('other', 'Other', 'guild');
g.phase = 5;
g.spicePeekKnown = true;
const fact = { kind: 'spicePeek', value: 'hagga_basin' } as const;
g.spiceDeck = [{ territory: 'hagga_basin', amount: 6, sector: 12 }];
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(g, atreides)), 'yes');
assert.equal(knowledgeFactAnswer({ kind: 'spicePeek', value: 'worm' }, truthKnowledgeOf(g, atreides)), 'no');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(g, other)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...g, spicePeekKnown: false }, atreides)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...g, phase: 6 }, atreides)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...g, spiceDeck: [] }, atreides)), 'unknown');
assert.equal(knowledgeFactAnswer(fact), 'unknown');
// Private deck access itself is fenced when this respondent is not entitled.
const hidden = { ...g, get spiceDeck(): never { throw new Error('Private deck read'); } };
assert.equal(truthKnowledgeOf(hidden, other).spicePeek, null);
hidden.phase = 6;
assert.equal(truthKnowledgeOf(hidden, atreides).spicePeek, null);
});
void test('worm, Sandtrout and discovery blows stay distinct and compose with other facts', () => {
const g = createGame('PEEKTEST', newPlayer('a', 'Atreides', 'atreides'));
const p = g.players[0];
Object.assign(g, { phase: 5, spicePeekKnown: true, spiceDeck: [{ worm: true }] });
const worm = { kind: 'spicePeek', value: 'worm' } as const;
assert.equal(knowledgeFactAnswer(worm, truthKnowledgeOf(g, p)), 'yes');
g.spiceDeck = [{ sandtrout: true }];
assert.equal(knowledgeFactAnswer(worm, truthKnowledgeOf(g, p)), 'no');
assert.equal(knowledgeFactAnswer({ kind: 'spicePeek', value: 'sandtrout' }, truthKnowledgeOf(g, p)), 'yes');
g.spiceDeck = [{ territory: 'hagga_basin', amount: 6, sector: 12, discovery: 'discovery-hagga-basin' }];
const knowledge = truthKnowledgeOf(g, p);
const blow = { kind: 'spicePeek', value: 'hagga_basin' } as const;
assert.equal(knowledgeFactAnswer(blow, knowledge), 'yes');
assert.equal(truthFactAnswer(p, { kind: 'and', terms: [blow, { kind: 'spice', compare: 'gte', value: 0 }] }, knowledge), 'yes');
assert.equal(truthFactAnswer(p, { kind: 'or', terms: [worm, { kind: 'spice', compare: 'gte', value: 0 }] }, knowledge), 'yes');
});
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { knowledgeGame, knowledgeBotActions, askKnowledge, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
/** Native setup and phase progression; only the holder's Truthtrance acquisition
* is staged by knowledgeGame, with each physical card retained exactly once. */
function withPeek(advanced = false) {
let g = knowledgeGame(advanced);
for (let n = 0; n < 300 && !(g.phase === 5 && g.spicePeekKnown && !g.response && !g.phaseOpening); n++) {
const candidate = g.players.flatMap(player => knowledgeBotActions(g, player.id)
.map(action => ({ id: player.id, action })))[0];
assert.ok(candidate, `Native progression needs a legal decision in phase ${g.phase}`);
g = applyAction(g, candidate.id, candidate.action);
}
assert.equal(g.phase, 5);
assert.equal(g.spicePeekKnown, true);
assert.ok(g.spiceDeck[0]);
return g;
}
void test('actual Atreides peek supplies a private Truthtrance answer and native AI continuation', () => {
for (const advanced of [false, true]) {
const initial = withPeek(advanced);
const card = initial.spiceDeck[0];
const value = 'territory' in card ? card.territory : 'worm' in card ? 'worm' : 'sandtrout';
const question = { kind: 'spicePeek' as const, value };
const g = askKnowledge(initial, 'o', question);
assert.equal(viewGame(g, 'o').truthAnswer, 'yes');
assert.deepEqual(viewGame(g, 'o').spicePeek, card);
for (const id of ['a', 'b', 'f']) {
assert.equal(viewGame(g, id).truthAnswer, null);
assert.equal(viewGame(g, id).spicePeek, null);
}
rejectKnowledge(g, 'o', { type: 'truthAnswer', answer: 'no' });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'o');
view.players.find(player => player.id === 'o')!.bot = difficulty;
const actions = botActions(view);
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]);
const done = applyAction(g, 'o', actions[0]);
assert.equal(done.truthtrance, null);
assert.equal(done.truthHistory!.at(-1)!.answer, 'yes');
assert.deepEqual(done.spiceDeck[0], card);
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
}
}
});
void test('a canceled peek yields unknown and retains the physical Truthtrance', () => {
const initial = withPeek();
initial.spicePeekKnown = false;
const g = askKnowledge(initial, 'o', { kind: 'spicePeek', value: 'worm' });
assert.equal(viewGame(g, 'o').truthAnswer, 'unknown');
rejectKnowledge(g, 'o', { type: 'truthAnswer', answer: 'yes' });
const done = applyAction(g, 'o', { type: 'truthAnswer', answer: 'unknown' });
assert.equal(done.truthtrance?.stage, 'unknown');
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
});