Files
Dune/tests/public-deals-deferred-runtime.test.ts

145 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import type { PublicDeal } from '../game/public-deals';
import {
actMentatBribe as act, createMentatBribeFixture as fixture,
finishMentatBribe as finish, mentatBribeInventory as inventory,
mentatBribePlayer as player,
} from './fixture-mentat-bribes';
type DeferredDeal = PublicDeal & { paymentStatus?: 'pending' | 'paid' | 'void' | 'released' };
const getDeal = (game: Game, id: string): DeferredDeal => {
const deal = game.deals?.find(row => row.id === id); assert.ok(deal); return deal as DeferredDeal;
};
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Rejected packets preserve the original game.');
}
function offer(game: Game, proposer: string, payer: string, recipient: string, amount: number,
targets: string[] = [payer]) {
const payment = { kind: 'deferredBribe', payer, recipient, amount, dueTurn: game.turn };
const fields = targets.length === 1 ? { target: targets[0] } : { targets };
const next = act(game, proposer, { type: 'dealOffer', ...fields, terms: 'Pay the agreed spice this turn.', payment });
const deal = next.deals?.at(-1); assert.ok(deal);
return { game: next, id: deal.id, payment };
}
function accept(game: Game, id: string, actors: string[]) {
for (const actor of actors) game = act(game, actor, { type: 'dealRespond', deal: id, accept: true });
return game;
}
function pay(game: Game, payer: string, id: string) {
return act(game, payer, { type: 'dealPay', deal: id });
}
/** Each test reaches real Basic turn-one Mentat through original setup,
* bidding and Collection. The source orders only unopened physical cards;
* no dealt hand, purse, clock or agreement is installed. */
void test('unanimous deferred consent creates a pending exact payment without moving spice', () => {
const f = fixture(), before = f.game;
const { game: proposed, id, payment } = offer(before, f.opponent, f.target, f.asker, 3, [f.asker, f.target]);
const first = accept(proposed, id, [f.asker]);
assert.equal(getDeal(first, id).status, 'offered');
assert.equal(player(first, f.target).spice, player(before, f.target).spice);
const active = accept(first, id, [f.target]);
assert.equal(getDeal(active, id).status, 'active');
assert.equal(getDeal(active, id).paymentStatus, 'pending');
assert.deepEqual(getDeal(active, id).payment, payment);
assert.equal(player(active, f.target).spice, player(before, f.target).spice);
assert.equal(player(active, f.asker).bribes, player(before, f.asker).bribes);
assert.deepEqual(viewGame(active, f.opponent).deals.find(row => row.id === id), getDeal(active, id));
rejected(active, f.opponent, { type: 'dealPay', deal: id });
const paid = pay(active, f.target, id);
assert.equal(getDeal(paid, id).paymentStatus, 'paid');
assert.equal(player(paid, f.target).spice, player(before, f.target).spice - 3);
assert.equal(player(paid, f.asker).bribes, player(before, f.asker).bribes + 3);
assert.equal(player(paid, f.asker).spice, player(before, f.asker).spice);
rejected(paid, f.target, { type: 'dealPay', deal: id });
assert.deepEqual(inventory(paid), f.originalCards);
});
void test('two independently agreed debts need two exact native transfers', () => {
const f = fixture();
const first = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(first.game, first.id, [f.target]);
const second = offer(game, f.asker, f.target, f.asker, 4);
game = accept(second.game, second.id, [f.target]);
assert.equal(player(game, f.target).spice, 10);
game = pay(game, f.target, second.id);
assert.equal(getDeal(game, first.id).paymentStatus, 'pending');
assert.equal(getDeal(game, second.id).paymentStatus, 'paid');
assert.equal(player(game, f.target).spice, 6);
game = pay(game, f.target, first.id);
assert.equal(player(game, f.target).spice, 3);
assert.equal(player(game, f.asker).bribes, 7);
assert.equal(getDeal(game, first.id).paymentStatus, 'paid');
assert.deepEqual(inventory(game), f.originalCards);
});
void test('unanimous release removes only the unpaid agreement and never refunds a paid one', () => {
const f = fixture();
const first = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(first.game, first.id, [f.target]);
const second = offer(game, f.asker, f.target, f.asker, 4);
game = accept(second.game, second.id, [f.target]);
game = pay(game, f.target, first.id);
game = act(game, f.asker, { type: 'dealRelease', deal: second.id, agree: true });
assert.equal(getDeal(game, second.id).paymentStatus, 'pending');
game = act(game, f.target, { type: 'dealRelease', deal: second.id, agree: true });
assert.equal(getDeal(game, second.id).paymentStatus, 'released');
rejected(game, f.target, { type: 'dealPay', deal: second.id });
game = act(game, f.asker, { type: 'dealRelease', deal: first.id, agree: true });
game = act(game, f.target, { type: 'dealRelease', deal: first.id, agree: true });
assert.equal(getDeal(game, first.id).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('voluntary spending preserves enough cash for each accepted exact payment', () => {
const f = fixture();
const first = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(first.game, first.id, [f.target]);
const second = offer(game, f.asker, f.target, f.asker, 4);
game = accept(second.game, second.id, [f.target]);
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 });
assert.equal(player(game, f.target).spice, 7);
game = pay(game, f.target, first.id);
game = pay(game, f.target, second.id);
assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.opponent).bribes, 3);
assert.equal(player(game, f.asker).bribes, 7);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a payable current-turn debt blocks its payer ending Mentat, then real turn advance retains the receipt', () => {
const f = fixture();
const offerResult = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(offerResult.game, offerResult.id, [f.target]);
rejected(game, f.target, { type: 'ready' });
game = pay(game, f.target, offerResult.id);
game = act(game, f.target, { type: 'ready' });
const next = finish(game);
assert.equal(next.turn, f.game.turn + 1); assert.equal(next.phase, 0);
assert.equal(getDeal(next, offerResult.id).paymentStatus, 'paid');
assert.equal(player(next, f.asker).bribes, 3, 'Late Mentat transfers wait for the next Mentat opening.');
assert.deepEqual(inventory(next), f.originalCards);
});
void test('a staged externally lost purse is voided on another player’s actual next action', () => {
const f = fixture();
const offered = offer(f.game, f.asker, f.target, f.asker, 3);
let game = accept(offered.game, offered.id, [f.target]);
// Controlled external loss: move existing spice, never create/destroy it.
const staged = structuredClone(game), target = player(staged, f.target), other = player(staged, f.opponent);
target.spice -= 8; other.spice += 8;
game = act(staged, f.opponent, { type: 'ready' });
assert.equal(getDeal(game, offered.id).paymentStatus, 'void');
rejected(game, f.target, { type: 'dealPay', deal: offered.id });
assert.equal(player(game, f.target).spice, 2);
assert.equal(player(game, f.asker).bribes, 0);
assert.deepEqual(inventory(game), f.originalCards);
});