Support mixed current-fact and shipment Truthtrance questions

This commit is contained in:
Quintine committed 2026-10-03 07:11:28 +10:00
1 parent 0ceb28bd31
commit cd985c7c34
20 files changed
+982 -326

No files matched your search

+5 -7
View File
@@ -29,10 +29,8 @@ void test('compound shipment fields distinguish one shipment from two and retain
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}"`));
}
assert.equal((html.match(/This-turn shipment destination/g) ?? []).length, 2);
assert.equal((html.match(/Minimum physical forces from reserves/g) ?? []).length, 2);
const question: TruthQuestion = { kind: 'shipment', target: 'p', claim: { op: join, terms: clauses } };
const text = truthQuestionText(question, id => id);
assert.match(text, /Carthag/);
@@ -44,7 +42,7 @@ void test('compound shipment fields distinguish one shipment from two and retain
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"/);
assert.equal((markup('or', minimum).match(/aria-invalid="true"/g) ?? []).length, 1);
}
for (const minimum of [1, 20]) assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), false);
});
@@ -57,8 +55,8 @@ void test('the real answer panel explains whole-expression No and offers answers
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(own, /AND requires all conditions; OR requires at least one/);
assert.match(own, /No makes the whole claim false/);
assert.match(render('p', true), /<button[^>]*disabled=""[^>]*>Answer Yes/);
for (const other of ['a', 'o']) {
assert.doesNotMatch(render(other), />Answer (Yes|No)</);
+2 -2
View File
@@ -154,10 +154,10 @@ void test('every canonical Ecaz face receives its full original gameplay guide a
assert.equal(serialized.includes(note), false);
assert.equal(serialized.includes('sourceRules'), false);
assert.equal(serialized.includes('not-implemented'), false);
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits']);
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits']);
assert.match(presentation.availability ?? '', card.effect === 'recruits'
? /development controls during Revival/ : card.effect === 'harassWithdraw'
? /development controls in either battle-card slot/ : /battle effect is still being implemented/);
? /development controls in either battle-card slot/ : /development controls in either battle-card slot/);
}
});
+17
View File
@@ -0,0 +1,17 @@
import {applyAction,type Game} from '../game/engine';
import type {MixedShipmentExpression} from '../game/mixed-shipment-question';
import {compoundShipmentGame,openCompoundShipmentQuestion} from './fixture-compound-shipment';
/** Genuine base setup, then the existing labelled conserved shipment position. */
export function mixedShipmentGame(): Game {
return compoundShipmentGame();
}
export function askMixedShipment(game: Game, mixed: MixedShipmentExpression): Game {
return applyAction(openCompoundShipmentQuestion(game),game.players[1].id,{
type:'truthAsk',question:{kind:'mixedShipment',target:game.players[0].id,mixed},
});
}
export function mixedSpiceShipment(kind:'and'|'or',compare:'gte'|'lte',value:number): MixedShipmentExpression {
return {kind,terms:[{kind:'fact',fact:{kind:'spice',compare,value}},
{kind:'shipment',territory:'arrakeen',minimum:4}]};
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,viewGame} from '../game/engine';
import {askMixedShipment,mixedShipmentGame,mixedSpiceShipment} from './fixture-mixed-shipment';
import {askCompoundShipment} from './fixture-compound-shipment';
void test('Yes OR a true current spice fact permits later spending and skipping shipment without changing its historical answer',()=>{
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('or','gte',10));
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['yes']);
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
assert.equal(game.truthHistory!.at(-1)!.answer,'yes');
game=applyAction(game,'p',{type:'bribe',target:'a',amount:20});
assert.equal(game.players[0].spice,0);
game=applyAction(game,'p',{type:'endMovement'});
assert.equal(game.shipmentPromises![0].fulfilled,true);
assert.equal(game.players[0].shipped,false);
assert.equal(game.truthHistory!.at(-1)!.answer,'yes');
});
void test('No AND a false current spice fact permits a shipment that changes that current fact to true',()=>{
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('and','lte',10));
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['no']);
game=applyAction(game,'p',{type:'truthAnswer',answer:'no'});
game=applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:15});
assert.equal(game.players[0].spice,5);
assert.equal(game.players[0].forces['arrakeen:10'],15);
assert.equal(game.shipmentPromises![0].fulfilled,true);
assert.equal(game.truthHistory!.at(-1)!.answer,'no');
});
void test('Yes AND a true current fact requires the exact future shipment while an impossible current branch cannot be answered Yes',()=>{
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('and','gte',10));
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['yes','no']);
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
const before=JSON.stringify(game);
assert.throws(()=>applyAction(game,'p',{type:'endMovement'}));
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:4}));
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:3}));
assert.equal(JSON.stringify(game),before);
game=applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4});
assert.equal(game.players[0].forces['arrakeen:10'],4);
const impossible=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('and','gte',21));
assert.deepEqual(viewGame(impossible,'p').truthShipmentAnswers,['no']);
assert.throws(()=>applyAction(impossible,'p',{type:'truthAnswer',answer:'yes'}));
});
void test('No OR requires every frozen branch to be false and cannot negate an already true current clause',()=>{
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('or','lte',10));
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['yes','no']);
game=applyAction(game,'p',{type:'truthAnswer',answer:'no'});
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4}));
game=applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:15});
assert.equal(game.players[0].spice,5);
assert.equal(game.players[0].forces['carthag:11'],15);
assert.equal(game.shipmentPromises![0].fulfilled,true);
const impossible=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('or','gte',10));
assert.deepEqual(viewGame(impossible,'p').truthShipmentAnswers,['yes']);
assert.throws(()=>applyAction(impossible,'p',{type:'truthAnswer',answer:'no'}));
});
void test('nested current branches keep the selected future destination after voluntary spice changes',()=>{
let game=askMixedShipment(mixedShipmentGame(),{kind:'or',terms:[
mixedSpiceShipment('and','gte',10),
{kind:'and',terms:[{kind:'fact',fact:{kind:'spice',compare:'lte',value:10}},
{kind:'shipment',territory:'carthag',minimum:4}]},
]});
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
game=applyAction(game,'p',{type:'bribe',target:'a',amount:12});
assert.equal(game.players[0].spice,8);
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:4}));
game=applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4});
assert.equal(game.players[0].spice,4);
assert.equal(game.players[0].forces['arrakeen:10'],4);
});
void test('an unconstraining mixed answer cannot override an earlier pure shipment commitment',()=>{
let game=askCompoundShipment(mixedShipmentGame(),{territory:'carthag',minimum:4});
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
game=askMixedShipment(game,mixedSpiceShipment('or','gte',10));
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
assert.equal(game.shipmentPromises!.length,2);
assert.throws(()=>applyAction(game,'p',{type:'endMovement'}));
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4}));
game=applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:4});
assert.deepEqual(game.shipmentPromises!.map(p=>p.fulfilled),[true,true]);
assert.equal(game.players[0].forces['carthag:11'],4);
});
+168
View File
@@ -0,0 +1,168 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
compileMixedShipmentExpression,
parseMixedShipmentExpression,
type MixedShipmentExpression,
} from '../game/mixed-shipment-question';
import {
matchesShipment,
parseShipmentClaim,
parseShipmentExpression,
shipmentClaimDestinations,
validShipmentClaim,
ShipmentClaimError,
type ShipmentExpression,
} from '../game/shipment-promises';
import { truthFactAnswer, type TruthAnswer, type TruthFact } from '../game/truthtrance';
const fact = (value = 5): MixedShipmentExpression => ({
kind: 'fact', fact: { kind: 'spice', compare: 'gte', value },
});
const shipment = (territory = 'carthag', minimum = 4): MixedShipmentExpression => ({
kind: 'shipment', territory, minimum,
});
const group = (
kind: 'and' | 'or', ...terms: MixedShipmentExpression[]
): MixedShipmentExpression => ({ kind, terms });
// The caller supplies fact legality; these already-typed fixtures isolate the shared budget.
const decode = (value: unknown) =>
parseMixedShipmentExpression(value, (input) => input as TruthFact);
const events = [
null,
{ territory: 'carthag', amount: 3 },
{ territory: 'carthag', amount: 4 },
{ territory: 'arrakeen', amount: 4 },
];
const outcomes = (expression: ShipmentExpression) =>
events.map((event) => matchesShipment({ claim: expression }, event));
void test('AND and OR bind the whole question, including No rather than individual leaves', () => {
for (const [kind, answer, expected] of [
['and', 'yes', [false, false, true, false]],
['and', 'no', [false, false, false, false]],
['or', 'yes', [true, true, true, true]],
['or', 'no', [false, false, true, false]],
] as const) {
const expression = compileMixedShipmentExpression(
decode(group(kind, fact(), shipment())), () => answer,
);
assert.deepEqual(outcomes(expression), expected);
}
});
void test('nested mixed branches evaluate the same eventual event across different destinations', () => {
const expression = decode(group('or',
group('and', fact(5), shipment('carthag', 4)),
group('and', fact(10), shipment('arrakeen', 3)),
));
const player = { hand: [], traitors: [], traitorChoices: [], spice: 7 };
const claim = compileMixedShipmentExpression(expression, (f) => truthFactAnswer(player, f));
assert.deepEqual(outcomes(claim), [false, false, true, false]);
player.spice = 12;
const laterClaim = compileMixedShipmentExpression(expression, (f) => truthFactAnswer(player, f));
assert.deepEqual(outcomes(laterClaim), [false, false, true, true]);
assert.deepEqual(shipmentClaimDestinations({ claim }), ['carthag', 'arrakeen']);
});
void test('unknown is not false: decisive AND/OR branches dominate regardless of order', () => {
const unknown: ShipmentExpression = { constant: 'unknown' };
for (const order of [false, true]) {
for (const [op, constant, expected] of [
['and', 'no', false],
['and', 'yes', null],
['or', 'yes', true],
['or', 'no', null],
] as const) {
const known: ShipmentExpression = { constant };
const claim: ShipmentExpression = {
op, terms: order ? [known, unknown] : [unknown, known],
};
assert.equal(matchesShipment({ claim }, null), expected);
}
}
const and = compileMixedShipmentExpression(group('and', fact(), shipment()), () => 'unknown');
const or = compileMixedShipmentExpression(group('or', fact(), shipment()), () => 'unknown');
assert.deepEqual(outcomes(and), [false, false, null, false]);
assert.deepEqual(outcomes(or), [null, null, true, null]);
assert.deepEqual(outcomes({ op: 'or', terms: [and, { constant: 'no' }] }),
[false, false, null, false]);
});
void test('compilation freezes current answers once, not a callback or later fact state', () => {
const player = { hand: [], traitors: [], traitorChoices: [], spice: 7 };
const asked = decode(group('or', fact(), shipment()));
let reads = 0;
const frozen = compileMixedShipmentExpression(asked, (f) => {
reads++;
return truthFactAnswer(player, f);
});
player.spice = 0;
assert.equal(matchesShipment({ claim: frozen }, null), true);
assert.equal(matchesShipment({ claim: JSON.parse(JSON.stringify(frozen)) as ShipmentExpression },
{ territory: 'arrakeen', amount: 1 }), true);
assert.equal(reads, 1);
assert.equal(matchesShipment({
claim: compileMixedShipmentExpression(asked, (f) => truthFactAnswer(player, f)),
}, null), false);
const negative = compileMixedShipmentExpression(group('and', fact(), shipment()),
(f) => truthFactAnswer(player, f));
player.spice = 20;
assert.equal(matchesShipment({ claim: negative }, { territory: 'carthag', amount: 20 }), false);
});
void test('public shipment and mixed decoders reject private literals; stored internal claims retain unknown', () => {
for (const constant of ['yes', 'no', 'unknown'] as TruthAnswer[]) {
const compiled: ShipmentExpression = {
op: 'or', terms: [{ constant }, { territory: 'carthag', minimum: 4 }],
};
assert.throws(() => parseShipmentExpression(compiled), ShipmentClaimError);
assert.throws(() => parseShipmentClaim({ claim: compiled }), ShipmentClaimError);
assert.equal(validShipmentClaim({ claim: compiled }), false);
const stored = parseShipmentClaim({ claim: compiled }, { allowConstants: true });
assert.equal(matchesShipment(stored, null), constant === 'unknown' ? null : constant === 'yes');
assert.deepEqual(shipmentClaimDestinations(stored), ['carthag']);
assert.throws(() => decode({kind:'or',terms:[fact(),{constant}]}), ShipmentClaimError);
assert.throws(() => decode({kind:'or',terms:[{...fact(),constant},shipment()]}), ShipmentClaimError);
}
for (const constant of ['maybe', true, null])
assert.throws(() => parseShipmentExpression({ constant }, { allowConstants: true }), ShipmentClaimError);
assert.throws(() => parseShipmentExpression({ constant: 'yes', territory: 'carthag' },
{ allowConstants: true }), ShipmentClaimError);
});
void test('one grouping depth and logical-leaf budget includes embedded fact groups', () => {
// Group nodes do not consume leaves: fifteen current facts plus one shipment fit.
decode(group('and', ...Array.from({ length: 15 }, () => fact()), shipment()));
assert.throws(() => decode(group('and', ...Array.from({ length: 16 }, () => fact()), shipment())),
ShipmentClaimError);
const factGroup: TruthFact = { kind: 'or', terms: [
{ kind: 'spice', compare: 'gte', value: 5 },
{ kind: 'spice', compare: 'gte', value: 10 },
] };
const embedded: MixedShipmentExpression = { kind: 'fact', fact: factGroup };
decode(group('and', ...Array.from({ length: 7 }, () => embedded), fact(), shipment()));
assert.throws(() => decode(group('and', ...Array.from({ length: 8 }, () => embedded), shipment())),
ShipmentClaimError);
let nested: MixedShipmentExpression = embedded;
for (let n = 0; n < 3; n++) nested = group('or', shipment(), nested);
decode(nested); // Three mixed groups plus the embedded fact group.
assert.throws(() => decode(group('and', shipment(), nested)), ShipmentClaimError);
});
void test('mixed decoding keeps the supplied fact legality authority and rejects malformed grouping or shipment leaves', () => {
const forbidden = new Error('Fact is unavailable to this respondent.');
assert.throws(() => parseMixedShipmentExpression(group('and', fact(), shipment()), () => {
throw forbidden;
}), (error) => error === forbidden);
for (const value of [
fact(), shipment(), group('and', fact()), group('or'),
{ kind: 'xor', terms: [fact(), shipment()] },
group('and', fact(), shipment('missing', 4)),
group('and', fact(), shipment('carthag', 0)),
group('and', fact(), shipment('carthag', 21)),
group('and', fact(), shipment('carthag', 1.5)),
{ kind: 'and', terms: [fact(), shipment()], constant: 'yes' },
]) assert.throws(() => decode(value), ShipmentClaimError);
});