Connect compound Truthtrance shipment promises

Shipment-only AND/OR questions now evaluate one eventual reserve shipment or skip as a whole. Yes to AND requires every leaf, Yes to OR requires one, and No negates the complete expression. Separate answered questions remain jointly binding. The completion search considers all destination branches and owned preparation paths. Release and fulfillment apply once to the aggregate promise, and legacy flat records retain their shape.

The two-condition browser builder, grouped question/history and respondent guidance, all four AI answer policies and saved continuation are connected. Nested expressions have explicit software size limits. Existing active-unused-shipment, Basic/no-Guild Advanced and module gates remain; mixed present/future predicates and unresolved rule questions are unchanged. Independent source, privacy and persistence review found no blocker.

Types, lint, 4815 offline tests, build and 43 HTTP tests pass with stable source. The first typecheck caught an omitted flat-only guidance consumer and a legacy test requiring type narrowing; grouped guidance and an actual promised-state regression fix them. SQLite Basic/Advanced tests verify private reauthentication, second-OR-branch fulfillment, zero-write rejected actions, legal No skip and duplicate CAS with one result.

Two fixed genuine Basic/Advanced mixed-profile games complete in 1534 accepted actions, 41 periodic private JSON restores and 0 rejected candidates. Each uses one scenario-directed compound question from an actual held Truthtrance at a real legal opportunity; AI supplies all responses and other actions. Exact card, force, typed-elite and post-setup traitor custody are checked. Initial private harness runs needed correct immutable setup configuration and full-game auction/Fedaykin inventory accounting; original failed traces remain private, and the final same-seed runs pass. The full sample tree is reconstructed exactly by replacing only the three later UI/test edits; all 219 game source files match the samples. No game logic changed after those runs.

Browser QA used genuine base initialization before conserved turn-two staging. The human asked twenty to Carthag OR four to Arrakeen; the AI answered Yes and shipped four to the second destination. Public grouped history survived refresh. All 6 accepted actions replay exactly, with one discarded Truthtrance, one fulfilled aggregate promise, private JSON views and original seat custody intact. This remains bounded prototype evidence, not complete Truthtrance or mode certification.

All 1004 opening games and original seat/recovery records are preserved (1038 rooms after checks), including all 1005 rooms present immediately before checks. SQLite integrity and foreign-key checks pass. The healthy server was reused without restart, reset or recurring automation.

