Extend Truthtrance bidding and prepare alliance controls
Verify and publish checkpoint container / container (push) Successful in 11m0s
Verify and publish checkpoint container / container (push) Successful in 11m0s
This commit is contained in:
1 parent
77376c39d6
commit
e529cc2d7a
10 files changed
+525
-9
No files matched your search
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user