Support Truthtrance on Atreides auction inspection

This commit is contained in:
Quintine committed 2026-10-27 23:57:01 +11:00
1 parent 235c2eb03f
commit aa6f77372f
11 files changed
+228 -11

No files matched your search

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { createGame, newPlayer, type Auction } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
import { knowledgeFactAnswer, knowledgeFactText, parseKnowledgeFact, truthKnowledgeOf } from '../game/truthtrance-knowledge';
void test('auction knowledge accepts canonical Treachery Card names and normalizes inputs', () => {
for (const name of TRUTH_CARD_NAMES)
assert.deepEqual(parseKnowledgeFact({ kind: 'auctionPeek', name, extra: true }), { kind: 'auctionPeek', name });
for (const name of ['', 'a hidden card', null, 7, {}, ['Shield']])
assert.throws(() => parseKnowledgeFact({ kind: 'auctionPeek', name }));
assert.match(knowledgeFactText({ kind: 'auctionPeek', name: 'Shield' }), /auction card.*Shield/);
});
void test('only Atreides with an active successful ordinary-lot inspection can answer', () => {
const game = createGame('LOTTEST', newPlayer('a', 'Atreides', 'atreides'));
const owner = game.players[0];
const other = newPlayer('b', 'Other', 'guild');
const cards = baseDeck();
const shield = cards.find(card => card.name === 'Shield')!;
const otherCard = cards.find(card => card.name !== 'Shield')!;
const auction: Auction = { cards: [shield, otherCard], index: 0, bid: 0, bidder: null,
active: 'a', passed: [], opener: 0, peekKnown: true };
game.phase = 3;
game.auction = auction;
const fact = { kind: 'auctionPeek', name: 'Shield' } as const;
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, owner)), 'yes');
assert.equal(knowledgeFactAnswer({ kind: 'auctionPeek', name: otherCard.name }, truthKnowledgeOf(game, owner)), 'no');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, other)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, phase: 4 }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, auction: null }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, auction: { ...auction, peekKnown: false } }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, auction: { ...auction, index: 8 } }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact), 'unknown');
const next = { ...game, auction: { ...auction, index: 1 } };
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(next, owner)), 'no');
assert.equal(knowledgeFactAnswer({ kind: 'auctionPeek', name: otherCard.name }, truthKnowledgeOf(next, owner)), 'yes');
});
void test('non-entitled callers cannot read private lot cards; compound questions expose only the aggregate', () => {
const game = createGame('LOTTEST', newPlayer('a', 'Atreides', 'atreides'));
const owner = game.players[0];
const other = newPlayer('b', 'Other', 'guild');
game.phase = 3;
const shield = baseDeck().find(card => card.name === 'Shield')!;
const lot: Auction = { cards: [shield], index: 0, bid: 0, bidder: null,
active: 'a', passed: [], opener: 0, peekKnown: true };
game.auction = lot;
const hidden = { ...game, get auction(): never { throw new Error('Unentitled auction access'); } };
assert.equal(truthKnowledgeOf(hidden, other).auctionPeek, null);
hidden.phase = 4;
assert.equal(truthKnowledgeOf(hidden, owner).auctionPeek, null);
const uninspected = { ...lot, peekKnown: false, get cards(): never { throw new Error('Uninspected card access'); } };
assert.equal(truthKnowledgeOf({ ...game, auction: uninspected }, owner).auctionPeek, null);
const knowledge = truthKnowledgeOf(game, owner);
const fact = { kind: 'auctionPeek', name: 'Shield' } as const;
assert.equal(truthFactAnswer(owner, { kind: 'and', terms: [fact, { kind: 'spice', compare: 'gte', value: 0 }] }, knowledge), 'yes');
assert.equal(truthFactAnswer(owner, { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 999 }] }, knowledge), 'yes');
});
@@ -0,0 +1,60 @@
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';
/** Real setup and ordinary auction progression. The fixture stages only the
* holder's acquisition of the two physical Truthtrance cards. */
function withInspection(advanced = false) {
let g = knowledgeGame(advanced);
for (let n = 0; n < 200 && !(g.phase === 3 && g.auction?.peekKnown && !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 auction progression needs a legal decision in phase ${g.phase}`);
g = applyAction(g, candidate.id, candidate.action);
}
assert.equal(g.phase, 3);
assert.equal(g.auction?.peekKnown, true);
assert.ok(g.auction?.cards[g.auction.index]);
return g;
}
void test('actual ordinary auction inspection supplies a private Truthtrance answer for all AI profiles', () => {
for (const advanced of [false, true]) {
const initial = withInspection(advanced);
const card = initial.auction!.cards[initial.auction!.index];
const g = askKnowledge(initial, 'o', { kind: 'auctionPeek', name: card.name });
assert.equal(viewGame(g, 'o').truthAnswer, 'yes');
assert.equal(viewGame(g, 'o').auction?.card?.id, card.id);
for (const id of ['a', 'b', 'f']) {
assert.equal(viewGame(g, id).truthAnswer, null);
assert.equal(viewGame(g, id).auction?.card, 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.equal(done.auction!.cards[done.auction!.index].id, card.id);
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
}
}
});
void test('an unavailable auction inspection yields unknown and retains the physical card', () => {
const initial = withInspection();
initial.auction!.peekKnown = false;
const name = initial.auction!.cards[initial.auction!.index].name;
const g = askKnowledge(initial, 'o', { kind: 'auctionPeek', name });
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));
});
+17 -1
View File
@@ -9,7 +9,7 @@ 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' | 'spicePeek' }>) {
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' | 'spicePeek' | 'auctionPeek' }>) {
return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} }));
}
@@ -36,6 +36,22 @@ void test('knowledge controls expose full public faction registry and bounded st
assert.match(forecast, /If no such value is available/);
});
void test('auction peek selector is available in single and compound current-fact questions', () => {
const game = createGame('AUCTIONCONTROLS', newPlayer('a', 'Asker', 'emperor'));
joinGame(game, newPlayer('b', 'Target', 'atreides'));
const view = viewGame(game, 'a');
const fact: TruthFact = { kind: 'auctionPeek', name: 'Shield' };
const single = renderToStaticMarkup(createElement(TruthFactFields, { value: fact, game: view, target: 'b', onChange() {} }));
assert.match(single, /value="auctionPeek" selected/);
assert.match(single, /value="Shield" selected/);
assert.match(single, /current ordinary auction card/);
assert.match(single, /If no current card is known/);
const compound: TruthFact = { kind: 'or', 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, /Inspected ordinary auction card/);
assert.match(grouped, /value="Shield" selected/);
});
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"/);