Add current physical-force questions to Truthtrance
Let players compare normal, elite or total counters in reserves, the Tanks or one live board territory and sector. Reuse compound facts and private answers. Exclude concealed No-Field values and combat-strength modifiers; guard Basic factions without a tracked starred-counter split. Reparse saved fact questions before actions and projection, including nested leaves and dynamic locations. Preserve interrupted decisions, physical card disposal and historical answers after later legal movement. Connect controls, contextual guidance and the capability checklist while leaving full mode, combination and publication gates unchanged. Validation: types, lint, 5122 offline tests and production build passed. Independent review and 21 focused pure, engine, control and authenticated concurrent recovery tests passed. Six complete-game samples finished with 1551 legal actions, 39 JSON restores and no rejected actions. These are continuation checks, not AI strength calibration. Browser room 86WE6LXV exercised genuine setup, compound physical counts, AI response, mobile controls, human private-answer refresh and publication. Two existing Truthtrance Cards retired once each and forces stayed intact. All 1,584 prior rooms, 3,352 seats and auxiliary records remain unchanged; one QA room and human seat were added. No server restart or database reset. Existing screenshot capture failure leaves full visual acceptance open.
This commit is contained in:
1 parent
d1bf33d0bc
commit
7136c8dd19
14 files changed
+1112
-17
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ForceCountFields, forceCountInputError } from '../components/truthtrance-force-count';
|
||||
import type { ForceCountFact, ForceCountPlayer } from '../game/truthtrance-force-count';
|
||||
import type { MobileBoard } from '../game/board';
|
||||
|
||||
const fact: ForceCountFact = { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 };
|
||||
const player: Pick<ForceCountPlayer, 'faction' | 'elites'> = { faction: 'atreides' };
|
||||
function markup(value = fact, game: MobileBoard = {}, target = player) {
|
||||
return renderToStaticMarkup(createElement(ForceCountFields, { value, game, player: target, onChange() {} }));
|
||||
}
|
||||
|
||||
void test('force fact controls name physical pools and comparisons without revealing a hidden marker value', () => {
|
||||
const html = markup();
|
||||
for (const value of ['reserves', 'tanks', 'location', 'total', 'normal', 'elite', 'eq', 'gte', 'lte'])
|
||||
assert.match(html, new RegExp(`value="${value}"`));
|
||||
assert.match(html, /Number of force counters/);
|
||||
assert.match(html, /Each physical counter counts once, including advisors/);
|
||||
assert.match(html, /Concealed No-Field markers and their hidden values do not count/);
|
||||
assert.match(html, /Reserves includes counters on native Homeworlds/);
|
||||
assert.match(html, /min="0"/); assert.match(html, /max="9007199254740991"/);
|
||||
});
|
||||
|
||||
void test('location choices use the room board and the selected territory sectors', () => {
|
||||
const html = markup({ ...fact, zone: { kind: 'location', territory: 'carthag', sector: 11 } });
|
||||
assert.match(html, /Force territory/); assert.match(html, /Carthag/);
|
||||
assert.match(html, /Force sector/); assert.match(html, /value="11" selected/);
|
||||
assert.doesNotMatch(html, /value="hidden_mobile_stronghold"/);
|
||||
assert.doesNotMatch(html, /value="jacurutu"/);
|
||||
assert.ok(forceCountInputError({ ...fact, zone: { kind: 'location', territory: 'carthag', sector: 99 } }, {}, player));
|
||||
});
|
||||
|
||||
void test('an absent special-counter split disables typed comparisons without inventing counts', () => {
|
||||
const basic = { faction: 'emperor' } as const;
|
||||
assert.equal(forceCountInputError(fact, {}, basic), null);
|
||||
assert.ok(forceCountInputError({ ...fact, counter: 'elite' }, {}, basic));
|
||||
const html = markup({ ...fact, counter: 'elite' }, {}, basic);
|
||||
assert.match(html, /value="normal" disabled/); assert.match(html, /value="elite" disabled/);
|
||||
assert.match(html, /role="alert"/); assert.match(html, /does not track this faction/);
|
||||
const split = { faction: 'emperor' as const, elites: { reserves: 3, tanks: 1, forces: {} } };
|
||||
assert.equal(forceCountInputError({ ...fact, counter: 'elite' }, {}, split), null);
|
||||
assert.doesNotMatch(markup({ ...fact, counter: 'elite' }, {}, split), /role="alert"/);
|
||||
});
|
||||
|
||||
void test('invalid numeric input stays visible and blocks the shared form validation', () => {
|
||||
for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const input = { ...fact, value };
|
||||
assert.ok(forceCountInputError(input, {}, player));
|
||||
assert.match(markup(input), /aria-invalid="true"/);
|
||||
assert.match(markup(input), /role="alert"/);
|
||||
}
|
||||
assert.equal(forceCountInputError({ ...fact, value: 0 }, {}, player), null);
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
forceCountCounterError,
|
||||
forceCountFactMatches,
|
||||
forceCountFactText,
|
||||
parseForceCountFact,
|
||||
type ForceCountFact,
|
||||
type ForceCountPlayer,
|
||||
} from '../game/truthtrance-force-count';
|
||||
|
||||
const locations = [
|
||||
{ territory: 'arrakeen', sector: 10 },
|
||||
{ territory: 'imperial_basin', sector: 9 },
|
||||
] as const;
|
||||
|
||||
function fact(
|
||||
zone: ForceCountFact['zone'],
|
||||
counter: ForceCountFact['counter'] = 'total',
|
||||
compare: ForceCountFact['compare'] = 'eq',
|
||||
value = 0,
|
||||
): ForceCountFact {
|
||||
return { kind: 'forceCount', zone, counter, compare, value };
|
||||
}
|
||||
|
||||
void test('parser accepts exact live pools and rejects extra, mutually exclusive, unknown, and unsafe fields immutably', () => {
|
||||
const input = {
|
||||
kind: 'forceCount',
|
||||
zone: { kind: 'location', territory: 'arrakeen', sector: 10 },
|
||||
counter: 'elite',
|
||||
compare: 'gte',
|
||||
value: 2,
|
||||
};
|
||||
const before = structuredClone(input);
|
||||
assert.deepEqual(parseForceCountFact(input, locations), input);
|
||||
assert.deepEqual(input, before);
|
||||
for (const zone of [
|
||||
{ kind: 'reserves' },
|
||||
{ kind: 'tanks' },
|
||||
{ kind: 'location', territory: 'imperial_basin', sector: 9 },
|
||||
])
|
||||
assert.deepEqual(
|
||||
parseForceCountFact({ ...input, zone }, locations).zone,
|
||||
zone,
|
||||
);
|
||||
|
||||
for (const malformed of [
|
||||
{ ...input, privateHint: true },
|
||||
{ ...input, zone: { kind: 'reserves', territory: 'arrakeen' } },
|
||||
{
|
||||
...input,
|
||||
zone: {
|
||||
kind: 'location',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
pool: 'reserves',
|
||||
},
|
||||
},
|
||||
{ ...input, zone: { kind: 'location', territory: 'arrakeen', sector: 9 } },
|
||||
{ ...input, zone: { kind: 'location', territory: 'unknown', sector: 10 } },
|
||||
{
|
||||
...input,
|
||||
zone: { kind: 'location', territory: 'arrakeen', sector: 10.5 },
|
||||
},
|
||||
{ ...input, zone: { kind: 'homeworld', territory: 'caladan', sector: 0 } },
|
||||
])
|
||||
assert.throws(() => parseForceCountFact(malformed, locations));
|
||||
|
||||
for (const counter of ['special', '', 1, null, undefined])
|
||||
assert.throws(() => parseForceCountFact({ ...input, counter }, locations));
|
||||
for (const compare of ['gt', '=', '', 1, null, undefined])
|
||||
assert.throws(() => parseForceCountFact({ ...input, compare }, locations));
|
||||
for (const value of [
|
||||
-1,
|
||||
0.5,
|
||||
NaN,
|
||||
Infinity,
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
'2',
|
||||
null,
|
||||
])
|
||||
assert.throws(() => parseForceCountFact({ ...input, value }, locations));
|
||||
});
|
||||
|
||||
void test('current totals include elites while normal counts subtract the tracked physical subset in every pool', () => {
|
||||
const player: ForceCountPlayer = {
|
||||
faction: 'ixians',
|
||||
reserves: 10,
|
||||
tanks: 4,
|
||||
forces: { 'arrakeen:10': 6 },
|
||||
elites: {
|
||||
reserves: 3,
|
||||
tanks: 1,
|
||||
forces: { 'arrakeen:10': 2 },
|
||||
},
|
||||
};
|
||||
const before = structuredClone(player);
|
||||
for (const [zone, totals] of [
|
||||
[{ kind: 'reserves' }, [10, 7, 3]],
|
||||
[{ kind: 'tanks' }, [4, 3, 1]],
|
||||
[{ kind: 'location', territory: 'arrakeen', sector: 10 }, [6, 4, 2]],
|
||||
[{ kind: 'location', territory: 'imperial_basin', sector: 9 }, [0, 0, 0]],
|
||||
] as const)
|
||||
for (const [index, counter] of (
|
||||
['total', 'normal', 'elite'] as const
|
||||
).entries()) {
|
||||
const value = totals[index];
|
||||
assert.equal(
|
||||
forceCountFactMatches(player, fact(zone, counter, 'eq', value)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
forceCountFactMatches(player, fact(zone, counter, 'gte', value)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
forceCountFactMatches(player, fact(zone, counter, 'lte', value)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
forceCountFactMatches(player, fact(zone, counter, 'gte', value + 1)),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(player, before);
|
||||
});
|
||||
|
||||
void test('ordinary factions need no fabricated elite ledger, while split factions fail closed for denomination questions', () => {
|
||||
const ordinary: ForceCountPlayer = {
|
||||
faction: 'atreides',
|
||||
reserves: 8,
|
||||
tanks: 2,
|
||||
forces: { 'arrakeen:10': 5 },
|
||||
};
|
||||
assert.equal(forceCountCounterError(ordinary, 'normal'), null);
|
||||
assert.equal(
|
||||
forceCountFactMatches(
|
||||
ordinary,
|
||||
fact({ kind: 'reserves' }, 'normal', 'eq', 8),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
forceCountFactMatches(
|
||||
ordinary,
|
||||
fact({ kind: 'reserves' }, 'elite', 'eq', 0),
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const untracked: ForceCountPlayer = { ...ordinary, faction: 'emperor' };
|
||||
assert.equal(forceCountCounterError(untracked, 'total'), null);
|
||||
assert.match(forceCountCounterError(untracked, 'elite')!, /unavailable/);
|
||||
assert.equal(
|
||||
forceCountFactMatches(
|
||||
untracked,
|
||||
fact({ kind: 'reserves' }, 'total', 'eq', 8),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
forceCountFactMatches(
|
||||
untracked,
|
||||
fact({ kind: 'reserves' }, 'normal', 'eq', 8),
|
||||
),
|
||||
/unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('matcher validates only the queried pool and rejects malformed selected totals or elite subsets', () => {
|
||||
const base: ForceCountPlayer = {
|
||||
faction: 'ixians',
|
||||
reserves: 7,
|
||||
tanks: 2,
|
||||
forces: { 'arrakeen:10': 4 },
|
||||
elites: { reserves: 2, tanks: 1, forces: { 'arrakeen:10': 1 } },
|
||||
};
|
||||
const unrelated = {
|
||||
...structuredClone(base),
|
||||
tanks: -3,
|
||||
forces: null,
|
||||
} as unknown as ForceCountPlayer;
|
||||
assert.equal(
|
||||
forceCountFactMatches(
|
||||
unrelated,
|
||||
fact({ kind: 'reserves' }, 'normal', 'eq', 5),
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
for (const reserves of [-1, 1.5, NaN, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.throws(() =>
|
||||
forceCountFactMatches({ ...base, reserves }, fact({ kind: 'reserves' })),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
forceCountFactMatches(
|
||||
{ ...base, elites: { ...base.elites!, reserves: 8 } },
|
||||
fact({ kind: 'reserves' }, 'total', 'eq', 7),
|
||||
),
|
||||
/exceeds/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
forceCountFactMatches(
|
||||
{ ...base, elites: { ...base.elites!, forces: { 'arrakeen:10': 5 } } },
|
||||
fact({ kind: 'location', territory: 'arrakeen', sector: 10 }, 'elite'),
|
||||
),
|
||||
/exceeds/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
forceCountFactMatches(
|
||||
{ ...base, elites: { ...base.elites!, tanks: -1 } },
|
||||
fact({ kind: 'tanks' }, 'total'),
|
||||
),
|
||||
/nonnegative/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('question text uses printed territory display and states current pool, denomination, and comparison', () => {
|
||||
assert.equal(
|
||||
forceCountFactText(fact({ kind: 'reserves' }, 'total', 'eq', 1)),
|
||||
'you currently have exactly 1 physical force in your reserves',
|
||||
);
|
||||
assert.equal(
|
||||
forceCountFactText(fact({ kind: 'tanks' }, 'normal', 'lte', 2)),
|
||||
'you currently have at most 2 normal forces in the Tleilaxu Tanks',
|
||||
);
|
||||
assert.equal(
|
||||
forceCountFactText(
|
||||
fact(
|
||||
{ kind: 'location', territory: 'arrakeen', sector: 10 },
|
||||
'elite',
|
||||
'gte',
|
||||
3,
|
||||
),
|
||||
),
|
||||
'you currently have at least 3 elite forces in Arrakeen, sector 10',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
import { knowledgeGame, knowledgePhysical } from './truthtrance-knowledge-fixture';
|
||||
import { completedIxSkillsGame, assertIxSkillsCustody } from './ix-skills-fixture';
|
||||
import { discoveryFixture, enterDiscoveryCollection } from './fixture-discovery';
|
||||
import { homeworldRevivalFixture, revivalInventory } from './fixture-homeworld-revival';
|
||||
|
||||
type ForceFact = Extract<TruthFact, { kind: 'forceCount' }>;
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
const player = (game: Game, id: string) => game.players.find(candidate => candidate.id === id)!;
|
||||
const act = (game: Game, owner: string, action: Action) => reload(applyAction(reload(game), owner, action));
|
||||
const fact = (zone: ForceFact['zone'], counter: ForceFact['counter'], value: number,
|
||||
compare: ForceFact['compare'] = 'eq'): ForceFact => ({ kind: 'forceCount', zone, counter, compare, value });
|
||||
|
||||
function reject(game: Game, owner: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, owner, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Move an existing physical card into the asker's hand; never replace a deck. */
|
||||
function holdTruth(game: Game, owner: string) {
|
||||
if (player(game, owner).hand.some(card => card.effect === 'truthtrance')) return;
|
||||
const source = [game.deck, ...game.players.map(candidate => candidate.hand)]
|
||||
.find(cards => cards.some(card => card.effect === 'truthtrance'))!;
|
||||
assert.ok(source);
|
||||
const index = source.findIndex(card => card.effect === 'truthtrance');
|
||||
player(game, owner).hand.push(source.splice(index, 1)[0]);
|
||||
}
|
||||
|
||||
function declared(game: Game, owner = game.players[0].id) {
|
||||
const card = player(game, owner).hand.find(candidate => candidate.effect === 'truthtrance');
|
||||
assert.ok(card);
|
||||
game = act(game, owner, { type: 'card', card: card.id });
|
||||
for (let step = 0; game.truthtrance?.stage === 'priority' && step < 20; step++) {
|
||||
const voter = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!;
|
||||
game = act(game, voter.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
|
||||
function ask(game: Game, target: string, question: TruthFact, owner = game.players[0].id) {
|
||||
return act(declared(game, owner), owner, { type: 'truthAsk', question: { kind: 'fact', target, fact: question } });
|
||||
}
|
||||
|
||||
function answer(game: Game, target: string, expected: 'yes' | 'no') {
|
||||
const before = knowledgePhysical(game);
|
||||
const question = structuredClone(game.truthtrance!.question);
|
||||
assert.equal(viewGame(game, target).truthAnswer, expected);
|
||||
for (const other of game.players.filter(candidate => candidate.id !== target)) {
|
||||
assert.equal(viewGame(game, other.id).truthAnswer, null);
|
||||
assert.equal(viewGame(game, other.id).players.find(candidate => candidate.id === target)!.hand, undefined);
|
||||
}
|
||||
reject(game, target, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' });
|
||||
reject(game, target, { type: 'truthAnswer', answer: 'unknown' });
|
||||
game = act(game, target, { type: 'truthAnswer', answer: expected });
|
||||
assert.equal(game.truthtrance, null);
|
||||
assert.deepEqual(game.truthHistory!.at(-1)!.question, question);
|
||||
assert.equal(game.truthHistory!.at(-1)!.answer, expected);
|
||||
assert.deepEqual(knowledgePhysical(game), before);
|
||||
assert.deepEqual(reload(normalizeAutomaticGame(reload(game))), game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Genuine Advanced setup, followed by explicitly conserved typed positions. */
|
||||
function typedGame() {
|
||||
const game = knowledgeGame(true);
|
||||
const fremen = player(game, 'f');
|
||||
Object.assign(fremen, { reserves: 10, tanks: 3, forces: { 'wind_pass:14': 7 },
|
||||
elites: { reserves: 1, tanks: 1, forces: { 'wind_pass:14': 1 }, revived: 0 } });
|
||||
assert.equal(knowledgePhysical(game).forces.find(candidate => candidate.id === 'f')!.total, 20);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Truthtrance counts physical total, normal and elite forces in reserves, Tanks and exact board sectors', () => {
|
||||
const initial = typedGame();
|
||||
const rows: [ForceFact['zone'], number, number][] = [
|
||||
[{ kind: 'reserves' }, 10, 1],
|
||||
[{ kind: 'tanks' }, 3, 1],
|
||||
[{ kind: 'location', territory: 'wind_pass', sector: 14 }, 7, 1],
|
||||
[{ kind: 'location', territory: 'wind_pass', sector: 15 }, 0, 0],
|
||||
];
|
||||
for (const [zone, total, elite] of rows)
|
||||
for (const [counter, count] of [['total', total], ['normal', total - elite], ['elite', elite]] as const) {
|
||||
answer(ask(initial, 'f', fact(zone, counter, count)), 'f', 'yes');
|
||||
answer(ask(initial, 'f', fact(zone, counter, count + 1, 'gte')), 'f', 'no');
|
||||
answer(ask(initial, 'f', fact(zone, counter, Number.MAX_SAFE_INTEGER, 'lte')), 'f', 'yes');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic untracked starred factions allow total counts while ordinary absent elite tracking means zero elites', () => {
|
||||
const initial = knowledgeGame(false);
|
||||
assert.equal(player(initial, 'f').elites, undefined);
|
||||
answer(ask(initial, 'f', fact({ kind: 'reserves' }, 'total', player(initial, 'f').reserves)), 'f', 'yes');
|
||||
const waiting = declared(initial);
|
||||
for (const counter of ['normal', 'elite'] as const)
|
||||
reject(waiting, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'f', fact: fact({ kind: 'tanks' }, counter, 0) } });
|
||||
assert.equal(player(initial, 'b').elites, undefined);
|
||||
answer(ask(initial, 'b', fact({ kind: 'reserves' }, 'normal', player(initial, 'b').reserves)), 'b', 'yes');
|
||||
answer(ask(initial, 'b', fact({ kind: 'tanks' }, 'elite', 0)), 'b', 'yes');
|
||||
const ix = completedIxSkillsGame({ requestedSkill: 'warmaster' });
|
||||
holdTruth(ix, 't');
|
||||
assert.equal(player(ix, 'e').elites, undefined);
|
||||
answer(ask(ix, 'e', fact({ kind: 'reserves' }, 'total', player(ix, 'e').reserves), 't'), 'e', 'yes');
|
||||
const emperorWaiting = declared(ix, 't');
|
||||
for (const counter of ['normal', 'elite'] as const)
|
||||
reject(emperorWaiting, 't', { type: 'truthAsk', question: { kind: 'fact', target: 'e', fact: fact({ kind: 'tanks' }, counter, 0) } });
|
||||
});
|
||||
|
||||
void test('physical force facts include Bene Gesserit advisors without exposing a separate advisor counter', () => {
|
||||
const initial = knowledgeGame(true);
|
||||
Object.assign(player(initial, 'b'), { reserves: 16, tanks: 0, forces: { 'imperial_basin:10': 4 },
|
||||
advisors: { 'imperial_basin:10': {} } });
|
||||
const zone: ForceFact['zone'] = { kind: 'location', territory: 'imperial_basin', sector: 10 };
|
||||
answer(ask(initial, 'b', fact(zone, 'total', 4)), 'b', 'yes');
|
||||
answer(ask(initial, 'b', fact(zone, 'normal', 4)), 'b', 'yes');
|
||||
const waiting = declared(initial);
|
||||
reject(waiting, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'b',
|
||||
fact: { ...fact(zone, 'total', 4), counter: 'advisors' } } });
|
||||
});
|
||||
|
||||
void test('a force fact combined with a private hand fact projects only its aggregate answer to the respondent', () => {
|
||||
const initial = typedGame();
|
||||
const target = player(initial, 'f');
|
||||
const privateName = target.hand[0].name;
|
||||
const combined: TruthFact = { kind: 'or', terms: [
|
||||
fact({ kind: 'reserves' }, 'total', 11), { kind: 'hand', name: privateName },
|
||||
] };
|
||||
const game = ask(initial, 'f', combined);
|
||||
for (const id of ['a', 'b', 'o']) {
|
||||
const view = viewGame(game, id);
|
||||
assert.equal(view.truthAnswer, null);
|
||||
assert.equal(view.players.find(candidate => candidate.id === 'f')!.hand, undefined);
|
||||
assert.deepEqual(view.truthHistory, []);
|
||||
}
|
||||
const done = answer(game, 'f', 'yes');
|
||||
assert.equal(done.truthHistory!.length, 1);
|
||||
assert.deepEqual(done.truthHistory![0].question, { kind: 'fact', target: 'f', fact: combined });
|
||||
});
|
||||
|
||||
void test('invalid force questions reject atomically before consuming Truthtrance or fixing an answer', () => {
|
||||
const waiting = declared(typedGame());
|
||||
const base = fact({ kind: 'reserves' }, 'total', 0);
|
||||
const invalid = [
|
||||
{ ...base, value: -1 }, { ...base, value: 0.5 }, { ...base, value: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...base, value: '2' }, { ...base, compare: 'gt' }, { ...base, counter: 'combatStrength' },
|
||||
{ ...base, zone: { kind: 'homeworld', homeworld: 'salusa_secundus' } },
|
||||
{ ...base, zone: { kind: 'location', territory: 'salusa_secundus', sector: 0 } },
|
||||
{ ...base, zone: { kind: 'location', territory: 'arrakeen', sector: 11 } },
|
||||
{ ...base, zone: { kind: 'location', territory: 'arrakeen', sector: '10' } },
|
||||
{ ...base, zone: { kind: 'location', territory: MOBILE_STRONGHOLD, sector: 0 } },
|
||||
{ ...base, zone: { kind: 'location', territory: 'cistern', sector: 0 } },
|
||||
];
|
||||
for (const question of invalid)
|
||||
reject(waiting, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'f', fact: question } });
|
||||
assert.equal(waiting.truthtrance!.stage, 'ask');
|
||||
assert.equal(waiting.truthtrance!.question, null);
|
||||
assert.equal(waiting.discard.length, 0);
|
||||
});
|
||||
|
||||
void test('all four bot profiles pass priority and answer force facts legally from their private aggregate answer', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const initial = typedGame();
|
||||
const card = player(initial, 'a').hand.find(candidate => candidate.effect === 'truthtrance')!;
|
||||
let game = act(initial, 'a', { type: 'card', card: card.id });
|
||||
const priorityView = viewGame(game, 'f');
|
||||
priorityView.players.find(candidate => candidate.id === 'f')!.bot = difficulty;
|
||||
const actions = botActions(priorityView);
|
||||
assert.deepEqual(actions, [{ type: 'truthPass' }]);
|
||||
game = act(game, 'f', actions[0]);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const voter = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!;
|
||||
game = act(game, voter.id, { type: 'truthPass' });
|
||||
}
|
||||
game = act(game, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'f', fact: fact({ kind: 'tanks' }, 'elite', 1) } });
|
||||
const view = viewGame(game, 'f');
|
||||
view.players.find(candidate => candidate.id === 'f')!.bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const responses = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
assert.deepEqual(responses, [{ type: 'truthAnswer', answer: 'yes' }]);
|
||||
assert.deepEqual(botActions(viewGame(game, 'b')), []);
|
||||
const done = act(game, 'f', responses[0]);
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(game));
|
||||
}
|
||||
});
|
||||
|
||||
void test('historical force answers do not freeze a later legal revival or movement', () => {
|
||||
const revival = typedGame();
|
||||
Object.assign(revival, { phase: 4, active: null, phaseOpening: null, response: null, decision: null, storm: 18 });
|
||||
let game = answer(ask(revival, 'f', fact({ kind: 'tanks' }, 'total', 3)), 'f', 'yes');
|
||||
const history = structuredClone(game.truthHistory);
|
||||
game = act(game, 'f', { type: 'revive', amount: 2, elite: 1 });
|
||||
assert.equal(player(game, 'f').tanks, 1);
|
||||
assert.equal(player(game, 'f').reserves, 12);
|
||||
assert.deepEqual(game.truthHistory, history);
|
||||
|
||||
const movement = knowledgeGame(false);
|
||||
Object.assign(movement, { phase: 5, active: 'o', movementRemaining: ['o', 'b', 'f', 'a'], phaseOpening: null,
|
||||
response: null, decision: null, storm: 18 });
|
||||
Object.assign(player(movement, 'o'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, tanks: 0 });
|
||||
game = answer(ask(movement, 'o', fact({ kind: 'location', territory: 'imperial_basin', sector: 10 }, 'total', 3)), 'o', 'yes');
|
||||
const movementHistory = structuredClone(game.truthHistory);
|
||||
game = act(game, 'o', { type: 'move', forces: { 'imperial_basin:10': 2 }, territory: 'arrakeen', sector: 10 });
|
||||
assert.equal(player(game, 'o').forces['imperial_basin:10'], 1);
|
||||
assert.equal(player(game, 'o').forces['arrakeen:10'], 2);
|
||||
assert.deepEqual(game.truthHistory, movementHistory);
|
||||
});
|
||||
|
||||
void test('the live HMS interior is an exact physical location distinct from its external pointer and cyborg strength', () => {
|
||||
const initial = completedIxSkillsGame({ requestedSkill: 'warmaster' });
|
||||
holdTruth(initial, 'e');
|
||||
assert.equal(initial.mobileStronghold!.location, 'polar_sink:0');
|
||||
assert.equal(player(initial, 'i').forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(player(initial, 'i').elites!.forces[MOBILE_LOCATION], 3);
|
||||
for (const [counter, value] of [['total', 6], ['normal', 3], ['elite', 3]] as const)
|
||||
assertIxSkillsCustody(answer(ask(initial, 'i', fact({ kind: 'location', territory: MOBILE_STRONGHOLD, sector: 0 }, counter, value), 'e'), 'i', 'yes'));
|
||||
answer(ask(initial, 'i', fact({ kind: 'location', territory: 'polar_sink', sector: 0 }, 'total', 0), 'e'), 'i', 'yes');
|
||||
const waiting = declared(initial, 'e');
|
||||
reject(waiting, 'e', { type: 'truthAsk', question: { kind: 'fact', target: 'i',
|
||||
fact: fact({ kind: 'location', territory: MOBILE_STRONGHOLD, sector: 1 }, 'total', 6) } });
|
||||
});
|
||||
|
||||
void test('a Discovery location becomes queryable only after its genuine reveal', () => {
|
||||
let game = discoveryFixture();
|
||||
const token = enterDiscoveryCollection(game, 'cistern');
|
||||
holdTruth(game, 'g');
|
||||
const hidden = declared(game, 'g');
|
||||
reject(hidden, 'g', { type: 'truthAsk', question: { kind: 'fact', target: 'a',
|
||||
fact: fact({ kind: 'location', territory: 'cistern', sector: 0 }, 'total', 0) } });
|
||||
game = act(game, 'a', { type: 'discovery', token: token.id, reveal: false });
|
||||
game = act(game, 'a', { type: 'discovery', token: token.id, reveal: true });
|
||||
assert.equal(game.discoveries!.tokens.find(candidate => candidate.id === token.id)!.revealedTurn, game.turn);
|
||||
answer(ask(game, 'a', fact({ kind: 'location', territory: 'cistern', sector: 0 }, 'total', 0), 'g'), 'a', 'yes');
|
||||
});
|
||||
|
||||
void test('Homeworld reserve facts read the faction aggregate and do not add a separate Homeworld query', () => {
|
||||
const initial = homeworldRevivalFixture({ advanced: true });
|
||||
holdTruth(initial, 'f');
|
||||
const emperor = player(initial, 'e');
|
||||
const total = emperor.reserves;
|
||||
const elite = emperor.elites!.reserves;
|
||||
for (const [counter, value] of [['total', total], ['normal', total - elite], ['elite', elite]] as const)
|
||||
revivalInventory(answer(ask(initial, 'e', fact({ kind: 'reserves' }, counter, value), 'f'), 'e', 'yes'));
|
||||
const waiting = declared(initial, 'f');
|
||||
reject(waiting, 'f', { type: 'truthAsk', question: { kind: 'fact', target: 'e',
|
||||
fact: { kind: 'forceCount', zone: { kind: 'homeworld', homeworld: 'salusa-secundus' }, counter: 'total', compare: 'eq', value: total } } });
|
||||
});
|
||||
|
||||
void test('malformed saved force facts reject actions, private views and automatic recovery without mutation', () => {
|
||||
const game = ask(typedGame(), 'f', fact({ kind: 'reserves' }, 'normal', 9));
|
||||
const original = game.truthtrance!.question;
|
||||
assert.equal(original?.kind, 'fact');
|
||||
const base = fact({ kind: 'reserves' }, 'normal', 9);
|
||||
const invalid: unknown[] = [
|
||||
{ ...base, value: -1 }, { ...base, value: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...base, counter: 'unknown' }, { ...base, extra: 'discarded field' },
|
||||
{ ...base, zone: { kind: 'location', territory: 'arrakeen', sector: 11 } },
|
||||
{ ...base, zone: { kind: 'location', territory: MOBILE_STRONGHOLD, sector: 0 } },
|
||||
{ ...base, zone: { kind: 'location', territory: 'cistern', sector: 0 } },
|
||||
{ ...base, zone: { kind: 'reserves', territory: 'arrakeen' } },
|
||||
{ kind: 'and', terms: [base, { ...base, value: -1 }] },
|
||||
];
|
||||
for (const invalidFact of invalid) {
|
||||
const saved = reload(game);
|
||||
saved.truthtrance!.question = { kind: 'fact', target: 'f', fact: invalidFact as TruthFact };
|
||||
const before = structuredClone(saved);
|
||||
reject(saved, 'f', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.throws(() => viewGame(saved, 'f'));
|
||||
assert.throws(() => normalizeAutomaticGame(saved));
|
||||
assert.deepEqual(saved, before);
|
||||
}
|
||||
const untracked = reload(game);
|
||||
delete player(untracked, 'f').elites;
|
||||
const before = structuredClone(untracked);
|
||||
reject(untracked, 'f', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.throws(() => viewGame(untracked, 'f'));
|
||||
assert.throws(() => normalizeAutomaticGame(untracked));
|
||||
assert.deepEqual(untracked, before);
|
||||
answer(game, 'f', 'yes');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||
|
||||
const clock = { now: () => 29000, sleep: async () => {} };
|
||||
const rows = (db: DatabaseSync) => db.prepare('SELECT state,version FROM rooms').all();
|
||||
|
||||
void test('authenticated force-and-hand fact restores privately and concurrent truthful answers retire one card once', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const made = await f.rooms.createRoom('Force question', 'atreides', false, []);
|
||||
const code = made.view.code;
|
||||
const joined = [await f.rooms.joinRoom(code, 'Force target', 'emperor'), await f.rooms.joinRoom(code, 'Observer', 'guild')];
|
||||
const auths = await Promise.all([made.token!, ...joined.map(p => p.token!)].map(token => f.restart().authenticate(code, token)));
|
||||
const act = async (index: number, action: Action) => {
|
||||
const before = await f.restart().readRoom(code);
|
||||
await f.restart().act(code, auths[index], before.version, action, clock);
|
||||
return f.restart().readRoom(code);
|
||||
};
|
||||
for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' });
|
||||
let game = await act(0, { type: 'start' });
|
||||
for (let i = 0; i < auths.length; i++)
|
||||
game = await act(i, { type: 'traitor', leader: game.players.find(p => p.id === auths[i].playerId)!.traitorChoices[0] });
|
||||
assert.equal(game.status, 'playing');
|
||||
const inventory = sampleInventory(game), seats = f.sqlite.prepare('SELECT * FROM seats').all();
|
||||
const asker = auths[0].playerId, target = auths[1].playerId;
|
||||
const own = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
// Conserved fixture positions and physical cards, only in disposable SQLite.
|
||||
Object.assign(own(game, target), { reserves: 14, tanks: 2, forces: { 'carthag:11': 4 } });
|
||||
const give = (id: string, name: string) => {
|
||||
const groups = [game.deck, ...game.players.map(p => p.hand)];
|
||||
const source = groups.find(cards => cards.some(card => card.name === name))!;
|
||||
const card = source.splice(source.findIndex(card => card.name === name), 1)[0];
|
||||
own(game, id).hand.push(card); return card.id;
|
||||
};
|
||||
const truth = give(asker, 'Truthtrance'); give(target, 'Shield');
|
||||
verifySampleCustody(game, inventory);
|
||||
const oldVersion = game.version; game.version++;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(game), game.version, code, oldVersion).changes, 1);
|
||||
const parent = { phase: game.phase, decision: game.decision, response: game.response, stormDials: game.stormDials };
|
||||
game = await act(0, { type: 'card', card: truth });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const index = auths.findIndex(auth => !game.truthtrance!.passed.includes(auth.playerId));
|
||||
assert.ok(index >= 0); game = await act(index, { type: 'truthPass' });
|
||||
}
|
||||
game = await act(0, { type: 'truthAsk', question: { kind: 'fact', target, fact: {
|
||||
kind: 'and', terms: [
|
||||
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'eq', value: 14 },
|
||||
{ kind: 'hand', name: 'Shield' },
|
||||
],
|
||||
} } });
|
||||
assert.equal((await f.restart().readSeatView(code, auths[1])).truthAnswer, 'yes');
|
||||
for (const index of [0, 2]) {
|
||||
const view = await f.restart().readSeatView(code, auths[index]);
|
||||
assert.equal(view.truthAnswer, null);
|
||||
assert.equal(view.players.find(p => p.id === target)!.hand, undefined);
|
||||
}
|
||||
const persisted = rows(f.sqlite), version = game.version;
|
||||
for (const [index, answer] of [[0, 'yes'], [1, 'no'], [1, 'unknown']] as const) {
|
||||
await assert.rejects(f.restart().act(code, auths[index], version, { type: 'truthAnswer', answer }, clock));
|
||||
assert.deepEqual(rows(f.sqlite), persisted);
|
||||
}
|
||||
let arrivals = 0; let release!: () => void;
|
||||
const barrier = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
|
||||
const results = await Promise.allSettled([
|
||||
f.rooms.act(code, auths[1], version, { type: 'truthAnswer', answer: 'yes' }, clock),
|
||||
f.restart().act(code, auths[1], version, { type: 'truthAnswer', answer: 'yes' }, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2); assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(r => r.status === 'rejected').length, 1);
|
||||
game = await f.restart().readRoom(code);
|
||||
assert.equal(game.version, version + 1); assert.equal(game.truthtrance, null);
|
||||
assert.equal(game.truthHistory?.length, 1); assert.equal(game.truthHistory![0].answer, 'yes');
|
||||
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
||||
assert.deepEqual({ phase: game.phase, decision: game.decision, response: game.response, stormDials: game.stormDials }, parent);
|
||||
assert.equal(own(game, target).reserves, 14); assert.equal(own(game, target).tanks, 2);
|
||||
assert.deepEqual(own(game, target).forces, { 'carthag:11': 4 });
|
||||
verifySampleCustody(game, inventory);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
||||
const completed = rows(f.sqlite);
|
||||
await assert.rejects(f.restart().act(code, auths[1], version, { type: 'truthAnswer', answer: 'yes' }, clock));
|
||||
assert.deepEqual(rows(f.sqlite), completed);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user