Support future-turn public deal payments
This commit is contained in:
1 parent
bb193d8f8c
commit
7d71647a90
16 files changed
+641
-35
No files matched your search
@@ -22,7 +22,7 @@ void test('deferred deal terms bind an exact positive payment to this turn witho
|
||||
assert.deepEqual(parsePublicDealPayment(immediate, ['a', 'b']), immediate);
|
||||
assert.deepEqual(parsePublicDealPayment(deferred(4), ['a', 'b'], 3), deferred(4));
|
||||
for (const input of [
|
||||
{ ...deferred(4), dueTurn: 4 }, { ...deferred(0) },
|
||||
{ ...deferred(4), dueTurn: 2 }, { ...deferred(0) },
|
||||
{ ...deferred(4), recipient: 'c' }, { ...deferred(4), extra: true },
|
||||
]) assert.throws(() => parsePublicDealPayment(input, ['a', 'b'], 3), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment(deferred(4), ['a', 'b']), PublicDealError);
|
||||
@@ -61,7 +61,7 @@ void test('mutual release and proven impossibility end only an unpaid accepted p
|
||||
const bad = [
|
||||
{ ...first, paymentStatus: undefined }, { ...first, paymentStatus: 'released' },
|
||||
{ ...first, status: 'offered', paymentStatus: 'pending' },
|
||||
{ ...first, payment: { ...deferred(4), dueTurn: 4 } },
|
||||
{ ...first, payment: { ...deferred(4), dueTurn: 2 } },
|
||||
{ ...first, payment: { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 4 }, paymentStatus: 'paid' },
|
||||
];
|
||||
for (const deal of bad) assert.throws(() => validatePublicDeals([deal], seats), PublicDealError);
|
||||
|
||||
@@ -46,7 +46,7 @@ void test('deal form exposes human non-allied recipients and clearly describes l
|
||||
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
|
||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||
assert.match(html, /free-text terms do not automatically transfer spice/);
|
||||
assert.match(html, /Include an immediate spice payment/);
|
||||
assert.match(html, /Include a spice payment/);
|
||||
assert.match(html, /Do not trade cards, forces, leaders or faction powers/);
|
||||
assert.match(html, /maxlength="500"/i);
|
||||
assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\)/);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
actMentatBribe as act, createMentatBribeFixture as fixture,
|
||||
finishMentatBribe as finish, mentatBribeInventory as inventory, mentatBribePlayer as player,
|
||||
nextMentatBribeOpening as nextMentat,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
function offer(f: ReturnType<typeof fixture>, game: Game, amount: number, dueTurn: number) {
|
||||
game = act(game, f.asker, { type: 'dealOffer', target: f.target, terms: 'Pay the agreed spice.',
|
||||
payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount, dueTurn } });
|
||||
const deal = game.deals!.at(-1)!.id;
|
||||
game = act(game, f.target, { type: 'dealRespond', deal, accept: true });
|
||||
return { game, deal };
|
||||
}
|
||||
|
||||
void test('human autopilot waits for the actual due turn before paying a future deal', () => {
|
||||
const f = fixture();
|
||||
const accepted = offer(f, f.game, 3, f.game.turn + 1);
|
||||
let game = act(accepted.game, f.target, { type: 'setAutopilot', difficulty: 'Easy' });
|
||||
assert.equal(player(game, f.target).autopilot, 'Easy');
|
||||
assert.equal(viewGame(game, f.target).deferredDealPaymentOptions[accepted.deal]?.blocked, null);
|
||||
assert.ok(!botActions(viewGame(game, f.target)).some(action => action.type === 'dealPay'));
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
assert.equal(game.deals!.find(row => row.id === accepted.deal)!.paymentStatus, 'pending');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
|
||||
game = finish(game);
|
||||
assert.equal(game.turn, f.game.turn + 1);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
const action = botActions(viewGame(game, f.target))[0];
|
||||
assert.deepEqual(action, { type: 'dealPay', deal: accepted.deal });
|
||||
game = act(game, f.target, action);
|
||||
assert.equal(game.deals!.find(row => row.id === accepted.deal)!.paymentStatus, 'paid');
|
||||
assert.equal(player(game, f.target).spice, 7);
|
||||
assert.equal(player(game, f.asker).bribes, 3);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('an insolvent Mentat payer cannot sacrifice an individually payable current deal for a future one', () => {
|
||||
const f = fixture();
|
||||
const first = offer(f, f.game, 6, f.game.turn + 1);
|
||||
const second = offer(f, first.game, 6, f.game.turn + 1);
|
||||
const future = offer(f, second.game, 2, f.game.turn + 2);
|
||||
const smallerFuture = offer(f, future.game, 1, f.game.turn + 2);
|
||||
let game = act(smallerFuture.game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
|
||||
game = nextMentat(finish(game));
|
||||
assert.equal(game.turn, f.game.turn + 1);
|
||||
assert.equal(game.phase, 8);
|
||||
assert.equal(player(game, f.target).spice, 7);
|
||||
for (const deal of [first.deal, second.deal])
|
||||
assert.equal(game.deals!.find(row => row.id === deal)!.paymentStatus, 'pending');
|
||||
const before = structuredClone(game);
|
||||
assert.ok(viewGame(game, f.target).deferredDealPaymentOptions[future.deal]?.blocked);
|
||||
assert.throws(() => act(game, f.target, { type: 'dealPay', deal: future.deal }));
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(viewGame(game, f.target).deferredDealPaymentOptions[smallerFuture.deal]?.blocked, null);
|
||||
const sparePaid = act(game, f.target, { type: 'dealPay', deal: smallerFuture.deal });
|
||||
assert.equal(player(sparePaid, f.target).spice, 6);
|
||||
assert.equal(sparePaid.deals!.find(row => row.id === smallerFuture.deal)!.paymentStatus, 'paid');
|
||||
for (const deal of [first.deal, second.deal])
|
||||
assert.equal(sparePaid.deals!.find(row => row.id === deal)!.paymentStatus, 'pending');
|
||||
const paid = act(game, f.target, { type: 'dealPay', deal: first.deal });
|
||||
assert.equal(player(paid, f.target).spice, 1);
|
||||
assert.equal(paid.deals!.find(row => row.id === first.deal)!.paymentStatus, 'paid');
|
||||
assert.equal(paid.deals!.find(row => row.id === second.deal)!.paymentStatus, 'void');
|
||||
assert.equal(paid.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
|
||||
assert.deepEqual(inventory(paid), f.originalCards);
|
||||
});
|
||||
|
||||
void test('early future payment cannot spend the funds needed for a currently due deal', () => {
|
||||
const f = fixture();
|
||||
const due = offer(f, f.game, 8, f.game.turn);
|
||||
const future = offer(f, due.game, 3, f.game.turn + 1);
|
||||
const before = structuredClone(future.game);
|
||||
assert.ok(viewGame(before, f.target).deferredDealPaymentOptions[future.deal]?.blocked);
|
||||
assert.throws(() => act(future.game, f.target, { type: 'dealPay', deal: future.deal }));
|
||||
assert.deepEqual(future.game, before);
|
||||
assert.equal(player(future.game, f.target).spice, 10);
|
||||
assert.equal(future.game.deals!.find(row => row.id === due.deal)!.paymentStatus, 'pending');
|
||||
assert.equal(future.game.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
|
||||
|
||||
const paid = act(future.game, f.target, { type: 'dealPay', deal: due.deal });
|
||||
assert.equal(player(paid, f.target).spice, 2);
|
||||
assert.equal(player(paid, f.asker).bribes, 8);
|
||||
assert.equal(paid.deals!.find(row => row.id === due.deal)!.paymentStatus, 'paid');
|
||||
assert.equal(paid.deals!.find(row => row.id === future.deal)!.paymentStatus, 'pending');
|
||||
assert.deepEqual(inventory(paid), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
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 publicDeals from '../game/public-deals';
|
||||
import { PublicDealList, PublicDeals, publicDealOfferAction, publicDealRequests } from '../components/public-deals';
|
||||
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 payment = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 3, dueTurn: 5 } as const;
|
||||
const deal = { id: 'deal', from: 'a', to: 'b', terms: 'A promise.', turn: 2,
|
||||
status: 'active', releaseVotes: [], payment, paymentStatus: 'pending' };
|
||||
const base = { status: 'playing', turn: 2, me: 'a', roomControl: null,
|
||||
players: [{ id: 'a', name: 'Alice', faction: 'atreides' }, { id: 'b', name: 'Basil', faction: 'harkonnen' }],
|
||||
deals: [deal], deferredDealPaymentOptions: { deal: { blocked: null } },
|
||||
} as unknown as GameView;
|
||||
const buttons = (game: GameView, calls: Action[]) => elements(PublicDealList({ game, act: action => calls.push(action), disabled: false }))
|
||||
.filter(element => typeof element.props.onClick === 'function');
|
||||
const find = (nodes: Element[], label: string) => nodes.find(node => node.props.children === label);
|
||||
const html = (game: GameView) => renderToStaticMarkup(createElement(PublicDealList, { game, act() {}, disabled: false }));
|
||||
|
||||
void test('offers accept a whole current or future deadline without an invented maximum', () => {
|
||||
for (const dueTurn of [2, 5, 1_000_000]) {
|
||||
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', { ...payment, dueTurn }, 'a', 2),
|
||||
{ type: 'dealOffer', target: 'b', terms: 'Terms', payment: { ...payment, dueTurn } });
|
||||
}
|
||||
for (const dueTurn of [0, 1, 1.5, NaN, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', { ...payment, dueTurn }, 'a', 2), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('future offers remain acceptable through the deadline, then can only be declined', () => {
|
||||
const offered = { ...deal, status: 'offered', paymentStatus: undefined };
|
||||
const game = { ...base, me: 'b', bribeOptions: { blocked: 'Finish the current decision.' }, deals: [offered] } as unknown as GameView;
|
||||
const calls: Action[] = [];
|
||||
for (const turn of [2, 5]) {
|
||||
assert.equal(find(buttons({ ...game, turn }, calls), 'Accept deal')?.props.disabled, false);
|
||||
assert.doesNotMatch(html({ ...game, turn }), /due turn has passed/);
|
||||
}
|
||||
const expired = { ...game, turn: 6 };
|
||||
assert.match(html(expired), /due turn has passed/);
|
||||
assert.equal(find(buttons(expired, calls), 'Accept deal')?.props.disabled, true);
|
||||
assert.equal(find(buttons(expired, calls), 'Decline deal')?.props.disabled, false);
|
||||
});
|
||||
|
||||
void test('a future pending payment stays visible and can be paid early when projected legal', () => {
|
||||
const calls: Action[] = [];
|
||||
assert.match(html(base), /on or before the end of turn 5/);
|
||||
assert.match(html(base), /Payment status: pending/);
|
||||
const pay = find(buttons(base, calls), 'Pay agreed amount');
|
||||
assert.ok(pay); assert.equal(pay.props.disabled, false);
|
||||
(pay.props.onClick as () => void)();
|
||||
assert.deepEqual(calls, [{ type: 'dealPay', deal: 'deal' }]);
|
||||
const blocked = { ...base, deferredDealPaymentOptions: { deal: { blocked: 'Finish the current decision.' } } } as unknown as GameView;
|
||||
assert.match(html(blocked), /Finish the current decision/);
|
||||
assert.equal(find(buttons(blocked, calls), 'Pay agreed amount')?.props.disabled, true);
|
||||
});
|
||||
|
||||
void test('payment attention starts on the due turn while future payment remains in history', () => {
|
||||
assert.deepEqual(publicDealRequests(base), []);
|
||||
const future = renderToStaticMarkup(createElement(PublicDeals, { game: base, act() {}, busy: false }));
|
||||
assert.doesNotMatch(future, /Public deals · payment due/);
|
||||
assert.match(future, /Payment status: pending/);
|
||||
for (const turn of [5, 6]) assert.deepEqual(publicDealRequests({ ...base, turn }), [['payment', 'deal']]);
|
||||
const released = { ...base, deals: [{ ...deal, status: 'released', paymentStatus: 'released' }] } as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests({ ...released, turn: 5 }), []);
|
||||
const withRelease = { ...base, deals: [{ ...deal, releaseVotes: ['b'] }] } as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(withRelease), [['release', 'deal', 'b']]);
|
||||
});
|
||||
|
||||
/** Exercise the real controlled form callbacks without claiming browser hydration. */
|
||||
function controller(game: GameView) {
|
||||
const states: unknown[] = [], sent: Action[] = [];
|
||||
let cursor = 0;
|
||||
const exports: { PublicDeals?: typeof PublicDeals } = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/public-deals.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText, {
|
||||
exports,
|
||||
require(name: string) {
|
||||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||
if (name === 'react') return {
|
||||
useState(initial: unknown) {
|
||||
const index = cursor++;
|
||||
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
|
||||
return [states[index], (next: unknown) => { states[index] = typeof next === 'function' ? next(states[index]) : next; }];
|
||||
},
|
||||
useRef: () => ({ current: null }), useEffect: () => {},
|
||||
};
|
||||
if (name === '@/components/ui/button') return { Button: 'button' };
|
||||
if (name === '@/game/catalog') return catalog;
|
||||
if (name === '@/game/public-deals') return publicDeals;
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
});
|
||||
assert.ok(exports.PublicDeals);
|
||||
const render = () => { cursor = 0; return elements(exports.PublicDeals!({ game, busy: false, act: action => sent.push(action) })); };
|
||||
return { render, sent };
|
||||
}
|
||||
|
||||
void test('the authoring form selects and validates a cross-turn deadline', () => {
|
||||
const ui = controller(base);
|
||||
const check = (index: number) => {
|
||||
const input = ui.render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
|
||||
assert.ok(input);
|
||||
(input.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
|
||||
};
|
||||
const choose = (index: number, value: string) => {
|
||||
const select = ui.render().filter(element => element.type === 'select')[index]; assert.ok(select);
|
||||
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
||||
};
|
||||
check(0); check(1); choose(0, 'deferredBribe'); choose(1, 'a'); choose(2, 'b');
|
||||
const textarea = ui.render().find(element => element.type === 'textarea'); assert.ok(textarea);
|
||||
(textarea.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'Terms' } });
|
||||
const editDue = (value: string) => {
|
||||
const input = ui.render().find(element => element.type === 'input' && element.props.type === 'number' && element.props.min === 2);
|
||||
assert.ok(input); assert.equal(input.props.max, undefined);
|
||||
(input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
||||
};
|
||||
editDue('5');
|
||||
const form = ui.render().find(element => element.type === 'form'); assert.ok(form);
|
||||
(form.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'dealOffer', target: 'b', terms: 'Terms', payment: { ...payment, amount: 1 } });
|
||||
editDue('1');
|
||||
const submit = ui.render().find(element => element.type === 'button' && element.props.type === 'submit');
|
||||
assert.ok(submit); assert.equal(submit.props.disabled, true);
|
||||
const invalidForm = ui.render().find(element => element.type === 'form'); assert.ok(invalidForm);
|
||||
(invalidForm.props.onSubmit as (event: { preventDefault(): void }) => void)({ preventDefault() {} });
|
||||
assert.equal(ui.sent.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @dune-suite integration
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
async function request(path: string, cookie?: string, body?: unknown, selected?: string) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(selected ? { 'X-Dune-Seat': selected } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
data: await response.json() as GameView & { error?: string } };
|
||||
}
|
||||
|
||||
/** This test creates only its own room on the isolated integration server. */
|
||||
void test('authenticated participants consent to a future-due deal and its payer settles early once', async () => {
|
||||
const host = await request('/api/rooms', undefined, {
|
||||
name: 'Future deal recipient', faction: 'atreides', advanced: false, expansions: [],
|
||||
});
|
||||
assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const payer = await request(path, undefined, { type: 'join', name: 'Future deal payer', faction: 'harkonnen' });
|
||||
assert.equal(payer.status, 200, JSON.stringify(payer.data)); assert.ok(payer.cookie);
|
||||
const third = await request(path, undefined, { type: 'join', name: 'Future deal witness', faction: 'emperor' });
|
||||
assert.equal(third.status, 200, JSON.stringify(third.data)); assert.ok(third.cookie);
|
||||
const seats = [
|
||||
{ id: host.data.me, cookie: host.cookie },
|
||||
{ id: payer.data.me, cookie: payer.cookie },
|
||||
{ id: third.data.me, cookie: third.cookie },
|
||||
];
|
||||
let view = third.data;
|
||||
const act = async (cookie: string, action: object) => {
|
||||
const next = await request(path, cookie, { version: view.version, action });
|
||||
assert.equal(next.status, 200, JSON.stringify(next.data)); view = next.data; return next.data;
|
||||
};
|
||||
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
|
||||
await act(host.cookie, { type: 'start' });
|
||||
for (const seat of seats) {
|
||||
const own = await request(path, seat.cookie);
|
||||
assert.equal(own.status, 200, JSON.stringify(own.data)); view = own.data;
|
||||
const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
|
||||
if (choices?.length) await act(seat.cookie, { type: 'traitor', leader: choices[0] });
|
||||
}
|
||||
assert.equal(view.status, 'playing'); assert.equal(view.phase, 0);
|
||||
const offerTurn = view.turn;
|
||||
const payerBefore = await request(path, payer.cookie);
|
||||
const recipientBefore = await request(path, host.cookie);
|
||||
assert.equal(payerBefore.status, 200); assert.equal(recipientBefore.status, 200);
|
||||
const payerSpice = payerBefore.data.players.find(player => player.id === payer.data.me)!.spice;
|
||||
const recipientSpice = recipientBefore.data.players.find(player => player.id === host.data.me)!.spice;
|
||||
const recipientIncoming = recipientBefore.data.bribeOptions.incoming;
|
||||
assert.ok(typeof payerSpice === 'number' && payerSpice >= 3);
|
||||
|
||||
const payment = { kind: 'deferredBribe', payer: payer.data.me, recipient: host.data.me,
|
||||
amount: 3, dueTurn: offerTurn + 1 };
|
||||
await act(host.cookie, { type: 'dealOffer', targets: [payer.data.me, third.data.me],
|
||||
terms: 'Pay the agreed spice by next turn.', payment });
|
||||
const deal = view.deals.at(-1); assert.ok(deal); assert.equal(deal.status, 'offered');
|
||||
assert.deepEqual(deal.payment, payment);
|
||||
const pay = { type: 'dealPay', deal: deal.id };
|
||||
const premature = await request(path, payer.cookie, { version: view.version, action: pay });
|
||||
assert.equal(premature.status, 409, JSON.stringify(premature.data));
|
||||
await act(payer.cookie, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.status, 'offered');
|
||||
await act(third.cookie, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.status, 'active');
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.paymentStatus, 'pending');
|
||||
for (const seat of seats) {
|
||||
const read = await request(path, seat.cookie); assert.equal(read.status, 200, JSON.stringify(read.data));
|
||||
const visible: GameView['deals'][number] | undefined = read.data.deals.find(row => row.id === deal.id);
|
||||
assert.deepEqual(visible?.payment, payment); assert.equal(visible?.paymentStatus, 'pending');
|
||||
}
|
||||
const pendingPayer = await request(path, payer.cookie);
|
||||
const pendingRecipient = await request(path, host.cookie);
|
||||
assert.equal(pendingPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice);
|
||||
assert.equal(pendingRecipient.data.bribeOptions.incoming, recipientIncoming);
|
||||
assert.equal(pendingPayer.data.deferredDealPaymentOptions[deal.id]?.blocked, null);
|
||||
assert.equal(pendingRecipient.data.deferredDealPaymentOptions[deal.id], undefined);
|
||||
|
||||
const otherSeat = await request(path, host.cookie, { version: view.version, action: pay });
|
||||
assert.equal(otherSeat.status, 409, JSON.stringify(otherSeat.data));
|
||||
const forged = await request(path, host.cookie, { version: view.version, action: pay }, payer.data.me);
|
||||
assert.equal(forged.status, 409, JSON.stringify(forged.data));
|
||||
const previousVersion = view.version;
|
||||
await act(payer.cookie, pay);
|
||||
assert.equal(view.turn, offerTurn); assert.ok(view.turn < payment.dueTurn);
|
||||
assert.equal(view.deals.find(row => row.id === deal.id)?.paymentStatus, 'paid');
|
||||
const paidPayer = await request(path, payer.cookie);
|
||||
const paidRecipient = await request(path, host.cookie);
|
||||
assert.equal(paidPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice - 3);
|
||||
assert.equal(paidRecipient.data.players.find(player => player.id === host.data.me)!.spice, recipientSpice);
|
||||
assert.equal(paidRecipient.data.bribeOptions.incoming, recipientIncoming + 3);
|
||||
const stale = await request(path, payer.cookie, { version: previousVersion, action: pay });
|
||||
assert.equal(stale.status, 409, JSON.stringify(stale.data));
|
||||
const replay = await request(path, payer.cookie, { version: view.version, action: pay });
|
||||
assert.equal(replay.status, 409, JSON.stringify(replay.data));
|
||||
assert.equal((await request(path, host.cookie)).data.bribeOptions.incoming, recipientIncoming + 3);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createPublicDeal, markPublicDealPaid, parsePublicDealPayment, PublicDealError,
|
||||
releasePublicDeal, respondPublicDeal, validatePublicDeals,
|
||||
type PublicDeal, type PublicDealSeat,
|
||||
} from '../game/public-deals';
|
||||
import {
|
||||
outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal,
|
||||
} from '../game/public-deal-obligations';
|
||||
|
||||
const seats: PublicDealSeat[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
const payment = (dueTurn: number, amount = 3) => ({
|
||||
kind: 'deferredBribe' as const, payer: 'a', recipient: 'b', amount, dueTurn,
|
||||
});
|
||||
const offer = (id: string, dueTurn: number, turn = 3) =>
|
||||
createPublicDeal(seats, 'a', 'b', 'Pay by the agreed turn.', turn, id, payment(dueTurn));
|
||||
const accept = (id: string, dueTurn: number, turn = 3) =>
|
||||
respondPublicDeal(offer(id, dueTurn, turn), seats, 'b', true);
|
||||
|
||||
void test('a deferred term retains an exact safe deadline at or after its offer turn', () => {
|
||||
const immediate = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 2 } as const;
|
||||
assert.deepEqual(parsePublicDealPayment(immediate, ['a', 'b']), immediate);
|
||||
assert.deepEqual(parsePublicDealPayment(payment(3), ['a', 'b'], 3), payment(3));
|
||||
assert.deepEqual(parsePublicDealPayment(payment(7), ['a', 'b'], 3), payment(7));
|
||||
assert.deepEqual(parsePublicDealPayment(payment(100), ['a', 'b'], 3), payment(100),
|
||||
'A later agreed game extension may make a distant deadline reachable.');
|
||||
for (const invalid of [payment(2), payment(0), payment(3.5), payment(Infinity),
|
||||
payment(Number.MAX_SAFE_INTEGER + 1), { ...payment(4), extra: true }])
|
||||
assert.throws(() => parsePublicDealPayment(invalid, ['a', 'b'], 3), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment(payment(4), ['a', 'b']), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment(payment(4), ['a', 'b'], 0), PublicDealError);
|
||||
assert.throws(() => parsePublicDealPayment({ ...payment(4), recipient: 'c' }, ['a', 'b'], 3), PublicDealError);
|
||||
});
|
||||
|
||||
void test('separate pending and outstanding views preserve current-turn semantics', () => {
|
||||
const current = accept('current', 3);
|
||||
const later = accept('later', 5);
|
||||
const farther = accept('farther', 9);
|
||||
const paid = markPublicDealPaid(accept('paid', 8), seats, 'a');
|
||||
const declined = respondPublicDeal(offer('declined', 6), seats, 'b', false);
|
||||
const offered = offer('offered', 7);
|
||||
const deals: PublicDeal[] = [current, later, farther, paid, declined, offered];
|
||||
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 3).map(row => row.deal), ['current']);
|
||||
assert.equal(publicDealPaymentTotal(deals, 'a', 3), 3);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 3).map(row => row.deal), ['current', 'later', 'farther']);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 4).map(row => row.deal), ['later', 'farther']);
|
||||
assert.deepEqual(pendingPublicDealPayments(deals, 'a', 5).map(row => row.deal), ['later']);
|
||||
assert.equal(publicDealPaymentTotal(deals, 'a', 5), 3);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'a', 10), []);
|
||||
assert.deepEqual(outstandingPublicDealPayments(deals, 'c', 3), []);
|
||||
assert.equal(deals[0].paymentStatus, 'pending', 'Reading after a deadline never mutates a receipt.');
|
||||
});
|
||||
|
||||
void test('early settlement and mutual release preserve exact future receipt history', () => {
|
||||
const pending = accept('future', 6);
|
||||
const copied = JSON.parse(JSON.stringify(pending)) as PublicDeal;
|
||||
validatePublicDeals([copied], seats);
|
||||
const paid = markPublicDealPaid(copied, seats, 'a');
|
||||
assert.equal(paid.paymentStatus, 'paid');
|
||||
const stillPaid = releasePublicDeal(releasePublicDeal(paid, seats, 'a', true), seats, 'b', true);
|
||||
assert.equal(stillPaid.status, 'released');
|
||||
assert.equal(stillPaid.paymentStatus, 'paid');
|
||||
const canceled = releasePublicDeal(releasePublicDeal(pending, seats, 'a', true), seats, 'b', true);
|
||||
assert.equal(canceled.paymentStatus, 'released');
|
||||
assert.deepEqual(outstandingPublicDealPayments([stillPaid, canceled], 'a', 3), []);
|
||||
assert.equal(pending.paymentStatus, 'pending', 'Pure transitions preserve the original record.');
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actMentatBribe as act, advanceMentatBribe as advance,
|
||||
createMentatBribeFixture as fixture, finishMentatBribe as finish,
|
||||
mentatBribeClean as clean, mentatBribeInventory as inventory,
|
||||
mentatBribePlayer as player,
|
||||
} from './fixture-mentat-bribes';
|
||||
|
||||
function createDeal(game: Game, proposer: string, payer: string, recipient: string,
|
||||
amount: number, dueTurn: number) {
|
||||
const offered = act(game, proposer, { type: 'dealOffer', target: payer,
|
||||
terms: 'Pay the agreed spice by the stated turn.',
|
||||
payment: { kind: 'deferredBribe', payer, recipient, amount, dueTurn } });
|
||||
const deal = offered.deals?.at(-1); assert.ok(deal);
|
||||
const accepted = act(offered, payer, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(accepted.deals?.find(row => row.id === deal.id)?.paymentStatus, 'pending');
|
||||
return { game: accepted, id: deal.id };
|
||||
}
|
||||
function status(game: Game, id: string) {
|
||||
const deal = game.deals?.find(row => row.id === id); assert.ok(deal);
|
||||
return deal.paymentStatus;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected payment leaves the original game intact.');
|
||||
}
|
||||
|
||||
/** Each route begins at the original H/A/E Basic turn-one Mentat, earned by
|
||||
* native setup, bidding and collection. The shared fixture selects only
|
||||
* unopened physical card/Spice positions; no hand or wallet is installed. */
|
||||
void test('an accepted next-turn deal survives the real turn boundary and may be paid at its due turn', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const initialSpice = player(f.game, payer).spice;
|
||||
const { game: accepted, id } = createDeal(f.game, recipient, payer, recipient, 3, f.game.turn + 1);
|
||||
assert.equal(player(accepted,payer).spice,initialSpice);
|
||||
const next = finish(accepted);
|
||||
assert.equal(next.turn,f.game.turn + 1); assert.equal(status(next,id),'pending');
|
||||
assert.equal(player(next,payer).spice,initialSpice);
|
||||
assert.equal(viewGame(next,payer).deferredDealPaymentOptions[id]?.blocked,null);
|
||||
const paid = act(next,payer,{type:'dealPay',deal:id});
|
||||
assert.equal(status(paid,id),'paid');
|
||||
assert.equal(player(paid,payer).spice,initialSpice - 3);
|
||||
assert.equal(player(paid,recipient).bribes,3);
|
||||
reject(paid,payer,{type:'dealPay',deal:id});
|
||||
const beforeOpening = advance(paid, game => game.turn === next.turn && game.phase === 7 && clean(game));
|
||||
const recipientBefore = player(beforeOpening,recipient).spice;
|
||||
const opening = advance(beforeOpening, game => game.turn === next.turn && game.phase === 8 && clean(game));
|
||||
assert.equal(player(opening,recipient).spice,recipientBefore + 3);
|
||||
assert.equal(player(opening,recipient).bribes,0);
|
||||
assert.equal(status(opening,id),'paid');
|
||||
assert.deepEqual(inventory(opening),f.originalCards);
|
||||
});
|
||||
|
||||
void test('a future-due deal can be paid early, and its actual late Mentat receipt waits until the next opening', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
|
||||
const recipientSpice = player(accepted,recipient).spice;
|
||||
const paid = act(accepted,payer,{type:'dealPay',deal:id});
|
||||
assert.equal(status(paid,id),'paid');
|
||||
assert.equal(player(paid,recipient).bribes,3);
|
||||
assert.equal(player(paid,recipient).spice,recipientSpice);
|
||||
const beforeOpening = advance(paid, game => game.turn === f.game.turn + 1 && game.phase === 7 && clean(game));
|
||||
const priorSpice = player(beforeOpening,recipient).spice;
|
||||
const opening = advance(beforeOpening, game => game.turn === f.game.turn + 1 && game.phase === 8 && clean(game));
|
||||
assert.equal(player(opening,recipient).spice,priorSpice + 3);
|
||||
assert.equal(player(opening,recipient).bribes,0);
|
||||
assert.equal(status(opening,id),'paid');
|
||||
assert.deepEqual(inventory(opening),f.originalCards);
|
||||
});
|
||||
|
||||
void test('before the due turn an accepted debt neither reserves current spice nor blocks ending the turn', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,6,f.game.turn + 1);
|
||||
const spent = act(accepted,payer,{type:'bribe',target:f.opponent,amount:8});
|
||||
assert.equal(player(spent,payer).spice,2);
|
||||
assert.equal(status(spent,id),'pending');
|
||||
const next = finish(spent);
|
||||
assert.equal(next.turn,f.game.turn + 1);
|
||||
assert.equal(status(next,id),'pending', 'Future funding is unknown, not automatically impossible.');
|
||||
assert.deepEqual(inventory(next),f.originalCards);
|
||||
});
|
||||
|
||||
void test('a payable debt blocks its payer from ending the actual due-turn Mentat', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
|
||||
const due = advance(accepted, game => game.turn === f.game.turn + 1 && game.phase === 8 && clean(game));
|
||||
assert.equal(status(due,id),'pending');
|
||||
reject(due,payer,{type:'ready'});
|
||||
const paid = act(due,payer,{type:'dealPay',deal:id});
|
||||
const ready = act(paid,payer,{type:'ready'});
|
||||
const next = finish(ready);
|
||||
assert.equal(next.turn,f.game.turn + 2);
|
||||
assert.equal(status(next,id),'paid');
|
||||
assert.deepEqual(inventory(next),f.originalCards);
|
||||
});
|
||||
|
||||
void test('unanimous early release cancels only the future unpaid term', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker;
|
||||
const { game: accepted, id } = createDeal(f.game,recipient,payer,recipient,3,f.game.turn + 1);
|
||||
const requested = act(accepted,recipient,{type:'dealRelease',deal:id,agree:true});
|
||||
assert.equal(status(requested,id),'pending');
|
||||
const released = act(requested,payer,{type:'dealRelease',deal:id,agree:true});
|
||||
assert.equal(status(released,id),'released');
|
||||
reject(released,payer,{type:'dealPay',deal:id});
|
||||
const next = finish(released);
|
||||
assert.equal(status(next,id),'released');
|
||||
assert.equal(player(next,payer).spice,player(f.game,payer).spice);
|
||||
assert.deepEqual(inventory(next),f.originalCards);
|
||||
});
|
||||
|
||||
void test('different due turns remain separate and may be settled in either order', () => {
|
||||
const f = fixture(), payer = f.target, recipient = f.asker, turn = f.game.turn;
|
||||
const first = createDeal(f.game,recipient,payer,recipient,3,turn + 1);
|
||||
const second = createDeal(first.game,recipient,payer,recipient,4,turn + 2);
|
||||
const next = finish(second.game);
|
||||
assert.equal(status(next,first.id),'pending'); assert.equal(status(next,second.id),'pending');
|
||||
const paidSecond = act(next,payer,{type:'dealPay',deal:second.id});
|
||||
assert.equal(status(paidSecond,first.id),'pending'); assert.equal(status(paidSecond,second.id),'paid');
|
||||
assert.equal(player(paidSecond,recipient).bribes,4);
|
||||
const paidFirst = act(paidSecond,payer,{type:'dealPay',deal:first.id});
|
||||
assert.equal(status(paidFirst,first.id),'paid');
|
||||
assert.equal(player(paidFirst,recipient).bribes,7);
|
||||
assert.deepEqual(inventory(paidFirst),f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user