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

188 lines
11 KiB
TypeScript

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);
});