Support scheduled travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m8s
Verify and publish checkpoint container / container (push) Successful in 11m8s
This commit is contained in:
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],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user