Add enforceable current-turn travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s
Verify and publish checkpoint container / container (push) Successful in 10m49s
This commit is contained in:
1 parent
ac1f32c859
commit
82c639c977
13 files changed
+777
-28
No files matched your search
@@ -7,6 +7,8 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import ts from 'typescript';
|
||||
import * as catalog from '../game/catalog';
|
||||
import * as board from '../game/board';
|
||||
import * as coalitionAlliance from '../game/coalition-alliance';
|
||||
import * as publicDeals from '../game/public-deals';
|
||||
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
@@ -99,6 +101,8 @@ function controller(game: GameView) {
|
||||
};
|
||||
if (name === '@/components/ui/button') return { Button: 'button' };
|
||||
if (name === '@/game/catalog') return catalog;
|
||||
if (name === '@/game/board') return board;
|
||||
if (name === '@/game/coalition-alliance') return coalitionAlliance;
|
||||
if (name === '@/game/public-deals') return publicDeals;
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ import { isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import ts from 'typescript';
|
||||
import * as catalog from '../game/catalog';
|
||||
import * as board from '../game/board';
|
||||
import * as coalitionAlliance from '../game/coalition-alliance';
|
||||
import * as publicDeals from '../game/public-deals';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
import type { PublicDeals } from '../components/public-deals';
|
||||
@@ -56,6 +58,8 @@ function controller() {
|
||||
};
|
||||
if (name === '@/components/ui/button') return { Button: 'button' };
|
||||
if (name === '@/game/catalog') return catalog;
|
||||
if (name === '@/game/board') return board;
|
||||
if (name === '@/game/coalition-alliance') return coalitionAlliance;
|
||||
if (name === '@/game/public-deals') return publicDeals;
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
|
||||
@@ -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 { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
|
||||
import { TravelPromises } from '../components/travel-promises';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{children?:ReactNode; [key:string]:unknown}>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
const travel = {kind:'ship', territory:'carthag', minimum:3, answer:true} as const;
|
||||
const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const;
|
||||
const deal = {id:'deal', from:'a', to:'b', terms:'The agreed journey.', turn:2,
|
||||
status:'active', releaseVotes:[], travel};
|
||||
const base = {status:'playing', turn:2, me:'b', roomControl:null, coalitionState:null,
|
||||
players:[{id:'a',name:'Alice',faction:'atreides',ally:null}, {id:'b',name:'Basil',faction:'harkonnen',ally:null}],
|
||||
deals:[deal], dealTravelStatus:{deal:'pending'}} as unknown as GameView;
|
||||
const markup = (game:GameView, disabled = false) =>
|
||||
renderToStaticMarkup(createElement(PublicDealList, {game, act() {}, disabled}));
|
||||
|
||||
void test('the offer packet retains the original recipient action and validates one exact physical term', () => {
|
||||
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', undefined, 'a', 2, travel),
|
||||
{type:'dealOffer', target:'b', terms:'Terms', travel});
|
||||
const avoid = {...travel, kind:'move' as const, answer:false, minimum:20};
|
||||
assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid),
|
||||
{type:'dealOffer', target:'b', terms:'Avoid', travel:avoid});
|
||||
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel), null);
|
||||
assert.equal(publicDealOfferAction(['a'], 'Terms', undefined, 'a', 2, travel), null);
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, undefined, 2, travel), null);
|
||||
for (const invalid of [
|
||||
{...travel, minimum:0}, {...travel, minimum:21}, {...travel, minimum:1.5},
|
||||
{...travel, territory:'not-a-territory'}, {...travel, answer:'yes'},
|
||||
{...travel, kind:'ride'}, {...travel, extra:true},
|
||||
]) assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2,
|
||||
invalid as unknown as typeof travel), null);
|
||||
// Older callers retain their original exact wire packets.
|
||||
assert.deepEqual(publicDealOfferAction(['b'], ' Terms '), {type:'dealOffer', target:'b', terms:'Terms'});
|
||||
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms'), {type:'dealOffer', targets:['b','c'], terms:'Terms'});
|
||||
});
|
||||
|
||||
void test('the public deal shows the exact recipient, travel intent, turn and authoritative status', () => {
|
||||
const html = markup(base);
|
||||
assert.match(html, /Current-turn travel term/);
|
||||
assert.match(html, /Basil \(Harkonnen\) agrees to make a reserve shipment of at least 3 physical forces in one action to Carthag during turn 2/);
|
||||
assert.match(html, /Travel status: pending/);
|
||||
const avoid = {...base, me:'a', deals:[{...deal, travel:{kind:'move',territory:'arrakeen',minimum:2,answer:false}}],
|
||||
dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView;
|
||||
assert.match(markup(avoid), /agrees to avoid a ground movement of at least 2 physical forces in one action to Arrakeen/);
|
||||
assert.match(markup(avoid), /Travel status: fulfilled/);
|
||||
for (const status of ['void','released'] as const)
|
||||
assert.match(markup({...base, dealTravelStatus:{deal:status}} as unknown as GameView), new RegExp(`Travel status: ${status}`));
|
||||
});
|
||||
|
||||
void test('only the obligated recipient gets a persistent pending reminder', () => {
|
||||
assert.deepEqual(publicDealRequests(base), [['travel','deal']]);
|
||||
assert.match(renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})), /Public deals · travel term pending/);
|
||||
assert.deepEqual(publicDealRequests({...base,me:'a'}), []);
|
||||
assert.deepEqual(publicDealRequests({...base,dealTravelStatus:{deal:'fulfilled'}} as unknown as GameView), []);
|
||||
assert.deepEqual(publicDealRequests({...base,deals:[{...deal,status:'released'}]} as unknown as GameView), []);
|
||||
});
|
||||
|
||||
void test('an offered travel term remains actionable to the recipient until its current turn has passed', () => {
|
||||
const offered = {...base,deals:[{...deal,status:'offered'}],dealTravelStatus:{}} as unknown as GameView;
|
||||
const actions:Action[] = [];
|
||||
const tree = PublicDealList({game:offered,disabled:false,act(action) {actions.push(action);}});
|
||||
const accept = elements(tree).find(node => node.props.children === 'Accept deal');
|
||||
assert.ok(accept); assert.equal(accept.props.disabled,false);
|
||||
(accept.props.onClick as () => void)();
|
||||
assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:true}]);
|
||||
const expired = {...offered,turn:3};
|
||||
assert.match(markup(expired), /travel term’s turn has passed/);
|
||||
const expiredTree = PublicDealList({game:expired,disabled:false,act() {}});
|
||||
assert.equal(elements(expiredTree).find(node => node.props.children === 'Accept deal')?.props.disabled,true);
|
||||
assert.equal(elements(expiredTree).find(node => node.props.children === 'Decline deal')?.props.disabled,false);
|
||||
});
|
||||
|
||||
void test('declined and withdrawn travel offers do not claim to be awaiting acceptance', () => {
|
||||
for (const status of ['declined', 'withdrawn'] as const) {
|
||||
const closed = {...base, deals:[{...deal, status}], dealTravelStatus:{}} as unknown as GameView;
|
||||
const html = markup(closed);
|
||||
assert.match(html, /The term did not become binding/);
|
||||
assert.match(html, /Basil \(Harkonnen\) would make a reserve shipment/);
|
||||
assert.doesNotMatch(html, /Basil \(Harkonnen\) agrees to/);
|
||||
assert.doesNotMatch(html, /The term becomes binding on final acceptance/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the recipient guide distinguishes a void deal from unanimous release', () => {
|
||||
// Staged public projections isolate presentation; native creation and lifecycle
|
||||
// are exercised by the separate runtime integration cases.
|
||||
const promise = {event:'travel-event', sourceDeal:'deal', player:'b', asker:'a', turn:2,
|
||||
mixed:{kind:'action', action:{kind:'ship', territory:'carthag', minimum:3}},
|
||||
answer:true, fulfilled:false, released:true};
|
||||
for (const [status, expected, absent] of [
|
||||
['void', /Void: this agreement is no longer possible/, /Released: this whole commitment/],
|
||||
['released', /Released: this whole commitment is no longer binding/, /Void: this agreement/],
|
||||
] as const) {
|
||||
const game = {...base, allLeaders:[], combatLocations:[{id:'carthag',name:'Carthag'}],
|
||||
travelPromises:[promise], dealTravelStatus:{deal:status}} as unknown as GameView;
|
||||
const html = renderToStaticMarkup(createElement(TravelPromises,{game, busy:false, act() {}}));
|
||||
assert.match(html, /Public deal: agreed Yes/);
|
||||
assert.match(html, expected);
|
||||
assert.doesNotMatch(html, absent);
|
||||
assert.match(html, /after the answer or deal acceptance/);
|
||||
assert.equal(TravelPromises({game:{...game, me:'a'}, busy:false, act() {}}), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the real offer form explains single-recipient eligibility before a travel term is selected', () => {
|
||||
const html = renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false}));
|
||||
assert.match(html, /Include a current-turn travel term/);
|
||||
assert.match(html, /Select exactly one human recipient to add a travel term/);
|
||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { actTruthTravel as act, createTruthTravelFixture as fixture,
|
||||
advanceTruthTravel as advance, settleTruthTravel as settle, truthTravelInventory as inventory,
|
||||
truthTravelPlayer as player } from './fixture-truth-travel';
|
||||
import { bindTruthBribe, createTruthBribeFixture } from './fixture-truth-bribe';
|
||||
import { createTruthMixedBribeFixture, declareTruthMixedBribe } from './fixture-truth-mixed-bribe';
|
||||
|
||||
type Term = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean };
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected actions do not modify the table.');
|
||||
}
|
||||
function offer(game: Game, proposer: string, target: string, travel: Term) {
|
||||
const next = act(game, proposer, { type: 'dealOffer', target, terms: 'Agreed travel this turn.', travel });
|
||||
const deal = next.deals?.at(-1); assert.ok(deal);
|
||||
return { game: next, id: deal.id };
|
||||
}
|
||||
function accept(game: Game, target: string, id: string) {
|
||||
return act(game, target, { type: 'dealRespond', deal: id, accept: true });
|
||||
}
|
||||
function status(game: Game, viewer: string, id: string) {
|
||||
return viewGame(game, viewer).dealTravelStatus[id];
|
||||
}
|
||||
function receipt(game: Game, id: string) {
|
||||
const found = game.travelPromises?.find(row => row.sourceDeal === id);
|
||||
assert.ok(found, 'Final consent creates a source-linked native travel receipt.');
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Original Basic base33 setup and real first-turn phase-five prefix from the
|
||||
* native TruthTravel fixture. Only its unopened deal/Spice order is selected;
|
||||
* these tests offer and accept the agreement through real player actions. */
|
||||
void test('accepted shipment Yes preserves the real shipment, forbids divergence and settles once', () => {
|
||||
const f = fixture(), before = f.game;
|
||||
const term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
|
||||
const offered = offer(before, f.asker, f.target, term);
|
||||
assert.equal(offered.game.travelPromises?.length ?? 0, 0, 'An offer alone creates no commitment.');
|
||||
const game = accept(offered.game, f.target, offered.id);
|
||||
assert.equal(receipt(game, offered.id).player, f.target);
|
||||
assert.equal(receipt(game, offered.id).asker, f.asker);
|
||||
for (const viewer of [f.asker, f.target]) assert.equal(status(game, viewer, offered.id), 'pending');
|
||||
reject(game, f.target, { ...f.shipmentAction, territory: 'carthag', sector: 11 });
|
||||
reject(game, f.target, f.currentEnd);
|
||||
const next = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(player(next, f.target).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(next, f.target).reserves, player(before, f.target).reserves - 1);
|
||||
assert.equal(status(next, f.asker, offered.id), 'fulfilled');
|
||||
assert.equal(status(next, f.target, offered.id), 'fulfilled');
|
||||
assert.equal(next.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
|
||||
assert.deepEqual(inventory(next), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a negative shipment term forbids its matching physical shipment and settles after closing', () => {
|
||||
const f = fixture(), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: false });
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
assert.equal(status(game, f.target, offered.id), 'pending');
|
||||
reject(game, f.target, f.shipmentAction);
|
||||
game = settle(act(game, f.target, f.currentEnd));
|
||||
assert.equal(status(game, f.target, offered.id), 'fulfilled');
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'] ?? 0, 0);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
for (const answer of [true, false]) void test(`accepted movement ${answer ? 'Yes' : 'No'} uses the original physical ground action`, () => {
|
||||
const f = fixture({ window: 'shipped' });
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'move', territory: f.movementTerritory, minimum: 1, answer });
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
assert.equal(receipt(game, offered.id).player, f.target);
|
||||
assert.equal(status(game, f.target, offered.id), 'pending');
|
||||
if (answer) {
|
||||
reject(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
} else {
|
||||
reject(game, f.target, f.firstMove);
|
||||
game = settle(act(game, f.target, f.currentEnd));
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'] ?? 0, 0);
|
||||
}
|
||||
assert.equal(status(game, f.asker, offered.id), 'fulfilled');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('both parties must release a pending obligation and only then may its target close', () => {
|
||||
const f = fixture(), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
|
||||
assert.equal(status(game, f.target, offered.id), 'pending');
|
||||
reject(game, f.target, f.currentEnd);
|
||||
game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
|
||||
assert.equal(status(game, f.target, offered.id), 'released');
|
||||
assert.equal(receipt(game, offered.id).released, true);
|
||||
game = settle(act(game, f.target, f.currentEnd));
|
||||
assert.equal(status(game, f.asker, offered.id), 'released');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a released deal cannot retain an unfulfilled live travel receipt', () => {
|
||||
const f = fixture(), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
|
||||
game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
|
||||
assert.equal(status(game, f.target, offered.id), 'released');
|
||||
assert.equal(receipt(game, offered.id).fulfilled, false);
|
||||
assert.equal(receipt(game, offered.id).released, true);
|
||||
const corrupted = structuredClone(game);
|
||||
receipt(corrupted, offered.id).released = false;
|
||||
const before = structuredClone(corrupted);
|
||||
assert.throws(() => viewGame(corrupted, f.target));
|
||||
assert.deepEqual(corrupted, before);
|
||||
reject(corrupted, f.target, f.currentEnd);
|
||||
});
|
||||
|
||||
void test('travel terms reject payment combinations, multiparty packets and malformed claims', () => {
|
||||
const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
|
||||
const basic = { type: 'dealOffer', target: f.target, terms: 'Agreed travel.', travel: term } satisfies Action;
|
||||
reject(f.game, f.asker, { ...basic, payment: { kind: 'deferredBribe', payer: f.target,
|
||||
recipient: f.asker, amount: 1, dueTurn: f.game.turn } });
|
||||
reject(f.game, f.asker, { type: 'dealOffer', targets: [f.target, f.asker], terms: 'Travel.', travel: term });
|
||||
reject(f.game, f.asker, { ...basic, travel: { ...term, minimum: 0 } });
|
||||
reject(f.game, f.asker, { ...basic, travel: { ...term, answer: 'yes' } });
|
||||
reject(f.game, f.asker, { ...basic, travel: { ...term, territory: 'not_a_territory' } });
|
||||
});
|
||||
|
||||
void test('two compatible accepted agreements share one real qualifying shipment', () => {
|
||||
const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
|
||||
const first = offer(f.game, f.asker, f.target, term);
|
||||
let game = accept(first.game, f.target, first.id);
|
||||
const second = offer(game, f.asker, f.target, term);
|
||||
game = accept(second.game, f.target, second.id);
|
||||
assert.notEqual(receipt(game, first.id).event, receipt(game, second.id).event);
|
||||
assert.equal(status(game, f.target, first.id), 'pending');
|
||||
assert.equal(status(game, f.target, second.id), 'pending');
|
||||
const next = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(status(next, f.target, first.id), 'fulfilled');
|
||||
assert.equal(status(next, f.target, second.id), 'fulfilled');
|
||||
assert.equal(player(next, f.target).reserves, player(f.game, f.target).reserves - 1);
|
||||
assert.deepEqual(inventory(next), f.originalCards);
|
||||
});
|
||||
|
||||
void test('final consent to a conflicting obligation rolls back without disturbing the original', () => {
|
||||
const f = fixture(), yes: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true };
|
||||
const first = offer(f.game, f.asker, f.target, yes);
|
||||
let game = accept(first.game, f.target, first.id);
|
||||
const second = offer(game, f.asker, f.target, { ...yes, answer: false });
|
||||
game = second.game;
|
||||
const before = structuredClone(game);
|
||||
reject(game, f.target, { type: 'dealRespond', deal: second.id, accept: true });
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(game.deals!.find(deal => deal.id === second.id)!.status, 'offered');
|
||||
assert.equal(game.travelPromises!.filter(row => row.sourceDeal === second.id).length, 0);
|
||||
assert.equal(status(game, f.target, first.id), 'pending');
|
||||
const completed = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(status(completed, f.target, first.id), 'fulfilled');
|
||||
});
|
||||
|
||||
void test('a pending travel term prevents final consent to a separate current-turn deferred bribe', () => {
|
||||
const f = fixture(), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
game = act(game, f.asker, { type: 'dealOffer', target: f.target, terms: 'A separate payment this turn.',
|
||||
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount: 1, dueTurn: game.turn } });
|
||||
const payment = game.deals!.at(-1)!;
|
||||
assert.notEqual(payment.id, offered.id);
|
||||
assert.equal(payment.status, 'offered');
|
||||
const before = structuredClone(game);
|
||||
reject(game, f.target, { type: 'dealRespond', deal: payment.id, accept: true });
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active');
|
||||
assert.equal(game.deals!.find(deal => deal.id === payment.id)!.status, 'offered');
|
||||
assert.equal(status(game, f.target, offered.id), 'pending');
|
||||
assert.equal(receipt(game, offered.id).released, false);
|
||||
const completed = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(status(completed, f.target, offered.id), 'fulfilled');
|
||||
});
|
||||
|
||||
void test('final travel consent rejects a target with a live native Truthtrance bribe promise', () => {
|
||||
const f = createTruthBribeFixture();
|
||||
const term: Term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true };
|
||||
// Control: the same original timing supports the travel deal by itself.
|
||||
const control = offer(f.game, f.asker, f.target, term);
|
||||
assert.equal(status(accept(control.game, f.target, control.id), f.target, control.id), 'pending');
|
||||
const bound = bindTruthBribe(f, 'yes').game;
|
||||
assert.ok(bound.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released));
|
||||
const offered = offer(bound, f.asker, f.target, term);
|
||||
const before = structuredClone(offered.game);
|
||||
reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
|
||||
assert.deepEqual(offered.game, before);
|
||||
assert.equal(offered.game.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
|
||||
assert.equal(offered.game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
assert.ok(offered.game.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released));
|
||||
});
|
||||
|
||||
void test('a pending public travel deal leaves simple and mixed bribe questions askable with only Unknown', () => {
|
||||
const f = createTruthMixedBribeFixture();
|
||||
const offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true });
|
||||
const accepted = accept(offered.game, f.target, offered.id);
|
||||
const asking = declareTruthMixedBribe(f, accepted).game;
|
||||
const simple: Action = { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
|
||||
turn: asking.turn, recipient: f.asker, minimum: 3 } };
|
||||
for (const [question, kind] of [[simple, 'simple'], [f.questionAction, 'mixed']] as const) {
|
||||
const asked = act(asking, f.asker, question), target = viewGame(asked, f.target);
|
||||
assert.deepEqual(kind === 'simple' ? target.truthBribeAnswers : target.truthMixedBribeAnswers, ['unknown']);
|
||||
reject(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
reject(asked, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
const answered = act(asked, f.target, { type: 'truthAnswer', answer: 'unknown' });
|
||||
assert.equal(answered.truthHistory!.at(-1)!.answer, 'unknown');
|
||||
assert.equal(answered.bribePromises?.length ?? 0, 0);
|
||||
assert.equal(answered.bribeExpressionPromises?.length ?? 0, 0);
|
||||
assert.equal(answered.mixedBribeQuestions?.length ?? 0, 0);
|
||||
assert.equal(status(answered, f.target, offered.id), 'pending');
|
||||
assert.equal(receipt(answered, offered.id).released, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an unanswered travel offer cannot acquire an obligation after its original turn', () => {
|
||||
const f = fixture(), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
|
||||
const nextTurn = advance(offered.game, game => game.turn > f.game.turn);
|
||||
assert.ok(nextTurn.turn > f.game.turn);
|
||||
reject(nextTurn, f.target, { type: 'dealRespond', deal: offered.id, accept: true });
|
||||
assert.equal(nextTurn.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
assert.equal(nextTurn.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
|
||||
assert.deepEqual(inventory(nextTurn), f.originalCards);
|
||||
});
|
||||
|
||||
void test('voluntary spice spending and premature ground movement cannot eliminate promised completion', () => {
|
||||
const f = fixture({ hajr: false }), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
|
||||
let game = accept(offered.game, f.target, offered.id);
|
||||
reject(game, f.target, { type: 'bribe', target: f.asker, amount: player(game, f.target).spice - 1 });
|
||||
reject(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.equal(status(game, f.target, offered.id), 'fulfilled');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
|
||||
const moved = fixture({ window: 'shipped', hajr: false });
|
||||
const ground = offer(moved.game, moved.asker, moved.target,
|
||||
{ kind: 'move', territory: moved.movementTerritory, minimum: 1, answer: true });
|
||||
let awaiting = accept(ground.game, moved.target, ground.id);
|
||||
reject(awaiting, moved.target, moved.secondMove);
|
||||
reject(awaiting, moved.target, moved.currentEnd);
|
||||
awaiting = settle(act(awaiting, moved.target, moved.firstMove));
|
||||
assert.equal(status(awaiting, moved.target, ground.id), 'fulfilled');
|
||||
});
|
||||
|
||||
void test('a staged external loss makes an impossible agreement void without retroactive consent', () => {
|
||||
const f = fixture(), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
|
||||
const accepted = accept(offered.game, f.target, offered.id);
|
||||
// Isolate the aftermath of a foreign resource loss; this is not claimed as
|
||||
// an actual opponent action in the two-player native chronology.
|
||||
const externallyChanged = structuredClone(accepted);
|
||||
player(externallyChanged, f.target).spice = 0;
|
||||
const reconciled = normalizeAutomaticGame(externallyChanged);
|
||||
assert.equal(status(reconciled, f.target, offered.id), 'void');
|
||||
assert.equal(receipt(reconciled, offered.id).released, true);
|
||||
assert.equal(reconciled.deals!.find(deal => deal.id === offered.id)!.status, 'active');
|
||||
assert.deepEqual(inventory(reconciled), f.originalCards);
|
||||
let released = act(reconciled, f.asker, { type: 'dealRelease', deal: offered.id, agree: true });
|
||||
assert.equal(status(released, f.target, offered.id), 'void');
|
||||
released = act(released, f.target, { type: 'dealRelease', deal: offered.id, agree: true });
|
||||
assert.equal(released.deals!.find(deal => deal.id === offered.id)!.status, 'released');
|
||||
assert.equal(status(released, f.asker, offered.id), 'void');
|
||||
assert.equal(status(released, f.target, offered.id), 'void');
|
||||
assert.notEqual(receipt(released, offered.id).releasedByDeal, true);
|
||||
});
|
||||
|
||||
void test('all four autopilot profiles follow an accepted shipment commitment through legal actions', () => {
|
||||
const f = fixture(), offered = offer(f.game, f.asker, f.target,
|
||||
{ kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true });
|
||||
const accepted = accept(offered.game, f.target, offered.id);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = act(accepted, f.target, { type: 'setAutopilot', difficulty });
|
||||
for (let step = 0; step < 8 && status(game, f.target, offered.id) === 'pending'; step++) {
|
||||
const actions = botActions(viewGame(game, f.target));
|
||||
assert.ok(actions.length, `${difficulty} needs a legal continuation`);
|
||||
game = settle(act(game, f.target, actions[0]));
|
||||
}
|
||||
assert.equal(status(game, f.target, offered.id), 'fulfilled', difficulty);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
|
||||
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
|
||||
type PublicDeal, type PublicDealSeat,
|
||||
} from '../game/public-deals';
|
||||
|
||||
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
const term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true } as const;
|
||||
const fresh = () => createPublicDeal(seats, 'a', 'b', 'Travel this turn.', 3, 'offer');
|
||||
|
||||
void test('travel terms accept only exact bounded native actions and an explicit answer', () => {
|
||||
for (const kind of ['ship', 'move'] as const) for (const answer of [true, false]) {
|
||||
for (const minimum of [1, 20]) {
|
||||
const input = { kind, territory: 'polar_sink', minimum, answer };
|
||||
assert.deepEqual(parsePublicDealTravel(input), input);
|
||||
assert.notEqual(parsePublicDealTravel(input), input);
|
||||
}
|
||||
}
|
||||
for (const invalid of [null, undefined, [], {},
|
||||
{ ...term, kind: 'fly' }, { ...term, territory: 'not_a_territory' },
|
||||
...[0, -1, 21, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(minimum => ({ ...term, minimum })),
|
||||
{ ...term, answer: 'yes' }, { ...term, answer: undefined },
|
||||
{ ...term, sector: 0 }, { ...term, turn: 4 }, { ...term, actor: 'a' },
|
||||
]) assert.throws(() => parsePublicDealTravel(invalid), PublicDealError);
|
||||
});
|
||||
|
||||
void test('a recipient accepts and both release an unchanged current-turn travel term', () => {
|
||||
const source = fresh();
|
||||
const input = { kind: 'ship' as const, territory: 'polar_sink', minimum: 1, answer: true };
|
||||
const offered = setPublicDealTravel(source, input, seats);
|
||||
input.minimum = 2;
|
||||
assert.equal(source.travel, undefined);
|
||||
assert.deepEqual(offered.travel, term);
|
||||
assert.equal(offered.turn, 3);
|
||||
assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError);
|
||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
||||
assert.equal(active.status, 'active');
|
||||
assert.deepEqual(active.travel, term);
|
||||
const first = releasePublicDeal(active, seats, 'a', true);
|
||||
assert.equal(first.status, 'active');
|
||||
const released = releasePublicDeal(first, seats, 'b', true);
|
||||
assert.equal(released.status, 'released');
|
||||
assert.deepEqual(released.travel, term);
|
||||
const roundtrip = JSON.parse(JSON.stringify([offered, active, released])) as PublicDeal[];
|
||||
// Ledger IDs must be unique; each lifecycle snapshot is validated separately.
|
||||
for (const deal of roundtrip) {
|
||||
const before = JSON.stringify(deal);
|
||||
validatePublicDeals([deal], seats);
|
||||
assert.equal(JSON.stringify(deal), before);
|
||||
assert.deepEqual(deal.travel, term);
|
||||
assert.equal(Object.hasOwn(deal, 'travelStatus'), false);
|
||||
}
|
||||
assert.deepEqual(respondPublicDeal(offered, seats, 'b', false).travel, term);
|
||||
assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term);
|
||||
});
|
||||
|
||||
void test('travel is incompatible with multi-party, payment, replacement and malformed persisted terms', () => {
|
||||
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');
|
||||
const payment = createPublicDeal(seats, 'a', 'b', 'Terms', 3, 'paid',
|
||||
{ kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 });
|
||||
const offered = setPublicDealTravel(fresh(), term, seats);
|
||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
||||
for (const deal of [multi, payment, offered, active])
|
||||
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
|
||||
for (const invalid of [
|
||||
{ ...multi, travel: term }, { ...payment, travel: term },
|
||||
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
|
||||
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
|
||||
]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError);
|
||||
});
|
||||
|
||||
void test('legacy two-party and multi-party records validate without acquiring travel fields', () => {
|
||||
const legacy = fresh();
|
||||
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');
|
||||
const before = JSON.stringify([legacy, multi]);
|
||||
validatePublicDeals([legacy, multi], seats);
|
||||
assert.equal(JSON.stringify([legacy, multi]), before);
|
||||
assert.equal(Object.hasOwn(legacy, 'travel'), false);
|
||||
assert.equal(Object.hasOwn(multi, 'travel'), false);
|
||||
});
|
||||
Reference in new issue
Block a user