Support Truthtrance questions about storm dials
This commit is contained in:
1 parent
56b3aeab68
commit
8006e53580
17 files changed
+823
-14
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in new issue
Block a user