From e529cc2d7a5612c013267fb31552930f0d28c56f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:06:55 +1100 Subject: [PATCH] Extend Truthtrance bidding and prepare alliance controls --- components/coalition-nexus.tsx | 92 +++++++++++++ docs/CURRENT_STATUS.md | 15 ++- docs/TRUTHTRANCE_TURN_ACTIONS.md | 7 +- game/coalition-funding.ts | 153 ++++++++++++++++++++++ game/engine.ts | 4 +- game/reference.ts | 2 +- game/truthtrance.ts | 4 +- tests/coalition-funding.test.ts | 122 +++++++++++++++++ tests/coalition-nexus-controls.test.tsx | 60 +++++++++ tests/truth-future-battle-bidding.test.ts | 75 +++++++++++ 10 files changed, 525 insertions(+), 9 deletions(-) create mode 100644 components/coalition-nexus.tsx create mode 100644 game/coalition-funding.ts create mode 100644 tests/coalition-funding.test.ts create mode 100644 tests/coalition-nexus-controls.test.tsx create mode 100644 tests/truth-future-battle-bidding.test.ts diff --git a/components/coalition-nexus.tsx b/components/coalition-nexus.tsx new file mode 100644 index 00000000..a88261dd --- /dev/null +++ b/components/coalition-nexus.tsx @@ -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
+

Group alliance negotiations

+

All three players must agree before an invitation forms an alliance. + The existing pair remains allied while an invitation is pending.

+ {view.groups.length ? :

There are no alliances yet.

} + {proposal ?
+

{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.

+

Approved: {names(proposal.approvals)}. Waiting for: {names( + [...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)), + ) || 'no one'}.

+ {options.mayAccept && } + {options.mayDecline && } +
:
+ {options.invitations.length > 0 &&

Invite an unallied player to an existing pair, or ask to join a pair.

} + {options.invitations.map(option => )} + {options.mayLeave && } + {ownGroup?.members.length === 3 &&

Leaving keeps the other two players allied under the provisional online policy.

} +
} +
; +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c86c600c..f6aa7aab 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index d95c0d7a..06dc55a4 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -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 diff --git a/game/coalition-funding.ts b/game/coalition-funding.ts new file mode 100644 index 00000000..b772c8d5 --- /dev/null +++ b/game/coalition-funding.ts @@ -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 => + !!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) { + const groups = coalitionGroups(input.seats, input.coalitionState); + requireFunding(Array.isArray(input.authorizations), 'Supply current authoritative alliance funding epochs.'); + const expected = new Set(); + for (const group of groups) for (const edge of group.edges) + expected.add(edgeKey(edge.members[0], edge.members[1])); + const current = new Map(), usedEpochs = new Set(); + 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): { + 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(), 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(); + for (const pledge of reconciled.pledges) pledges.set(pairKey(pledge.donor, pledge.recipient), pledge); + const donors = new Set(); + 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 }; +} diff --git a/game/engine.ts b/game/engine.ts index 591ab218..b18b3224 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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 diff --git a/game/reference.ts b/game/reference.ts index 9e2b61bd..91b8a445 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 2e701bea..9e62a335 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -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') { diff --git a/tests/coalition-funding.test.ts b/tests/coalition-funding.test.ts new file mode 100644 index 00000000..9fa2cca5 --- /dev/null +++ b/tests/coalition-funding.test.ts @@ -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/); +}); diff --git a/tests/coalition-nexus-controls.test.tsx b/tests/coalition-nexus-controls.test.tsx new file mode 100644 index 00000000..c3b9ee6e --- /dev/null +++ b/tests/coalition-nexus-controls.test.tsx @@ -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/); +}); diff --git a/tests/truth-future-battle-bidding.test.ts b/tests/truth-future-battle-bidding.test.ts new file mode 100644 index 00000000..e354e899 --- /dev/null +++ b/tests/truth-future-battle-bidding.test.ts @@ -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'); + } +});