Verified source tree: 1b24372fbe34e24db56e8f095c25744eeefb27581f2bc6cced2fb752f5bdcb94
This commit is contained in:
Quintine committed 2026-09-14 05:43:00 +10:00
1 parent cb691e1b14
commit 9f4de647ce
19 files changed
+1306 -117

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { compoundShipmentGame, askCompoundShipment, compoundShipmentCustody } from './fixture-compound-shipment';
import { matchesShipment } from '../game/shipment-promises';
function pending(advanced: boolean) {
const g = compoundShipmentGame(advanced);
Object.assign(g.players[0], { reserves: 10, forces: { 'carthag:11': 10 }, spice: 4 });
return askCompoundShipment(g, { op: 'or', terms: [
{ territory: 'carthag', minimum: 20 }, { territory: 'arrakeen', minimum: 4 },
] });
}
void test('all four profiles answer and complete the later OR branch when the first branch is impossible', () => {
for (const advanced of [false, true]) for (const level of DIFFICULTIES) {
let g = pending(advanced);
g.players[0].bot = level;
const answer = botActions(viewGame(g, 'p'))[0];
assert.deepEqual(answer, { type: 'truthAnswer', answer: 'yes' });
g = applyAction(g, 'p', answer);
const candidates = botActions(viewGame(g, 'p'));
const ship = candidates.find(action => action.type === 'ship');
assert.ok(ship, `${level} must retain an executable completion beyond its strategic shortlist`);
for (const action of candidates) assert.doesNotThrow(() => applyAction(g, 'p', action));
const next = applyAction(g, 'p', ship);
assert.equal(next.players[0].spice, 0);
assert.equal(next.players[0].reserves, 6);
const promise = next.shipmentPromises?.[0];
assert.ok(promise);
assert.equal(promise.fulfilled, true);
assert.equal(matchesShipment(promise, { territory: 'arrakeen', amount: 4 }), true);
compoundShipmentCustody(next);
for (const player of next.players)
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(next, player.id))),
JSON.parse(JSON.stringify(viewGame(JSON.parse(JSON.stringify(next)) as Game, player.id))));
}
});
void test('impossible different-destination AND never makes an AI publish Yes', () => {
for (const level of DIFFICULTIES) {
const g = askCompoundShipment(compoundShipmentGame(), { op: 'and', terms: [
{ territory: 'carthag', minimum: 1 }, { territory: 'arrakeen', minimum: 1 },
] });
g.players[0].bot = level;
assert.deepEqual(botActions(viewGame(g, 'p'))[0], { type: 'truthAnswer', answer: 'no' });
}
});
void test('compound answer ranking does not inspect rival private fields or mutate its supplied view', () => {
const g = pending(true);
g.players[0].bot = 'Hard';
const original = viewGame(g, 'p');
const expected = botActions(original);
const view = viewGame(g, 'p');
for (const rival of view.players.filter(player => player.id !== 'p'))
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
Object.defineProperty(rival, field, { get() { throw new Error(`Unentitled ${field} access`); } });
assert.deepEqual(botActions(view), expected);
assert.deepEqual(viewGame(g, 'p'), original);
});
+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { registerHooks } from 'node:module';
import { ShipmentClaimFields, invalidShipmentClause } from '../components/shipment-claim-fields';
import { truthQuestionText, type TruthQuestion } from '../game/truthtrance';
import { applyAction, viewGame } from '../game/engine';
import { ShipmentPromises } from '../components/shipment-promises';
import { compoundShipmentGame, askCompoundShipment } from './fixture-compound-shipment';
const aliases = registerHooks({ resolve(specifier, context, next) {
return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
} });
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
const clauses = [{ territory: 'carthag', minimum: 6 }, { territory: 'arrakeen', minimum: 4 }];
function markup(join: 'single' | 'and' | 'or', minimum = 4) {
return renderToStaticMarkup(createElement(ShipmentClaimFields, {
id: 'shipment-test', join, clauses: [clauses[0], { ...clauses[1], minimum }],
onJoin() {}, onClauses() {},
}));
}
void test('compound shipment fields distinguish one shipment from two and retain accessible per-clause labels', () => {
assert.equal((markup('single').match(/<fieldset/g) ?? []).length, 1);
for (const join of ['and', 'or'] as const) {
const html = markup(join);
assert.equal((html.match(/<fieldset/g) ?? []).length, 2);
assert.match(html, /All conditions describe the same shipment/);
assert.match(html, /A No answer requires the whole statement to be false/);
for (const index of [0, 1]) {
assert.match(html, new RegExp(`for="shipment-test-territory-${index}"`));
assert.match(html, new RegExp(`for="shipment-test-minimum-${index}"`));
}
const question: TruthQuestion = { kind: 'shipment', target: 'p', claim: { op: join, terms: clauses } };
const text = truthQuestionText(question, id => id);
assert.match(text, /Carthag/);
assert.match(text, /Arrakeen/);
assert.match(text, new RegExp(join.toUpperCase()));
}
});
void test('empty, fractional and out-of-range compound counts stay invalid rather than coercing to a different promise', () => {
for (const minimum of [NaN, 0, 2.5, 21]) {
assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), true);
assert.match(markup('or', minimum), /id="shipment-test-minimum-1"[^>]*aria-invalid="true"/);
}
for (const minimum of [1, 20]) assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), false);
});
void test('the real answer panel explains whole-expression No and offers answers only to the respondent', () => {
const g = askCompoundShipment(compoundShipmentGame(true), { op: 'or', terms: clauses });
const render = (me: string, busy = false) => renderToStaticMarkup(createElement(Truthtrance, {
game: viewGame(g, me), act() {}, busy,
}));
const own = render('p');
assert.match(own, /Answer Yes/);
assert.match(own, /Answer No/);
assert.match(own, /for AND, at least one condition must be false/);
assert.match(own, /every condition must be false/);
assert.match(render('p', true), /<button[^>]*disabled=""[^>]*>Answer Yes/);
for (const other of ['a', 'o']) {
assert.doesNotMatch(render(other), />Answer (Yes|No)</);
assert.match(render(other), /Waiting for Shipper to answer/);
}
});
void test('accepted compound promises render grouped private guidance and preserve the server completion', () => {
for (const op of ['and', 'or'] as const) for (const answer of ['yes', 'no'] as const) {
const terms = op === 'and' ? [clauses[0], { territory: 'carthag', minimum: 4 }] : clauses;
const asked = askCompoundShipment(compoundShipmentGame(true), { op, terms });
const promised = applyAction(asked, 'p', { type: 'truthAnswer', answer });
const view = viewGame(promised, 'p');
const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: view, act() {}, busy: false }));
assert.match(html, new RegExp(`You answered ${answer === 'yes' ? 'Yes' : 'No'}`));
assert.match(html, new RegExp(op.toUpperCase()));
assert.match(html, /Carthag/);
assert.doesNotMatch(html, /undefined/);
if (answer === 'yes') assert.match(html, /Suggested next step/);
for (const rival of ['a', 'o']) assert.equal(renderToStaticMarkup(createElement(ShipmentPromises, {
game: viewGame(promised, rival), act() {}, busy: false,
})), '');
}
});
+435
View File
@@ -0,0 +1,435 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import {
matchesShipment,
parseShipmentClaim,
parseShipmentExpression,
shipmentClaimDestinations,
shipmentClaimText,
type ShipmentExpression,
} from '../game/shipment-promises';
import {
compoundShipmentGame,
openCompoundShipmentQuestion,
askCompoundShipment,
compoundShipmentCustody,
holdCompoundShipmentCard,
} from './fixture-compound-shipment';
const leaf = (territory: string, minimum: number): ShipmentExpression => ({
territory,
minimum,
});
const and = (...terms: ShipmentExpression[]): ShipmentExpression => ({
op: 'and',
terms,
});
const or = (...terms: ShipmentExpression[]): ShipmentExpression => ({
op: 'or',
terms,
});
const alternative = or(leaf('carthag', 6), leaf('arrakeen', 4));
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const answer = (g: Game, answer: 'yes' | 'no') =>
applyAction(reload(g), 'p', { type: 'truthAnswer', answer });
const bind = (g: Game, claim = alternative, value: 'yes' | 'no' = 'yes') =>
answer(askCompoundShipment(g, claim), value);
const ship = (g: Game, territory: string, amount: number) =>
applyAction(reload(g), 'p', {
type: 'ship',
territory,
sector: territory === 'carthag' ? 11 : 10,
amount,
});
function reject(g: Game, actor: string, action: Action): void {
const before = structuredClone(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function restored(g: Game): Game {
const saved = reload(g);
for (const p of g.players)
assert.deepEqual(viewGame(saved, p.id), viewGame(g, p.id));
compoundShipmentCustody(saved);
return saved;
}
function nextShipment(g: Game): Action {
const next = viewGame(g, 'p').shipmentCompletion?.actions[0];
assert.ok(next);
for (const id of ['a', 'o'])
assert.equal(viewGame(g, id).shipmentCompletion, null);
return next;
}
void test('shipment trees evaluate one physical event with aggregate AND/OR truth, bounded shipment-only input and legacy leaves', () => {
const a = leaf('carthag', 4),
b = leaf('arrakeen', 3);
for (const [claim, outcomes] of [
[and(a, b), [false, false, false, false]],
[or(a, b), [false, true, true, false]],
[and(a, or(leaf('carthag', 6), b)), [false, false, false, false]],
[or(and(a, leaf('carthag', 5)), b), [false, false, true, false]],
] as const) {
const parsed = parseShipmentClaim({ claim });
assert.deepEqual(
[
null,
{ territory: 'carthag', amount: 4 },
{ territory: 'arrakeen', amount: 3 },
{ territory: 'carthag', amount: 3 },
].map((event) => matchesShipment(parsed, event)),
outcomes,
);
assert.deepEqual(shipmentClaimDestinations(parsed), [
'carthag',
'arrakeen',
]);
assert.match(shipmentClaimText(parsed), /ship at least/);
}
assert.deepEqual(parseShipmentClaim({ territory: 'carthag', minimum: 4 }), a);
for (const claim of [
{ op: 'xor', terms: [a, b] },
{ op: 'or', terms: [] },
{ op: 'and', terms: [a] },
{ op: 'or', terms: [a, { kind: 'spice', minimum: 2 }] },
{ op: 'or', terms: [a, { ...b, target: 'o' }] },
{ op: 'and', terms: Array.from({ length: 17 }, () => a) },
leaf('arrakeen', 0),
leaf('missing', 4),
])
assert.throws(() => parseShipmentExpression(claim));
let tooDeep = a;
for (let n = 0; n < 5; n++) tooDeep = or(a, tooDeep);
assert.throws(() => parseShipmentExpression(tooDeep));
assert.throws(() =>
parseShipmentClaim({ territory: 'carthag', minimum: 4, claim: a }),
);
});
void test('Basic and Advanced OR Yes remain one obligation and can choose a legal nonfirst branch after JSON restoration', () => {
for (const advanced of [false, true]) {
const initial = compoundShipmentGame(advanced);
initial.storm = 11; // Public storm blocks only the first branch.
const asked = askCompoundShipment(initial, alternative);
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['yes', 'no']);
for (const id of ['a', 'o'])
assert.equal(viewGame(asked, id).truthShipmentAnswers, null);
reject(asked, 'a', { type: 'truthAnswer', answer: 'yes' });
const promised = restored(answer(asked, 'yes'));
assert.deepEqual(promised.shipmentPromises, [
{ turn: 2, player: 'p', asker: 'a', claim: alternative, answer: true },
]);
const next = nextShipment(promised);
assert.equal(next.type, 'ship');
assert.equal(next.territory, 'arrakeen');
assert.equal(next.amount, 4);
reject(promised, 'p', { type: 'endMovement' });
const done = restored(applyAction(promised, 'p', next));
assert.equal(done.players[0].forces['arrakeen:10'], 4);
assert.equal(done.players[0].reserves, 16);
assert.equal(done.shipmentPromises?.length, 1);
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
assert.equal(
done.discard.filter((c) => c.effect === 'truthtrance').length,
1,
);
assert.deepEqual(normalizeAutomaticGame(done), done);
}
});
void test('different-destination AND cannot be Yes; No allows either individual branch without flattening', () => {
for (const advanced of [false, true]) {
const asked = askCompoundShipment(
compoundShipmentGame(advanced),
and(leaf('carthag', 6), leaf('arrakeen', 4)),
);
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['no']);
reject(asked, 'p', { type: 'truthAnswer', answer: 'yes' });
const promised = answer(asked, 'no');
for (const [territory, amount] of [
['carthag', 6],
['arrakeen', 4],
] as const) {
const done = restored(ship(promised, territory, amount));
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
}
assert.equal(
applyAction(promised, 'p', { type: 'endMovement' }).shipmentPromises?.[0]
.fulfilled,
true,
);
}
});
void test('nested same-destination thresholds bind the aggregate result, including AND No', () => {
const expression = and(
leaf('carthag', 4),
or(leaf('carthag', 6), leaf('arrakeen', 3)),
);
for (const advanced of [false, true]) {
const asked = askCompoundShipment(
compoundShipmentGame(advanced),
expression,
);
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['yes', 'no']);
const positive = answer(asked, 'yes');
assert.equal(nextShipment(positive).amount, 6);
reject(positive, 'p', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 5,
});
reject(positive, 'p', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 3,
});
assert.equal(
restored(ship(positive, 'carthag', 6)).shipmentPromises?.[0].fulfilled,
true,
);
const negative = answer(asked, 'no');
for (const amount of [4, 5])
assert.equal(
ship(negative, 'carthag', amount).shipmentPromises?.[0].fulfilled,
true,
);
reject(negative, 'p', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 6,
});
}
});
void test('OR No forbids each matching branch and permits fewer forces, another destination or no shipment', () => {
for (const advanced of [false, true]) {
const promised = bind(compoundShipmentGame(advanced), alternative, 'no');
for (const [territory, amount] of [
['carthag', 6],
['arrakeen', 4],
] as const)
reject(promised, 'p', {
type: 'ship',
territory,
sector: territory === 'carthag' ? 11 : 10,
amount,
});
for (const [territory, amount] of [
['carthag', 5],
['arrakeen', 3],
['imperial_basin', 1],
] as const)
assert.equal(
restored(ship(promised, territory, amount)).shipmentPromises?.[0]
.fulfilled,
true,
);
const done = restored(
applyAction(restored(promised), 'p', { type: 'endMovement' }),
);
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
assert.equal(done.players[0].reserves, 20);
assert.equal(done.players[0].spice, 20);
}
});
void test('separate compound and legacy answers remain jointly binding without rewriting old saved leaf shape', () => {
for (const advanced of [false, true]) {
let g = bind(compoundShipmentGame(advanced));
g = applyAction(openCompoundShipmentQuestion(g), 'a', {
type: 'truthAsk',
question: {
kind: 'shipment',
target: 'p',
territory: 'carthag',
minimum: 1,
},
});
g = restored(answer(g, 'no'));
assert.deepEqual(g.shipmentPromises?.[1], {
turn: 2,
player: 'p',
asker: 'a',
territory: 'carthag',
minimum: 1,
answer: false,
});
assert.equal(Object.hasOwn(g.shipmentPromises![1], 'claim'), false);
assert.equal(nextShipment(g).territory, 'arrakeen');
reject(g, 'p', {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 6,
});
const done = restored(ship(g, 'arrakeen', 4));
assert.ok(done.shipmentPromises?.every((p) => p.fulfilled));
assert.equal(
done.discard.filter((c) => c.effect === 'truthtrance').length,
2,
);
}
});
void test('joint expressions select the only remaining threshold interval, not a first positive destination', () => {
const g = bind(
bind(compoundShipmentGame(), or(leaf('carthag', 6), leaf('arrakeen', 4))),
or(leaf('carthag', 1), leaf('arrakeen', 6)),
'no',
);
assert.equal(nextShipment(g).territory, 'arrakeen');
assert.equal(nextShipment(g).amount, 4);
for (const amount of [4, 5])
assert.ok(
ship(g, 'arrakeen', amount).shipmentPromises?.every((p) => p.fulfilled),
);
for (const amount of [3, 6])
reject(g, 'p', { type: 'ship', territory: 'arrakeen', sector: 10, amount });
});
void test('a later OR branch can depend on real Ghola, retained Karama and recovered outgoing aid preparation', () => {
for (const advanced of [false, true]) {
let g = compoundShipmentGame(advanced);
const p = g.players[0];
Object.assign(p, {
reserves: 1,
tanks: 5,
forces: { 'arrakeen:10': 14 },
spice: 3,
ally: 'o',
});
if (p.elites)
p.elites = {
reserves: 0,
tanks: 0,
forces: { 'arrakeen:10': 5 },
revived: 0,
};
g.players[2].ally = 'p';
g.storm = 11;
const ghola = holdCompoundShipmentCard(g, 'p', 'ghola');
const karama = holdCompoundShipmentCard(g, 'p', 'karama');
g = applyAction(g, 'p', { type: 'pledgeAid', amount: 2 });
g = restored(bind(g, or(leaf('carthag', 6), leaf('arrakeen', 6))));
const actions: Action[] = [];
for (let n = 0; !g.players[0].shipped && n < 8; n++) {
const next = nextShipment(g);
actions.push(next);
g = restored(applyAction(g, 'p', next));
}
assert.equal(g.players[0].shipped, true);
assert.equal(g.players[0].forces['arrakeen:10'], 20);
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[0].tanks, 0);
assert.equal(g.aid.p.amount, 0);
assert.ok(actions.some((a) => a.type === 'pledgeAid' && a.amount === 0));
assert.ok(actions.some((a) => a.card === ghola));
assert.ok(actions.some((a) => a.card === karama));
assert.equal(g.shipmentPromises?.[0].fulfilled, true);
}
});
void test('loss of one OR alternative preserves the whole obligation and voluntary destruction of its surviving branch rejects', () => {
let g = compoundShipmentGame();
g.players[0].spice = 2;
g.players[0].ally = 'o';
g.players[2].ally = 'p';
g = applyAction(g, 'o', { type: 'pledgeAid', amount: 6 });
g = bind(g, or(leaf('carthag', 6), leaf('arrakeen', 2)));
assert.equal(nextShipment(g).territory, 'carthag');
g = restored(applyAction(g, 'o', { type: 'pledgeAid', amount: 0 }));
assert.equal(g.shipmentPromises?.[0].released, undefined);
assert.equal(nextShipment(g).territory, 'arrakeen');
reject(g, 'p', { type: 'bribe', target: 'a', amount: 1 });
assert.equal(ship(g, 'arrakeen', 2).shipmentPromises?.[0].fulfilled, true);
});
void test('hidden rival wealth and card identities do not change the target-private feasible answer set or continuation shape', () => {
const initial = compoundShipmentGame(true);
initial.players[0].spice = 3;
const asked = askCompoundShipment(initial, alternative);
const changed = reload(asked);
changed.players[2].spice = 1000;
holdCompoundShipmentCard(changed, 'o', 'karama');
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['no']);
assert.deepEqual(
viewGame(changed, 'p').truthShipmentAnswers,
viewGame(asked, 'p').truthShipmentAnswers,
);
for (const g of [asked, changed]) {
for (const actor of ['a', 'o']) {
assert.equal(viewGame(g, actor).truthShipmentAnswers, null);
assert.equal(viewGame(g, actor).shipmentCompletion, null);
}
const done = answer(g, 'no');
assert.equal(
done.log.length - g.log.length,
answer(asked, 'no').log.length - asked.log.length,
);
assert.equal(done.truthtrance, null);
assert.equal(done.response, null);
assert.equal(done.decision, null);
}
});
void test('an opponent making every OR branch impossible releases one aggregate obligation exactly once', () => {
let g = compoundShipmentGame(true);
g.players[0].spice = 0;
g.players[0].ally = 'o';
g.players[2].ally = 'p';
g = applyAction(g, 'o', { type: 'pledgeAid', amount: 6 });
g = bind(g);
g = restored(applyAction(g, 'o', { type: 'pledgeAid', amount: 0 }));
assert.equal(g.shipmentPromises?.length, 1);
assert.equal(g.shipmentPromises?.[0].released, true);
assert.equal(g.shipmentPromises?.[0].fulfilled, undefined);
const before = g.log.length;
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const done = restored(applyAction(g, 'p', { type: 'endMovement' }));
assert.equal(done.shipmentPromises?.[0].released, true);
assert.equal(done.shipmentPromises?.[0].fulfilled, undefined);
assert.equal(
done.log
.slice(before)
.filter((entry) => JSON.stringify(entry).includes('no longer binding'))
.length,
0,
);
});
void test('malformed compound questions and saved promises reject before view, action, normalization or autopilot can mutate them', () => {
const ready = openCompoundShipmentQuestion(compoundShipmentGame());
const invalid = {
op: 'or',
terms: [leaf('carthag', 6), { kind: 'spice', min: 2 }],
};
reject(ready, 'a', {
type: 'truthAsk',
question: { kind: 'shipment', target: 'p', claim: invalid },
});
const pending = askCompoundShipment(compoundShipmentGame(), alternative);
const promised = answer(pending, 'yes');
for (const source of [pending, promised]) {
const g = reload(source);
if (g.truthtrance?.question)
Object.assign(g.truthtrance.question, { claim: invalid });
else Object.assign(g.shipmentPromises![0], { claim: invalid });
const before = structuredClone(g);
for (const p of g.players) assert.throws(() => viewGame(g, p.id));
assert.throws(() => normalizeAutomaticGame(g));
reject(g, 'p', { type: 'endMovement' });
reject(g, 'p', { type: 'setAutopilot', enabled: true });
assert.deepEqual(g, before);
}
});
+257
View File
@@ -0,0 +1,257 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import type { RoomsClock } from "../db/rooms";
import { viewGame, type Action, type Game } from "../game/engine";
import type { ShipmentExpression } from "../game/shipment-promises";
import {
askCompoundShipment,
compoundShipmentCustody,
compoundShipmentGame,
} from "./fixture-compound-shipment";
import { unitStore } from "./fixture-nexus-room-store";
const clock: RoomsClock = { now: () => 10_000, sleep: async () => {} };
const expression: ShipmentExpression = {
op: "or",
terms: [
{ territory: "carthag", minimum: 6 },
{ territory: "arrakeen", minimum: 4 },
],
};
const qualifying: Action = {
type: "ship",
territory: "arrakeen",
sector: 10,
amount: 4,
};
const contradictsYes: Action = {
type: "ship",
territory: "arrakeen",
sector: 10,
amount: 3,
};
const contradictsNo: Action = {
type: "ship",
territory: "carthag",
sector: 11,
amount: 6,
};
async function persistedQuestion(advanced: boolean) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const made = await store.rooms.createRoom(
"Compound shipment SQL",
"emperor",
advanced,
[],
);
const code = made.view.code;
const joined = [
await store.rooms.joinRoom(code, "Atreides asker", "atreides"),
await store.rooms.joinRoom(code, "Harkonnen observer", "harkonnen"),
];
const tokens = [made.token, ...joined.map((seat) => seat.token!)];
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const lobby = await store.rooms.readRoom(code);
const question = askCompoundShipment(
compoundShipmentGame(advanced, ids),
expression,
);
question.code = code;
question.version = lobby.version;
sqlite
.prepare("UPDATE rooms SET state = ?, version = ? WHERE code = ?")
.run(JSON.stringify(question), question.version, code);
store.writes.length = 0;
return { ...store, code, auths, ids, tokens, question };
}
async function rejectsWithoutWrite(
fixture: Awaited<ReturnType<typeof persistedQuestion>>,
action: Action,
pattern: RegExp,
) {
const before = await fixture.restart().readRoom(fixture.code);
const writes = fixture.writes.length;
await assert.rejects(
fixture
.restart()
.act(fixture.code, fixture.auths[0], before.version, action, clock),
pattern,
);
assert.deepEqual(await fixture.restart().readRoom(fixture.code), before);
assert.equal(fixture.writes.length, writes);
}
function assertSingleCompoundPromise(g: Game, answer: boolean) {
assert.equal(g.shipmentPromises?.length, 1);
assert.deepEqual(g.shipmentPromises![0].claim, expression);
assert.equal(g.shipmentPromises![0].answer, answer);
assert.equal(Object.hasOwn(g.shipmentPromises![0], "territory"), false);
assert.equal(Object.hasOwn(g.shipmentPromises![0], "minimum"), false);
assert.equal(g.truthHistory?.length, 1);
assert.deepEqual(g.truthHistory![0].question, {
kind: "shipment",
target: g.players[0].id,
claim: expression,
});
}
async function assertPrivateRestore(
fixture: Awaited<ReturnType<typeof persistedQuestion>>,
answers: boolean,
completion: boolean,
) {
const state = await fixture.restart().readRoom(fixture.code);
for (const index of fixture.auths.keys()) {
const rooms = fixture.restart();
const restored = await rooms.authenticate(
fixture.code,
fixture.tokens[index],
);
const view = await rooms.readSeatView(fixture.code, restored);
assert.deepEqual(view, viewGame(state, fixture.ids[index]));
assert.deepEqual(
view.truthShipmentAnswers,
index === 0 && answers ? ["yes", "no"] : null,
);
assert.equal(!!view.shipmentCompletion, index === 0 && completion);
assert.equal(
JSON.stringify(view).includes("truthShipmentCompletion"),
false,
);
for (const player of view.players)
if (player.id !== fixture.ids[index])
assert.equal("hand" in player, false);
}
}
function concurrentGate() {
let arrivals = 0;
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
return {
wait: async () => {
if (++arrivals === 2) release();
await waiting;
},
release,
arrivals: () => arrivals,
};
}
for (const advanced of [false, true])
void test(`${advanced ? "Advanced" : "Basic"} restored compound Yes honors the second OR branch and duplicate shipment commits once`, async () => {
const f = await persistedQuestion(advanced);
try {
await assertPrivateRestore(f, true, false);
const pending = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.auths[0],
pending.version,
{ type: "truthAnswer", answer: "yes" },
clock,
);
const accepted = await f.restart().readRoom(f.code);
assertSingleCompoundPromise(accepted, true);
assert.equal(accepted.shipmentPromises![0].fulfilled, undefined);
await assertPrivateRestore(f, false, true);
await rejectsWithoutWrite(f, contradictsYes, /Truthtrance|promise/i);
f.writes.length = 0;
const gate = concurrentGate();
f.hooks.beforeWrite = gate.wait;
const timer = setTimeout(gate.release, 2_000);
let outcomes: PromiseSettledResult<unknown>[];
try {
outcomes = await Promise.allSettled(
[qualifying, qualifying].map((action) =>
f
.restart()
.act(f.code, f.auths[0], accepted.version, action, clock),
),
);
} finally {
clearTimeout(timer);
delete f.hooks.beforeWrite;
}
assert.equal(gate.arrivals(), 2);
assert.equal(
outcomes.filter((outcome) => outcome.status === "fulfilled").length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, accepted.version + 1);
assertSingleCompoundPromise(done, true);
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.equal(done.players[0].forces["arrakeen:10"], 4);
assert.equal(done.players[0].reserves, 16);
assert.equal(done.players[0].spice, 16);
assert.equal(done.players[0].shipped, true);
assert.equal(
done.discard.filter((card) => card.effect === "truthtrance").length,
1,
);
compoundShipmentCustody(done);
await assertPrivateRestore(f, false, false);
} finally {
delete f.hooks.beforeWrite;
f.sqlite.close();
}
});
for (const advanced of [false, true])
void test(`${advanced ? "Advanced" : "Basic"} restored compound No rejects a matching shipment and permits a shipment skip`, async () => {
const f = await persistedQuestion(advanced);
try {
const pending = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.auths[0],
pending.version,
{ type: "truthAnswer", answer: "no" },
clock,
);
const accepted = await f.restart().readRoom(f.code);
assertSingleCompoundPromise(accepted, false);
await assertPrivateRestore(f, false, false);
await rejectsWithoutWrite(f, contradictsNo, /Truthtrance|promise/i);
const beforeSkip = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.auths[0],
beforeSkip.version,
{ type: "endMovement" },
clock,
);
const skipped = await f.restart().readRoom(f.code);
assertSingleCompoundPromise(skipped, false);
assert.equal(skipped.shipmentPromises![0].fulfilled, true);
assert.equal(skipped.players[0].shipped, false);
assert.deepEqual(skipped.players[0].forces, beforeSkip.players[0].forces);
assert.equal(skipped.players[0].reserves, beforeSkip.players[0].reserves);
assert.equal(skipped.players[0].spice, beforeSkip.players[0].spice);
compoundShipmentCustody(skipped);
await assertPrivateRestore(f, false, false);
} finally {
f.sqlite.close();
}
});
+141
View File
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeBaseGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck } from '../game/cards';
import type { ShipmentExpression } from '../game/shipment-promises';
/** Genuine base setup; then explicitly conserved hands, force pools and clean turn-two movement topology. */
export function compoundShipmentGame(
advanced = false,
seatIds: readonly [string, string, string] = ['p', 'a', 'o'],
): Game {
const factions = ['emperor', 'atreides', 'harkonnen'] as const;
let g = createGame(
'COMPOUNDSHIP',
newPlayer(seatIds[0], 'Shipper', factions[0]),
advanced,
);
for (let i = 1; i < factions.length; i++)
joinGame(g, newPlayer(seatIds[i], factions[i], factions[i]));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeBaseGameForAudit(g);
for (let n = 0; g.status === 'setup' && n < 100; n++) {
let progressed = false;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((other) => other.id === p.id)!.bot = 'Hard';
const action = botActions(view)[0];
if (!action) continue;
g = applyAction(g, p.id, action);
progressed = true;
break;
}
assert.ok(progressed, 'Genuine setup has an entitled actor.');
}
assert.equal(g.status, 'playing');
assert.equal(g.setupStage, undefined);
Object.assign(g, {
phase: 5,
turn: 2,
active: seatIds[0],
storm: 18,
order: [...seatIds],
movementRemaining: [...seatIds],
response: null,
decision: null,
phaseOpening: null,
});
for (const p of g.players) {
g.deck.push(...p.hand);
p.hand = [];
Object.assign(p, {
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
shipped: false,
moved: 0,
revived: 0,
});
if (p.elites) p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
}
compoundShipmentCustody(g);
return g;
}
/** Scenario preparation only: relocate an existing physical card, never fabricate it. */
export function holdCompoundShipmentCard(
g: Game,
owner: string,
effect: string,
): string {
const at = g.deck.findIndex((c) => c.effect === effect || c.kind === effect);
assert.ok(at >= 0, `Physical ${effect} remains in the deck.`);
const card = g.deck.splice(at, 1)[0];
g.players.find((p) => p.id === owner)!.hand.push(card);
return card.id;
}
/** Clone and perform the real card/priority actions; never mutate the input. */
export function openCompoundShipmentQuestion(state: Game): Game {
let g: Game = JSON.parse(JSON.stringify(state));
const asker = g.players[1].id;
const card = holdCompoundShipmentCard(g, asker, 'truthtrance');
g = applyAction(g, asker, { type: 'card', card });
for (let n = 0; g.truthtrance?.stage === 'priority' && n < 10; n++) {
const actor = g.players.find((p) => !g.truthtrance!.passed.includes(p.id));
assert.ok(actor);
g = applyAction(g, actor.id, { type: 'truthPass' });
}
assert.equal(g.truthtrance?.stage, 'ask');
compoundShipmentCustody(g);
return g;
}
/** Start a new real question and return the target-owned answer window. */
export function askCompoundShipment(
g: Game,
expression: ShipmentExpression,
): Game {
return applyAction(openCompoundShipmentQuestion(g), g.players[1].id, {
type: 'truthAsk',
question: { kind: 'shipment', target: g.players[0].id, claim: expression },
});
}
export function compoundShipmentCustody(g: Game): void {
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites) {
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
5,
);
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
for (const [where, amount] of Object.entries(p.elites.forces))
assert.ok(amount <= (p.forces[where] ?? 0));
}
}
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
const ids = cards.map((c) => c.id).sort();
assert.equal(new Set(ids).size, ids.length);
assert.deepEqual(
ids,
baseDeck()
.map((c) => c.id)
.sort(),
);
}
@@ -899,7 +899,10 @@ void test('the already-bound shipment promise cannot be deleted, replaced, relea
g.shipmentPromises![0].fulfilled = true;
},
(g) => {
g.shipmentPromises![0].minimum++;
const promise = g.shipmentPromises![0];
assert.equal(promise.claim, undefined);
assert.ok(typeof promise.minimum === 'number');
promise.minimum++;
},
(g) => {
g.shipmentPromises![0].player = 'p';