Support staged group shipment funding
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
624f95dde4
commit
34aa64cdef
13 files changed
+828
-16
No files matched your search
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
/** Only the viewer's own pledges and contributions offered to that viewer. */
|
||||
export type CoalitionFundingView = {
|
||||
targets: readonly { id: string; name: string; pledged: number; maximum: number }[];
|
||||
incoming: readonly { donor: string; name: string; amount: number }[];
|
||||
blocked: string | null;
|
||||
};
|
||||
export type CoalitionFundingContribution = { donor: string; amount: number };
|
||||
export type CoalitionPledgeAction = { type: 'coalitionPledge'; recipient: string; amount: number };
|
||||
|
||||
function safeMaximum(value: number): number {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
/** Keep only positive donor-specific allocations within the current pledges. */
|
||||
export function coalitionFundingContributions(
|
||||
incoming: CoalitionFundingView['incoming'], selections: Readonly<Record<string, number>>,
|
||||
): CoalitionFundingContribution[] {
|
||||
return incoming.flatMap(entry => {
|
||||
const selected = selections[entry.donor];
|
||||
const amount = Number.isFinite(selected)
|
||||
? Math.max(0, Math.min(safeMaximum(entry.amount), Math.trunc(selected))) : 0;
|
||||
return amount ? [{ donor: entry.donor, amount }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Advisory validation only; the engine verifies current membership, escrow,
|
||||
* commitments and wallet again when it receives the action. */
|
||||
export function coalitionPledgeAction(
|
||||
target: CoalitionFundingView['targets'][number], raw: string,
|
||||
blocked: string | null, busy = false,
|
||||
): CoalitionPledgeAction | null {
|
||||
if (busy || blocked || !/^(0|[1-9]\d*)$/.test(raw)) return null;
|
||||
const amount = Number(raw);
|
||||
if (!Number.isSafeInteger(amount) || amount > safeMaximum(target.maximum)) return null;
|
||||
return { type: 'coalitionPledge', recipient: target.id, amount };
|
||||
}
|
||||
|
||||
export function CoalitionFunding({ view, busy = false, act, onContributionsChange }: {
|
||||
view: CoalitionFundingView;
|
||||
busy?: boolean;
|
||||
act: (action: CoalitionPledgeAction) => void;
|
||||
onContributionsChange?: (contributions: CoalitionFundingContribution[]) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [pledges, setPledges] = useState<Record<string, string>>({});
|
||||
const incomingKey = JSON.stringify(view.incoming.map(({donor, amount}) => [donor, amount]));
|
||||
const [selection, setSelection] = useState<{key:string; shares:Record<string, number>}>({key:'', shares:{}});
|
||||
const shares = selection.key === incomingKey ? selection.shares : {};
|
||||
|
||||
function changeShare(donor: string, amount: number) {
|
||||
const next = { ...shares, [donor]: amount };
|
||||
const contributions = coalitionFundingContributions(view.incoming, next);
|
||||
setSelection({key:incomingKey, shares:Object.fromEntries(contributions.map(item => [item.donor, item.amount]))});
|
||||
onContributionsChange?.(contributions);
|
||||
}
|
||||
|
||||
return <section aria-label="Group alliance funding" className="alliance-funding space-y-3">
|
||||
<h3>Group alliance funding</h3>
|
||||
<p>Set aside spice separately for each ally. Unspent pledges return at the end of the phase.</p>
|
||||
{view.blocked && <p className="notice">{view.blocked}</p>}
|
||||
{view.targets.map((target, index) => {
|
||||
const raw = pledges[target.id] ?? String(target.pledged);
|
||||
const action = coalitionPledgeAction(target, raw, view.blocked, busy);
|
||||
const inputId = `${id}-pledge-${index}`;
|
||||
return <div key={target.id} className="space-y-2">
|
||||
<label htmlFor={inputId}>Pledge to {target.name} · {target.pledged} spice currently set aside</label>
|
||||
<input id={inputId} type="number" min={0} max={safeMaximum(target.maximum)} step={1}
|
||||
inputMode="numeric" value={raw} disabled={busy || !!view.blocked}
|
||||
onChange={event => setPledges(previous => ({ ...previous, [target.id]: event.target.value }))} />
|
||||
<button type="button" className="game-action" disabled={!action}
|
||||
onClick={() => { if (action) act(action); }}>Set funding for {target.name}</button>
|
||||
</div>;
|
||||
})}
|
||||
{view.incoming.length ? <div>
|
||||
<p>Spice pledged to you:</p>
|
||||
{view.incoming.map((entry, index) => {
|
||||
const maximum = safeMaximum(entry.amount);
|
||||
const value = Math.max(0, Math.min(maximum, shares[entry.donor] ?? 0));
|
||||
const inputId = `${id}-share-${index}`;
|
||||
return <div key={entry.donor} className="space-y-2">
|
||||
<p>{entry.name}: {entry.amount} spice</p>
|
||||
{onContributionsChange && <>
|
||||
<label htmlFor={inputId}>Use from {entry.name} for your next shipment</label>
|
||||
<input id={inputId} type="number" min={0} max={maximum} step={1}
|
||||
inputMode="numeric" value={value} disabled={busy || !!view.blocked}
|
||||
onChange={event => {
|
||||
const entered = Number(event.target.value);
|
||||
changeShare(entry.donor, Number.isFinite(entered)
|
||||
? Math.max(0, Math.min(maximum, Math.trunc(entered))) : 0);
|
||||
}} />
|
||||
</>}
|
||||
</div>;
|
||||
})}
|
||||
</div> : <p>No allies have pledged spice to you.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -285,6 +285,7 @@ import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assa
|
||||
import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion';
|
||||
import { AdvancedAllySeparation } from './advanced-ally-separation';
|
||||
import { CoalitionNexus } from './coalition-nexus';
|
||||
import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding';
|
||||
import { NexusEmperorPayment } from './nexus-emperor-payment';
|
||||
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
|
||||
import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase';
|
||||
@@ -583,6 +584,7 @@ export function GameTable({
|
||||
});
|
||||
const [aidAmount, setAidAmount] = useState(0);
|
||||
const [allyPayment, setAllyPayment] = useState('');
|
||||
const [coalitionShares, setCoalitionShares] = useState<{key:string; shares:CoalitionFundingContribution[]}>({key:'', shares:[]});
|
||||
const fillBattlePlan = (plan: Plan) => {
|
||||
setDial(plan.dial);
|
||||
setSupport(plan.support);
|
||||
@@ -780,9 +782,17 @@ export function GameTable({
|
||||
if (useGuildSecret && shipmentCost === null && !g.nexusGuildSecretAlly?.blocked) shipmentProblems.push('Choose a current Guild Secret Ally shipment.');
|
||||
if (useRicheseShipment && shipmentCost === null && !g.nexusRichese?.blocked)
|
||||
shipmentProblems.push('Choose a current Richese Secret Ally shipment of up to five physical forces.');
|
||||
const coalitionFundingKey = JSON.stringify(g.coalitionFunding?.incoming.map(({donor, amount}) => [donor, amount]) ?? []);
|
||||
const currentCoalitionShares = g.coalitionFunding && coalitionShares.key === coalitionFundingKey
|
||||
? coalitionFundingContributions(g.coalitionFunding.incoming,
|
||||
Object.fromEntries(coalitionShares.shares.map(share => [share.donor, share.amount]))) : [];
|
||||
const groupShipmentShare = currentCoalitionShares.reduce((sum, share) => sum + share.amount, 0);
|
||||
const availableShipmentAid = g.coalitionFunding
|
||||
? g.coalitionFunding.incoming.reduce((sum, share) => sum + share.amount, 0) : g.aid.available;
|
||||
const selectedAllyPayment =
|
||||
shipmentCost === null
|
||||
? 0
|
||||
: g.coalitionFunding ? groupShipmentShare
|
||||
: !me.ally || allyPayment === ''
|
||||
? Math.max(0, shipmentCost - (me.spice ?? 0))
|
||||
: Number(allyPayment);
|
||||
@@ -797,9 +807,9 @@ export function GameTable({
|
||||
`Ally payment must be a whole number from 0 to ${shipmentCost}.`,
|
||||
);
|
||||
else {
|
||||
if (selectedAllyPayment > g.aid.available)
|
||||
if (selectedAllyPayment > availableShipmentAid)
|
||||
shipmentProblems.push(
|
||||
`Only ${g.aid.available} pledged ally spice is available.`,
|
||||
`Only ${availableShipmentAid} pledged ally spice is available.`,
|
||||
);
|
||||
if (shipmentCost - selectedAllyPayment > (me.spice ?? 0))
|
||||
shipmentProblems.push(
|
||||
@@ -4226,6 +4236,9 @@ export function GameTable({
|
||||
<ChoamGamont game={g} act={act} busy={busy} />
|
||||
<ChoamWorthless game={g} act={act} busy={busy} />
|
||||
<ChoamBattleFunding game={g} act={act} busy={busy} />
|
||||
{g.coalitionFunding && <CoalitionFunding view={g.coalitionFunding} busy={busy} act={act}
|
||||
{...(shipmentAvailable ? {onContributionsChange:(shares:CoalitionFundingContribution[]) =>
|
||||
setCoalitionShares({key:coalitionFundingKey, shares})} : {})} />}
|
||||
{me.ally && [3, 5].includes(g.phase) && (
|
||||
<details className="alliance-funding">
|
||||
<summary>
|
||||
@@ -4940,7 +4953,7 @@ export function GameTable({
|
||||
quote={shipmentQuote}
|
||||
funding={{
|
||||
ownSpice: me.spice ?? 0,
|
||||
pledgedSpice: g.aid.available,
|
||||
pledgedSpice: availableShipmentAid,
|
||||
}}
|
||||
unavailableReasons={shipmentProblems}
|
||||
/>
|
||||
@@ -4957,6 +4970,8 @@ export function GameTable({
|
||||
const shipment: Action = {
|
||||
type: 'ship', elite: eliteAmount, ...payment,
|
||||
territory: selected, sector, amount,
|
||||
...(g.coalitionFunding && currentCoalitionShares.length
|
||||
? {allyPayment: 0, coalitionContributions: currentCoalitionShares} : {}),
|
||||
...(smugglerOffer ? { smuggler: useSmuggler } : {}),
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
};
|
||||
|
||||
+14
-4
@@ -40,6 +40,16 @@ cases. The three-member option remains
|
||||
unavailable: faction benefits, funding, and other group interactions still
|
||||
need integration. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-nexus-negotiation--28-october-2026).
|
||||
|
||||
**Staged group shipment funding — 28 October. Prototyped / Partial.** Two
|
||||
allies can pledge separately to a group member, who may use named shares for
|
||||
one ordinary Basic shipment; an outside Guild receives the payment and unspent
|
||||
pledges return to their donors. The staged table has private pledge and share
|
||||
controls. The related eight-file batch passes 48 cases, with real Nexus and
|
||||
shipment actions following a staged starting position. Guild-member groups,
|
||||
group bidding and special transports remain
|
||||
unsupported; group play is still not exposed in the lobby.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-shipment-funding--28-october-2026).
|
||||
|
||||
**Basic allied No-Field transit — 28 October. Prototyped / Partial.** Richese
|
||||
may offer its ally an immediately revealed shipment into a Richese-occupied
|
||||
territory. The recipient may depart or accept the existing provisional loss
|
||||
@@ -132,10 +142,10 @@ enabled or claimed complete by the standalone foundation. Shipment entry and
|
||||
visitor consequences now use the group-aware receipt and physical baseline.
|
||||
The staged Nexus negotiation is connected; group funding, faction benefits
|
||||
and other engine paths still need integration before the option can be offered.
|
||||
Connected staged Nexus controls and a funding quote 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 group funding controls to native actions and AI.
|
||||
Connected staged Nexus and shipment controls model unanimous consent, explicit
|
||||
donor allocations and edge-bound pledge authorizations. The engine issues and
|
||||
revokes those authorizations and refunds shipment escrow; group-funded bidding,
|
||||
special transport and Guild-allied rates still require integration.
|
||||
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. The latest
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Optional three-stronghold alliance victory
|
||||
|
||||
## Group ordinary-shipment funding — 28 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** Group allies can set
|
||||
aside separate amounts for each other during Shipment and Movement. A shipper
|
||||
may explicitly allocate amounts from each pledged donor to an ordinary physical
|
||||
reserve shipment; the authoritative payment rechecks those exact pledges,
|
||||
charges the shipper's remainder once, and retains each donor's identity. An
|
||||
outside Guild receives the eligible payment. Unspent pledges return to their
|
||||
respective donors at phase end. Alliance edge authorizations are renewed on
|
||||
formation, preserved for surviving edges and revoked on departure, with
|
||||
aggregated refunds for removed edges. Controls show only the viewer's own
|
||||
outgoing and incoming pledges; bots may continue using their own spice.
|
||||
|
||||
This first path is limited to Basic classic-faction staged groups without a
|
||||
Guild member, optional modules, Karama shipment pricing or special transport.
|
||||
Guild-allied rates, other shipment types, group-funded bidding, and the
|
||||
remaining faction benefits and interactions are not connected. No lobby can
|
||||
start a group game yet. The recipient-specific pledge and allocation procedure
|
||||
is a provisional online convention rather than a printed worked example.
|
||||
|
||||
## Group Nexus negotiation — 28 October 2026
|
||||
|
||||
**Integrated groundwork, not a selectable game mode.** In a staged Basic
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Group shipment funding — 28 October 2026
|
||||
|
||||
The unexposed Basic group prototype uses recipient-specific escrow and
|
||||
explicit donor allocations for an ordinary physical shipment. Those online
|
||||
controls are provisional; Guild-allied pricing, bidding and other transports
|
||||
remain separate. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-shipment-funding--28-october-2026).
|
||||
|
||||
## Group Nexus negotiations — 28 October 2026
|
||||
|
||||
For the unexposed Basic group foundation, unanimous consent, a held Nexus
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { coalitionGroups, type CoalitionSeat, type CoalitionState } from './coalition-alliance';
|
||||
import {
|
||||
CoalitionFundingError,
|
||||
reconcileCoalitionPledges,
|
||||
type CoalitionFundingAuthorization,
|
||||
type CoalitionPledge,
|
||||
} from './coalition-funding';
|
||||
|
||||
/** Epochs are never reused, including after an edge leaves and reforms. */
|
||||
export type CoalitionEscrowState = Readonly<{
|
||||
authorizations: readonly CoalitionFundingAuthorization[];
|
||||
pledges: readonly CoalitionPledge[];
|
||||
usedEpochs: readonly string[];
|
||||
}>;
|
||||
export type CoalitionEscrowMembership = Readonly<{
|
||||
seats: readonly CoalitionSeat[];
|
||||
coalitionState?: CoalitionState;
|
||||
}>;
|
||||
export type CoalitionEscrowRefund = Readonly<{ donor: string; amount: number }>;
|
||||
|
||||
const whole = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) >= 0;
|
||||
const validEpoch = (s: unknown): s is string => typeof s === 'string' && !!s &&
|
||||
s.length <= 256 && s.trim() === s;
|
||||
function check(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new CoalitionFundingError(message);
|
||||
}
|
||||
const key = (members: readonly string[]) => JSON.stringify(members);
|
||||
const edge = (a: string, b: string) => key([a, b].sort());
|
||||
const directed = (donor: string, recipient: string) => key([donor, recipient]);
|
||||
function checkedSum(a: number, b: number): number {
|
||||
const result = a + b;
|
||||
check(whole(result), 'Escrow totals must be safe integers.');
|
||||
return result;
|
||||
}
|
||||
function edges(membership: CoalitionEscrowMembership): Map<string, readonly [string, string]> {
|
||||
const out = new Map<string, readonly [string, string]>();
|
||||
for (const group of coalitionGroups(membership.seats, membership.coalitionState))
|
||||
for (const item of group.edges) out.set(key(item.members), item.members);
|
||||
return out;
|
||||
}
|
||||
function validateHistory(escrow: CoalitionEscrowState) {
|
||||
check(!!escrow && Array.isArray(escrow.authorizations) && Array.isArray(escrow.pledges) &&
|
||||
Array.isArray(escrow.usedEpochs) && escrow.usedEpochs.every(validEpoch) &&
|
||||
new Set(escrow.usedEpochs).size === escrow.usedEpochs.length,
|
||||
'The escrow must retain unique valid epoch history.');
|
||||
const used = new Set(escrow.usedEpochs);
|
||||
check(escrow.authorizations.every(auth => auth && validEpoch(auth.epoch) && used.has(auth.epoch)) &&
|
||||
escrow.pledges.every(pledge => pledge && validEpoch(pledge.epoch) && used.has(pledge.epoch)),
|
||||
'Every escrow epoch must appear in its retained history.');
|
||||
}
|
||||
function validateCurrent(membership: CoalitionEscrowMembership, escrow: CoalitionEscrowState) {
|
||||
validateHistory(escrow);
|
||||
const result = reconcileCoalitionPledges({ ...membership,
|
||||
authorizations: escrow.authorizations, pledges: escrow.pledges });
|
||||
check(result.refunds.length === 0, 'Reconcile stale pledges before changing escrow.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one authoritative alliance transition. Call once for each formation or
|
||||
* break, not on a collapsed before/after history: an edge broken and reformed
|
||||
* must receive a new epoch. The caller supplies freshly generated IDs for only
|
||||
* the new edges, atomically installs the result and credits each donor refund.
|
||||
* A pledge with a live commitment cannot be refunded until that commitment
|
||||
* has been settled or canceled. The caller supplies every such locked pledge.
|
||||
*/
|
||||
export function reconcileCoalitionEscrow(input: Readonly<{
|
||||
before: CoalitionEscrowMembership;
|
||||
after: CoalitionEscrowMembership;
|
||||
escrow: CoalitionEscrowState;
|
||||
newAuthorizations: readonly CoalitionFundingAuthorization[];
|
||||
locked?: readonly CoalitionPledge[];
|
||||
}>): { escrow: CoalitionEscrowState; refunds: CoalitionEscrowRefund[] } {
|
||||
const { before, after, escrow } = input;
|
||||
validateCurrent(before, escrow);
|
||||
const oldEdges = edges(before), newEdges = edges(after);
|
||||
const beforeIds = before.seats.map(seat => seat.id).sort();
|
||||
const afterIds = after.seats.map(seat => seat.id).sort();
|
||||
check(key(beforeIds) === key(afterIds), 'Escrow transitions require the same seated players.');
|
||||
check(Array.isArray(input.newAuthorizations) && Array.isArray(input.locked ?? []),
|
||||
'Supply new edge authorizations and any locked pledges.');
|
||||
const previous = new Map(escrow.authorizations.map(auth => [key(auth.members), auth]));
|
||||
const history = new Set(escrow.usedEpochs);
|
||||
const fresh = new Map<string, CoalitionFundingAuthorization>();
|
||||
for (const auth of input.newAuthorizations) {
|
||||
check(!!auth && Array.isArray(auth.members) && auth.members.length === 2 &&
|
||||
typeof auth.members[0] === 'string' && typeof auth.members[1] === 'string' &&
|
||||
auth.members[0] < auth.members[1] && validEpoch(auth.epoch),
|
||||
'Each fresh epoch needs an ordered pair of allies.');
|
||||
const id = key(auth.members);
|
||||
check(newEdges.has(id) && !oldEdges.has(id) && !fresh.has(id) && !history.has(auth.epoch),
|
||||
'Fresh epochs must uniquely authorize new edges and cannot be reused.');
|
||||
history.add(auth.epoch);
|
||||
fresh.set(id, { members: [auth.members[0], auth.members[1]], epoch: auth.epoch });
|
||||
}
|
||||
const authorizations: CoalitionFundingAuthorization[] = [];
|
||||
for (const [id, members] of newEdges) {
|
||||
const auth = oldEdges.has(id) ? previous.get(id) : fresh.get(id);
|
||||
check(auth, 'Every new alliance edge needs a fresh epoch.');
|
||||
authorizations.push({ members: [members[0], members[1]], epoch: auth.epoch });
|
||||
}
|
||||
const pledged = new Map(escrow.pledges.map(item => [directed(item.donor, item.recipient), item]));
|
||||
const locked = new Set<string>();
|
||||
for (const item of input.locked ?? []) {
|
||||
check(!!item && typeof item.donor === 'string' && typeof item.recipient === 'string' &&
|
||||
whole(item.amount) && item.amount > 0 && validEpoch(item.epoch), 'A locked pledge needs a valid committed amount.');
|
||||
const id = directed(item.donor, item.recipient), pledge = pledged.get(id);
|
||||
check(!locked.has(id) && pledge && pledge.epoch === item.epoch && item.amount <= pledge.amount,
|
||||
'Each committed pledge must match its available escrow once.');
|
||||
locked.add(id);
|
||||
check(newEdges.has(edge(item.donor, item.recipient)),
|
||||
'Resolve committed payments before breaking their alliance edge.');
|
||||
}
|
||||
const reconciled = reconcileCoalitionPledges({ ...after, authorizations, pledges: escrow.pledges });
|
||||
const totals = new Map<string, number>();
|
||||
for (const item of reconciled.refunds)
|
||||
totals.set(item.donor, checkedSum(totals.get(item.donor) ?? 0, item.amount));
|
||||
return {
|
||||
escrow: { authorizations, pledges: reconciled.pledges, usedEpochs: [...history] },
|
||||
refunds: [...totals].map(([donor, amount]) => ({ donor, amount })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return a signed wallet change and replacement ledger without mutating the
|
||||
* donor. `available` is uncommitted wallet spice; `locked` is the already
|
||||
* committed amount of this recipient's existing pledge. The engine must
|
||||
* verify the actual wallet and atomically apply both outputs. */
|
||||
export function setCoalitionPledge(input: CoalitionEscrowMembership & Readonly<{
|
||||
escrow: CoalitionEscrowState;
|
||||
donor: string;
|
||||
recipient: string;
|
||||
amount: number;
|
||||
available: number;
|
||||
locked?: number;
|
||||
}>): { escrow: CoalitionEscrowState; walletDelta: number } {
|
||||
const { escrow, donor, recipient, amount, available } = input;
|
||||
const locked = input.locked ?? 0;
|
||||
validateCurrent(input, escrow);
|
||||
check(typeof donor === 'string' && typeof recipient === 'string' && donor !== recipient &&
|
||||
whole(amount) && whole(available) && whole(locked), 'Choose a valid donor, recipient and safe spice amounts.');
|
||||
const auth = escrow.authorizations.find(item => key(item.members) === edge(donor, recipient));
|
||||
check(auth, 'A pledge requires a current authorized alliance edge.');
|
||||
const old = escrow.pledges.find(item => item.donor === donor && item.recipient === recipient);
|
||||
const prior = old?.amount ?? 0;
|
||||
check(locked <= prior && amount >= locked, 'Committed pledge spice cannot be withdrawn.');
|
||||
const delta = prior - amount;
|
||||
check(delta >= 0 || -delta <= available, 'The donor lacks uncommitted spice for this pledge.');
|
||||
if (delta > 0) checkedSum(available, delta);
|
||||
const pledges = escrow.pledges.filter(item => item.donor !== donor || item.recipient !== recipient)
|
||||
.map(item => ({ ...item }));
|
||||
if (amount) pledges.push({ donor, recipient, amount, epoch: auth.epoch });
|
||||
return { escrow: { authorizations: escrow.authorizations.map(item => ({
|
||||
members: [item.members[0], item.members[1]], epoch: item.epoch,
|
||||
})), pledges, usedEpochs: [...escrow.usedEpochs] }, walletDelta: delta };
|
||||
}
|
||||
+135
-8
@@ -752,6 +752,10 @@ import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipmen
|
||||
type BasicAlliedShipment } from './allied-separation';
|
||||
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionGroups, coalitionOf,
|
||||
leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance';
|
||||
import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowMembership,
|
||||
type CoalitionEscrowState } from './coalition-escrow';
|
||||
import { CoalitionFundingError, quoteCoalitionFunding, settleCoalitionFunding,
|
||||
type CoalitionContribution } from './coalition-funding';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat';
|
||||
import { FACTIONS, faction, type FactionId } from './catalog';
|
||||
import { quoteLobbyBotConfiguration } from './lobby-bot-configuration';
|
||||
@@ -1352,6 +1356,8 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
|
||||
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
|
||||
};
|
||||
type PendingShipment = {
|
||||
/** Donor-specific paid shares for the staged group shipment path. */
|
||||
coalitionContributions?: CoalitionContribution[];
|
||||
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
|
||||
smuggler?: SmugglerShipment;
|
||||
smugglerCompanion?: SmugglerNoFieldCompanion;
|
||||
@@ -1434,6 +1440,7 @@ type RicheseAllyOffer = {
|
||||
export type Game = {
|
||||
/** Unexposed group-alliance integration state; absent in existing games. */
|
||||
coalitionState?: CoalitionState;
|
||||
coalitionEscrow?: CoalitionEscrowState;
|
||||
/** Public written agreements. Their prose is honored by players, not parsed. */
|
||||
deals?: PublicDeal[];
|
||||
/** A human-to-human lobby offer, pending the recipient's consent. */
|
||||
@@ -16001,9 +16008,34 @@ function coalitionNexusAvailable(g: Game): boolean {
|
||||
}
|
||||
function coalitionRule<T>(work: () => T): T {
|
||||
try { return work(); }
|
||||
catch (error) { if (error instanceof CoalitionAllianceError) throw new RuleError(error.message); throw error; }
|
||||
catch (error) { if (error instanceof CoalitionAllianceError || error instanceof CoalitionFundingError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function coalitionMembership(g: Game): CoalitionEscrowMembership {
|
||||
return { seats: g.players.map(({id, ally, allySinceTurn}) => ({id, ally, allySinceTurn})),
|
||||
...(g.coalitionState ? {coalitionState: structuredClone(g.coalitionState)} : {}) };
|
||||
}
|
||||
function coalitionEdges(g: Game) {
|
||||
return coalitionRule(() => coalitionGroups(g.players, g.coalitionState).flatMap(group => group.edges.map(edge => edge.members)));
|
||||
}
|
||||
function initializeCoalitionEscrow(g: Game) {
|
||||
if (g.coalitionEscrow) return;
|
||||
const authorizations = coalitionEdges(g).map(members => ({members, epoch: crypto.randomUUID()}));
|
||||
g.coalitionEscrow = {authorizations, pledges: [], usedEpochs: authorizations.map(entry => entry.epoch)};
|
||||
}
|
||||
function syncCoalitionEscrow(g: Game, before: CoalitionEscrowMembership) {
|
||||
if (!g.coalitionEscrow) return;
|
||||
const prior = new Set(g.coalitionEscrow.authorizations.map(auth => JSON.stringify(auth.members)));
|
||||
const newAuthorizations = coalitionEdges(g).filter(members => !prior.has(JSON.stringify(members)))
|
||||
.map(members => ({members, epoch: crypto.randomUUID()}));
|
||||
const result = coalitionRule(() => reconcileCoalitionEscrow({before, after: coalitionMembership(g),
|
||||
escrow: g.coalitionEscrow!, newAuthorizations}));
|
||||
for (const refund of result.refunds)
|
||||
requireRule(Number.isSafeInteger(getPlayer(g, refund.donor).spice + refund.amount), 'Group refund exceeds the donor wallet.');
|
||||
for (const refund of result.refunds) getPlayer(g, refund.donor).spice += refund.amount;
|
||||
g.coalitionEscrow = result.escrow;
|
||||
}
|
||||
function applyCoalitionTransition(g: Game, result: CoalitionTransition) {
|
||||
const before = coalitionMembership(g);
|
||||
g.coalitionState = result.state;
|
||||
for (const [id, ally] of Object.entries(result.allyUpdates)) getPlayer(g, id).ally = ally;
|
||||
for (const [id, turn] of Object.entries(result.allySinceUpdates)) {
|
||||
@@ -16011,6 +16043,24 @@ function applyCoalitionTransition(g: Game, result: CoalitionTransition) {
|
||||
if (turn === null) delete player.allySinceTurn;
|
||||
else player.allySinceTurn = turn;
|
||||
}
|
||||
syncCoalitionEscrow(g, before);
|
||||
}
|
||||
/** Initial staged group-funding scope: ordinary Basic planet shipments, with
|
||||
* Guild outside the group. Guild-allied rate and other purchases remain separate. */
|
||||
function coalitionShipmentFundingAvailable(g: Game, p: Player): boolean {
|
||||
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || g.phase !== 5 ||
|
||||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
|
||||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || g.karamaShipping ||
|
||||
g.spiceBankerIncomePreview) return false;
|
||||
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
const guild = byFaction(g, 'guild');
|
||||
return group?.members.length === 3 && (!guild || !group.members.includes(guild.id));
|
||||
}
|
||||
function coalitionShipmentQuote(g: Game, p: Player, cost: number, contributions: CoalitionContribution[]) {
|
||||
requireRule(coalitionShipmentFundingAvailable(g, p), 'Group funding is unavailable for this shipment.');
|
||||
return coalitionRule(() => quoteCoalitionFunding({seats:g.players, coalitionState:g.coalitionState,
|
||||
authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges,
|
||||
purpose:'shipment', payer:p.id, cost, ownAvailable:uncommittedSpice(g, p), contributions}));
|
||||
}
|
||||
/** The original native reciprocal quote owns identity and pairing legality. */
|
||||
function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
|
||||
@@ -28331,6 +28381,18 @@ function currentPhaseResources(g: Game) {
|
||||
}
|
||||
}
|
||||
function commitPhaseResources(g: Game, quote: PhaseResourceQuote) {
|
||||
const groupRefunds = new Map<string, number>();
|
||||
if (quote.clearAid && g.coalitionEscrow) {
|
||||
for (const pledge of g.coalitionEscrow.pledges) {
|
||||
requireRule(Number.isSafeInteger(pledge.amount) && pledge.amount >= 0 &&
|
||||
g.players.some(player => player.id === pledge.donor), 'Group refund needs a seated donor and valid escrow.');
|
||||
const total = (groupRefunds.get(pledge.donor) ?? 0) + pledge.amount;
|
||||
requireRule(Number.isSafeInteger(total), 'Group funding refund exceeds a safe total.');
|
||||
groupRefunds.set(pledge.donor, total);
|
||||
}
|
||||
for (const [donor, amount] of groupRefunds)
|
||||
requireRule(Number.isSafeInteger(quote.balances[donor] + amount), 'Group refund exceeds the donor wallet.');
|
||||
}
|
||||
for (const credit of quote.credits) {
|
||||
const owner = getPlayer(g, credit.player);
|
||||
owner.spice = credit.balance;
|
||||
@@ -28342,6 +28404,10 @@ function commitPhaseResources(g: Game, quote: PhaseResourceQuote) {
|
||||
}
|
||||
for (const id of quote.tokenResets) g.techTokens![id].spice = 0;
|
||||
if (quote.clearAid) g.aid = {};
|
||||
if (quote.clearAid && g.coalitionEscrow) {
|
||||
for (const [donor, amount] of groupRefunds) getPlayer(g, donor).spice += amount;
|
||||
g.coalitionEscrow = {...g.coalitionEscrow, pledges: []};
|
||||
}
|
||||
}
|
||||
function transferTech(g: Game, id: TechId, winner: string) {
|
||||
g.techTokens![id].owner = winner;
|
||||
@@ -37874,6 +37940,14 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
||||
);
|
||||
integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current shipment spice');
|
||||
integer(shipment.allyPayment, 0, cost, 'Shipment ally payment');
|
||||
if (shipment.coalitionContributions !== undefined) {
|
||||
requireRule(shipment.allyPayment === 0 && !shipment.noField && !shipment.alliedNoField &&
|
||||
!shipment.source && !shipment.nexusEvent && !shipment.guildSecretEvent && !shipment.guildNexusEvent &&
|
||||
!shipment.smuggler && !shipment.homeworldSources && Array.isArray(shipment.coalitionContributions),
|
||||
'Group funding currently supports an ordinary physical reserve shipment.');
|
||||
coalitionShipmentQuote(g, p, cost, shipment.coalitionContributions);
|
||||
return;
|
||||
}
|
||||
const credit = aidFor(g, p);
|
||||
if (credit)
|
||||
integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge');
|
||||
@@ -38521,6 +38595,13 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
|
||||
owner.noFieldEvent = crypto.randomUUID();
|
||||
owner.spice -= quote.ownerPayment;
|
||||
p.spice -= quote.recipientPayment;
|
||||
} else if (shipment.coalitionContributions !== undefined) {
|
||||
const result = coalitionRule(() => settleCoalitionFunding({seats:g.players, coalitionState:g.coalitionState,
|
||||
authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges,
|
||||
purpose:'shipment', payer:p.id, cost, ownAvailable:uncommittedSpice(g, p),
|
||||
contributions:shipment.coalitionContributions!}));
|
||||
p.spice -= result.quote.own;
|
||||
g.coalitionEscrow = {...g.coalitionEscrow!, pledges:result.pledges};
|
||||
} else payWithAlly(g, p, cost, allyPayment);
|
||||
if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost;
|
||||
if (pairRevealed) {
|
||||
@@ -38561,7 +38642,9 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
|
||||
if (movementPreparation) { g.karamaShipping = null; return; }
|
||||
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
|
||||
const guild = byFaction(g, 'guild');
|
||||
const guildPayment = guildShipmentIncome({
|
||||
const groupShares = shipment.coalitionContributions;
|
||||
const groupTotal = groupShares?.reduce((sum, share) => sum + share.amount, 0) ?? 0;
|
||||
const guildPayment = groupShares ? (guild ? cost : 0) : guildShipmentIncome({
|
||||
guild: guild?.id,
|
||||
shipper: p.id,
|
||||
ally: p.ally,
|
||||
@@ -38570,10 +38653,15 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
|
||||
bankOnly: g.karamaShipping?.player === p.id ||
|
||||
!!(shipment.alliedNoField && alliedNoFieldPricing(g, p).bankOnly),
|
||||
});
|
||||
if (!settlement?.recipient && guild && guildPayment > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),
|
||||
allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
|
||||
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]);
|
||||
if (!settlement?.recipient && guild && guildPayment > 0) {
|
||||
const named = groupShares ? [{payer:p.id, amount:cost - groupTotal},
|
||||
...groupShares.map(share => ({payer:share.donor, amount:share.amount}))]
|
||||
: [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment },
|
||||
...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])];
|
||||
g.response = guildPaymentResponse(g, guild.id,
|
||||
groupShares ? named.map(share => share.amount) : shipmentIncomeContributions(g, p, cost, allyPayment),
|
||||
groupShares || allyPayment ? undefined : p.id, named);
|
||||
}
|
||||
settleBankerShipmentIncome(g, p, cost, allyPayment);
|
||||
g.karamaShipping = null;
|
||||
log(
|
||||
@@ -44257,6 +44345,7 @@ function applyActionInner(
|
||||
requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.');
|
||||
const revision = integer(action.expectedRevision, 0, Number.MAX_SAFE_INTEGER, 'Coalition revision');
|
||||
if (t === 'coalitionPropose') {
|
||||
initializeCoalitionEscrow(g);
|
||||
const result = coalitionRule(() => beginCoalitionAdmission(g.players, g.coalitionState, {
|
||||
event: crypto.randomUUID(), nexus: crypto.randomUUID(), turn: g.turn,
|
||||
groupId: stringField(action.groupId), entrant: stringField(action.entrant),
|
||||
@@ -44317,6 +44406,7 @@ function applyActionInner(
|
||||
const formerOffer = g.allianceOffers[id];
|
||||
const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name;
|
||||
const other = target === null ? null : getPlayer(g, target);
|
||||
const escrowBefore = g.coalitionEscrow ? coalitionMembership(g) : null;
|
||||
const quote = normalNexusAllianceQuote(g, p, target);
|
||||
for (const player of g.players) player.ally = quote.allies[player.id];
|
||||
g.allianceOffers = quote.offers;
|
||||
@@ -44334,6 +44424,7 @@ function applyActionInner(
|
||||
} else {
|
||||
log(g, `${p.name} offered an alliance to ${other!.name}; both players must agree before an alliance forms.`);
|
||||
}
|
||||
if (escrowBefore) syncCoalitionEscrow(g, escrowBefore);
|
||||
g.ready = [];
|
||||
return g;
|
||||
}
|
||||
@@ -44371,6 +44462,19 @@ function applyActionInner(
|
||||
log(g, `${p.name} claimed CHOAM charity.`);
|
||||
return g;
|
||||
}
|
||||
if (t === 'coalitionPledge') {
|
||||
requireRule(coalitionShipmentFundingAvailable(g, p), 'Group shipment funding is unavailable now.');
|
||||
const recipient = stringField(action.recipient);
|
||||
const amount = integer(action.amount, 0, Number.MAX_SAFE_INTEGER, 'Group pledge');
|
||||
const result = coalitionRule(() => setCoalitionPledge({ ...coalitionMembership(g),
|
||||
escrow:g.coalitionEscrow!, donor:id, recipient, amount, available:uncommittedSpice(g, p) }));
|
||||
requireRule(Number.isSafeInteger(p.spice + result.walletDelta) && p.spice + result.walletDelta >= 0,
|
||||
'Group pledge exceeds the donor wallet.');
|
||||
p.spice += result.walletDelta;
|
||||
g.coalitionEscrow = result.escrow;
|
||||
log(g, `${p.name} updated group shipment funding for ${getPlayer(g, recipient).name}.`);
|
||||
return g;
|
||||
}
|
||||
if (t === 'pledgeAid') {
|
||||
requireRule(
|
||||
([3, 5].includes(g.phase) ||
|
||||
@@ -44922,11 +45026,19 @@ function applyActionInner(
|
||||
'Fremen reinforcements must arrive within two territories of the Great Flat.',
|
||||
);
|
||||
}
|
||||
requireRule(
|
||||
let coalitionContributions: CoalitionContribution[] | undefined;
|
||||
if (action.coalitionContributions !== undefined) {
|
||||
requireRule(execution === 'live' && Array.isArray(action.coalitionContributions) && !noField &&
|
||||
!nexusEvent && !guildSecretEvent && !smuggler && !smugglerCompanion &&
|
||||
action.homeworldSources === undefined && (action.allyPayment === undefined || action.allyPayment === 0),
|
||||
'Group funding currently supports an ordinary physical reserve shipment.');
|
||||
coalitionContributions = action.coalitionContributions as CoalitionContribution[];
|
||||
coalitionShipmentQuote(g, p, cost, coalitionContributions);
|
||||
} else requireRule(
|
||||
cost <= p.spice + (aidFor(g, p)?.amount ?? 0),
|
||||
'Not enough spice to ship those forces.',
|
||||
);
|
||||
const allyPayment = contribution(g, p, cost, action.allyPayment);
|
||||
const allyPayment = coalitionContributions === undefined ? contribution(g, p, cost, action.allyPayment) : 0;
|
||||
const shipment: PendingShipment = {
|
||||
...(smuggler ? { smuggler } : {}),
|
||||
...(smugglerCompanion ? { smugglerCompanion } : {}),
|
||||
@@ -44943,6 +45055,7 @@ function applyActionInner(
|
||||
elite,
|
||||
cost,
|
||||
allyPayment,
|
||||
...(coalitionContributions === undefined ? {} : {coalitionContributions}),
|
||||
advisors,
|
||||
...(action.homeworldSources === undefined
|
||||
? {}
|
||||
@@ -46503,6 +46616,10 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
!g.nexus,
|
||||
spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null),
|
||||
allianceOffers: g.allianceOffers,
|
||||
// Membership and edge ages are public; historical proposal IDs and escrow
|
||||
// are not needed by board rules in the player view.
|
||||
coalitionState: g.coalitionState ? {...g.coalitionState,
|
||||
usedEvents:g.coalitionState.proposal ? [g.coalitionState.proposal.event] : []} : undefined,
|
||||
coalitionNexus: coalitionNexusAvailable(g) ? {
|
||||
me: id,
|
||||
players: g.players.map(({id: seatId, name}) => ({id: seatId, name})),
|
||||
@@ -46839,6 +46956,16 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
? g.aid[me.ally].amount
|
||||
: 0,
|
||||
},
|
||||
coalitionFunding: coalitionShipmentFundingAvailable(g, me) ? (() => {
|
||||
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id))!;
|
||||
const pledges = g.coalitionEscrow!.pledges;
|
||||
return {targets: group.members.filter(member => member !== id).map(member => {
|
||||
const pledged = pledges.find(entry => entry.donor === id && entry.recipient === member)?.amount ?? 0;
|
||||
return {id: member, name: getPlayer(g, member).name, pledged,
|
||||
maximum: Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, uncommittedSpice(g, me)) + pledged)};
|
||||
}), incoming: pledges.filter(entry => entry.recipient === id && entry.amount > 0)
|
||||
.map(entry => ({donor:entry.donor, name:getPlayer(g, entry.donor).name, amount:entry.amount})), blocked:null};
|
||||
})() : null,
|
||||
players: g.players.map((p) => ({
|
||||
...(p.noField
|
||||
? { noField: projectRicheseNoField(p.noField, false) }
|
||||
|
||||
+8
-1
@@ -2781,7 +2781,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'alliance-funding',
|
||||
title: 'Fund an ally’s bid or shipment',
|
||||
category: 'Getting started',
|
||||
coverage: 'Implemented',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'Use Alliance funding during bidding or shipment and movement to authorize a payment contribution.',
|
||||
steps: [
|
||||
@@ -2793,6 +2794,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
example:
|
||||
'You have two spice and your ally pledges four. You may bid six; winning uses two of yours and four of theirs.',
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Ordinary pair funding is connected. Staged Basic group games support recipient-specific pledges and named contributions for ordinary physical shipments when Guild is outside the group; group bidding, Guild-allied rates and special transports remain open.', evidence: ['game/coalition-escrow.ts', 'game/coalition-funding.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Pair funding has normal table controls. Staged group shipment controls project each player’s own outgoing and incoming pledges; group play is not selectable yet.', evidence: ['components/coalition-funding.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing bots can participate using their own spice; group funding strategy is deferred.' },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused staged cases exercise two donors, an outside Guild, rejected splits and individual refunds. Full group play and deployed acceptance remain open.', evidence: ['tests/coalition-shipment-funding.test.ts', 'tests/coalition-escrow.test.ts', 'tests/coalition-funding-controls.test.tsx'] },
|
||||
],
|
||||
related: ['bidding', 'movement', 'faction-guild', 'card-karama'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowState,
|
||||
type CoalitionEscrowMembership } from '../game/coalition-escrow';
|
||||
import type { CoalitionState } from '../game/coalition-alliance';
|
||||
|
||||
const empty: CoalitionEscrowState = { authorizations: [], pledges: [], usedEpochs: [] };
|
||||
const seats = (values: [string, string | null][]) => values.map(([id, ally]) => ({id, ally}));
|
||||
const unallied: CoalitionEscrowMembership = { seats: seats([['a', null], ['b', null], ['c', null]]) };
|
||||
const pair: CoalitionEscrowMembership = { seats: seats([['a', 'b'], ['b', 'a'], ['c', null]]) };
|
||||
const bcPair: CoalitionEscrowMembership = { seats: seats([['a', null], ['b', 'c'], ['c', 'b']]) };
|
||||
const tripleState: CoalitionState = {version: 1, revision: 1, 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},
|
||||
],
|
||||
}]};
|
||||
const triple: CoalitionEscrowMembership = {
|
||||
seats: seats([['a', null], ['b', null], ['c', null]]), coalitionState: tripleState,
|
||||
};
|
||||
const ab = {members: ['a', 'b'] as const, epoch: 'ab1'};
|
||||
const ac = {members: ['a', 'c'] as const, epoch: 'ac1'};
|
||||
const bc = {members: ['b', 'c'] as const, epoch: 'bc1'};
|
||||
|
||||
function startedPair() {
|
||||
return reconcileCoalitionEscrow({before: unallied, after: pair, escrow: empty, newAuthorizations: [ab]}).escrow;
|
||||
}
|
||||
function startedTriple() {
|
||||
return reconcileCoalitionEscrow({before: pair, after: triple, escrow: startedPair(), newAuthorizations: [ac, bc]}).escrow;
|
||||
}
|
||||
|
||||
void test('each new edge receives its own epoch while the existing edge retains its epoch', () => {
|
||||
const first = startedPair(), original = structuredClone(first);
|
||||
const result = reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac, bc]});
|
||||
assert.deepEqual(result.escrow.authorizations, [ab, ac, bc]);
|
||||
assert.deepEqual(result.escrow.usedEpochs, ['ab1', 'ac1', 'bc1']);
|
||||
assert.deepEqual(result.refunds, []);
|
||||
assert.deepEqual(first, original);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(result)), result);
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac]}), /fresh epoch/);
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac, ac, bc]}), /uniquely/);
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first,
|
||||
newAuthorizations: [{...ac, epoch: 'ab1'}, bc]}), /reused/);
|
||||
});
|
||||
|
||||
void test('recipient-specific pledges retain independent balances and protect committed spice', () => {
|
||||
const initial = startedTriple(), before = structuredClone(initial);
|
||||
const one = setCoalitionPledge({...triple, escrow: initial, donor: 'a', recipient: 'b', amount: 6, available: 10});
|
||||
assert.equal(one.walletDelta, -6);
|
||||
const two = setCoalitionPledge({...triple, escrow: one.escrow, donor: 'a', recipient: 'c', amount: 3, available: 4});
|
||||
assert.equal(two.walletDelta, -3);
|
||||
const reduced = setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 4, available: 1, locked: 4});
|
||||
assert.equal(reduced.walletDelta, 2);
|
||||
assert.deepEqual(reduced.escrow.pledges, [
|
||||
{donor: 'a', recipient: 'c', amount: 3, epoch: 'ac1'},
|
||||
{donor: 'a', recipient: 'b', amount: 4, epoch: 'ab1'},
|
||||
]);
|
||||
assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 3, available: 1, locked: 4}), /Committed/);
|
||||
assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 8, available: 1}), /lacks/);
|
||||
assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'a', amount: 1, available: 1}), /valid donor/);
|
||||
assert.deepEqual(initial, before);
|
||||
});
|
||||
|
||||
void test('a departing member receives each refund and surviving allies keep their edge and escrow', () => {
|
||||
let ledger = startedTriple();
|
||||
for (const [donor, recipient, amount] of [['a', 'b', 3], ['a', 'c', 4], ['b', 'c', 5], ['c', 'a', 2]] as const)
|
||||
ledger = setCoalitionPledge({...triple, escrow: ledger, donor, recipient, amount, available: 20}).escrow;
|
||||
const before = structuredClone(ledger);
|
||||
const result = reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: ledger, newAuthorizations: []});
|
||||
assert.deepEqual(result.escrow.authorizations, [bc]);
|
||||
assert.deepEqual(result.escrow.pledges, [{donor: 'b', recipient: 'c', amount: 5, epoch: 'bc1'}]);
|
||||
assert.deepEqual(result.refunds, [{donor: 'a', amount: 7}, {donor: 'c', amount: 2}]);
|
||||
assert.deepEqual(ledger, before);
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: ledger, newAuthorizations: [],
|
||||
locked: [{donor: 'a', recipient: 'b', amount: 1, epoch: 'ab1'}]}), /Resolve committed/);
|
||||
});
|
||||
|
||||
void test('same-turn reformation requires fresh epochs and cannot reactivate old authorization', () => {
|
||||
const old = startedTriple();
|
||||
const left = reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: old, newAuthorizations: []}).escrow;
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: bcPair, after: triple, escrow: left, newAuthorizations: [ab, ac]}), /reused/);
|
||||
const result = reconcileCoalitionEscrow({before: bcPair, after: triple, escrow: left,
|
||||
newAuthorizations: [{...ab, epoch: 'ab2'}, {...ac, epoch: 'ac2'}]});
|
||||
assert.deepEqual(result.escrow.authorizations, [{...ab, epoch: 'ab2'}, {...ac, epoch: 'ac2'}, bc]);
|
||||
assert.deepEqual(result.escrow.usedEpochs, ['ab1', 'ac1', 'bc1', 'ab2', 'ac2']);
|
||||
});
|
||||
|
||||
void test('overflow, duplicate or malformed ledgers and forged locks fail without mutation', () => {
|
||||
const huge: CoalitionEscrowState = {...startedTriple(), pledges: [
|
||||
{donor: 'a', recipient: 'b', amount: Number.MAX_SAFE_INTEGER, epoch: 'ab1'},
|
||||
{donor: 'a', recipient: 'c', amount: 1, epoch: 'ac1'},
|
||||
]};
|
||||
const before = structuredClone(huge);
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: huge, newAuthorizations: []}), /safe integers/);
|
||||
assert.throws(() => setCoalitionPledge({...triple, escrow: huge, donor: 'a', recipient: 'c', amount: 0, available: Number.MAX_SAFE_INTEGER}), /safe integers/);
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: triple, escrow: huge, newAuthorizations: [],
|
||||
locked: [{donor: 'a', recipient: 'c', amount: 2, epoch: 'ac1'}]}), /match its available/);
|
||||
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: triple, escrow: {...huge, usedEpochs: ['ab1', 'ac1', 'ac1', 'bc1']}, newAuthorizations: []}), /unique valid/);
|
||||
assert.throws(() => setCoalitionPledge({...triple, escrow: {...huge, pledges: [...huge.pledges, huge.pledges[0]]}, donor: 'a', recipient: 'b', amount: 1, available: 1}), /only one pledge/);
|
||||
assert.deepEqual(huge, before);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { CoalitionFunding, coalitionFundingContributions, coalitionPledgeAction,
|
||||
type CoalitionFundingView } from '../components/coalition-funding';
|
||||
|
||||
const view: CoalitionFundingView = {
|
||||
targets: [
|
||||
{ id: 'b', name: 'Emperor', pledged: 2, maximum: 6 },
|
||||
{ id: 'c', name: 'Fremen', pledged: 1, maximum: 5 },
|
||||
],
|
||||
incoming: [
|
||||
{ donor: 'b', name: 'Emperor', amount: 3 },
|
||||
{ donor: 'c', name: 'Fremen', amount: 2 },
|
||||
],
|
||||
blocked: null,
|
||||
};
|
||||
|
||||
void test('each outgoing ally has a separate labelled pledge and incoming amount', () => {
|
||||
const html = renderToStaticMarkup(createElement(CoalitionFunding, {
|
||||
view, act() {}, onContributionsChange() {},
|
||||
}));
|
||||
assert.match(html, /Pledge to Emperor · 2 spice currently set aside/);
|
||||
assert.match(html, /Pledge to Fremen · 1 spice currently set aside/);
|
||||
assert.match(html, /Set funding for Emperor/);
|
||||
assert.match(html, /Set funding for Fremen/);
|
||||
assert.match(html, /Emperor: 3 spice/);
|
||||
assert.match(html, /Fremen: 2 spice/);
|
||||
assert.match(html, /Use from Emperor for your next shipment/);
|
||||
assert.match(html, /Use from Fremen for your next shipment/);
|
||||
});
|
||||
|
||||
void test('pledge action uses the named recipient and validates whole, safe limits', () => {
|
||||
const target = view.targets[0];
|
||||
assert.deepEqual(coalitionPledgeAction(target, '6', null),
|
||||
{ type: 'coalitionPledge', recipient: 'b', amount: 6 });
|
||||
assert.deepEqual(coalitionPledgeAction(target, '0', null),
|
||||
{ type: 'coalitionPledge', recipient: 'b', amount: 0 });
|
||||
for (const invalid of ['', '-1', '1.5', '7', '01', '1e2', '9007199254740992'])
|
||||
assert.equal(coalitionPledgeAction(target, invalid, null), null);
|
||||
assert.equal(coalitionPledgeAction(target, '2', 'Finish the pending payment.'), null);
|
||||
assert.equal(coalitionPledgeAction(target, '2', null, true), null);
|
||||
});
|
||||
|
||||
void test('shipment selections retain donor identity and clamp to live pledges', () => {
|
||||
assert.deepEqual(coalitionFundingContributions(view.incoming, {b: 9, c: 1.9, missing: 8}), [
|
||||
{ donor: 'b', amount: 3 }, { donor: 'c', amount: 1 },
|
||||
]);
|
||||
assert.deepEqual(coalitionFundingContributions(view.incoming, {b: -2, c: 0}), []);
|
||||
assert.deepEqual(coalitionFundingContributions([{donor: 'b', name: 'Emperor', amount: 1}],
|
||||
{b: 3, c: 2}), [{ donor: 'b', amount: 1 }]);
|
||||
});
|
||||
|
||||
void test('busy or blocked controls cannot submit and omit shipment inputs when not requested', () => {
|
||||
const blocked = renderToStaticMarkup(createElement(CoalitionFunding, {
|
||||
view: {...view, blocked: 'Finish the pending payment.'}, act() {},
|
||||
}));
|
||||
assert.match(blocked, /Finish the pending payment/);
|
||||
assert.match(blocked, /disabled=""[^>]*>Set funding for Emperor/);
|
||||
assert.doesNotMatch(blocked, /Use from Emperor for your next shipment/);
|
||||
const busy = renderToStaticMarkup(createElement(CoalitionFunding, { view, busy: true, act() {} }));
|
||||
assert.match(busy, /disabled=""[^>]*>Set funding for Fremen/);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
const send = (game: Game, player: string, action: Action) => applyAction(game, player, action);
|
||||
|
||||
/** The empty ledger and Nexus are staged because no lobby enables the group
|
||||
* mode yet. The pair, invitation and unanimous group formation are actual
|
||||
* actions. The subsequent quiet movement boundary and starting wallets are
|
||||
* staged; each faction's force inventory remains conserved. */
|
||||
function fixture(): Game {
|
||||
let game = createGame('GROUPFUND', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
game.players.push(newPlayer('b', 'Emperor', 'emperor'),
|
||||
newPlayer('c', 'Fremen', 'fremen'), newPlayer('d', 'Guild', 'guild'));
|
||||
Object.assign(game, {status: 'playing', phase: 1, turn: 2, nexus: true,
|
||||
order: ['a', 'b', 'c', 'd'], ready: [], storm: 18, deck: baseDeck()});
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = send(game, 'a', {type: 'alliance', target: 'b'});
|
||||
game = send(game, 'b', {type: 'alliance', target: 'a'});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
game = send(game, 'a', {type: 'coalitionPropose', groupId: pair.id,
|
||||
entrant: 'c', expectedRevision: game.coalitionState!.revision});
|
||||
for (const id of ['b', 'c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = send(game, id, {type: 'coalitionAnswer', event: offer.event,
|
||||
nexus: offer.nexus, turn: offer.turn, accept: true,
|
||||
expectedRevision: game.coalitionState!.revision});
|
||||
}
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a', 'b', 'c']);
|
||||
assert.ok(game.coalitionEscrow, 'Real group formation initialized the escrow.');
|
||||
Object.assign(game, {phase: 5, nexus: false, active: 'a', ready: [],
|
||||
movementRemaining: ['a', 'b', 'c', 'd'], guildTimingGranted: false,
|
||||
guildTimingLocked: false});
|
||||
for (const player of game.players) {
|
||||
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
|
||||
player.hand = []; player.traitors = []; player.traitorChoices = [];
|
||||
player.shipped = false; player.moved = 0;
|
||||
}
|
||||
game.players[0].spice = 2;
|
||||
return game;
|
||||
}
|
||||
|
||||
function pledge(game: Game): Game {
|
||||
game = send(game, 'b', {type: 'coalitionPledge', recipient: 'a', amount: 4});
|
||||
return send(game, 'c', {type: 'coalitionPledge', recipient: 'a', amount: 3});
|
||||
}
|
||||
const shipment: Action = {type: 'ship', territory: 'imperial_basin', sector: 9,
|
||||
amount: 3, elite: 0, allyPayment: 0,
|
||||
coalitionContributions: [{donor: 'b', amount: 3}, {donor: 'c', amount: 2}]};
|
||||
|
||||
function finishResponses(game: Game): Game {
|
||||
for (let step = 0; game.response && step < 12; step++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(actor, 'Every income response must have a remaining voter.');
|
||||
game = send(game, actor.id, {type: 'passResponse'});
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.equal(game.pendingShipment ?? null, null);
|
||||
return game;
|
||||
}
|
||||
function remaining(game: Game, donor: string, recipient = 'a'): number {
|
||||
return game.coalitionEscrow!.pledges.find(pledge => pledge.donor === donor &&
|
||||
pledge.recipient === recipient)?.amount ?? 0;
|
||||
}
|
||||
function conservedForces(game: Game) {
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((n, value) => n + value, 0), 20);
|
||||
}
|
||||
function spiceIncludingEscrow(game: Game): number {
|
||||
return game.players.reduce((n, player) => n + player.spice, 0) +
|
||||
(game.coalitionEscrow?.pledges.reduce((n, pledge) => n + pledge.amount, 0) ?? 0);
|
||||
}
|
||||
|
||||
void test('two allies fund one ordinary shipment with donor-specific settlement and Guild income', () => {
|
||||
let game = fixture();
|
||||
const originalSpice = spiceIncludingEscrow(game);
|
||||
game = pledge(game);
|
||||
assert.equal(game.players[1].spice, 6);
|
||||
assert.equal(game.players[2].spice, 7);
|
||||
assert.equal(remaining(game, 'b'), 4);
|
||||
assert.equal(remaining(game, 'c'), 3);
|
||||
assert.deepEqual(viewGame(game, 'a').coalitionFunding?.incoming.map(({donor, amount}) => ({donor, amount})),
|
||||
[{donor:'b', amount:4}, {donor:'c', amount:3}]);
|
||||
assert.equal(viewGame(game, 'd').coalitionFunding, null);
|
||||
assert.deepEqual(viewGame(game, 'b').coalitionFunding?.incoming, []);
|
||||
game = send(game, 'a', shipment);
|
||||
if (game.response) assert.equal(game.response.kind, 'guildIncome');
|
||||
game = finishResponses(game);
|
||||
assert.equal(game.players[0].spice, 1, 'The shipper pays the one-spice remainder.');
|
||||
assert.equal(game.players[1].spice, 6, 'Already escrowed contributions are not charged twice.');
|
||||
assert.equal(game.players[2].spice, 7);
|
||||
assert.equal(remaining(game, 'b'), 1);
|
||||
assert.equal(remaining(game, 'c'), 1);
|
||||
assert.equal(game.players[3].spice, 16, 'The outsider Guild receives the full six-spice invoice.');
|
||||
assert.equal(game.players[0].forces['imperial_basin:9'], 3);
|
||||
assert.equal(game.players[0].reserves, 17);
|
||||
assert.equal(game.players[0].shipped, true);
|
||||
conservedForces(game);
|
||||
assert.equal(spiceIncludingEscrow(game), originalSpice);
|
||||
});
|
||||
|
||||
void test('fake or insufficient donor contributions reject without payment or arrival', () => {
|
||||
const game = pledge(fixture());
|
||||
for (const contributions of [
|
||||
[{donor: 'd', amount: 3}],
|
||||
[{donor: 'b', amount: 5}],
|
||||
[{donor: 'b', amount: 3}, {donor: 'b', amount: 1}],
|
||||
[{donor: 'b', amount: 3}], // Shipper cannot afford the three-spice remainder.
|
||||
]) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'a', {...shipment, coalitionContributions: contributions}));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('unspent group shipment pledges return to their actual donors when movement ends', () => {
|
||||
let game = finishResponses(send(pledge(fixture()), 'a', shipment));
|
||||
for (let step = 0; game.phase === 5 && step < 20; step++) {
|
||||
if (game.response) { game = finishResponses(game); continue; }
|
||||
if (game.decision?.kind === 'guildTiming') {
|
||||
game = send(game, game.decision.player, {type: 'decision', take: false});
|
||||
continue;
|
||||
}
|
||||
assert.ok(game.active, 'A live movement turn must have an actor.');
|
||||
game = send(game, game.active, {type: 'endMovement'});
|
||||
}
|
||||
assert.notEqual(game.phase, 5, 'The native movement queue completes.');
|
||||
assert.equal(game.players[1].spice, 7);
|
||||
assert.equal(game.players[2].spice, 8);
|
||||
assert.equal(remaining(game, 'b'), 0);
|
||||
assert.equal(remaining(game, 'c'), 0);
|
||||
assert.equal(spiceIncludingEscrow(game), 32);
|
||||
conservedForces(game);
|
||||
});
|
||||
|
||||
void test('one donor receives both separate unspent pledges at phase end', () => {
|
||||
let game = fixture();
|
||||
game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3});
|
||||
game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2});
|
||||
assert.equal(game.players[1].spice, 5);
|
||||
for (let step = 0; game.phase === 5 && step < 20; step++) {
|
||||
if (game.response) { game = finishResponses(game); continue; }
|
||||
if (game.decision?.kind === 'guildTiming') {
|
||||
game = send(game, game.decision.player, {type:'decision', take:false});
|
||||
continue;
|
||||
}
|
||||
assert.ok(game.active);
|
||||
game = send(game, game.active, {type:'endMovement'});
|
||||
}
|
||||
assert.notEqual(game.phase, 5);
|
||||
assert.equal(game.players[1].spice, 10);
|
||||
assert.equal(game.coalitionEscrow!.pledges.length, 0);
|
||||
assert.equal(spiceIncludingEscrow(game), 32);
|
||||
});
|
||||
|
||||
void test('leaving refunds only broken group edges and retains the surviving pair pledge', () => {
|
||||
let game = fixture();
|
||||
game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3});
|
||||
game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2});
|
||||
const oldEpoch = game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch;
|
||||
// Stage a later quiet Nexus; normal phase ending would already refund these pledges.
|
||||
Object.assign(game, {phase:1, nexus:true, active:null, ready:[]});
|
||||
game = send(game, 'c', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
|
||||
assert.equal(game.players[1].spice, 7);
|
||||
assert.equal(remaining(game, 'b', 'a'), 3);
|
||||
assert.equal(remaining(game, 'b', 'c'), 0);
|
||||
assert.equal(game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch, oldEpoch);
|
||||
assert.equal(spiceIncludingEscrow(game), 32);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { baseDeck } from '../game/cards';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { createRicheseNoField } from '../game/richese-no-field';
|
||||
import type { CoalitionState } from '../game/coalition-alliance';
|
||||
import { isAlliedShipmentVisit } from '../game/allied-separation';
|
||||
|
||||
/** Controlled Basic phase and force placement; the three-member group is
|
||||
* staged because Nexus cannot create one yet. No full-game claim is made. */
|
||||
@@ -68,6 +69,15 @@ void test('ordinary movement cannot enter either group ally territory', () => {
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('the player view gives the shared transit quote the public group membership', () => {
|
||||
const game = fixture();
|
||||
const view = viewGame(game, 'a');
|
||||
const own = view.players.find(player => player.id === 'a')!;
|
||||
assert.equal(isAlliedShipmentVisit(view, own, 'imperial_basin'), true);
|
||||
assert.equal(isAlliedShipmentVisit(view, own, 'polar_sink'), false);
|
||||
assert.equal(Object.hasOwn(view, 'coalitionEscrow'), false);
|
||||
});
|
||||
|
||||
void test('a three-member group visitor can ship then depart without losing its pre-arrival forces', () => {
|
||||
const original = fixture(); let game = ship(original);
|
||||
assert.equal(game.players[0].spice, 4);
|
||||
|
||||
@@ -43,6 +43,32 @@ void test('staged group mode renders its real Nexus invitation and hides conflic
|
||||
assert.match(markup(game, 'h', true), /disabled=""[^>]*>Accept invitation/);
|
||||
});
|
||||
|
||||
void test('staged group shipment funding appears on the live table for the donor and recipient', () => {
|
||||
let game = nexusOfferGame();
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = applyAction(game, 'a', {type:'alliance', target:'e'});
|
||||
game = applyAction(game, 'e', {type:'alliance', target:'a'});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id,
|
||||
entrant:'h', expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['e', 'h']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = applyAction(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
// Stage the next movement boundary; the group negotiation above used real actions.
|
||||
Object.assign(game, {phase:5, nexus:false, active:'a', ready:[], movementRemaining:['a','e','h']});
|
||||
const emperor = game.players.find(player => player.id === 'e')!;
|
||||
assert.ok(emperor.reserves > 0);
|
||||
emperor.reserves--;
|
||||
emperor.forces['arrakeen:10'] = (emperor.forces['arrakeen:10'] ?? 0) + 1;
|
||||
game = applyAction(game, 'e', {type:'coalitionPledge', recipient:'a', amount:1});
|
||||
assert.match(markup(game, 'a'), /Emperor recipient: 1 spice/);
|
||||
assert.match(markup(game, 'a'), /Use from Emperor recipient for your next shipment/);
|
||||
assert.match(markup(game, 'a'), /Basic allied transit: ship forces/);
|
||||
assert.match(markup(game, 'e'), /Pledge to Atreides offerer · 1 spice currently set aside/);
|
||||
});
|
||||
|
||||
void test('live outgoing offer is named and withdrawable only by its sender, separately from an incoming invitation', () => {
|
||||
const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' });
|
||||
const html = markup(g);
|
||||
|
||||
Reference in new issue
Block a user