Support Truthtrance questions about storm dials

This commit is contained in:
Quintine committed 2026-10-27 21:08:02 +11:00
1 parent 56b3aeab68
commit 8006e53580
17 files changed
+823 -14

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import { actTruthMovement, createTruthMovementFixture, truthMovementInventory } from './fixture-truth-movement';
export const actTruthStormDial = actTruthMovement;
/** Genuine Basic setup and starting deal from the existing movement fixture.
* Only unopened original cards were reordered for the original deal. This
* source uses its actual post-setup, pre-first-dial state; no wallet, force,
* response, dial, or card was installed or duplicated. */
export function createTruthStormDialFixture(value = 7) {
const base = createTruthMovementFixture();
const game = structuredClone(base.afterSetup);
const { asker, target, truth } = base;
assert.equal(game.status, 'playing'); assert.equal(game.phase, 0); assert.equal(game.turn, 1);
assert.equal(game.advanced, false); assert.equal(game.stormPending, null);
assert.deepEqual(new Set(game.stormDialers), new Set([asker, target]));
assert.deepEqual(game.stormDials, {});
assert.ok(game.players.find(player => player.id === asker)?.hand.some(card => card.id === truth));
assert.deepEqual(truthMovementInventory(game), base.originalCards);
const source = JSON.stringify(['stormDial', game.turn, target]);
const mixed = { kind: 'action' as const, action: { kind: 'stormDial' as const, source, value } };
return { base, game, asker, target, truth, source, value, mixed };
}
export type TruthStormDialFixture = ReturnType<typeof createTruthStormDialFixture>;
export function declareStormDialQuestion(f: TruthStormDialFixture, source: Game = f.game) {
let game = actTruthStormDial(source, f.asker, { type: 'card', card: f.truth });
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
assert.ok(player); game = actTruthStormDial(game, player.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
export function askStormDial(game: Game, f: TruthStormDialFixture, mixed: unknown = f.mixed, target = f.target) {
return actTruthStormDial(game, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} } as Action);
}
export function bindStormDial(f: TruthStormDialFixture, answer: 'yes' | 'no', source: Game = f.game) {
const declared = declareStormDialQuestion(f, source), asked = askStormDial(declared, f);
const game = actTruthStormDial(asked, f.target, { type: 'truthAnswer', answer });
return { declared, asked, game };
}
/** Controlled supplement: move the second original physical Truthtrance from
* the unopened deck into the asker's existing hand. This preserves every card
* identity and the native hand limit; it is not represented as an in-game sale
* or transfer. Native queue, questions, answers and the dial are real actions. */
export function declareTwoStormDialQuestions(f: TruthStormDialFixture, source: Game = f.game) {
let game = structuredClone(source);
const owner = game.players.find(player => player.id === f.asker); assert.ok(owner);
const index = game.deck.findIndex(card => card.effect === 'truthtrance' && card.id !== f.truth);
assert.ok(index >= 0, 'The second physical Truthtrance must remain in the unopened deck');
assert.ok(owner.hand.length < 4, 'The controlled extra card must fit the normal hand limit');
const second = game.deck.splice(index, 1)[0]; owner.hand.push(second);
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
game = actTruthStormDial(game, f.asker, { type: 'card', card: f.truth, cards: [second.id] });
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
assert.ok(player); game = actTruthStormDial(game, player.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, second: second.id };
}
/** Controlled physical location swap for this one card interaction: exchange
* the target's original dealt card with the original unopened Weather card.
* For the foreign-owner branch, move that same acquired Weather to the asker.
* Neither setup adjustment is presented as an in-game transaction; every
* original card remains in exactly one location and both hands stay legal. */
export function sourceWithStormWeather(f: TruthStormDialFixture, holder: 'target' | 'asker') {
const game = structuredClone(f.game);
const target = game.players.find(player => player.id === f.target); assert.ok(target?.hand.length);
const asker = game.players.find(player => player.id === f.asker); assert.ok(asker);
const index = game.deck.findIndex(card => card.effect === 'weather'); assert.ok(index >= 0);
const weather = game.deck[index];
[target.hand[0], game.deck[index]] = [weather, target.hand[0]];
if (holder === 'asker') {
assert.ok(asker.hand.length < 4);
asker.hand.push(target.hand.splice(0, 1)[0]);
}
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
return { game, weather: weather.id, player: holder === 'target' ? f.target : f.asker };
}
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
nativeStormDialOpportunity, parseStormDialClaim, parseStormDialSource,
stormDialMatches, type StormDialGame,
} from '../game/storm-dial-question';
const first: StormDialGame = {
status: 'playing', advanced: false, phase: 0, turn: 1,
players: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
stormDialers: ['a', 'b'], stormPending: null, stormDials: {},
};
const source = (turn: number, player: string) => JSON.stringify(['stormDial', turn, player]);
const claim = (turn: number, player: string, value: number) =>
({ kind: 'stormDial' as const, source: source(turn, player), value });
void test('public opportunity selects the real Basic unsubmitted dialer and exact turn', () => {
assert.deepEqual(nativeStormDialOpportunity(first, 'a'), {
turn: 1, player: 'a', source: source(1, 'a'), min: 0, max: 20,
});
assert.equal(nativeStormDialOpportunity(first, 'c'), null);
assert.equal(nativeStormDialOpportunity(first, 'a', source(2, 'a')), null);
assert.equal(nativeStormDialOpportunity({ ...first, phase: 1 }, 'a'), null);
assert.equal(nativeStormDialOpportunity({ ...first, status: 'finished' }, 'a'), null);
assert.equal(nativeStormDialOpportunity({ ...first, advanced: true }, 'a'), null);
assert.equal(nativeStormDialOpportunity({ ...first, stormPending: 0 }, 'a'), null);
assert.deepEqual(nativeStormDialOpportunity({ ...first, turn: 2 }, 'a'), {
turn: 2, player: 'a', source: source(2, 'a'), min: 1, max: 3,
});
});
void test('server and public view eligibility test submission without reading sealed values', () => {
const hidden = Object.defineProperty({}, 'a', { enumerable: true, get() { throw Error('read sealed value'); } });
const state = { ...first, stormDials: hidden };
assert.equal(nativeStormDialOpportunity(state, 'a'), null);
assert.ok(nativeStormDialOpportunity(state, 'b'));
const { stormDials: _hidden, ...publicView } = state;
assert.equal(nativeStormDialOpportunity({ ...publicView, stormSubmitted: ['a'] }, 'a'), null);
assert.ok(nativeStormDialOpportunity({ ...publicView, stormSubmitted: ['a'] }, 'b'));
assert.equal(nativeStormDialOpportunity({ ...publicView, stormSubmitted: ['c'] }, 'b'), null);
});
void test('canonical source and claim enforce actual turn-specific ranges and no extra keys', () => {
assert.deepEqual(parseStormDialSource(source(1, 'a')), { turn: 1, player: 'a' });
for (const invalid of [' ["stormDial",1,"a"]', source(0, 'a'), source(1, ''), '["stormDial",1,"a",0]', 'not-json'])
assert.equal(parseStormDialSource(invalid), null);
assert.deepEqual(parseStormDialClaim(claim(1, 'a', 0)), claim(1, 'a', 0));
assert.deepEqual(parseStormDialClaim(claim(1, 'a', 20)), claim(1, 'a', 20));
assert.deepEqual(parseStormDialClaim(claim(2, 'a', 3)), claim(2, 'a', 3));
for (const invalid of [claim(1, 'a', -1), claim(1, 'a', 21), claim(2, 'a', 0),
claim(2, 'a', 4), claim(2, 'a', 2.5), { ...claim(1, 'a', 4), reveal: true }])
assert.throws(() => parseStormDialClaim(invalid));
});
void test('match evaluates only an actual legal submission in the named native range', () => {
assert.equal(stormDialMatches(claim(1, 'a', 0), 0), true);
assert.equal(stormDialMatches(claim(1, 'a', 0), 1), false);
assert.equal(stormDialMatches(claim(2, 'a', 3), 3), true);
for (const invalid of [undefined, null, -1, 0, 4, 1.5, '3'])
assert.throws(() => stormDialMatches(claim(2, 'a', 2), invalid));
});
void test('malformed or inconsistent public seat and dialer sets have no opportunity', () => {
for (const game of [
{ ...first, stormDialers: ['a', 'a'] },
{ ...first, stormDialers: ['a', 'unknown'] },
{ ...first, players: [{ id: 'a' }, { id: 'a' }] },
{ ...first, stormDialers: [] },
{ ...first, stormDials: undefined },
{ ...first, stormSubmitted: ['a', 'a'] },
]) assert.equal(nativeStormDialOpportunity(game, 'a'), null);
});
+187
View File
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionGuideButton } from '../components/turn-action-promises';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { advanceTruthMovement, truthMovementInventory } from './fixture-truth-movement';
import {
actTruthStormDial as act, askStormDial as ask, bindStormDial as bind,
createTruthStormDialFixture as fixture, declareStormDialQuestion as declare,
declareTwoStormDialQuestions as declareTwo, sourceWithStormWeather,
} from './fixture-truth-storm-dial';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function privateDials(game: Game, player: string) {
const view = viewGame(game, player);
assert.equal(view.stormRevealed, null);
assert.equal('stormDials' in view, false);
return view;
}
void test('a Yes binds the current original dial and guides the native human and all four bots', () => {
const f = fixture(), asked = ask(declare(f), f);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(game.truthtrance, null); assert.equal(game.stormPending, null);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
const view = privateDials(game, f.target), guide = view.turnActionGuide;
assert.ok(guide); assert.equal(guide.action.type, 'stormDial'); assert.equal(guide.action.amount, f.value);
const sent: Action[] = [];
const props = { game: view, busy: false, act: (action: Action) => sent.push(action) };
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /storm|dial/i);
button.props.onClick(); assert.deepEqual(sent, [guide.action]);
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target);
bot.players.find(player => player.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bot)[0], guide.action);
}
rejected(game, f.target, { type: 'stormDial', amount: f.value + 1 });
game = act(game, f.target, guide.action);
assert.equal(game.stormDials[f.target], f.value); assert.equal(game.stormPending, null);
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
const observer = privateDials(game, f.asker);
assert.ok(observer.stormSubmitted.includes(f.target));
game = act(game, f.asker, { type: 'stormDial', amount: 3 });
assert.equal(game.stormPending, f.value + 3);
assert.deepEqual(viewGame(game, f.asker).stormRevealed, { [f.target]: f.value, [f.asker]: 3 });
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
});
void test('a No forbids only the named value and the other real dialer remains sealed', () => {
const f = fixture();
const otherDialed = act(f.game, f.asker, { type: 'stormDial', amount: 3 });
assert.equal(otherDialed.stormPending, null);
const targetView = privateDials(otherDialed, f.target);
assert.ok(targetView.stormSubmitted.includes(f.asker));
let game = bind(f, 'no', otherDialed).game;
rejected(game, f.target, { type: 'stormDial', amount: f.value });
const guide = viewGame(game, f.target).turnActionGuide;
assert.ok(guide); assert.equal(guide.action.type, 'stormDial'); assert.notEqual(guide.action.amount, f.value);
game = act(game, f.target, guide.action);
assert.equal(game.stormPending, 3 + Number(guide.action.amount));
assert.notEqual(game.stormDials[f.target], f.value);
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
});
void test('stale and ineligible original dial questions are rejected without changing state', () => {
const f = fixture(), declared = declare(f);
const stale = { kind: 'action', action: { kind: 'stormDial', source: JSON.stringify(['stormDial', 0, f.target]), value: f.value } };
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: declared.turn, mixed: stale,
} });
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: declared.turn,
mixed: { kind: 'action', action: { kind: 'stormDial', source: f.source, value: 21 } },
} });
const alreadyDialed = act(f.game, f.target, { type: 'stormDial', amount: 1 });
const late = declare(f, alreadyDialed);
rejected(late, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: late.turn, mixed: f.mixed,
} });
});
void test('two actual Truthtrances bind one dial: Yes A remains compatible with No B but excludes a second conflicting Yes', () => {
const f = fixture(7), queued = declareTwo(f);
let game = ask(queued.game, f);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(game.truthtrance?.stage, 'ask');
const second = { kind: 'action', action: { kind: 'stormDial', source: f.source, value: 4 } };
game = ask(game, f, second);
const answers = viewGame(game, f.target).truthTurnActionAnswers;
assert.ok(answers?.includes('no')); assert.ok(!answers?.includes('yes'));
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
assert.equal(game.truthtrance, null);
assert.equal(game.turnActionPromises?.filter(row => row.player === f.target).length, 2);
rejected(game, f.target, { type: 'stormDial', amount: 4 });
rejected(game, f.target, { type: 'stormDial', amount: 5 });
game = act(game, f.target, { type: 'stormDial', amount: 7 });
assert.equal(game.stormDials[f.target], 7);
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('a genuine later Basic Storm cannot accept a whole No that forbids every native dial value', () => {
const first = fixture();
// The existing original programme uses real first-turn setup, payment and
// movement; finish that actual actor, then use only the native continuation.
let game = act(first.base.game, first.target, { type: 'endMovement' });
game = advanceTruthMovement(game, state => state.turn === 2 && state.phase === 0);
assert.equal(game.advanced, false); assert.equal(game.stormPending, null);
assert.ok(game.stormDialers.includes(first.target));
assert.ok(game.players.find(player => player.id === first.asker)?.hand.some(card => card.id === first.truth));
const later = { ...first, game, source: JSON.stringify(['stormDial', 2, first.target]), value: 1,
mixed: { kind: 'action' as const, action: { kind: 'stormDial' as const,
source: JSON.stringify(['stormDial', 2, first.target]), value: 1 } } };
const clauses = [1, 2, 3].map(value => ({ kind: 'action' as const, action: {
kind: 'stormDial' as const, source: later.source, value,
} }));
game = ask(declare(later), later, { kind: 'or', terms: clauses });
const answers = viewGame(game, later.target).truthTurnActionAnswers;
assert.ok(answers?.includes('yes')); assert.ok(!answers?.includes('no'));
rejected(game, later.target, { type: 'truthAnswer', answer: 'no' });
game = act(game, later.target, { type: 'truthAnswer', answer: 'yes' });
rejected(game, later.target, { type: 'stormDial', amount: 0 });
rejected(game, later.target, { type: 'stormDial', amount: 4 });
const guide = viewGame(game, later.target).turnActionGuide; assert.ok(guide);
assert.ok([1, 2, 3].includes(Number(guide.action.amount)));
game = act(game, later.target, guide.action);
assert.ok([1, 2, 3].includes(game.stormDials[later.target]));
assert.deepEqual(truthMovementInventory(game), later.base.originalCards);
});
void test('a required dial prevents own Weather Control but a different player can supersede and release it', () => {
const f = fixture(7);
const own = sourceWithStormWeather(f, 'target');
let game = bind(f, 'yes', own.game).game;
rejected(game, own.player, { type: 'card', card: own.weather, amount: 2 });
assert.equal(game.stormPending, null);
assert.ok(game.players.find(player => player.id === own.player)?.hand.some(card => card.id === own.weather));
game = act(game, f.target, { type: 'stormDial', amount: 7 });
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
const foreign = sourceWithStormWeather(f, 'asker');
game = bind(f, 'yes', foreign.game).game;
game = act(game, foreign.player, { type: 'card', card: foreign.weather, amount: 2 });
assert.equal(game.stormPending, 2);
assert.equal(game.stormMovementSource?.kind, 'weather');
assert.equal(game.stormDials[f.target], undefined);
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.released);
assert.equal(game.discard.filter(card => card.id === foreign.weather).length, 1);
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
});
void test('foreign Weather leaves an affordable payment alternative active until the actual bribe fulfills it', () => {
const f = fixture(7), foreign = sourceWithStormWeather(f, 'asker');
const mixed = { kind: 'or', terms: [f.mixed, { kind: 'action', action: {
kind: 'payment', recipient: f.asker, minimum: 2,
} }] };
let game = ask(declare(f, foreign.game), f, mixed);
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
const payer = game.players.find(player => player.id === f.target),
recipient = game.players.find(player => player.id === f.asker);
assert.ok(payer && recipient && payer.spice >= 2); assert.notEqual(payer.ally, f.asker);
const beforeSpice = payer.spice, beforeBribes = recipient.bribes;
game = act(game, foreign.player, { type: 'card', card: foreign.weather, amount: 2 });
assert.equal(game.stormPending, 2); assert.equal(game.stormMovementSource?.kind, 'weather');
let promise = game.turnActionPromises?.find(row => row.player === f.target); assert.ok(promise);
assert.equal(promise.released, false); assert.equal(promise.fulfilled, false);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 });
promise = game.turnActionPromises?.find(row => row.player === f.target); assert.ok(promise);
assert.equal(promise.released, false); assert.equal(promise.fulfilled, true);
assert.equal(game.players.find(player => player.id === f.target)?.spice, beforeSpice - 2);
assert.equal(game.players.find(player => player.id === f.asker)?.bribes, beforeBribes + 2);
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { TurnActionGuideButton } from '../components/turn-action-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TurnActionLeaf } from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
const dial = (turn: number, value: number): Expression => ({ kind: 'action', action: {
kind: 'stormDial', source: JSON.stringify(['stormDial', turn, 'p']), value,
} });
/** Controlled public projection: this exercises controls, not native setup. */
function projection(turn: number): GameView {
const game = createGame('STORMDIALCONTROL', newPlayer('p', 'Dialer', 'atreides'));
joinGame(game, newPlayer('a', 'Asker', 'emperor'));
const view = viewGame(game, 'p');
view.status = 'playing'; view.turn = turn; view.phase = 0; view.advanced = false;
view.stormPending = null; view.stormDialers = ['p', 'a']; view.stormSubmitted = [];
return view;
}
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
void test('editor selects the exact original Storm dial and enforces native turn ranges', () => {
for (const [turn, min, max] of [[1, 0, 20], [2, 1, 3]] as const) {
const game = projection(turn); let value: Expression = { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } };
const tree = TurnActionFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
const select = elements(tree).find(element => element.props.id === 'truth-turn-action-1-action'); assert.ok(select);
(select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'stormDial' } });
assert.deepEqual(value, dial(turn, min));
assert.equal(turnActionInputError(value, game, 'p'), null);
assert.equal(turnActionInputError(dial(turn, max), game, 'p'), null);
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/);
game.stormSubmitted = ['p'];
assert.notEqual(turnActionInputError(value, game, 'p'), null);
}
});
void test('native Storm guide dispatches exact current owner action and rejects stale submission', () => {
const view = projection(1), event = 'controlled-dial-promise', mixed = dial(1, 7);
view.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 1, mixed, answer: true, fulfilled: false, released: false }];
const action: Action = { type: 'stormDial', amount: 7, turnActionPromiseEvent: event, turnActionPromisePhase: 0 };
view.turnActionGuide = { event, action };
const sent: Action[] = [], props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /Submit Storm dial 7/);
button.props.onClick(); assert.deepEqual(sent, [action]);
view.stormSubmitted = ['p']; button.props.onClick(); assert.deepEqual(sent, [action]);
});
void test('other player sealing a dial does not expose or invalidate this owner’s current guide', () => {
const view = projection(1), event = 'controlled-dial-promise', mixed = dial(1, 7);
view.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 1, mixed, answer: true, fulfilled: false, released: false }];
const action: Action = { type: 'stormDial', amount: 7, turnActionPromiseEvent: event, turnActionPromisePhase: 0 };
view.turnActionGuide = { event, action };
const sent: Action[] = [], button = TurnActionGuideButton({ game: view, busy: false, act: next => sent.push(next) }); assert.ok(button);
view.stormSubmitted = ['a']; button.props.onClick(); assert.deepEqual(sent, [action]);
assert.equal(view.stormRevealed, null); assert.equal('stormDials' in view, false);
});
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
import {
TurnActionError, closeTurnActionStormDial, finishTurnActionTurn, parseTurnActionLeaf,
recordTurnActionStormDial, turnActionBranches, turnActionStormDialChoices, turnActionValue,
validateTurnActionPromises, type TurnActionBranchLeaf, type TurnActionLeaf, type TurnActionPromise,
} from '../game/turn-action-promises';
const source = (turn = 2, player = 'dialer') => JSON.stringify(['stormDial', turn, player]);
const dial = (value: number, turn = 2): MixedActionExpression<TurnActionLeaf> =>
({ kind: 'action', action: { kind: 'stormDial', source: source(turn), value } });
const group = (kind: 'and' | 'or', ...terms: MixedActionExpression<TurnActionLeaf>[]): MixedActionExpression<TurnActionLeaf> =>
({ kind, terms });
function promise(mixed: MixedActionExpression<TurnActionLeaf>, answer = true, turn = 2): TurnActionPromise {
return { turn, mixed, event: 'promise', player: 'dialer', asker: 'asker', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
shipment: { pending: false, event: null }, progress: [], payments: [], transportClosed: true,
paymentClosed: false, fulfilled: false, released: false,
stormDial: { source: source(turn), closed: false, actual: null } };
}
const branches = (row: TurnActionPromise) => [...turnActionBranches(row)];
void test('storm-dial leaves use a canonical original actor and the turn’s actual numeric bounds', () => {
assert.deepEqual(parseTurnActionLeaf({ kind: 'stormDial', source: source(1), value: 0 }),
{ kind: 'stormDial', source: source(1), value: 0 });
assert.deepEqual(parseTurnActionLeaf({ kind: 'stormDial', source: source(2), value: 3 }),
{ kind: 'stormDial', source: source(2), value: 3 });
for (const claim of [
{ kind: 'stormDial', source: source(2), value: 0 },
{ kind: 'stormDial', source: source(2), value: 4 },
{ kind: 'stormDial', source: source(1), value: 21 },
{ kind: 'stormDial', source: 'not-json', value: 1 },
{ kind: 'stormDial', source: source(1), value: 1, privateValue: 2 },
]) assert.throws(() => parseTurnActionLeaf(claim), TurnActionError);
});
void test('boolean alternatives share exactly one dial and reject contradictory or exhausted ranges', () => {
assert.deepEqual(branches(promise(group('and', dial(1), dial(2)))), []);
assert.deepEqual(branches(promise(group('or', dial(1), dial(2), dial(3)), false)), []);
const row = promise(group('and', dial(1), dial(2)), false);
const alternatives = branches(row);
assert.equal(alternatives.length, 2);
for (const branch of alternatives) assert.ok(turnActionStormDialChoices(branch, source()).length > 0);
const signed: TurnActionBranchLeaf[] = [
{ kind: 'stormDial', source: source(), value: 1, answer: true },
{ kind: 'stormDial', source: source(), value: 2, answer: false },
];
assert.deepEqual(turnActionStormDialChoices(signed, source()), [1]);
assert.deepEqual(turnActionStormDialChoices([...signed, { kind: 'stormDial', source: source(), value: 1, answer: false }], source()), []);
assert.equal(turnActionStormDialChoices([], source(1)).length, 21);
});
void test('the native sealed value settles the whole answer and a No cannot be evaded', () => {
const yes = promise(dial(2));
const [satisfied] = recordTurnActionStormDial([yes], 'dialer', 2, source(), 2);
assert.equal(satisfied.fulfilled, true);
assert.deepEqual(satisfied.stormDial, { source: source(), closed: true, actual: 2 });
assert.equal(turnActionValue(satisfied), true);
const no = promise(dial(2), false);
assert.throws(() => recordTurnActionStormDial([no], 'dialer', 2, source(), 2), TurnActionError);
assert.equal(no.stormDial?.actual, null);
const [satisfiedNo] = recordTurnActionStormDial([no], 'dialer', 2, source(), 1);
assert.equal(satisfiedNo.fulfilled, true);
assert.equal(turnActionValue(satisfiedNo), false);
validateTurnActionPromises([satisfied, satisfiedNo].map((row, index) => ({ ...row, event: `event:${index}` })), ['dialer', 'asker']);
});
void test('a closed missing dial stays absent and an unsatisfied Yes releases only at actual turn expiry', () => {
const [closedYes] = closeTurnActionStormDial([promise(dial(3))], 'dialer', 2, source());
assert.equal(closedYes.stormDial?.actual, null);
assert.equal(closedYes.fulfilled, false);
assert.deepEqual(branches(closedYes), []);
const [expired] = finishTurnActionTurn([closedYes], 'dialer', 2);
assert.equal(expired.released, true);
const [closedNo] = closeTurnActionStormDial([promise(dial(3), false)], 'dialer', 2, source());
assert.equal(closedNo.fulfilled, true);
assert.equal(closedNo.stormDial?.actual, null);
});
void test('stored stages bind original leaves to the respondent and preserve legacy promises', () => {
const original = promise(dial(2));
validateTurnActionPromises([JSON.parse(JSON.stringify(original))], ['dialer', 'asker']);
const wrong = { ...original, stormDial: { source: source(2, 'asker'), closed: false, actual: null } };
assert.throws(() => validateTurnActionPromises([wrong], ['dialer', 'asker']), TurnActionError);
assert.throws(() => validateTurnActionPromises([{ ...original, stormDial: undefined }], ['dialer', 'asker']), TurnActionError);
assert.throws(() => recordTurnActionStormDial([original], 'asker', 2, source(), 2), TurnActionError);
const legacy = promise({ kind: 'action', action: { kind: 'payment', recipient: 'other', minimum: 1 } });
delete legacy.stormDial;
validateTurnActionPromises([legacy], ['dialer', 'asker', 'other']);
});