Route Advanced Emperor allied auction 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
3ba9087405
commit
599af2475d
19 files changed
+883
-35
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { PHASES } from '@/game/catalog';
|
||||
import { areCoalitionAllies } from '@/game/coalition-alliance';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
||||
@@ -17,7 +18,8 @@ import { Input } from './ui/input';
|
||||
type TurnActionExpression = MixedActionExpression<TurnActionLeaf>;
|
||||
const recipients = (game: GameView, target: string) => {
|
||||
const player = game.players.find(player => player.id === target);
|
||||
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally) : [];
|
||||
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally &&
|
||||
(!game.coalitionState || !areCoalitionAllies(game.players, game.coalitionState, target, recipient.id))) : [];
|
||||
};
|
||||
/** Public faction names only; casualties, custody and native eligibility stay
|
||||
* on the server. Advanced Atreides may name their Kwisatz Haderach. */
|
||||
|
||||
+22
-1
@@ -17,6 +17,23 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Advanced Emperor auction funding — 29 October. Prototyped / Partial.**
|
||||
New ordinary and Richese auction invoices route the Emperor's direct allied
|
||||
contribution to the bank under the supplied Advanced source, while direct gifts
|
||||
remain buyer-owned and Basic keeps its existing payment rule. Pair and staged
|
||||
classic group bids, Richese lots and selected payment effects have focused
|
||||
coverage: nine changed-path files pass 69 cases, and the two related paired
|
||||
Bureaucrat files pass 102 cases after updating their expectations. Typecheck,
|
||||
lint and build pass.
|
||||
An additional five-file legacy payment run exposed 56 failures at the previous
|
||||
commit and 58 here: 55 failure names overlap, one Charity fixture failure varied
|
||||
between runs, and the two new outdated expectations were corrected afterward.
|
||||
Homeworld fixture repair and a clean broader run remain open. Current-turn negative bribe answers also permit group funding without
|
||||
releasing the answer, and group members are excluded from bribe recipient
|
||||
controls. Full group games and broader module interactions remain open.
|
||||
[Payment scope](NEXUS_EMPEROR_RULES.md#advanced-emperor-contribution-recipient--29-october-2026);
|
||||
[group scope](OPTIONAL_ALLIANCE_VICTORY.md#group-funding-with-negative-bribe-answers--29-october-2026).
|
||||
|
||||
**Advanced classic group foundation — 29 October. Prototyped / Partial.**
|
||||
Staged group alliances now connect genuine Advanced Nexus consent, ordinary
|
||||
multi-donor bid/shipment funding, the ending player's immediate allied
|
||||
@@ -612,7 +629,11 @@ Run 24 was canceled before starting; run 25 for `83ccd71` succeeded and
|
||||
published revision and checkpoint tags with digest
|
||||
`sha256:4548e1501826946b3edb20ef73cf1d1be53ac15f22b472f0ef1d8b9124d7b836`.
|
||||
Run 26 was canceled before starting; run 27 for `2fe97dd` was running at the
|
||||
last check.
|
||||
last earlier check. Run 51 for `cdc47ed` subsequently succeeded, including
|
||||
isolated HTTP verification, registry login and image publication. Run 52 was
|
||||
canceled before starting; run 53 for `3ba9087` was still running at the latest
|
||||
check. The already-approved runner network adjustment and registry secret
|
||||
remain in use; neither needed to be recreated.
|
||||
Deployment remains unverified.
|
||||
Existing NAS deployment and its persistent storage remain untouched.
|
||||
|
||||
|
||||
@@ -353,7 +353,25 @@ compulsory funding below. Seller-paid Secret Ally purchase remains unfinished.
|
||||
|
||||
## Betrayal and remaining decisions
|
||||
|
||||
Normal Emperor-funded auction help goes to the ally and is paid back to Emperor, according to November's FAQ. Therefore forcing its contribution cannot automatically be implemented as transferring its spice to the bank. Bind the actual allied purchase and its original price/contributions. [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2)
|
||||
The earlier Basic interpretation sends normal Emperor-funded auction help to
|
||||
the ally and back to Emperor, following [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2).
|
||||
The Advanced source cutover below changes the recipient of a contribution;
|
||||
bind the actual allied purchase and its original price and payer shares.
|
||||
|
||||
### Advanced Emperor contribution recipient — 29 October 2026
|
||||
|
||||
The user-authorized supplied revised rulebook, physical p. 15 (printed p. 13),
|
||||
Bidding, directs Emperor's contribution to an ally's Treachery Card purchase
|
||||
to the Spice Bank. An amount transferred directly using Emperor's alliance
|
||||
ability is instead the buyer's own spice and follows the purchase's normal
|
||||
recipient. This is an Advanced source cutover; Basic retains the earlier
|
||||
interpretation above. Ordinary and Richese paid-lot prototypes preserve the
|
||||
actual payer and recipient legs through settlement. For example, if an ally
|
||||
buys an ordinary card for six with two contributed by Emperor, four goes to
|
||||
Emperor and two to the bank. Income cancellation can affect only the eligible
|
||||
Emperor income, not return or replay the bank contribution. This implementation
|
||||
is partial and does not certify Nexus purchase variants, all optional-module
|
||||
interactions or full Advanced play.
|
||||
|
||||
### Implemented battle alternative (bounded)
|
||||
|
||||
|
||||
@@ -158,11 +158,24 @@ entry remain unfinished.
|
||||
**Staged Basic prototype, not a selectable game mode.** Fulfilled and released
|
||||
Truthtrance records no longer disable later group Nexus negotiations or group
|
||||
funding. The later scope above allows departures with live bribe answers and
|
||||
funding with outsider-only commitments; group-member funding with unresolved
|
||||
records remains fenced until reconciliation. The existing committed-bid
|
||||
funding with outsider-only commitments. The later negative-answer extension
|
||||
below permits current-turn No bribe commitments while other unresolved
|
||||
group-member commitments remain fenced. The existing committed-bid
|
||||
settlement exception is retained. Other combinations remain unfinished, and no
|
||||
lobby exposes group play.
|
||||
|
||||
## Group funding with negative bribe answers — 29 October 2026
|
||||
|
||||
**Staged Basic and Advanced prototypes, not selectable game modes.** A current
|
||||
No answer to a simple or grouped bribe question remains binding while members
|
||||
pledge and spend group funding on an ordinary bid or physical shipment. Those
|
||||
transactions are not ordinary bribes. Current Yes answers and unreconciled
|
||||
older No answers still fence group funding; unrelated commitment guards and
|
||||
the existing committed-bid exception remain. Group-member recipient controls
|
||||
also exclude all allies, not just a single pair ally. Allocation and group
|
||||
extension are provisional online procedures; other commitments, modules,
|
||||
special transport and complete group play remain unfinished.
|
||||
|
||||
## Group funding with Karama shipment pricing — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** Group members can
|
||||
@@ -243,6 +256,13 @@ 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.
|
||||
|
||||
The later staged Advanced group path also retains each donor's original
|
||||
ordinary-bid payment destination. Under the authorized supplied revised
|
||||
rulebook, physical p. 15 (printed p. 13), Emperor's contribution goes to the
|
||||
bank; a direct Emperor alliance gift is buyer-owned and follows the ordinary
|
||||
recipient. This Advanced cutover does not change the Basic interpretation in
|
||||
the preceding paragraph. Further group interactions remain unfinished.
|
||||
|
||||
## Group ordinary-shipment funding — 28 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** Group allies can set
|
||||
|
||||
@@ -94,6 +94,21 @@ the original native counter/card continuation. Positive Black Market
|
||||
self-bidding, exhausted-cache counts and other special-lot boundaries remain;
|
||||
the supplied source excludes Richese lots from Ixian Technology.
|
||||
|
||||
### Emperor contribution to a Richese lot — 29 October 2026
|
||||
|
||||
The authorized supplied revised rulebook, physical p. 15 (printed p. 13),
|
||||
Bidding, sends Emperor's contribution to an ally's Treachery Card purchase to
|
||||
the Spice Bank. A direct Emperor alliance gift becomes the buyer's own spice
|
||||
and follows the normal purchase recipient. This Advanced cutover also applies
|
||||
to the connected Richese cache and Black Market sale prototypes: the seller
|
||||
receives the buyer-funded portion, while Emperor's contributed portion goes to
|
||||
the bank. If Richese buys or retains its own lot, only the buyer-funded portion
|
||||
goes to Emperor; the contributed portion goes to the bank. Basic retains its
|
||||
earlier payment policy. The original price, donor amounts, and each payment
|
||||
destination remain attached to the pending sale rather than reconstructed from
|
||||
later balances. Broader optional-module combinations and complete Richese play
|
||||
remain unverified.
|
||||
|
||||
## Printed rules, by checkpoint
|
||||
|
||||
The source anchor is R2 pp.4–6,10–12: ten cache cards stay outside the hand; one revealed cache lot replaces a normal lot and is declared first or last before Ixian preparation. It uses Once Around or Silent. Other buyers pay Richese; Richese pays Emperor/bank. Discards enter normal discard. Ordinary Karama acquisition is barred. Once Around gives each other faction one ascending bid/pass in a chosen direction, then Richese's final opportunity. Silent reveals eligible bidders' simultaneous offers, including zero, with storm-order ties. A zero result permits cache keep/remove. Advanced Black Market offers one concealed hand card before Declaration, permits all three methods, allows Atreides inspection and unverified sales claims, pays Richese, and shrinks the normal pool only after a sale; no bid means retain. Direct-cache purchases cannot use Ixian allied replacement; Black Market purchases can, even for Richese-family cards. Harkonnen bonus applies to both. Juice of Sapho works in Once Around. Cancellation prevents cache auction at round start or Black Market sale. The special power buys a privately chosen cache card for three spice. [R2](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FactionId } from './catalog';
|
||||
import type { Game, ResponseWindow } from './engine';
|
||||
import { requireRicheseDeclarationCache } from './richese-settlement';
|
||||
import { quoteNormalAuctionRetirement } from './normal-auction-retirement';
|
||||
import { routeAuctionPayment } from './auction-payment-routing';
|
||||
|
||||
export type AuctionContinuationSeat = {
|
||||
id: string;
|
||||
@@ -97,6 +98,8 @@ export function auctionEmperorIncomeAmount(
|
||||
sale: NonNullable<Game['currentAuctionSale']>,
|
||||
): number {
|
||||
if (sale.free || sale.recipient === 'bank') return 0;
|
||||
if (sale.paymentRouting) return sale.paymentRouting.filter(leg => leg.recipient === 'emperor')
|
||||
.reduce((total, leg) => total + leg.amount, 0);
|
||||
return sale.origin === 'normal' || sale.seller === sale.winner
|
||||
? sale.amount
|
||||
: sale.richeseContribution ?? 0;
|
||||
@@ -322,6 +325,28 @@ function calculate(
|
||||
);
|
||||
const winner = seated(sale.winner)!;
|
||||
requireAuction(winner, 'The auction winner is no longer seated.');
|
||||
if (sale.paymentRouting !== undefined) {
|
||||
const emperor = input.players.find(player => player.faction === 'emperor');
|
||||
requireAuction(input.advanced && !sale.free && Array.isArray(sale.paymentRouting) &&
|
||||
sale.paymentRouting.length > 0 && sale.paymentRouting.every(leg => seated(leg.payer)) &&
|
||||
JSON.stringify(sale.paymentRouting) === JSON.stringify(routeAuctionPayment({
|
||||
advanced: true, free: false, price: sale.amount, buyer: winner.id,
|
||||
emperor: emperor?.id ?? null, seller: sale.seller, bankOnly: sale.recipient === 'bank',
|
||||
contributions: sale.paymentRouting.map(({payer, amount}) => ({payer, amount})),
|
||||
})) && sale.paymentRouting.some(leg => leg.payer === emperor?.id &&
|
||||
leg.payer !== winner.id && leg.recipient === 'bank'),
|
||||
'The paid auction lost its original Advanced Emperor bank contribution.');
|
||||
if (sale.origin === 'normal') {
|
||||
const a = input.auction!;
|
||||
const donors = a.coalitionContributions ?? (a.allyPayment ? [{donor:winner.ally, amount:a.allyPayment}] : []);
|
||||
const expected = donors.map(({donor,amount}) => ({payer:donor, amount}));
|
||||
const own = sale.amount - expected.reduce((sum, leg) => sum + leg.amount, 0);
|
||||
if (own > 0) expected.unshift({payer:winner.id, amount:own});
|
||||
requireAuction(JSON.stringify(sale.paymentRouting.map(({payer,amount}) => ({payer,amount}))) === JSON.stringify(expected),
|
||||
'The paid auction contributions no longer match the original bid.');
|
||||
} else requireAuction(sale.paymentRouting.every(leg => leg.payer === winner.id || leg.payer === winner.ally),
|
||||
'The paid Richese lot lost its original buyer or ally.');
|
||||
}
|
||||
let stage = operation.kind;
|
||||
const steps: AuctionContinuationQuote['steps'] = [];
|
||||
if (operation.kind === 'cancel') {
|
||||
@@ -387,7 +412,9 @@ function calculate(
|
||||
if (!sale.free && sale.origin !== 'normal' && sale.seller !== winner.id) {
|
||||
if (sale.recipient !== 'bank') {
|
||||
const seller = seated(sale.seller!)!;
|
||||
const amount = sale.amount - (sale.richeseContribution ?? 0);
|
||||
const amount = sale.paymentRouting
|
||||
? sale.paymentRouting.filter(leg => leg.recipient === 'seller').reduce((sum, leg) => sum + leg.amount, 0)
|
||||
: sale.amount - (sale.richeseContribution ?? 0);
|
||||
requireAuction(
|
||||
count(seller.spice) && count(seller.spice + amount),
|
||||
'The seller credit would overflow its current balance.',
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Actual positive payer legs of a single paid physical Treachery auction.
|
||||
* The engine supplies already-authorized, funded contributors and owns the
|
||||
* debit, escrow, income responses and any later diversion or cancellation. */
|
||||
export type AuctionPaymentInput = Readonly<{
|
||||
advanced: boolean;
|
||||
free: boolean;
|
||||
price: number;
|
||||
buyer: string;
|
||||
emperor: string | null;
|
||||
/** Null is an ordinary auction; a Richese seller may retain its own card. */
|
||||
seller: string | null;
|
||||
/** Existing seller-income prevention may route the entire invoice to Bank. */
|
||||
bankOnly?: boolean;
|
||||
contributions: readonly Readonly<{ payer: string; amount: number }>[];
|
||||
}>;
|
||||
|
||||
export type AuctionPaymentLeg = {
|
||||
payer: string;
|
||||
amount: number;
|
||||
recipient: 'bank' | 'emperor' | 'seller';
|
||||
};
|
||||
|
||||
export class AuctionPaymentRoutingError extends Error {
|
||||
constructor(message: string) { super(message); this.name = 'AuctionPaymentRoutingError'; }
|
||||
}
|
||||
function check(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new AuctionPaymentRoutingError(message);
|
||||
}
|
||||
const id = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value.trim() === value;
|
||||
const amount = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
|
||||
/**
|
||||
* Route the exact already-funded invoice without touching any wallet.
|
||||
* `seller` identifies Richese, not a generic normal-auction bank seller.
|
||||
* In Advanced, Emperor's direct contribution to another buyer goes to Bank;
|
||||
* Richese's contribution to another buyer of its own card goes to Emperor or
|
||||
* Bank. A direct Emperor gift is part of the buyer's own leg, not aid.
|
||||
*
|
||||
* Payer identities/alliances and escrow authority are checked by the caller;
|
||||
* this helper ensures the invoice has unique, positive, exactly funded legs.
|
||||
* Free acquisitions carry no paid legs, even when their nominal price is > 0.
|
||||
*/
|
||||
export function routeAuctionPayment(input: AuctionPaymentInput): AuctionPaymentLeg[] {
|
||||
check(!!input && typeof input === 'object' && !Array.isArray(input) &&
|
||||
typeof input.advanced === 'boolean' && typeof input.free === 'boolean' &&
|
||||
amount(input.price) && id(input.buyer) &&
|
||||
(input.emperor === null || id(input.emperor)) &&
|
||||
(input.seller === null || id(input.seller)) &&
|
||||
(input.bankOnly === undefined || typeof input.bankOnly === 'boolean') &&
|
||||
Array.isArray(input.contributions), 'Supply one valid auction invoice.');
|
||||
check(input.seller === null || input.seller !== input.emperor,
|
||||
'The Richese seller and Emperor must be distinct factions.');
|
||||
const seen = new Set<string>();
|
||||
let total = 0;
|
||||
for (const leg of input.contributions) {
|
||||
check(!!leg && typeof leg === 'object' && !Array.isArray(leg) && id(leg.payer) &&
|
||||
amount(leg.amount) && leg.amount > 0 && !seen.has(leg.payer),
|
||||
'Every actual payer must have one distinct positive contribution.');
|
||||
seen.add(leg.payer);
|
||||
total += leg.amount;
|
||||
check(Number.isSafeInteger(total), 'The total auction invoice exceeds safe integer bounds.');
|
||||
}
|
||||
if (input.free) {
|
||||
check(input.contributions.length === 0, 'A free acquisition cannot carry paid contributions.');
|
||||
return [];
|
||||
}
|
||||
check(total === input.price, 'Actual auction contributions must exactly fund the price.');
|
||||
const emperorOrBank = (): AuctionPaymentLeg['recipient'] =>
|
||||
input.emperor ? 'emperor' : 'bank';
|
||||
return input.contributions.map(leg => {
|
||||
let recipient: AuctionPaymentLeg['recipient'];
|
||||
if (input.bankOnly) recipient = 'bank';
|
||||
else if (input.advanced && input.emperor && leg.payer === input.emperor && leg.payer !== input.buyer)
|
||||
recipient = 'bank';
|
||||
else if (input.seller === null || input.seller === input.buyer)
|
||||
recipient = input.emperor && input.emperor !== input.buyer ? 'emperor' : 'bank';
|
||||
else if (input.advanced && leg.payer === input.seller)
|
||||
recipient = emperorOrBank();
|
||||
else recipient = 'seller';
|
||||
return {payer:leg.payer, amount:leg.amount, recipient};
|
||||
});
|
||||
}
|
||||
+79
-18
@@ -1,5 +1,6 @@
|
||||
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
|
||||
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
|
||||
import { routeAuctionPayment, type AuctionPaymentLeg } from './auction-payment-routing';
|
||||
import { createFutureBattleLeader, bindFutureBattleLeader, settleFutureBattleLeader, releaseFutureBattleLeader,
|
||||
expireFutureBattleLeader, futureBattleLeaderTiming, futureBattleLeaderWindow, type FutureBattleLeaderReceipt } from './future-battle-leader';
|
||||
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
|
||||
@@ -2098,6 +2099,8 @@ export type Game = {
|
||||
recipient?: 'bank';
|
||||
/** Actual Richese donor share of another buyer's lot; paid to Emperor or bank. */
|
||||
richeseContribution?: number;
|
||||
/** New Advanced invoices preserve each actual destination through responses. */
|
||||
paymentRouting?: AuctionPaymentLeg[];
|
||||
} | null;
|
||||
pendingAmbassador?: {
|
||||
/** Fresh entries retain their actual arrival source; older frames omit it. */
|
||||
@@ -11366,7 +11369,8 @@ function spiceBankerIncomeIntegrity(g: Game): void {
|
||||
const payment = carrier?.spiceBankerIncomePayment;
|
||||
if (!payment) {
|
||||
requireRule(!carrier || ('winner' in carrier ? carrier.free ||
|
||||
(carrier.recipient !== 'bank' && auctionEmperorIncomeAmount(carrier) === 0) :
|
||||
(carrier.recipient !== 'bank' && auctionEmperorIncomeAmount(carrier) === 0 &&
|
||||
!carrier.paymentRouting?.some(leg => leg.recipient === 'bank')) :
|
||||
carrier.kind !== 'guildIncome' &&
|
||||
!(carrier.kind === 'revivalIncome' && carrier.recipient !== carrier.owner && (carrier.amount ?? 0) > 1)),
|
||||
'The original paid transaction lost its native bank invoice.');
|
||||
@@ -11379,6 +11383,13 @@ function spiceBankerIncomeIntegrity(g: Game): void {
|
||||
new Set(payment.legs.map(leg => leg.payer)).size === payment.legs.length,
|
||||
'The original paid bank transaction changed its event or contribution legs.');
|
||||
if (carrier && 'winner' in carrier) {
|
||||
if (carrier.paymentRouting) {
|
||||
const expected = carrier.paymentRouting.filter(leg => leg.recipient !== 'seller')
|
||||
.map(leg => ({payer:leg.payer, amount:leg.amount, recipient:leg.recipient === 'emperor' ? 'player' : 'bank'}));
|
||||
requireRule(payment.kind === 'auction' && !carrier.free && JSON.stringify(payment.legs) === JSON.stringify(expected),
|
||||
'The deferred bank invoice no longer matches its original auction payer legs.');
|
||||
continue;
|
||||
}
|
||||
const amount = carrier.recipient === 'bank' ? carrier.amount : auctionEmperorIncomeAmount(carrier);
|
||||
const contributionOnly = carrier.origin !== 'normal' && carrier.seller !== carrier.winner && carrier.recipient !== 'bank';
|
||||
requireRule(payment.kind === 'auction' && !carrier.free &&
|
||||
@@ -11590,7 +11601,18 @@ function bureaucratPaymentIntegrity(g: Game) {
|
||||
requireRule(g.leaderSkills && bureaucratPaymentModeSupported(g) && carrier.bureaucratPaymentEvent === source.event,
|
||||
'The saved Bureaucrat payment lost its module or independent event.');
|
||||
validateBureaucratSource(g,source);
|
||||
if ('winner' in carrier) requireRule(source.kind === 'auction' && !carrier.free &&
|
||||
if ('winner' in carrier && carrier.paymentRouting) {
|
||||
const sellerIncome = carrier.origin !== 'normal' && carrier.seller !== carrier.winner;
|
||||
const shares = carrier.paymentRouting.filter(leg => leg.recipient === (sellerIncome ? 'seller' : 'emperor'))
|
||||
.map(leg => ({payer:leg.payer,amount:leg.amount}));
|
||||
requireRule(source.kind === 'auction' && !carrier.free &&
|
||||
source.payee === (sellerIncome ? carrier.seller : byFaction(g,'emperor')?.id) &&
|
||||
source.amount === shares.reduce((sum,share) => sum + share.amount,0) &&
|
||||
shares.some(share => share.payer === source.payer) &&
|
||||
(!source.contributions || JSON.stringify(source.contributions) === JSON.stringify(shares)) &&
|
||||
(sellerIncome ? source.richeseSaleAmount === carrier.amount : source.richeseSaleAmount === undefined),
|
||||
'The Bureaucrat source no longer matches its paid auction sale.');
|
||||
} else if ('winner' in carrier) requireRule(source.kind === 'auction' && !carrier.free &&
|
||||
(source.richeseSaleAmount !== undefined
|
||||
? g.advanced && carrier.origin !== 'normal' && carrier.seller !== carrier.winner &&
|
||||
(carrier.richeseContribution ?? 0) > 0 && source.richeseSaleAmount === carrier.amount &&
|
||||
@@ -15414,15 +15436,25 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
|
||||
if (!confirmed && beginNexusEmperorSellerPurchase(g, quote, bank)) return;
|
||||
const lot = g.richeseAuction!, owner = getPlayer(g,lot.owner);
|
||||
const winner = getPlayer(g, quote.winner), card = quote.card;
|
||||
const emperor = byFaction(g, 'emperor');
|
||||
const paymentRouting = !free && g.advanced && emperor && winner.ally === emperor.id && quote.allyPayment > 0
|
||||
? routeAuctionPayment({advanced:true, free:false, price:quote.amount, buyer:winner.id,
|
||||
emperor:emperor.id, seller:owner.id, bankOnly:bank, contributions:[
|
||||
...(quote.ownPayment > 0 ? [{payer:winner.id,amount:quote.ownPayment}] : []),
|
||||
{payer:emperor.id,amount:quote.allyPayment},
|
||||
]}) : undefined;
|
||||
const richeseContribution = !free && g.advanced && winner.id !== owner.id && winner.ally === owner.id
|
||||
? quote.allyPayment : 0;
|
||||
const bankAmount = free ? 0 : bank || winner.id === owner.id ? quote.amount : richeseContribution;
|
||||
const emperor = byFaction(g, 'emperor');
|
||||
const incomePayment = g.spiceBankerIncomePreview && bankAmount > 0 ? bankerIncomePayment(g, 'auction',
|
||||
const routedBankLegs = paymentRouting?.filter(leg => leg.recipient !== 'seller').map(leg => ({
|
||||
payer:leg.payer, amount:leg.amount, recipient:leg.recipient === 'emperor' ? 'player' as const : 'bank' as const,
|
||||
}));
|
||||
const incomePayment = g.spiceBankerIncomePreview && (routedBankLegs?.length || bankAmount > 0) ? bankerIncomePayment(g, 'auction',
|
||||
routedBankLegs ?? (
|
||||
bank || winner.id === owner.id
|
||||
? [{payer:winner.id,amount:quote.ownPayment,recipient:bank || !emperor ? 'bank' : 'player'},
|
||||
...(quote.allyPayment ? [{payer:winner.ally!,amount:quote.allyPayment,recipient:bank || !emperor ? 'bank' as const : 'player' as const}] : [])]
|
||||
: [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}]) : undefined;
|
||||
: [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}])) : undefined;
|
||||
if (!free) payWithAlly(g, winner, quote.amount, quote.allyPayment);
|
||||
g.richeseFunding = {};
|
||||
const retained = lot.source === 'blackMarket' && winner.id === owner.id;
|
||||
@@ -15434,10 +15466,14 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
|
||||
}
|
||||
g.currentAuctionSale = {
|
||||
...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event,
|
||||
occupiedContributions: free ? [] : [{ payer: winner.id, amount: quote.ownPayment },
|
||||
...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}),
|
||||
occupiedContributions: free ? [] : paymentRouting
|
||||
? paymentRouting.filter(leg => leg.recipient === (winner.id === owner.id ? 'emperor' : 'seller'))
|
||||
.map(({payer,amount}) => ({payer,amount}))
|
||||
: [{ payer: winner.id, amount: quote.ownPayment },
|
||||
...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}),
|
||||
...(richeseContribution ? {richeseContribution} : {}),
|
||||
...(incomePayment ? {spiceBankerIncomePayment:incomePayment} : {}),
|
||||
...(paymentRouting ? {paymentRouting} : {}),
|
||||
winner: winner.id,
|
||||
amount: quote.amount,
|
||||
free,
|
||||
@@ -15446,7 +15482,8 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
|
||||
...(bank ? {recipient:'bank' as const} : {}),
|
||||
...(!free ? stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id) : {}),
|
||||
};
|
||||
if (bank || !emperor) awardBankerIncome(g, incomePayment);
|
||||
if (bank || !emperor || paymentRouting && auctionEmperorIncomeAmount(g.currentAuctionSale) === 0)
|
||||
awardBankerIncome(g, incomePayment);
|
||||
log(
|
||||
g,
|
||||
`${winner.name} ${retained ? 'retained their own Black Market card under provisional paid self-purchase' : `bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'}`} for ${quote.amount} spice.${free ? ' Emperor Nexus retained the proven price; no seller or bank payment occurred.' : ' The funded bid was paid automatically.'}`,
|
||||
@@ -16318,9 +16355,16 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
|
||||
if (!group || group.members.length < 3) return false;
|
||||
const groupHasUnsettled = (records: readonly {player: string; fulfilled?: boolean; released?: boolean}[] | undefined) =>
|
||||
!!records?.some(record => group.members.includes(record.player) && !record.fulfilled && !record.released);
|
||||
// A current No bribe answer only constrains later ordinary bribes. Moving
|
||||
// spice into alliance escrow or paying a bid/shipment does not satisfy it.
|
||||
// Positive and stale answers still need their funding preserved separately.
|
||||
const groupHasFundedBribe = (records: readonly {player: string; turn: number; answer: boolean;
|
||||
fulfilled?: boolean; released?: boolean}[] | undefined) => !!records?.some(record =>
|
||||
group.members.includes(record.player) && !record.fulfilled && !record.released &&
|
||||
(record.turn !== g.turn || record.answer));
|
||||
return committedBid || ![g.bidPromises, g.phaseBidPromises, g.turnActionPromises,
|
||||
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
|
||||
g.bribePromises, g.bribeExpressionPromises].some(groupHasUnsettled);
|
||||
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises].some(groupHasUnsettled) &&
|
||||
!groupHasFundedBribe(g.bribePromises) && !groupHasFundedBribe(g.bribeExpressionPromises);
|
||||
}
|
||||
function coalitionEmperorGroup(g: Game, p: Player) {
|
||||
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' ||
|
||||
@@ -17037,9 +17081,11 @@ function payWithAlly(g: Game, p: Player, cost: number, allyPayment: number) {
|
||||
if (credit) credit.amount -= allyPayment;
|
||||
}
|
||||
function auctionBureaucratParties(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) {
|
||||
const split = g.advanced && allyPayment > 0 && seller !== undefined && seller !== p.id && p.ally === seller;
|
||||
const emperorBank = g.advanced && allyPayment > 0 && p.ally === byFaction(g,'emperor')?.id;
|
||||
const split = g.advanced && allyPayment > 0 && seller !== undefined && seller !== p.id &&
|
||||
(p.ally === seller || emperorBank);
|
||||
return {payee:seller && seller !== p.id ? seller : byFaction(g,'emperor')?.id,
|
||||
shares:(split ? [{payer:p.id,amount:amount-allyPayment}]
|
||||
shares:(split || emperorBank ? [{payer:p.id,amount:amount-allyPayment}]
|
||||
: [{payer:p.id,amount:amount-allyPayment},...(allyPayment ? [{payer:p.ally!,amount:allyPayment}] : [])]).filter(share => share.amount > 0),
|
||||
richeseSaleAmount:split ? amount : undefined};
|
||||
}
|
||||
@@ -18085,6 +18131,17 @@ function auctionNext(g: Game) {
|
||||
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
|
||||
const a = g.auction!;
|
||||
const winner = getPlayer(g, a.bidder!);
|
||||
const emperor = byFaction(g, 'emperor');
|
||||
const donors = a.coalitionContributions ?? (a.allyPayment && winner.ally
|
||||
? [{donor:winner.ally, amount:a.allyPayment}] : []);
|
||||
const donorTotal = donors.reduce((sum, leg) => sum + leg.amount, 0);
|
||||
const paymentRouting = !free && g.advanced && emperor &&
|
||||
donors.some(leg => leg.donor === emperor.id && leg.amount > 0)
|
||||
? routeAuctionPayment({advanced:true, free:false, price:a.bid, buyer:winner.id,
|
||||
emperor:emperor.id, seller:null, contributions:[
|
||||
...(a.bid > donorTotal ? [{payer:winner.id, amount:a.bid-donorTotal}] : []),
|
||||
...donors.map(leg => ({payer:leg.donor, amount:leg.amount})),
|
||||
]}) : undefined;
|
||||
if (free) delete a.coalitionContributions;
|
||||
else if (a.coalitionContributions !== undefined) {
|
||||
coalitionBidPaymentQuote(g, winner, a.bid, a.coalitionContributions, true);
|
||||
@@ -18096,18 +18153,21 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
|
||||
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;
|
||||
const incomePayment = !free && g.spiceBankerIncomePreview ? bankerIncomePayment(g, 'auction', [
|
||||
const incomePayment = !free && g.spiceBankerIncomePreview ? bankerIncomePayment(g, 'auction', paymentRouting
|
||||
? paymentRouting.map(leg => ({payer:leg.payer,amount:leg.amount,
|
||||
recipient:leg.recipient === 'emperor' ? 'player' as const : 'bank' as const})) : [
|
||||
{payer: winner.id, amount: a.bid - (a.allyPayment ?? 0), recipient: paysEmperor ? 'player' : 'bank'},
|
||||
...(a.allyPayment ? [{payer: winner.ally!, amount: a.allyPayment,
|
||||
recipient: paysEmperor ? 'player' as const : 'bank' as const}] : []),
|
||||
]) : undefined;
|
||||
g.currentAuctionSale = {
|
||||
...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID(),
|
||||
occupiedContributions: [{ payer: winner.id, amount: free ? 0 : a.bid - (a.allyPayment ?? 0) },
|
||||
...(!free && a.allyPayment ? [{ payer: winner.ally!, amount: a.allyPayment }] : [])] } : {}),
|
||||
occupiedContributions: paymentRouting ? paymentRouting.filter(leg => leg.recipient === 'emperor')
|
||||
.map(({payer,amount}) => ({payer,amount})) : [{ payer: winner.id, amount: free ? 0 : a.bid - (a.allyPayment ?? 0) },
|
||||
...(!free && a.allyPayment ? [{ payer: winner.ally!, amount: a.allyPayment }] : [])] } : {}),
|
||||
...(incomePayment ? {spiceBankerIncomePayment: incomePayment} : {}),
|
||||
...(paymentRouting ? {paymentRouting} : {}),
|
||||
winner: winner.id,
|
||||
amount: a.bid,
|
||||
free,
|
||||
@@ -18115,7 +18175,7 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
|
||||
seller: null,
|
||||
...(!free ? stampAuctionBureaucrat(g,winner,a.bid,a.allyPayment ?? 0) : {}),
|
||||
};
|
||||
if (!paysEmperor) awardBankerIncome(g, incomePayment);
|
||||
if (!paysEmperor || auctionEmperorIncomeAmount(g.currentAuctionSale) === 0) awardBankerIncome(g, incomePayment);
|
||||
log(
|
||||
g,
|
||||
`${faction(winner.faction).name} won a treachery card for ${emperorNexus ? `the Emperor Nexus card after proving ${a.bid} spice` : free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`,
|
||||
@@ -18205,7 +18265,8 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure
|
||||
const sale = g.currentAuctionSale!, received = step.amount - (bureaucratDiversion ?? 0);
|
||||
const source: OccupiedPercentageSource = { kind: 'richese-income', event: crypto.randomUUID(),
|
||||
native: seller.id, amount: received, payment: { id: sale.occupiedEvent!,
|
||||
binding: occupiedProducerProof(g, 'richese-income'), paid: sale.amount,
|
||||
binding: occupiedProducerProof(g, 'richese-income'),
|
||||
paid: sale.paymentRouting ? sale.occupiedContributions!.reduce((sum,leg) => sum + leg.amount,0) : sale.amount,
|
||||
contributions: sale.occupiedContributions!, recipient: seller.id, received } };
|
||||
if (admitOccupiedPercentage(g, source, received, { kind: 'auction', quote: { ...quote, steps: [] } })) return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export const HELP = {
|
||||
'A shield protects against projectiles. A snooper protects against poison. One card cannot occupy both battle slots.',
|
||||
response:
|
||||
'The table pauses before this power resolves. Other factions can use a legal Karama cancellation; everyone can allow the power.',
|
||||
ally: 'Set aside spice for an ally’s bids or shipments. You may reclaim unused funds, but a current winning bid must remain funded.',
|
||||
ally: 'Set aside spice for an ally’s bids or shipments. You may reclaim unused funds, but a current winning bid must remain funded. In Advanced auctions, the Emperor’s direct contribution to an ally’s purchase goes to the bank; a gift already given to the buyer is the buyer’s own payment.',
|
||||
prescience:
|
||||
'Only the requested element becomes fixed. Other plan elements stay private until reveal. Low Salusa prohibits free Sardaukar revival, including Ghola; other legal Ghola choices remain available.',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => { const result = game.players.find(player => player.id === id); assert.ok(result); return result; };
|
||||
|
||||
/** Original public H/A/E setup and real second-turn Nexus; only the optional
|
||||
* empty group ledger is staged. Test wallets and the cancellation card swap
|
||||
* below are explicitly controlled, not claimed native earnings. */
|
||||
function auction(advanced: boolean, group: boolean): Game {
|
||||
const source = createTruthAllianceFixture({ advanced, consent: 'none' });
|
||||
let game = structuredClone(source.game);
|
||||
if (group) game.coalitionState = emptyCoalitionState('three-short');
|
||||
game = act(game, 'harkonnen', { type: 'alliance', target: 'emperor' });
|
||||
game = act(game, 'emperor', { type: 'alliance', target: 'harkonnen' });
|
||||
if (group) {
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'harkonnen'); assert.ok(pair);
|
||||
game = act(game, 'harkonnen', { type: 'coalitionPropose', groupId: pair.id,
|
||||
entrant: 'atreides', expectedRevision: game.coalitionState!.revision });
|
||||
for (const id of ['emperor', 'atreides']) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
game = advanceTruthAlliance(game, state => state.phase === 3 && !!state.auction && truthAllianceClean(state));
|
||||
for (let count = 0; game.auction && game.auction.active !== 'harkonnen' && count < game.players.length; count++)
|
||||
game = act(game, game.auction.active, { type: 'passBid' });
|
||||
assert.equal(game.auction?.active, 'harkonnen');
|
||||
// Set only the wallets needed to observe the original physical bid and payer
|
||||
// legs; no auction, card, seat, alliance, or pending receipt is installed.
|
||||
seat(game, 'harkonnen').spice = 2;
|
||||
seat(game, 'emperor').spice = 10;
|
||||
seat(game, 'atreides').spice = 10;
|
||||
return game;
|
||||
}
|
||||
|
||||
function passResponses(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 20; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Reach native paid sale, leaving any resulting Emperor-income response for
|
||||
* the caller. Do not invent the special purchase or repeat an invoice. */
|
||||
function bid(game: Game, action: Action): Game {
|
||||
game = act(game, 'harkonnen', action);
|
||||
for (let count = 0; !game.response && !game.decision && game.auction?.bidder === 'harkonnen' && count < 12; count++)
|
||||
game = act(game, game.auction.active, { type: 'passBid' });
|
||||
if (game.decision?.kind === 'auctionPayment') game = act(game, 'harkonnen', { type: 'decision', karama: false });
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const amount of [2, 6]) {
|
||||
void test(`Advanced ordinary pair routes the Emperor's ${amount}-spice contribution to the bank`, () => {
|
||||
let game = auction(true, false);
|
||||
const hand = seat(game, 'harkonnen').hand.length;
|
||||
seat(game, 'harkonnen').spice = amount === 2 ? 4 : 0;
|
||||
game = act(game, 'emperor', { type: 'pledgeAid', amount });
|
||||
game = bid(game, { type: 'bid', amount: 6, allyPayment: amount });
|
||||
game = passResponses(game);
|
||||
assert.ok(seat(game, 'harkonnen').hand.length > hand);
|
||||
assert.equal(seat(game, 'harkonnen').spice, 0);
|
||||
assert.equal(seat(game, 'emperor').spice, 10 - amount + (6 - amount));
|
||||
});
|
||||
}
|
||||
|
||||
for (const amount of [2, 6]) {
|
||||
void test(`Advanced three-member bid routes the Emperor's ${amount}-spice share to the bank`, () => {
|
||||
let game = auction(true, true);
|
||||
seat(game, 'harkonnen').spice = amount === 6 ? 0 : 1;
|
||||
game = act(game, 'emperor', { type: 'coalitionPledge', recipient: 'harkonnen', amount });
|
||||
if (amount === 2) game = act(game, 'atreides', { type: 'coalitionPledge', recipient: 'harkonnen', amount: 3 });
|
||||
game = bid(game, { type: 'bid', amount: 6, allyPayment: 0, coalitionContributions: amount === 6
|
||||
? [{ donor: 'emperor', amount: 6 }]
|
||||
: [{ donor: 'emperor', amount: 2 }, { donor: 'atreides', amount: 3 }] });
|
||||
game = passResponses(game);
|
||||
assert.equal(seat(game, 'harkonnen').spice, 0);
|
||||
assert.equal(seat(game, 'emperor').spice, 10 - amount + (6 - amount));
|
||||
assert.equal(seat(game, 'atreides').spice, amount === 2 ? 7 : 10);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced direct Emperor gift belongs to the buyer and is not treated as an Emperor contribution', () => {
|
||||
let game = auction(true, false);
|
||||
game = act(game, 'emperor', { type: 'emperorGift', amount: 2 });
|
||||
game = passResponses(game);
|
||||
assert.equal(seat(game, 'emperor').spice, 8);
|
||||
assert.equal(seat(game, 'harkonnen').spice, 4);
|
||||
game = bid(game, { type: 'bid', amount: 4, allyPayment: 0 });
|
||||
game = passResponses(game);
|
||||
assert.equal(seat(game, 'harkonnen').spice, 0);
|
||||
assert.equal(seat(game, 'emperor').spice, 12);
|
||||
});
|
||||
|
||||
void test('Basic keeps the earlier full-Emperor-income handling for the same ordinary pair bid', () => {
|
||||
let game = auction(false, false);
|
||||
seat(game, 'harkonnen').spice = 4;
|
||||
game = act(game, 'emperor', { type: 'pledgeAid', amount: 2 });
|
||||
game = bid(game, { type: 'bid', amount: 6, allyPayment: 2 });
|
||||
game = passResponses(game);
|
||||
assert.equal(seat(game, 'harkonnen').spice, 0);
|
||||
assert.equal(seat(game, 'emperor').spice, 14);
|
||||
});
|
||||
|
||||
void test('canceling the remaining Advanced Emperor income keeps both payer debits and the purchased card', () => {
|
||||
let game = auction(true, false);
|
||||
seat(game, 'harkonnen').spice = 4;
|
||||
// Controlled conservation supplement: swap one original dealt Harkonnen
|
||||
// card for an original unauctioned Karama, with no card duplicated.
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [karama] = game.deck.splice(index, 1), [swapped] = seat(game, 'harkonnen').hand.splice(0, 1);
|
||||
seat(game, 'harkonnen').hand.push(karama); game.deck.push(swapped);
|
||||
const hand = seat(game, 'harkonnen').hand.length;
|
||||
game = act(game, 'emperor', { type: 'pledgeAid', amount: 2 });
|
||||
game = bid(game, { type: 'bid', amount: 6, allyPayment: 2 });
|
||||
assert.equal(game.response?.kind, 'emperorIncome');
|
||||
game = act(game, 'harkonnen', { type: 'card', mode: 'cancel', card: karama.id });
|
||||
game = passResponses(game);
|
||||
assert.equal(seat(game, 'harkonnen').spice, 0);
|
||||
assert.equal(seat(game, 'emperor').spice, 8);
|
||||
assert.ok(seat(game, 'harkonnen').hand.length >= hand);
|
||||
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
allowE3Responses, chooseBureaucratE3Payment, createBureaucratE3AuctionFixture,
|
||||
payBureaucratE3Auction,
|
||||
} from './fixture-bureaucrat-e3-funding';
|
||||
import { createSpiceBankerIncomeFixture } from './fixture-spice-banker-income';
|
||||
import { freshBiddingGame, biddingPlayer } from './fixture-homeworld-occupied-bidding';
|
||||
import { qualifyDefensePosition } from './fixture-homeworld-occupied-defenses';
|
||||
import { advanceOriginal } from './fixture-homeworld-occupied-producers';
|
||||
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
|
||||
void test('Advanced Emperor ally bank share does not inflate a Bureaucrat diversion from the buyer-funded income', () => {
|
||||
// Real E3 setup, original Bureaucrat assignment and turn-two alliance;
|
||||
// the fixture uses the original wallets and physical card.
|
||||
const f = createBureaucratE3AuctionFixture({ advanced: true, donor: 'emperor', payerShare: 5, donorShare: 3 });
|
||||
const paid = payBureaucratE3Auction(f);
|
||||
assert.deepEqual(paid.currentAuctionSale?.paymentRouting, [
|
||||
{ payer: f.payer, amount: 5, recipient: 'emperor' },
|
||||
{ payer: f.donor, amount: 3, recipient: 'bank' },
|
||||
]);
|
||||
const pending = allowE3Responses(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.decision?.player, f.trainer);
|
||||
const done = allowE3Responses(chooseBureaucratE3Payment(pending, true));
|
||||
assert.equal(seat(done, f.payer).spice, seat(f.beforePledge, f.payer).spice - 5);
|
||||
assert.equal(seat(done, f.donor).spice, seat(f.beforePledge, f.donor).spice - 3 + 3);
|
||||
assert.equal(done.bureaucratPayments?.used.length, 1);
|
||||
assert.ok(seat(done, f.payer).hand.some(card => card.id === f.card));
|
||||
});
|
||||
|
||||
void test('Advanced Emperor ally bank share creates a Spice Banker grant from the actual bank leg', () => {
|
||||
// Real Skills setup and native bidding; the existing fixture stages the
|
||||
// reciprocal alliance and its original aid before the physical purchase.
|
||||
const f = createSpiceBankerIncomeFixture({ advanced: true, kind: 'auction',
|
||||
emperor: true, amount: 6, allySplit: 4 });
|
||||
const emperor = f.game.players.find(player => player.faction === 'emperor')!;
|
||||
let game = applyAction(f.beforePayment, f.actor, f.paymentAction);
|
||||
game = allowE3Responses(game);
|
||||
const sources = game.spiceBankerIncome!.sources.slice(f.beforePayment.spiceBankerIncome!.sources.length);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.deepEqual(sources[0].source.bankLegs, [{ payer: emperor.id, amount: 4 }]);
|
||||
assert.equal(sources[0].grant?.owner, f.owner);
|
||||
assert.equal(seat(game, emperor.id).spice, seat(f.beforePayment, emperor.id).spice + 2);
|
||||
assert.equal(seat(game, f.payer).spice, seat(f.beforePayment, f.payer).spice - 2);
|
||||
});
|
||||
|
||||
void test('occupied Kaitain allocates only the buyer-funded Emperor income from an Advanced split bid', () => {
|
||||
let game = freshBiddingGame('emperor', 'harkonnen');
|
||||
// Existing physical occupation fixture stages Kaitain's qualifying position;
|
||||
// this test also stages the pair and its wallets, then uses native actions.
|
||||
qualifyDefensePosition(game, 'homeworld:emperor');
|
||||
biddingPlayer(game, 'ally').ally = 'native';
|
||||
biddingPlayer(game, 'native').ally = 'ally';
|
||||
game = advanceOriginal(game, state => state.phase === 3 && !!state.auction && !state.response && !state.decision);
|
||||
for (let n = 0; game.auction?.active !== 'ally' && n < game.players.length; n++)
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
assert.equal(game.auction?.active, 'ally');
|
||||
biddingPlayer(game, 'ally').spice = 4;
|
||||
biddingPlayer(game, 'native').spice = 10;
|
||||
const occupierBefore = biddingPlayer(game, 'occupier').spice;
|
||||
game = applyAction(game, 'native', { type: 'pledgeAid', amount: 2 });
|
||||
game = applyAction(game, 'ally', { type: 'bid', amount: 6, allyPayment: 2 });
|
||||
for (let n = 0; !game.currentAuctionSale && n < game.players.length + 2; n++) {
|
||||
if (game.decision?.kind === 'auctionPayment')
|
||||
game = applyAction(game, 'ally', { type: 'decision', karama: false });
|
||||
else game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
}
|
||||
game = advanceOriginal(game, state => state.decision?.kind === 'homeworldOccupiedPercentage');
|
||||
const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!;
|
||||
assert.equal(offer.amount, 2);
|
||||
const receipt = game.homeworldOccupiedPercentageLedger!.receipts.at(-1)!;
|
||||
assert.equal(receipt.source.amount, 4);
|
||||
game = applyAction(game, 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 });
|
||||
assert.equal(seat(game, 'native').spice, 10);
|
||||
assert.equal(seat(game, 'ally').spice, 0);
|
||||
assert.equal(seat(game, 'occupier').spice, occupierBefore + 2);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import { createTruthtranceRichesePromisesFixture, advanceTruthtranceRichesePromises as advance,
|
||||
actTruthtranceRichesePromises as act, truthtranceRichesePromisesInventory as inventory } from './fixture-truthtrance-richese-promises';
|
||||
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
|
||||
function choose(game: Game, fields: Record<string, unknown>): Game {
|
||||
assert.ok(game.decision && game.richeseBidding);
|
||||
return act(game, game.decision.player, { type: 'decision', event: game.richeseBidding.event, ...fields } as Action);
|
||||
}
|
||||
|
||||
function responses(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 20; count++) {
|
||||
const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Native Advanced public Richese/Atreides/Emperor setup and original card
|
||||
* chronology. The pair is explicitly staged at Charity; wallets are staged only
|
||||
* once the native silent lot is open. No lot, payment or held card is created. */
|
||||
function lot(source: 'cache' | 'blackMarket', buyer: 'atreides' | 'richese') {
|
||||
const f = createTruthtranceRichesePromisesFixture({ advanced: true });
|
||||
let game = structuredClone(f.game);
|
||||
seat(game, buyer).ally = 'emperor'; seat(game, 'emperor').ally = buyer;
|
||||
seat(game, buyer).allySinceTurn = game.turn; seat(game, 'emperor').allySinceTurn = game.turn;
|
||||
game = advance(game, state => state.decision?.kind === 'richeseBlackMarket');
|
||||
if (source === 'blackMarket') {
|
||||
const card = seat(game, 'richese').hand[0]; assert.ok(card);
|
||||
game = choose(game, { card: card.id, method: 'silent' });
|
||||
} else {
|
||||
game = choose(game, { decline: true });
|
||||
game = advance(game, state => state.decision?.kind === 'richeseDeclaration');
|
||||
game = choose(game, { position: 'first' });
|
||||
game = responses(game);
|
||||
game = advance(game, state => state.decision?.kind === 'richeseCache');
|
||||
const card = game.richeseCache?.[0]; assert.ok(card);
|
||||
game = choose(game, { card: card.id, method: 'silent' });
|
||||
}
|
||||
game = responses(game);
|
||||
assert.equal(game.richeseAuction?.source, source);
|
||||
assert.equal(game.richeseAuction?.method, 'silent');
|
||||
seat(game, buyer).spice = 10;
|
||||
seat(game, 'emperor').spice = 10;
|
||||
if (buyer !== 'richese') seat(game, 'richese').spice = 10;
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return { game, buyer, card: game.richeseAuction!.cardId, originalCards: f.originalCards };
|
||||
}
|
||||
|
||||
function purchase(f: ReturnType<typeof lot>): Game {
|
||||
let game = act(f.game, 'emperor', { type: 'pledgeAid', amount: 2 });
|
||||
const event = game.richeseAuction!.event, order = [...game.richeseAuction!.order];
|
||||
for (const id of order)
|
||||
game = act(game, id, { type: 'richeseBid', event, amount: id === f.buyer ? 6 : 0,
|
||||
allyPayment: id === f.buyer ? 2 : 0 });
|
||||
return responses(game);
|
||||
}
|
||||
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
void test(`Advanced ${source} sends an Emperor ally's contribution to bank, not Richese`, () => {
|
||||
const f = lot(source, 'atreides');
|
||||
const game = purchase(f);
|
||||
assert.equal(seat(game, 'atreides').spice, 6);
|
||||
assert.equal(seat(game, 'emperor').spice, 8);
|
||||
assert.equal(seat(game, 'richese').spice, 14, 'Seller receives only the buyer-funded four spice.');
|
||||
assert.equal(seat(game, 'atreides').hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test(`Advanced ${source} paid Richese self-purchase sends only its own share to Emperor`, () => {
|
||||
const f = lot(source, 'richese');
|
||||
const game = purchase(f);
|
||||
assert.equal(seat(game, 'richese').spice, 6);
|
||||
assert.equal(seat(game, 'emperor').spice, 12, 'Emperor gets four from Richese; its own two goes to bank.');
|
||||
assert.equal(seat(game, 'richese').hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { AuctionPaymentRoutingError, routeAuctionPayment, type AuctionPaymentInput } from '../game/auction-payment-routing';
|
||||
|
||||
const leg = (payer: string, amount: number, recipient: 'bank' | 'emperor' | 'seller') =>
|
||||
({payer, amount, recipient});
|
||||
function invoice(patch: Partial<AuctionPaymentInput> = {}): AuctionPaymentInput {
|
||||
return {advanced:true, free:false, price:9, buyer:'buyer', emperor:'emperor', seller:null,
|
||||
contributions:[{payer:'buyer',amount:3}, {payer:'emperor',amount:4}, {payer:'ally',amount:2}], ...patch};
|
||||
}
|
||||
|
||||
void test('Advanced ordinary auction routes Emperor aid to Bank and other actual payer legs to Emperor', () => {
|
||||
const source = invoice(), before = structuredClone(source);
|
||||
assert.deepEqual(routeAuctionPayment(source), [
|
||||
leg('buyer',3,'emperor'), leg('emperor',4,'bank'), leg('ally',2,'emperor'),
|
||||
]);
|
||||
assert.deepEqual(source, before);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({advanced:false})), [
|
||||
leg('buyer',3,'emperor'), leg('emperor',4,'emperor'), leg('ally',2,'emperor'),
|
||||
], 'Basic retains the preexisting aggregate income routing.');
|
||||
});
|
||||
|
||||
void test('a direct gift is buyer-owned funding and does not become Emperor aid', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({price:7, contributions:[
|
||||
{payer:'buyer',amount:5}, {payer:'ally',amount:2},
|
||||
]})), [leg('buyer',5,'emperor'), leg('ally',2,'emperor')]);
|
||||
});
|
||||
|
||||
void test('an Emperor buyer or an absent Emperor sends a normal invoice to Bank', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'emperor',price:6,contributions:[
|
||||
{payer:'emperor',amount:4}, {payer:'ally',amount:2},
|
||||
]})), [leg('emperor',4,'bank'), leg('ally',2,'bank')]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({emperor:null,price:5,contributions:[
|
||||
{payer:'buyer',amount:3}, {payer:'ally',amount:2},
|
||||
]})), [leg('buyer',3,'bank'), leg('ally',2,'bank')]);
|
||||
});
|
||||
|
||||
void test('Richese seller, Emperor and other donors retain distinct Advanced special-auction destinations', () => {
|
||||
const contributions = [{payer:'buyer',amount:3}, {payer:'emperor',amount:2},
|
||||
{payer:'richese',amount:2}, {payer:'ally',amount:1}];
|
||||
assert.deepEqual(routeAuctionPayment(invoice({seller:'richese',price:8,contributions})), [
|
||||
leg('buyer',3,'seller'), leg('emperor',2,'bank'), leg('richese',2,'emperor'), leg('ally',1,'seller'),
|
||||
]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({advanced:false,seller:'richese',price:8,contributions})), [
|
||||
leg('buyer',3,'seller'), leg('emperor',2,'seller'), leg('richese',2,'seller'), leg('ally',1,'seller'),
|
||||
]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({seller:'richese',emperor:null,price:5,contributions:[
|
||||
{payer:'buyer',amount:3}, {payer:'richese',amount:2},
|
||||
]})), [leg('buyer',3,'seller'), leg('richese',2,'bank')]);
|
||||
});
|
||||
|
||||
void test('Emperor buying a Richese card receives Richese aid, while buyer payment goes to the seller', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'emperor',seller:'richese',price:5,contributions:[
|
||||
{payer:'emperor',amount:3}, {payer:'richese',amount:2},
|
||||
]})), [leg('emperor',3,'seller'), leg('richese',2,'emperor')]);
|
||||
});
|
||||
|
||||
void test('Richese paid self-retention routes its own price normally and Emperor aid to Bank', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'richese',seller:'richese',price:7,contributions:[
|
||||
{payer:'richese',amount:3}, {payer:'emperor',amount:4},
|
||||
]})), [leg('richese',3,'emperor'), leg('emperor',4,'bank')]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'richese',seller:'richese',emperor:null,price:3,
|
||||
contributions:[{payer:'richese',amount:3}]})), [leg('richese',3,'bank')]);
|
||||
});
|
||||
|
||||
void test('a Bank-only override reroutes every leg and a free purchase carries no paid legs', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({seller:'richese',bankOnly:true})), [
|
||||
leg('buyer',3,'bank'), leg('emperor',4,'bank'), leg('ally',2,'bank'),
|
||||
]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({free:true,contributions:[]})), []);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({free:false,price:0,contributions:[]})), []);
|
||||
assert.throws(() => routeAuctionPayment(invoice({free:true})), AuctionPaymentRoutingError);
|
||||
});
|
||||
|
||||
void test('the physical invoice rejects duplicates, phantom legs, unfunded costs and unsafe sums', () => {
|
||||
for (const patch of [
|
||||
{contributions:[{payer:'buyer',amount:0}, {payer:'ally',amount:9}]},
|
||||
{contributions:[{payer:'buyer',amount:4}, {payer:'buyer',amount:5}]},
|
||||
{contributions:[{payer:'buyer',amount:8}]},
|
||||
{contributions:[{payer:'buyer',amount:10}]},
|
||||
{contributions:[{payer:'',amount:9}]},
|
||||
{price:-1}, {price:1.5}, {price:Number.MAX_SAFE_INTEGER + 1},
|
||||
{seller:'emperor'}, {bankOnly:'yes'},
|
||||
{price:Number.MAX_SAFE_INTEGER, contributions:[{payer:'buyer',amount:Number.MAX_SAFE_INTEGER},
|
||||
{payer:'ally',amount:1}]},
|
||||
]) assert.throws(() => routeAuctionPayment(invoice(patch as Partial<AuctionPaymentInput>)), AuctionPaymentRoutingError);
|
||||
});
|
||||
@@ -41,8 +41,9 @@ function assertAuctionCosts(f: BureaucratE1AuctionFixture, game: Game): void {
|
||||
function assertAuctionOutcome(f: BureaucratE1AuctionFixture, done: Game, diverted: number): void {
|
||||
assertAuctionCosts(f, done);
|
||||
const donorLeg = f.donor === f.payee ? f.donorShare : 0;
|
||||
const income = f.amount - (f.beforeAction.advanced ? donorLeg : 0) - diverted;
|
||||
assert.equal(seat(done, f.payee).spice,
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + f.amount - diverted);
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + income);
|
||||
assert.deepEqual(hand(done, f.payer),
|
||||
[...hand(f.beforeAction, f.payer), f.card, f.beforeAction.deck[0].id]);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
|
||||
@@ -35,10 +35,11 @@ function assertAuctionCosts(f: BureaucratE3AuctionFixture, game: Game): void {
|
||||
function assertAuctionOutcome(f: BureaucratE3AuctionFixture, done: Game, diverted: number): void {
|
||||
assertAuctionCosts(f, done);
|
||||
const donorLeg = f.donor === f.payee ? f.donorShare : 0;
|
||||
const income = f.amount - (f.beforeAction.advanced ? donorLeg : 0) - diverted;
|
||||
assert.equal(seat(done, f.payee).spice,
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + f.amount - diverted);
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + income);
|
||||
assert.equal(seat(done, f.donor).spice,
|
||||
seat(f.beforePledge, f.donor).spice - f.donorShare + (f.donor === f.payee ? f.amount - diverted : 0));
|
||||
seat(f.beforePledge, f.donor).spice - f.donorShare + (f.donor === f.payee ? income : 0));
|
||||
assert.deepEqual(hand(done, f.payer), [...hand(f.beforeAction, f.payer), f.card]);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0);
|
||||
|
||||
@@ -74,10 +74,10 @@ void test('Advanced group bid settles both donors once and pays the Emperor', ()
|
||||
assert.ok(seat(game, payer).hand.length > hand);
|
||||
assert.equal(seat(game, payer).spice, 1);
|
||||
assert.equal(seat(game, donor1).spice, 6);
|
||||
assert.equal(seat(game, donor2).spice, 13, 'Emperor receives the six-spice winning price.');
|
||||
assert.equal(seat(game, donor2).spice, 11, 'The Emperor donor pays two to the bank and receives the other four.');
|
||||
assert.equal(remaining(game, donor1, payer), 1);
|
||||
assert.equal(remaining(game, donor2, payer), 1);
|
||||
assert.equal(allSpice(game), beforeSpice);
|
||||
assert.equal(allSpice(game), beforeSpice - 2, 'The Emperor donor’s own contribution goes to the bank.');
|
||||
});
|
||||
|
||||
void test('Advanced group shipment spends the actual donor pledges and bank invoice', () => {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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 act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
const escrow = (game: Game, donor: string) => game.coalitionEscrow!.pledges
|
||||
.find(row => row.donor === donor && row.recipient === 'a')?.amount ?? 0;
|
||||
const spice = (game: Game) => game.players.reduce((sum, player) => sum + player.spice, 0) +
|
||||
(game.coalitionEscrow?.pledges.reduce((sum, pledge) => sum + pledge.amount, 0) ?? 0);
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function pass(game: Game): Game {
|
||||
for (let i = 0; game.response && i < 20; i++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(actor);
|
||||
game = act(game, actor.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Group mode is unexposed: stage only its fresh Nexus ledger, the later
|
||||
* phase, wallets and card custody. Group formation, physical Truthtrance
|
||||
* declarations and answers, escrow and the native payment actions are real. */
|
||||
function source(advanced: boolean, phase: 'bid' | 'ship'): Game {
|
||||
let game = createGame('GROUPNEGATIVEBRIBE', newPlayer('a', 'Atreides', 'atreides'), advanced);
|
||||
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(), response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState('unlimited');
|
||||
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 actor of ['b','c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = act(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
for (const player of game.players) {
|
||||
player.spice = 10; player.hand = []; player.forces = {}; player.reserves = 20;
|
||||
player.tanks = 0; player.traitors = []; player.traitorChoices = [];
|
||||
player.shipped = false; player.moved = 0;
|
||||
}
|
||||
seat(game, 'a').spice = 2;
|
||||
const truth = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(truth >= 0);
|
||||
seat(game, 'd').hand.push(game.deck.splice(truth, 1)[0]);
|
||||
// A real printed Karama keeps the bidder's native invoice choice open.
|
||||
const karama = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karama >= 0);
|
||||
seat(game, 'a').hand.push(game.deck.splice(karama, 1)[0]);
|
||||
if (phase === 'bid') {
|
||||
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');
|
||||
} else Object.assign(game, {phase:5, nexus:false, active:'a', ready:[],
|
||||
movementRemaining:['a','b','c','d'], guildTimingGranted:false, guildTimingLocked:false});
|
||||
return game;
|
||||
}
|
||||
|
||||
function answer(game: Game, kind: 'bribe' | 'bribeExpression', reply: 'yes' | 'no'): Game {
|
||||
const card = seat(game, 'd').hand.find(row => row.effect === 'truthtrance'); assert.ok(card);
|
||||
game = act(game, 'd', {type:'card', card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, {type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
const question = kind === 'bribe'
|
||||
? {kind, target:'b', turn:game.turn, recipient:'d', minimum:1}
|
||||
: {kind, target:'b', turn:game.turn, claim:{kind:'or', terms:[
|
||||
{kind:'payment', recipient:'d', minimum:1}, {kind:'payment', recipient:'d', minimum:2},
|
||||
]}};
|
||||
game = act(game, 'd', {type:'truthAsk', question});
|
||||
game = act(game, 'b', {type:'truthAnswer', answer:reply});
|
||||
assert.equal(game.truthtrance, null);
|
||||
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 shares = [{donor:'b', amount:3}, {donor:'c', amount:2}];
|
||||
function settleBid(game: Game): Game {
|
||||
for (let count = 0; !game.response && !game.decision && count < 15; count++) {
|
||||
assert.ok(game.auction?.active);
|
||||
game = act(game, game.auction.active, {type:'passBid'});
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'auctionPayment');
|
||||
game = act(game, 'a', {type:'decision', karama:false});
|
||||
return pass(game);
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) for (const kind of ['bribe','bribeExpression'] as const)
|
||||
for (const phase of ['bid','ship'] as const)
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${phase} preserves donor's No ${kind} through group funding`, () => {
|
||||
let game = answer(source(advanced, phase), kind, 'no');
|
||||
const original = spice(game);
|
||||
const ledger = kind === 'bribe' ? game.bribePromises! : game.bribeExpressionPromises!;
|
||||
assert.ok(ledger.some(row => row.player === 'b' && !row.answer && !row.fulfilled && !row.released));
|
||||
assert.ok(viewGame(game, 'a').coalitionFunding);
|
||||
game = pledge(game);
|
||||
assert.equal(seat(game, 'b').spice, 6);
|
||||
assert.equal(seat(game, 'c').spice, 7);
|
||||
if (phase === 'bid') {
|
||||
game = act(game, 'a', {type:'bid', amount:6, allyPayment:0, coalitionContributions:shares});
|
||||
assert.deepEqual(game.auction?.coalitionContributions, shares);
|
||||
game = settleBid(game);
|
||||
} else {
|
||||
game = act(game, 'a', {type:'ship', territory:'imperial_basin', sector:9, amount:3,
|
||||
elite:0, allyPayment:0, coalitionContributions:shares});
|
||||
game = pass(game);
|
||||
if (game.decision?.kind === 'guildShipment') {
|
||||
game = act(game, game.decision.player, {type:'decision', allow:true});
|
||||
game = pass(game);
|
||||
}
|
||||
assert.equal(seat(game, 'a').forces['imperial_basin:9'], 3);
|
||||
}
|
||||
assert.equal(escrow(game, 'b'), 1);
|
||||
assert.equal(escrow(game, 'c'), 1);
|
||||
assert.equal(spice(game), original - (advanced && phase === 'bid' ? 3 : 0),
|
||||
'The group shares and native recipient are paid exactly once, including the Advanced Emperor bank leg.');
|
||||
const after = kind === 'bribe' ? game.bribePromises! : game.bribeExpressionPromises!;
|
||||
assert.ok(after.some(row => row.player === 'b' && !row.answer && !row.fulfilled && !row.released));
|
||||
reject(game, 'b', {type:'bribe', target:'d', amount:1});
|
||||
});
|
||||
|
||||
for (const advanced of [false,true]) for (const kind of ['bribe','bribeExpression'] as const)
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} positive ${kind} remains fenced from group funding`, () => {
|
||||
const game = answer(source(advanced, 'ship'), kind, 'yes');
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
reject(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4});
|
||||
});
|
||||
|
||||
void test('an unreconciled previous-turn No bribe answer retains the existing funding fence', () => {
|
||||
const game = source(false, 'ship');
|
||||
game.bribePromises = [{event:'staged-old-answer', player:'b', asker:'d', turn:game.turn - 1,
|
||||
recipient:'d', minimum:1, answer:false, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
reject(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4});
|
||||
});
|
||||
@@ -129,14 +129,14 @@ void test('an unreconciled prior-turn commitment blocks group shipment funding',
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('a live No bribe-expression commitment blocks group shipment funding', () => {
|
||||
void test('a live No bribe-expression commitment permits group shipment funding', () => {
|
||||
const game = fixture();
|
||||
game.bribeExpressionPromises = [{event:'live-expression-no', player:'b', asker:'a',
|
||||
turn:game.turn, claim:{kind:'payment', recipient:'d', minimum:1}, answer:false, progress:[]}];
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
|
||||
assert.deepEqual(game, before);
|
||||
assert.ok(viewGame(game, 'a').coalitionFunding);
|
||||
const pledged = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4});
|
||||
assert.equal(remaining(pledged, 'b'), 4);
|
||||
assert.deepEqual(pledged.bribeExpressionPromises, game.bribeExpressionPromises);
|
||||
});
|
||||
|
||||
void test('a current unsupported Truthtrance commitment still blocks group shipment funding', () => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||||
|
||||
const payment = (recipient: string): MixedActionExpression<TurnActionLeaf> =>
|
||||
({kind:'action', action:{kind:'payment', recipient, minimum:1}});
|
||||
|
||||
/** Controlled quiet Nexus and preview ledger, with native pair and triple
|
||||
* formation. The editor consumes the resulting public projection. */
|
||||
function projected(triple: boolean): GameView {
|
||||
let game = createGame('TURNPAYGROUP', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
|
||||
newPlayer('d', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d'], ready:[]});
|
||||
game.coalitionState = emptyCoalitionState('three-short');
|
||||
game = applyAction(game, 'a', {type:'alliance', target:'b'});
|
||||
game = applyAction(game, 'b', {type:'alliance', target:'a'});
|
||||
if (triple) {
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const actor of ['b','c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = applyAction(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
}
|
||||
return viewGame(game, 'd');
|
||||
}
|
||||
|
||||
function html(game: GameView, recipient: string) {
|
||||
return renderToStaticMarkup(createElement(TurnActionFields, {
|
||||
game, target:'a', value:payment(recipient), disabled:false, onChange() {},
|
||||
}));
|
||||
}
|
||||
|
||||
void test('whole-turn payment editor excludes every group ally and accepts an outsider', () => {
|
||||
const game = projected(true);
|
||||
assert.equal(game.players.find(player => player.id === 'a')!.ally, null,
|
||||
'Triple membership must come from the projected coalition ledger.');
|
||||
for (const id of ['a','b','c']) {
|
||||
assert.match(turnActionInputError(payment(id), game, 'a') ?? '', /non-allied recipient/);
|
||||
assert.doesNotMatch(html(game, 'd'), new RegExp(`<option value="${id}"`));
|
||||
}
|
||||
assert.equal(turnActionInputError(payment('d'), game, 'a'), null);
|
||||
assert.match(html(game, 'd'), /<option value="d" selected="">Harkonnen<\/option>/);
|
||||
});
|
||||
|
||||
void test('whole-turn payment editor preserves reciprocal pairs and unallied choices', () => {
|
||||
const game = projected(false);
|
||||
assert.match(turnActionInputError(payment('b'), game, 'a') ?? '', /non-allied recipient/);
|
||||
for (const id of ['c','d']) assert.equal(turnActionInputError(payment(id), game, 'a'), null);
|
||||
const legacy = structuredClone(game);
|
||||
delete legacy.coalitionState;
|
||||
assert.match(turnActionInputError(payment('b'), legacy, 'a') ?? '', /non-allied recipient/);
|
||||
for (const id of ['c','d']) assert.equal(turnActionInputError(payment(id), legacy, 'a'), null);
|
||||
assert.match(html(legacy, 'c'), /<option value="c" selected="">Fremen<\/option>/);
|
||||
assert.doesNotMatch(html(legacy, 'c'), /<option value="b"/);
|
||||
});
|
||||
Reference in new issue
Block a user