Expand Basic Semuta and Truthtrance rules
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
7e6e8c2d12
commit
4f990db52e
13 files changed
+351
-12
No files matched your search
@@ -67,6 +67,7 @@ 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 { parseFreeformFactV4 } from '@/game/truthtrance-freeform-facts-v4';
|
||||
import {
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
@@ -960,9 +961,9 @@ export function Truthtrance({
|
||||
placeholder="Will you ship at least six forces to Carthag this turn?"
|
||||
/>
|
||||
</label>
|
||||
{scope === 'fact' && (parseFreeformFactV2(text) || parseFreeformFactV3(text)) ? (
|
||||
{scope === 'fact' && (parseFreeformFactV2(text) || parseFreeformFactV3(text) || parseFreeformFactV4(text)) ? (
|
||||
<p className="notice" aria-label="Recognized freeform question">
|
||||
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: (parseFreeformFactV2(text) || parseFreeformFactV3(text))! }, leaderName, locationName, name)}
|
||||
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: (parseFreeformFactV2(text) || parseFreeformFactV3(text) || parseFreeformFactV4(text))! }, leaderName, locationName, name)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="notice">
|
||||
|
||||
+20
-2
@@ -17,6 +17,23 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Semuta after Thumper and Amal — 29 October. Prototyped / Partial.**
|
||||
The clean public-discard continuations now include the existing independent
|
||||
Ecaz-card/Ix-deck Basic profile. Claim/pass preserves the first worm/blow or
|
||||
committed Amal halving exactly once; staged physical card journeys include
|
||||
legal bot reactions. Other profiles and discard parents remain open.
|
||||
[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-independent-card-thumper-and-amal--29-october-2026).
|
||||
|
||||
**Basic Truthtrance negative current facts — 29 October. Prototyped /
|
||||
Partial.** Narrowly recognized negative card and category questions now display
|
||||
and enforce zero-count interpretations; previous versions and ambiguous text
|
||||
keep their original semantics. Actual question and answer paths include all
|
||||
four minimal bot profiles. The related batch passes 101 unit cases and the
|
||||
additional rendered-control check; TypeScript, lint and the production build
|
||||
pass. Broader
|
||||
language, full modes and deployed acceptance remain open.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-negative-hand-facts--29-october-2026).
|
||||
|
||||
**Advanced CHOAM Auditor choice — 29 October. Prototyped / Partial.** A
|
||||
surviving Auditor may select one or two eligible cards; the chosen count carries
|
||||
through Karama, the actual cancellation price and the private random draw.
|
||||
@@ -34,8 +51,9 @@ completed request, a possible expected denial, not proof of an authorization
|
||||
failure; the exception's cause was not recorded. Acceptance diagnostics now
|
||||
distinguish request exceptions from the last completed response and report only
|
||||
sanitized details; the security checks are unchanged. Two focused diagnostic
|
||||
cases pass. Run #86 was building its container at the last check; the updated
|
||||
diagnostics still need CI verification.
|
||||
cases pass. Run #86 succeeded; run #87 was canceled before starting and run
|
||||
#88 for the Auditor checkpoint was running when checked. The updated diagnostics
|
||||
remain awaiting their first executed CI verification.
|
||||
|
||||
**Truthtrance ally funding — 29 October. Prototyped / Partial.**
|
||||
Supported quiet Basic Bidding and Shipment/Movement questions can bind a future
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic negative Truthtrance facts — 29 October 2026
|
||||
|
||||
Bounded newly asked negative hand questions use zero-count predicates. The
|
||||
grammar is a provisional online interpretation of the FAQ's permission to
|
||||
ask game-related yes/no questions; ambiguous text remains player-managed.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-negative-hand-facts--29-october-2026).
|
||||
|
||||
## Advanced Auditor inspection choice — 29 October 2026
|
||||
|
||||
The authorized revised rules, physical p. 29, say a surviving Auditor may
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Semuta discard producers and continuation inventory
|
||||
|
||||
## Basic independent-card Thumper and Amal — 29 October 2026
|
||||
|
||||
In the supported Basic Richese profile with the Ix deck and independent Ecaz
|
||||
cards, Semuta can react to another player's completed Thumper or Amal discard.
|
||||
Claim or pass resumes the existing first Spice Blow or phase opening once;
|
||||
Amal's halving is already committed before the offer. The existing clean-parent
|
||||
guards remain in force. [Runtime cases](../tests/semuta-recruits-window-cards.test.ts)
|
||||
start a public game, then stage physical card custody and the relevant phase
|
||||
boundary. Other modules and discard parents remain unfinished.
|
||||
|
||||
## Advanced CHOAM and Residual Poison discards — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** The public Advanced Richese and classic/CHOAM preview
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Truthtrance: recorded predictions and storm knowledge
|
||||
|
||||
## Freeform negative hand facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New current-fact questions recognize bounded forms
|
||||
such as “Do you have no Shield?” and “Do you not have any poison weapons?”
|
||||
The editor shows the checked zero-card interpretation, the respondent sees
|
||||
the private answer, and bots use the same truthful answer. Named faces and
|
||||
the existing primary-role categories use their existing count predicates.
|
||||
Newly recognized questions carry version 4; older versions and unstamped
|
||||
questions retain their prior semantics. Ambiguous, compound, future and other
|
||||
unrecognized wording remains player-managed. The parser is a provisional
|
||||
online interpretation of the FAQ's game-related yes/no permission, not
|
||||
general language understanding. [Parser](../game/truthtrance-freeform-facts-v4.ts),
|
||||
[runtime cases](../tests/truthtrance-freeform-v4-runtime.test.ts).
|
||||
|
||||
## Freeform hand-category facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New fact-scoped questions can ask whether a player
|
||||
@@ -29,7 +43,7 @@ results; all minimal bot profiles use that same aggregate.
|
||||
Version 2 retains the earlier exact forms for newly asked questions. Saved
|
||||
version 1 questions are validated against the unchanged version 1 parser, and
|
||||
unstamped older questions keep their original behavior. Mixed operators,
|
||||
ambiguous names, negation, future promises and unsupported grammar remain
|
||||
ambiguous names, unsupported negation, future promises and other grammar remain
|
||||
player-managed with unknown bot answers. This bounded grammar is an online
|
||||
interpretation of the FAQ's game-related yes/no permission, not general natural
|
||||
language understanding. See the [parser](../game/truthtrance-freeform-facts-v2.ts)
|
||||
@@ -46,7 +60,7 @@ show the canonical interpretation. The respondent alone sees the verified
|
||||
answer until publishing it; the server rejects an inconsistent answer. All bot
|
||||
profiles use the same answer path.
|
||||
|
||||
Only anchored supported phrases are interpreted. Ambiguous, negative, compound,
|
||||
Only anchored supported phrases are interpreted. Ambiguous, unsupported, compound,
|
||||
future or otherwise unsupported wording keeps the existing human-managed
|
||||
behavior, and bots answer unknown. Current-turn promise text is never parsed.
|
||||
The server stamps a versioned interpretation on each newly asked recognized
|
||||
|
||||
+4
-2
@@ -9797,6 +9797,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'battleCleanup' && (continuation.source === 'winner' ||
|
||||
continuation.source === 'moritani' && !g.advanced && continuation.retention?.source === undefined) ||
|
||||
continuation.kind === 'residualPoisonDiscard' ||
|
||||
continuation.kind === 'thumperDiscard' || continuation.kind === 'amalDiscard' ||
|
||||
continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' ||
|
||||
continuation.kind === 'truthtranceDiscard' && continuation.promise === null &&
|
||||
(continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') ||
|
||||
@@ -9809,6 +9810,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'battleCleanup' && (continuation.source === 'winner' ||
|
||||
continuation.source === 'moritani' && !g.advanced && continuation.retention?.source === undefined) ||
|
||||
continuation.kind === 'residualPoisonDiscard' ||
|
||||
continuation.kind === 'thumperDiscard' || continuation.kind === 'amalDiscard' ||
|
||||
continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' ||
|
||||
continuation.kind === 'truthtranceDiscard' && continuation.promise === null &&
|
||||
(continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') ||
|
||||
@@ -43299,7 +43301,7 @@ function applyActionInner(
|
||||
const window = windowCardOpportunity(g, 'amal');
|
||||
requireRule(window && quotedAmalCards(g, p).includes(card.id), 'Retain your original native Amal opening and physical card.');
|
||||
recordNativeTurnActionWindowCard(g, id, 'amal', window.source);
|
||||
const before = g.semutaPreview
|
||||
const before = g.semutaPreview || g.semutaRecruitsPreview
|
||||
? g.players.map(player => ({ player: player.id, spice: player.spice }))
|
||||
: null;
|
||||
const used = discard(g, p, card.id);
|
||||
@@ -46925,7 +46927,7 @@ function applyActionInner(
|
||||
g,
|
||||
`${p.name} played Thumper in place of the next spice-card reveal.`,
|
||||
);
|
||||
if (g.semutaPreview) {
|
||||
if (g.semutaPreview || g.semutaRecruitsPreview) {
|
||||
const continuation: ThumperDiscardContinuation = {
|
||||
kind: 'thumperDiscard', player: p.id, card: used.id,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
|
||||
+2
-2
@@ -3991,7 +3991,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, 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.',
|
||||
'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. Supported negative hand questions check that the named face or category count is zero. 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.',
|
||||
'In supported quiet Basic Bidding or Shipment and Movement, ask whether a player will update funding for their current mutual ally to at least a chosen amount this phase. Yes requires a future accepted update while possible; No forbids reaching the amount. An existing pledge alone does not count, a repeated same total does, and the ally chooses whether to spend it. Only one definite funding question is supported per phase; other future-action questions in that phase and optional modules remain separate.',
|
||||
'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.',
|
||||
@@ -6629,7 +6629,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
|
||||
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
|
||||
'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. Supported battle cleanup, clean pre-leader Residual Poison, native Moritani allied retention, Ixian substitution, CHOAM sales and Worthless costs, clean Ambassador and Terror discards, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. The original effect stays committed; other Semuta opportunities remain unfinished.',
|
||||
'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. With the Ix deck, clean Thumper and Amal discards can also be claimed before their original continuation. Supported battle cleanup, clean pre-leader Residual Poison, native Moritani allied retention, Ixian substitution, CHOAM sales and Worthless costs, clean Ambassador and Terror discards, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. The original effect stays committed; other Semuta opportunities remain unfinished.',
|
||||
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
|
||||
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
|
||||
import type { CardCountFact } from './truthtrance-card-count';
|
||||
import type { HandInventoryFact } from './truthtrance-hand-inventory';
|
||||
|
||||
/**
|
||||
* Recognize only new, unambiguous present-tense negation forms. Earlier
|
||||
* parsers remain unchanged for previously stamped questions. The caller
|
||||
* limits interpretation to fact-scoped questions and displays the result.
|
||||
*/
|
||||
export function parseFreeformFactV4(text: string): CardCountFact | HandInventoryFact | null {
|
||||
if (typeof text !== 'string' || text.length > 500) return null;
|
||||
const match = /^do[ \t]+you[ \t]+(?:have[ \t]+no|not[ \t]+have[ \t]+(a|an|any))[ \t]+(.+?)[ \t]*\??$/i.exec(text.trim());
|
||||
if (!match) return null;
|
||||
const subject = match[2].trim();
|
||||
// Reuse v3's bounded primary-role vocabulary and zero-count semantics.
|
||||
const category = parseFreeformFactV3(`Do you have exactly 0 ${subject}`);
|
||||
const inventory = category?.kind === 'handInventory' ? category : null;
|
||||
const nameInput = subject.replace(/[ \t]+in[ \t]+your[ \t]+hand$/i, '').trim();
|
||||
if (inventory && /^(a|an)$/i.test(match[1] ?? '') && /s$/i.test(nameInput)) return null;
|
||||
const names = TRUTH_CARD_NAMES.filter(name => name.toLowerCase() === nameInput.toLowerCase());
|
||||
// Refuse if the words could mean both a named face and a category.
|
||||
if ((inventory && names.length > 0) || names.length > 1) return null;
|
||||
if (inventory) return inventory;
|
||||
return names.length === 1
|
||||
? { kind: 'handCount', name: names[0], compare: 'eq', value: 0 }
|
||||
: null;
|
||||
}
|
||||
+10
-2
@@ -68,6 +68,7 @@ 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 { parseFreeformFactV4 } from './truthtrance-freeform-facts-v4';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
@@ -132,7 +133,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 | 3; fact: TruthFact };
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4; fact: TruthFact };
|
||||
};
|
||||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||||
export type TruthQueueEntry = {
|
||||
@@ -676,9 +677,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
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 negativeFact = question.scope === 'fact' && marker.version === 4 && !parseFreeformFactV3(question.text)
|
||||
? parseFreeformFactV4(question.text) : null;
|
||||
const expected = oldFact ? { version: 1 as const, fact: oldFact }
|
||||
: newFact ? { version: 2 as const, fact: newFact }
|
||||
: categoryFact ? { version: 3 as const, fact: categoryFact } : null;
|
||||
: categoryFact ? { version: 3 as const, fact: categoryFact }
|
||||
: negativeFact ? { version: 4 as const, fact: negativeFact } : null;
|
||||
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
|
||||
'The saved freeform interpretation does not match its original question.');
|
||||
question.interpreted = expected;
|
||||
@@ -1146,6 +1150,10 @@ export function resolveTruthAction(
|
||||
else {
|
||||
const categoryFact = parseFreeformFactV3(w.question.text);
|
||||
if (categoryFact) w.question.interpreted = { version: 3, fact: categoryFact };
|
||||
else {
|
||||
const negativeFact = parseFreeformFactV4(w.question.text);
|
||||
if (negativeFact) w.question.interpreted = { version: 4, fact: negativeFact };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (w.question.kind === 'mixedBattlePlan')
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const found = game.players.find(player => player.id === id); assert.ok(found); return found;
|
||||
};
|
||||
|
||||
/** Public Basic start with the Ix deck and independent Ecaz cards. After real
|
||||
* setup, stage original card custody and the specific phase boundary below. */
|
||||
function fixture(effect: 'thumper' | 'amal') {
|
||||
let game = createGame('SEMUTAWINDOW', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
game = act(game, 'r', {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'r', {type:'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
assert.equal(game.semutaPreview, undefined);
|
||||
for (let step = 0; game.status === 'setup' && step < 100; step++) {
|
||||
const next = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(next, 'Native setup must offer a legal action');
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const original = inventory(game);
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
const take = (id: string) => {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
|
||||
const index = zone.findIndex(card => card.id === id || card.effect === id);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error(`Missing physical ${id}`);
|
||||
};
|
||||
seat(game, 'r').hand.push(take(SEMUTA_DRUG_ID));
|
||||
const played = take(effect); seat(game, 'a').hand.push(played);
|
||||
assert.deepEqual(inventory(game), original);
|
||||
return {game, played, original};
|
||||
}
|
||||
|
||||
function finishOffer(game: Game, claim: boolean) {
|
||||
const frame = game.pendingTreacheryDiscard; assert.ok(frame?.reaction);
|
||||
const event = frame.batch.event;
|
||||
if (claim) {
|
||||
game = act(game, 'r', {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, 'r', {type:'semutaSelect', event, card:frame.batch.entries[0].card.id});
|
||||
} else {
|
||||
for (const player of game.players) game = act(game, player.id, {type:'semutaPass', event});
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.throws(() => act(game, 'r', {type:'semutaCommit', event}));
|
||||
return game;
|
||||
}
|
||||
|
||||
function checkBots(pending: Game) {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = structuredClone(pending); seat(game, 'r').bot = difficulty;
|
||||
const action = botActions(viewGame(game, 'r')).find(item =>
|
||||
item.type === 'semutaCommit' || item.type === 'semutaPass');
|
||||
assert.ok(action);
|
||||
assert.doesNotThrow(() => act(game, 'r', action));
|
||||
}
|
||||
}
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`Basic independent Ix Thumper offers Semuta ${claim ? 'claim' : 'pass'} before one Spice Blow`, () => {
|
||||
const f = fixture('thumper'), game = f.game;
|
||||
const lands = game.spiceDeck.filter(card => 'territory' in card && !card.discovery && card.sector !== 18);
|
||||
const prior = lands[0], next = lands[1];
|
||||
assert.ok(prior && next && 'territory' in prior && 'territory' in next);
|
||||
const priorKey = `${prior.territory}:${prior.sector}`;
|
||||
const remaining = game.spiceDeck.filter(card => card !== prior && card !== next);
|
||||
game.spiceDeck = [next, ...remaining]; game.spiceDiscard = [[prior], []];
|
||||
seat(game, 'e').forces = {[priorKey]:3}; seat(game, 'e').reserves = 17;
|
||||
game.spice = {[priorKey]:8};
|
||||
Object.assign(game, {turn:2, phase:1, active:null, ready:[], storm:18,
|
||||
decision:null, response:null, phaseOpening:null, stormPending:null,
|
||||
spiceWindow:null, spiceResolution:null, spiceSequence:null, nexus:false});
|
||||
const beforeDeck = structuredClone(game.spiceDeck);
|
||||
const pending = act(game, 'a', {type:'card', card:f.played.id});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'thumperDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.spiceDeck, beforeDeck);
|
||||
assert.equal(pending.spice[priorKey], 8);
|
||||
assert.equal(seat(pending, 'e').tanks, 0);
|
||||
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
|
||||
checkBots(pending);
|
||||
const resumed = finishOffer(pending, claim);
|
||||
assert.equal(seat(resumed, 'e').tanks, 3);
|
||||
assert.equal(resumed.spice[priorKey], undefined);
|
||||
assert.equal(resumed.spiceWindow?.territory, next.territory);
|
||||
assert.equal(seat(resumed, 'r').hand.some(card => card.id === f.played.id), claim);
|
||||
assert.equal(resumed.discard.filter(card => card.id === f.played.id).length, Number(!claim));
|
||||
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.deepEqual(inventory(resumed), f.original);
|
||||
});
|
||||
|
||||
void test(`Basic independent Ix Amal offers Semuta ${claim ? 'claim' : 'pass'} after one halving`, () => {
|
||||
const f = fixture('amal'); let game = f.game;
|
||||
seat(game, 'r').spice = 11; seat(game, 'a').spice = 9; seat(game, 'e').spice = 7;
|
||||
Object.assign(game, {turn:2, phase:2, active:null, ready:[], decision:null,
|
||||
response:null, phaseOpening:null, auction:null, stormPending:null});
|
||||
for (const id of ['a', 'e', 'r']) game = act(game, id, {type:'ready'});
|
||||
assert.equal(game.phase, 3); assert.ok(game.phaseOpening);
|
||||
game = act(game, 'e', {type:'ready'});
|
||||
assert.deepEqual(game.phaseOpening?.passed, ['e']);
|
||||
const deck = structuredClone(game.deck);
|
||||
const pending = act(game, 'a', {type:'card', card:f.played.id});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'amalDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.phaseOpening, null);
|
||||
assert.deepEqual(pending.deck, deck);
|
||||
assert.deepEqual(pending.players.map(player => player.spice), [5, 4, 3]);
|
||||
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
|
||||
checkBots(pending);
|
||||
let resumed = finishOffer(pending, claim);
|
||||
assert.deepEqual(resumed.phaseOpening?.passed, []);
|
||||
assert.deepEqual(resumed.players.map(player => player.spice), [5, 4, 3]);
|
||||
assert.equal(seat(resumed, 'r').hand.some(card => card.id === f.played.id), claim);
|
||||
assert.equal(resumed.discard.filter(card => card.id === f.played.id).length, Number(!claim));
|
||||
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
for (const id of ['a', 'e', 'r']) resumed = act(resumed, id, {type:'ready'});
|
||||
assert.equal(resumed.phaseOpening, null);
|
||||
assert.deepEqual(resumed.players.map(player => player.spice), [5, 4, 3]);
|
||||
assert.deepEqual(inventory(resumed), f.original);
|
||||
});
|
||||
}
|
||||
@@ -68,3 +68,14 @@ void test('a category freeform displays its primary-role meaning without exposin
|
||||
assert.match(target, /Your current game information establishes:/);
|
||||
assert.doesNotMatch(observer, /Your current game information establishes:/);
|
||||
});
|
||||
|
||||
void test('a negative freeform displays the checked zero-card meaning without exposing the target answer', () => {
|
||||
const game = pending(5, 'Do you have no Shield?');
|
||||
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 exactly 0 cards named Shield/);
|
||||
}
|
||||
assert.match(target, /Your current game information establishes:/);
|
||||
assert.doesNotMatch(observer, /Your current game information establishes:/);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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';
|
||||
|
||||
/** Use the real card, priority queue and answer actions with conserved hand staging. */
|
||||
function pending(present: boolean, category: boolean, scope: 'fact' | 'currentTurn' = 'fact') {
|
||||
let g = knowledgeGame();
|
||||
const target = g.players.find(p => p.id === 'b')!;
|
||||
for (const card of target.hand.slice()) moveSemutaEmperorCard(g, card.id, 'f');
|
||||
if (present) {
|
||||
const card = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)]
|
||||
.find(c => category ? c.kind === 'projectile' : c.name === 'Shield');
|
||||
assert.ok(card); moveSemutaEmperorCard(g, card.id, target.id);
|
||||
}
|
||||
const truth = g.players[0].hand.find(card => card.effect === 'truthtrance');
|
||||
assert.ok(truth);
|
||||
g = applyAction(g, 'a', {type:'card', card:truth.id});
|
||||
while (g.truthtrance?.stage === 'priority') {
|
||||
const p = g.players.find(row => !g.truthtrance!.passed.includes(row.id));
|
||||
assert.ok(p); g = applyAction(g, p.id, {type:'truthPass'});
|
||||
}
|
||||
const text = category ? 'Do you have no weapons?' : 'Do you not have a Shield?';
|
||||
return applyAction(g, 'a', {type:'truthAsk', question:{kind:'freeform', target:'b', scope, text}});
|
||||
}
|
||||
function freeform(g: Game) {
|
||||
const q = g.truthtrance?.question;
|
||||
assert.equal(q?.kind, 'freeform');
|
||||
if (q?.kind !== 'freeform') throw Error('Expected freeform question');
|
||||
return q;
|
||||
}
|
||||
|
||||
for (const present of [true, false]) for (const category of [true, false]) {
|
||||
void test(`v4 ${category ? 'category' : 'named card'} negation enforces the truthful ${present ? 'no' : 'yes'} answer`, () => {
|
||||
const g = pending(present, category);
|
||||
const answer = present ? 'no' : 'yes';
|
||||
const fact = category
|
||||
? {kind:'handInventory', category:'weapon', compare:'eq', value:0}
|
||||
: {kind:'handCount', name:'Shield', compare:'eq', value:0};
|
||||
assert.deepEqual(freeform(g).interpreted, {version:4, fact});
|
||||
assert.equal(viewGame(g, 'b').truthAnswer, answer);
|
||||
for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(g, observer).truthAnswer, null);
|
||||
rejectKnowledge(g, 'b', {type:'truthAnswer', answer:present ? 'yes' : 'no'});
|
||||
rejectKnowledge(g, 'b', {type:'truthAnswer', answer:'unknown'});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(g, 'b');
|
||||
view.players.find(p => p.id === 'b')!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer}]);
|
||||
}
|
||||
const physical = knowledgePhysical(g);
|
||||
const restored = JSON.parse(JSON.stringify(g)) 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 negative questions and current-turn text retain manual semantics', () => {
|
||||
const current = pending(true, false, 'currentTurn');
|
||||
assert.equal(freeform(current).interpreted, undefined);
|
||||
const old = pending(true, false);
|
||||
delete freeform(old).interpreted;
|
||||
assert.equal(viewGame(old, 'b').truthAnswer, null);
|
||||
assert.equal(applyAction(old, 'b', {type:'truthAnswer', answer:'yes'}).truthHistory!.at(-1)!.answer, 'yes');
|
||||
});
|
||||
|
||||
void test('a tampered version-4 receipt cannot change the recognized negative fact', () => {
|
||||
const g = pending(true, false);
|
||||
const q = freeform(g);
|
||||
q.interpreted = {version:4, fact:{kind:'handCount', name:'Snooper', compare:'eq', value:0}};
|
||||
assert.throws(() => viewGame(g, 'b'));
|
||||
assert.throws(() => applyAction(g, 'b', {type:'truthAnswer', answer:'yes'}));
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseFreeformFactV3 } from '../game/truthtrance-freeform-facts-v3';
|
||||
import { parseFreeformFactV4 } from '../game/truthtrance-freeform-facts-v4';
|
||||
|
||||
void test('v4 parses exact negative named cards and primary hand categories', () => {
|
||||
const cases = [
|
||||
['Do you have no Shield?', {kind:'handCount', name:'Shield', compare:'eq', value:0}],
|
||||
['DO YOU NOT HAVE A SHIELD IN YOUR HAND?', {kind:'handCount', name:'Shield', compare:'eq', value:0}],
|
||||
['Do you have no poison weapons?', {kind:'handInventory', category:'poisonWeapon', compare:'eq', value:0}],
|
||||
['Do you not have any projectile defenses in your hand?', {kind:'handInventory', category:'projectileDefense', compare:'eq', value:0}],
|
||||
['Do you have no cards in your hand?', {kind:'handInventory', category:'all', compare:'eq', value:0}],
|
||||
['Do you not have a worthless card?', {kind:'handInventory', category:'worthless', compare:'eq', value:0}],
|
||||
] as const;
|
||||
for (const [text, expected] of cases) {
|
||||
assert.equal(parseFreeformFactV3(text), null, text);
|
||||
assert.deepEqual(parseFreeformFactV4(text), expected, text);
|
||||
}
|
||||
});
|
||||
|
||||
void test('v4 rejects ambiguous, future, malformed and compound language', () => {
|
||||
for (const text of [
|
||||
'Do you have no special cards?', 'Do you not have a weapons?', 'Do you have no constructor?',
|
||||
'Do you have no Shield or Snooper?', 'Will you have no Shield?',
|
||||
'Do you have no Shield this turn?', 'Do you have no Shield??',
|
||||
'Do you have no Shield?\nPlease answer.', 'Do you have no Shield?'.padEnd(501, ' '),
|
||||
'Do you have at most 0 poison weapons?',
|
||||
]) assert.equal(parseFreeformFactV4(text), null, text);
|
||||
});
|
||||
Reference in new issue
Block a user