Support freeform Traitor and Face Dancer facts
Verify and publish checkpoint container / container (push) Successful in 11m16s
Verify and publish checkpoint container / container (push) Successful in 11m16s
This commit is contained in:
1 parent
152476c348
commit
34f533ae8d
10 files changed
+402
-6
No files matched your search
+1
-1
@@ -4036,7 +4036,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. Supported negative hand questions check that the named face or category count is zero. Category questions use the displayed printed primary role. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, physical force pools, concealed No-Field values and current auction knowledge also use the existing checked facts. Two to eight recognized clauses can be joined by one unambiguous 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. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, physical force pools, concealed No-Field values, current auction knowledge, and uniquely named Traitors or unrevealed Face Dancers also use the existing checked facts. Two to eight recognized clauses can be joined by one unambiguous 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.',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { parseFreeformFactV2 } from './truthtrance-freeform-facts-v2';
|
||||
import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
|
||||
import { parseFreeformFactV4 } from './truthtrance-freeform-facts-v4';
|
||||
import { parseTruthFreeformFactsV5 } from './truthtrance-freeform-facts-v5';
|
||||
import { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
|
||||
import { parseTruthFreeformFactsV7 } from './truthtrance-freeform-facts-v7';
|
||||
import { parseTruthFreeformFactsV8 } from './truthtrance-freeform-facts-v8';
|
||||
import { TRUTH_FACT_MAX_TERMS } from './truth-fact-expression';
|
||||
import type { TruthFact } from './truthtrance';
|
||||
|
||||
export type FreeformPublicLeader = { id: string; name: string };
|
||||
type Atom = { fact: TruthFact; fresh: boolean };
|
||||
type Separator = { start: number; end: number; kind: 'and' | 'or' };
|
||||
|
||||
/** Only exact public roster names resolve. A caller can supply the special
|
||||
* Cheap Hero identity, but must never supply held cards or other private data. */
|
||||
function namedAtom(input: string, leaders: readonly FreeformPublicLeader[]): TruthFact | null {
|
||||
const text = input.trim().replace(/[ \t]+/g, ' ');
|
||||
const traitor = /^is (.+?) your traitor$/i.exec(text);
|
||||
const dancer = /^do you hold (.+?) as an unrevealed face dancer$/i.exec(text);
|
||||
const name = (traitor?.[1] ?? dancer?.[1])?.toLowerCase();
|
||||
if (!name) return null;
|
||||
const candidates = new Set(leaders.filter(leader =>
|
||||
typeof leader?.id === 'string' && leader.id.length > 0 && leader.id !== DUKE_VIDAL_ID &&
|
||||
typeof leader.name === 'string' && leader.name.trim().replace(/[ \t]+/g, ' ').toLowerCase() === name
|
||||
).map(leader => leader.id));
|
||||
if (candidates.size !== 1) return null;
|
||||
const leader = [...candidates][0];
|
||||
return { kind: traitor ? 'traitor' : 'faceDancer', leader };
|
||||
}
|
||||
|
||||
/** Resolve a named current fact, or a uniquely parsed flat homogeneous
|
||||
* AND/OR with other already-supported atoms. Prior whole questions retain
|
||||
* their old versions; ambiguous, mixed and unrecognized forms stay manual. */
|
||||
export function parseTruthFreeformFactsV9(text: string, leaders: readonly FreeformPublicLeader[]): TruthFact | null {
|
||||
if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text) || !Array.isArray(leaders)) return null;
|
||||
const input = text.trim();
|
||||
if (parseFreeformFactV2(input) || parseFreeformFactV3(input) || parseFreeformFactV4(input) ||
|
||||
parseTruthFreeformFactsV5(input) || parseFreeformFactV6(input) || parseTruthFreeformFactsV7(input) ||
|
||||
parseTruthFreeformFactsV8(input)) return null;
|
||||
const body = input.endsWith('?') ? input.slice(0, -1).trimEnd() : input;
|
||||
if (!body || body.includes('?')) return null;
|
||||
const single = namedAtom(body, leaders);
|
||||
const separators: Separator[] = [...body.matchAll(/[ \t]+(and|or)[ \t]+/gi)].map(match => ({
|
||||
start: match.index!, end: match.index! + match[0].length,
|
||||
kind: match[1].toLowerCase() as 'and' | 'or',
|
||||
}));
|
||||
if (separators.length > 24) return null;
|
||||
const shorthand = /^do[ \t]+you[ \t]+have[ \t]+/i.test(body);
|
||||
const atomCache = new Map<string, Atom | null>();
|
||||
const atom = (raw: string, first: boolean): Atom | null => {
|
||||
const segment = raw.trim();
|
||||
if (!segment) return null;
|
||||
const key = `${first ? 'first' : 'later'}:${segment}`;
|
||||
if (atomCache.has(key)) return atomCache.get(key)!;
|
||||
let qualified = segment;
|
||||
if (!first && /^not[ \t]+have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
|
||||
else if (!first && /^have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
|
||||
else if (!first && shorthand && !/^(?:do|does|did|is)\b/i.test(segment)) qualified = `Do you have ${segment}`;
|
||||
const fresh = namedAtom(qualified, leaders);
|
||||
const old = parseFreeformFactV3(qualified) ?? parseFreeformFactV4(qualified) ?? parseFreeformFactV6(qualified) ??
|
||||
(/^(?:do[ \t]+you[ \t]+have[ \t]+a[ \t]+concealed|is[ \t]+the[ \t]+current|does[ \t]+your[ \t]+recorded)/i.test(qualified)
|
||||
? parseTruthFreeformFactsV8(qualified) : null);
|
||||
const result = fresh && old ? null : fresh ? {fact:fresh, fresh:true}
|
||||
: old && old.kind !== 'and' && old.kind !== 'or' ? {fact:old, fresh:false} : null;
|
||||
atomCache.set(key, result);
|
||||
return result;
|
||||
};
|
||||
const memo = new Map<string, Atom[][]>();
|
||||
const suffix = (offset: number, kind: 'and' | 'or', count: number): Atom[][] => {
|
||||
const key = `${offset}:${kind}:${count}`;
|
||||
const cached = memo.get(key); if (cached) return cached;
|
||||
const results: Atom[][] = [];
|
||||
const last = atom(body.slice(offset), false);
|
||||
if (last && count + 1 <= TRUTH_FACT_MAX_TERMS) results.push([last]);
|
||||
if (count + 1 < TRUTH_FACT_MAX_TERMS) for (const separator of separators) {
|
||||
if (separator.start <= offset || separator.kind !== kind) continue;
|
||||
const head = atom(body.slice(offset, separator.start), false);
|
||||
if (!head) continue;
|
||||
for (const tail of suffix(separator.end, kind, count + 1)) {
|
||||
results.push([head, ...tail]);
|
||||
if (results.length >= 2) break;
|
||||
}
|
||||
if (results.length >= 2) break;
|
||||
}
|
||||
memo.set(key, results);
|
||||
return results;
|
||||
};
|
||||
const matches: Array<{kind:'and'|'or'; atoms:Atom[]}> = [];
|
||||
for (const separator of separators) {
|
||||
const head = atom(body.slice(0, separator.start), true);
|
||||
if (!head) continue;
|
||||
for (const tail of suffix(separator.end, separator.kind, 1)) {
|
||||
matches.push({kind:separator.kind, atoms:[head, ...tail]});
|
||||
if (matches.length >= 2) return null;
|
||||
}
|
||||
}
|
||||
if (single) return matches.length ? null : single;
|
||||
const match = matches[0];
|
||||
return match?.atoms.some(item => item.fresh)
|
||||
? {kind:match.kind, terms:match.atoms.map(item => item.fact)} : null;
|
||||
}
|
||||
+25
-2
@@ -75,6 +75,7 @@ import { parseTruthFreeformFactsV5 } from './truthtrance-freeform-facts-v5';
|
||||
import { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
|
||||
import { parseTruthFreeformFactsV7 } from './truthtrance-freeform-facts-v7';
|
||||
import { parseTruthFreeformFactsV8 } from './truthtrance-freeform-facts-v8';
|
||||
import { parseTruthFreeformFactsV9 } from './truthtrance-freeform-facts-v9';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
@@ -140,7 +141,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 | 4 | 5 | 6 | 7 | 8; fact: TruthFact };
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; fact: TruthFact };
|
||||
};
|
||||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||||
export type TruthQueueEntry = {
|
||||
@@ -174,6 +175,16 @@ const record = (value: unknown): Record<string, unknown> => {
|
||||
);
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
/** Names available to every player, independent of private Traitor custody. */
|
||||
function freeformLeaderRoster(g: Game): { id: string; name: string }[] {
|
||||
return [
|
||||
...g.players.flatMap(player => player.leaders)
|
||||
.filter(leader => leader.id !== DUKE_VIDAL_ID)
|
||||
.map(leader => ({ id: leader.id, name: leader.name })),
|
||||
{ id: CHEAP_HERO_TRAITOR, name: 'Cheap Hero / Heroine' },
|
||||
];
|
||||
}
|
||||
|
||||
function parseFact(
|
||||
g: Game,
|
||||
value: unknown,
|
||||
@@ -740,6 +751,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
|
||||
!parseFreeformFactV6(question.text) && !parseTruthFreeformFactsV7(question.text)
|
||||
? parseTruthFreeformFactsV8(question.text) : null;
|
||||
const leaderFact = question.scope === 'fact' && marker.version === 9 &&
|
||||
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
|
||||
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
|
||||
!parseFreeformFactV6(question.text) && !parseTruthFreeformFactsV7(question.text) &&
|
||||
!parseTruthFreeformFactsV8(question.text)
|
||||
? parseTruthFreeformFactsV9(question.text, freeformLeaderRoster(g)) : null;
|
||||
const expected = oldFact ? { version: 1 as const, fact: oldFact }
|
||||
: newFact ? { version: 2 as const, fact: newFact }
|
||||
: categoryFact ? { version: 3 as const, fact: categoryFact }
|
||||
@@ -747,7 +764,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
: compoundFact ? { version: 5 as const, fact: compoundFact }
|
||||
: currentFact ? { version: 6 as const, fact: parseFact(g, currentFact, g.players.find(player => player.id === question.target)!) }
|
||||
: aggregateFact ? { version: 7 as const, fact: parseFact(g, aggregateFact, g.players.find(player => player.id === question.target)!) }
|
||||
: inspectedFact ? { version: 8 as const, fact: parseFact(g, inspectedFact, g.players.find(player => player.id === question.target)!) } : null;
|
||||
: inspectedFact ? { version: 8 as const, fact: parseFact(g, inspectedFact, g.players.find(player => player.id === question.target)!) }
|
||||
: leaderFact ? { version: 9 as const, fact: parseFact(g, leaderFact, g.players.find(player => player.id === question.target)!) } : null;
|
||||
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
|
||||
'The saved freeform interpretation does not match its original question.');
|
||||
question.interpreted = expected;
|
||||
@@ -1246,6 +1264,11 @@ export function resolveTruthAction(
|
||||
const inspectedFact = parseTruthFreeformFactsV8(w.question.text);
|
||||
if (inspectedFact) w.question.interpreted = { version: 8,
|
||||
fact: parseFact(g, inspectedFact, g.players.find(player => player.id === w.question!.target)!) };
|
||||
else {
|
||||
const leaderFact = parseTruthFreeformFactsV9(w.question.text, freeformLeaderRoster(g));
|
||||
if (leaderFact) w.question.interpreted = { version: 9,
|
||||
fact: parseFact(g, leaderFact, g.players.find(player => player.id === w.question!.target)!) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user