Support scheduled travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m8s

This commit is contained in:
Quintine committed 2026-10-29 08:19:52 +11:00
1 parent e1e02fc1a1
commit 1e81ce8ad8
13 files changed
+767 -56

No files matched your search

@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { actTruthTravel as act, createTruthTravelFixture as fixture,
truthTravelInventory as inventory, truthTravelPlayer as player } from './fixture-truth-travel';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Conflicting consent must leave the source game unchanged.');
}
function travelOffer(game: Game, proposer: string, traveler: string, dueTurn: number) {
const next = act(game, proposer, {type:'dealOffer', target:traveler, terms:'Travel in a future turn.',
travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn}});
const deal = next.deals?.at(-1); assert.ok(deal);
return {game:next, id:deal.id};
}
function paymentOffer(game: Game, proposer: string, target: string, payer: string, recipient: string, dueTurn: number) {
const next = act(game, proposer, {type:'dealOffer', target, terms:'Pay a future bribe.',
payment:{kind:'deferredBribe', payer, recipient, amount:2, dueTurn}});
const deal = next.deals?.at(-1); assert.ok(deal);
return {game:next, id:deal.id};
}
function accept(game: Game, recipient: string, id: string) {
return act(game, recipient, {type:'dealRespond', deal:id, accept:true});
}
function deal(game: Game, id: string) {
const found = game.deals?.find(row => row.id === id); assert.ok(found); return found;
}
/** Each case uses the genuine Basic Atreides/Emperor setup and actual native
* first-turn pre-shipment window. These are independent two-party offers and
* acceptances; no wallet, force, pending debt or travel receipt is staged. */
void test('a same-turn deferred debt cannot be accepted after the payer schedules travel', () => {
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
const future = travelOffer(f.game, f.asker, f.target, dueTurn);
let game = accept(future.game, f.target, future.id);
assert.equal(viewGame(game, f.target).dealTravelStatus[future.id], 'scheduled');
const payment = paymentOffer(game, f.asker, f.target, f.target, f.asker, dueTurn);
game = payment.game;
const purse = player(game, f.target).spice;
reject(game, f.target, {type:'dealRespond', deal:payment.id, accept:true});
assert.equal(deal(game, future.id).status, 'active');
assert.equal(deal(game, payment.id).status, 'offered');
assert.equal(game.travelPromises?.filter(row => row.sourceDeal === future.id).length, 1);
assert.equal(player(game, f.target).spice, purse);
assert.deepEqual(inventory(game), originalCards);
});
void test('a same-turn scheduled journey cannot be accepted after the traveler owes a deferred debt', () => {
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
const payment = paymentOffer(f.game, f.asker, f.target, f.target, f.asker, dueTurn);
let game = accept(payment.game, f.target, payment.id);
assert.equal(deal(game, payment.id).paymentStatus, 'pending');
const future = travelOffer(game, f.asker, f.target, dueTurn);
game = future.game;
const purse = player(game, f.target).spice;
reject(game, f.target, {type:'dealRespond', deal:future.id, accept:true});
assert.equal(deal(game, payment.id).status, 'active');
assert.equal(deal(game, payment.id).paymentStatus, 'pending');
assert.equal(deal(game, future.id).status, 'offered');
assert.equal(game.travelPromises?.some(row => row.sourceDeal === future.id) ?? false, false);
assert.equal(player(game, f.target).spice, purse);
assert.deepEqual(inventory(game), originalCards);
});
for (const order of ['travel-first', 'payment-first'] as const) void test(
`different future due turns coexist when accepted ${order}`, () => {
const f = fixture(), travelDue = f.game.turn + 1, paymentDue = travelDue + 1;
let game = f.game, travelId: string, paymentId: string;
if (order === 'travel-first') {
const future = travelOffer(game, f.asker, f.target, travelDue); travelId = future.id;
game = accept(future.game, f.target, travelId);
const payment = paymentOffer(game, f.asker, f.target, f.target, f.asker, paymentDue); paymentId = payment.id;
game = accept(payment.game, f.target, paymentId);
} else {
const payment = paymentOffer(game, f.asker, f.target, f.target, f.asker, paymentDue); paymentId = payment.id;
game = accept(payment.game, f.target, paymentId);
const future = travelOffer(game, f.asker, f.target, travelDue); travelId = future.id;
game = accept(future.game, f.target, travelId);
}
assert.equal(deal(game, travelId).status, 'active');
assert.equal(viewGame(game, f.target).dealTravelStatus[travelId], 'scheduled');
assert.equal(game.travelPromises?.find(row => row.sourceDeal === travelId)?.turn, travelDue);
assert.equal(deal(game, paymentId).paymentStatus, 'pending');
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice);
assert.deepEqual(inventory(game), f.originalCards);
},
);
for (const order of ['travel-first', 'payment-first'] as const) void test(
`the same future due turn can involve a different payment payer when accepted ${order}`, () => {
const f = fixture(), dueTurn = f.game.turn + 1;
let game = f.game, travelId: string, paymentId: string;
if (order === 'travel-first') {
const future = travelOffer(game, f.asker, f.target, dueTurn); travelId = future.id;
game = accept(future.game, f.target, travelId);
const payment = paymentOffer(game, f.asker, f.target, f.asker, f.target, dueTurn); paymentId = payment.id;
game = accept(payment.game, f.target, paymentId);
} else {
const payment = paymentOffer(game, f.asker, f.target, f.asker, f.target, dueTurn); paymentId = payment.id;
game = accept(payment.game, f.target, paymentId);
const future = travelOffer(game, f.asker, f.target, dueTurn); travelId = future.id;
game = accept(future.game, f.target, travelId);
}
assert.equal(deal(game, travelId).status, 'active');
assert.equal(viewGame(game, f.target).dealTravelStatus[travelId], 'scheduled');
assert.equal(deal(game, paymentId).paymentStatus, 'pending');
assert.equal(player(game, f.asker).spice, player(f.game, f.asker).spice);
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice);
assert.deepEqual(inventory(game), f.originalCards);
},
);
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthTravel as act, advanceTruthTravel as advance, createTruthTravelFixture as fixture,
settleTruthTravel as settle, truthTravelClean as clean, truthTravelInventory as inventory,
truthTravelPlayer as player } from './fixture-truth-travel';
type Term = {kind:'ship'|'move'; territory:string; minimum:number; answer:boolean; dueTurn:number};
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Rejected consent and travel actions leave the game unchanged.');
}
function offer(game: Game, proposer: string, target: string, travel: Term) {
const next = act(game, proposer, {type:'dealOffer', target, terms:'Agreed travel on a later 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 row = game.travelPromises?.find(item => item.sourceDeal === id); assert.ok(row);
return row;
}
function endCurrent(game: Game, target: string) {
assert.equal(game.phase, 5); assert.equal(game.active, target);
return settle(act(game, target, {type:'endMovement'}));
}
function dueShipment(game: Game, target: string, dueTurn: number): Game {
return advance(game, state => state.turn === dueTurn && state.phase === 5 &&
state.active === target && clean(state) && !state.pendingShipment);
}
/** The shared Basic two-human fixture reaches the actual first-turn shipment
* opportunity through setup and phase actions. All agreements, transfers,
* Charity and due-turn actions below are real player actions; no funds, clocks,
* forces, choices or receipts are staged. */
void test('future consent does not reserve an empty purse; actual next-turn Charity funds the promised shipment', () => {
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
const purse = player(f.game, f.target).spice; assert.ok(purse > 0);
let game = act(f.game, f.target, {type:'bribe', target:f.asker, amount:purse});
assert.equal(player(game, f.target).spice, 0);
const offered = offer(game, f.asker, f.target,
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
game = accept(offered.game, f.target, offered.id);
assert.equal(player(game, f.target).spice, 0);
assert.equal(receipt(game, offered.id).turn, dueTurn);
assert.equal(receipt(game, offered.id).player, f.target);
assert.equal(status(game, f.asker, offered.id), 'scheduled');
assert.equal(status(game, f.target, offered.id), 'scheduled');
assert.equal(viewGame(game, f.target).travelPromiseGuide ?? null, null);
game = endCurrent(game, f.target);
assert.equal(status(game, f.target, offered.id), 'scheduled');
game = advance(game, state => state.turn === dueTurn && state.phase === 2 && clean(state) &&
!state.ready.includes(f.target));
assert.equal(status(game, f.target, offered.id), 'pending');
assert.equal(player(game, f.target).spice, 0);
game = act(game, f.target, {type:'charity'});
assert.equal(player(game, f.target).spice, 2);
game = dueShipment(game, f.target, dueTurn);
reject(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(game, f.asker, offered.id), 'fulfilled');
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.deepEqual(inventory(game), originalCards);
});
for (const answer of [true, false]) void test(`future movement ${answer ? 'requires' : 'forbids'} the real matching action in its due turn`, () => {
const f = fixture(), dueTurn = f.game.turn + 1;
const offered = offer(f.game, f.asker, f.target,
{kind:'move', territory:f.movementTerritory, minimum:1, answer, dueTurn});
let game = accept(offered.game, f.target, offered.id);
assert.equal(status(game, f.target, offered.id), 'scheduled');
game = endCurrent(game, f.target);
game = dueShipment(game, f.target, dueTurn);
game = settle(act(game, f.target, f.shipmentAction));
if (answer) {
reject(game, f.target, f.currentEnd);
game = settle(act(game, f.target, f.firstMove));
} else {
reject(game, f.target, f.firstMove);
game = settle(act(game, f.target, f.currentEnd));
}
assert.equal(status(game, f.target, offered.id), 'fulfilled');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('an unaccepted offer remains available through its due turn and expires only afterwards', () => {
const f = fixture(), dueTurn = f.game.turn + 1;
const offered = offer(f.game, f.asker, f.target,
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
let game = endCurrent(offered.game, f.target);
game = dueShipment(game, f.target, dueTurn);
assert.equal(game.deals!.find(row => row.id === offered.id)!.status, 'offered');
const accepted = accept(game, f.target, offered.id);
assert.equal(status(accepted, f.target, offered.id), 'pending');
assert.equal(receipt(accepted, offered.id).turn, dueTurn);
game = endCurrent(game, f.target);
game = advance(game, state => state.turn > dueTurn);
reject(game, f.target, {type:'dealRespond', deal:offered.id, accept:true});
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
});
void test('mutual release before the due turn cancels the scheduled receipt without blocking current travel', () => {
const f = fixture(), dueTurn = f.game.turn + 1;
const offered = offer(f.game, f.asker, f.target,
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
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), 'scheduled');
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).releasedByDeal, true);
game = endCurrent(game, f.target);
game = dueShipment(game, f.target, dueTurn);
assert.equal(status(game, f.asker, offered.id), 'released');
game = settle(act(game, f.target, f.currentEnd));
assert.equal(status(game, f.target, offered.id), 'released');
});
void test('an unfunded future promise remains pending until its actual travel turn can close', () => {
const f = fixture(), dueTurn = f.game.turn + 1;
const offered = offer(f.game, f.asker, f.target,
{kind:'ship', territory:f.shipmentTerritory, minimum:20, answer:true, dueTurn});
let game = accept(offered.game, f.target, offered.id);
game = endCurrent(game, f.target);
game = dueShipment(game, f.target, dueTurn);
assert.equal(status(game, f.target, offered.id), 'pending');
assert.ok(player(game, f.target).spice < 40);
game = settle(act(game, f.target, f.currentEnd));
assert.equal(status(game, f.target, offered.id), 'void');
});
void test('a witnessed due shipment cannot be voluntarily made unaffordable', () => {
const f = fixture(), dueTurn = f.game.turn + 1;
const offered = offer(f.game, f.asker, f.target,
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
let game = endCurrent(accept(offered.game, f.target, offered.id), f.target);
game = dueShipment(game, f.target, dueTurn);
const purse = player(game, f.target).spice; assert.ok(purse > 2);
reject(game, f.target, {type:'bribe', target:f.asker, amount:purse - 1});
game = act(game, f.target, {type:'bribe', target:f.asker, amount:purse - 2});
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(game, f.target, offered.id), 'fulfilled');
});
void test('public contradictions cannot be accepted and do not disturb the earlier future agreement', () => {
const f = fixture(), dueTurn = f.game.turn + 1;
const term:Term = {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn};
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, answer:false});
game = second.game;
reject(game, f.target, {type:'dealRespond', deal:second.id, accept:true});
const third = offer(game, f.asker, f.target, {...term, territory:'carthag'});
game = third.game;
reject(game, f.target, {type:'dealRespond', deal:third.id, accept:true});
assert.equal(status(game, f.target, first.id), 'scheduled');
assert.equal(game.deals!.find(deal => deal.id === second.id)!.status, 'offered');
assert.equal(game.deals!.find(deal => deal.id === third.id)!.status, 'offered');
game = endCurrent(game, f.target);
game = dueShipment(game, f.target, dueTurn);
assert.equal(status(game, f.target, first.id), 'pending');
const purse = player(game, f.target).spice; assert.ok(purse > 2);
reject(game, f.target, {type:'bribe', target:f.asker, amount:purse - 1});
game = settle(act(game, f.target, f.shipmentAction));
assert.equal(status(game, f.target, first.id), 'fulfilled');
});
void test('all minimal autopilot profiles complete an accepted future shipment on the due turn', () => {
const f = fixture(), dueTurn = f.game.turn + 1;
const offered = offer(f.game, f.asker, f.target,
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
let game = accept(offered.game, f.target, offered.id);
game = endCurrent(game, f.target);
const due = dueShipment(game, f.target, dueTurn);
for (const difficulty of DIFFICULTIES) {
let controlled = act(due, f.target, {type:'setAutopilot', difficulty});
for (let step = 0; step < 8 && status(controlled, f.target, offered.id) === 'pending'; step++) {
const actions = botActions(viewGame(controlled, f.target));
assert.ok(actions.length, `${difficulty} needs a legal native continuation`);
controlled = settle(act(controlled, f.target, actions[0]));
}
assert.equal(status(controlled, f.target, offered.id), 'fulfilled', difficulty);
}
});
void test('a game that naturally ends before the scheduled turn voids the unfulfilled term', () => {
const f = fixture(), dueTurn = 11;
const offered = offer(f.game, f.asker, f.target,
{kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn});
let game = accept(offered.game, f.target, offered.id);
game = endCurrent(game, f.target);
game = advance(game, state => state.status === 'finished');
assert.ok(game.turn < dueTurn);
assert.equal(status(game, f.asker, offered.id), 'void');
assert.equal(receipt(game, offered.id).fulfilled, false);
});
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthTurnAction as act, advanceTruthTurnAction as advance, bindTruthTurnAction as bind,
createTruthTurnActionFixture as fixture, finishTruthTurnAction as finish,
settleTruthTurnAction as settle, truthTurnActionClean as clean,
truthTurnActionInventory as inventory, truthTurnActionPlayer as player,
truthTurnActionPromise as promise, truthTurnActionShip as ship,
} from './fixture-truth-turn-action';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Rejected actions preserve both original receipts.');
}
/** Real Basic three-seat game, actual dealt Truthtrance and native movement
* window. This schedules the later public obligation first; it then asks and
* fulfills a distinct genuine whole-turn Truthtrance shipment in turn one. */
void test('a completed earlier whole-turn Truthtrance receipt preserves a scheduled future travel deal through rollover', () => {
const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game);
let game = act(f.game, f.asker, {type:'dealOffer', target:f.target, terms:'Ship to Polar Sink next turn.',
travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn}});
const deal = game.deals?.at(-1); assert.ok(deal);
game = act(game, f.target, {type:'dealRespond', deal:deal.id, accept:true});
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'scheduled');
const scheduled = game.travelPromises?.find(row => row.sourceDeal === deal.id); assert.ok(scheduled);
assert.equal(scheduled.turn, dueTurn); assert.equal(scheduled.player, f.target);
assert.equal(scheduled.fulfilled, false); assert.equal(scheduled.released, false);
game = bind(f, 'yes', ship(), game).game;
const truth = game.turnActionPromises?.at(-1); assert.ok(truth);
assert.equal(truth.turn, f.game.turn); assert.equal(truth.player, f.target);
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'scheduled');
reject(game, f.target, f.end);
game = settle(act(game, f.target, f.shipment));
assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'scheduled');
assert.equal(game.travelPromises?.find(row => row.sourceDeal === deal.id)?.fulfilled, false);
const firstForces = player(game, f.target).forces['polar_sink:0'];
game = settle(act(game, f.target, f.end));
game = finish(game);
assert.equal(game.turn, dueTurn);
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'pending');
assert.equal(promise(game, f.target).fulfilled, true);
game = advance(game, state => state.turn === dueTurn && state.phase === 5 &&
state.active === f.target && clean(state) && !state.pendingShipment, [], true);
reject(game, f.target, f.end);
game = settle(act(game, f.target, f.shipment));
assert.equal(viewGame(game, f.target).dealTravelStatus[deal.id], 'fulfilled');
assert.equal(player(game, f.target).forces['polar_sink:0'], firstForces + 1);
assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), originalCards);
});
@@ -10,7 +10,7 @@ import { nextStrongholdFactionsNativeStep as next } from './fixture-stronghold-f
const proposer = 'atreides', traveler = 'emperor', first = 'harkonnen', second = 'fremen';
const shipment: Action = {type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0};
const movement: Action = {type:'move', from:'polar_sink:0', territory:'imperial_basin', sector:9, amount:1};
type Term = {actor:string; kind:'ship'|'move'; territory:string; minimum:number; answer:boolean};
type Term = {actor:string; kind:'ship'|'move'; territory:string; minimum:number; answer:boolean; dueTurn?:number};
function reach(game: Game, until: (game: Game) => boolean): Game {
for (let count = 0; count < 1200; count++) {
@@ -120,6 +120,34 @@ void test('four-party consent requires all other recipients first and release re
assert.equal(status(game, first, offered.id), 'released');
});
void test('a four-party future shipment stays scheduled until the due turn and follows the named traveler', () => {
const initial = fixture(), dueTurn = initial.turn + 1, originalCards = inventory(initial);
const offered = offer(initial, [first, traveler, second],
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn});
let game = offered.game;
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
game = respond(game, second, offered.id);
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
game = respond(game, first, offered.id);
game = respond(game, traveler, offered.id);
assert.equal(game.deals!.at(-1)!.status, 'active');
assert.equal(receipt(game, offered.id).player, traveler);
assert.equal(receipt(game, offered.id).turn, dueTurn);
for (const viewer of [proposer, first, second, traveler])
assert.equal(status(game, viewer, offered.id), 'scheduled');
assert.equal(viewGame(game, traveler).travelPromiseGuide ?? null, null);
game = settle(act(game, traveler, {type:'endMovement'}));
game = reach(game, state => state.turn === dueTurn && state.phase === 5 &&
state.active === traveler && clean(state) && !state.pendingShipment);
assert.equal(status(game, traveler, offered.id), 'pending');
reject(game, traveler, {type:'endMovement'});
game = settle(act(game, traveler, shipment));
for (const viewer of [proposer, first, second, traveler])
assert.equal(status(game, viewer, offered.id), 'fulfilled');
assert.equal(player(game, traveler).forces['polar_sink:0'], 1);
assert.deepEqual(inventory(game), originalCards);
});
for (const answer of [true, false]) void test(`group movement ${answer ? 'requires' : 'forbids'} one real matching action`, () => {
const moved = settle(act(fixture(), traveler, shipment));
const offered = offer(moved, [first, traveler],
+55 -2
View File
@@ -39,6 +39,14 @@ void test('the offer packet retains legacy two-party terms and validates an expl
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'c'}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel, actor:'c'}});
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn:2}),
{type:'dealOffer', target:'b', terms:'Terms', travel}, 'Current-turn packet keeps the legacy shape.');
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, {...travel,dueTurn:4}),
{type:'dealOffer', target:'b', terms:'Terms', payment, travel:{...travel,dueTurn:4}});
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel,actor:'c',dueTurn:4}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel,actor:'c',dueTurn:4}});
for (const dueTurn of [0, 1, 2.5, Number.NaN, undefined])
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel,dueTurn}), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'a'}), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'d'}), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel, actor:'b'}), null);
@@ -67,7 +75,7 @@ void test('the combined offer explains payment timing and the recipient can acce
const html = markup(offered);
assert.match(html, /Alice \(Atreides\) pays 2 spice to Basil \(Harkonnen\) on final acceptance/);
assert.match(html, /recipient collects their share at the next Mentat opening/);
assert.match(html, /cannot use this payment to fund the current travel/);
assert.match(html, /cannot spend this payment before its next Mentat collection/);
const sent:Action[] = [];
const tree = PublicDealList({game:offered, disabled:false, act(action) {sent.push(action);}});
const accept = elements(tree).find(node => node.props.children === 'Accept deal');
@@ -218,6 +226,51 @@ void test('the group offer form selects an invited traveler and revalidates part
ui.check(3); assert.equal(button()?.props.disabled,true, 'Group travel cannot be combined with payment.');
});
void test('the author chooses a future travel turn and can return to the legacy current-turn packet', () => {
const ui = combinedForm();
ui.check(0); ui.check(2);
ui.select(2,'carthag');
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Later journey'}});
const deadline = () => ui.render().find(node => node.type === 'input' && node.props.type === 'number' && node.props.min === 2);
const submit = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
assert.ok(deadline());
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'4'}});
const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
assert.equal(submit()?.props.disabled,false);
(form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',target:'b',terms:'Later journey',
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,dueTurn:4}});
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'1'}});
assert.equal(submit()?.props.disabled,true);
(deadline()!.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'2'}});
assert.equal(submit()?.props.disabled,false);
const current = ui.render().find(node => node.type === 'form'); assert.ok(current);
(current.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',target:'b',terms:'Later journey',
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true}});
});
void test('future offers stay actionable until the selected turn and scheduled terms do not prompt the traveler early', () => {
const offered = {...base,turn:3, deals:[{...deal,status:'offered',travel:{...travel,dueTurn:4}}],
dealTravelStatus:{}} as unknown as GameView;
assert.match(markup(offered), /during turn 4/);
assert.match(markup(offered), /Before turn 4, this term reserves no forces or spice/);
const accept = (game:GameView) => elements(PublicDealList({game,disabled:false,act() {}}))
.find(node => node.props.children === 'Accept deal');
assert.equal(accept(offered)?.props.disabled,false);
assert.equal(accept({...offered,turn:4})?.props.disabled,false);
const expired = {...offered,turn:5};
assert.equal(accept(expired)?.props.disabled,true);
assert.match(markup(expired), /travel term’s turn has passed/);
const scheduled = {...offered, deals:[{...deal,travel:{...travel,dueTurn:4}}],
dealTravelStatus:{deal:'scheduled'}} as unknown as GameView;
assert.match(markup(scheduled), /Travel status: scheduled/);
assert.deepEqual(publicDealRequests(scheduled), []);
assert.deepEqual(publicDealRequests({...scheduled,turn:4,dealTravelStatus:{deal:'pending'}} as unknown as GameView),
[['travel','deal']]);
});
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/);
@@ -275,7 +328,7 @@ void test('the recipient guide distinguishes a void deal from unanimous release'
void test('the real offer form asks for recipients 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, /Include a travel term/);
assert.match(html, /Select at least one human recipient to add a travel term/);
assert.match(html, /cannot automatically enforce free-text promises/);
});
+48 -1
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
publicDealTravelActor,
publicDealTravelActor, publicDealTravelDueTurn,
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
type PublicDeal, type PublicDealSeat,
} from '../game/public-deals';
@@ -29,6 +29,53 @@ void test('travel terms accept only exact bounded native actions and an explicit
assert.deepEqual(parsePublicDealTravel({...term, actor:'c'}), {...term, actor:'c'});
});
void test('travel due turns are exact positive safe integers and cannot predate the offer', () => {
for (const dueTurn of [1, 3, 4, Number.MAX_SAFE_INTEGER]) {
assert.deepEqual(parsePublicDealTravel({...term, dueTurn}), {...term, dueTurn});
assert.deepEqual(parsePublicDealTravel({...term, actor:'c', dueTurn}), {...term, actor:'c', dueTurn});
}
for (const dueTurn of [0, -1, 2.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '4', undefined, null]) {
assert.throws(() => parsePublicDealTravel({...term, dueTurn}), PublicDealError);
assert.throws(() => parsePublicDealTravel({...term, actor:'c', dueTurn}), PublicDealError);
}
const old = setPublicDealTravel(fresh(), term, seats);
assert.equal(publicDealTravelDueTurn(old), 3);
assert.equal(Object.hasOwn(old.travel!, 'dueTurn'), false);
for (const dueTurn of [1, 2]) {
assert.throws(() => setPublicDealTravel(fresh(), {...term, dueTurn}, seats), PublicDealError);
assert.throws(() => validatePublicDeals([{...fresh(), travel:{...term, dueTurn}}], seats), PublicDealError);
}
for (const dueTurn of [3, 4]) {
const offered = setPublicDealTravel(fresh(), {...term, dueTurn}, seats);
assert.equal(publicDealTravelDueTurn(offered), dueTurn);
assert.deepEqual(offered.travel, {...term, dueTurn});
}
});
void test('future two-party and group terms retain due turns across consent and release', () => {
const payment = {kind:'immediateBribe', payer:'a', recipient:'b', amount:2} as const;
const pair = setPublicDealTravel(createPublicDeal(seats, 'a', 'b', 'Future travel.', 3, 'pair', payment),
{...term, dueTurn:5}, seats);
const pairActive = respondPublicDeal(pair, seats, 'b', true);
const pairReleased = releasePublicDeal(releasePublicDeal(pairActive, seats, 'a', true), seats, 'b', true);
const group = setPublicDealTravel(createMultipartyPublicDeal(seats, 'a', ['b','c'], 'Future travel.', 3, 'group'),
{...term, actor:'c', dueTurn:5}, seats);
assert.throws(() => respondPublicDeal(group, seats, 'c', true), PublicDealError);
const groupActive = respondPublicDeal(respondPublicDeal(group, seats, 'b', true), seats, 'c', true);
const groupReleased = releasePublicDeal(releasePublicDeal(releasePublicDeal(groupActive, seats, 'a', true),
seats, 'b', true), seats, 'c', true);
for (const record of [pair, pairActive, pairReleased, group, groupActive, groupReleased]) {
const roundtrip = JSON.parse(JSON.stringify(record)) as PublicDeal;
validatePublicDeals([roundtrip], seats);
assert.equal(publicDealTravelDueTurn(roundtrip), 5);
assert.deepEqual(roundtrip.travel, record.travel);
assert.deepEqual(roundtrip.payment, record.payment);
}
const backwardsGroup = {...group, travel:{...term, actor:'c', dueTurn:2}};
assert.throws(() => validatePublicDeals([backwardsGroup], seats), PublicDealError);
assert.throws(() => validatePublicDeals([{...group, payment, travel:{...term, actor:'c', dueTurn:5}}], seats), 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 };