Connect Truthtrance questions about recorded predictions and storm knowledge
Add typed questions about the respondent's stored Bene Gesserit prediction faction/turn, active submitted storm dial and actually known native Advanced Fremen storm card. Use the same eligible private facts for authoritative validation and target-only answer projection. Preserve unknown/rephrase/save continuation, compound aggregate privacy and physical card custody. Future promises, freeform interpretation and strategic AI question selection remain incomplete; no mode or publication gate opens. Connect four question controls with all numeric comparisons, canonical faction choices and shared input guards. All four AI profiles answer the owner-only verified scalar. Independent source/privacy review and integration checks fixed missing comparisons, parent selector mapping and invalid submission guards. Thirteen new cases and the 44-case focused union cover normal/unknown/dishonest answers, private views, JSON and authenticated SQLite restoration, interrupted storm-peek continuation, stale/duplicate/competing requests and corrupt saved predicates without writes. Validation: types, lint, 4662 offline tests, production build and 43 HTTP tests passed with unchanged source. Two genuine-setup four-profile games completed without rejected actions: Basic 192 actions/five periodic JSON rounds and Advanced 518/fourteen. Exact Treachery/force/elite custody and all eight final restored seat views pass. Three explicitly scripted legal questions use naturally held cards, covering prediction faction, prediction turn and submitted storm dial. Forecast questioning is not exercised by these complete games; the samples do not measure autonomous AI asking strategy or strength. The separate Advanced browser exercise uses real prediction and forecast progression, staging only physical Truthtrance acquisition by relocation. Invalid dial input is blocked; an unknown answer can be rephrased; both compound prediction clauses and the forecast comparison work. Refresh after submission and final resolution restores history unknown/Yes/Yes and the remaining Shield. Both physical Truthtrances are discarded once, unrelated resources remain unchanged and private projection passes. SQLite tests establish pending-answer restoration; the browser observation does not claim a paused pending answer after reconnect. After the reported outage, all 767 opening games and original seat/recovery records were preserved (801 after HTTP checks). All 768 rooms and original records immediately before verification also remained unchanged. Database integrity and foreign keys passed. Reused the healthy server without restart, reset or recurring automation. Verified source tree: 09daaac288cb91ea407f4573f853a59d307e12ed30d2eccd39e671a47336f40d
This commit is contained in:
1 parent
8e1cd7b75b
commit
8906b41bda
14 files changed
+989
-13
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
|
||||
import { knowledgeFactInputError } from '../game/truthtrance-knowledge';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
|
||||
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' }>) {
|
||||
return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} }));
|
||||
}
|
||||
|
||||
void test('knowledge controls expose full public faction registry and bounded stored predicates', () => {
|
||||
const faction = markup({ kind: 'prediction', field: 'faction', faction: 'atreides' });
|
||||
assert.match(faction, /value="atreides"/);
|
||||
assert.match(faction, /value="emperor"/);
|
||||
assert.match(faction, /already recorded faction choice/);
|
||||
const turn = markup({ kind: 'prediction', field: 'turn', compare: 'gte', value: 1 });
|
||||
assert.match(turn, /min="1"/);
|
||||
assert.match(turn, /max="10"/);
|
||||
const dial = markup({ kind: 'stormDial', compare: 'lte', value: 20 });
|
||||
assert.match(dial, /min="0"/);
|
||||
assert.match(dial, /max="20"/);
|
||||
assert.match(dial, /value="eq"/);
|
||||
assert.match(dial, /value="gte"/);
|
||||
assert.match(dial, /value="lte"/);
|
||||
const forecast = markup({ kind: 'stormForecast', compare: 'eq', value: 6 });
|
||||
assert.match(forecast, /min="1"/);
|
||||
assert.match(forecast, /max="6"/);
|
||||
assert.match(forecast, /value="eq"/);
|
||||
assert.match(forecast, /value="gte"/);
|
||||
assert.match(forecast, /value="lte"/);
|
||||
assert.match(forecast, /If no such value is available/);
|
||||
});
|
||||
|
||||
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 }));
|
||||
assert.ok(knowledgeFactInputError({ kind: 'stormDial', compare: 'gte', value: 21 }));
|
||||
assert.ok(knowledgeFactInputError({ kind: 'stormForecast', compare: 'lte', value: 0 }));
|
||||
assert.equal(knowledgeFactInputError({ kind: 'stormDial', compare: 'eq', value: 20 }), null);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
|
||||
export function knowledgeBotActions(g:Game,id:string) {
|
||||
const view=viewGame(g,id);view.players.find(p=>p.id===id)!.bot='Medium';
|
||||
return botActions(view);
|
||||
}
|
||||
|
||||
/** Real Basic/Advanced setup and locked BG prediction. Only the asker's physical
|
||||
* Truthtrance acquisition is staged; all other opening cards remain in custody. */
|
||||
export function knowledgeGame(advanced = false, ids = ['a', 'b', 'f', 'o']) {
|
||||
let g = createGame('KNOWTEST', newPlayer(ids[0], 'Question holder', 'guild'), advanced, []);
|
||||
for (const [index, faction] of ['beneGesserit', 'fremen', 'atreides'].entries())
|
||||
joinGame(g, newPlayer(ids[index + 1], faction, faction as 'beneGesserit' | 'fremen' | 'atreides'));
|
||||
for (const p of g.players) p.ready = true;
|
||||
g = initializeBaseGameForAudit(g);
|
||||
g = applyAction(g, ids[1], { type: 'predict', faction: 'guild', turn: 7 });
|
||||
for (let n = 0; g.status !== 'playing' && n < 40; n++) {
|
||||
const candidate = g.players.flatMap(p => knowledgeBotActions(g,p.id).map(action => ({id:p.id,action})))[0];
|
||||
assert.ok(candidate,'A legal setup choice must be available');
|
||||
g = applyAction(g,candidate.id,candidate.action);
|
||||
}
|
||||
assert.equal(g.status,'playing');assert.equal(g.phase,0);
|
||||
const cards = [...g.deck,...g.players.flatMap(p=>p.hand)].filter(c=>c.effect==='truthtrance');
|
||||
assert.equal(cards.length,2);
|
||||
for (const card of cards) {
|
||||
g.deck = g.deck.filter(c=>c.id!==card.id);
|
||||
for (const p of g.players) p.hand = p.hand.filter(c=>c.id!==card.id);
|
||||
g.players[0].hand.push(card);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
export function knownForecastGame(ids = ['a','b','f','o']) {
|
||||
let g = knowledgeGame(true,ids);
|
||||
for (const id of g.stormDialers) g=applyAction(g,id,{type:'stormDial',amount:0});
|
||||
for (let n=0;(g.phase===0||g.response?.kind==='stormPeek')&&n<40;n++) {
|
||||
const candidates=g.players.flatMap(p=>knowledgeBotActions(g,p.id).map(action=>({id:p.id,action})));
|
||||
const candidate=candidates.find(c=>c.action.type==='passResponse'||c.action.type==='ready');
|
||||
assert.ok(candidate,'Storm progression must have a pass/ready action');
|
||||
g=applyAction(g,candidate.id,candidate.action);
|
||||
}
|
||||
assert.equal(g.phase,1);assert.equal(g.stormCardKnown,true);
|
||||
return g;
|
||||
}
|
||||
|
||||
export function askKnowledge(state:Game,target:string,fact:TruthFact) {
|
||||
const asker=state.players[0],card=asker.hand.find(c=>c.effect==='truthtrance')!;
|
||||
let g=applyAction(state,asker.id,{type:'card',card:card.id});
|
||||
while(g.truthtrance?.stage==='priority') {
|
||||
const p=g.players.find(p=>!g.truthtrance!.passed.includes(p.id))!;
|
||||
g=applyAction(g,p.id,{type:'truthPass'});
|
||||
}
|
||||
return applyAction(g,asker.id,{type:'truthAsk',question:{kind:'fact',target,fact}});
|
||||
}
|
||||
export function rejectKnowledge(g:Game,id:string,action:Action) {
|
||||
const before=structuredClone(g);assert.throws(()=>applyAction(g,id,action));assert.deepEqual(g,before);
|
||||
}
|
||||
export function knowledgePhysical(g:Game) {
|
||||
return {cards:[...g.deck,...g.discard,...g.players.flatMap(p=>p.hand)].map(c=>c.id).sort(),
|
||||
forces:g.players.map(p=>({id:p.id,total:p.reserves+p.tanks+Object.values(p.forces).reduce((a,b)=>a+b,0),elite:(p.elites?.reserves??0)+(p.elites?.tanks??0)+Object.values(p.elites?.forces??{}).reduce((a,b)=>a+b,0)}))};
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
knowledgeGame,
|
||||
knownForecastGame,
|
||||
askKnowledge,
|
||||
knowledgePhysical,
|
||||
} from './truthtrance-knowledge-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 23000, sleep: async () => {} };
|
||||
const rows = (db: DatabaseSync) =>
|
||||
db.prepare('SELECT state,version FROM rooms ORDER BY code').all();
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const secrets = (g: Game) => ({
|
||||
predictions: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
prediction: p.prediction ?? null,
|
||||
})),
|
||||
stormDials: g.stormDials,
|
||||
stormDialers: g.stormDialers,
|
||||
stormCard: g.stormCard,
|
||||
stormCardKnown: g.stormCardKnown,
|
||||
response: g.response,
|
||||
phase: g.phase,
|
||||
turn: g.turn,
|
||||
});
|
||||
|
||||
async function fixture(
|
||||
t: test.TestContext,
|
||||
advanced: boolean,
|
||||
prepare: (ids: string[]) => Game,
|
||||
) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom(
|
||||
'Private knowledge recovery',
|
||||
'guild',
|
||||
advanced,
|
||||
[],
|
||||
);
|
||||
const code = made.view.code,
|
||||
tokens = [made.token];
|
||||
for (const faction of ['beneGesserit', 'fremen', 'atreides'] 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),
|
||||
g = prepare(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);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, tokens, auths, ids, g };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
|
||||
async function restored(f: Fixture, expected: Game) {
|
||||
const before = rows(f.sqlite);
|
||||
for (const [index, token] of f.tokens.entries()) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, token);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(expected, f.ids[index]));
|
||||
for (const rival of view.players.filter((p) => p.id !== f.ids[index])) {
|
||||
assert.equal('hand' in rival, false);
|
||||
assert.equal('spice' in rival, false);
|
||||
assert.equal('prediction' in rival, false);
|
||||
}
|
||||
if (expected.truthtrance?.question?.target !== f.ids[index])
|
||||
assert.equal(view.truthAnswer, null);
|
||||
if (index !== 2) assert.equal(view.stormForecast, null);
|
||||
}
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
}
|
||||
async function act(
|
||||
f: Fixture,
|
||||
index: number,
|
||||
action: Action,
|
||||
version?: number,
|
||||
) {
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, f.tokens[index]);
|
||||
const current = await rooms.readRoom(f.code);
|
||||
await rooms.act(f.code, auth, version ?? current.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
function oneCardSpent(initial: Game, done: Game) {
|
||||
const card = initial.truthtrance!.queue[0].card,
|
||||
owner = initial.truthtrance!.queue[0].player;
|
||||
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
|
||||
for (const p of done.players)
|
||||
assert.equal(
|
||||
p.hand.some((c) => c.id === card),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
done.players.find((p) => p.id === owner)!.hand,
|
||||
initial.players
|
||||
.find((p) => p.id === owner)!
|
||||
.hand.filter((c) => c.id !== card),
|
||||
);
|
||||
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
|
||||
assert.equal(done.truthtrance, null);
|
||||
}
|
||||
|
||||
void test('saved BG AND/OR prediction questions restore privately and consume one physical Truthtrance only after an authenticated truthful answer', async (t) => {
|
||||
for (const advanced of [false, true])
|
||||
for (const kind of ['and', 'or'] as const) {
|
||||
const fact: TruthFact = {
|
||||
kind,
|
||||
terms: [
|
||||
{ kind: 'prediction', field: 'faction', faction: 'guild' },
|
||||
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 3 },
|
||||
],
|
||||
};
|
||||
const expected = kind === 'and' ? 'no' : 'yes';
|
||||
const f = await fixture(t, advanced, (ids) =>
|
||||
askKnowledge(knowledgeGame(advanced, ids), ids[1], fact),
|
||||
);
|
||||
await restored(f, f.g);
|
||||
const immutable = rows(f.sqlite),
|
||||
seats = f.sqlite
|
||||
.prepare('SELECT * FROM seats ORDER BY player_id')
|
||||
.all();
|
||||
await assert.rejects(
|
||||
act(f, 3, { type: 'truthAnswer', answer: expected }),
|
||||
);
|
||||
await assert.rejects(
|
||||
act(f, 1, {
|
||||
type: 'truthAnswer',
|
||||
answer: expected === 'yes' ? 'no' : 'yes',
|
||||
}),
|
||||
/truthfully/,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(f, 1, { type: 'truthAnswer', answer: 'unknown' }),
|
||||
/truthfully/,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(f, 1, { type: 'truthAnswer', answer: expected }, f.g.version - 1),
|
||||
/table changed/,
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), immutable);
|
||||
assert.equal(f.writes.length, 0);
|
||||
const done = await act(
|
||||
f,
|
||||
1,
|
||||
{ type: 'truthAnswer', answer: expected },
|
||||
f.g.version,
|
||||
);
|
||||
assert.equal(done.version, f.g.version + 1);
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
|
||||
assert.deepEqual(secrets(done), secrets(f.g));
|
||||
oneCardSpent(f.g, done);
|
||||
await restored(f, done);
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(
|
||||
act(f, 1, { type: 'truthAnswer', answer: expected }, f.g.version),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(f, 1, { type: 'truthAnswer', answer: expected }, done.version),
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes),
|
||||
[1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
seats,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('saved current storm-dial and known Fremen forecast facts answer without changing the committed value or exposing it to other seats', async (t) => {
|
||||
for (const forecast of [false, true]) {
|
||||
const f = await fixture(t, forecast, (ids) => {
|
||||
let g = forecast ? knownForecastGame(ids) : knowledgeGame(false, ids);
|
||||
const target = forecast
|
||||
? ids[2]
|
||||
: g.stormDialers.find((id) => id !== ids[0])!;
|
||||
if (!forecast)
|
||||
g = applyAction(g, target, { type: 'stormDial', amount: 0 });
|
||||
return askKnowledge(g, target, {
|
||||
kind: forecast ? 'stormForecast' : 'stormDial',
|
||||
compare: 'eq',
|
||||
value: forecast ? g.stormCard! : 0,
|
||||
});
|
||||
});
|
||||
const target = f.g.truthtrance!.question!.target,
|
||||
index = f.ids.indexOf(target);
|
||||
assert.equal(viewGame(f.g, target).truthAnswer, 'yes');
|
||||
await restored(f, f.g);
|
||||
const before = rows(f.sqlite);
|
||||
await assert.rejects(
|
||||
act(f, index, { type: 'truthAnswer', answer: 'no' }),
|
||||
/truthfully/,
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
const done = await act(f, index, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.deepEqual(secrets(done), secrets(f.g));
|
||||
oneCardSpent(f.g, done);
|
||||
await restored(f, done);
|
||||
if (!forecast) {
|
||||
const other = done.stormDialers.find((id) => id !== target)!;
|
||||
const continued = await act(f, f.ids.indexOf(other), {
|
||||
type: 'stormDial',
|
||||
amount: 0,
|
||||
});
|
||||
assert.equal(continued.stormDials[target], 0);
|
||||
assert.equal(continued.stormDials[other], 0);
|
||||
await restored(f, continued);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** A conserved real Karama creates the native response; no response object is invented. */
|
||||
function stormResponseGame(ids: string[]) {
|
||||
let g = knowledgeGame(true, ids);
|
||||
const karama = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
|
||||
(c) => c.effect === 'karama',
|
||||
)!;
|
||||
g.deck = g.deck.filter((c) => c.id !== karama.id);
|
||||
for (const p of g.players) p.hand = p.hand.filter((c) => c.id !== karama.id);
|
||||
g.players[0].hand.push(karama);
|
||||
for (const id of g.stormDialers)
|
||||
g = applyAction(g, id, { type: 'stormDial', amount: 0 });
|
||||
for (const p of g.players)
|
||||
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.response?.kind, 'stormPeek');
|
||||
assert.equal(g.stormCardKnown, false);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('unknown forecast survives restart and retains the card for rephrasing or saving while the original storm response resumes once', async (t) => {
|
||||
for (const save of [false, true]) {
|
||||
const f = await fixture(t, true, (ids) =>
|
||||
askKnowledge(stormResponseGame(ids), ids[2], {
|
||||
kind: 'stormForecast',
|
||||
compare: 'eq',
|
||||
value: 1,
|
||||
}),
|
||||
);
|
||||
const initial = clone(f.g),
|
||||
card = initial.truthtrance!.queue[0].card;
|
||||
await restored(f, initial);
|
||||
await assert.rejects(
|
||||
act(f, 2, { type: 'truthAnswer', answer: 'no' }),
|
||||
/truthfully/,
|
||||
);
|
||||
let g = await act(f, 2, { type: 'truthAnswer', answer: 'unknown' });
|
||||
assert.equal(g.truthtrance!.stage, 'unknown');
|
||||
assert.deepEqual(g.players[0].hand, initial.players[0].hand);
|
||||
assert.equal(
|
||||
g.discard.some((c) => c.id === card),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(secrets(g), secrets(initial));
|
||||
await restored(f, g);
|
||||
const before = rows(f.sqlite);
|
||||
await assert.rejects(act(f, 1, { type: 'truthSave' }));
|
||||
await assert.rejects(act(f, 2, { type: 'truthAnswer', answer: 'unknown' }));
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
if (save) {
|
||||
g = await act(f, 0, { type: 'truthSave' });
|
||||
assert.equal(g.truthtrance, null);
|
||||
assert.deepEqual(g.players[0].hand, initial.players[0].hand);
|
||||
assert.equal(
|
||||
g.discard.some((c) => c.id === card),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
g = await act(f, 0, {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: f.ids[1],
|
||||
fact: { kind: 'prediction', field: 'turn', compare: 'eq', value: 7 },
|
||||
},
|
||||
});
|
||||
await restored(f, g);
|
||||
g = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
|
||||
oneCardSpent(initial, g);
|
||||
}
|
||||
assert.deepEqual(g.response, initial.response);
|
||||
assert.deepEqual(secrets(g), secrets(initial));
|
||||
await restored(f, g);
|
||||
for (
|
||||
let count = 0;
|
||||
g.response?.kind === 'stormPeek' && count < 5;
|
||||
count++
|
||||
) {
|
||||
const responder = g.players.find(
|
||||
(p) => !g.response!.passed.includes(p.id),
|
||||
)!;
|
||||
g = await act(f, f.ids.indexOf(responder.id), { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.stormCardKnown, true);
|
||||
assert.equal(g.discard.filter((c) => c.id === card).length, save ? 0 : 1);
|
||||
assert.deepEqual(knowledgePhysical(g), knowledgePhysical(initial));
|
||||
await restored(f, g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent identical truthful prediction answers commit one physical card and one history entry', async (t) => {
|
||||
const f = await fixture(t, true, (ids) =>
|
||||
askKnowledge(knowledgeGame(true, ids), ids[1], {
|
||||
kind: 'prediction',
|
||||
field: 'faction',
|
||||
faction: 'guild',
|
||||
}),
|
||||
);
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled(
|
||||
[0, 1].map(() =>
|
||||
act(f, 1, { type: 'truthAnswer', answer: 'yes' }, f.g.version),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
delete f.hooks.beforeWrite;
|
||||
}
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter((r) => r.status === 'rejected').length, 1);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, f.g.version + 1);
|
||||
assert.equal(done.truthHistory!.length, 1);
|
||||
assert.deepEqual(secrets(done), secrets(f.g));
|
||||
oneCardSpent(f.g, done);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
await restored(f, done);
|
||||
});
|
||||
|
||||
void test('malformed saved knowledge bounds or fields cannot produce an authenticated answer or write a repaired state', async (t) => {
|
||||
const f = await fixture(t, false, (ids) =>
|
||||
askKnowledge(knowledgeGame(false, ids), ids[1], {
|
||||
kind: 'prediction',
|
||||
field: 'turn',
|
||||
compare: 'eq',
|
||||
value: 7,
|
||||
}),
|
||||
);
|
||||
for (const malformed of [
|
||||
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 11 },
|
||||
{ kind: 'prediction', field: 'winner', compare: 'eq', value: 7 },
|
||||
{ kind: 'stormForecast', compare: 'eq', value: 7 },
|
||||
]) {
|
||||
const damaged = clone(f.g),
|
||||
question = damaged.truthtrance!.question!;
|
||||
assert.equal(question.kind, 'fact');
|
||||
if (question.kind !== 'fact')
|
||||
throw new Error('Expected the stored structured question');
|
||||
question.fact = malformed as TruthFact;
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(JSON.stringify(damaged), f.code);
|
||||
const before = rows(f.sqlite),
|
||||
writes = f.writes.length;
|
||||
const rooms = f.restart(),
|
||||
auth = await rooms.authenticate(f.code, f.tokens[1]);
|
||||
await assert.rejects(
|
||||
rooms.readSeatView(f.code, auth),
|
||||
/prediction|forecast/,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(f, 1, { type: 'truthAnswer', answer: 'yes' }),
|
||||
/prediction|forecast/,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(f, 1, { type: 'truthAnswer', answer: 'unknown' }),
|
||||
/prediction|forecast/,
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, writes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { FACTIONS } from '../game/catalog';
|
||||
import { truthFactAnswer, truthQuestionText, type TruthFact } from '../game/truthtrance';
|
||||
import { knowledgeFactAnswer, parseKnowledgeFact, truthKnowledgeOf, type KnowledgeFact } from '../game/truthtrance-knowledge';
|
||||
import { knowledgeGame, knownForecastGame, askKnowledge, rejectKnowledge, knowledgePhysical, knowledgeBotActions } from './truthtrance-knowledge-fixture';
|
||||
|
||||
void test('knowledge predicates normalize typed inputs and reject invalid factions, comparisons and printed ranges',()=>{
|
||||
for(const faction of FACTIONS) assert.deepEqual(parseKnowledgeFact({kind:'prediction',field:'faction',faction:faction.id,extra:'ignored'}),{kind:'prediction',field:'faction',faction:faction.id});
|
||||
for(const [kind,min,max] of [['prediction',1,10],['stormDial',0,20],['stormForecast',1,6]] as const)
|
||||
for(const compare of ['eq','gte','lte'] as const) {
|
||||
const template={kind,...(kind==='prediction'?{field:'turn'}:{}),compare};
|
||||
for(const value of [min,max]) assert.equal((parseKnowledgeFact({...template,value}) as {value:number}).value,value);
|
||||
for(const value of [min-1,max+1,1.5,NaN,Infinity,'3',null]) assert.throws(()=>parseKnowledgeFact({...template,value}));
|
||||
assert.throws(()=>parseKnowledgeFact({...template,value:min,compare:'gt'}));
|
||||
}
|
||||
for(const value of [null,[],{}, {kind:'prediction',field:'winner'}, {kind:'prediction',field:'faction',faction:'unknown'}]) assert.throws(()=>parseKnowledgeFact(value));
|
||||
});
|
||||
|
||||
void test('recorded BG prediction answers faction and turn questions without exposing either underlying value',()=>{
|
||||
for(const advanced of [false,true])for(const [fact,expected] of [
|
||||
[{kind:'prediction',field:'faction',faction:'guild'},'yes'],
|
||||
[{kind:'prediction',field:'faction',faction:'atreides'},'no'],
|
||||
[{kind:'prediction',field:'turn',compare:'gte',value:7},'yes'],
|
||||
[{kind:'prediction',field:'turn',compare:'lte',value:6},'no'],
|
||||
] as const){
|
||||
const initial=knowledgeGame(advanced),g=askKnowledge(initial,'b',fact);
|
||||
const physical=knowledgePhysical(initial),saved=JSON.parse(JSON.stringify(g));
|
||||
for(const p of g.players) assert.deepEqual(viewGame(saved,p.id),viewGame(g,p.id));
|
||||
assert.equal(viewGame(g,'b').truthAnswer,expected);assert.equal(viewGame(g,'o').truthAnswer,null);
|
||||
const rival=viewGame(g,'a').players.find(p=>p.id==='b')!;assert.equal('prediction' in rival,false);
|
||||
rejectKnowledge(g,'b',{type:'truthAnswer',answer:expected==='yes'?'no':'yes'});
|
||||
rejectKnowledge(g,'o',{type:'truthAnswer',answer:expected});
|
||||
const done=applyAction(saved,'b',{type:'truthAnswer',answer:expected});
|
||||
assert.equal(done.truthtrance,null);assert.equal(done.truthHistory!.at(-1)!.answer,expected);
|
||||
assert.deepEqual(done.players[1].prediction,initial.players[1].prediction);assert.deepEqual(knowledgePhysical(done),physical);
|
||||
assert.equal(done.discard.filter(c=>c.effect==='truthtrance').length,1);
|
||||
rejectKnowledge(done,'b',{type:'truthAnswer',answer:expected});
|
||||
}
|
||||
});
|
||||
|
||||
void test('a submitted storm dial answers while locked and unpublished, but absent or expired submissions remain unknown',()=>{
|
||||
let initial=knowledgeGame();const target=initial.stormDialers.find(id=>id!=='a')!;
|
||||
const fact:KnowledgeFact={kind:'stormDial',compare:'eq',value:0};
|
||||
const missing=askKnowledge(initial,target,fact);assert.equal(viewGame(missing,target).truthAnswer,'unknown');
|
||||
initial=applyAction(initial,target,{type:'stormDial',amount:0});
|
||||
const g=askKnowledge(initial,target,fact);assert.equal(viewGame(g,target).truthAnswer,'yes');
|
||||
assert.equal(viewGame(g,'a').stormRevealed,null);
|
||||
const done=applyAction(g,target,{type:'truthAnswer',answer:'yes'});assert.equal(done.stormDials[target],0);
|
||||
assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
|
||||
const other=done.stormDialers.find(id=>id!==target)!;
|
||||
let progressed=applyAction(done,other,{type:'stormDial',amount:0});
|
||||
for(let n=0;progressed.phase===0&&n<20;n++) {
|
||||
const candidate=progressed.players.flatMap(p=>knowledgeBotActions(progressed,p.id).map(action=>({id:p.id,action}))).find(c=>c.action.type==='ready'||c.action.type==='passResponse')!;
|
||||
assert.ok(candidate);progressed=applyAction(progressed,candidate.id,candidate.action);
|
||||
}
|
||||
assert.equal(progressed.phase,1);
|
||||
assert.equal(truthKnowledgeOf(progressed,progressed.players.find(p=>p.id===target)!).stormDial,null);
|
||||
});
|
||||
|
||||
void test('forecast predicates use only actual Fremen knowledge and never reveal a canceled, unknown or other-faction storm card',()=>{
|
||||
const initial=knownForecastGame(),value=initial.stormCard!;
|
||||
const fact:KnowledgeFact={kind:'stormForecast',compare:'eq',value};
|
||||
const g=askKnowledge(initial,'f',fact);assert.equal(viewGame(g,'f').truthAnswer,'yes');
|
||||
for(const id of ['a','b','o']) {const v=viewGame(g,id);assert.equal(v.stormForecast,null);assert.equal(v.truthAnswer,null);}
|
||||
const done=applyAction(g,'f',{type:'truthAnswer',answer:'yes'});assert.equal(done.stormCard,value);assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
|
||||
for(const id of ['a','b','o']) assert.equal(truthKnowledgeOf(initial,initial.players.find(p=>p.id===id)!).stormForecast,null);
|
||||
const unknown={...initial,stormCardKnown:false};
|
||||
assert.equal(knowledgeFactAnswer(fact,truthKnowledgeOf(unknown,unknown.players[2])),'unknown');
|
||||
assert.equal(viewGame(askKnowledge(unknown,'f',fact),'f').truthAnswer,'unknown');
|
||||
assert.equal(truthKnowledgeOf({...initial,advanced:false},initial.players[2]).stormForecast,null);
|
||||
});
|
||||
|
||||
void test('compound answers publish only the aggregate and all four bots use the same owner-only truth',()=>{
|
||||
const fact:TruthFact={kind:'or',terms:[{kind:'prediction',field:'faction',faction:'guild'},{kind:'prediction',field:'turn',compare:'eq',value:3}]};
|
||||
const a=knowledgeGame(),b=structuredClone(a);b.players[1].prediction={faction:'atreides',turn:3};
|
||||
const left=askKnowledge(a,'b',fact),right=askKnowledge(b,'b',fact);
|
||||
for(const id of ['a','f','o']) assert.deepEqual(viewGame(left,id),viewGame(right,id));
|
||||
assert.equal(viewGame(left,'b').truthAnswer,'yes');assert.equal(viewGame(right,'b').truthAnswer,'yes');
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
const g=structuredClone(left);g.players[1].bot=difficulty;
|
||||
const actions=botActions(viewGame(g,'b'));assert.deepEqual(actions,[{type:'truthAnswer',answer:'yes'}]);
|
||||
const done=applyAction(g,'b',actions[0]);assert.equal(done.truthHistory!.at(-1)!.answer,'yes');
|
||||
}
|
||||
const knowledge=truthKnowledgeOf(a,a.players[1]);
|
||||
assert.equal(truthFactAnswer(a.players[1],{kind:'and',terms:[{kind:'stormForecast',compare:'eq',value:1},{kind:'prediction',field:'faction',faction:'atreides'}]},knowledge),'no');
|
||||
assert.equal(truthFactAnswer(a.players[1],{kind:'or',terms:[{kind:'stormForecast',compare:'eq',value:1},{kind:'prediction',field:'faction',faction:'atreides'}]},knowledge),'unknown');
|
||||
});
|
||||
|
||||
void test('unknown stored knowledge retains the real card and permits a different question without fabricating a future promise',()=>{
|
||||
const initial=knowledgeGame(),g=askKnowledge(initial,'o',{kind:'prediction',field:'turn',compare:'eq',value:7});
|
||||
assert.equal(viewGame(g,'o').truthAnswer,'unknown');rejectKnowledge(g,'o',{type:'truthAnswer',answer:'no'});
|
||||
const unknown=applyAction(g,'o',{type:'truthAnswer',answer:'unknown'});
|
||||
assert.equal(unknown.truthtrance!.stage,'unknown');assert.deepEqual(unknown.players[0].hand,initial.players[0].hand);
|
||||
const retry=applyAction(unknown,'a',{type:'truthAsk',question:{kind:'fact',target:'b',fact:{kind:'prediction',field:'turn',compare:'eq',value:7}}});
|
||||
const done=applyAction(retry,'b',{type:'truthAnswer',answer:'yes'});assert.equal(done.truthtrance,null);
|
||||
assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
|
||||
const wording=truthQuestionText({kind:'fact',target:'b',fact:{kind:'prediction',field:'faction',faction:'guild'}},id=>id);
|
||||
assert.match(wording,/recorded.*prediction/);assert.doesNotMatch(wording,/will you win|must win/i);
|
||||
});
|
||||
Reference in new issue
Block a user