Support Truthtrance No-Field shipment promises
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 10:07:00 +11:00
1 parent 9ab9b9a613
commit 7fccd62814
16 files changed
+658 -32

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
matchesShipment,
parseShipmentClaim,
parseShipmentExpression,
shipmentClaimDestinations,
shipmentClaimHasNoField,
shipmentClaimText,
ShipmentClaimError,
} from '../game/shipment-promises';
import { TERRITORIES } from '../game/board';
import { shipmentExpressionInputError, shipmentExpressionInputSize } from '../game/shipment-expression-input';
void test('a No-Field shipment statement keeps identity and denomination private', () => {
const wildcard = parseShipmentClaim({ claim: { kind: 'noField' } });
assert.deepEqual(wildcard, { claim: { kind: 'noField' } });
assert.equal(shipmentClaimHasNoField(wildcard), true);
assert.deepEqual(shipmentClaimDestinations(wildcard), TERRITORIES.map(t => t.id));
assert.equal(shipmentClaimText(wildcard), 'ship your No-Field from your reserves');
assert.equal(matchesShipment(wildcard, null), false);
assert.equal(matchesShipment(wildcard, { territory: 'carthag', sector: 11, amount: 1 }), false);
assert.equal(matchesShipment(wildcard, { territory: 'carthag', sector: 11, amount: 0, noField: true }), true);
assert.equal(shipmentExpressionInputError({ kind: 'noField' }), null);
assert.deepEqual(shipmentExpressionInputSize({ kind: 'noField' }), { leaves: 1, depth: 0 });
const target = parseShipmentClaim({ claim: { kind: 'noField', territory: 'carthag', sector: 11 } });
assert.deepEqual(shipmentClaimDestinations(target), ['carthag']);
assert.match(shipmentClaimText(target), /Carthag, sector 11/);
assert.equal(matchesShipment(target, { territory: 'carthag', sector: 11, amount: 0, noField: true }), true);
assert.equal(matchesShipment(target, { territory: 'carthag', sector: 10, amount: 0, noField: true }), false);
assert.equal(matchesShipment(target, { territory: 'arrakeen', sector: 10, amount: 0, noField: true }), false);
});
void test('No-Field and physical clauses constrain the same real shipment', () => {
const claim = parseShipmentClaim({ claim: { op: 'and', terms: [
{ kind: 'noField', territory: 'carthag' },
{ territory: 'carthag', minimum: 2 },
] } });
assert.equal(matchesShipment(claim, { territory: 'carthag', sector: 11, amount: 2, noField: true }), true);
assert.equal(matchesShipment(claim, { territory: 'carthag', sector: 11, amount: 1, noField: true }), false);
assert.equal(matchesShipment(claim, { territory: 'carthag', sector: 11, amount: 2 }), false);
assert.equal(matchesShipment(claim, null), false);
const old = parseShipmentClaim({ territory: 'carthag', minimum: 1 });
assert.equal(shipmentClaimHasNoField(old), false);
assert.equal(shipmentClaimHasNoField(claim), true);
assert.equal(matchesShipment(old, { territory: 'carthag', amount: 0, noField: true }), false);
assert.equal(matchesShipment(old, { territory: 'carthag', amount: 1 }), true);
const either = parseShipmentClaim({ claim: { op: 'or', terms: [
{ kind: 'noField' }, { territory: 'arrakeen', minimum: 3 },
] } });
assert.equal(matchesShipment(either, { territory: 'carthag', amount: 0, noField: true }), true);
assert.equal(matchesShipment(either, { territory: 'arrakeen', amount: 3 }), true);
assert.equal(matchesShipment(either, null), false);
});
void test('No-Field statements reject secret identifiers, invalid destinations and standalone sectors', () => {
for (const bad of [
{ kind: 'noField', token: 'secret' }, { kind: 'noField', value: 3 },
{ kind: 'noField', sector: 11 }, { kind: 'noField', territory: 'not-a-territory' },
{ kind: 'noField', territory: 'carthag', sector: 10 },
{ kind: 'noField', territory: 'carthag', minimum: 1 },
]) assert.throws(() => parseShipmentExpression(bad), ShipmentClaimError);
assert.match(shipmentExpressionInputError({ kind: 'noField', sector: 11 })!, /No-Field shipment/);
});
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ShipmentExpressionFields } from '../components/shipment-expression-fields';
import { TruthShipmentQuestion } from '../components/truth-shipment-question';
import { TruthNoFieldShipmentFields, type NoFieldShipmentInput } from '../components/truth-no-field-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action } from '../game/engine';
import type { ShipmentExpression } from '../game/shipment-promises';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function byId(tree: ReactNode, id: string): Element {
const element = elements(tree).find(item => item.props.id === id);
assert.ok(element, `Expected ${id}`);
return element;
}
function choose(element: Element, value: string) {
(element.props.onChange as (event: {currentTarget: {value: string}}) => void)({currentTarget: {value}});
}
void test('No-Field condition authors any destination or one printed territory and sector', () => {
let value: NoFieldShipmentInput = {kind: 'noField'};
const tree = () => TruthNoFieldShipmentFields({id: 'marker', value, disabled: false, onChange(next) { value = next; }});
const html = renderToStaticMarkup(createElement(TruthNoFieldShipmentFields, {id: 'marker', value, disabled: false, onChange() {}}));
assert.match(html, /Any destination/);
assert.doesNotMatch(html, /token ID|token value/i);
choose(byId(tree(), 'marker-no-field-territory'), 'arrakeen');
assert.deepEqual(value, {kind:'noField', territory:'arrakeen'});
choose(byId(tree(), 'marker-no-field-sector'), '10');
assert.deepEqual(value, {kind:'noField', territory:'arrakeen', sector:10});
choose(byId(tree(), 'marker-no-field-territory'), 'carthag');
assert.deepEqual(value, {kind:'noField', territory:'carthag'}, 'changing destination removes the old sector');
choose(byId(tree(), 'marker-no-field-sector'), '10');
assert.deepEqual(value, {kind:'noField', territory:'carthag'}, 'invalid sector is ignored');
choose(byId(tree(), 'marker-no-field-territory'), '');
assert.deepEqual(value, {kind:'noField'});
});
void test('disabled marker fields ignore direct callback invocation', () => {
let changes = 0;
const value: NoFieldShipmentInput = {kind:'noField', territory:'arrakeen', sector:10};
const tree = TruthNoFieldShipmentFields({id:'locked', value, disabled:true, onChange() { changes++; }});
assert.equal(byId(tree, 'locked-no-field-territory').props.disabled, true);
assert.equal(byId(tree, 'locked-no-field-sector').props.disabled, true);
choose(byId(tree, 'locked-no-field-territory'), 'carthag');
choose(byId(tree, 'locked-no-field-sector'), '');
assert.equal(changes, 0);
});
void test('mixed shipment expression can change one condition to No-Field without replacing the physical condition', () => {
let value: ShipmentExpression = {op:'or', terms:[{territory:'carthag',minimum:2}, {territory:'arrakeen',minimum:3}]};
const tree = () => ShipmentExpressionFields({value, id:'question', disabled:false, allowNoField:true, onChange(next) { value = next; }});
choose(byId(tree(), 'question-1.2-condition-type'), 'noField');
assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {kind:'noField'}]});
const marker = elements(tree()).find(element => element.type === TruthNoFieldShipmentFields);
assert.ok(marker);
(marker.props.onChange as (claim: NoFieldShipmentInput) => void)({kind:'noField',territory:'arrakeen',sector:10});
assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {kind:'noField',territory:'arrakeen',sector:10}]});
choose(byId(tree(), 'question-1.2-condition-type'), 'physical');
assert.deepEqual(value, {op:'or', terms:[{territory:'carthag',minimum:2}, {territory:'carthag',minimum:1}]});
});
void test('a non-Richese target cannot add a No-Field condition and can remove a previously selected one', () => {
let value: ShipmentExpression = {kind:'noField'};
const tree = () => ShipmentExpressionFields({value, id:'other', disabled:false, allowNoField:false, onChange(next) { value = next; }});
const html = renderToStaticMarkup(createElement(ShipmentExpressionFields, {value, id:'other', disabled:false, allowNoField:false, onChange() {}}));
assert.match(html, /Choose Richese for a No-Field shipment condition/);
choose(byId(tree(), 'other-1-condition-type'), 'physical');
assert.deepEqual(value, {territory:'carthag',minimum:1});
assert.equal(elements(tree()).some(element => element.props.id === 'other-1-condition-type'), false);
});
void test('shipment question asks an exact No-Field claim for Richese and blocks it for another faction', () => {
const source = createGame('MARKERQUESTION', newPlayer('asker','Asker','atreides'), false, ['choam']);
joinGame(source, newPlayer('richese','Richese','richese'));
joinGame(source, newPlayer('emperor','Emperor','emperor'));
const game = viewGame(source, 'asker');
game.truthtrance = {stage:'ask', queue:[{player:'asker',card:'truth'}], passed:[], question:null};
game.truthShipmentTargets = ['richese','emperor'];
let target = 'richese';
let claim: ShipmentExpression = {territory:'carthag',minimum:1};
const actions: Action[] = [];
const question = () => ({kind:'shipment' as const, target, claim});
const wrapper = () => TruthShipmentQuestion({game, question:question(), busy:false,
act(action) { actions.push(action); }, onChange(next) { claim = next; }});
const fieldTree = () => {
const field = elements(wrapper()).find(element => element.type === ShipmentExpressionFields);
assert.ok(field);
return ShipmentExpressionFields(field.props as Parameters<typeof ShipmentExpressionFields>[0]);
};
const button = () => {
const found = elements(wrapper()).find(element => element.props['data-testid'] === 'truth-shipment-ask');
assert.ok(found);
return found;
};
choose(byId(fieldTree(), 'truth-shipment-1-condition-type'), 'noField');
assert.deepEqual(claim, {kind:'noField'});
const marker = elements(fieldTree()).find(element => element.type === TruthNoFieldShipmentFields);
assert.ok(marker);
(marker.props.onChange as (value: NoFieldShipmentInput) => void)({kind:'noField', territory:'arrakeen', sector:10});
assert.equal(button().props.disabled, false);
(button().props.onClick as () => void)();
assert.deepEqual(actions, [{type:'truthAsk', question:{kind:'shipment', target:'richese',
claim:{kind:'noField', territory:'arrakeen', sector:10}}}]);
target = 'emperor';
assert.equal(button().props.disabled, true);
(button().props.onClick as () => void)();
assert.equal(actions.length, 1);
choose(byId(fieldTree(), 'truth-shipment-1-condition-type'), 'physical');
assert.deepEqual(claim, {territory:'carthag',minimum:1});
assert.equal(button().props.disabled, false);
});
@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { deployRicheseNoField } from '../game/richese-no-field';
import type { TruthQuestion } from '../game/truthtrance';
import {
createTruthtranceRichesePromisesFixture as fixture,
actTruthtranceRichesePromises as act,
askTruthtranceRichesePromises as ask,
advanceTruthtranceRichesePromises as advance,
truthtranceRichesePromisesClean as clean,
truthtranceRichesePromisesPlayer as player,
truthtranceRichesePromisesInventory as inventory,
} from './fixture-truthtrance-richese-promises';
const markerQuestion = {
kind: 'shipment', target: 'richese',
claim: {kind: 'noField', territory: 'carthag', sector: 11},
} as unknown as TruthQuestion;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const seat = (game: Game, id: string) => {
const found = game.players.find(row => row.id === id); assert.ok(found); return found;
};
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function start(answer: 'yes' | 'no') {
const f = fixture();
const question = ask(structuredClone(f.game), f, markerQuestion);
assert.ok(viewGame(question, f.asker).truthShipmentTargets.includes(f.target));
assert.deepEqual(viewGame(question, f.target).truthShipmentAnswers, ['yes', 'no']);
let game = act(question, f.target, {type:'truthAnswer', answer});
game = advance(game, clean);
assert.equal(game.shipmentPromises?.length, 1);
assert.equal(game.shipmentPromises![0].answer, answer === 'yes');
assert.equal(game.shipmentPromises![0].fulfilled, undefined);
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
return {f, game};
}
function markerAction(game: Game, value = 0, territory = 'carthag', sector = 11): Action {
const richese = player(game);
const token = richese.noField!.tokens.find(item => item.value === value); assert.ok(token);
assert.ok(richese.noFieldEvent);
return {type:'ship', noField:token.id, event:richese.noFieldEvent, territory, sector, allyPayment:0};
}
function finishResponse(game: Game): Game {
return advance(game, clean);
}
void test('Basic early Yes requires the requested owned No-Field, including its zero token', () => {
const {f, game: before} = start('yes');
const choice = markerAction(before), target = player(before), marker = String(choice.noField);
const ownView = viewGame(before, f.target), otherView = viewGame(before, f.asker);
assert.ok(ownView.shipmentCompletion?.actions.some(action => action.type === 'ship' && action.noField));
assert.equal(otherView.shipmentCompletion, null);
assert.equal(otherView.richeseNoField?.private, null);
assert.equal(JSON.stringify(otherView).includes(marker), false);
reject(before, f.target, {type:'endMovement'});
reject(before, f.target, {type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0});
reject(before, f.target, markerAction(before, 0, 'arrakeen', 10));
const declared = act(reload(before), f.target, choice);
const done = finishResponse(declared);
assert.equal(player(done).noField?.deployed?.tokenId, marker);
assert.deepEqual(player(done).noField?.deployed?.location, {territory:'carthag', sector:11});
assert.equal(player(done).reserves, target.reserves);
assert.equal(player(done).spice, target.spice - 1);
assert.equal(player(done).shipped, true);
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.equal(done.shipmentPromises![0].released, undefined);
assert.deepEqual(inventory(done), f.originalInventory);
assert.equal(viewGame(done, f.asker).richeseNoField?.private, null);
reject(done, f.target, choice);
});
void test('Basic No forbids the selected marker shipment but permits a physical shipment', () => {
const {f, game: before} = start('no');
reject(before, f.target, markerAction(before));
const done = finishResponse(act(before, f.target, {
type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0,
}));
assert.equal(player(done).forces['carthag:11'], 1);
assert.equal(player(done).noField?.deployed, null);
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('Basic No permits a marker in a different destination', () => {
const {f, game: before} = start('no');
const action = markerAction(before, 3, 'arrakeen', 10);
const done = finishResponse(act(before, f.target, action));
assert.deepEqual(player(done).noField?.deployed?.location, {territory:'arrakeen', sector:10});
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('an opposing Karama prevents the marker and releases an impossible Yes without spending shipment', () => {
const {f, game: original} = start('yes');
const game = structuredClone(original), opponent = seat(game, f.observer);
const from = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(from >= 0 && opponent.hand.length);
// Move an existing physical Karama into the observer's hand via a conserved
// card swap; the declaration and the resulting counter are actual actions.
[opponent.hand[0], game.deck[from]] = [game.deck[from], opponent.hand[0]];
const karama = opponent.hand[0].id, before = structuredClone(game);
const declared = act(game, f.target, markerAction(game));
assert.equal(declared.response?.kind, 'richeseNoField');
assert.equal(declared.shipmentPromises![0].fulfilled, undefined);
let stopped = act(declared, f.observer, {type:'card', mode:'cancel', card:karama});
stopped = finishResponse(stopped);
assert.equal(player(stopped).noField?.deployed, null);
assert.equal(player(stopped).noFieldBlockedTurn, stopped.turn);
assert.equal(player(stopped).shipped, false);
assert.equal(player(stopped).spice, player(before).spice);
assert.equal(stopped.shipmentPromises![0].released, true);
assert.equal(stopped.shipmentPromises![0].fulfilled, undefined);
reject(stopped, f.target, markerAction(stopped));
const normal = finishResponse(act(stopped, f.target, {
type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0,
}));
assert.equal(player(normal).forces['carthag:11'], 1);
assert.deepEqual(inventory(normal), f.originalInventory);
});
void test('all existing bot profiles use the same private feasible answers and legal marker completion', () => {
const f = fixture(), asked = ask(structuredClone(f.game), f, markerQuestion);
for (const difficulty of DIFFICULTIES) {
const answering = viewGame(asked, f.target);
answering.players.find(row => row.id === f.target)!.bot = difficulty;
const answer = botActions(answering).find(action => action.type === 'truthAnswer');
assert.ok(answer && answering.truthShipmentAnswers?.includes(answer.answer as 'yes' | 'no' | 'unknown'));
assert.doesNotThrow(() => act(reload(asked), f.target, answer));
const {game} = start('yes');
const turn = viewGame(game, f.target);
turn.players.find(row => row.id === f.target)!.bot = difficulty;
const action = botActions(turn)[0]; assert.ok(action, difficulty);
assert.doesNotThrow(() => act(reload(game), f.target, action));
}
});
void test('a marker-only declaration does not satisfy an existing physical-force promise', () => {
const f = fixture();
let game = ask(structuredClone(f.game), f);
game = act(game, f.target, {type:'truthAnswer', answer:'yes'});
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
reject(game, f.target, markerAction(game));
const done = finishResponse(act(game, f.target, f.shipmentAction));
assert.equal(player(done).forces['carthag:11'], 5);
assert.equal(done.shipmentPromises![0].fulfilled, true);
});
void test('Basic cannot promise a single shipment is both a marker and physical-only forces', () => {
const f = fixture();
const question = {
kind:'shipment', target:f.target, claim:{op:'and', terms:[
{kind:'noField', territory:'carthag'}, {territory:'carthag', minimum:1},
]},
} as unknown as TruthQuestion;
const asked = ask(structuredClone(f.game), f, question);
assert.deepEqual(viewGame(asked, f.target).truthShipmentAnswers, ['no']);
reject(asked, f.target, {type:'truthAnswer', answer:'yes'});
const done = act(asked, f.target, {type:'truthAnswer', answer:'no'});
assert.equal(done.shipmentPromises![0].answer, false);
});
void test('the marker question cannot target a faction that does not own a No-Field', () => {
const f = fixture();
let game = act(structuredClone(f.game), f.asker, {type:'card', card:f.truthtrance});
game = advance(game, state => state.truthtrance?.stage === 'ask');
const question = {kind:'shipment', target:f.observer, claim:{kind:'noField', territory:'carthag'}};
reject(game, f.asker, {type:'truthAsk', question});
});
void test('a deployed marker can be revealed before the promised different No-Field is shipped', () => {
const f = fixture(), staged = structuredClone(f.game), richese = player(staged);
const previous = richese.noField!.tokens.find(item => item.value === 3); assert.ok(previous);
// Model an earlier turn's original marker with the same physical token and
// history; staging does not create forces or change reserves.
richese.noField = deployRicheseNoField(richese.noField!, {
tokenId: previous.id, controller: richese.id,
location: {territory:'polar_sink', sector:0},
});
let game = ask(staged, f, markerQuestion);
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
game = act(game, f.target, {type:'truthAnswer', answer:'yes'});
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
const first = viewGame(game, f.target).shipmentCompletion?.actions[0];
assert.equal(first?.type, 'revealNoField');
assert.equal(first?.token, previous.id);
game = act(game, f.target, first!);
assert.equal(player(game).noField?.deployed, null);
assert.equal(player(game).noField?.lastShipped, previous.id);
assert.equal(player(game).forces['polar_sink:0'], 3);
const next = viewGame(game, f.target).shipmentCompletion?.actions[0];
assert.equal(next?.type, 'ship');
assert.ok(next?.noField && next.noField !== previous.id);
game = finishResponse(act(game, f.target, next!));
assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.equal(player(game).noField?.deployed?.tokenId, next!.noField);
assert.deepEqual(player(game).noField?.deployed?.location, {territory:'carthag', sector:11});
});
function givePhysicalKarama(game: Game, id: string): string {
const target = seat(game, id), held = target.hand.find(card => card.effect === 'karama');
if (held) return held.id;
assert.ok(target.hand.length);
const inDeck = game.deck.findIndex(card => card.effect === 'karama');
if (inDeck >= 0) {
[target.hand[0], game.deck[inDeck]] = [game.deck[inDeck], target.hand[0]];
return target.hand[0].id;
}
for (const other of game.players) {
if (other.id === id) continue;
const index = other.hand.findIndex(card => card.effect === 'karama');
if (index >= 0) {
[target.hand[0], other.hand[index]] = [other.hand[index], target.hand[0]];
return target.hand[0].id;
}
}
throw Error('No original physical Karama was available for the fixture');
}
void test('Richese cannot cancel its own promised No-Field while another responder holds Karama', () => {
const game = start('yes').game, karama = givePhysicalKarama(game, 'richese');
const opposing = givePhysicalKarama(game, 'emperor');
assert.notEqual(karama, opposing);
assert.ok(player(game).hand.some(card => card.id === karama));
const declared = act(game, 'richese', markerAction(game));
assert.equal(declared.response?.kind, 'richeseNoField');
reject(declared, 'richese', {type:'card', mode:'cancel', card:karama});
assert.equal(declared.pendingKull ?? null, null);
assert.equal(declared.pendingTreacheryDiscard ?? null, null);
assert.ok(player(declared).hand.some(card => card.id === karama));
assert.equal(declared.shipmentPromises![0].released, undefined);
assert.equal(declared.shipmentPromises![0].fulfilled, undefined);
});