Extend Truthtrance bidding and prepare alliance controls
Verify and publish checkpoint container / container (push) Successful in 11m0s

This commit is contained in:
Quintine committed 2026-10-28 22:06:55 +11:00
1 parent 77376c39d6
commit e529cc2d7a
10 files changed
+525 -9

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { quoteCoalitionFunding, reconcileCoalitionPledges, settleCoalitionFunding, type CoalitionFundingInput } from '../game/coalition-funding';
import type { CoalitionSeat, CoalitionState } from '../game/coalition-alliance';
function fixture(): CoalitionFundingInput {
const seats: CoalitionSeat[] = ['a', 'b', 'c', 'd'].map(id => ({ id, ally: null }));
const coalitionState: CoalitionState = { version: 1, revision: 3, proposal: null, usedEvents: [], groups: [{
id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [
{ members: ['a', 'b'], formedTurn: 2 }, { members: ['a', 'c'], formedTurn: 3 },
{ members: ['b', 'c'], formedTurn: 3 },
],
}] };
return { seats, coalitionState, purpose: 'bid', payer: 'a', cost: 10, ownAvailable: 4,
authorizations: [{ members: ['a', 'b'], epoch: 'ab1' }, { members: ['a', 'c'], epoch: 'ac1' },
{ members: ['b', 'c'], epoch: 'bc1' }],
pledges: [
{ donor: 'b', recipient: 'a', amount: 4, epoch: 'ab1' }, { donor: 'c', recipient: 'a', amount: 5, epoch: 'ac1' },
{ donor: 'b', recipient: 'c', amount: 2, epoch: 'bc1' },
], contributions: [{ donor: 'b', amount: 3 }, { donor: 'c', amount: 4 }] };
}
void test('exact split uses each donor escrow and preserves unrelated pledges', () => {
const input = fixture(), original = structuredClone(input);
const quote = quoteCoalitionFunding(input);
assert.deepEqual(quote, { purpose: 'bid', payer: 'a', cost: 10, own: 3,
contributions: [{ donor: 'b', amount: 3 }, { donor: 'c', amount: 4 }] });
assert.deepEqual(settleCoalitionFunding(input), { quote, pledges: [
{ donor: 'b', recipient: 'a', amount: 1, epoch: 'ab1' }, { donor: 'c', recipient: 'a', amount: 1, epoch: 'ac1' },
{ donor: 'b', recipient: 'c', amount: 2, epoch: 'bc1' },
] });
assert.deepEqual(input, original);
});
void test('pair and own-only payments retain exact split including zero-cost shipments', () => {
const seats: CoalitionSeat[] = [{ id: 'a', ally: 'b' }, { id: 'b', ally: 'a' }, { id: 'c', ally: null }];
const input: CoalitionFundingInput = { seats, purpose: 'shipment', payer: 'a', cost: 6, ownAvailable: 4,
authorizations: [{ members: ['a', 'b'], epoch: 'ab1' }],
pledges: [{ donor: 'b', recipient: 'a', amount: 3, epoch: 'ab1' }], contributions: [{ donor: 'b', amount: 2 }] };
assert.deepEqual(settleCoalitionFunding(input), { quote: { purpose: 'shipment', payer: 'a', cost: 6,
own: 4, contributions: [{ donor: 'b', amount: 2 }] }, pledges: [{ donor: 'b', recipient: 'a', amount: 1, epoch: 'ab1' }] });
assert.equal(quoteCoalitionFunding({ ...input, cost: 4, contributions: [] }).own, 4);
assert.equal(quoteCoalitionFunding({ ...input, cost: 0, ownAvailable: 0, contributions: [] }).own, 0);
});
void test('settlement revalidates current pledges, coalition and payer budget', () => {
const input = fixture();
quoteCoalitionFunding(input);
assert.throws(() => settleCoalitionFunding({ ...input, ownAvailable: 2 }), /remaining cost/);
assert.throws(() => settleCoalitionFunding({ ...input, pledges: [
{ donor: 'b', recipient: 'a', amount: 2, epoch: 'ab1' }, ...input.pledges.slice(1),
] }), /current escrow/);
const seats: CoalitionSeat[] = [{ id: 'a', ally: 'b' }, { id: 'b', ally: 'a' },
{ id: 'c', ally: null }, { id: 'd', ally: null }];
assert.throws(() => settleCoalitionFunding({ ...input, seats, coalitionState: undefined,
authorizations: [{ members: ['a', 'b'], epoch: 'ab1' }] }), /Reconcile and refund stale/);
assert.throws(() => settleCoalitionFunding({ ...input, coalitionState: null as unknown as CoalitionState }), /ledger cannot be null/);
});
void test('only the stated recipient can use a pledge and no split silently reallocates', () => {
const input = fixture();
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'd', amount: 1 }] }), /current escrow/);
assert.throws(() => quoteCoalitionFunding({ ...input, payer: 'c', contributions: [{ donor: 'c', amount: 1 }] }), /current escrow/);
assert.throws(() => quoteCoalitionFunding({ ...input, cost: 2 }), /exceed the cost/);
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'b', amount: 3 }, { donor: 'b', amount: 1 }] }), /each donating ally once/i);
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'b', amount: 0 }] }), /positive safe/);
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [...input.pledges, input.pledges[0]] }), /only one pledge/);
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [{ donor: 'd', recipient: 'a', amount: 1, epoch: 'ad1' }] }), /Reconcile and refund stale/);
});
void test('safe integer boundaries avoid overflow and JSON round trips keep the split', () => {
const input = fixture(), max = Number.MAX_SAFE_INTEGER;
const large: CoalitionFundingInput = { ...input, cost: max, ownAvailable: 0,
pledges: [{ donor: 'b', recipient: 'a', amount: max, epoch: 'ab1' }, { donor: 'c', recipient: 'a', amount: max, epoch: 'ac1' }],
contributions: [{ donor: 'b', amount: max }] };
assert.equal(settleCoalitionFunding(large).quote.own, 0);
assert.deepEqual(settleCoalitionFunding(JSON.parse(JSON.stringify(input)) as CoalitionFundingInput), settleCoalitionFunding(input));
for (const bad of [-1, 0.5, NaN, Infinity, max + 1]) {
assert.throws(() => quoteCoalitionFunding({ ...input, cost: bad }), /safe spice/);
assert.throws(() => quoteCoalitionFunding({ ...input, contributions: [{ donor: 'b', amount: bad }] }), /positive safe/);
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [{ donor: 'b', recipient: 'a', amount: bad, epoch: 'ab1' }] }), /safe escrow/);
}
});
void test('a broken and re-formed alliance cannot spend an old pledge even in the same turn', () => {
const seats: CoalitionSeat[] = [{ id: 'a', ally: 'b' }, { id: 'b', ally: 'a' }, { id: 'c', ally: null }];
const original: CoalitionFundingInput = { seats, purpose: 'bid', payer: 'a', cost: 4, ownAvailable: 0,
authorizations: [{ members: ['a', 'b'], epoch: 'first-edge' }],
pledges: [{ donor: 'b', recipient: 'a', amount: 4, epoch: 'first-edge' }],
contributions: [{ donor: 'b', amount: 4 }] };
assert.equal(quoteCoalitionFunding(original).own, 0);
const broken = { ...original, seats: seats.map(seat => ({ ...seat, ally: null })), authorizations: [] };
assert.deepEqual(reconcileCoalitionPledges(broken), { pledges: [], refunds: original.pledges });
const reformed = { ...original, authorizations: [{ members: ['a', 'b'] as [string, string], epoch: 'new-edge' }] };
assert.throws(() => settleCoalitionFunding(reformed), /Reconcile and refund stale/);
assert.deepEqual(reconcileCoalitionPledges(reformed), { pledges: [], refunds: original.pledges });
const fresh = { ...reformed, pledges: [{ donor: 'b', recipient: 'a', amount: 4, epoch: 'new-edge' }] };
assert.equal(settleCoalitionFunding(fresh).pledges[0].amount, 0);
});
void test('unrelated stale pledges require explicit reconciliation without consuming valid escrow', () => {
const input = fixture();
const changed = { ...input, authorizations: [input.authorizations[0], input.authorizations[1],
{ members: ['b', 'c'] as [string, string], epoch: 'bc2' }] };
assert.throws(() => quoteCoalitionFunding(changed), /Reconcile and refund stale/);
const reconciled = reconcileCoalitionPledges(changed);
assert.deepEqual(reconciled.refunds, [input.pledges[2]]);
assert.deepEqual(reconciled.pledges, input.pledges.slice(0, 2));
assert.equal(settleCoalitionFunding({ ...changed, pledges: reconciled.pledges }).quote.own, 3);
assert.deepEqual(input.pledges, fixture().pledges);
});
void test('authorizations must cover exactly the current edges with valid ordered epochs', () => {
const input = fixture();
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: input.authorizations.slice(1) }), /Every current alliance edge/);
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [...input.authorizations, input.authorizations[0]] }), /exactly/);
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [input.authorizations[0],
{ members: ['a', 'c'], epoch: 'ab1' }, input.authorizations[2]] }), /uniquely/);
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [{ members: ['b', 'a'], epoch: 'bad' }] }), /ordered pair/);
assert.throws(() => quoteCoalitionFunding({ ...input, authorizations: [{ members: ['a', 'b'], epoch: ' ' }] }), /ordered pair/);
assert.throws(() => quoteCoalitionFunding({ ...input, pledges: [{ donor: 'b', recipient: 'a', amount: 1, epoch: '' }] }), /epoch/);
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CoalitionNexus, coalitionNexusOptions, type CoalitionNexusView } from '../components/coalition-nexus';
import type { CoalitionGroup } from '../game/coalition-alliance';
const pair: CoalitionGroup = { id: 'pair', members: ['a', 'b'], formedTurn: 2,
edges: [{ members: ['a', 'b'], formedTurn: 2 }] };
function view(me = 'a'): CoalitionNexusView {
return { me, revision: 4, groups: [pair], proposal: null, players: [
{ id: 'a', name: 'Atreides' }, { id: 'b', name: 'Emperor' },
{ id: 'c', name: 'Fremen' }, { id: 'd', name: 'Guild' },
] };
}
const html = (value: CoalitionNexusView, disabled = false) =>
renderToStaticMarkup(createElement(CoalitionNexus, { view: value, disabled, onIntent() {} }));
void test('only a pair member or the unallied entrant is offered an invitation', () => {
const member = coalitionNexusOptions(view('a'));
assert.deepEqual(member.invitations.map(({ entrant }) => entrant), ['c', 'd']);
assert.equal(member.mayLeave, true);
const entrant = coalitionNexusOptions(view('c'));
assert.deepEqual(entrant.invitations.map(({ entrant }) => entrant), ['c']);
assert.equal(entrant.mayLeave, false);
assert.match(html(view('a')), /Invite Fremen to Atreides, Emperor/);
assert.match(html(view('c')), /Ask to join Atreides, Emperor/);
assert.deepEqual(coalitionNexusOptions(view('missing')).invitations, []);
assert.doesNotMatch(html(view('missing')), /Invite Fremen|Ask to join|Leave Atreides/);
});
void test('pending invitation presents approval and withdrawal only to its participants', () => {
const v = view('c');
v.proposal = { event: 'offer', nexus: 'nexus', turn: 3, groupId: pair.id, original: pair,
entrant: 'c', proposer: 'a', approvals: ['a'] };
let options = coalitionNexusOptions(v);
assert.equal(options.mayAccept, true); assert.equal(options.mayDecline, true);
assert.deepEqual(options.invitations, []); assert.equal(options.mayLeave, false);
assert.match(html(v), /Approved: Atreides/); assert.match(html(v), /Waiting for: Emperor, Fremen/);
assert.match(html(v), /Accept invitation/); assert.match(html(v), /Decline and cancel invitation/);
v.me = 'a'; options = coalitionNexusOptions(v);
assert.equal(options.mayAccept, false); assert.equal(options.mayDecline, true);
assert.match(html(v), /Withdraw and cancel invitation/); assert.doesNotMatch(html(v), /Accept invitation/);
v.me = 'b'; v.proposal.approvals.push('b');
assert.match(html(v), /Withdraw approval and cancel invitation/);
assert.doesNotMatch(html(v), /Accept invitation/);
v.me = 'd'; options = coalitionNexusOptions(v);
assert.equal(options.mayAccept, false); assert.equal(options.mayDecline, false);
assert.doesNotMatch(html(v), /Accept invitation|cancel invitation/);
});
void test('three-member groups cannot receive another entrant and describe leaving policy', () => {
const v = view('a');
v.groups = [{ ...pair, members: ['a', 'b', 'c'] }];
const options = coalitionNexusOptions(v);
assert.deepEqual(options.invitations, []); assert.equal(options.mayLeave, true);
assert.match(html(v), /Leaving keeps the other two players allied/);
assert.match(html(v), /Leave Atreides, Emperor, Fremen/);
assert.match(html(v, true), /disabled=""[^>]*>Leave Atreides, Emperor, Fremen/);
});
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { advanceTruthRevival, nextTruthRevival, truthRevivalClean, truthRevivalPlayer } from './fixture-truth-revival';
import {
actFutureBattleCharity as act, askFutureBattleCharity as ask,
createFutureBattleCharityFixture, futureBattleCharityRecords as records,
reachFutureBattleCharityBattle as reachBattle, reachFutureBattleCharityWindow as reachMovement,
} from './fixture-truth-future-battle-charity';
function fixture() {
const source = createFutureBattleCharityFixture();
const game = advanceTruthRevival(structuredClone(source.game), state =>
state.turn === source.game.turn && state.phase === 3 && !!state.auction && truthRevivalClean(state));
assert.equal(game.phase, 3);
assert.ok(viewGame(game, source.asker).truthFutureBattleLeaderTargets.includes(source.target));
return { ...source, game };
}
for (const answer of ['yes', 'no'] as const) {
void test(`Basic Bidding future-battle ${answer} preserves the lot and binds the later battle`, () => {
const initial = fixture();
const gameWithBid = act(initial.game, initial.game.auction!.active, {type: 'bid', amount: 1});
const f = { ...initial, game: gameWithBid };
const lot = structuredClone(f.game.auction);
let game = act(ask(f), f.target, {type: 'truthAnswer', answer});
assert.deepEqual(game.auction, lot);
assert.equal(records(game)[0].status, 'waiting');
game = reachBattle(reachMovement(game, f), f);
assert.equal(records(game)[0].status, 'bound');
const plan = (leader: string): Action => ({type: 'battlePlan', leader, dial: 0, support: 0, weapon: null, defense: null});
const before = structuredClone(game);
assert.throws(() => act(game, f.target, plan(answer === 'yes' ? f.alternative : f.leader)));
assert.deepEqual(game, before);
game = act(game, f.target, plan(answer === 'yes' ? f.leader : f.alternative));
const opponent = game.battle!.attacker === f.target ? game.battle!.defender : game.battle!.attacker;
const other = truthRevivalPlayer(game, opponent).leaders.find(leader =>
!leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
assert.ok(other);
game = act(game, opponent, plan(other.id));
assert.ok(game.battle?.revealed);
assert.equal(records(game)[0].status, 'settled');
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
});
}
void test('Basic Bidding conditional answer expires without a respondent battle', () => {
const f = fixture();
let game = act(ask(f), f.target, {type: 'truthAnswer', answer: 'yes'});
game = reachMovement(game, f);
assert.ok(Object.values(truthRevivalPlayer(game, f.target).forces).every(amount => amount === 0));
for (let count = 0; count < 1200 && game.turn === f.game.turn && game.phase <= 6 && game.status === 'playing'; count++) {
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = act(game, game.active, {type: 'endMovement'}); continue;
}
const next = nextTruthRevival(game); assert.ok(next);
game = act(game, next.actor, next.action);
}
assert.equal(records(game)[0].status, 'expired');
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
});
void test('each AI difficulty can legally answer the Bidding question', () => {
for (const difficulty of DIFFICULTIES) {
const f = fixture();
const asked = ask(f);
truthRevivalPlayer(asked, f.target).bot = difficulty;
const answer = botActions(viewGame(asked, f.target)).find(action => action.type === 'truthAnswer');
assert.ok(answer);
const game = act(asked, f.target, answer);
assert.equal(records(game)[0].status, 'waiting');
}
});