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
@@ -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'] },
|
||||
|
||||
@@ -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, 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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user