Automatically return the full combined hand when it is the only legal special-Karama return. Preserve real choices and restore the suspended response or auction payment through the same settlement. Record the actual blind draw as owner-only historical card faces, with a bound integrity receipt; legacy forced saves describe held-card history truthfully. Add readable private history and enlarged inspectors. Both manual and forced returns publish only a faction/action completion tag. Wake idle legacy exchanges through production recovery without writes for real choices. Keep provisional auction funding rules and release gates. Validation: types, lint, 5150 offline tests, build and 49 HTTP tests passed. Independent review resolved history substitution and root review completed idle recovery wiring. New tests cover 1-4 forced draws, real choices, overlays, exact saved history, immutable rejection, all four legal AI paths, production competing actions/recovery, private views and custody. One genuine Advanced four-player sample completed 158 actions with 4 JSON restores and no rejected action; no difficulty tuning. Browser QA QUTHVPTT used genuine Advanced setup and conserved Bidding positions. The actual power returned four cards without a prompt; private history/enlargement and exact refresh worked. Phone panel fit without horizontal overflow with 44px inspector controls. Screenshot capture timed out; complete visual acceptance and transient notice capture remain open. Preserved every prior row across all ten tables, including 1586 rooms and 3354 seats. New QA/integration records bring totals to 1626 rooms and 3442 seats. No reset, restart or automation.
297 lines
16 KiB
TypeScript
297 lines
16 KiB
TypeScript
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');
|
|
// Setup may move the target's only opening Truthtrance to the asker.
|
|
// Stage a definite held card without changing the physical inventory.
|
|
const inventory = knowledgePhysical(initial);
|
|
initial.deck.push(...target.hand);
|
|
const privateCard = initial.deck.shift();
|
|
assert.ok(privateCard);
|
|
target.hand = [privateCard];
|
|
assert.deepEqual(knowledgePhysical(initial), inventory);
|
|
const privateName = privateCard.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');
|
|
});
|