Expand Basic Semuta and Truthtrance rules
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 05:57:46 +11:00
1 parent 7e6e8c2d12
commit 4f990db52e
13 files changed
+351 -12

No files matched your search

+4 -2
View File
@@ -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
View File
@@ -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.',
+28
View File
@@ -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
View File
@@ -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')