Add verified Truthtrance hand inventory questions

Players can ask total-hand and primary-role card-count comparisons using the
existing count controls and compound facts. Component presentation shares the
same default-role classification, including canonical Richese cards, Ix alternate
uses, and Ecaz slot-only specials. Only the respondent receives the verified
aggregate answer; current facts do not impose future custody promises.

Eight new tests cover all 60 catalog cards, real engine actions, malformed input,
private worlds, all four bots, JSON continuation, and a legal custody transfer.
Expanded production-room SQLite cases cover restored total/category/compound
facts, suspended auction state, and one winning concurrent answer/discard.
Independent review's two copy issues were fixed before broad verification.

Validation: types, lint and 4,200 offline tests passed; production build passed;
40 HTTP tests passed. One stable broad run (check 123036 ms, build 6505 ms,
integration 23467 ms). Browser room LC3E55GY verified combined role counts,
total hand size, invalid-input feedback and refresh at 390px phone width.
All 33 pre-integration rooms preserved unchanged; HTTP checks added 30 rooms.
No complete-mode, full-game or publication gate opened.

Private report: 20260913-hand-inventory-final/report.json
Source fingerprint: c6a69a9e69941cdd1e5734d92afedbf10d31fda36c91d26ca7e15f4fb7c59b18
Efficiency pilot milestone 3: aggregate counter 58,695,053 to 59,144,653 at
completed verification = 449,600 tokens, including context/coordination overhead.
Two bounded workhorse-model helpers, reused UI and SQLite fixtures, one browser
room. Counter is not monetary cost and this differently sized batch does not
establish a savings percentage. Final Git/push overhead follows this sample.
This commit is contained in:
Quintine committed 2026-09-13 11:29:05 +10:00
1 parent 32791ed5e2
commit 7454456755
14 files changed
+980 -151

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
import type { Card } from './cards';
import { richeseCardDefinition } from './richese-cards';
export const CARD_CATEGORIES = [
'weapon',
'defense',
'worthless',
'hero',
'special',
] as const;
export type CardCategory = (typeof CARD_CATEGORIES)[number];
const PRIMARY_CATEGORY: Record<Card['kind'], CardCategory> = {
projectile: 'weapon',
poison: 'weapon',
lasgun: 'weapon',
poisonBlade: 'weapon',
poisonTooth: 'weapon',
artillery: 'weapon',
weirdingWay: 'weapon',
shield: 'defense',
snooper: 'defense',
shieldSnooper: 'defense',
chemistry: 'defense',
worthless: 'worthless',
hero: 'hero',
special: 'special',
};
/** Primary component role, independent of chosen battle slots or faction powers.
* Richese's physical definitions distinguish weapons/defenses stored as special.
* Weirding Way and Chemistry retain their defaults; Worthless remains separate.
*/
export function printedCardCategory(card: Card): CardCategory {
const definition = richeseCardDefinition(card);
if (definition?.behavior.battleCategory === 'weapon') return 'weapon';
if (definition?.behavior.battleCategory === 'poison-defense')
return 'defense';
return PRIMARY_CATEGORY[card.kind];
}
+6 -7
View File
@@ -3,6 +3,7 @@ import { BATTLE_CARD_HELP, battleCardLabel } from './battle-cards';
import { RULE_TOPICS, type RuleTopic } from './reference';
import { ecazTreacheryDefinition } from './ecaz-cards';
import { richeseCardDefinition } from './richese-cards';
import { printedCardCategory } from './card-category';
/** Presentation uses only a card the caller has already been authorized to see. */
export type VisibleCard = Readonly<
@@ -130,15 +131,13 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
const topics = topicIds
.map((id) => RULE_TOPICS.find((topic) => topic.id === id))
.filter((topic): topic is RuleTopic => !!topic);
const role = ['shield', 'snooper', 'shieldSnooper', 'chemistry'].includes(
card.kind,
)
? 'defense'
: card.kind === 'hero'
const category = printedCardCategory(card);
const role =
category === 'hero'
? 'leader'
: card.kind === 'special' || card.kind === 'worthless'
: category === 'special' || category === 'worthless'
? 'utility'
: 'weapon';
: category;
return {
category:
card.kind === 'special'
+61
View File
@@ -2114,6 +2114,66 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
{
id: 'truthtrance-hand-inventory',
title: 'Truthtrance: hand size and primary card roles',
category: 'Cards',
coverage: 'Partial',
summary:
'Ask a verified Yes/No question about total hand size or how many held cards have a selected primary role.',
steps: [
'Declare Truthtrance, choose Verified cards, traitors or spice, then Hand size or primary card role. Select all cards or a primary role, a comparison and a whole count of zero or more. Review the question before asking.',
'Count only physical cards currently in the target’s hand, including reserved cards still held there. Decks, discards, separate caches and cards already on the table do not count. Every held physical copy counts once.',
'Primary roles are the cards’ default roles. Weirding Way is a weapon; Chemistry is a defense. Alternate battle roles do not change these counts. Worthless cards and Cheap Heroes or Heroines are separate categories. Bene Gesserit and CHOAM powers do not turn held Worthless cards into another category.',
'Richese cards use their printed roles: Stone Burner and Mirror Weapon are weapons; Portable Snooper is a defense. Other Special cards belong to the Special category. A classification question does not make an unfinished card effect available.',
'Only the target receives the verified answer before answering. Other players see the question and its final aggregate Yes/No answer, without a list of matching cards. An exact comparison answered Yes necessarily confirms that count. AND/OR combinations publish only their combined result.',
'These are current facts, not promises to keep cards or to use them in battle. All four AI levels answer from their own verified result. Broader freeform questions and future-action commitments remain unfinished.',
],
example:
'A hand of Weirding Way, Chemistry and Baliset has three cards: one primary weapon, one primary defense and one Worthless card.',
related: [
'card-truthtrance',
'truthtrance-card-count',
'truthtrance-spice',
'privacy',
],
checklist: [
{
area: 'Implementation',
status: 'Implemented',
detail:
'Validated total and primary-role physical hand counts share the component classification and aggregate fact evaluator.',
},
{
area: 'Player controls',
status: 'Implemented',
detail:
'Role selection, comparison, accessible count input, invalid-input feedback and the exact public question preview.',
},
{
area: 'AI',
status: 'Partial',
detail:
'All four profiles truthfully answer; strategic choice of these questions remains uncalibrated.',
},
{
area: 'Documentation',
status: 'Implemented',
detail:
'Explains hand custody, default roles, alternate uses, private aggregation and current-fact lifetime.',
},
{
area: 'Verification',
status: 'Partial',
detail:
'Focused role, engine, privacy, AI, JSON and persisted concurrent-answer checks; no complete mode certificate.',
evidence: [
'tests/truthtrance-hand-inventory.test.ts',
'tests/truthtrance-spice-recovery.test.ts',
],
},
],
},
{
id: 'truthtrance-shipment',
title: 'Truthtrance: shipment promises',
@@ -2194,6 +2254,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'privacy',
'truthtrance-spice',
'truthtrance-card-count',
'truthtrance-hand-inventory',
'truthtrance-shipment',
],
},
+88
View File
@@ -0,0 +1,88 @@
import type { Card } from './cards';
import {
CARD_CATEGORIES,
printedCardCategory,
type CardCategory,
} from './card-category';
/** A current custody question, not a promise to retain cards or use them. */
export type HandInventoryFact = {
kind: 'handInventory';
category: 'all' | CardCategory;
compare: 'eq' | 'gte' | 'lte';
value: number;
};
export const HAND_INVENTORY_LABELS: Record<
HandInventoryFact['category'],
string
> = {
all: 'All cards in hand',
weapon: 'Weapons · primary role',
defense: 'Defenses · primary role',
worthless: 'Worthless cards',
hero: 'Cheap Heroes and Heroines',
special: 'Special cards without a primary battle role',
};
export function parseHandInventoryFact(
v: Record<string, unknown>,
): HandInventoryFact {
if (
v.kind !== 'handInventory' ||
(v.category !== 'all' &&
!CARD_CATEGORIES.includes(v.category as CardCategory))
)
throw new Error('Choose all held cards or a known primary card role.');
if (v.compare !== 'eq' && v.compare !== 'gte' && v.compare !== 'lte')
throw new Error('Compare held cards using exactly, at least, or at most.');
if (
typeof v.value !== 'number' ||
!Number.isSafeInteger(v.value) ||
v.value < 0
)
throw new Error(
'Choose a nonnegative whole card count within the safe integer range.',
);
return {
kind: 'handInventory',
category: v.category as HandInventoryFact['category'],
compare: v.compare,
value: v.value,
};
}
export function handInventoryFactMatches(
hand: readonly Card[],
fact: HandInventoryFact,
): boolean {
const count =
fact.category === 'all'
? hand.length
: hand.filter((card) => printedCardCategory(card) === fact.category)
.length;
return fact.compare === 'eq'
? count === fact.value
: fact.compare === 'gte'
? count >= fact.value
: count <= fact.value;
}
export function handInventoryFactText(fact: HandInventoryFact): string {
const comparison =
fact.compare === 'eq'
? 'exactly'
: fact.compare === 'gte'
? 'at least'
: 'at most';
const cards = fact.value === 1 ? 'card' : 'cards';
const description: Record<HandInventoryFact['category'], string> = {
all: `${cards} in your hand`,
weapon: `${cards} whose primary role is a weapon`,
defense: `${cards} whose primary role is a defense`,
worthless: `Worthless ${cards}`,
hero: `Cheap Hero or Cheap Heroine ${cards}`,
special: `Special ${cards} without a primary battle role`,
};
return `you currently hold ${comparison} ${fact.value} ${description[fact.category]}`;
}
+18 -3
View File
@@ -9,6 +9,12 @@ import {
type CardCountFact,
} from './truthtrance-card-count';
import { richeseCards } from './richese-cards';
import {
parseHandInventoryFact,
handInventoryFactMatches,
handInventoryFactText,
type HandInventoryFact,
} from './truthtrance-hand-inventory';
import {
parsePlanClaim,
planClaimText,
@@ -22,6 +28,7 @@ import { DUKE_VIDAL_ID } from './duke-vidal';
export type TruthFact =
| CardCountFact
| HandInventoryFact
| { kind: 'hand'; name: string }
| { kind: 'traitor'; leader: string }
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
@@ -75,9 +82,11 @@ function parseFact(
'Truthtrance supports at most 16 clauses and four levels of grouping.',
);
const v = record(value);
if (v.kind === 'handCount') {
if (v.kind === 'handCount' || v.kind === 'handInventory') {
try {
return parseCardCountFact(v, TRUTH_CARD_NAMES);
return v.kind === 'handCount'
? parseCardCountFact(v, TRUTH_CARD_NAMES)
: parseHandInventoryFact(v);
} catch (error) {
throw new TruthError(
error instanceof Error ? error.message : String(error),
@@ -146,7 +155,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.status === 'playing' &&
g.phase === 5 &&
g.active === v.target &&
shipmentAvailable(g,g.players.find((p) => p.id === v.target)!) &&
shipmentAvailable(
g,
g.players.find((p) => p.id === v.target)!,
) &&
!g.response &&
!g.decision &&
!g.phaseOpening &&
@@ -252,6 +264,8 @@ export function truthFactAnswer(
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'>,
fact: TruthFact,
): TruthAnswer {
if (fact.kind === 'handInventory')
return handInventoryFactMatches(p.hand, fact) ? 'yes' : 'no';
if (fact.kind === 'handCount')
return cardCountFactMatches(p.hand, fact) ? 'yes' : 'no';
if (fact.kind === 'hand')
@@ -293,6 +307,7 @@ export function truthQuestionText(
if (q.kind === 'battlePlan')
return `In this battle in ${territory(q.territory).name}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
const clause = (f: TruthFact): string => {
if (f.kind === 'handInventory') return handInventoryFactText(f);
if (f.kind === 'handCount') return cardCountFactText(f);
if (f.kind === 'hand') return `you hold ${f.name}`;
if (f.kind === 'traitor') return `${leaderName(f.leader)} is your traitor`;