Support mixed current-fact and shipment Truthtrance questions
This commit is contained in:
1 parent
0ceb28bd31
commit
cd985c7c34
20 files changed
+982
-326
No files matched your search
@@ -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)</);
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}]};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user