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

+92
View File
@@ -0,0 +1,92 @@
'use client';
import type { CoalitionAdmission, CoalitionGroup } from '../game/coalition-alliance';
/** Public Nexus projection. The authoritative engine still checks all timing,
* eligibility, event and revision constraints before changing the alliance. */
export type CoalitionNexusView = {
me: string;
players: readonly { id: string; name: string }[];
groups: readonly CoalitionGroup[];
proposal: CoalitionAdmission | null;
revision: number;
};
export type CoalitionNexusIntent =
| { kind: 'propose'; groupId: string; entrant: string; expectedRevision: number }
| { kind: 'answer'; event: string; nexus: string; turn: number; accept: boolean; expectedRevision: number }
| { kind: 'leave'; expectedRevision: number };
/** Choices are informational; the server remains the legality authority. */
export function coalitionNexusOptions(view: CoalitionNexusView): {
invitations: { groupId: string; entrant: string; members: readonly string[] }[];
mayLeave: boolean;
mayAccept: boolean;
mayDecline: boolean;
} {
const seated = view.players.some(player => player.id === view.me);
const ownGroup = view.groups.find(group => group.members.includes(view.me));
const occupied = new Set(view.groups.flatMap(group => group.members));
const invitations = !seated || view.proposal ? [] : view.groups.flatMap(group =>
group.members.length !== 2 ? [] : view.players
.filter(player => !occupied.has(player.id) &&
(player.id === view.me || group.members.includes(view.me)))
.map(player => ({ groupId: group.id, entrant: player.id, members: group.members })));
const participants = view.proposal
? [...view.proposal.original.members, view.proposal.entrant] : [];
const mayDecline = seated && participants.includes(view.me);
return {
invitations,
mayLeave: seated && !!ownGroup && !view.proposal,
mayAccept: mayDecline && !view.proposal!.approvals.includes(view.me),
mayDecline,
};
}
/** Render only when the group-alliance option and Nexus are authorized. */
export function CoalitionNexus({ view, disabled = false, onIntent }: {
view: CoalitionNexusView;
disabled?: boolean;
onIntent: (intent: CoalitionNexusIntent) => void;
}) {
const options = coalitionNexusOptions(view);
const name = (id: string) => view.players.find(player => player.id === id)?.name ?? id;
const names = (ids: readonly string[]) => ids.map(name).join(', ');
const proposal = view.proposal;
const ownGroup = view.groups.find(group => group.members.includes(view.me));
const submit = (intent: CoalitionNexusIntent) => { if (!disabled) onIntent(intent); };
return <section aria-label="Group alliance negotiations" className="notice min-w-0 space-y-3 break-words">
<h3>Group alliance negotiations</h3>
<p>All three players must agree before an invitation forms an alliance.
The existing pair remains allied while an invitation is pending.</p>
{view.groups.length ? <ul>{view.groups.map(group =>
<li key={group.id}>{names(group.members)}</li>)}</ul> : <p>There are no alliances yet.</p>}
{proposal ? <div aria-live="polite">
<p>{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.</p>
<p>Approved: {names(proposal.approvals)}. Waiting for: {names(
[...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)),
) || 'no one'}.</p>
{options.mayAccept && <button className="game-action" disabled={disabled} onClick={() => submit({
kind: 'answer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn,
accept: true, expectedRevision: view.revision,
})}>Accept invitation</button>}
{options.mayDecline && <button className="game-action" disabled={disabled} onClick={() => submit({
kind: 'answer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn,
accept: false, expectedRevision: view.revision,
})}>{proposal.proposer === view.me ? 'Withdraw and cancel invitation' :
proposal.approvals.includes(view.me) ? 'Withdraw approval and cancel invitation' :
'Decline and cancel invitation'}</button>}
</div> : <div>
{options.invitations.length > 0 && <p>Invite an unallied player to an existing pair, or ask to join a pair.</p>}
{options.invitations.map(option => <button className="game-action" disabled={disabled}
key={JSON.stringify([option.groupId, option.entrant])} onClick={() => submit({
kind: 'propose', groupId: option.groupId, entrant: option.entrant, expectedRevision: view.revision,
})}>{option.entrant === view.me ? `Ask to join ${names(option.members)}` :
`Invite ${name(option.entrant)} to ${names(option.members)}`}</button>)}
{options.mayLeave && <button className="game-action" disabled={disabled} onClick={() => submit({
kind: 'leave', expectedRevision: view.revision,
})}>Leave {names(ownGroup!.members)}</button>}
{ownGroup?.members.length === 3 && <p>Leaving keeps the other two players allied under the provisional online policy.</p>}
</div>}
</section>;
}
+14 -1
View File
@@ -29,9 +29,22 @@ enabled or claimed complete by the standalone foundation. The shipment helper
now models several witnesses and one physical baseline, with the existing pair
engine using the same quote. Group receipts, separation, Nexus, funding and
other engine paths still need integration before the option can be offered.
Unconnected Nexus controls and a funding quote now model unanimous consent,
explicit donor allocations and edge-bound pledge authorizations. The engine
still must issue and revoke unique edge authorizations, settle and refund
escrow atomically, and connect these controls to native actions and AI.
The separate printed unlimited-size / three-stronghold variant remains missing;
the current group model deliberately caps membership at three. The combined
earlier batch passed 189 cases; the shipment batch passed 58 cases.
earlier batch passed 189 cases; the shipment batch passed 58 cases. The latest
focused funding, Nexus, shipment and Truthtrance union passed 61 cases.
**Conditional future-battle Truthtrance in Basic Bidding — 28 October.
Prototyped / Partial.** The named-leader question also works during a quiet
ordinary auction. Its existing current-leader answer, first actual battle and
no-battle expiry are preserved; the auction retains its active bid and actor.
Four original-action scenarios cover both answers, expiry and legal AI replies.
Other windows, faction and module
combinations remain separately guarded.
**Conditional future-battle Truthtrance in Basic Charity — 28 October.
Prototyped / Partial.** The named-leader question also works in a quiet
+4 -3
View File
@@ -2,7 +2,7 @@
## Conditional next-battle leader question — 28 October 2026
**Prototyped / Partial.** In a quiet Basic Charity, Revival or Shipment and Movement window with
**Prototyped / Partial.** In a quiet Basic Charity, Bidding, Revival or Shipment and Movement window with
classic factions and no optional modules, a player can ask whether another
player will use a named native leader in that player's first later battle this
turn, if one occurs. A definite answer creates a waiting receipt; it does not
@@ -13,14 +13,15 @@ ends. An independently impossible answer is released by the existing battle
promise reconciliation. Another structured action answer for that respondent
is guarded while the conditional answer is active; broader promise composition,
expansion factions, optional modules and Advanced are not supported here.
During Charity and Revival, definite answers use currently available native leaders or
During Charity, Bidding and Revival, definite answers use currently available native leaders or
Cheap Heroes; they do not forecast a future revival or spend its resources.
The publisher's [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
permits game-related current-turn action questions and requires honoring an
answer while possible. The first-battle binding and no-opportunity expiry are
provisional online interpretations, not specific publisher rulings. The
[native runtime tests](../tests/truth-future-battle-leader-runtime.test.ts)
[native runtime tests](../tests/truth-future-battle-leader-runtime.test.ts) and
[Bidding continuation tests](../tests/truth-future-battle-bidding.test.ts)
exercise both answers and expiry without inventing a battle.
## Printed flight and pre-leader cards — 27 October 2026
+153
View File
@@ -0,0 +1,153 @@
import { coalitionGroups, type CoalitionSeat, type CoalitionState } from './coalition-alliance';
/** Already-escrowed spice, explicitly pledged by one donor to one ally.
* Wallet debits, pledge authorization, commitments and refunds remain the
* engine's responsibility; this ledger does not create or hold spice itself. */
export type CoalitionPledge = Readonly<{
donor: string;
recipient: string;
amount: number;
/** The particular alliance edge under which this escrow was authorized. */
epoch: string;
}>;
export type CoalitionFundingAuthorization = Readonly<{
/** Sorted pair of currently allied seats. */
members: readonly [string, string];
epoch: string;
}>;
export type CoalitionContribution = Readonly<{ donor: string; amount: number }>;
export type CoalitionFundingInput = Readonly<{
seats: readonly CoalitionSeat[];
coalitionState?: CoalitionState;
purpose: 'bid' | 'shipment';
payer: string;
cost: number;
/** Spendable payer spice after other obligations have been reserved. */
ownAvailable: number;
/** Authoritative current edge authorizations, never supplied by a client. */
authorizations: readonly CoalitionFundingAuthorization[];
pledges: readonly CoalitionPledge[];
contributions: readonly CoalitionContribution[];
}>;
export type CoalitionFundingQuote = {
purpose: 'bid' | 'shipment';
payer: string;
cost: number;
own: number;
contributions: { donor: string; amount: number }[];
};
export class CoalitionFundingError extends Error {
constructor(message: string) { super(message); this.name = 'CoalitionFundingError'; }
}
function requireFunding(condition: unknown, message: string): asserts condition {
if (!condition) throw new CoalitionFundingError(message);
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const amount = (value: unknown): value is number =>
Number.isSafeInteger(value) && Number(value) >= 0;
const pairKey = (donor: string, recipient: string) => JSON.stringify([donor, recipient]);
const edgeKey = (a: string, b: string) => JSON.stringify([a, b].sort());
const epoch = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value;
/** The engine must mint a globally fresh, never-reused epoch on edge formation,
* preserve it while that edge remains allied, and revoke it atomically on a
* break. A re-formed edge must receive a different epoch even in the same turn.
* Legacy pairs need an issued epoch before funding may use this API. */
function fundingEdges(input: Pick<CoalitionFundingInput, 'seats' | 'coalitionState' | 'authorizations'>) {
const groups = coalitionGroups(input.seats, input.coalitionState);
requireFunding(Array.isArray(input.authorizations), 'Supply current authoritative alliance funding epochs.');
const expected = new Set<string>();
for (const group of groups) for (const edge of group.edges)
expected.add(edgeKey(edge.members[0], edge.members[1]));
const current = new Map<string, string>(), usedEpochs = new Set<string>();
for (const authorization of input.authorizations) {
requireFunding(record(authorization) && Array.isArray(authorization.members) &&
authorization.members.length === 2 && typeof authorization.members[0] === 'string' &&
typeof authorization.members[1] === 'string' && authorization.members[0] < authorization.members[1] &&
epoch(authorization.epoch), 'Each funding epoch needs an ordered pair of allies.');
const key = edgeKey(authorization.members[0], authorization.members[1]);
requireFunding(expected.has(key) && !current.has(key) && !usedEpochs.has(authorization.epoch),
'Funding epochs must uniquely match current alliance edges exactly.');
current.set(key, authorization.epoch);
usedEpochs.add(authorization.epoch);
}
requireFunding(current.size === expected.size, 'Every current alliance edge needs an authoritative funding epoch.');
return current;
}
/** Separate stale escrow for refund after a break or epoch replacement.
* The engine must resolve or cancel any affected committed payments and
* atomically refund every returned stale record before using the retained
* ledger. No wallet or pledge is changed by this pure function. */
export function reconcileCoalitionPledges(input: Pick<CoalitionFundingInput,
'seats' | 'coalitionState' | 'authorizations' | 'pledges'>): {
pledges: CoalitionPledge[]; refunds: CoalitionPledge[];
} {
requireFunding(record(input) && Array.isArray(input.pledges), 'Supply the complete pledged escrow ledger.');
const current = fundingEdges(input);
const seated = new Set(input.seats.map(seat => seat.id));
const seen = new Set<string>(), pledges: CoalitionPledge[] = [], refunds: CoalitionPledge[] = [];
for (const pledge of input.pledges) {
requireFunding(record(pledge) && typeof pledge.donor === 'string' && typeof pledge.recipient === 'string' &&
pledge.donor !== pledge.recipient && seated.has(pledge.donor) && seated.has(pledge.recipient) &&
amount(pledge.amount) && epoch(pledge.epoch), 'Every pledge needs seated parties, safe escrow and an epoch.');
const key = pairKey(pledge.donor, pledge.recipient);
requireFunding(!seen.has(key), 'Each donor may have only one pledge for a recipient.');
seen.add(key);
(current.get(edgeKey(pledge.donor, pledge.recipient)) === pledge.epoch ? pledges : refunds).push({
donor: pledge.donor, recipient: pledge.recipient, amount: pledge.amount, epoch: pledge.epoch,
});
}
return { pledges, refunds };
}
/** Validate an exact declared split against current coalition membership and
* each recipient-specific escrow. The group-funding and allocation procedure
* is an online interpretation; the printed rule permits allies to help pay.
* This is not an authorization, reservation, or a promise of future funds. */
export function quoteCoalitionFunding(input: CoalitionFundingInput): CoalitionFundingQuote {
requireFunding(record(input) && (input.purpose === 'bid' || input.purpose === 'shipment') &&
amount(input.cost) && amount(input.ownAvailable) && Array.isArray(input.pledges) &&
Array.isArray(input.contributions), 'Choose a valid bid or shipment and safe spice amounts.');
requireFunding(typeof input.payer === 'string' && input.seats.some(seat => seat.id === input.payer),
'Choose a seated payer.');
const reconciled = reconcileCoalitionPledges(input);
requireFunding(reconciled.refunds.length === 0, 'Reconcile and refund stale pledge escrow before payment.');
const pledges = new Map<string, CoalitionPledge>();
for (const pledge of reconciled.pledges) pledges.set(pairKey(pledge.donor, pledge.recipient), pledge);
const donors = new Set<string>();
const contributions: { donor: string; amount: number }[] = [];
let own = input.cost;
for (const share of input.contributions) {
requireFunding(record(share) && typeof share.donor === 'string' && amount(share.amount) && share.amount > 0 &&
!donors.has(share.donor), 'Declare each donating ally once with a positive safe amount.');
const pledge = pledges.get(pairKey(share.donor, input.payer));
requireFunding(pledge && share.amount <= pledge.amount,
'Each contribution needs enough current escrow pledged to this payer.');
requireFunding(share.amount <= own, 'Contributions cannot exceed the cost.');
own -= share.amount;
donors.add(share.donor);
contributions.push({ donor: share.donor, amount: share.amount });
}
requireFunding(own <= input.ownAvailable, 'The payer cannot cover the remaining cost.');
return { purpose: input.purpose, payer: input.payer, cost: input.cost, own, contributions };
}
/** Revalidate the complete current input at the moment of payment and return
* a new pledge ledger. The engine must debit quote.own and install this ledger
* in the same authoritative action; recipient routing and replay protection
* remain native transaction responsibilities. Never apply a stale quote alone. */
export function settleCoalitionFunding(input: CoalitionFundingInput): {
quote: CoalitionFundingQuote;
pledges: CoalitionPledge[];
} {
const quote = quoteCoalitionFunding(input);
const debits = new Map(quote.contributions.map(share => [share.donor, share.amount]));
const pledges = input.pledges.map(pledge => ({ ...pledge,
amount: pledge.amount - (pledge.recipient === quote.payer ? debits.get(pledge.donor) ?? 0 : 0),
}));
return { quote, pledges };
}
+2 -2
View File
@@ -31695,7 +31695,7 @@ function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeade
...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
];
if (q.turn !== g.turn || q.target !== p.id || ![2, 4, 5].includes(g.phase) ||
if (q.turn !== g.turn || q.target !== p.id || ![2, 3, 4, 5].includes(g.phase) ||
(g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id &&
(row.status === 'waiting' || row.status === 'bound')) ||
otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
@@ -45802,7 +45802,7 @@ export function viewGame(state: Game, id: string, now?: number) {
truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && [2, 4, 5].includes(g.phase) &&
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && [2, 3, 4, 5].includes(g.phase) &&
!g.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response &&
!g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard
+1 -1
View File
@@ -3305,7 +3305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'In a quiet Basic Charity, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
'In a quiet Basic Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
+2 -2
View File
@@ -602,11 +602,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
v.turn === g.turn && typeof v.leader === 'string' &&
leaders(target.faction).some(leader => leader.id === v.leader) &&
g.status === 'playing' && !g.advanced && [2, 4, 5].includes(g.phase) && !g.expansions.length &&
g.status === 'playing' && !g.advanced && [2, 3, 4, 5].includes(g.phase) && !g.expansions.length &&
!g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose a native leader in a quiet Basic Charity, Revival or Shipment and Movement window.');
'Choose a native leader in a quiet Basic Charity, Bidding, Revival or Shipment and Movement window.');
return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
}
if (v.kind === 'battlePlan') {
+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');
}
});