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

+58 -18
View File
@@ -4,29 +4,56 @@ import { useId } from 'react';
import { Input } from '@/components/ui/input';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
import type { CardCountFact } from '@/game/truthtrance-card-count';
import {
HAND_INVENTORY_LABELS,
type HandInventoryFact,
} from '@/game/truthtrance-hand-inventory';
export function CardCountFields({
export function CardCountFields<T extends CardCountFact | HandInventoryFact>({
value,
onChange,
}: {
value: CardCountFact;
onChange: (fact: CardCountFact) => void;
value: T;
onChange: (fact: T) => void;
}) {
const id = useId();
const invalid = !Number.isSafeInteger(value.value) || value.value < 0;
return (
<>
<label>
Card name
<select
value={value.name}
onChange={(event) => onChange({ ...value, name: event.target.value })}
>
{TRUTH_CARD_NAMES.map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
{value.kind === 'handCount' ? (
<label>
Card name
<select
value={value.name}
onChange={(event) =>
onChange({ ...value, name: event.target.value })
}
>
{TRUTH_CARD_NAMES.map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
) : (
<label>
Cards to count
<select
value={value.category}
onChange={(event) =>
onChange({
...value,
category: event.target.value as HandInventoryFact['category'],
})
}
>
{Object.entries(HAND_INVENTORY_LABELS).map(([category, label]) => (
<option key={category} value={category}>
{label}
</option>
))}
</select>
</label>
)}
<label>
Compare card count
<select
@@ -44,7 +71,7 @@ export function CardCountFields({
</select>
</label>
<label htmlFor={`${id}-count`}>
Number of copies
{value.kind === 'handCount' ? 'Number of copies' : 'Number of cards'}
<Input
id={`${id}-count`}
type="number"
@@ -61,10 +88,23 @@ export function CardCountFields({
/>
</label>
<p id={`${id}-help`} className="fine">
Counts copies of this exact named card in the player’s hand now. Cards
in the deck, discard pile, or a separate cache do not count. The answer
reveals only whether the comparison is true.
{value.kind === 'handCount'
? 'Counts copies of this exact named card'
: value.category === 'all'
? 'Counts all physical cards'
: 'Counts physical cards of the selected primary role'}{' '}
in the player’s hand now. Cards in the deck, discard pile, or a separate
cache do not count. The answer reveals only whether the comparison is
true.
</p>
{value.kind === 'handInventory' && value.category !== 'all' && (
<p className="fine">
Primary roles follow the cards: Weirding Way is a weapon and Chemistry
is a defense. Worthless cards stay separate, including for Bene
Gesserit or CHOAM. Alternate uses and faction powers do not change
this count.
</p>
)}
{invalid && (
<p id={`${id}-error`} className="notice" role="alert">
{Number.isInteger(value.value) &&
+26 -12
View File
@@ -62,7 +62,9 @@ export function Truthtrance({
]);
const factId = useId();
const invalidSpice = (fact: TruthFact) =>
(fact.kind === 'spice' || fact.kind === 'handCount') &&
(fact.kind === 'spice' ||
fact.kind === 'handCount' ||
fact.kind === 'handInventory') &&
(!Number.isSafeInteger(fact.value) || fact.value < 0);
const invalidFact = clauses
.slice(0, join === 'single' ? 1 : 2)
@@ -357,9 +359,10 @@ export function Truthtrance({
<p className="notice">
Automatic enforcement currently supports the active
player’s unused shipment in base Basic games and base
Advanced games without Guild or optional modules. Select that player and finish pending
decisions first. Earlier questions and other rules
configurations remain unfinished.
Advanced games without Guild or optional modules. Select
that player and finish pending decisions first. Earlier
questions and other rules configurations remain
unfinished.
</p>
)}
<p className="notice">
@@ -442,16 +445,23 @@ export function Truthtrance({
compare: 'gte',
value: 2,
}
: e.target.value === 'spice'
: e.target.value === 'handInventory'
? {
kind: 'spice',
kind: 'handInventory',
category: 'all',
compare: 'gte',
value: 6,
}
: {
kind: 'traitor',
leader: g.allLeaders[0].id,
value: 2,
}
: e.target.value === 'spice'
? {
kind: 'spice',
compare: 'gte',
value: 6,
}
: {
kind: 'traitor',
leader: g.allLeaders[0].id,
}
: old,
),
)
@@ -461,6 +471,9 @@ export function Truthtrance({
<option value="handCount">
Number of a named card
</option>
<option value="handInventory">
Hand size or primary card role
</option>
<option value="traitor">Selected a traitor</option>
<option value="spice">
Current personal spice
@@ -487,7 +500,8 @@ export function Truthtrance({
))}
</select>
</label>
) : c.kind === 'handCount' ? (
) : c.kind === 'handCount' ||
c.kind === 'handInventory' ? (
<CardCountFields
value={c}
onChange={(next) =>
+6
View File
@@ -18,6 +18,12 @@ offline tests passed. See [pilot measurements](EFFICIENCY_PILOT.md). The current
Advanced shipment batch extends typed preparation and commitments without Guild
or optional modules; its validation is recorded in the checkpoint commit.
`32791ed` verified that batch with 4,192 offline tests, build, 40 HTTP tests and
two isolated browser journeys. The next bounded batch adds [total hand and
primary-role Truthtrance counts](TRUTHTRANCE_HAND_INVENTORY.md), reusing existing
private-answer, controls and persisted continuation paths. Its focused browser
journey verifies two AI answers, invalid-input feedback and phone-width refresh.
## Remaining readiness
| Area | Established evidence | Remaining gate |
+14 -2
View File
@@ -9,8 +9,8 @@ The removed recurring automation stays removed.
| Milestone | Deliverable | Measurement |
| --- | --- | --- |
| 1: setup | Current status, decision index, reusable checks, saved-game snapshots/backups and HTTP seat restoration | Accepted checkpoint `6e26ba5`. Aggregate counters 57,957,554 → 58,124,416: 166,862 tokens including planning, coordination and the user-requested save reset. Two bounded helpers. Fifteen added regressions; 4,177 offline tests passed. Three broad attempts: two stopped before tests on type/lint errors; final check passed in 130.147s. One cancellation defect found and fixed in review. |
| 2: core readiness batch | Remaining Basic/Advanced capabilities with rules, controls, bots and recovery together | Start aggregate counter: 58,124,416. Batch: typed Advanced reserve-shipment commitments without Guild or optional modules. Two bounded helpers: regression implementation and independent rules/privacy review. Final counts belong to the verified checkpoint; pending Guild settlement remains explicit. |
| 3: next dependency batch | Next core or connected expansion capability selected from current evidence | Repeat measurements; compare cost per accepted capability and investigate repeated work. |
| 2: core readiness batch | Typed Advanced reserve-shipment commitments without Guild or optional modules | Accepted checkpoint `32791ed`. Aggregate counters 58,124,416 → 58,695,053: 570,637 tokens. Two bounded helpers: regression implementation and independent rules/privacy review. Fifteen added tests; 4,192 offline and 40 HTTP tests plus build passed. One stable broad run; focused checks resolved setup mistakes and two review findings before it. Two browser rooms verified distinct AI and human paths. Pending Guild settlement remains explicit. |
| 3: next dependency batch | Truthtrance total hand size and primary-role counts, controls, bots and recovery | Start aggregate counter 58,695,053. Two bounded helpers on the cheaper workhorse model for regression implementation and independent review. Reuses the existing count panel and persisted-room fixtures; eight added tests and expanded SQLite cases. Two copy issues resolved before broad verification. One isolated browser room covers combined role counts, total size, invalid input and phone refresh. Confirmed endpoint and final results are recorded in the checkpoint commit. |
The aggregate counter may update only between turns. Record a confirmed endpoint
when available rather than estimating missing usage. It includes coordination and
@@ -21,6 +21,18 @@ existing checks; broad checks still run once source/review is stable and repeat
subsequent changes or failures justify them. Browser fixtures remain feature-specific;
the reusable HTTP restoration helper does not automate browser setup or visual QA.
Milestone 2 cost more than the tooling baseline and cannot establish a saving.
Its larger rules/interaction scope, browser setup and context recovery contribute
to that total. Keep the third batch bounded, reuse fixtures and avoid repeatedly
loading old audits. Compare concrete capabilities and reopened defects alongside
token counts before choosing the next budget; three different deliverables alone
cannot support a reliable percentage estimate.
Check account usage after each verified checkpoint. The user explicitly confirmed
the stop point as **80% weekly usage consumed (20% remaining)**. Keep the goal
unfinished when stopping for usage, preserve Git and subagent requirements, and
leave the removed recurring automation absent. After `32791ed`, usage was 3% used.
Use [current status](CURRENT_STATUS.md), [rule decisions](RULE_DECISIONS.md) and
[verification workflow](VERIFICATION_WORKFLOW.md) as short entry points. Historical
logs remain references, not mandatory full-context input on every turn.
+21
View File
@@ -1,5 +1,26 @@
# Dune implementation status
## 13 September 2026 — Truthtrance hand size and primary-role facts
Added total physical hand counts and explicit primary-role comparisons, sharing
classification with component presentation. Canonical Richese roles, Ix alternate
uses, Worthless faction powers and Ecaz slot-only specials retain their distinct
semantics. Current facts disclose one aggregate result and create no retention
promise. The existing count controls, all four bot answer paths and production-room
continuations are reused; no public mode or publication gate opens.
Eight new focused tests cover the 60-card catalog, actual engine questions,
privacy, AI and JSON/custody changes. Expanded SQLite cases restore total/category
and compound answers around an interrupted auction and commit competing answers
once. Independent review findings were limited to two corrected wording issues.
Room `LC3E55GY` verifies combined role counts, total size, invalid input and phone
refresh. Required broad results and aggregate token endpoint belong to the verified
checkpoint commit and private source-bound report. See
[hand inventory contract](TRUTHTRANCE_HAND_INVENTORY.md) and
[efficiency measurements](EFFICIENCY_PILOT.md). Wider Truthtrance and Guild gaps
remain explicit; preserve all post-reset games.
**Typed Advanced shipment-promise checkpoint, 13 September 2026:** [Shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) now support base Advanced games without Guild or optional modules through the existing authoritative commitment, controls, private guidance, bots and recovery. Ghola preparation reuses canonical eligible normal/elite limits; pending BG conversion preserves feasible promises while retaining real responses and hiding conditional shipment pricing. Cancellation releases only when owned alternatives fail. Genuine Advanced scenarios and production SQLite races cover physical custody and saved continuations. Full validation and browser outcomes are recorded in the checkpoint commit and source-bound verification report. Ordinary Guild settlement, broader commitments, combined modules and complete mode acceptance remain unfinished; no release gate is opened.
**Efficiency setup and fresh local save checkpoint, 13 September 2026:** [Current status](CURRENT_STATUS.md) and the [rule decision index](RULE_DECISIONS.md) consolidate existing evidence without changing mode gates. [Reusable verification](VERIFICATION_WORKFLOW.md) adds private source-bound check logs, read-only saved-game snapshots/online backups and bounded HTTP seat-restoration checks. Focused regressions cover child failure/cancellation, stale source, output protection, WAL backup and seat privacy; the required full-check result is recorded in this checkpoint’s Git commit and external verification report. The user explicitly authorized clearing old local saves: a verified private backup preceded removal of 3,644 rooms and related records, preserving schema/migrations and creating a fresh empty baseline. Future games remain protected. [Pilot measurements](EFFICIENCY_PILOT.md) retain selective subagents, mandatory verified Git pushes and the full game scope; no gameplay or release gate is enabled. Browser setup remains feature-specific, and no live browser acceptance is claimed for this tooling change.
+1
View File
@@ -10,6 +10,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
| Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) |
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
+62
View File
@@ -0,0 +1,62 @@
# Truthtrance hand inventory
13 September 2026. A bounded current-fact capability; public Advanced and unfinished
expansion starts remain gated. Freeform interpretation and broader commitments are
still incomplete.
## Contract and authority
The [GF9 November 2020 FAQ, p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
permits game-related Yes/No questions and AND/OR combinations, including a weapon
and defense example. Official-domain indexed page text was retrieved on this date.
This implementation supplies precise current-hand predicates, not a restriction on
the questions allowed by the published card.
`HandInventoryFact` compares physical cards currently held with a nonnegative safe
integer, using exactly, at least or at most. Categories are all cards, primary
weapons, primary defenses, Worthless, Cheap Hero/Heroine, and remaining Special
cards. Decks, discards, separate caches and on-table cards are outside the hand.
Reserved cards still held in hand count. This fact creates no obligation to keep
cards, acquire more, or use them in a particular slot.
Primary roles are an explicitly worded query definition. They reuse the existing
component presentation and canonical Richese definitions, not battle-slot legality:
Weirding Way is primarily a weapon, Chemistry a defense, and Worthless cards remain
separate even for BG/CHOAM. Stone Burner and Mirror Weapon are printed weapons;
Portable Snooper is a printed defense despite their persisted `special` kind.
See [Richese components](RICHESE_COMPONENTS.md) for that physical inventory audit.
No unfinished effect is enabled by counting its held card.
`game/card-category.ts` provides the shared classification. The focused inventory
module parses, compares and renders the predicate; the existing Truthtrance flow
handles priority, aggregate three-valued logic, truthful responses, physical card
disposal and resuming the interrupted decision. Only the respondent sees the
verified answer before publication. Exact Yes comparisons necessarily disclose
the selected count, but matching card identities and individual compound results
are not added to other views. All four bots use the same owner-only answer.
## Verification and limits
`tests/truthtrance-hand-inventory.test.ts` exercises physical copies, default and
alternate roles, input rejection, actual questions/answers, private views, four
profiles, JSON continuations and the absence of a retention promise. The existing
production-room SQLite suite `tests/truthtrance-spice-recovery.test.ts` now covers
total/category and compound inventory facts across module restarts, suspended
auction payment, unchanged private views and competing answers committing once.
Browser acceptance and final required check results are recorded in the milestone
checkpoint and its private verification report. These checks establish the stated
fact boundary; they do not certify every card interaction, arbitrary question,
AI question strategy, complete Advanced play or expansion readiness.
Browser room `LC3E55GY` was created through the lobby, backed up, then staged through
the genuine Basic initializer into an isolated phase-five question opportunity.
Its Medium Emperor held four physical cards: two Shields, Lasgun and Baliset.
The human asker submitted primary-weapon-at-least-one AND
primary-defense-at-least-two; the AI publicly answered Yes. A negative count
disabled submission and showed the correction. At 390px width the human then
submitted total-hand-exactly-four and refreshed during the answer wait. The room
restored both Yes records, an empty asker hand and the prior movement decision.
Phone screenshots showed readable controls and the public preview; the temporary
viewport was reset. Other saved rooms were not used as fixtures. This is targeted
question/recovery evidence, not a complete-game browser run.
+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`;
+431
View File
@@ -0,0 +1,431 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixDeck, treacheryDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { CARD_CATEGORIES, printedCardCategory } from '../game/card-category';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import {
handInventoryFactMatches,
handInventoryFactText,
parseHandInventoryFact,
type HandInventoryFact,
} from '../game/truthtrance-hand-inventory';
import type { TruthFact } from '../game/truthtrance';
const inventoryFact = (
category: HandInventoryFact['category'],
compare: HandInventoryFact['compare'],
value: number,
): HandInventoryFact => ({ kind: 'handInventory', category, compare, value });
void test('the complete implemented catalog has one exhaustive primary printed role, including Ix alternate-slot and canonical Richese edges', () => {
const catalog = [...baseDeck(), ...ixDeck(), ...richeseCards(), ...ecazTreacheryCards()];
const before = structuredClone(catalog);
const counts: Record<HandInventoryFact['category'], number> = {
all: 60, weapon: 17, defense: 13, worthless: 6, hero: 2, special: 22,
};
assert.deepEqual(CARD_CATEGORIES, ['weapon', 'defense', 'worthless', 'hero', 'special']);
for (const category of CARD_CATEGORIES)
assert.equal(catalog.filter((card) => printedCardCategory(card) === category).length, counts[category]);
const category = (name: string) =>
printedCardCategory(catalog.find((card) => card.name === name)!);
for (const name of ['Poison Blade', 'Poison Tooth', 'Artillery Strike', 'Weirding Way', 'Stone Burner', 'Mirror Weapon'])
assert.equal(category(name), 'weapon', name);
for (const name of ['Shield Snooper', 'Chemistry', 'Portable Snooper'])
assert.equal(category(name), 'defense', name);
for (const name of ['Reinforcements', 'Harass & Withdraw'])
assert.equal(category(name), 'special', `${name} is only eligible for a battle slot`);
for (const name of ['Stone Burner', 'Mirror Weapon', 'Portable Snooper']) {
const canonical = richeseCards().find((card) => card.name === name)!;
assert.equal(printedCardCategory({ ...canonical, id: `forged-${canonical.id}` }), 'special');
}
for (const [category, count] of Object.entries(counts) as [
HandInventoryFact['category'], number,
][]) {
assert.ok(handInventoryFactMatches(catalog, inventoryFact(category, 'eq', count)));
assert.ok(!handInventoryFactMatches(catalog, inventoryFact(category, 'gte', count + 1)));
assert.ok(!handInventoryFactMatches(catalog, inventoryFact(category, 'lte', count - 1)));
}
assert.ok(handInventoryFactMatches(catalog, inventoryFact('all', 'lte', Number.MAX_SAFE_INTEGER)));
assert.deepEqual(catalog, before);
});
void test('the parser normalizes records and rejects malformed categories, comparisons, and unsafe counts immutably', () => {
const valid = {
kind: 'handInventory',
category: 'weapon',
compare: 'gte',
value: 2,
privateDetail: 'ignored',
};
const before = structuredClone(valid);
assert.deepEqual(
parseHandInventoryFact(valid),
inventoryFact('weapon', 'gte', 2),
);
assert.deepEqual(valid, before);
for (const category of ['weapons', 'Weapon', '', 1, null, undefined])
assert.throws(
() => parseHandInventoryFact({ ...valid, category }),
/known primary card role/,
);
for (const compare of ['gt', 'lt', '=', '', 1, null, undefined])
assert.throws(
() => parseHandInventoryFact({ ...valid, compare }),
/Compare held cards/,
);
for (const value of [
-1,
0.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER + 1,
'2',
null,
undefined,
])
assert.throws(
() => parseHandInventoryFact({ ...valid, value }),
/whole card count/,
);
assert.throws(
() => parseHandInventoryFact({ ...valid, kind: 'handCount' }),
/known primary card role/,
);
});
void test('inventory question text identifies current custody, the comparator, and primary-role semantics', () => {
assert.equal(
handInventoryFactText(inventoryFact('all', 'eq', 1)),
'you currently hold exactly 1 card in your hand',
);
assert.equal(
handInventoryFactText(inventoryFact('weapon', 'gte', 2)),
'you currently hold at least 2 cards whose primary role is a weapon',
);
assert.equal(
handInventoryFactText(inventoryFact('special', 'lte', 0)),
'you currently hold at most 0 Special cards without a primary battle role',
);
});
function takeDeck(g: Game, id: string, name: string) {
const index = g.deck.findIndex((card) => card.name === name);
assert.ok(index >= 0, name);
g.players
.find((player) => player.id === id)!
.hand.push(g.deck.splice(index, 1)[0]);
}
function takeRichese(g: Game, id: string, name: string) {
const index = g.richeseCache!.findIndex((card) => card.name === name);
assert.ok(index >= 0, name);
g.players
.find((player) => player.id === id)!
.hand.push(g.richeseCache!.splice(index, 1)[0]);
}
function fixture() {
const g = createGame('INVTRUTH', newPlayer('a', 'Asker', 'atreides'), false, [
'ix',
'choam',
]);
g.players.push(
newPlayer('r', 'Target', 'richese'),
newPlayer('h', 'Observer', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
turn: 2,
phase: 4,
order: ['h', 'r', 'a'],
deck: treacheryDeck(['ix']),
richeseCache: richeseCards(),
});
for (const player of g.players) {
player.spice = 20;
player.traitors = [player.leaders[0].id];
}
const selected = new Set(g.players.flatMap((player) => player.traitors));
g.traitorReserve = g.players
.flatMap((player) => player.leaders.map((leader) => leader.id))
.filter((id) => !selected.has(id));
takeDeck(g, 'a', 'Truthtrance');
takeDeck(g, 'a', 'Shield');
takeDeck(g, 'h', 'Truthtrance');
takeDeck(g, 'h', 'Snooper');
for (const name of ['Poison Blade', 'Chemistry', 'Cheap Hero', 'Harvester'])
takeDeck(g, 'r', name);
return g;
}
function declare(state: Game, id = 'a') {
const player = state.players.find((candidate) => candidate.id === id)!;
return applyAction(state, id, {
type: 'card',
card: player.hand.find((card) => card.effect === 'truthtrance')!.id,
});
}
function finishPriority(state: Game) {
let g = state;
while (g.truthtrance?.stage === 'priority') {
const player = g.players.find(
(candidate) => !g.truthtrance!.passed.includes(candidate.id),
)!;
g = applyAction(g, player.id, { type: 'truthPass' });
}
assert.equal(g.truthtrance?.stage, 'ask');
return g;
}
function pending(state: Game, fact: TruthFact, asker = 'a', target = 'r') {
return applyAction(finishPriority(declare(state, asker)), asker, {
type: 'truthAsk',
question: { kind: 'fact', target, fact },
});
}
function rejectUnchanged(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function physicalCardIds(g: Game) {
return [
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...g.players.flatMap((player) => player.hand),
]
.map((card) => card.id)
.sort();
}
void test('real declaration, priority, question, and answer use only current hand custody and discard Truthtrance once', () => {
const cases: [HandInventoryFact, 'yes' | 'no'][] = [
[inventoryFact('all', 'eq', 4), 'yes'],
[inventoryFact('weapon', 'eq', 1), 'yes'],
[inventoryFact('defense', 'gte', 2), 'no'],
[inventoryFact('worthless', 'eq', 0), 'yes'],
[inventoryFact('hero', 'gte', 2), 'no'],
[inventoryFact('special', 'eq', 1), 'yes'],
];
for (const [fact, expected] of cases) {
const initial = fixture();
const targetHand = structuredClone(initial.players[1].hand);
const g = pending(initial, fact);
assert.equal(viewGame(g, 'r').truthAnswer, expected);
const done = applyAction(g, 'r', {
type: 'truthAnswer',
answer: expected,
});
assert.equal(done.truthtrance, null);
assert.deepEqual(done.truthHistory!.at(-1), {
turn: 2,
phase: 4,
asker: 'a',
question: { kind: 'fact', target: 'r', fact },
answer: expected,
});
assert.deepEqual(done.players[1].hand, targetHand);
assert.equal(
done.discard.filter((card) => card.effect === 'truthtrance').length,
1,
);
}
const g = pending(fixture(), inventoryFact('weapon', 'eq', 1));
rejectUnchanged(g, 'r', { type: 'truthAnswer', answer: 'no' });
rejectUnchanged(g, 'r', { type: 'truthAnswer', answer: 'unknown' });
rejectUnchanged(g, 'h', { type: 'truthAnswer', answer: 'yes' });
});
void test('malformed inventory leaves reject atomically even behind an already-true OR clause', () => {
const g = finishPriority(declare(fixture()));
const valid = inventoryFact('weapon', 'gte', 1);
const invalid = [
{ ...valid, category: 'weapons' },
{ ...valid, category: 'Weapon' },
{ ...valid, category: null },
{ ...valid, compare: 'gt' },
{ ...valid, value: -1 },
{ ...valid, value: 1.5 },
{ ...valid, value: Number.MAX_SAFE_INTEGER + 1 },
{ ...valid, value: '3' },
];
for (const leaf of invalid)
rejectUnchanged(g, 'a', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'r',
fact: {
kind: 'or',
terms: [inventoryFact('all', 'gte', 0), leaf],
},
},
});
assert.equal(g.truthtrance?.stage, 'ask');
assert.equal(g.truthtrance.question, null);
assert.equal(g.discard.length, 0);
});
void test('combined inventory facts preserve unknown semantics and disclose only the aggregate boolean', () => {
const initial = fixture();
initial.players[1].traitorChoices = ['atreides-1'];
initial.traitorReserve = initial.traitorReserve!.filter(
(id) => id !== 'atreides-1',
);
const unknown: TruthFact = { kind: 'traitor', leader: 'atreides-1' };
const scenarios: [TruthFact, 'yes' | 'no' | 'unknown'][] = [
[
{ kind: 'and', terms: [inventoryFact('weapon', 'eq', 1), unknown] },
'unknown',
],
[{ kind: 'and', terms: [inventoryFact('weapon', 'eq', 2), unknown] }, 'no'],
[
{ kind: 'or', terms: [inventoryFact('defense', 'eq', 1), unknown] },
'yes',
],
[
{ kind: 'or', terms: [inventoryFact('defense', 'eq', 2), unknown] },
'unknown',
],
];
for (const [fact, expected] of scenarios) {
const g = pending(initial, fact);
assert.equal(viewGame(g, 'r').truthAnswer, expected);
for (const id of ['a', 'h']) {
const view = viewGame(g, id);
assert.equal(view.truthAnswer, null);
assert.equal(
view.players.find((player) => player.id === 'r')!.hand,
undefined,
);
assert.equal(
view.players.find((player) => player.id === 'r')!.traitorChoices,
undefined,
);
for (const card of g.players[1].hand)
assert.equal(JSON.stringify(view).includes(card.id), false);
}
const done = applyAction(g, 'r', {
type: 'truthAnswer',
answer: expected,
});
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
}
});
function swapHeldCard(
g: Game,
playerId: string,
heldName: string,
deckName: string,
) {
const player = g.players.find((candidate) => candidate.id === playerId)!;
const held = player.hand.findIndex((card) => card.name === heldName);
const deck = g.deck.findIndex((card) => card.name === deckName);
assert.ok(held >= 0, heldName);
assert.ok(deck >= 0, deckName);
const [replacement] = g.deck.splice(deck, 1);
g.deck.push(player.hand.splice(held, 1, replacement)[0]);
}
void test('equivalent hidden inventories yield the same outsider projection and all four bot profiles return only the legal boolean', () => {
for (const difficulty of DIFFICULTIES)
for (const fact of [
inventoryFact('all', 'eq', 4),
inventoryFact('weapon', 'gte', 1),
inventoryFact('defense', 'lte', 0),
inventoryFact('special', 'eq', 1),
]) {
const first = fixture();
first.players[1].bot = difficulty;
const second = structuredClone(first);
swapHeldCard(second, 'r', 'Poison Blade', 'Poison Tooth');
swapHeldCard(second, 'h', 'Snooper', 'Gom Jabbar');
const worlds = [pending(first, fact), pending(second, fact)];
assert.deepEqual(viewGame(worlds[0], 'a'), viewGame(worlds[1], 'a'));
const expected = handInventoryFactMatches(worlds[0].players[1].hand, fact)
? 'yes'
: 'no';
for (const world of worlds) {
const targetView = viewGame(world, 'r');
const snapshot = structuredClone(targetView);
assert.equal(targetView.truthAnswer, expected);
assert.deepEqual(botActions(targetView), [
{ type: 'truthAnswer', answer: expected },
]);
assert.deepEqual(targetView, snapshot);
assert.deepEqual(botActions(viewGame(world, 'h')), []);
const done = applyAction(world, 'r', botActions(targetView)[0]);
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
}
}
});
void test('a JSON-restored answer records the old fact while a later legal Richese gift changes the next current answer', () => {
const initial = fixture();
for (const name of ['Cheap Hero', 'Harvester']) {
const index = initial.players[1].hand.findIndex(
(card) => card.name === name,
);
assert.ok(index >= 0);
initial.deck.push(initial.players[1].hand.splice(index, 1)[0]);
}
takeRichese(initial, 'r', 'Stone Burner');
const stone = initial.players[1].hand.find(
(card) => card.name === 'Stone Burner',
)!;
const ids = physicalCardIds(initial);
let g = pending(initial, inventoryFact('weapon', 'eq', 2));
g = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'r', {
type: 'truthAnswer',
answer: 'yes',
});
assert.equal(g.truthHistory!.at(-1)!.answer, 'yes');
g.players[1].ally = 'h';
g.players[2].ally = 'r';
g = applyAction(g, 'r', { type: 'richeseGift', card: stone.id });
assert.equal(
g.players[1].hand.some((card) => card.id === stone.id),
false,
);
assert.equal(
g.players[2].hand.some((card) => card.id === stone.id),
true,
);
g = pending(g, inventoryFact('weapon', 'eq', 1), 'h', 'r');
assert.equal(viewGame(g, 'r').truthAnswer, 'yes');
const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'r', {
type: 'truthAnswer',
answer: 'yes',
});
assert.deepEqual(
done.truthHistory!.map((record) => record.answer),
['yes', 'yes'],
);
assert.deepEqual(physicalCardIds(done), ids);
assert.equal(
done.discard.filter((card) => card.effect === 'truthtrance').length,
2,
);
});
+148 -109
View File
@@ -450,12 +450,45 @@ void test('an existing structured hand fact remains answerable after room-module
}
});
void test('hand-count questions restore physical duplicate counts and expose the definite answer only to the target', async () => {
for (const [compare, value, answer] of [
['eq', 2, 'yes'],
['gte', 3, 'no'],
['lte', 2, 'yes'],
] as const) {
void test('hand-count and inventory questions restore physical counts and expose the definite answer only to the target', async () => {
const cases: [TruthFact, 'yes' | 'no', 'yes' | 'no'][] = [
[
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 2 },
'yes',
'no',
],
[
{ kind: 'handCount', name: 'Shield', compare: 'gte', value: 3 },
'no',
'no',
],
[
{ kind: 'handCount', name: 'Shield', compare: 'lte', value: 2 },
'yes',
'yes',
],
[
{ kind: 'handInventory', category: 'all', compare: 'eq', value: 3 },
'yes',
'yes',
],
[
{ kind: 'handInventory', category: 'defense', compare: 'eq', value: 2 },
'yes',
'no',
],
[
{ kind: 'handInventory', category: 'weapon', compare: 'gte', value: 1 },
'no',
'yes',
],
[
{ kind: 'handInventory', category: 'special', compare: 'lte', value: 1 },
'yes',
'yes',
],
];
for (const [fact, answer, alternateAnswer] of cases) {
const f = await fixture();
try {
const secondShield = f.initial.deck.findIndex(
@@ -466,12 +499,6 @@ void test('hand-count questions restore physical duplicate counts and expose the
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(JSON.stringify(f.initial), f.code);
const fact: TruthFact = {
kind: 'handCount',
name: 'Shield',
compare,
value,
};
await ask(f, fact);
const pending = await restored(f);
assertSuspended(f, pending);
@@ -502,11 +529,10 @@ void test('hand-count questions restore physical duplicate counts and expose the
engine.viewGame(alternative, f.seats[index].playerId),
engine.viewGame(pending, f.seats[index].playerId),
);
if (compare === 'eq')
assert.equal(
engine.viewGame(alternative, f.seats[1].playerId).truthAnswer,
'no',
);
assert.equal(
engine.viewGame(alternative, f.seats[1].playerId).truthAnswer,
alternateAnswer,
);
const done = await act(f, 1, { type: 'truthAnswer', answer });
assert.equal(done.truthtrance, null);
@@ -533,99 +559,112 @@ void test('hand-count questions restore physical duplicate counts and expose the
}
});
void test('recovered hand-count answers reject dishonest or wrong-seat requests and concurrent truthful answers commit exactly once', async () => {
const f = await fixture();
try {
await ask(f, {
kind: 'handCount',
name: 'Shield',
compare: 'eq',
value: 1,
});
const before = await restored(f);
await assertPrivate(f, 'yes');
const writeCount = f.writes.length;
for (const index of [0, 2])
await assert.rejects(
f
.restart()
.act(
f.code,
f.seats[index],
before.version,
{ type: 'truthAnswer', answer: 'yes' },
clock,
),
/questioned player/,
);
for (const answer of ['no', 'unknown'])
await assert.rejects(
f
.restart()
.act(
f.code,
f.seats[1],
before.version,
{ type: 'truthAnswer', answer },
clock,
),
/truthfully/,
);
assert.equal(f.writes.length, writeCount);
assert.deepEqual(await f.restart().readRoom(f.code), before);
void test('recovered hand-count and compound inventory answers reject dishonest or wrong-seat requests and concurrent truthful answers commit exactly once', async () => {
const facts: TruthFact[] = [
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 1 },
{ kind: 'handInventory', category: 'all', compare: 'eq', value: 2 },
{
kind: 'and',
terms: [
{
kind: 'handInventory',
category: 'defense',
compare: 'gte',
value: 1,
},
{ kind: 'handInventory', category: 'special', compare: 'eq', value: 1 },
],
},
];
for (const fact of facts) {
const f = await fixture();
try {
await ask(f, fact);
const before = await restored(f);
await assertPrivate(f, 'yes');
const writeCount = f.writes.length;
for (const index of [0, 2])
await assert.rejects(
f
.restart()
.act(
f.code,
f.seats[index],
before.version,
{ type: 'truthAnswer', answer: 'yes' },
clock,
),
/questioned player/,
);
for (const answer of ['no', 'unknown'])
await assert.rejects(
f
.restart()
.act(
f.code,
f.seats[1],
before.version,
{ type: 'truthAnswer', answer },
clock,
),
/truthfully/,
);
assert.equal(f.writes.length, writeCount);
assert.deepEqual(await f.restart().readRoom(f.code), before);
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const action = { type: 'truthAnswer', answer: 'yes' };
const outcomes = await Promise.allSettled([
f.rooms.act(f.code, f.seats[1], before.version, action, clock),
f.restart().act(f.code, f.seats[1], before.version, action, clock),
]);
f.hooks.beforeWrite = undefined;
assert.equal(
outcomes.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
outcomes.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const after = await f.restart().readRoom(f.code);
assert.equal(after.version, before.version + 1);
assert.equal(after.truthtrance, null);
assert.deepEqual(after.truthHistory, [
{
turn: before.turn,
phase: before.phase,
asker: f.seats[0].playerId,
question: before.truthtrance!.question!,
answer: 'yes',
},
]);
assert.equal(
after.discard.filter((card) => card.id === f.card.id).length,
1,
);
assert.equal(
after.players[0].hand.some((card) => card.id === f.card.id),
false,
);
assert.deepEqual(inventory(after), inventory(before));
assertSuspended(f, after);
const committedWrites = f.writes.length;
for (const version of [before.version, after.version])
await assert.rejects(
f.restart().act(f.code, f.seats[1], version, action, clock),
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const action = { type: 'truthAnswer', answer: 'yes' };
const outcomes = await Promise.allSettled([
f.rooms.act(f.code, f.seats[1], before.version, action, clock),
f.restart().act(f.code, f.seats[1], before.version, action, clock),
]);
f.hooks.beforeWrite = undefined;
assert.equal(
outcomes.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(f.writes.length, committedWrites);
assert.deepEqual(await restored(f), after);
await assertPrivate(f, null);
} finally {
f.hooks.beforeWrite = undefined;
f.sqlite.close();
assert.equal(
outcomes.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const after = await f.restart().readRoom(f.code);
assert.equal(after.version, before.version + 1);
assert.equal(after.truthtrance, null);
assert.deepEqual(after.truthHistory, [
{
turn: before.turn,
phase: before.phase,
asker: f.seats[0].playerId,
question: before.truthtrance!.question!,
answer: 'yes',
},
]);
assert.equal(
after.discard.filter((card) => card.id === f.card.id).length,
1,
);
assert.equal(
after.players[0].hand.some((card) => card.id === f.card.id),
false,
);
assert.deepEqual(inventory(after), inventory(before));
assertSuspended(f, after);
const committedWrites = f.writes.length;
for (const version of [before.version, after.version])
await assert.rejects(
f.restart().act(f.code, f.seats[1], version, action, clock),
);
assert.equal(f.writes.length, committedWrites);
assert.deepEqual(await restored(f), after);
await assertPrivate(f, null);
} finally {
f.hooks.beforeWrite = undefined;
f.sqlite.close();
}
}
});