Files
Dune/tests/truth-storm-dial-comparisons-runtime.test.ts

201 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
},
);