188 lines
11 KiB
TypeScript
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);
|
|
});
|