Support staged group bid funding
Verify and publish checkpoint container / container (push) Successful in 10m27s
Verify and publish checkpoint container / container (push) Successful in 10m27s
This commit is contained in:
1 parent
34aa64cdef
commit
1eeeb32566
12 files changed
+426
-37
No files matched your search
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user