diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx index b9a0c9ed..215f40d2 100644 --- a/components/turn-action-fields.tsx +++ b/components/turn-action-fields.tsx @@ -75,7 +75,7 @@ const config: MixedActionConfig = { 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. 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. 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. @@ -200,7 +200,7 @@ const config: MixedActionConfig = { if (kind === 'ship' || kind === 'move') onChange({ kind, territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum }); }}> - + @@ -218,7 +218,18 @@ const config: MixedActionConfig = { const opportunity = nativeStormDialOpportunity(game, target, value.source); return <>

The respondent’s original current Storm dial counts once. Other dials remain sealed until reveal.

- + + + = (source.turn === 1 ? 0 : 1) && value.value <= (source.turn === 1 ? 20 : 3), '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. */ @@ -60,7 +69,8 @@ export function stormDialMatches(claim: StormDialClaim, actual: unknown): boolea check(typeof actual === 'number' && Number.isSafeInteger(actual) && actual >= (source.turn === 1 ? 0 : 1) && actual <= (source.turn === 1 ? 20 : 3), '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. diff --git a/game/truthtrance.ts b/game/truthtrance.ts index d6c16796..b7f46d29 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -795,7 +795,7 @@ export function truthQuestionText( }; if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, 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.effect === 'karamaBattleCancel') { const response = parseKaramaBattleCancelSource(action.source); diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts index e9b13a96..3d2fdc64 100644 --- a/game/turn-action-promises.ts +++ b/game/turn-action-promises.ts @@ -225,7 +225,8 @@ function storedFact(value: unknown, depth = 0): TruthFact { } 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 === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]); if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]); @@ -576,7 +577,8 @@ export function liveTurnActionPromises(promises: re function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean { 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 === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle && 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); const min = decoded.turn === 1 ? 0 : 1, max = decoded.turn === 1 ? 20 : 3; 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. */ @@ -622,8 +625,6 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf if (leaf.kind === 'stormDial') { const prior = merged.find(other => sameAction(other, leaf)); 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 (!turnActionStormDialChoices(merged, leaf.source).length) return null; continue; diff --git a/tests/storm-dial-question.test.ts b/tests/storm-dial-question.test.ts index fe986c1e..659e6434 100644 --- a/tests/storm-dial-question.test.ts +++ b/tests/storm-dial-question.test.ts @@ -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)); }); +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', () => { for (const game of [ { ...first, stormDialers: ['a', 'a'] }, diff --git a/tests/truth-storm-dial-comparisons-runtime.test.ts b/tests/truth-storm-dial-comparisons-runtime.test.ts new file mode 100644 index 00000000..dc137fbf --- /dev/null +++ b/tests/truth-storm-dial-comparisons-runtime.test.ts @@ -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); + } + }, +); diff --git a/tests/turn-action-storm-dial-controls.test.tsx b/tests/turn-action-storm-dial-controls.test.tsx index 78b2a1bd..b6f3f16b 100644 --- a/tests/turn-action-storm-dial-controls.test.tsx +++ b/tests/turn-action-storm-dial-controls.test.tsx @@ -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, min - 1), game, 'p'), null); 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']; assert.notEqual(turnActionInputError(value, game, 'p'), null); }