Extend Truthtrance facts and Basic Semuta Tech
Verify and publish checkpoint container / container (push) Failing after 10m53s
Verify and publish checkpoint container / container (push) Failing after 10m53s
This commit is contained in:
1 parent
16f10beecf
commit
7a083ac9aa
12 files changed
+361
-6
No files matched your search
@@ -65,6 +65,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { parseFreeformFactV2 } from '@/game/truthtrance-freeform-facts-v2';
|
||||
import { parseFreeformFactV3 } from '@/game/truthtrance-freeform-facts-v3';
|
||||
import {
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
@@ -947,9 +948,9 @@ export function Truthtrance({
|
||||
placeholder="Will you ship at least six forces to Carthag this turn?"
|
||||
/>
|
||||
</label>
|
||||
{scope === 'fact' && parseFreeformFactV2(text) ? (
|
||||
{scope === 'fact' && (parseFreeformFactV2(text) || parseFreeformFactV3(text)) ? (
|
||||
<p className="notice" aria-label="Recognized freeform question">
|
||||
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: parseFreeformFactV2(text)! }, leaderName, locationName, name)}
|
||||
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: (parseFreeformFactV2(text) || parseFreeformFactV3(text))! }, leaderName, locationName, name)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="notice">
|
||||
|
||||
@@ -17,6 +17,22 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Truthtrance category questions — 29 October. Prototyped / Partial.**
|
||||
Recognized freeform current-fact questions now include weapon, defense and
|
||||
Worthless hand categories and selected subtypes. The table sees the primary-role
|
||||
interpretation; the target sees the checked answer privately. Old versioned and
|
||||
unstamped questions retain their original semantics. General language and
|
||||
promises remain open. [Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-hand-category-facts--29-october-2026).
|
||||
|
||||
**Basic Semuta and Tech Tokens — 29 October. Prototyped / Partial.**
|
||||
Fresh Richese/classic games with the CHOAM deck and Tech Tokens enable the
|
||||
existing Semuta preview. Real Hajr claim/pass and later Heighliners shipment
|
||||
use actual setup/Tech assignment with conserved card staging. Other shared
|
||||
Semuta paths under Tech remain provisional. The combined batch passes 51
|
||||
related cases, quick type/lint checks and the production build; complete modes
|
||||
and deployed acceptance remain open.
|
||||
[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#basic-tech-tokens-and-semuta--29-october-2026).
|
||||
|
||||
**Basic Kull and Tech Tokens — 29 October. Prototyped / Partial.**
|
||||
Three to six CHOAM/classic seats with the Ix and CHOAM decks may use the
|
||||
existing printed Kull preview with Tech Tokens; the host retains an opt-out.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Semuta Drug source update and implementation boundary
|
||||
|
||||
## Basic Tech Tokens and Semuta — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh Basic Richese/classic tables with exactly the
|
||||
CHOAM deck and three to six players retain the existing Semuta preview when
|
||||
Tech Tokens are enabled. The [focused native journeys](../tests/semuta-tech-public-basic.test.ts)
|
||||
use public setup, first Storm and actual Tech assignment, with labelled
|
||||
conserved card staging; they exercise Hajr discard, Semuta claim/pass and a
|
||||
subsequent Heighliners shipment, plus legal bot reactions. Started games are
|
||||
not retrofitted. Other Tech interactions, module combinations and complete
|
||||
rules/deployed acceptance remain open.
|
||||
|
||||
|
||||
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
|
||||
|
||||
## Completed Box search during Basic auctions — 28 October 2026
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Truthtrance: recorded predictions and storm knowledge
|
||||
|
||||
## Freeform hand-category facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New fact-scoped questions can ask whether a player
|
||||
holds a weapon, defense, Worthless card, or projectile/poison weapon or defense,
|
||||
or compare a numeric category count using exactly, at least or at most. The
|
||||
editor shows the canonical printed primary-role interpretation before asking;
|
||||
only the target sees the checked answer before publishing it. Unsupported
|
||||
wording and current-turn promises keep their existing manual semantics.
|
||||
|
||||
Newly recognized category questions carry a version 3 interpretation. Existing
|
||||
version 1 and 2 parsers and unstamped questions retain their original meaning.
|
||||
The [parser](../game/truthtrance-freeform-facts-v3.ts), [rule cases](../tests/truthtrance-freeform-v3.test.ts)
|
||||
and [action journeys](../tests/truthtrance-freeform-v3-runtime.test.ts) cover
|
||||
physical hands, truthful answers and minimal legal bots. This is a bounded
|
||||
online interpretation, not general language understanding or complete rules.
|
||||
|
||||
|
||||
## Freeform hand size and combined facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New fact-scoped questions recognize exact comparisons
|
||||
|
||||
+4
-1
@@ -13282,7 +13282,10 @@ function bareBasicSemutaProfile(g: Game): boolean {
|
||||
new Set(g.expansions).size === g.expansions.length &&
|
||||
g.players.every(p => faction(p.faction).expansion === 'base' ||
|
||||
g.expansions.includes(faction(p.faction).expansion)) &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
|
||||
(!g.techTokens || g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||||
g.players.every(p => p.faction === 'richese' || CLASSIC_FACTIONS[p.faction] === true) &&
|
||||
!g.kullPreview && !g.nexusKullPreview && nativeFactionTechProfile(g, true)) &&
|
||||
!g.strongholdCards && !g.ecazTreachery && !g.discoveryEnabled &&
|
||||
!g.discoveries && !g.discoveryStash && !g.greatMaker;
|
||||
}
|
||||
|
||||
+2
-1
@@ -1341,6 +1341,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive Trip to Gamont before its ordinary/elite return or native No-Field reveal and capped recall, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, other reactive Worthless uses, inspected Residual Poison battles, nested responses, other pending rewards and wider interactions remain unfinished.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
...(definition.card.effect === 'semutaDrug' ? [
|
||||
'In fresh Basic games with Richese, classic opponents, the CHOAM deck and Tech Tokens, the existing Semuta preview is also enabled. Other module combinations remain separate; existing started games are not retrofitted.',
|
||||
'Fresh Advanced Richese preview games with only the CHOAM expansion deck and classic or CHOAM opponents enable Semuta reactions for supported ordinary Hajr, Ghola, Harvester and Weather Control discards, completed Box and Distrans, simple definite Truthtrance answers, clean Residual Poison, completed CHOAM sales, supported printed CHOAM Worthless costs and supported battle cleanup. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.',
|
||||
'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.',
|
||||
'Mandatory and winner battle discards also pause before native Tleilaxu Face Dance. Claim or Continue preserves the original winner’s casualties and card cleanup, then restores the same private Face Dancer opportunity. Replacement, leader death and force sources remain native; the claimed Treachery card stays with its claimant, and no Face Dancer card becomes a Semuta target.',
|
||||
@@ -3989,7 +3990,7 @@ 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, exact personal-spice, named-card and total-hand-size forms are checked as current facts. Two spice or card facts may be joined by one AND or OR; only their aggregate answer is published. 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.',
|
||||
'In a new freeform game-fact question, exact personal-spice, named-card, total-hand-size and selected weapon, defense or Worthless category forms are checked as current facts. Category questions use the displayed printed primary role. Two spice or named-card facts may be joined by one AND or OR; only their aggregate answer is published. 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.',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { parseFreeformFactV2, type FreeformFactV2 } from './truthtrance-freeform-facts-v2';
|
||||
import type { HandInventoryFact } from './truthtrance-hand-inventory';
|
||||
|
||||
export type FreeformFactV3 = FreeformFactV2 | HandInventoryFact;
|
||||
|
||||
/** Exact category phrases only. These mean the existing printed primary role,
|
||||
* not every card a faction might use in that slot through a special power. */
|
||||
const categories: Record<string, HandInventoryFact['category']> = {
|
||||
weapon: 'weapon', weapons: 'weapon',
|
||||
defense: 'defense', defenses: 'defense',
|
||||
'worthless card': 'worthless', 'worthless cards': 'worthless',
|
||||
'projectile weapon': 'projectileWeapon', 'projectile weapons': 'projectileWeapon',
|
||||
'poison weapon': 'poisonWeapon', 'poison weapons': 'poisonWeapon',
|
||||
'projectile defense': 'projectileDefense', 'projectile defenses': 'projectileDefense',
|
||||
'poison defense': 'poisonDefense', 'poison defenses': 'poisonDefense',
|
||||
};
|
||||
|
||||
/**
|
||||
* Version 3 extends the stable v2 grammar with anchored, current hand-category
|
||||
* questions. Saved v1 and v2 receipts must continue using their own parsers.
|
||||
* The caller restricts this to fact-scoped questions and displays the primary
|
||||
* role interpretation before an answer is checked.
|
||||
*/
|
||||
export function parseFreeformFactV3(text: string): FreeformFactV3 | null {
|
||||
if (typeof text !== 'string' || text.length > 500) return null;
|
||||
const existing = parseFreeformFactV2(text);
|
||||
if (existing) return existing;
|
||||
const input = text.trim();
|
||||
const match = /^do[ \t]+you[ \t]+have[ \t]+(?:(a|an|any)|(at[ \t]+least|exactly|at[ \t]+most)[ \t]+([0-9]+))[ \t]+([a-z]+(?:[ \t]+[a-z]+)*?)(?:[ \t]+in[ \t]+your[ \t]+hand)?[ \t]*\??$/i.exec(input);
|
||||
if (!match) return null;
|
||||
const label = match[4].toLowerCase().replace(/[ \t]+/g, ' ');
|
||||
if (!Object.hasOwn(categories, label)) return null;
|
||||
const category = categories[label];
|
||||
if (match[1] && /^(?:a|an)$/i.test(match[1]) && label.endsWith('s')) return null;
|
||||
const count = match[3] === undefined ? 1 : Number(match[3]);
|
||||
if (!Number.isSafeInteger(count) || count < 0) return null;
|
||||
const comparison = match[2]?.toLowerCase().replace(/[ \t]+/g, ' ');
|
||||
return { kind: 'handInventory', category,
|
||||
compare: comparison === 'exactly' ? 'eq' : comparison === 'at most' ? 'lte' : 'gte',
|
||||
value: count };
|
||||
}
|
||||
+10
-2
@@ -66,6 +66,7 @@ import { coalitionAllies } from './coalition-alliance';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { parseFreeformFactV1 } from './truthtrance-freeform-facts';
|
||||
import { parseFreeformFactV2 } from './truthtrance-freeform-facts-v2';
|
||||
import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
@@ -129,7 +130,7 @@ export type TruthQuestion =
|
||||
text: string;
|
||||
scope: 'fact' | 'currentTurn';
|
||||
/** Only newly asked, explicitly recognized facts carry this stable interpretation. */
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2; fact: TruthFact };
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3; fact: TruthFact };
|
||||
};
|
||||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||||
export type TruthQueueEntry = {
|
||||
@@ -657,8 +658,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const marker = record(v.interpreted);
|
||||
const oldFact = question.scope === 'fact' && marker.version === 1 ? parseFreeformFactV1(question.text) : null;
|
||||
const newFact = question.scope === 'fact' && marker.version === 2 ? parseFreeformFactV2(question.text) : null;
|
||||
const categoryFact = question.scope === 'fact' && marker.version === 3 && !parseFreeformFactV2(question.text)
|
||||
? parseFreeformFactV3(question.text) : null;
|
||||
const expected = oldFact ? { version: 1 as const, fact: oldFact }
|
||||
: newFact ? { version: 2 as const, fact: newFact } : null;
|
||||
: newFact ? { version: 2 as const, fact: newFact }
|
||||
: categoryFact ? { version: 3 as const, fact: categoryFact } : null;
|
||||
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
|
||||
'The saved freeform interpretation does not match its original question.');
|
||||
question.interpreted = expected;
|
||||
@@ -1119,6 +1123,10 @@ export function resolveTruthAction(
|
||||
if (w.question.kind === 'freeform' && w.question.scope === 'fact') {
|
||||
const fact = parseFreeformFactV2(w.question.text);
|
||||
if (fact) w.question.interpreted = { version: 2, fact };
|
||||
else {
|
||||
const categoryFact = parseFreeformFactV3(w.question.text);
|
||||
if (categoryFact) w.question.interpreted = { version: 3, fact: categoryFact };
|
||||
}
|
||||
}
|
||||
if (w.question.kind === 'mixedBattlePlan')
|
||||
check(effects.mixedBattleAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextNativeTechFactionsStep } from './fixture-native-tech-factions';
|
||||
import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Run original setup and first Storm; select only safe unopened Spice draws.
|
||||
* Once the real movement opportunity is reached, relocate the physical Semuta
|
||||
* and Hajr to their users' hands and return those hands' other cards to the
|
||||
* deck. No Tech ownership, force, resource or turn state is staged. */
|
||||
function fixture() {
|
||||
let game = createGame('SEMUTATECHPUBLIC', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
game = applyAction(game, 'r', { type: 'techTokens', enabled: true });
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = applyAction(game, 'r', { type: 'start' });
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.semutaPreview, true);
|
||||
const original = inventory(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 250; count++) {
|
||||
const step = nextNativeTechFactionsStep(game); assert.ok(step);
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position);
|
||||
const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card);
|
||||
}
|
||||
for (let count = 0; !(game.phase === 5 && game.active === 'a' && clean(game)) && count < 1600; count++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextNativeTechFactionsStep(game); assert.ok(step);
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.phase, 5);
|
||||
assert.equal(game.active, 'a');
|
||||
assert.ok(clean(game));
|
||||
const owners = TECH_TOKENS.map(token => game.techTokens![token.id].owner);
|
||||
assert.equal(new Set(owners).size, 3);
|
||||
assert.ok(owners.every(owner => game.players.some(player => player.id === owner)));
|
||||
for (const id of ['r', 'a']) game.deck.push(...seat(game, id).hand.splice(0));
|
||||
const zones = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [], ...game.players.map(player => player.hand)];
|
||||
const take = (match: (card: Game['deck'][number]) => boolean) => {
|
||||
for (const zone of zones) {
|
||||
const index = zone.findIndex(match);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw Error('The original physical card was not found');
|
||||
};
|
||||
const semuta = take(card => card.id === SEMUTA_DRUG_ID);
|
||||
const hajr = take(card => card.effect === 'hajr');
|
||||
seat(game, 'r').hand.push(semuta);
|
||||
seat(game, 'a').hand.push(hajr);
|
||||
assert.deepEqual(inventory(game), original);
|
||||
return { game, original, hajr: hajr.id, tech: structuredClone(game.techTokens!) };
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Basic public Tech Semuta ${claim ? 'claims' : 'passes'} a real Hajr discard and continues the shipment`, () => {
|
||||
const f = fixture();
|
||||
let game = applyAction(f.game, 'a', { type: 'card', card: f.hajr });
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending?.reaction?.stage === 'offer');
|
||||
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
|
||||
assert.equal(viewGame(game, 'r').semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(game.techTokens, f.tech);
|
||||
const event = pending.batch.event;
|
||||
if (claim) game = applyAction(game, 'r', { type: 'semutaCommit', event });
|
||||
else for (const player of game.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = applyAction(game, 'r', { type: 'semutaSelect', event, card: f.hajr });
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.ok(game.hajr.includes('a'));
|
||||
assert.equal(seat(game, 'r').hand.some(card => card.id === f.hajr), claim);
|
||||
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
||||
const owner = game.techTokens!.heighliners.owner!;
|
||||
const already = game.techTokens!.heighliners.triggeredTurn === game.turn;
|
||||
const spice = already ? game.techTokens!.heighliners.spice : ownedTech(game.techTokens, owner).length;
|
||||
const reserves = seat(game, 'a').reserves;
|
||||
game = applyAction(game, 'a', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 });
|
||||
for (let count = 0; !clean(game) && count < 30; count++) {
|
||||
const step = nextNativeTechFactionsStep(game); assert.ok(step);
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.ok(clean(game));
|
||||
assert.equal(seat(game, 'a').reserves, reserves - 1);
|
||||
assert.equal(game.techTokens!.heighliners.owner, owner);
|
||||
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
|
||||
assert.equal(game.techTokens!.heighliners.spice, spice);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
}
|
||||
|
||||
void test('all four bot profiles can legally respond to the Basic Tech Semuta offer', () => {
|
||||
const f = fixture();
|
||||
const pending = applyAction(f.game, 'a', { type: 'card', card: f.hajr });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, 'r');
|
||||
const player = view.players.find(candidate => candidate.id === 'r');
|
||||
assert.ok(player);
|
||||
player.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => applyAction(pending, 'r', action));
|
||||
}
|
||||
});
|
||||
@@ -57,3 +57,14 @@ void test('a compound freeform shows its aggregate interpretation to the table',
|
||||
assert.match(target, /<strong>Yes<\/strong>/);
|
||||
assert.doesNotMatch(observer, /Your current game information establishes:/);
|
||||
});
|
||||
|
||||
void test('a category freeform displays its primary-role meaning without exposing the target answer', () => {
|
||||
const game = pending(5, 'Do you have a weapon?');
|
||||
const target = markup(game, 'b'), observer = markup(game, 'o');
|
||||
for (const html of [target, observer]) {
|
||||
assert.match(html, /Checked as:/);
|
||||
assert.match(html, /you currently hold at least 1 card whose primary role is a weapon/);
|
||||
}
|
||||
assert.match(target, /Your current game information establishes:/);
|
||||
assert.doesNotMatch(observer, /Your current game information establishes:/);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, 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';
|
||||
|
||||
/** Actual Truthtrance actions after conserving the target's staged hand. */
|
||||
function pending(hasWeapon: boolean, scope: 'fact' | 'currentTurn' = 'fact'): Game {
|
||||
let game = knowledgeGame();
|
||||
const target = game.players.find(seat => seat.id === 'b')!;
|
||||
for (const card of target.hand.slice()) moveSemutaEmperorCard(game, card.id, 'f');
|
||||
if (hasWeapon) {
|
||||
const weapon = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)]
|
||||
.find(card => card.kind === 'projectile');
|
||||
assert.ok(weapon); moveSemutaEmperorCard(game, weapon.id, target.id);
|
||||
}
|
||||
const truth = game.players[0].hand.find(card => card.effect === 'truthtrance');
|
||||
assert.ok(truth);
|
||||
game = applyAction(game, 'a', { type: 'card', card: truth.id });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id));
|
||||
assert.ok(seat); game = applyAction(game, seat.id, { type: 'truthPass' });
|
||||
}
|
||||
return applyAction(game, 'a', { type: 'truthAsk', question: {
|
||||
kind: 'freeform', target: 'b', scope, text: 'Do you have a weapon?',
|
||||
} });
|
||||
}
|
||||
|
||||
for (const hasWeapon of [true, false]) {
|
||||
void test(`a category question checks the target's current primary weapon role: ${hasWeapon}`, () => {
|
||||
const game = pending(hasWeapon), answer = hasWeapon ? 'yes' : 'no';
|
||||
const question = game.truthtrance!.question;
|
||||
assert.equal(question?.kind, 'freeform');
|
||||
if (question?.kind !== 'freeform') throw Error('Expected freeform question');
|
||||
assert.deepEqual(question.interpreted, { version: 3,
|
||||
fact: { kind: 'handInventory', category: 'weapon', compare: 'gte', value: 1 } });
|
||||
assert.equal(viewGame(game, 'b').truthAnswer, answer);
|
||||
for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(game, observer).truthAnswer, null);
|
||||
rejectKnowledge(game, 'b', { type: 'truthAnswer', answer: hasWeapon ? 'no' : 'yes' });
|
||||
const physical = knowledgePhysical(game);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'b');
|
||||
view.players.find(seat => seat.id === 'b')!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{ type: 'truthAnswer', answer }]);
|
||||
}
|
||||
const restored = JSON.parse(JSON.stringify(game)) as Game;
|
||||
const done = applyAction(restored, 'b', { type: 'truthAnswer', answer });
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, answer);
|
||||
assert.deepEqual(knowledgePhysical(done), physical);
|
||||
});
|
||||
}
|
||||
|
||||
void test('unstamped and current-turn category questions keep their prior manual semantics', () => {
|
||||
const currentTurn = pending(true, 'currentTurn');
|
||||
assert.equal(currentTurn.truthtrance?.question?.kind, 'freeform');
|
||||
assert.equal(currentTurn.truthtrance?.question?.kind === 'freeform' && currentTurn.truthtrance.question.interpreted, undefined);
|
||||
const legacy = pending(true);
|
||||
const question = legacy.truthtrance!.question;
|
||||
assert.equal(question?.kind, 'freeform');
|
||||
if (question?.kind !== 'freeform') throw Error('Expected freeform question');
|
||||
delete question.interpreted;
|
||||
assert.equal(viewGame(legacy, 'b').truthAnswer, null);
|
||||
assert.equal(applyAction(legacy, 'b', { type: 'truthAnswer', answer: 'no' }).truthHistory!.at(-1)!.answer, 'no');
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { parseFreeformFactV2 } from '../game/truthtrance-freeform-facts-v2';
|
||||
import { parseFreeformFactV3 } from '../game/truthtrance-freeform-facts-v3';
|
||||
import { handInventoryFactMatches, type HandInventoryFact } from '../game/truthtrance-hand-inventory';
|
||||
|
||||
void test('freeform v3 recognizes current primary-role existence and count questions', () => {
|
||||
const cases: Array<[string, HandInventoryFact]> = [
|
||||
['Do you have a weapon?', {kind:'handInventory', category:'weapon', compare:'gte', value:1}],
|
||||
['DO YOU HAVE ANY DEFENSES IN YOUR HAND?', {kind:'handInventory', category:'defense', compare:'gte', value:1}],
|
||||
['Do you have at least 02 worthless cards?', {kind:'handInventory', category:'worthless', compare:'gte', value:2}],
|
||||
['Do you have exactly 0 poison weapons?', {kind:'handInventory', category:'poisonWeapon', compare:'eq', value:0}],
|
||||
['Do you have at most 3 projectile weapons in your hand?', {kind:'handInventory', category:'projectileWeapon', compare:'lte', value:3}],
|
||||
['Do you have any poison defenses?', {kind:'handInventory', category:'poisonDefense', compare:'gte', value:1}],
|
||||
['Do you have a projectile defense?', {kind:'handInventory', category:'projectileDefense', compare:'gte', value:1}],
|
||||
];
|
||||
for (const [text, fact] of cases) assert.deepEqual(parseFreeformFactV3(text), fact, text);
|
||||
});
|
||||
|
||||
void test('freeform v3 uses the existing physical hand category semantics', () => {
|
||||
const deck = baseDeck();
|
||||
const weapon = deck.find(card => card.kind === 'projectile')!;
|
||||
const defense = deck.find(card => card.kind === 'shield')!;
|
||||
const worthless = deck.find(card => card.kind === 'worthless')!;
|
||||
assert.ok(weapon && defense && worthless);
|
||||
const hand = [weapon, defense, worthless];
|
||||
for (const text of ['Do you have a weapon?', 'Do you have a defense?',
|
||||
'Do you have exactly 1 worthless card?', 'Do you have exactly 0 poison weapons?']) {
|
||||
const fact = parseFreeformFactV3(text);
|
||||
assert.ok(fact && fact.kind === 'handInventory', text);
|
||||
assert.equal(handInventoryFactMatches(hand, fact), true, text);
|
||||
}
|
||||
const absent = parseFreeformFactV3('Do you have any poison defenses?');
|
||||
assert.ok(absent && absent.kind === 'handInventory');
|
||||
assert.equal(handInventoryFactMatches(hand, absent), false);
|
||||
});
|
||||
|
||||
void test('freeform v3 preserves v2 named-card, spice, hand-size and compound interpretations', () => {
|
||||
for (const text of [
|
||||
'Do you have a Shield?', 'Do you have a Cheap Hero?', 'Do you have at least 4 spice?',
|
||||
'Do you have at most 3 cards in your hand?', 'Do you have a Shield and a Snooper?',
|
||||
]) {
|
||||
const v2 = parseFreeformFactV2(text);
|
||||
assert.ok(v2, text);
|
||||
assert.deepEqual(parseFreeformFactV3(text), v2, text);
|
||||
}
|
||||
});
|
||||
|
||||
void test('freeform v3 leaves ambiguous, negative, compound and future wording manual', () => {
|
||||
for (const text of [
|
||||
'Do you have a weapons?', 'Do you have an at least?', 'Do you have a constructor?', 'Do you have a special card?',
|
||||
'Do you have exactly -1 weapons?', 'Do you have exactly 1.5 weapons?',
|
||||
'Do you have at least 9007199254740992 weapons?',
|
||||
'Do you have a weapon or a Shield?', 'Do you have a weapon and a defense?',
|
||||
'Do you not have a weapon?', 'Will you have a weapon?',
|
||||
'Do you have a weapon this turn?', 'Do you have a weapon??',
|
||||
'Do you have a weapon?\nPlease answer.', 'Do you have a weapon?'.padEnd(501, ' '),
|
||||
]) assert.equal(parseFreeformFactV3(text), null, text);
|
||||
});
|
||||
Reference in new issue
Block a user