233 lines
13 KiB
TypeScript
233 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import type { TruthFact } from '../game/truthtrance';
|
|
import {
|
|
createTruthFactExpressionFixture as fixture,
|
|
actTruthFactExpression as act,
|
|
truthFactExpressionClean as clean,
|
|
truthFactExpressionPlayer as player,
|
|
truthFactExpressionInventory as inventory,
|
|
truthFactExpressionFacts,
|
|
truthFactExpressionScenario as scenario,
|
|
truthFactExpressionNestedScenario as nestedScenario,
|
|
declareTruthFactExpression as declare,
|
|
passTruthFactExpressionPriority as passPriority,
|
|
askTruthFactExpression as ask,
|
|
answerTruthFactExpression as answer,
|
|
finishTruthFactExpression as finish,
|
|
type TruthFactExpressionFixture,
|
|
} from './fixture-truth-fact-expression';
|
|
|
|
function reject(game: Game, actor: string, action: Action, message?: RegExp) {
|
|
const before = structuredClone(game);
|
|
const run = () => applyAction(game, actor, action);
|
|
if (message) assert.throws(run, message);
|
|
else assert.throws(run);
|
|
assert.deepEqual(game, before);
|
|
}
|
|
|
|
/** Only the actual interrupted Storm controls, not a recovery snapshot. */
|
|
function stormControls(game: Game) {
|
|
return {
|
|
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
|
|
order: game.order, ready: game.ready, storm: game.storm,
|
|
stormPending: game.stormPending, stormDials: game.stormDials, stormDialers: game.stormDialers,
|
|
stormMovementSource: game.stormMovementSource,
|
|
response: game.response ?? null, decision: game.decision ?? null,
|
|
phaseOpening: game.phaseOpening ?? null, pendingKarama: game.pendingKarama ?? null,
|
|
};
|
|
}
|
|
|
|
function aggregateOnly(game: Game, f: TruthFactExpressionFixture, expected: 'yes' | 'no') {
|
|
assert.equal(game.truthtrance?.stage, 'answer');
|
|
assert.equal(viewGame(game, f.target).truthAnswer, expected);
|
|
for (const other of game.players.filter(candidate => candidate.id !== f.target))
|
|
assert.equal(viewGame(game, other.id).truthAnswer, null);
|
|
}
|
|
|
|
function completed(game: Game, f: TruthFactExpressionFixture, fact: TruthFact, expected: 'yes' | 'no') {
|
|
assert.ok(clean(game)); assert.equal(game.truthtrance, null);
|
|
assert.deepEqual(stormControls(game), stormControls(f.source));
|
|
assert.deepEqual(player(game, f.target).hand, player(f.source, f.target).hand);
|
|
assert.equal(player(game, f.target).spice, 10); assert.equal(player(game, f.target).reserves, 20);
|
|
assert.equal(game.discard.length, f.source.discard.length + 1);
|
|
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
|
assert.equal(player(game, f.asker).hand.some(card => card.id === f.card), false);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
assert.equal(game.truthHistory!.length, (f.source.truthHistory?.length ?? 0) + 1);
|
|
assert.deepEqual(game.truthHistory!.at(-1), {
|
|
turn: 1, phase: 0, asker: f.asker,
|
|
question: { kind: 'fact', target: f.target, fact }, answer: expected,
|
|
});
|
|
}
|
|
|
|
void test('compound facts begin with original Basic cards and native Storm dials plus a real nonempty Ready list', () => {
|
|
const f = fixture();
|
|
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
|
assert.deepEqual(f.game.players.map(candidate => candidate.faction), ['atreides', 'emperor', 'harkonnen', 'guild']);
|
|
assert.equal(f.sourceActionCount, f.base.sourceActionCount + 3);
|
|
assert.equal(f.actions.length, f.sourceActionCount);
|
|
assert.deepEqual(f.stormSteps, [
|
|
...f.game.stormDialers.map(actor => ({ actor, action: { type: 'stormDial', amount: 0 } })),
|
|
{ actor: f.target, action: { type: 'ready' } },
|
|
]);
|
|
assert.equal(f.beforeStormControls.stormPending, null); assert.deepEqual(f.beforeStormControls.ready, []);
|
|
assert.deepEqual(f.game.ready, [f.target]); assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 1);
|
|
assert.equal(f.game.response ?? null, null); assert.equal(f.game.truthtrance ?? null, null);
|
|
assert.equal(f.game.truthHistory?.length ?? 0, 0);
|
|
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), [f.card]);
|
|
assert.deepEqual(player(f.game, f.target).hand.map(card => card.name), ['Baliset']);
|
|
assert.ok(player(f.game, f.counterHolder).hand.some(card => card.id === f.karama));
|
|
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
|
|
assert.deepEqual(f.fact, { kind: 'and', terms: [
|
|
{ kind: 'spice', compare: 'gte', value: 10 },
|
|
{ kind: 'handCount', name: 'Baliset', compare: 'gte', value: 1 },
|
|
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 0 },
|
|
] });
|
|
});
|
|
|
|
void test('original three through eight clause AND and OR questions enforce both truthful aggregate answers', () => {
|
|
const f = fixture();
|
|
for (const kind of ['and', 'or'] as const)
|
|
for (const count of [3, 4, 5, 6, 7, 8])
|
|
for (const expected of ['yes', 'no'] as const) {
|
|
const selected = scenario(kind, count, expected), asked = ask(f, selected.fact);
|
|
assert.equal(asked.actions.length, 5, 'One declaration, three other-seat priority passes and one question');
|
|
assert.deepEqual(stormControls(asked.game), stormControls(f.source));
|
|
aggregateOnly(asked.game, f, expected);
|
|
reject(asked.game, f.target, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' }, /truthfully/);
|
|
reject(asked.game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /truthfully/);
|
|
const done = answer(asked.game, expected);
|
|
completed(done, f, selected.fact, expected);
|
|
}
|
|
});
|
|
|
|
void test('mixed nested AND inside OR and OR inside AND retain their public question and target-only aggregate', () => {
|
|
const f = fixture();
|
|
for (const kind of ['and', 'or'] as const)
|
|
for (const expected of ['yes', 'no'] as const) {
|
|
const selected = nestedScenario(kind, expected), asked = ask(f, selected.fact);
|
|
assert.deepEqual(asked.game.truthtrance!.question, { kind: 'fact', target: f.target, fact: selected.fact });
|
|
aggregateOnly(asked.game, f, expected);
|
|
const done = answer(asked.game, expected);
|
|
completed(done, f, selected.fact, expected);
|
|
}
|
|
});
|
|
|
|
void test('the original holder waits for real priority and one answer discards once before the same Storm Ready control resumes', () => {
|
|
const f = fixture(), declared = declare(f);
|
|
assert.deepEqual(declared.game.truthtrance!.queue, [{ player: f.asker, card: f.card }]);
|
|
assert.deepEqual(declared.game.truthtrance!.passed, [f.asker]);
|
|
assert.deepEqual(stormControls(declared.game), stormControls(f.source));
|
|
reject(declared.game, f.asker, f.askStep.action, /priority/);
|
|
let game = passPriority(declared.game, declared.actions);
|
|
assert.equal(declared.actions.length, 4); assert.equal(game.truthtrance!.stage, 'ask');
|
|
reject(game, f.target, f.askStep.action, /holder/);
|
|
reject(game, f.asker, { type: 'truthPass' });
|
|
game = act(game, f.asker, f.askStep.action, declared.actions);
|
|
aggregateOnly(game, f, 'yes');
|
|
reject(game, f.asker, f.answerStep.action, /questioned player/);
|
|
reject(game, f.guild, f.answerStep.action, /questioned player/);
|
|
reject(game, f.target, { type: 'truthAnswer', answer: 'no' }, /truthfully/);
|
|
reject(game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /truthfully/);
|
|
reject(game, f.asker, f.continuationStep.action);
|
|
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
|
|
game = answer(game, 'yes', declared.actions);
|
|
assert.equal(declared.actions.length, 6);
|
|
completed(game, f, f.fact, 'yes');
|
|
reject(game, f.target, f.answerStep.action);
|
|
reject(game, f.asker, f.askStep.action);
|
|
const continued = act(game, f.continuationStep.actor, f.continuationStep.action, declared.actions);
|
|
assert.equal(declared.actions.length, 7);
|
|
assert.equal(continued.phase, 0); assert.equal(continued.turn, 1);
|
|
assert.deepEqual(continued.ready, [f.target, f.asker]);
|
|
assert.equal(continued.response ?? null, null); assert.ok(clean(continued));
|
|
assert.equal(continued.discard.filter(card => card.id === f.card).length, 1);
|
|
assert.equal(continued.truthHistory!.length, 1);
|
|
assert.deepEqual(inventory(continued), f.originalCards);
|
|
});
|
|
|
|
void test('complete server validation rejects a bad third clause even when the first clause determines the aggregate', () => {
|
|
const f = fixture(), declared = declare(f), game = passPriority(declared.game);
|
|
const { trueFacts, falseFacts } = truthFactExpressionFacts();
|
|
const invalid = [
|
|
{ kind: 'or', terms: [trueFacts[0], falseFacts[0], { kind: 'spice', compare: 'gte', value: -1 }] },
|
|
{ kind: 'and', terms: [falseFacts[0], trueFacts[0],
|
|
{ kind: 'handCount', name: 'Not an original card', compare: 'eq', value: 0 }] },
|
|
{ kind: 'or', terms: [trueFacts[0], falseFacts[0],
|
|
{ kind: 'and', terms: [trueFacts[0], { kind: 'handInventory', category: 'unknown', compare: 'eq', value: 0 }] }] },
|
|
];
|
|
for (const fact of invalid)
|
|
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.target, fact } });
|
|
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
|
|
assert.deepEqual(stormControls(game), stormControls(f.source));
|
|
assert.deepEqual(game.discard, f.source.discard); assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('original current facts accept the existing sixteen-node and four-depth boundaries', () => {
|
|
const f = fixture(), { trueFacts } = truthFactExpressionFacts();
|
|
const sixteen: TruthFact = { kind: 'and', terms: [
|
|
{ kind: 'or', terms: trueFacts.slice(0, 6) },
|
|
{ kind: 'and', terms: trueFacts.slice(0, 7) },
|
|
] };
|
|
let depthFour: TruthFact = trueFacts[0];
|
|
for (let depth = 0; depth < 4; depth++) depthFour = { kind: 'and', terms: [trueFacts[1], depthFour] };
|
|
for (const [id, fact] of [['sixteen-nodes', sixteen], ['depth-four', depthFour]] as const) {
|
|
const done = finish(f, { id, fact, answer: 'yes' });
|
|
assert.equal(done.actions.length, 6);
|
|
completed(done.game, f, fact, 'yes');
|
|
}
|
|
});
|
|
|
|
void test('a ninth group child, seventeenth node and fifth depth reject before the original card is consumed', () => {
|
|
const f = fixture(), game = passPriority(declare(f).game);
|
|
const { trueFacts } = truthFactExpressionFacts();
|
|
let depthFive: TruthFact = trueFacts[0];
|
|
for (let depth = 0; depth < 5; depth++) depthFive = { kind: 'and', terms: [trueFacts[1], depthFive] };
|
|
const invalid: [TruthFact, RegExp][] = [
|
|
[{ kind: 'and', terms: [] }, /two to eight/],
|
|
[{ kind: 'or', terms: [trueFacts[0]] }, /two to eight/],
|
|
[{ kind: 'or', terms: [...trueFacts, trueFacts[0]] }, /two to eight/],
|
|
[{ kind: 'and', terms: [
|
|
{ kind: 'or', terms: trueFacts.slice(0, 7) },
|
|
{ kind: 'and', terms: trueFacts.slice(0, 7) },
|
|
] }, /16 clauses/],
|
|
[depthFive, /four levels/],
|
|
];
|
|
for (const [fact, message] of invalid)
|
|
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.target, fact } }, message);
|
|
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
|
|
assert.deepEqual(game.discard, f.source.discard);
|
|
assert.ok(player(game, f.asker).hand.some(card => card.id === f.card));
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('all four minimal profiles pass real priority and answer new compound questions from the private aggregate', () => {
|
|
const f = fixture();
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const declared = declare(f), priorityView = viewGame(declared.game, f.target);
|
|
priorityView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
|
const priorityAction = botActions(priorityView);
|
|
assert.deepEqual(priorityAction, [{ type: 'truthPass' }]);
|
|
const passed = act(declared.game, f.target, priorityAction[0]);
|
|
assert.equal(passed.truthtrance!.passed.includes(f.target), true);
|
|
assert.equal(passPriority(passed).truthtrance!.stage, 'ask');
|
|
for (const selected of [scenario('and', 8, 'yes'), scenario('or', 8, 'no'),
|
|
nestedScenario('and', 'no'), nestedScenario('or', 'yes')]) {
|
|
const asked = ask(f, selected.fact), view = viewGame(asked.game, f.target);
|
|
view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
|
const before = structuredClone(view), actions = botActions(view);
|
|
assert.deepEqual(view, before);
|
|
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: selected.answer }]);
|
|
assert.deepEqual(botActions(viewGame(asked.game, f.asker)), []);
|
|
assert.deepEqual(botActions(viewGame(asked.game, f.guild)), []);
|
|
aggregateOnly(asked.game, f, selected.answer);
|
|
const done = act(asked.game, f.target, actions[0]);
|
|
completed(done, f, selected.fact, selected.answer);
|
|
}
|
|
}
|
|
});
|