Support staged group bid funding
Verify and publish checkpoint container / container (push) Successful in 10m27s

This commit is contained in:
Quintine committed 2026-10-28 23:22:50 +11:00
1 parent 34aa64cdef
commit 1eeeb32566
12 files changed
+426 -37

No files matched your search

+9 -5
View File
@@ -4,7 +4,7 @@ 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 }[];
targets: readonly { id: string; name: string; pledged: number; maximum: number; minimum?: number }[];
incoming: readonly { donor: string; name: string; amount: number }[];
blocked: string | null;
};
@@ -35,13 +35,15 @@ export function coalitionPledgeAction(
): 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;
if (!Number.isSafeInteger(amount) || amount < safeMaximum(target.minimum ?? 0) ||
amount > safeMaximum(target.maximum)) return null;
return { type: 'coalitionPledge', recipient: target.id, amount };
}
export function CoalitionFunding({ view, busy = false, act, onContributionsChange }: {
export function CoalitionFunding({ view, busy = false, act, onContributionsChange, purpose = 'shipment' }: {
view: CoalitionFundingView;
busy?: boolean;
purpose?: 'bid' | 'shipment';
act: (action: CoalitionPledgeAction) => void;
onContributionsChange?: (contributions: CoalitionFundingContribution[]) => void;
}) {
@@ -66,9 +68,11 @@ export function CoalitionFunding({ view, busy = false, act, onContributionsChang
const raw = pledges[target.id] ?? String(target.pledged);
const action = coalitionPledgeAction(target, raw, view.blocked, busy);
const inputId = `${id}-pledge-${index}`;
const minimum = safeMaximum(target.minimum ?? 0);
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}
{minimum > 0 && <p>The current high bid commits {minimum} spice of this pledge.</p>}
<input id={inputId} type="number" min={minimum} 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}
@@ -84,7 +88,7 @@ export function CoalitionFunding({ view, busy = false, act, onContributionsChang
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>
<label htmlFor={inputId}>Use from {entry.name} for your next {purpose}</label>
<input id={inputId} type="number" min={0} max={maximum} step={1}
inputMode="numeric" value={value} disabled={busy || !!view.blocked}
onChange={event => {
+12 -9
View File
@@ -453,11 +453,6 @@ export function GameTable({
? bidDraft.value
: String(minimumBid);
const bidAmount = integerInputValue(bidText);
const maximumBid = me.hand?.some((c) =>
canUseAsKaramaRole(g, me, c),
)
? Number.MAX_SAFE_INTEGER
: (me.spice ?? 0) + g.aid.available;
const [eliteAmount, setEliteAmount] = useState(0);
const [homeworldDraft, setHomeworldDraft] = useState<{
key: string;
@@ -782,13 +777,16 @@ 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 coalitionFundingKey = JSON.stringify([g.code, g.turn, g.phase,
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 maximumBid = me.hand?.some((c) => canUseAsKaramaRole(g, me, c))
? Number.MAX_SAFE_INTEGER : (me.spice ?? 0) + (g.phase === 3 && g.coalitionFunding ? groupShipmentShare : g.aid.available);
const selectedAllyPayment =
shipmentCost === null
? 0
@@ -3890,7 +3888,7 @@ export function GameTable({
{actionButton(
'Pay with spice',
{ type: 'decision', karama: false },
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available + (g.auction?.coalitionPayment ?? 0),
)}
{me.hand
?.filter((card) => canSpendKarama(card, 'auction-free-purchase'))
@@ -4237,7 +4235,8 @@ export function GameTable({
<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[]) =>
purpose={g.phase === 3 ? 'bid' : 'shipment'}
{...((shipmentAvailable || g.phase === 3 && g.auction?.active === me.id) ? {onContributionsChange:(shares:CoalitionFundingContribution[]) =>
setCoalitionShares({key:coalitionFundingKey, shares})} : {})} />}
{me.ally && [3, 5].includes(g.phase) && (
<details className="alliance-funding">
@@ -4494,11 +4493,15 @@ export function GameTable({
type: 'bid',
amount: bidAmount,
...payment,
...(g.coalitionFunding && currentCoalitionShares.length
? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}),
},
!!normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) ||
!Number.isSafeInteger(bidAmount) ||
bidAmount < minimumBid ||
bidAmount > maximumBid,
bidAmount > maximumBid ||
!!(g.coalitionFunding && currentCoalitionShares.length &&
(groupShipmentShare > bidAmount || bidAmount - groupShipmentShare > (me.spice ?? 0))),
)}
{actionButton('Pass this bid', { type: 'passBid' })}
</>
+14 -3
View File
@@ -46,10 +46,21 @@ 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
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).
**Staged group bid funding — 28 October. Prototyped / Partial.** Basic
classic ordinary auctions can reserve, settle or release the declared shares
of multiple allied donors. An outbid or Karama purchase releases those locks;
normal Emperor income and end-of-phase refunds remain. Controls and legal bot
payment choices use the committed funding. Special auctions and other group
benefits remain separate, and no lobby exposes the group option.
The nine-file related batch passes 80 cases, including a later real
Truthtrance question; that test transfers its physical card from the deck in
a staged setup.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-bid-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
@@ -144,8 +155,8 @@ 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 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.
revokes those authorizations and refunds escrow; special auctions, 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
+20 -1
View File
@@ -1,5 +1,24 @@
# Optional three-stronghold alliance victory
## Group ordinary-bid funding — 28 October 2026
**Staged Basic prototype, not a selectable game mode.** A group member can
allocate separately pledged spice from multiple allies to an ordinary bid.
Each selected share remains committed while that bid leads and becomes
withdrawable after an outbid. The winner pays the original donor split once;
using a Karama instead releases the shares without spending them. The
ordinary Emperor income and phase-end refunds remain in force. The donor sees
the amount committed to the current bid, and the bidder sees the combined
committed payment. Group controls and minimal legal bot decisions are connected
in staged games.
The [publisher FAQ, p. 2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
describes allied contributions and the Emperor ally's payment returning to
Emperor. Recipient-specific escrow and explicit per-donor allocation are
provisional online procedures. This scope is Basic classic-faction ordinary
auctions; optional modules, competing Truthtrance commitments, special
auctions and complete group play remain separate. No lobby exposes group play.
## Group ordinary-shipment funding — 28 October 2026
**Staged Basic prototype, not a selectable game mode.** Group allies can set
@@ -15,7 +34,7 @@ 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
Guild-allied rates, other shipment types, special auctions, 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.
+6
View File
@@ -1,5 +1,11 @@
# Rule decision index
## Group ordinary-bid funding — 28 October 2026
The unexposed Basic group prototype keeps donor-specific pledges locked while
their bid leads, then settles or releases those exact shares. The allocation
and escrow procedure is provisional. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-ordinary-bid-funding--28-october-2026).
## Group shipment funding — 28 October 2026
The unexposed Basic group prototype uses recipient-specific escrow and
+1 -1
View File
@@ -3003,7 +3003,7 @@ function policyActions(g: GameView): Action[] {
);
const use =
!!karama &&
((g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available ||
((g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available + (g.auction?.coalitionPayment ?? 0) ||
(level >= 1 && (g.auction?.bid ?? 0) >= 4));
return [
{ type: 'decision', karama: use },
+70 -15
View File
@@ -1008,6 +1008,8 @@ export type Auction = {
passed: string[];
opener: number;
allyPayment?: number;
/** Private donor-specific shares of the current high bid. */
coalitionContributions?: CoalitionContribution[];
};
export type Decision =
| { kind: 'ecazBattleLead'; player: string; event: string; choices: string[]; lowEcaz?: true }
@@ -16047,21 +16049,29 @@ function applyCoalitionTransition(g: Game, result: CoalitionTransition) {
}
/** 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 ||
function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): boolean {
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || ![3, 5].includes(g.phase) ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || g.karamaShipping ||
g.spiceBankerIncomePreview) return false;
g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction ||
(!committedBid && (g.bidPromises?.length || g.phaseBidPromises?.length || g.turnActionPromises?.length ||
g.revivalPromises?.length || g.bribePromises?.length))) 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));
return group?.members.length === 3 && (g.phase === 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.');
requireRule(g.phase === 5 && coalitionFundingAvailable(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}));
}
function coalitionBidPaymentQuote(g: Game, p: Player, cost: number, contributions: CoalitionContribution[], committed = false) {
requireRule(g.phase === 3 && coalitionFundingAvailable(g, p, committed), 'Group funding is unavailable for this bid.');
return coalitionRule(() => quoteCoalitionFunding({seats:g.players, coalitionState:g.coalitionState,
authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges,
purpose:'bid', payer:p.id, cost, ownAvailable:p.spice, contributions}));
}
/** The original native reciprocal quote owns identity and pairing legality. */
function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
if (target !== null) {
@@ -16687,7 +16697,9 @@ function uncommittedSpice(g: Game, p: Player) {
richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id) -
// A current sale already consumed the bid; only an unpaid bid stays reserved.
(g.auction?.bidder === p.id && !g.currentAuctionSale
? g.auction.bid - (g.auction.allyPayment ?? 0)
? g.auction.bid - (g.auction.coalitionContributions
? g.auction.coalitionContributions.reduce((sum, share) => sum + share.amount, 0)
: (g.auction.allyPayment ?? 0))
: 0) -
(battleSupportCost(g, p, g.battle?.plans[p.id]?.support ?? 0) -
(g.battle?.plans[p.id]?.allyPayment ?? 0)) -
@@ -16850,6 +16862,15 @@ function normalBidQuote(g: Game, p: Player, amount: unknown, requestedAid?: unkn
checkBureaucratContributors(g, parties.payee, parties.shares);
return { amount: quote.amount, allyPayment };
}
function coalitionBidQuote(g: Game, p: Player, amount: unknown, contributions: CoalitionContribution[]) {
requireRule(g.auction && coalitionFundingAvailable(g, p), 'Group funding is unavailable for this bid.');
const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
const quote = quoteNormalAuctionBid({currentBid:g.auction.bid, amount, maximum:Number.MAX_SAFE_INTEGER});
if (!quote.ok) throw new RuleError(quote.reason);
coalitionBidPaymentQuote(g, p, quote.amount, contributions);
return {amount:quote.amount, allyPayment:0};
}
function bidOpportunity(g: Game): BidOpportunity | null {
const a = g.auction;
if (g.status !== 'playing' || g.phase !== 3 || !a || g.currentAuctionSale ||
@@ -17716,7 +17737,16 @@ function auctionNext(g: Game) {
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
const a = g.auction!;
const winner = getPlayer(g, a.bidder!);
if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
if (free) delete a.coalitionContributions;
else if (a.coalitionContributions !== undefined) {
coalitionBidPaymentQuote(g, winner, a.bid, a.coalitionContributions, true);
const result = coalitionRule(() => settleCoalitionFunding({seats:g.players, coalitionState:g.coalitionState,
authorizations:g.coalitionEscrow!.authorizations, pledges:g.coalitionEscrow!.pledges,
purpose:'bid', payer:winner.id, cost:a.bid, ownAvailable:winner.spice,
contributions:a.coalitionContributions!}));
winner.spice -= result.quote.own;
g.coalitionEscrow = {...g.coalitionEscrow!, pledges:result.pledges};
} else payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
winner.hand.push(a.cards[a.index]);
const emperor = byFaction(g, 'emperor');
const paysEmperor = !!emperor && emperor.id !== winner.id;
@@ -19388,6 +19418,10 @@ function completeKarama(g: Game, p: Player, use: KaramaUse, card?: Card) {
function auctionSpicePaymentFunded(g: Game, p: Player) {
const a = g.auction;
if (!a || a.bidder !== p.id) return false;
if (a.coalitionContributions !== undefined) {
try { coalitionBidPaymentQuote(g, p, a.bid, a.coalitionContributions, true); return true; }
catch (error) { if (error instanceof RuleError) return false; throw error; }
}
const allyPayment = a.allyPayment ?? 0;
return (
Number.isInteger(a.bid) &&
@@ -19753,7 +19787,8 @@ function finishAutomaticDecision(g: Game): boolean {
}
function recoverAuctionPayment(g: Game, p: Player) {
const a = g.auction!;
if (a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) || karamaCard(g, p, undefined, 'auction-free-purchase')) {
if ((a.coalitionContributions !== undefined ? auctionSpicePaymentFunded(g, p)
: a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)) || karamaCard(g, p, undefined, 'auction-free-purchase')) {
g.decision = { kind: 'auctionPayment', player: p.id };
return;
}
@@ -19763,6 +19798,7 @@ function recoverAuctionPayment(g: Game, p: Player) {
a.bid = 0;
a.bidder = null;
a.allyPayment = 0;
delete a.coalitionContributions;
a.passed = [];
if (a.saphoOrder) {
const first = a.saphoOrder.find(id => getPlayer(g,id).hand.length < handLimit(getPlayer(g,id)));
@@ -44463,16 +44499,18 @@ function applyActionInner(
return g;
}
if (t === 'coalitionPledge') {
requireRule(coalitionShipmentFundingAvailable(g, p), 'Group shipment funding is unavailable now.');
requireRule(coalitionFundingAvailable(g, p), 'Group funding is unavailable now.');
const recipient = stringField(action.recipient);
const amount = integer(action.amount, 0, Number.MAX_SAFE_INTEGER, 'Group pledge');
const locked = g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === recipient
? g.auction.coalitionContributions?.find(share => share.donor === id)?.amount ?? 0 : 0;
const result = coalitionRule(() => setCoalitionPledge({ ...coalitionMembership(g),
escrow:g.coalitionEscrow!, donor:id, recipient, amount, available:uncommittedSpice(g, p) }));
escrow:g.coalitionEscrow!, donor:id, recipient, amount, available:uncommittedSpice(g, p), locked }));
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}.`);
log(g, `${p.name} updated group funding for ${getPlayer(g, recipient).name}.`);
return g;
}
if (t === 'pledgeAid') {
@@ -44553,13 +44591,23 @@ function applyActionInner(
);
const a = g.auction;
if (t === 'bid') {
const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment);
let groupShares: CoalitionContribution[] | undefined;
if (action.coalitionContributions !== undefined) {
requireRule(Array.isArray(action.coalitionContributions) &&
(action.allyPayment === undefined || action.allyPayment === 0),
'Choose donor shares for this group bid.');
groupShares = action.coalitionContributions as CoalitionContribution[];
}
const quotedBid = groupShares === undefined ? normalBidQuote(g, p, action.amount, action.allyPayment)
: coalitionBidQuote(g, p, action.amount, groupShares);
if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
hasBribePromises(g, p.id, true)) {
const trial = structuredClone(g);
trial.auction!.bid = quotedBid.amount;
trial.auction!.bidder = p.id;
trial.auction!.allyPayment = quotedBid.allyPayment;
if (groupShares === undefined) delete trial.auction!.coalitionContributions;
else trial.auction!.coalitionContributions = groupShares.map(share => ({...share}));
guardPromisedBid(trial, getPlayer(trial, p.id), action, quotedBid.amount);
reconcileRevivalPromises(trial, { actor: p.id, action });
reconcileBribePromises(trial, { actor: p.id, action });
@@ -44567,6 +44615,8 @@ function applyActionInner(
guardPromisedBid(g, p, action, quotedBid.amount);
a.bid = quotedBid.amount;
a.allyPayment = quotedBid.allyPayment;
if (groupShares === undefined) delete a.coalitionContributions;
else a.coalitionContributions = groupShares.map(share => ({...share}));
a.bidder = id;
a.passed = [];
} else {
@@ -46956,12 +47006,14 @@ export function viewGame(state: Game, id: string, now?: number) {
? g.aid[me.ally].amount
: 0,
},
coalitionFunding: coalitionShipmentFundingAvailable(g, me) ? (() => {
coalitionFunding: coalitionFundingAvailable(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,
const minimum = g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === member
? g.auction.coalitionContributions?.find(share => share.donor === id)?.amount ?? 0 : 0;
return {id: member, name: getPlayer(g, member).name, pledged, minimum,
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};
@@ -47219,7 +47271,10 @@ export function viewGame(state: Game, id: string, now?: number) {
bidder: g.auction.bidder,
active: g.auction.active,
...(g.auction.bidder === id
? { allyPayment: g.auction.allyPayment ?? 0 }
? { allyPayment: g.auction.allyPayment ?? 0,
...(g.auction.coalitionContributions === undefined ? {} : {
coalitionPayment:g.auction.coalitionContributions.reduce((sum, share) => sum + share.amount, 0),
}) }
: {}),
remaining: g.auction.cards.length - g.auction.index,
inspectionBlocked: g.homeworldOccupationPreview ? quoteOccupiedBiddingAuthority(g, 'atreidesInspection').blocked : null,
+1
View File
@@ -94,6 +94,7 @@ function calculate(input: NormalAuctionRetirementInput): NormalAuctionRetirement
: atreides ? { kind: 'atreidesAuction' as const, owner: atreides.id, passed: [] } : null;
const nextAuction = structuredClone(a);
delete nextAuction.saphoOrder;
delete nextAuction.coalitionContributions;
return {
kind: 'normalLot', auction: {
...nextAuction, index, bid: 0, allyPayment: 0, bidder: null,
+3 -3
View File
@@ -2795,10 +2795,10 @@ 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: 'Implementation', status: 'Partial', detail: 'Ordinary pair funding is connected. Staged Basic group games support recipient-specific pledges and named contributions for ordinary bids, plus physical shipments when Guild is outside the group; Guild-allied shipment rates and special transactions 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 bid and 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused staged cases exercise multiple donors, an outside Guild, ordinary auctions, Emperor income, rejected splits and individual refunds. Full group play and deployed acceptance remain open.', evidence: ['tests/coalition-bid-funding.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-escrow.test.ts', 'tests/coalition-funding-controls.test.tsx'] },
],
related: ['bidding', 'movement', 'faction-guild', 'card-karama'],
},
+252
View File
@@ -0,0 +1,252 @@
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';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
/** Staged quiet Basic Nexus, empty group ledger and pre-Charity wallets; pair
* formation, unanimous group agreement, Charity completion and the auction
* are real engine actions. One original Karama is physically transferred from
* the deck to the bidder, so the actual payment choice remains available.
* This is a controlled setup, not a completed game or a public group option. */
function fixture(): Game {
let game = createGame('GROUPBID', newPlayer('a', 'Fremen bidder', 'fremen'), false);
game.players.push(newPlayer('b', 'Guild donor', 'guild'),
newPlayer('c', 'Harkonnen donor', 'harkonnen'),
newPlayer('d', 'Emperor outsider', 'emperor'));
Object.assign(game, { status: 'playing', phase: 1, turn: 2, nexus: true,
order: ['a', 'b', 'c', 'd'], ready: [], storm: 18, deck: baseDeck(),
discard: [], response: null, decision: null, phaseOpening: null });
for (const player of game.players) {
player.spice = 10; player.hand = []; player.forces = {}; player.reserves = 20;
player.tanks = 0; player.traitorChoices = []; player.traitors = [];
}
game.players[0].spice = 2;
const cardIndex = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(cardIndex >= 0);
game.players[0].hand.push(game.deck.splice(cardIndex, 1)[0]);
game.coalitionState = emptyCoalitionState();
game = act(game, 'a', {type: 'alliance', target: 'b'});
game = act(game, 'b', {type: 'alliance', target: 'a'});
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
game = act(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 = act(game, id, {type: 'coalitionAnswer', event: offer.event,
nexus: offer.nexus, turn: offer.turn, accept: true,
expectedRevision: game.coalitionState!.revision});
}
assert.ok(game.coalitionEscrow);
Object.assign(game, {phase: 2, nexus: false, active: null, ready: []});
for (const player of game.players) game = act(game, player.id, {type: 'ready'});
assert.equal(game.phase, 3);
assert.equal(game.auction?.active, 'a');
return game;
}
function pledge(game: Game): Game {
game = act(game, 'b', {type: 'coalitionPledge', recipient: 'a', amount: 4});
return act(game, 'c', {type: 'coalitionPledge', recipient: 'a', amount: 3});
}
const groupBid: Action = {type: 'bid', amount: 6, allyPayment: 0,
coalitionContributions: [{donor:'b', amount:3}, {donor:'c', amount:2}]};
const pledged = (game: Game, donor: string, recipient = 'a') =>
game.coalitionEscrow!.pledges.find(item => item.donor === donor && item.recipient === recipient)?.amount ?? 0;
const spice = (game: Game) => game.players.reduce((sum, player) => sum + player.spice, 0) +
(game.coalitionEscrow?.pledges.reduce((sum, entry) => sum + entry.amount, 0) ?? 0);
function rejected(game: Game, id: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, id, action));
assert.deepEqual(game, before);
}
function passResponse(game: Game): Game {
if (!game.response) return game;
const kind = game.response?.kind;
for (let i = 0; game.response?.kind === kind && i < 16; i++) {
const next = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(next);
game = act(game, next.id, {type: 'passResponse'});
}
return game;
}
function finishBid(game: Game): Game {
for (let i = 0; !game.response && !game.decision && i < 12; i++) {
assert.ok(game.auction?.active);
game = act(game, game.auction.active, {type: 'passBid'});
}
return game;
}
void test('each donor share of a leading group bid remains locked until outbid', () => {
let game = act(pledge(fixture()), 'a', groupBid);
assert.equal(game.auction!.bidder, 'a');
assert.deepEqual(game.auction!.coalitionContributions, groupBid.coalitionContributions);
rejected(game, 'b', {type:'coalitionPledge', recipient:'a', amount:2});
rejected(game, 'c', {type:'coalitionPledge', recipient:'a', amount:1});
assert.equal(pledged(game, 'b'), 4);
assert.equal(pledged(game, 'c'), 3);
game = act(game, 'b', {type:'passBid'});
game = act(game, 'c', {type:'passBid'});
assert.equal(game.auction!.active, 'd');
game = act(game, 'd', {type:'bid', amount:7, allyPayment:0});
assert.equal(game.auction!.bidder, 'd');
assert.equal(game.auction!.coalitionContributions, undefined);
game = act(game, 'b', {type:'coalitionPledge', recipient:'a', amount:1});
game = act(game, 'c', {type:'coalitionPledge', recipient:'a', amount:0});
assert.equal(pledged(game, 'b'), 1);
assert.equal(pledged(game, 'c'), 0);
assert.equal(game.players[1].spice, 9);
assert.equal(game.players[2].spice, 10);
assert.equal(spice(game), 32);
});
void test('only the bidder sees the combined group payment, without raw donor allocations', () => {
const game = act(pledge(fixture()), 'a', groupBid);
const own = viewGame(game, 'a').auction; assert.ok(own);
assert.equal(own.coalitionPayment, 5);
assert.equal(Object.hasOwn(own, 'coalitionContributions'), false);
for (const id of ['b', 'c', 'd']) {
const other = viewGame(game, id).auction; assert.ok(other);
assert.equal(Object.hasOwn(other, 'coalitionPayment'), false);
assert.equal(Object.hasOwn(other, 'coalitionContributions'), false);
}
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty} chooses a legal decision for a funded group bid`, () => {
let game = act(pledge(fixture()), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
const before = structuredClone(game), view = viewGame(game, 'a');
view.players.find(player => player.id === 'a')!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
assert.equal(action.type, 'decision');
assert.equal(typeof action.karama, 'boolean');
assert.ok(act(game, 'a', action));
assert.deepEqual(game, before);
});
void test('winning group bid settles its named donors once and pays the outside Emperor', () => {
let game = pledge(fixture());
const original = spice(game), beforeHand = game.players[0].hand.length;
game = act(game, 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
if (game.response) assert.equal(game.response.kind, 'emperorIncome');
game = passResponse(game);
if (game.auction) assert.equal(game.auction.coalitionContributions, undefined,
'A later lot cannot retain the previous bid’s donor allocation.');
assert.equal(game.players[0].spice, 1);
assert.equal(game.players[1].spice, 6);
assert.equal(game.players[2].spice, 7);
assert.equal(pledged(game, 'b'), 1);
assert.equal(pledged(game, 'c'), 1);
assert.equal(game.players[3].spice, 16);
assert.equal(game.players[0].hand.length, beforeHand + 1);
assert.equal(spice(game), original);
});
void test('a later Truthtrance bid commitment does not invalidate an already funded group bid', () => {
const source = fixture();
// Controlled, custody-preserving transfer: the real Truthtrance moves from
// the undealt deck to Emperor's empty hand; no card or pledge is invented.
const index = source.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(index >= 0);
const [truth] = source.deck.splice(index, 1);
source.players[3].hand.push(truth);
let game = act(pledge(source), 'a', groupBid);
game = act(game, 'd', {type:'card', card:truth.id});
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id));
assert.ok(next);
game = act(game, next.id, {type:'truthPass'});
}
assert.equal(game.truthtrance?.stage, 'ask');
assert.ok(game.auction);
game = act(game, 'd', {type:'truthAsk', question:{kind:'bid', target:'b',
turn:game.turn, lot:game.auction.index + 1, minimum:8}});
game = act(game, 'b', {type:'truthAnswer', answer:'no'});
assert.ok(game.bidPromises?.some(promise => promise.player === 'b' && !promise.answer));
assert.equal(game.auction!.bidder, 'a');
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
if (game.response) game = passResponse(game);
assert.equal(game.players[0].spice, 1);
assert.equal(pledged(game, 'b'), 1);
assert.equal(pledged(game, 'c'), 1);
assert.equal(game.players[3].spice, 16);
});
void test('an Emperor donor receives the entire winning payment under the normal income rule', () => {
let game = fixture();
// Staged faction swap tests the printed Emperor contribution case while
// preserving wallets, cards and the original group membership.
game.players[1].faction = 'emperor';
game.players[3].faction = 'guild';
game = act(pledge(game), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
assert.equal(game.response?.kind, 'emperorIncome');
game = passResponse(game);
assert.equal(game.players[1].spice, 12);
assert.equal(pledged(game, 'b'), 1);
assert.equal(game.players[3].spice, 10);
assert.equal(spice(game), 32);
});
void test('invalid or insufficient group bid splits reject atomically', () => {
const game = pledge(fixture());
for (const shares of [
[{donor:'d', amount:3}],
[{donor:'b', amount:5}, {donor:'c', amount:1}],
[{donor:'b', amount:3}, {donor:'b', amount:1}],
[{donor:'b', amount:3}],
[{donor:'b', amount:4}, {donor:'c', amount:3}],
]) rejected(game, 'a', {...groupBid, coalitionContributions: shares});
});
void test('paying with the real Karama releases the group shares without charging donors or Emperor', () => {
let game = act(pledge(fixture()), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
const card = game.players[0].hand.find(item => item.effect === 'karama'); assert.ok(card);
game = act(game, 'a', {type:'decision', karama:true, card:card.id});
if (game.response) game = passResponse(game);
assert.equal(game.players[0].spice, 2);
assert.equal(game.players[3].spice, 10);
assert.equal(pledged(game, 'b'), 4);
assert.equal(pledged(game, 'c'), 3);
assert.equal(game.discard.filter(item => item.id === card.id).length, 1);
game = act(game, 'b', {type:'coalitionPledge', recipient:'a', amount:0});
game = act(game, 'c', {type:'coalitionPledge', recipient:'a', amount:0});
assert.equal(spice(game), 32);
assert.equal(game.players[1].spice, 10);
assert.equal(game.players[2].spice, 10);
});
void test('unused group bid pledges return to each donor when Bidding closes', () => {
let game = act(pledge(fixture()), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
if (game.response) game = passResponse(game);
for (let i = 0; game.phase === 3 && i < 25; i++) {
if (game.response) { game = passResponse(game); continue; }
assert.ok(!game.decision, 'No unrelated decision should interrupt this staged basic auction.');
assert.ok(game.auction?.active);
game = act(game, game.auction.active, {type:'passBid'});
}
assert.notEqual(game.phase, 3);
assert.equal(pledged(game, 'b'), 0);
assert.equal(pledged(game, 'c'), 0);
assert.equal(game.players[1].spice, 7);
assert.equal(game.players[2].spice, 8);
assert.equal(game.players[3].spice, 16);
assert.equal(spice(game), 32);
});
+15
View File
@@ -29,6 +29,12 @@ void test('each outgoing ally has a separate labelled pledge and incoming amount
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/);
const bidding = renderToStaticMarkup(createElement(CoalitionFunding, {
view, act() {}, onContributionsChange() {}, purpose: 'bid',
}));
assert.match(bidding, /Use from Emperor for your next bid/);
assert.match(bidding, /Use from Fremen for your next bid/);
assert.doesNotMatch(bidding, /for your next shipment/);
});
void test('pledge action uses the named recipient and validates whole, safe limits', () => {
@@ -41,6 +47,15 @@ void test('pledge action uses the named recipient and validates whole, safe limi
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);
const committed = {...target, minimum: 2};
assert.equal(coalitionPledgeAction(committed, '1', null), null);
assert.deepEqual(coalitionPledgeAction(committed, '2', null),
{type: 'coalitionPledge', recipient: 'b', amount: 2});
const html = renderToStaticMarkup(createElement(CoalitionFunding, {
view:{...view, targets:[committed]}, act() {}, purpose:'bid',
}));
assert.match(html, /The current high bid commits 2 spice of this pledge/);
assert.match(html, /min="2"/);
});
void test('shipment selections retain donor identity and clamp to live pledges', () => {
+23
View File
@@ -69,6 +69,29 @@ void test('staged group shipment funding appears on the live table for the donor
assert.match(markup(game, 'e'), /Pledge to Atreides offerer · 1 spice currently set aside/);
});
void test('staged group bid funding displays donor selection in the ordinary auction', () => {
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});
}
const card = game.deck.shift(); assert.ok(card);
// Stage an ordinary lot with its original physical card in auction custody.
Object.assign(game, {phase:3, nexus:false, active:'a', ready:[], auction:{
cards:[card], index:0, bid:0, bidder:null, active:'a', passed:[], opener:0,
}});
game = applyAction(game, 'e', {type:'coalitionPledge', recipient:'a', amount:1});
assert.match(markup(game, 'a'), /Use from Emperor recipient for your next bid/);
assert.match(markup(game, 'a'), /Raise bid/);
});
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);