Interpret bounded Truthtrance fact questions
Verify and publish checkpoint container / container (push) Successful in 10m38s
Verify and publish checkpoint container / container (push) Successful in 10m38s
This commit is contained in:
1 parent
88c0ab1547
commit
8ae275f598
16 files changed
+431
-31
No files matched your search
@@ -90,7 +90,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
||||
<option value="stormForecast">Known storm forecast</option>
|
||||
<option value="spicePeek">Known next Spice Blow card</option>
|
||||
<option value="auctionPeek">Inspected ordinary auction card</option>
|
||||
<option value="cacheLot">Public Basic Richese cache auction card</option>
|
||||
<option value="cacheLot">Public Richese cache auction card</option>
|
||||
<option value="ixPoolPeek">Recorded Ixian inspected auction pool includes a card</option>
|
||||
<option value="presciencePeek">Inspected battle plan field</option>
|
||||
<option value="spice">Current personal spice</option>
|
||||
|
||||
@@ -75,12 +75,12 @@ export function KnowledgeFactFields({
|
||||
) : value.kind === 'cacheLot' ? (
|
||||
<>
|
||||
<label>
|
||||
Public Basic Richese cache auction card
|
||||
Public Richese cache auction card
|
||||
<select value={value.name} onChange={(event) => onChange({ kind: 'cacheLot', name: event.target.value })}>
|
||||
{TRUTH_CARD_NAMES.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className="fine">This asks about the current public Richese cache auction card in Basic play. If that auction is unavailable, the answer is I don’t know. Only the answer is published.</p>
|
||||
<p className="fine">This asks about the current public Richese cache auction card. If that auction is unavailable, the answer is I don’t know. Only the answer is published.</p>
|
||||
</>
|
||||
) : value.kind === 'auctionPeek' ? (
|
||||
<>
|
||||
|
||||
@@ -64,6 +64,7 @@ import { useId, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { parseFreeformFactV1 } from '@/game/truthtrance-freeform-facts';
|
||||
import {
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
@@ -352,6 +353,9 @@ export function Truthtrance({
|
||||
{w.question && (
|
||||
<blockquote>
|
||||
<p>{truthQuestionText(w.question, leaderName, locationName, name)}</p>
|
||||
{w.question.kind === 'freeform' && w.question.interpreted && (
|
||||
<p className="fine">Checked as: {truthQuestionText({ kind: 'fact', target: w.question.target, fact: w.question.interpreted.fact }, leaderName, locationName, name)}</p>
|
||||
)}
|
||||
<p className="fine">Asked of {name(w.question.target)}</p>
|
||||
</blockquote>
|
||||
)}
|
||||
@@ -556,7 +560,7 @@ export function Truthtrance({
|
||||
elements remain yours to choose.
|
||||
</p>
|
||||
</>
|
||||
) : w.question!.kind === 'fact' ? (
|
||||
) : w.question!.kind === 'fact' || w.question!.kind === 'freeform' && w.question!.interpreted ? (
|
||||
<>
|
||||
<p className="notice">
|
||||
Your current game information establishes:{' '}
|
||||
@@ -943,12 +947,17 @@ export function Truthtrance({
|
||||
placeholder="Will you ship at least six forces to Carthag this turn?"
|
||||
/>
|
||||
</label>
|
||||
<p className="notice">
|
||||
Freeform answers rely on the players. Automatic checks for
|
||||
arbitrary promises are unfinished. AI can answer structured
|
||||
fact, supported shipment and battle-plan questions; it
|
||||
cannot yet interpret freeform questions.
|
||||
</p>
|
||||
{scope === 'fact' && parseFreeformFactV1(text) ? (
|
||||
<p className="notice" aria-label="Recognized freeform question">
|
||||
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: parseFreeformFactV1(text)! }, leaderName, locationName, name)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="notice">
|
||||
This wording is not automatically interpreted. Players must answer
|
||||
facts truthfully; AI will answer I don’t know. Automatic checks
|
||||
for arbitrary promises remain unfinished.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!['fact', 'battlePlan', 'futureBattleLeader', 'shipment'].includes(kind) && button(
|
||||
|
||||
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Advanced cache and recognized freeform facts — 29 October. Prototyped / Partial.**
|
||||
Structured Truthtrance can name the current public Advanced Richese cache lot.
|
||||
New fact-scoped freeform questions also recognize exact current-spice comparisons
|
||||
and named-card holdings; the canonical interpretation is shown, verified and
|
||||
saved with a version. Unsupported wording and older unstamped questions retain
|
||||
their previous behavior. Fifty-two related cases pass, including human and
|
||||
minimal bot answers. General natural-language understanding, other rules and
|
||||
deployed acceptance remain open. See [scope](TRUTHTRANCE_KNOWLEDGE.md#recognized-freeform-current-facts--29-october-2026)
|
||||
and [cache scope](TRUTHTRANCE_KNOWLEDGE.md#public-advanced-richese-cache-lot--29-october-2026).
|
||||
|
||||
**Advanced Ixian auction knowledge — 29 October. Prototyped / Partial.**
|
||||
Native Ixians can answer a structured Truthtrance question about their recorded
|
||||
auction group in Advanced Bidding. The public-start journey covers native
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
# Truthtrance: recorded predictions and storm knowledge
|
||||
|
||||
## Recognized freeform current facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New freeform questions explicitly scoped as current
|
||||
facts recognize the exact forms “Do you have at least/exactly/at most N spice?”
|
||||
and “Do you have a/an [printed card name]?” with case-insensitive wording and
|
||||
optional question mark. The former counts the respondent's own current spice;
|
||||
the latter checks their physical hand. The public editor and pending question
|
||||
show the canonical interpretation. The respondent alone sees the verified
|
||||
answer until publishing it; the server rejects an inconsistent answer. All bot
|
||||
profiles use the same answer path.
|
||||
|
||||
Only anchored supported phrases are interpreted. Ambiguous, negative, compound,
|
||||
future or otherwise unsupported wording keeps the existing human-managed
|
||||
behavior, and bots answer unknown. Current-turn promise text is never parsed.
|
||||
The server stamps a versioned interpretation on each newly asked recognized
|
||||
question and validates it against its text; older unstamped questions keep
|
||||
their original behavior. This does not implement general language understanding
|
||||
or enforcement of arbitrary promises. The FAQ allows game-related yes/no
|
||||
questions; this limited parser is an online interpretation, not a printed
|
||||
restriction. See the [parser](../game/truthtrance-freeform-facts.ts) and its
|
||||
[rule](../tests/truthtrance-freeform-facts.test.ts) and
|
||||
[runtime](../tests/truthtrance-freeform-runtime.test.ts) tests.
|
||||
|
||||
## Public Advanced Richese cache lot — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A structured cache-lot question can also name the
|
||||
currently offered public Richese cache card in Advanced play. Any seated
|
||||
respondent can answer while that actual lot is active. A missing, settled,
|
||||
non-cache or inaccessible lot yields unknown; the private Black Market hand
|
||||
card is never exposed by this question. The existing Yes/No answer and minimal
|
||||
bot paths are unchanged.
|
||||
|
||||
The [Advanced journey](../tests/truthtrance-cache-lot-advanced.test.ts) starts
|
||||
an Advanced game and exercises the real cache and Black Market actions, human
|
||||
answers and all four bot profiles. Full Truthtrance, wider combinations and
|
||||
deployed acceptance remain open. The selector expresses the FAQ's general
|
||||
yes/no question permission; it is a provisional online interpretation.
|
||||
|
||||
## Public Basic Richese cache lot — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** A separate `{kind:'cacheLot', name}` fact asks whether
|
||||
the currently offered public Basic Richese cache card has a named printed face.
|
||||
Any seated respondent can give a definite answer while that actual lot is
|
||||
active. Missing, completed, Advanced and Black Market lots return unknown.
|
||||
active. At that checkpoint missing, completed, Advanced and Black Market lots
|
||||
returned unknown; Advanced public cache support is described above.
|
||||
Only the offered card is read, not the other cached cards; the existing
|
||||
Atreides inspection question keeps its original meaning. Single and compound
|
||||
editors use the public name registry and minimal bots use the same answer path.
|
||||
|
||||
+4
-2
@@ -47098,8 +47098,10 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
truthAnswer:
|
||||
g.truthtrance?.stage === 'answer' &&
|
||||
g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'fact'
|
||||
? truthFactAnswer(me, g.truthtrance.question.fact, truthKnowledgeOf(g, me))
|
||||
(g.truthtrance.question.kind === 'fact' ||
|
||||
g.truthtrance.question.kind === 'freeform' && g.truthtrance.question.interpreted)
|
||||
? truthFactAnswer(me, g.truthtrance.question.kind === 'fact'
|
||||
? g.truthtrance.question.fact : g.truthtrance.question.interpreted!.fact, truthKnowledgeOf(g, me))
|
||||
: null,
|
||||
schema: g.schema,
|
||||
botsPending: g.botsPending ?? false,
|
||||
|
||||
+4
-3
@@ -3293,7 +3293,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
|
||||
'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.',
|
||||
'Ask whether the current ordinary auction card or Advanced Richese Black Market card inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Other special-auction inspections and occupied Homeworld inspections are not yet included.',
|
||||
'In a Basic Richese cache auction, ask any participant whether the currently offered public cache card has a named face. If no supported cache lot is active, the answer is I don’t know. This is separate from the Atreides inspection question.',
|
||||
'In a Basic or Advanced Richese cache auction, ask any participant whether the currently offered public cache card has a named face. If no supported cache lot is active, the answer is I don’t know. This is separate from the Atreides inspection question.',
|
||||
'In Basic or Advanced Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.',
|
||||
'Ask an entitled participant whether an already answered Prescience reveals a named leader, weapon or defense, no element, or an exact force dial. A pending, unavailable or differently inspected element yields I don’t know.',
|
||||
'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.',
|
||||
@@ -3304,7 +3304,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks including Advanced Black Market, revealed Prescience, the Ixian recorded auction group and the current public Basic Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks including Advanced Black Market, revealed Prescience, the Ixian recorded auction group and the current public Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Fact choices, numeric comparisons, public faction and card names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/mixed-shipment-fields.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] },
|
||||
@@ -3989,11 +3989,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
'In a new freeform game-fact question, the exact forms “Do you have at least/exactly/at most N spice?” and “Do you have a [card name]?” are checked as current personal spice or hand facts. The recognized interpretation appears before asking and with the question. Other wording and current-turn promises remain player-managed; bots answer unknown. Already-pending older questions keep their original behavior.',
|
||||
'For current spice, choose Exactly, At least or At most and a nonnegative whole amount. This counts spice personally held when answering, excluding separately pledged aid and incoming payments. It does not ask whether a particular action is affordable or promise that the balance will stay unchanged. A true current-spice answer does not prevent later legal spending.',
|
||||
'For a concealed No-Field, choose its current printed value0,3 or5, not the prospective force pool. A missing or already revealed marker answers No, including for0; a concealed5 with empty reserves still answers Yes to5. Only the respondent sees the expected aggregate before public reply. Neither question nor answer turns up the physical token or commits later use; current-force facts continue to exclude markers.',
|
||||
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
|
||||
'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.',
|
||||
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
|
||||
'Recognized freeform current-fact questions use the same verified answer as structured facts. Other freeform questions accept Yes, No, or I don’t know and rely on the players; AI answers I don’t know for wording it cannot interpret.',
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
|
||||
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
|
||||
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import type { TruthFact } from './truthtrance';
|
||||
|
||||
/**
|
||||
* Version 1 deliberately recognizes only complete, unambiguous current facts.
|
||||
* Keep its semantics stable for questions that persist an interpretation.
|
||||
* The caller must restrict use to fact-scoped questions; this function never
|
||||
* interprets current-turn commitments or consults private game state.
|
||||
*/
|
||||
export function parseFreeformFactV1(text: string): Extract<TruthFact, { kind: 'hand' | 'spice' }> | null {
|
||||
if (typeof text !== 'string' || text.length > 500) return null;
|
||||
const input = text.trim();
|
||||
const spice = /^do[ \t]+you[ \t]+have[ \t]+(at[ \t]+least|exactly|at[ \t]+most)[ \t]+([0-9]+)[ \t]+spice[ \t]*\??$/i.exec(input);
|
||||
if (spice) {
|
||||
const value = Number(spice[2]);
|
||||
if (!Number.isSafeInteger(value) || value < 0) return null;
|
||||
const relation = spice[1].toLowerCase().replace(/[ \t]+/g, ' ');
|
||||
return { kind: 'spice', compare: relation === 'at least' ? 'gte' : relation === 'at most' ? 'lte' : 'eq', value };
|
||||
}
|
||||
const card = /^do[ \t]+you[ \t]+have[ \t]+(?:a|an)[ \t]+(.+?)[ \t]*\??$/i.exec(input);
|
||||
if (!card) return null;
|
||||
const name = card[1].trim().toLowerCase();
|
||||
const matches = TRUTH_CARD_NAMES.filter(candidate => candidate.toLowerCase() === name);
|
||||
return matches.length === 1 ? { kind: 'hand', name: matches[0] } : null;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export type TruthKnowledge = {
|
||||
stormForecast: number | null;
|
||||
spicePeek: string | null;
|
||||
auctionPeek: string | null;
|
||||
/** The currently offered public Basic Richese cache card, not the cache. */
|
||||
/** The currently offered public Richese cache card, not the cache. */
|
||||
cacheLot?: string | null;
|
||||
/** The authorized respondent's recorded Ixian group, not remaining lots. */
|
||||
ixPoolPeek?: readonly string[] | null;
|
||||
@@ -117,9 +117,9 @@ export function truthKnowledgeOf(
|
||||
? g.players.find(seat => seat.id === richeseLot.owner)?.hand.find(card => card.id === richeseLot.cardId)
|
||||
: undefined;
|
||||
const lotName = lot?.peekKnown ? lot.cards[lot.index]?.name : offered?.name;
|
||||
// A Basic Richese cache lot is shown to every seat. It is not the owner's
|
||||
// private cache inventory or an Advanced Black Market card.
|
||||
const cacheLot = g.phase === 3 && !g.advanced && g.players.some(seat => seat.id === p.id) &&
|
||||
// A Richese cache lot is public in Basic and Advanced; it is not the
|
||||
// owner's private cache inventory or an Advanced Black Market card.
|
||||
const cacheLot = g.phase === 3 && g.players.some(seat => seat.id === p.id) &&
|
||||
g.richeseAuction?.source === 'cache' && !g.richeseAuction.outcome
|
||||
? g.richeseCache?.find(card => card.id === g.richeseAuction!.cardId)?.name : undefined;
|
||||
// Native Ixians may answer from their own successful inspection. The
|
||||
|
||||
+27
-7
@@ -64,6 +64,7 @@ import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCo
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { coalitionAllies } from './coalition-alliance';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { parseFreeformFactV1 } from './truthtrance-freeform-facts';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
@@ -126,6 +127,8 @@ export type TruthQuestion =
|
||||
target: string;
|
||||
text: string;
|
||||
scope: 'fact' | 'currentTurn';
|
||||
/** Only newly asked, explicitly recognized facts carry this stable interpretation. */
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> };
|
||||
};
|
||||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||||
export type TruthQueueEntry = {
|
||||
@@ -643,12 +646,20 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
v.scope === 'fact' || v.scope === 'currentTurn',
|
||||
'Commitments may only concern the current turn.',
|
||||
);
|
||||
return {
|
||||
const question: Extract<TruthQuestion, { kind: 'freeform' }> = {
|
||||
kind: 'freeform',
|
||||
target: v.target,
|
||||
text: v.text.trim(),
|
||||
scope: v.scope,
|
||||
};
|
||||
if (v.interpreted !== undefined) {
|
||||
const fact = question.scope === 'fact' ? parseFreeformFactV1(question.text) : null;
|
||||
const expected = fact ? { version: 1 as const, fact } : null;
|
||||
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
|
||||
'The saved freeform interpretation does not match its original question.');
|
||||
question.interpreted = expected;
|
||||
}
|
||||
return question;
|
||||
}
|
||||
/** Reparse a committed question without recomputing its answer or binding again.
|
||||
* The caller supplies the unchanged parent opportunity with its controls restored. */
|
||||
@@ -691,6 +702,7 @@ export function validateSavedTruthtrance(g: Game) {
|
||||
);
|
||||
}
|
||||
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' ||
|
||||
window?.question?.kind === 'freeform' && window.question.interpreted ||
|
||||
window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader') {
|
||||
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
|
||||
'The saved fact question must retain its asking player and answer stage.');
|
||||
@@ -1095,7 +1107,15 @@ export function resolveTruthAction(
|
||||
p.id === current.player,
|
||||
'Waiting for the Truthtrance holder to ask a question.',
|
||||
);
|
||||
w.question = parseQuestion(g, p.id, a.question);
|
||||
// Derive the v1 interpretation at submission, never from client state.
|
||||
// Old saved questions without this stamp retain their previous semantics.
|
||||
const supplied = record(a.question);
|
||||
const { interpreted: _ignoredInterpretation, ...questionInput } = supplied;
|
||||
w.question = parseQuestion(g, p.id, questionInput);
|
||||
if (w.question.kind === 'freeform' && w.question.scope === 'fact') {
|
||||
const fact = parseFreeformFactV1(w.question.text);
|
||||
if (fact) w.question.interpreted = { version: 1, fact };
|
||||
}
|
||||
if (w.question.kind === 'mixedBattlePlan')
|
||||
check(effects.mixedBattleAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a whole mixed battle-plan question compatible with this battle.');
|
||||
@@ -1192,9 +1212,9 @@ export function resolveTruthAction(
|
||||
a.answer === 'yes' || a.answer === 'no' || a.answer === 'unknown',
|
||||
'Answer Yes, No, or I don’t know.',
|
||||
);
|
||||
if (q.kind === 'fact')
|
||||
if (q.kind === 'fact' || q.kind === 'freeform' && q.interpreted)
|
||||
check(
|
||||
a.answer === truthFactAnswer(p, q.fact, truthKnowledgeOf(g, p)),
|
||||
a.answer === truthFactAnswer(p, q.kind === 'fact' ? q.fact : q.interpreted!.fact, truthKnowledgeOf(g, p)),
|
||||
'Answer this fact question truthfully.',
|
||||
);
|
||||
if (q.kind === 'shipment' || q.kind === 'mixedShipment') {
|
||||
@@ -1347,11 +1367,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer compatible with your available native leaders and earlier commitments.');
|
||||
if (a.answer !== 'unknown') effects.bindFutureBattleLeader(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
// Freeform semantics cannot be certified by a deterministic engine. AI does not invent an answer.
|
||||
if (q.kind === 'freeform' && p.bot)
|
||||
// Unrecognized freeform semantics cannot be certified; AI does not invent an answer.
|
||||
if (q.kind === 'freeform' && !q.interpreted && p.bot)
|
||||
check(
|
||||
a.answer === 'unknown',
|
||||
'AI cannot yet interpret freeform questions; ask a structured fact question.',
|
||||
'AI cannot interpret this freeform question; ask a recognized or structured fact question.',
|
||||
);
|
||||
(g.truthHistory ??= []).push({
|
||||
turn: g.turn,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { newPlayer, viewGame } from '../game/engine';
|
||||
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
|
||||
import { knowledgeFactAnswer, truthKnowledgeOf } from '../game/truthtrance-knowledge';
|
||||
import {
|
||||
actTruthtranceRichesePromises as act,
|
||||
advanceTruthtranceRichesePromises as advance,
|
||||
askTruthtranceRichesePromises as ask,
|
||||
createTruthtranceRichesePromisesFixture as fixture,
|
||||
passTruthRicheseFreshDiscard,
|
||||
truthtranceRichesePromisesClean as clean,
|
||||
truthtranceRichesePromisesInventory as inventory,
|
||||
} from './fixture-truthtrance-richese-promises';
|
||||
|
||||
/** Public Advanced Richese setup and actual cache auction; the shared fixture
|
||||
* orders unopened deal and Spice cards but does not forge the cache lot. */
|
||||
function liveCache() {
|
||||
const f = fixture({ advanced: true });
|
||||
const game = advance(f.earlySource, state => clean(state) && state.phase === 3 &&
|
||||
state.richeseAuction?.source === 'cache' && !state.richeseAuction.outcome);
|
||||
const card = game.richeseCache!.find(item => item.id === game.richeseAuction!.cardId);
|
||||
assert.ok(card);
|
||||
assert.equal(game.advanced, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return { f, game, card };
|
||||
}
|
||||
|
||||
for (const expected of ['yes', 'no'] as const) {
|
||||
void test(`Advanced public cache question answers ${expected} and all bots continue`, () => {
|
||||
const { f, game, card } = liveCache();
|
||||
for (const seat of game.players) assert.equal(viewGame(game, seat.id).richeseAuction?.card?.id, card.id);
|
||||
const name = expected === 'yes' ? card.name : TRUTH_CARD_NAMES.find(face => face !== card.name);
|
||||
assert.ok(name);
|
||||
const lot = structuredClone(game.richeseAuction);
|
||||
const asked = ask(structuredClone(game), f, {kind:'fact', target:f.richese,
|
||||
fact:{kind:'cacheLot', name}});
|
||||
assert.equal(viewGame(asked, f.richese).truthAnswer, expected);
|
||||
assert.equal(viewGame(asked, f.asker).truthAnswer, null);
|
||||
const before = structuredClone(asked);
|
||||
assert.throws(() => act(asked, f.richese, {type:'truthAnswer', answer:expected === 'yes' ? 'no' : 'yes'}));
|
||||
assert.deepEqual(asked, before);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(asked, f.richese);
|
||||
view.players.find(player => player.id === f.richese)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:expected}]);
|
||||
const done = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.richese,
|
||||
{type:'truthAnswer', answer:expected}));
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
|
||||
assert.deepEqual(done.richeseAuction, lot);
|
||||
assert.deepEqual(inventory(done), f.originalCards);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced cache question is unknown outside the active public lot or for an outsider', () => {
|
||||
const {game, card} = liveCache();
|
||||
const fact = {kind:'cacheLot', name:card.name} as const;
|
||||
const target = game.players.find(player => player.id === 'richese')!;
|
||||
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, target)), 'yes');
|
||||
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, newPlayer('outside','Outside','guild'))), 'unknown');
|
||||
for (const change of [
|
||||
(state: typeof game) => { state.phase = 4; },
|
||||
(state: typeof game) => { state.richeseAuction = null; },
|
||||
(state: typeof game) => { state.richeseAuction!.outcome = {kind:'unbid'}; },
|
||||
(state: typeof game) => { state.richeseCache = state.richeseCache!.filter(item => item.id !== card.id); },
|
||||
]) {
|
||||
const staged = structuredClone(game); change(staged);
|
||||
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(staged, target)), 'unknown');
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual Advanced Black Market remains unknown to the public cache question', () => {
|
||||
const f = fixture({advanced:true});
|
||||
let game = advance(f.earlySource, state => state.decision?.kind === 'richeseBlackMarket');
|
||||
const card = game.players.find(player => player.id === f.richese)!.hand[0];
|
||||
assert.ok(card);
|
||||
game = act(game, f.richese, {type:'decision', event:game.richeseBidding!.event, card:card.id, method:'silent'});
|
||||
for (let count = 0; game.response && count < game.players.length; count++) {
|
||||
const respondent = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(respondent); game = act(game, respondent.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.richeseAuction?.source, 'blackMarket');
|
||||
assert.equal(knowledgeFactAnswer({kind:'cacheLot',name:card.name},
|
||||
truthKnowledgeOf(game, game.players.find(player => player.id === f.richese)!)), 'unknown');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
const asked = ask(game, f, {kind:'fact', target:f.richese, fact:{kind:'cacheLot', name:card.name}});
|
||||
assert.equal(viewGame(asked, f.richese).truthAnswer, 'unknown');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(asked, f.richese);
|
||||
view.players.find(player => player.id === f.richese)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:'unknown'}]);
|
||||
const answered = act(structuredClone(asked), f.richese, {type:'truthAnswer', answer:'unknown'});
|
||||
assert.equal(answered.truthtrance?.stage, 'unknown');
|
||||
assert.deepEqual(inventory(answered), f.originalCards);
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,7 @@ void test('single and compound current facts expose the public cache auction sel
|
||||
value: fact, game: view, target: 'target', onChange() {},
|
||||
}));
|
||||
assert.match(single, /value="cacheLot" selected/);
|
||||
assert.match(single, /Public Basic Richese cache auction card/);
|
||||
assert.match(single, /Public Richese cache auction card/);
|
||||
assert.match(single, /value="Shield" selected/);
|
||||
assert.match(single, /If that auction is unavailable/);
|
||||
const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
|
||||
@@ -40,7 +40,7 @@ void test('single and compound current facts expose the public cache auction sel
|
||||
value: compound, game: view, target: 'target', disabled: false, onChange() {},
|
||||
}));
|
||||
assert.match(grouped, /value="cacheLot" selected/);
|
||||
assert.match(grouped, /Public Basic Richese cache auction card/);
|
||||
assert.match(grouped, /Public Richese cache auction card/);
|
||||
assert.match(grouped, /value="Shield" selected/);
|
||||
assert.equal(invalidTruthFact(fact, view, 'target'), false);
|
||||
assert.equal(invalidTruthFact({ kind: 'cacheLot', name: 'not a printed card' }, view, 'target'), true);
|
||||
|
||||
@@ -33,12 +33,11 @@ void test('public cache question validates printed faces and answers from the ac
|
||||
}
|
||||
});
|
||||
|
||||
void test('cache lot question becomes unknown outside the supported active Basic public lot', () => {
|
||||
void test('cache lot question becomes unknown outside a supported active public lot', () => {
|
||||
const { game, card } = liveCache();
|
||||
const fact = { kind: 'cacheLot', name: card.name } as const;
|
||||
const outside = [
|
||||
(copy: typeof game) => { copy.phase = 4; },
|
||||
(copy: typeof game) => { copy.advanced = true; },
|
||||
(copy: typeof game) => { copy.richeseAuction = null; },
|
||||
(copy: typeof game) => { copy.richeseAuction!.source = 'blackMarket'; },
|
||||
(copy: typeof game) => { copy.richeseAuction!.outcome = { kind: 'unbid' }; },
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { Truthtrance } from '../components/truthtrance';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { knowledgeGame } from './truthtrance-knowledge-fixture';
|
||||
|
||||
/** Actual native game and Truthtrance actions, with the target's scalar
|
||||
* spice staged to show both personalized result branches. */
|
||||
function pending(spice: number): Game {
|
||||
let game = knowledgeGame();
|
||||
game.players.find(player => player.id === 'b')!.spice = spice;
|
||||
const card = game.players[0].hand.find(item => item.effect === 'truthtrance');
|
||||
assert.ok(card);
|
||||
game = applyAction(game,'a',{type:'card',card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const voter = game.players.find(player => !game.truthtrance!.passed.includes(player.id));
|
||||
assert.ok(voter); game = applyAction(game,voter.id,{type:'truthPass'});
|
||||
}
|
||||
return applyAction(game,'a',{type:'truthAsk',question:{kind:'freeform',target:'b',
|
||||
scope:'fact',text:'Do you have at least 5 spice?'}});
|
||||
}
|
||||
function markup(game: Game, seat: string) {
|
||||
return renderToStaticMarkup(createElement(Truthtrance, {game:viewGame(game,seat), busy:false, act() {}}));
|
||||
}
|
||||
|
||||
for (const [spice, answer] of [[5,'Yes'],[4,'No']] as const) {
|
||||
void test(`recognized freeform shows checked meaning to the table and private ${answer} to the target`, () => {
|
||||
const game = pending(spice);
|
||||
const target = markup(game,'b');
|
||||
const observer = markup(game,'o');
|
||||
for (const html of [target, observer]) {
|
||||
assert.match(html, /Do you have at least 5 spice\?/);
|
||||
assert.match(html, /Checked as:/);
|
||||
assert.match(html, /you currently hold at least 5 spice/);
|
||||
}
|
||||
assert.match(target, /Your current game information establishes:/);
|
||||
assert.match(target, new RegExp(`<strong>${answer}</strong>`));
|
||||
assert.match(target, /Only you see this until you answer/);
|
||||
assert.match(target, /Publish my answer/);
|
||||
assert.doesNotMatch(observer, /Your current game information establishes:/);
|
||||
assert.doesNotMatch(observer, /Publish my answer/);
|
||||
assert.match(observer, /Waiting for .* to answer/);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseFreeformFactV1 } from '../game/truthtrance-freeform-facts';
|
||||
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
|
||||
|
||||
void test('freeform v1 compiles exact current personal spice comparisons', () => {
|
||||
assert.deepEqual(parseFreeformFactV1('Do you have at least 5 spice?'), {kind:'spice', compare:'gte', value:5});
|
||||
assert.deepEqual(parseFreeformFactV1(' DO YOU HAVE EXACTLY 0 SPICE '), {kind:'spice', compare:'eq', value:0});
|
||||
assert.deepEqual(parseFreeformFactV1('do you have at most 7 spice'), {kind:'spice', compare:'lte', value:7});
|
||||
assert.deepEqual(parseFreeformFactV1('Do\tyou have at\tleast 0002 spice?'), {kind:'spice', compare:'gte', value:2});
|
||||
assert.equal(parseFreeformFactV1('Do you have exactly 9007199254740992 spice?'), null);
|
||||
});
|
||||
|
||||
void test('freeform v1 resolves only exact printed card names regardless of case', () => {
|
||||
for (const name of TRUTH_CARD_NAMES) {
|
||||
assert.deepEqual(parseFreeformFactV1(`do you have a ${name.toUpperCase()}?`), {kind:'hand', name});
|
||||
}
|
||||
assert.deepEqual(parseFreeformFactV1('Do you have an Artillery Strike?'), {kind:'hand', name:'Artillery Strike'});
|
||||
assert.equal(parseFreeformFactV1('Do you have a shield card?'), null);
|
||||
assert.equal(parseFreeformFactV1('Do you have a secret weapon?'), null);
|
||||
});
|
||||
|
||||
void test('freeform v1 declines ambiguous, compound, negative and future wording', () => {
|
||||
for (const phrase of [
|
||||
'', 'Do you have 5 spice?', 'Do you have more than 5 spice?',
|
||||
'Do you have at least -1 spice?', 'Do you have at least 2.5 spice?',
|
||||
'Do you have at least 5 spice and a Shield?',
|
||||
'Do you not have a Shield?', 'Do you have a Shield or a Snooper?',
|
||||
'Do you have a Shield this turn?', 'Will you have at least 5 spice?',
|
||||
'Do you have a Shield??', 'Do you have a Shield? Please answer.',
|
||||
'Do you have at least 5 spice?\nOr a Shield?',
|
||||
'Do you have a Shield?'.padEnd(501, ' '),
|
||||
]) assert.equal(parseFreeformFactV1(phrase), null, phrase);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
import { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
|
||||
|
||||
type Stamp = { version: 1; fact: unknown };
|
||||
type Freeform = {kind: 'freeform'; target: string; text: string; scope: 'fact' | 'currentTurn'; interpreted?: Stamp};
|
||||
|
||||
/** Actual Truthtrance declaration, priority, question and response actions.
|
||||
* The shared fixture starts the native game and stages original card custody. */
|
||||
function ask(state: Game, question: Freeform): Game {
|
||||
const owner = state.players[0];
|
||||
const card = owner.hand.find(item => item.effect === 'truthtrance'); assert.ok(card);
|
||||
let game = applyAction(state, owner.id, {type:'card', card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
|
||||
assert.ok(player); game = applyAction(game, player.id, {type:'truthPass'});
|
||||
}
|
||||
return applyAction(game, owner.id, {type:'truthAsk', question} as Action);
|
||||
}
|
||||
function freeform(game: Game): Freeform {
|
||||
const question = game.truthtrance?.question;
|
||||
assert.equal(question?.kind, 'freeform');
|
||||
return question as Freeform;
|
||||
}
|
||||
|
||||
for (const expected of ['yes','no'] as const) for (const kind of ['spice','hand'] as const) {
|
||||
void test(`recognized ${kind} freeform yields verified ${expected} for humans and all bots`, () => {
|
||||
const initial = knowledgeGame();
|
||||
const target = initial.players.find(player => player.id === 'b')!;
|
||||
let text: string, fact: Stamp['fact'];
|
||||
if (kind === 'spice') {
|
||||
target.spice = expected === 'yes' ? 5 : 4;
|
||||
text = 'Do you have at least 5 spice?';
|
||||
fact = {kind:'spice', compare:'gte', value:5};
|
||||
} else {
|
||||
const shield = [...initial.deck, ...initial.players.flatMap(player => player.hand)]
|
||||
.find(item => item.name === 'Shield'); assert.ok(shield);
|
||||
moveSemutaEmperorCard(initial, shield.id, expected === 'yes' ? target.id : 'f');
|
||||
// No duplicated physical cards and no other Shield face in this target's hand.
|
||||
if (expected === 'no') {
|
||||
for (const card of [...target.hand].filter(item => item.name === 'Shield'))
|
||||
moveSemutaEmperorCard(initial, card.id, 'f');
|
||||
}
|
||||
text = 'Do you have a Shield?';
|
||||
fact = {kind:'hand', name:'Shield'};
|
||||
}
|
||||
const physical = knowledgePhysical(initial);
|
||||
const card = initial.players[0].hand.find(item => item.effect === 'truthtrance')!;
|
||||
const pending = ask(initial, {kind:'freeform', target:target.id, scope:'fact', text});
|
||||
assert.deepEqual(freeform(pending).interpreted, {version:1, fact});
|
||||
assert.equal(viewGame(pending, target.id).truthAnswer, expected);
|
||||
for (const id of ['a','f','o']) assert.equal(viewGame(pending,id).truthAnswer, null);
|
||||
rejectKnowledge(pending, target.id, {type:'truthAnswer', answer:expected === 'yes' ? 'no' : 'yes'});
|
||||
rejectKnowledge(pending, target.id, {type:'truthAnswer', answer:'unknown'});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending,target.id);
|
||||
view.players.find(player => player.id === target.id)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:expected}]);
|
||||
const done = applyAction(structuredClone(pending), target.id, {type:'truthAnswer', answer:expected});
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
|
||||
assert.equal(done.discard.filter(item => item.id === card.id).length,1);
|
||||
assert.deepEqual(knowledgePhysical(done), physical);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('only server interpretation is used when a client forges a stamp', () => {
|
||||
const initial = knowledgeGame();
|
||||
initial.players.find(player => player.id === 'b')!.spice = 4;
|
||||
const asked = ask(initial, {kind:'freeform', target:'b', scope:'fact',
|
||||
text:'Do you have at least 5 spice?', interpreted:{version:1, fact:{kind:'spice', compare:'gte', value:0}}});
|
||||
assert.deepEqual(freeform(asked).interpreted, {version:1, fact:{kind:'spice',compare:'gte',value:5}});
|
||||
assert.equal(viewGame(asked,'b').truthAnswer,'no');
|
||||
rejectKnowledge(asked,'b',{type:'truthAnswer',answer:'yes'});
|
||||
const unsupported = ask(initial, {kind:'freeform', target:'b', scope:'fact',
|
||||
text:'Will you win two battles?', interpreted:{version:1, fact:{kind:'spice', compare:'gte', value:0}}});
|
||||
assert.equal(freeform(unsupported).interpreted, undefined);
|
||||
});
|
||||
|
||||
void test('unsupported and current-turn prose retain previous freeform behavior', () => {
|
||||
for (const question of [
|
||||
{kind:'freeform', target:'b', scope:'fact', text:'Will you win two battles?'},
|
||||
{kind:'freeform', target:'b', scope:'currentTurn', text:'Do you have at least 5 spice?'},
|
||||
] as Freeform[]) {
|
||||
const initial = knowledgeGame();
|
||||
const pending = ask(initial, question);
|
||||
assert.equal(freeform(pending).interpreted, undefined);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending,'b');
|
||||
view.players.find(player => player.id === 'b')!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer',answer:'unknown'}]);
|
||||
}
|
||||
const human = applyAction(pending,'b',{type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(human.truthHistory!.at(-1)!.answer,'yes');
|
||||
assert.equal(human.truthtrance,null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('already-pending unstamped legacy freeform is not reinterpreted on answer', () => {
|
||||
const initial = knowledgeGame();
|
||||
initial.players.find(player => player.id === 'b')!.spice = 4;
|
||||
const legacy = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?'});
|
||||
const pendingQuestion = freeform(legacy);
|
||||
delete pendingQuestion.interpreted;
|
||||
assert.equal(viewGame(legacy,'b').truthAnswer, null);
|
||||
const answered = applyAction(legacy,'b',{type:'truthAnswer',answer:'yes'});
|
||||
const recorded = answered.truthHistory!.at(-1)!.question as Freeform;
|
||||
assert.equal(recorded.interpreted,undefined);
|
||||
assert.equal(answered.truthHistory!.at(-1)!.answer,'yes');
|
||||
});
|
||||
Reference in new issue
Block a user