Support Truthtrance Storm dial comparisons
This commit is contained in:
1 parent
c8889e94e2
commit
31b3c1ab87
12 files changed
+288
-18
No files matched your search
@@ -75,7 +75,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
|||||||
An open plan commitment settles when both plans are revealed, not merely when your own plan is sealed.
|
An open plan commitment settles when both plans are revealed, not merely when your own plan is sealed.
|
||||||
Traitor conditions concern one original Reveal or decline declaration in the current revealed battle.
|
Traitor conditions concern one original Reveal or decline declaration in the current revealed battle.
|
||||||
The declaration counts before a later Karama or betrayal response can prevent its effect.
|
The declaration counts before a later Karama or betrayal response can prevent its effect.
|
||||||
A Storm dial condition names one exact value the respondent will submit in their original current Storm dial.
|
A Storm dial condition compares the value the respondent will submit in their original current Storm dial.
|
||||||
The first Storm allows 0–20; later Basic storms allow 1–3. The other player's value stays sealed until both dials reveal.
|
The first Storm allows 0–20; later Basic storms allow 1–3. The other player's value stays sealed until both dials reveal.
|
||||||
Native Voice and Prescience conditions concern your original use or explicit decline, exact Voice command,
|
Native Voice and Prescience conditions concern your original use or explicit decline, exact Voice command,
|
||||||
or requested Prescience field in this battle. They count before later Karama prevention.
|
or requested Prescience field in this battle. They count before later Karama prevention.
|
||||||
@@ -200,7 +200,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
|||||||
if (kind === 'ship' || kind === 'move') onChange({ kind,
|
if (kind === 'ship' || kind === 'move') onChange({ kind,
|
||||||
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
|
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
|
||||||
}}>
|
}}>
|
||||||
<option value="stormDial">Submit an exact value in this current Storm dial</option>
|
<option value="stormDial">Compare your value in this current Storm dial</option>
|
||||||
<option value="cardPlay">Use a named printed card role after this answer</option>
|
<option value="cardPlay">Use a named printed card role after this answer</option>
|
||||||
<option value="windowCardPlay">Use a printed card in this original native window or shipment-price opportunity</option>
|
<option value="windowCardPlay">Use a printed card in this original native window or shipment-price opportunity</option>
|
||||||
<option value="battlePower">Declare native Voice or Prescience in this battle</option>
|
<option value="battlePower">Declare native Voice or Prescience in this battle</option>
|
||||||
@@ -218,7 +218,18 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
|||||||
const opportunity = nativeStormDialOpportunity(game, target, value.source);
|
const opportunity = nativeStormDialOpportunity(game, target, value.source);
|
||||||
return <>
|
return <>
|
||||||
<p className="fine">The respondent’s original current Storm dial counts once. Other dials remain sealed until reveal.</p>
|
<p className="fine">The respondent’s original current Storm dial counts once. Other dials remain sealed until reveal.</p>
|
||||||
<label htmlFor={`${id}-storm-dial-value`}>Exact Storm dial value</label>
|
<label htmlFor={`${id}-storm-dial-compare`}>Storm dial comparison</label>
|
||||||
|
<select id={`${id}-storm-dial-compare`} value={value.compare ?? 'eq'} disabled={disabled}
|
||||||
|
onChange={event => {
|
||||||
|
const compare = event.currentTarget.value;
|
||||||
|
if (compare === 'eq' || compare === 'gte' || compare === 'lte')
|
||||||
|
onChange({ ...value, compare });
|
||||||
|
}}>
|
||||||
|
<option value="eq">Exactly</option>
|
||||||
|
<option value="gte">At least</option>
|
||||||
|
<option value="lte">At most</option>
|
||||||
|
</select>
|
||||||
|
<label htmlFor={`${id}-storm-dial-value`}>Storm dial value</label>
|
||||||
<Input id={`${id}-storm-dial-value`} type="number" min={opportunity?.min ?? (game.turn === 1 ? 0 : 1)}
|
<Input id={`${id}-storm-dial-value`} type="number" min={opportunity?.min ?? (game.turn === 1 ? 0 : 1)}
|
||||||
max={opportunity?.max ?? (game.turn === 1 ? 20 : 3)} step={1} required disabled={disabled}
|
max={opportunity?.max ?? (game.turn === 1 ? 20 : 3)} step={1} required disabled={disabled}
|
||||||
value={Number.isFinite(value.value) ? value.value : ''}
|
value={Number.isFinite(value.value) ? value.value : ''}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ open mode gates.
|
|||||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||||
Prototype labels and final acceptance gates remain.
|
Prototype labels and final acceptance gates remain.
|
||||||
|
|
||||||
|
**Basic Truthtrance Storm-dial comparisons — 27 October. Prototyped / Partial.**
|
||||||
|
The upcoming original Basic dial supports exact, at-least and at-most
|
||||||
|
conditions, with multiple answers sharing the native range and older omitted
|
||||||
|
comparators remaining exact. Native controls and minimal bot guides use the
|
||||||
|
original sealed submission; wider combinations and final/deployed acceptance
|
||||||
|
remain open. [Scope](TRUTHTRANCE_TURN_ACTIONS.md#upcoming-basic-storm-dial-choice--27-october-2026).
|
||||||
|
|
||||||
**Basic Richese Semuta and bribe questions — 27 October. Prototyped / Partial.**
|
**Basic Richese Semuta and bribe questions — 27 October. Prototyped / Partial.**
|
||||||
Structured ordinary, grouped and mixed Truthtrance payment questions are
|
Structured ordinary, grouped and mixed Truthtrance payment questions are
|
||||||
available in the bare Basic Richese Semuta profile. Their exact answer and
|
available in the bare Basic Richese Semuta profile. Their exact answer and
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 27 October 2026 — Basic Truthtrance storm-dial comparisons
|
||||||
|
|
||||||
|
Upcoming original Basic Storm dials support exact, at-least and at-most
|
||||||
|
questions. Multiple answers share the real dial's finite legal range, and old
|
||||||
|
saved exact questions retain their original shape. Native sealed submission,
|
||||||
|
existing player guidance and minimal AI remain connected. Advanced and wider
|
||||||
|
combinations, full rules and deployed acceptance remain open.
|
||||||
|
[Scope](TRUTHTRANCE_TURN_ACTIONS.md#upcoming-basic-storm-dial-choice--27-october-2026).
|
||||||
|
|
||||||
## 27 October 2026 — Basic Semuta and Truthtrance bribes
|
## 27 October 2026 — Basic Semuta and Truthtrance bribes
|
||||||
|
|
||||||
The bare Basic Richese Semuta profile admits structured scalar, grouped and
|
The bare Basic Richese Semuta profile admits structured scalar, grouped and
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Basic Truthtrance storm-dial comparisons — 27 October 2026
|
||||||
|
|
||||||
|
Extend the provisional upcoming-dial question to exact, at-least and at-most
|
||||||
|
comparisons. Each answer concerns the same original native dial, using the
|
||||||
|
real turn-specific range; old conditions without a comparator remain exact.
|
||||||
|
This is an application of the FAQ's general current-turn question permission,
|
||||||
|
not a separately printed Storm protocol.
|
||||||
|
[Scope](TRUTHTRANCE_TURN_ACTIONS.md#upcoming-basic-storm-dial-choice--27-october-2026).
|
||||||
|
|
||||||
## Basic Semuta and Truthtrance bribes — 27 October 2026
|
## Basic Semuta and Truthtrance bribes — 27 October 2026
|
||||||
|
|
||||||
In the supported bare Basic Richese profile, provisionally resolve the fresh
|
In the supported bare Basic Richese profile, provisionally resolve the fresh
|
||||||
|
|||||||
@@ -1036,8 +1036,14 @@ rules, browser and deployed acceptance remain open.
|
|||||||
|
|
||||||
## Upcoming Basic storm-dial choice — 27 October 2026
|
## Upcoming Basic storm-dial choice — 27 October 2026
|
||||||
|
|
||||||
|
**Comparison follow-up:** a question may now ask whether the original value is
|
||||||
|
exactly, at least or at most its chosen threshold. Multiple conditions share
|
||||||
|
the same actual dial; the planner intersects their legal numeric choices.
|
||||||
|
Older saved conditions without a comparison retain their exact-value meaning.
|
||||||
|
[Runtime evidence](../tests/truth-storm-dial-comparisons-runtime.test.ts).
|
||||||
|
|
||||||
**Prototyped / Partial.** The publisher FAQ (November 2020, p. 8) permits
|
**Prototyped / Partial.** The publisher FAQ (November 2020, p. 8) permits
|
||||||
current-turn Yes/No questions. This event-bound exact future choice is the
|
current-turn Yes/No questions. This event-bound future choice is the
|
||||||
project's provisional online procedure for that permission, not a separately
|
project's provisional online procedure for that permission, not a separately
|
||||||
printed publisher protocol. It names a current eligible, unsubmitted Basic storm
|
printed publisher protocol. It names a current eligible, unsubmitted Basic storm
|
||||||
dialer, this turn and one legal value: 0–20 on turn one, 1–3 later. The server
|
dialer, this turn and one legal value: 0–20 on turn one, 1–3 later. The server
|
||||||
|
|||||||
+1
-1
@@ -3280,7 +3280,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
|
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
|
||||||
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
|
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
|
||||||
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
|
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
|
||||||
'A future storm-dial condition names a current Basic dialer who has not submitted and one exact legal value. Turn 1 permits 0–20; later Basic turns permit 1–3. A Yes binds that exact native choice, while a No excludes it. Existing whole-answer alternatives still apply. The other dial remains sealed, and the normal sum is revealed only after both dialers submit.',
|
'A future storm-dial condition names a current Basic dialer who has not submitted and asks whether their value will be exactly, at least or at most a chosen threshold. Turn 1 permits 0–20; later Basic turns permit 1–3. Yes and No bind the whole answer; multiple conditions share the same native choice. The other dial remains sealed, and the normal sum is revealed only after both dialers submit.',
|
||||||
'A native-power condition names your original Voice or Prescience opportunity in this battle. Ask about actual use or explicit Decline, an exact Voice category and require/forbid command, or the requested Prescience field. A requested field is not the inspected answer. The declaration counts before later Karama prevention.',
|
'A native-power condition names your original Voice or Prescience opportunity in this battle. Ask about actual use or explicit Decline, an exact Voice category and require/forbid command, or the requested Prescience field. A requested field is not the inspected answer. The declaration counts before later Karama prevention.',
|
||||||
'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.',
|
'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.',
|
||||||
'In a supported Basic battle response, ask whether a named non-owner will spend a physical printed Karama to cancel the current Voice or Prescience. The question may combine this exact response with current facts; other future action families remain unavailable while the response is open. Yes requires that actual card use while possible; No prevents it. A different player may cancel first, releasing an impossible Yes. Passing does not itself surrender a still-open response, but the target cannot close their last opportunity while a required Yes remains.',
|
'In a supported Basic battle response, ask whether a named non-owner will spend a physical printed Karama to cancel the current Voice or Prescience. The question may combine this exact response with current facts; other future action families remain unavailable while the response is open. Yes requires that actual card use while possible; No prevents it. A different player may cancel first, releasing an impossible Yes. Passing does not itself surrender a still-open response, but the target cannot close their last opportunity while a required Yes remains.',
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/** A future choice names only the actor's original native dial opportunity.
|
/** A future choice names only the actor's original native dial opportunity.
|
||||||
* The accepted value stays in the normal private storm dial until reveal. */
|
* The accepted value stays in the normal private storm dial until reveal. */
|
||||||
export type StormDialSource = { turn: number; player: string };
|
export type StormDialSource = { turn: number; player: string };
|
||||||
export type StormDialClaim = { kind: 'stormDial'; source: string; value: number };
|
export type StormDialClaim = {
|
||||||
|
kind: 'stormDial'; source: string; value: number;
|
||||||
|
/** Older saved claims omit this field and retain exact-value meaning. */
|
||||||
|
compare?: 'eq' | 'gte' | 'lte';
|
||||||
|
};
|
||||||
export type StormDialOpportunity = StormDialSource & { source: string; min: number; max: number };
|
export type StormDialOpportunity = StormDialSource & { source: string; min: number; max: number };
|
||||||
export type StormDialGame = {
|
export type StormDialGame = {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -43,15 +47,20 @@ export function parseStormDialSource(source: unknown): StormDialSource | null {
|
|||||||
return { turn: tuple[1], player: tuple[2] };
|
return { turn: tuple[1], player: tuple[2] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse only an exact future native choice within that turn's real dial range. */
|
/** Parse a future native choice within that turn's real dial range. */
|
||||||
export function parseStormDialClaim(value: unknown): StormDialClaim {
|
export function parseStormDialClaim(value: unknown): StormDialClaim {
|
||||||
check(object(value) && Object.keys(value).sort().join(',') === 'kind,source,value' && value.kind === 'stormDial',
|
check(object(value) &&
|
||||||
'Choose one original storm-dial opportunity and exact value.');
|
(Object.keys(value).sort().join(',') === 'kind,source,value' ||
|
||||||
|
Object.keys(value).sort().join(',') === 'compare,kind,source,value') &&
|
||||||
|
value.kind === 'stormDial' &&
|
||||||
|
(!Object.hasOwn(value, 'compare') || value.compare === 'eq' || value.compare === 'gte' || value.compare === 'lte'),
|
||||||
|
'Choose one original storm-dial opportunity and value comparison.');
|
||||||
const source = parseStormDialSource(value.source);
|
const source = parseStormDialSource(value.source);
|
||||||
check(source && typeof value.value === 'number' && Number.isSafeInteger(value.value) &&
|
check(source && typeof value.value === 'number' && Number.isSafeInteger(value.value) &&
|
||||||
value.value >= (source.turn === 1 ? 0 : 1) && value.value <= (source.turn === 1 ? 20 : 3),
|
value.value >= (source.turn === 1 ? 0 : 1) && value.value <= (source.turn === 1 ? 20 : 3),
|
||||||
'Choose a value within the original turn’s native storm-dial range.');
|
'Choose a value within the original turn’s native storm-dial range.');
|
||||||
return { kind: 'stormDial', source: value.source as string, value: value.value };
|
return { kind: 'stormDial', source: value.source as string, value: value.value,
|
||||||
|
...(Object.hasOwn(value, 'compare') ? { compare: value.compare as 'eq' | 'gte' | 'lte' } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compare the actual accepted native submission, never an invented default. */
|
/** Compare the actual accepted native submission, never an invented default. */
|
||||||
@@ -60,7 +69,8 @@ export function stormDialMatches(claim: StormDialClaim, actual: unknown): boolea
|
|||||||
check(typeof actual === 'number' && Number.isSafeInteger(actual) &&
|
check(typeof actual === 'number' && Number.isSafeInteger(actual) &&
|
||||||
actual >= (source.turn === 1 ? 0 : 1) && actual <= (source.turn === 1 ? 20 : 3),
|
actual >= (source.turn === 1 ? 0 : 1) && actual <= (source.turn === 1 ? 20 : 3),
|
||||||
'Retain the actual native storm-dial submission.');
|
'Retain the actual native storm-dial submission.');
|
||||||
return parsed.value === actual;
|
return parsed.compare === 'gte' ? actual >= parsed.value :
|
||||||
|
parsed.compare === 'lte' ? actual <= parsed.value : actual === parsed.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public eligibility only. Reads whether a value is sealed, never that value.
|
/** Public eligibility only. Reads whether a value is sealed, never that value.
|
||||||
|
|||||||
+1
-1
@@ -795,7 +795,7 @@ export function truthQuestionText(
|
|||||||
};
|
};
|
||||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||||
action => {
|
action => {
|
||||||
if (action.kind === 'stormDial') return `you submit exactly ${action.value} on your original storm dial this turn`;
|
if (action.kind === 'stormDial') return `you submit ${action.compare === 'gte' ? 'at least' : action.compare === 'lte' ? 'at most' : 'exactly'} ${action.value} on your original storm dial this turn`;
|
||||||
if (action.kind === 'windowCardPlay') {
|
if (action.kind === 'windowCardPlay') {
|
||||||
if (action.effect === 'karamaBattleCancel') {
|
if (action.effect === 'karamaBattleCancel') {
|
||||||
const response = parseKaramaBattleCancelSource(action.source);
|
const response = parseKaramaBattleCancelSource(action.source);
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actionKey(action: TurnActionLeaf): string {
|
function actionKey(action: TurnActionLeaf): string {
|
||||||
if (action.kind === 'stormDial') return JSON.stringify([action.kind, action.source, action.value]);
|
if (action.kind === 'stormDial') return JSON.stringify(action.compare && action.compare !== 'eq'
|
||||||
|
? [action.kind, action.source, action.value, action.compare] : [action.kind, action.source, action.value]);
|
||||||
if (action.kind === 'windowCardPlay') return JSON.stringify([action.kind, action.effect, action.source]);
|
if (action.kind === 'windowCardPlay') return JSON.stringify([action.kind, action.effect, action.source]);
|
||||||
if (action.kind === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]);
|
if (action.kind === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]);
|
||||||
if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]);
|
if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]);
|
||||||
@@ -576,7 +577,8 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
|
|||||||
|
|
||||||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||||||
if (left.kind !== right.kind) return false;
|
if (left.kind !== right.kind) return false;
|
||||||
if (left.kind === 'stormDial') return right.kind === 'stormDial' && left.source === right.source && left.value === right.value;
|
if (left.kind === 'stormDial') return right.kind === 'stormDial' && left.source === right.source &&
|
||||||
|
left.value === right.value && (left.compare ?? 'eq') === (right.compare ?? 'eq');
|
||||||
if (left.kind === 'windowCardPlay') return right.kind === 'windowCardPlay' && left.effect === right.effect && left.source === right.source;
|
if (left.kind === 'windowCardPlay') return right.kind === 'windowCardPlay' && left.effect === right.effect && left.source === right.source;
|
||||||
if (left.kind === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle &&
|
if (left.kind === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle &&
|
||||||
left.territory === right.territory && left.owner === right.owner && left.beneficiary === right.beneficiary &&
|
left.territory === right.territory && left.owner === right.owner && left.beneficiary === right.beneficiary &&
|
||||||
@@ -605,7 +607,8 @@ export function turnActionStormDialChoices(leaves: readonly TurnActionBranchLeaf
|
|||||||
leaf.kind === 'stormDial' && leaf.source === source);
|
leaf.kind === 'stormDial' && leaf.source === source);
|
||||||
const min = decoded.turn === 1 ? 0 : 1, max = decoded.turn === 1 ? 20 : 3;
|
const min = decoded.turn === 1 ? 0 : 1, max = decoded.turn === 1 ? 20 : 3;
|
||||||
return Array.from({ length: max - min + 1 }, (_, index) => index + min)
|
return Array.from({ length: max - min + 1 }, (_, index) => index + min)
|
||||||
.filter(value => named.every(leaf => (value === leaf.value) === leaf.answer));
|
.filter(value => named.every(leaf => stormDialMatches({ kind: 'stormDial', source: leaf.source,
|
||||||
|
value: leaf.value, ...(leaf.compare ? { compare: leaf.compare } : {}) }, value) === leaf.answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called within one record only: payment progress belongs to that interval. */
|
/** Called within one record only: payment progress belongs to that interval. */
|
||||||
@@ -622,8 +625,6 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
|||||||
if (leaf.kind === 'stormDial') {
|
if (leaf.kind === 'stormDial') {
|
||||||
const prior = merged.find(other => sameAction(other, leaf));
|
const prior = merged.find(other => sameAction(other, leaf));
|
||||||
if (prior && prior.answer !== leaf.answer) return null;
|
if (prior && prior.answer !== leaf.answer) return null;
|
||||||
if (leaf.answer && merged.some(other => other.kind === 'stormDial' && other.source === leaf.source &&
|
|
||||||
other.answer && other.value !== leaf.value)) return null;
|
|
||||||
if (!prior) merged.push({ ...leaf });
|
if (!prior) merged.push({ ...leaf });
|
||||||
if (!turnActionStormDialChoices(merged, leaf.source).length) return null;
|
if (!turnActionStormDialChoices(merged, leaf.source).length) return null;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ void test('match evaluates only an actual legal submission in the named native r
|
|||||||
assert.throws(() => stormDialMatches(claim(2, 'a', 2), invalid));
|
assert.throws(() => stormDialMatches(claim(2, 'a', 2), invalid));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('threshold comparisons retain the old exact claim shape and native bounds', () => {
|
||||||
|
const old = claim(1, 'a', 5);
|
||||||
|
assert.deepEqual(parseStormDialClaim(old), old);
|
||||||
|
assert.equal(stormDialMatches({ ...old, compare: 'eq' }, 5), true);
|
||||||
|
assert.equal(stormDialMatches({ ...old, compare: 'gte' }, 6), true);
|
||||||
|
assert.equal(stormDialMatches({ ...old, compare: 'gte' }, 4), false);
|
||||||
|
assert.equal(stormDialMatches({ ...old, compare: 'lte' }, 4), true);
|
||||||
|
assert.equal(stormDialMatches({ ...old, compare: 'lte' }, 6), false);
|
||||||
|
assert.throws(() => parseStormDialClaim({ ...old, compare: 'gt' }));
|
||||||
|
});
|
||||||
|
|
||||||
void test('malformed or inconsistent public seat and dialer sets have no opportunity', () => {
|
void test('malformed or inconsistent public seat and dialer sets have no opportunity', () => {
|
||||||
for (const game of [
|
for (const game of [
|
||||||
{ ...first, stormDialers: ['a', 'a'] },
|
{ ...first, stormDialers: ['a', 'a'] },
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { parseStormDialClaim, stormDialMatches } from '../game/storm-dial-question';
|
||||||
|
import { turnActionStormDialChoices, type TurnActionBranchLeaf } from '../game/turn-action-promises';
|
||||||
|
import { advanceTruthMovement, truthMovementInventory } from './fixture-truth-movement';
|
||||||
|
import { actTruthStormDial as act, askStormDial as ask, createTruthStormDialFixture as fixture,
|
||||||
|
declareStormDialQuestion as declare, declareTwoStormDialQuestions as declareTwo } from './fixture-truth-storm-dial';
|
||||||
|
|
||||||
|
type Compare = 'eq'|'gte'|'lte';
|
||||||
|
const source=(turn:number, player:string) => JSON.stringify(['stormDial',turn,player]);
|
||||||
|
const leaf=(event:string,value:number,compare?:Compare) => ({kind:'action' as const,
|
||||||
|
action:{kind:'stormDial' as const,source:event,value,...(compare ? {compare} : {})}});
|
||||||
|
function reject(game:Game, actor:string, action:Action) {
|
||||||
|
const before=structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before);
|
||||||
|
}
|
||||||
|
function hidden(game:Game, viewer:string) {
|
||||||
|
const view=viewGame(game,viewer);
|
||||||
|
assert.equal(view.stormRevealed,null); assert.equal('stormDials' in view,false); return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('storm dial predicates parse and compare legal native submissions without changing old exact claims', () => {
|
||||||
|
const event=source(1,'atreides');
|
||||||
|
const exact={kind:'stormDial' as const,source:event,value:7};
|
||||||
|
assert.deepEqual(parseStormDialClaim(exact),exact);
|
||||||
|
for (const [compare,yes,no] of [['eq',7,6],['gte',7,6],['lte',7,8]] as const) {
|
||||||
|
const claim={...exact,compare};
|
||||||
|
assert.equal(parseStormDialClaim(claim).compare,compare);
|
||||||
|
assert.equal(stormDialMatches(claim,yes),true); assert.equal(stormDialMatches(claim,no),false);
|
||||||
|
}
|
||||||
|
assert.equal(stormDialMatches(exact,7),true); assert.equal(stormDialMatches(exact,8),false);
|
||||||
|
for (const invalid of [{...exact,compare:'exact'}, {...exact,compare:'GTE'}, {...exact,compare:null},
|
||||||
|
{...exact,compare:'gte',secret:true}, {...exact,compare:'lte',value:21}, {...exact,compare:'eq',value:-1}])
|
||||||
|
assert.throws(() => parseStormDialClaim(invalid));
|
||||||
|
const later={kind:'stormDial' as const,source:source(2,'atreides'),value:3,compare:'lte' as const};
|
||||||
|
assert.equal(stormDialMatches(later,1),true); assert.equal(stormDialMatches(later,3),true);
|
||||||
|
assert.throws(() => stormDialMatches(later,0)); assert.throws(() => stormDialMatches(later,4));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('signed thresholds use the exact native range and intersect, including same-boundary equalities', () => {
|
||||||
|
const event=source(1,'atreides');
|
||||||
|
const signed=(compare:Compare,value:number,answer:boolean) =>
|
||||||
|
({kind:'stormDial' as const,source:event,compare,value,answer});
|
||||||
|
const choices=(...entries:TurnActionBranchLeaf[]) => turnActionStormDialChoices(entries,event);
|
||||||
|
assert.deepEqual(choices(signed('gte',5,true),signed('lte',9,true)),[5,6,7,8,9]);
|
||||||
|
assert.deepEqual(choices(signed('gte',7,true),signed('lte',7,true)),[7]);
|
||||||
|
assert.deepEqual(choices(signed('gte',7,true),signed('lte',7,false)),[8,9,10,11,12,13,14,15,16,17,18,19,20]);
|
||||||
|
assert.deepEqual(choices(signed('gte',5,true),signed('lte',4,true)),[]);
|
||||||
|
assert.deepEqual(choices(signed('gte',0,false)),[]);
|
||||||
|
assert.deepEqual(choices(signed('lte',20,false)),[]);
|
||||||
|
const next=source(2,'atreides');
|
||||||
|
const later=(compare:Compare,value:number,answer:boolean):TurnActionBranchLeaf =>
|
||||||
|
({kind:'stormDial',source:next,compare,value,answer});
|
||||||
|
assert.deepEqual(turnActionStormDialChoices([later('gte',2,true),later('lte',2,true)],next),[2]);
|
||||||
|
assert.deepEqual(turnActionStormDialChoices([later('gte',1,false)],next),[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [compare,answer,allowed,forbidden] of [
|
||||||
|
['eq','yes',7,6],['eq','no',6,7],
|
||||||
|
['gte','yes',7,6],['gte','no',6,7],
|
||||||
|
['lte','yes',7,8],['lte','no',8,7],
|
||||||
|
] as const) void test(`native Basic ${answer} to dial ${compare} 7 constrains the actual sealed choice`, () => {
|
||||||
|
const f=fixture(), asked=ask(declare(f),f,leaf(f.source,7,compare));
|
||||||
|
assert.ok(viewGame(asked,f.target).truthTurnActionAnswers?.includes(answer));
|
||||||
|
let game=act(asked,f.target,{type:'truthAnswer',answer});
|
||||||
|
assert.equal(game.truthtrance,null); assert.equal(game.stormPending,null);
|
||||||
|
reject(game,f.target,{type:'stormDial',amount:forbidden});
|
||||||
|
const guide=hidden(game,f.target).turnActionGuide; assert.ok(guide);
|
||||||
|
assert.equal(guide.action.type,'stormDial');
|
||||||
|
const amount=Number(guide.action.amount);
|
||||||
|
assert.equal(stormDialMatches({...leaf(f.source,7,compare).action},amount),answer === 'yes');
|
||||||
|
game=act(game,f.target,{type:'stormDial',amount:allowed});
|
||||||
|
assert.equal(game.stormDials[f.target],allowed); assert.equal(game.stormPending,null);
|
||||||
|
const observer=hidden(game,f.asker); assert.ok(observer.stormSubmitted.includes(f.target));
|
||||||
|
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
|
||||||
|
game=act(game,f.asker,{type:'stormDial',amount:3});
|
||||||
|
assert.equal(game.stormPending,allowed+3);
|
||||||
|
assert.deepEqual(viewGame(game,f.asker).stormRevealed,{[f.target]:allowed,[f.asker]:3});
|
||||||
|
assert.deepEqual(truthMovementInventory(game),f.base.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [firstValue,secondValue,legal,rejectedLow,rejectedHigh] of [[5,9,7,4,10],[7,7,7,6,8]] as const)
|
||||||
|
void test(`two original Truths intersect dial >=${firstValue} and <=${secondValue} without conflating comparison types`, () => {
|
||||||
|
const f=fixture(), queued=declareTwo(f);
|
||||||
|
let game=ask(queued.game,f,leaf(f.source,firstValue,'gte'));
|
||||||
|
game=act(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||||
|
assert.equal(game.truthtrance?.stage,'ask');
|
||||||
|
game=ask(game,f,leaf(f.source,secondValue,'lte'));
|
||||||
|
assert.ok(viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
|
||||||
|
game=act(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||||
|
reject(game,f.target,{type:'stormDial',amount:rejectedLow});
|
||||||
|
reject(game,f.target,{type:'stormDial',amount:rejectedHigh});
|
||||||
|
const guide=viewGame(game,f.target).turnActionGuide; assert.ok(guide);
|
||||||
|
assert.ok(Number(guide.action.amount)>=firstValue && Number(guide.action.amount)<=secondValue);
|
||||||
|
game=act(game,f.target,{type:'stormDial',amount:legal});
|
||||||
|
assert.equal(game.stormDials[f.target],legal);
|
||||||
|
assert.ok(game.turnActionPromises?.filter(row => row.player === f.target).every(row => row.fulfilled));
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.truth || card.id === queued.second).length,2);
|
||||||
|
assert.deepEqual(truthMovementInventory(game),f.base.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('incompatible second threshold Yes is withheld and the earlier accepted answer remains binding', () => {
|
||||||
|
const f=fixture(), queued=declareTwo(f);
|
||||||
|
let game=ask(queued.game,f,leaf(f.source,5,'gte'));
|
||||||
|
game=act(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||||
|
const prior=structuredClone(game.turnActionPromises![0]);
|
||||||
|
game=ask(game,f,leaf(f.source,4,'lte'));
|
||||||
|
const choices=viewGame(game,f.target).truthTurnActionAnswers;
|
||||||
|
assert.ok(choices?.includes('no')); assert.ok(!choices?.includes('yes'));
|
||||||
|
reject(game,f.target,{type:'truthAnswer',answer:'yes'});
|
||||||
|
game=act(game,f.target,{type:'truthAnswer',answer:'no'});
|
||||||
|
assert.deepEqual(game.turnActionPromises![0],prior);
|
||||||
|
reject(game,f.target,{type:'stormDial',amount:4});
|
||||||
|
game=act(game,f.target,{type:'stormDial',amount:5});
|
||||||
|
assert.ok(game.turnActionPromises?.every(row => row.fulfilled));
|
||||||
|
assert.deepEqual(truthMovementInventory(game),f.base.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('later Basic storm dials enforce the native 1–3 range and threshold answers', () => {
|
||||||
|
const first=fixture();
|
||||||
|
let game=act(first.base.game,first.target,{type:'endMovement'});
|
||||||
|
game=advanceTruthMovement(game,state => state.turn === 2 && state.phase === 0);
|
||||||
|
const later={...first,game,source:source(2,first.target)};
|
||||||
|
game=ask(declare(later),later,leaf(later.source,2,'gte'));
|
||||||
|
assert.ok(viewGame(game,later.target).truthTurnActionAnswers?.includes('no'));
|
||||||
|
game=act(game,later.target,{type:'truthAnswer',answer:'no'});
|
||||||
|
reject(game,later.target,{type:'stormDial',amount:0});
|
||||||
|
reject(game,later.target,{type:'stormDial',amount:2});
|
||||||
|
reject(game,later.target,{type:'stormDial',amount:4});
|
||||||
|
game=act(game,later.target,{type:'stormDial',amount:1});
|
||||||
|
assert.equal(game.stormDials[later.target],1);
|
||||||
|
assert.ok(game.turnActionPromises?.some(row => row.player === later.target && row.fulfilled));
|
||||||
|
assert.deepEqual(truthMovementInventory(game),later.base.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [kind,answer,legal,forbidden] of [
|
||||||
|
['and','yes',7,4], ['or','yes',2,7], ['or','no',7,2],
|
||||||
|
] as const) void test(`one nested ${kind.toUpperCase()} question combines comparator-bearing dial leaves under ${answer}`, () => {
|
||||||
|
const f=fixture();
|
||||||
|
const mixed=kind === 'and' ? {kind:'and',terms:[leaf(f.source,5,'gte'),leaf(f.source,9,'lte')]} :
|
||||||
|
{kind:'or',terms:[leaf(f.source,3,'lte'),leaf(f.source,10,'gte')]};
|
||||||
|
const asked=ask(declare(f),f,mixed);
|
||||||
|
assert.ok(viewGame(asked,f.target).truthTurnActionAnswers?.includes(answer));
|
||||||
|
let game=act(asked,f.target,{type:'truthAnswer',answer});
|
||||||
|
reject(game,f.target,{type:'stormDial',amount:forbidden});
|
||||||
|
const guide=viewGame(game,f.target).turnActionGuide; assert.ok(guide);
|
||||||
|
const quoted=Number(guide.action.amount);
|
||||||
|
const whole=kind === 'and' ? quoted >= 5 && quoted <= 9 : quoted <= 3 || quoted >= 10;
|
||||||
|
assert.equal(whole,answer === 'yes');
|
||||||
|
game=act(game,f.target,{type:'stormDial',amount:legal});
|
||||||
|
assert.equal(game.stormDials[f.target],legal); assert.equal(game.stormPending,null);
|
||||||
|
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
|
||||||
|
assert.deepEqual(truthMovementInventory(game),f.base.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a JSON-restored legacy dial claim without compare still requires the exact native value', () => {
|
||||||
|
const f=fixture(), asked=ask(declare(f),f,leaf(f.source,7));
|
||||||
|
let game=act(asked,f.target,{type:'truthAnswer',answer:'yes'});
|
||||||
|
const saved=JSON.parse(JSON.stringify(game)) as Game;
|
||||||
|
const question=saved.truthHistory?.at(-1)?.question;
|
||||||
|
assert.ok(question); assert.equal(question.kind,'turnActions');
|
||||||
|
if (question.kind !== 'turnActions' || question.mixed.kind !== 'action' || question.mixed.action.kind !== 'stormDial')
|
||||||
|
throw Error('Retain the original legacy storm-dial action in history');
|
||||||
|
assert.equal(Object.hasOwn(question.mixed.action,'compare'),false);
|
||||||
|
reject(saved,f.target,{type:'stormDial',amount:6});
|
||||||
|
reject(saved,f.target,{type:'stormDial',amount:8});
|
||||||
|
const guide=viewGame(saved,f.target).turnActionGuide; assert.ok(guide);
|
||||||
|
assert.equal(guide.action.amount,7);
|
||||||
|
game=act(saved,f.target,guide.action);
|
||||||
|
assert.equal(game.stormDials[f.target],7); assert.equal(game.stormPending,null);
|
||||||
|
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
|
||||||
|
game=act(game,f.asker,{type:'stormDial',amount:3});
|
||||||
|
assert.deepEqual(viewGame(game,f.asker).stormRevealed,{[f.target]:7,[f.asker]:3});
|
||||||
|
assert.deepEqual(truthMovementInventory(game),f.base.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const compare of ['eq','gte','lte'] as const) void test(
|
||||||
|
`all four minimal bot profiles honor a native ${compare} storm-dial answer`, () => {
|
||||||
|
const f=fixture(), asked=ask(declare(f),f,leaf(f.source,7,compare));
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const answerView=viewGame(asked,f.target);
|
||||||
|
answerView.players.find(row => row.id === f.target)!.bot=difficulty;
|
||||||
|
const answer=botActions(answerView)[0]; assert.ok(answer,difficulty);
|
||||||
|
assert.equal(answer.type,'truthAnswer'); assert.ok(['yes','no'].includes(String(answer.answer)));
|
||||||
|
let game=act(structuredClone(asked),f.target,answer);
|
||||||
|
const dialView=hidden(game,f.target);
|
||||||
|
dialView.players.find(row => row.id === f.target)!.bot=difficulty;
|
||||||
|
const guide=dialView.turnActionGuide; assert.ok(guide,difficulty);
|
||||||
|
const chosen=botActions(dialView)[0]; assert.deepEqual(chosen,guide.action);
|
||||||
|
assert.equal(stormDialMatches(leaf(f.source,7,compare).action,chosen.amount),answer.answer === 'yes');
|
||||||
|
game=act(game,f.target,chosen);
|
||||||
|
assert.equal(game.stormDials[f.target],chosen.amount);
|
||||||
|
assert.equal(game.stormPending,null);
|
||||||
|
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
|
||||||
|
assert.deepEqual(truthMovementInventory(game),f.base.originalCards);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -39,7 +39,13 @@ void test('editor selects the exact original Storm dial and enforces native turn
|
|||||||
assert.notEqual(turnActionInputError(dial(turn, max + 1), game, 'p'), null);
|
assert.notEqual(turnActionInputError(dial(turn, max + 1), game, 'p'), null);
|
||||||
assert.notEqual(turnActionInputError(dial(turn, min - 1), game, 'p'), null);
|
assert.notEqual(turnActionInputError(dial(turn, min - 1), game, 'p'), null);
|
||||||
const html = renderToStaticMarkup(createElement(TurnActionFields, { game, value, target: 'p', disabled: false, onChange() {} }));
|
const html = renderToStaticMarkup(createElement(TurnActionFields, { game, value, target: 'p', disabled: false, onChange() {} }));
|
||||||
assert.match(html, /Exact Storm dial value/); assert.match(html, /remain sealed/);
|
assert.match(html, /Storm dial comparison/); assert.match(html, /remain sealed/);
|
||||||
|
const dialTree = TurnActionFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
|
||||||
|
const comparator = elements(dialTree).find(element => element.props.id === 'truth-turn-action-1-storm-dial-compare');
|
||||||
|
assert.ok(comparator);
|
||||||
|
(comparator.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'gte' } });
|
||||||
|
assert.deepEqual(value, { kind: 'action', action: { kind: 'stormDial', source: JSON.stringify(['stormDial', turn, 'p']), value: min, compare: 'gte' } });
|
||||||
|
assert.equal(turnActionInputError(value, game, 'p'), null);
|
||||||
game.stormSubmitted = ['p'];
|
game.stormSubmitted = ['p'];
|
||||||
assert.notEqual(turnActionInputError(value, game, 'p'), null);
|
assert.notEqual(turnActionInputError(value, game, 'p'), null);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user