Offer Emperor gifts before allied auction payment
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
93b0146de9
commit
6cec4ed78b
11 files changed
+528
-2
No files matched your search
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
/** The server exposes the current optional opportunity only to its Emperor. */
|
||||
export function EmperorAuctionGift({ game, act, busy }: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [raw, setRaw] = useState('1');
|
||||
const offer = game.emperorAuctionGift;
|
||||
if (!offer) return null;
|
||||
const amount = /^[1-9]\d*$/.test(raw) ? Number(raw) : NaN;
|
||||
const canShare = !busy && Number.isSafeInteger(amount) && amount >= 1 && amount <= offer.maximum;
|
||||
const recipient = game.players.find(player => player.id === offer.recipient)?.name ?? 'your ally';
|
||||
|
||||
return <section className="notice min-w-0 space-y-3 break-words" aria-label="Emperor auction gift">
|
||||
<h3>Share spice with {recipient}</h3>
|
||||
<p className="fine">The winning bid and its committed ally payment split remain unchanged. Your ally chooses how to pay after the gift’s Karama response.</p>
|
||||
<label htmlFor={`${id}-amount`}>Spice to share</label>
|
||||
<input id={`${id}-amount`} type="number" inputMode="numeric" min={1} max={offer.maximum}
|
||||
step={1} value={raw} disabled={busy || offer.maximum < 1} onChange={event => setRaw(event.target.value)} />
|
||||
<button type="button" className="game-action" disabled={!canShare}
|
||||
onClick={() => { if (canShare) act({type:'emperorGift', amount}); }}>
|
||||
Share spice
|
||||
</button>
|
||||
<button type="button" className="game-action" disabled={busy}
|
||||
onClick={() => { if (!busy) act({type:'emperorAuctionGiftContinue', event:offer.event}); }}>
|
||||
Continue to payment
|
||||
</button>
|
||||
</section>;
|
||||
}
|
||||
@@ -291,6 +291,7 @@ import { RosterAlliance } from './roster-alliance';
|
||||
import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding';
|
||||
import { CoalitionEmperorAid } from './coalition-emperor-aid';
|
||||
import { EmperorTimingGift } from './emperor-timing-gift';
|
||||
import { EmperorAuctionGift } from './emperor-auction-gift';
|
||||
import { NexusEmperorPayment } from './nexus-emperor-payment';
|
||||
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
|
||||
import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase';
|
||||
@@ -3221,6 +3222,7 @@ export function GameTable({
|
||||
: 'Spoils of battle'}
|
||||
</h2>
|
||||
<EmperorTimingGift game={g} act={act} busy={busy} />
|
||||
<EmperorAuctionGift game={g} act={act} busy={busy} />
|
||||
{g.decision.kind === 'capturedLeader' &&
|
||||
(g.decision.controller ?? g.decision.owner) === me.id && (
|
||||
<p className="notice">
|
||||
@@ -3258,7 +3260,9 @@ export function GameTable({
|
||||
. No storm casualties have been applied.
|
||||
</p>
|
||||
)}
|
||||
{g.decision.player !== me.id ? (
|
||||
{g.emperorAuctionGiftPending ? (
|
||||
<p className="muted">Waiting for the Emperor to finish the allied gift opportunity.</p>
|
||||
) : g.decision.player !== me.id ? (
|
||||
<p className="muted">
|
||||
Waiting for{' '}
|
||||
{g.players.find((p) => p.id === g.decision?.player)?.name} to
|
||||
|
||||
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Emperor gift before Advanced allied auction settlement — 29 October.
|
||||
Prototyped / Partial.** A uniform opportunity precedes supported allied winning
|
||||
lots with or without a buyer's Karama, preserving the committed funding split
|
||||
through gift, cancellation and original payment. Actual auction journeys,
|
||||
rendered controls and the normal bot scheduler pass; the related 39-case
|
||||
batch, TypeScript, lint and production build pass. The private-balance-safe
|
||||
prototype still requires Continue at zero spice, so the no-confirmation goal
|
||||
for forced automatic actions remains open, as do other auction and timing
|
||||
parents and deployed acceptance.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#advanced-allied-auction-payment--29-october-2026).
|
||||
|
||||
**Emperor gift during Advanced Guild timing — 29 October. Prototyped /
|
||||
Partial.** A clean classic Advanced Guild timing decision now permits the
|
||||
Emperor's allied spice gift, with the original decision intact through the
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Emperor alliance gift timing
|
||||
|
||||
## Advanced allied auction payment — 29 October 2026
|
||||
|
||||
For a clean classic Advanced auction won by the Emperor's current mutual ally,
|
||||
the Emperor receives a uniform opportunity to give spice before the original
|
||||
payment. The same public waiting window appears whether the buyer holds Karama
|
||||
or the Emperor has available spice; only the Emperor sees the gift amount
|
||||
available. Continue leaves payment to the buyer or the existing automatic
|
||||
settlement. A gift's Karama response retains the original lot, bid and
|
||||
committed payer split; allowing it credits the buyer once, while cancellation
|
||||
transfers nothing. Gifted buyer spice follows the buyer's normal payment route;
|
||||
the previously pledged Emperor contribution remains separately accounted for.
|
||||
|
||||
The uniform priority and explicit Continue are provisional online timing for
|
||||
the authorized Advanced rules' any-time power, not publisher instructions.
|
||||
The same Continue is currently required even when the Emperor has no spice;
|
||||
this protects the private balance but still needs refinement to meet the
|
||||
project's no-confirmation requirement for forced automatic actions.
|
||||
Older already-open payments do not acquire a new window. Other auction modes,
|
||||
optional modules and live commitments needing a combined planner remain
|
||||
unfinished. [Runtime cases](../tests/emperor-auction-gift.test.ts) use real
|
||||
setup, alliance and bids with explicitly staged balances and cards;
|
||||
[rendered controls](../tests/emperor-auction-gift-controls.test.tsx) cover the
|
||||
table and owner-only opportunity.
|
||||
|
||||
## Advanced Guild timing prototype — 29 October 2026
|
||||
|
||||
The user-authorized revised rules, physical p. 31 (printed p. 29), allow the
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Advanced Emperor gift before allied auction payment — 29 October 2026
|
||||
|
||||
The supplied Advanced rules permit an allied gift at any time. The first
|
||||
clean ordinary-auction implementation offers the Emperor a uniform public
|
||||
opportunity before the original payment, independent of private card holdings;
|
||||
its priority and Continue are provisional online timing, not a publisher
|
||||
ruling. The original payer split remains committed.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#advanced-allied-auction-payment--29-october-2026).
|
||||
|
||||
## Advanced Emperor gift during Guild timing — 29 October 2026
|
||||
|
||||
The authorized revised Emperor sheet, physical p. 31, allows allied spice
|
||||
|
||||
@@ -4359,6 +4359,13 @@ function botAdvisorMatchingAllowed(g:GameView,action:Action) {
|
||||
} catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; }
|
||||
}
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.emperorAuctionGiftPending && !g.response) {
|
||||
const me = g.players.find(player => player.id === g.me);
|
||||
return (me?.bot || me?.autopilot) && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending &&
|
||||
g.emperorAuctionGift
|
||||
? [{type: 'emperorAuctionGiftContinue', event: g.emperorAuctionGift.event}] : [];
|
||||
}
|
||||
if (g.victoryContinuation)
|
||||
return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
|
||||
? policyActions(g) : [];
|
||||
|
||||
+110
@@ -1234,6 +1234,8 @@ type SpiceBankerIncomePayment = {
|
||||
export type ResponseWindow = {
|
||||
/** The original public Guild timing decision remains live during this gift. */
|
||||
emperorGuildTimingGift?: { event: string; signature: string };
|
||||
/** Links an allied auction gift to the still-pending payment opportunity. */
|
||||
emperorAuctionGiftEvent?: string;
|
||||
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
advisorMatchingSource?: AdvisorMatchingSource;
|
||||
@@ -2071,6 +2073,14 @@ export type Game = {
|
||||
phaseOpening: Game['phaseOpening'];
|
||||
};
|
||||
} | null;
|
||||
pendingEmperorAuctionGift?: {
|
||||
event: string;
|
||||
owner: string;
|
||||
recipient: string;
|
||||
turn: number;
|
||||
signature: string;
|
||||
giftAmount?: number;
|
||||
} | null;
|
||||
richeseGiftBlocked?: { turn: number; phase: number; cards: string[] };
|
||||
richeseAllyOffer?: RicheseAllyOffer | null;
|
||||
richeseAllyOpportunity?: { turn: number; recipient: string };
|
||||
@@ -18386,11 +18396,72 @@ function auctionNext(g: Game) {
|
||||
g.decision = { kind: 'auctionPayment', player: next.player };
|
||||
g.active = next.player;
|
||||
recoverAuctionPayment(g, getPlayer(g, next.player));
|
||||
beginEmperorAuctionGift(g);
|
||||
return;
|
||||
}
|
||||
a.active = next.player;
|
||||
g.active = a.active;
|
||||
}
|
||||
function emperorAuctionGiftSignature(g: Game): string {
|
||||
return JSON.stringify([g.turn, g.phase, g.active, g.decision, g.order, g.auction, g.aid]);
|
||||
}
|
||||
/** The public alliance and winning seat alone determine this first refusal.
|
||||
* Only auctionNext creates it: a restored payment never reopens the offer. */
|
||||
function beginEmperorAuctionGift(g: Game) {
|
||||
if (!g.advanced || g.status !== 'playing' || g.phase !== 3 ||
|
||||
g.expansions.length !== 0 || g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) ||
|
||||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
|
||||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.pendingEmperorAuctionGift ||
|
||||
g.response || g.phaseOpening || g.truthtrance || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingNullentropy ||
|
||||
g.currentAuctionSale || g.richeseAuction || g.ixAuction ||
|
||||
g.decision?.kind !== 'auctionPayment' || !g.auction ||
|
||||
g.auction.bidder !== g.decision.player) return;
|
||||
const emperor = byFaction(g, 'emperor');
|
||||
const buyer = g.players.find(player => player.id === g.decision!.player);
|
||||
if (!emperor || !buyer || emperor.id === buyer.id ||
|
||||
emperor.ally !== buyer.id || buyer.ally !== emperor.id) return;
|
||||
g.pendingEmperorAuctionGift = { event: crypto.randomUUID(), owner: emperor.id,
|
||||
recipient: buyer.id, turn: g.turn, signature: emperorAuctionGiftSignature(g) };
|
||||
}
|
||||
function emperorAuctionGiftIntegrity(g: Game) {
|
||||
const pending = g.pendingEmperorAuctionGift;
|
||||
if (!pending) {
|
||||
requireRule(!g.response?.emperorAuctionGiftEvent &&
|
||||
!(g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.emperorAuctionGiftEvent),
|
||||
'The Emperor auction gift opportunity is missing.');
|
||||
return;
|
||||
}
|
||||
const owner = g.players.find(player => player.id === pending.owner);
|
||||
const recipient = g.players.find(player => player.id === pending.recipient);
|
||||
requireRule(g.status === 'playing' && g.advanced && g.phase === 3 && g.turn === pending.turn &&
|
||||
typeof pending.event === 'string' && !!pending.event && owner?.faction === 'emperor' && recipient &&
|
||||
owner.ally === recipient.id && recipient.ally === owner.id &&
|
||||
g.decision?.kind === 'auctionPayment' && g.decision.player === recipient.id &&
|
||||
g.auction?.bidder === recipient.id && pending.signature === emperorAuctionGiftSignature(g),
|
||||
'The Emperor auction gift lost its original payment opportunity.');
|
||||
const response = g.pendingKarama?.use.kind === 'cancel'
|
||||
? g.pendingKarama.use.response : g.response;
|
||||
if (response)
|
||||
requireRule(response.kind === 'emperorGift' && response.owner === owner.id &&
|
||||
response.recipient === recipient.id && response.emperorAuctionGiftEvent === pending.event &&
|
||||
typeof response.amount === 'number' && Number.isSafeInteger(response.amount) &&
|
||||
response.amount > 0 && response.amount === pending.giftAmount &&
|
||||
response.amount <= uncommittedSpice(g, owner),
|
||||
'The pending Emperor gift differs from its original auction opportunity.');
|
||||
else requireRule(pending.giftAmount === undefined,
|
||||
'The Emperor auction gift lost its original response.');
|
||||
}
|
||||
function emperorAuctionGiftMaximum(g: Game, p: Player): number {
|
||||
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
hasMovementPromises(g, p.id, true) || hasBribePromises(g, p.id, true) ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(row => row.answer) ||
|
||||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length ||
|
||||
livePhaseBidPromises(g.phaseBidPromises ?? [], p.id, g.turn).some(row => row.answer)) return 0;
|
||||
return Math.max(0, uncommittedSpice(g, p));
|
||||
}
|
||||
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
|
||||
const a = g.auction!;
|
||||
const winner = getPlayer(g, a.bidder!);
|
||||
@@ -20438,6 +20509,7 @@ function finishHarkonnenExchange(
|
||||
{ faction: 'harkonnen', name: 'Harkonnen exchange' });
|
||||
}
|
||||
function finishAutomaticDecision(g: Game): boolean {
|
||||
if (g.pendingEmperorAuctionGift) return false;
|
||||
if (g.biddingEnd && biddingEndQuiet(g)) return finishBiddingEnd(g);
|
||||
if (advanceHomeworldReveal(g)) return true;
|
||||
const decision = g.decision;
|
||||
@@ -38255,6 +38327,11 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
`${emperor.name} ${response.kind === 'emperorGift' ? 'shared spice with' : `offered ${amount} extra force revivals to`} ${recipient.name}.`,
|
||||
);
|
||||
}
|
||||
if (response.emperorAuctionGiftEvent) {
|
||||
requireRule(g.pendingEmperorAuctionGift?.event === response.emperorAuctionGiftEvent,
|
||||
'The Emperor gift lost its original auction opportunity.');
|
||||
delete g.pendingEmperorAuctionGift.giftAmount;
|
||||
}
|
||||
} else if (response.kind === 'guildIncome') {
|
||||
const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null;
|
||||
awardFinalFactionBankerIncome(g, response.spiceBankerIncomePayment,
|
||||
@@ -41928,7 +42005,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
* automatic deal payment; it is never supplied by a client action. */
|
||||
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string,
|
||||
options?: {insolventDealPayment?: boolean}): Game {
|
||||
emperorAuctionGiftIntegrity(state);
|
||||
emperorGuildTimingGiftIntegrity(state);
|
||||
if (state.pendingEmperorAuctionGift)
|
||||
requireRule(!['dealPay', 'dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type),
|
||||
'Finish the Emperor auction gift opportunity before changing a public deal.');
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
factionSwapIntegrity(state);
|
||||
@@ -42673,6 +42754,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
emperorAuctionGiftIntegrity(state);
|
||||
emperorGuildTimingGiftIntegrity(state);
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
@@ -42946,6 +43028,27 @@ function applyActionInner(
|
||||
);
|
||||
requireRule(g.status !== 'finished', 'This game has ended.');
|
||||
const t = action.type;
|
||||
if (g.pendingEmperorAuctionGift && !g.response && !g.pendingKarama) {
|
||||
const pending = g.pendingEmperorAuctionGift;
|
||||
if (t === 'advanceBots') return g;
|
||||
requireRule(id === pending.owner, 'Wait for the Emperor to finish the allied gift opportunity.');
|
||||
if (t === 'emperorAuctionGiftContinue') {
|
||||
requireRule(Object.keys(action).sort().join(',') === 'event,type' && action.event === pending.event,
|
||||
'This Emperor auction opportunity is no longer current.');
|
||||
g.pendingEmperorAuctionGift = null;
|
||||
return g;
|
||||
}
|
||||
requireRule(t === 'emperorGift' && Object.keys(action).sort().join(',') === 'amount,type',
|
||||
'Choose an allied spice gift or continue to the original payment.');
|
||||
const amount = integer(action.amount, 1, emperorAuctionGiftMaximum(g, p), 'Spice');
|
||||
pending.giftAmount = amount;
|
||||
g.response = { kind: 'emperorGift', owner: id, recipient: pending.recipient,
|
||||
amount, elite: 0, passed: [], emperorAuctionGiftEvent: pending.event };
|
||||
return g;
|
||||
}
|
||||
if (g.pendingEmperorAuctionGift && (g.response || g.pendingKarama) && t !== 'advanceBots')
|
||||
requireRule(t === 'passResponse' || t === 'card' && action.mode === 'cancel',
|
||||
'Resolve the Emperor gift response before another action.');
|
||||
if (g.nexusFremenRideBetrayal?.stage === 'choose') {
|
||||
if (t === 'advanceBots') return g;
|
||||
requireRule(t === 'decision', 'Finish the current before-ride Betrayal opportunity first.');
|
||||
@@ -47205,6 +47308,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string, now?: number) {
|
||||
emperorAuctionGiftIntegrity(state);
|
||||
emperorGuildTimingGiftIntegrity(state);
|
||||
twoPlayerControllersIntegrity(state);
|
||||
factionDraftIntegrity(state);
|
||||
@@ -48258,6 +48362,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
? { amount: undefined }
|
||||
: {}),
|
||||
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
|
||||
...(g.response.emperorAuctionGiftEvent ? { emperorAuctionGiftEvent: undefined } : {}),
|
||||
}
|
||||
: null,
|
||||
paymentIncome: currentFactionPayment(g),
|
||||
@@ -48324,6 +48429,11 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
: 0,
|
||||
},
|
||||
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
|
||||
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
|
||||
? { owner: g.pendingEmperorAuctionGift.owner } : null,
|
||||
emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama
|
||||
? { event: g.pendingEmperorAuctionGift.event, recipient: g.pendingEmperorAuctionGift.recipient,
|
||||
maximum: emperorAuctionGiftMaximum(g, me) } : null,
|
||||
coalitionEmperor: (() => {
|
||||
const group = coalitionEmperorGroup(g, me);
|
||||
return group ? {allies: group.members.filter(member => member !== id),
|
||||
|
||||
@@ -91,6 +91,7 @@ export const FACTION_RULES: Record<
|
||||
],
|
||||
advanced: [
|
||||
'In a clean classic-faction Advanced game, the Emperor can also give an ally spice while the Guild is choosing its turn timing. Resolve the gift’s Karama response before the same Guild choice continues. Other interrupted windows remain unfinished.',
|
||||
'Before a supported ally’s ordinary auction payment, the Emperor may give spice or continue to that payment. The already declared buyer and allied funding shares remain fixed; the original payment follows after the gift response.',
|
||||
'Five Sardaukar forces have a printed battle strength of two each, except against Fremen, where they count as ordinary forces.',
|
||||
'Spice support applies per token. A supported Sardaukar contributes two strength; an unsupported Sardaukar contributes one, except when fighting Fremen.',
|
||||
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit. With Homeworlds, a low Salusa prohibits free Sardaukar returns; normal forces and eligible leaders remain available.',
|
||||
|
||||
@@ -53,7 +53,11 @@ 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 });
|
||||
if (game.pendingEmperorAuctionGift)
|
||||
game = act(game, game.pendingEmperorAuctionGift.owner,
|
||||
{ type: 'emperorAuctionGiftContinue', event: game.pendingEmperorAuctionGift.event });
|
||||
if (!game.response && game.decision?.kind === 'auctionPayment')
|
||||
game = act(game, 'harkonnen', { type: 'decision', karama: false });
|
||||
return game;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type Game, type GameView } from '../game/engine';
|
||||
import { EmperorAuctionGift } from '../components/emperor-auction-gift';
|
||||
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
/** Original public Advanced setup, natural worm and actual alliance, followed
|
||||
* by ordinary bidding actions. Wallet amounts are deliberately staged. */
|
||||
function fixture(emperorSpice: number): Game {
|
||||
const source = createTruthAllianceFixture({advanced:true, consent:'none'});
|
||||
let game = source.game;
|
||||
game = applyAction(game, 'harkonnen', {type:'alliance', target:'emperor'});
|
||||
game = applyAction(game, 'emperor', {type:'alliance', target:'harkonnen'});
|
||||
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 = applyAction(game, game.auction.active, {type:'passBid'});
|
||||
assert.equal(game.auction?.active, 'harkonnen');
|
||||
game.players.find(player => player.id === 'harkonnen')!.spice = 6;
|
||||
game.players.find(player => player.id === 'emperor')!.spice = emperorSpice;
|
||||
game = applyAction(game, 'harkonnen', {type:'bid', amount:4, allyPayment:0});
|
||||
for (let count = 0; game.auction && !game.response && !game.decision && count < 12; count++)
|
||||
game = applyAction(game, game.auction.active, {type:'passBid'});
|
||||
assert.equal(game.decision?.kind, 'auctionPayment');
|
||||
assert.deepEqual(viewGame(game, 'emperor').emperorAuctionGiftPending, {owner:'emperor'});
|
||||
return game;
|
||||
}
|
||||
|
||||
const component = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(EmperorAuctionGift, {game, busy, act() {}}));
|
||||
const table = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}}));
|
||||
const enabled = (label: string) => new RegExp(`<button\\b(?![^>]*disabled)[^>]*>${label}<\\/button>`);
|
||||
const disabled = (label: string) => new RegExp(`<button\\b[^>]*disabled=""[^>]*>${label}<\\/button>`);
|
||||
|
||||
void test('the owner sees the quoted recipient, amount and continuation on the actual table', () => {
|
||||
const game = fixture(10), own = viewGame(game, 'emperor');
|
||||
assert.equal(own.emperorAuctionGift?.recipient, 'harkonnen');
|
||||
assert.equal(own.emperorAuctionGift?.maximum, 10);
|
||||
const html = component(own);
|
||||
assert.match(html, /Share spice with Harkonnen/);
|
||||
assert.match(html, /committed ally payment split remain unchanged/);
|
||||
assert.match(html, /type="number"[^>]*min="1"[^>]*max="10"/);
|
||||
assert.match(html, enabled('Share spice'));
|
||||
assert.match(html, enabled('Continue to payment'));
|
||||
assert.match(table(own), /Share spice with Harkonnen/);
|
||||
assert.match(table(own), enabled('Continue to payment'));
|
||||
});
|
||||
|
||||
void test('zero available spice still permits the owner to continue, but not to give', () => {
|
||||
const own = viewGame(fixture(0), 'emperor');
|
||||
assert.equal(own.emperorAuctionGift?.maximum, 0);
|
||||
const html = component(own);
|
||||
assert.match(html, disabled('Share spice'));
|
||||
assert.match(html, enabled('Continue to payment'));
|
||||
assert.match(table(own), enabled('Continue to payment'));
|
||||
});
|
||||
|
||||
void test('nonowners see the public wait without an owner quote or private amount', () => {
|
||||
const game = fixture(10);
|
||||
for (const id of ['harkonnen', 'atreides']) {
|
||||
const view = viewGame(game, id);
|
||||
assert.equal(view.emperorAuctionGift, null);
|
||||
assert.deepEqual(view.emperorAuctionGiftPending, {owner:'emperor'});
|
||||
assert.equal(component(view), '');
|
||||
const html = table(view);
|
||||
assert.match(html, /Waiting for the Emperor to finish the allied gift opportunity/);
|
||||
assert.doesNotMatch(html, /Emperor auction gift/);
|
||||
assert.doesNotMatch(html, /Continue to payment/);
|
||||
assert.doesNotMatch(html, /Spice to share/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy controls disable both owner actions while preserving the waiting state', () => {
|
||||
const own = viewGame(fixture(10), 'emperor');
|
||||
const html = component(own, true);
|
||||
assert.match(html, disabled('Share spice'));
|
||||
assert.match(html, disabled('Continue to payment'));
|
||||
assert.match(table(own, true), disabled('Continue to payment'));
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
const act = (g: Game, actor: string, action: Action) => applyAction(g, actor, action);
|
||||
const seat = (g: Game, id: string) => { const p = g.players.find(row => row.id === id); assert.ok(p); return p; };
|
||||
const copy = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
|
||||
/** Genuine Advanced H/A/E setup, second-turn worm and reciprocal alliance.
|
||||
* Test wallets and conservation-preserving Karama swaps are staged; bidding,
|
||||
* passing, settlement and all responses are native actions. */
|
||||
function openAuction(buyerKarama = false, emperorSpice = 10) {
|
||||
const source = createTruthAllianceFixture({advanced:true, consent:'none'});
|
||||
let g = source.game;
|
||||
g = act(g, 'harkonnen', {type:'alliance', target:'emperor'});
|
||||
g = act(g, 'emperor', {type:'alliance', target:'harkonnen'});
|
||||
g = advanceTruthAlliance(g, s => s.phase === 3 && !!s.auction && truthAllianceClean(s));
|
||||
for (let i = 0; g.auction && g.auction.active !== 'harkonnen' && i < g.players.length; i++)
|
||||
g = act(g, g.auction.active, {type:'passBid'});
|
||||
assert.equal(g.auction?.active, 'harkonnen');
|
||||
seat(g, 'harkonnen').spice = 6; seat(g, 'emperor').spice = emperorSpice;
|
||||
// The winner's private physical Karama changes only whether the underlying
|
||||
// auction needs its own payment choice, never whether this gift opens.
|
||||
const buyer = seat(g, 'harkonnen');
|
||||
const removed = buyer.hand.filter(card => card.effect === 'karama');
|
||||
buyer.hand = buyer.hand.filter(card => card.effect !== 'karama');
|
||||
g.deck.push(...removed);
|
||||
if (buyerKarama) {
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [card] = g.deck.splice(index, 1); buyer.hand.push(card);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function finishBid(g: Game, amount = 4, allyPayment = 0) {
|
||||
const card = g.auction!.cards[g.auction!.index].id;
|
||||
g = act(g, 'harkonnen', {type:'bid', amount, allyPayment});
|
||||
for (let i = 0; g.auction && !g.response && !g.decision && i < 12; i++)
|
||||
g = act(g, g.auction.active, {type:'passBid'});
|
||||
assert.equal(g.decision?.kind, 'auctionPayment');
|
||||
return {g, card};
|
||||
}
|
||||
function opportunity(g: Game) {
|
||||
const result = viewGame(g, 'emperor').emperorAuctionGift;
|
||||
assert.ok(result); return result;
|
||||
}
|
||||
function continued(g: Game) {
|
||||
const offer = opportunity(g);
|
||||
return act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event});
|
||||
}
|
||||
function passResponses(g: Game) {
|
||||
for (let i = 0; g.response && i < 30; i++) {
|
||||
const p = g.players.find(row => !g.response!.passed.includes(row.id)); assert.ok(p);
|
||||
g = act(g, p.id, {type:'passResponse'});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function completePayment(g: Game) {
|
||||
if (g.decision?.kind === 'auctionPayment')
|
||||
g = act(g, g.decision.player, {type:'decision', karama:false});
|
||||
return passResponses(g);
|
||||
}
|
||||
function frozenAuction(g: Game) {
|
||||
const a = g.auction!;
|
||||
return {decision:structuredClone(g.decision), turn:g.turn, phase:g.phase, active:g.active,
|
||||
card:a.cards[a.index].id, index:a.index, bid:a.bid, bidder:a.bidder,
|
||||
passed:structuredClone(a.passed), allyPayment:a.allyPayment,
|
||||
aid:structuredClone(g.aid), clock:structuredClone(g.auctionClock ?? null)};
|
||||
}
|
||||
|
||||
for (const buyerKarama of [false, true]) for (const emperorSpice of [0, 10]) {
|
||||
void test(`uniform prepayment gift opportunity: buyer Karama ${buyerKarama}, Emperor spice ${emperorSpice}`, () => {
|
||||
const {g: initial, card} = finishBid(openAuction(buyerKarama, emperorSpice));
|
||||
const g = copy(initial), offer = opportunity(g), parent = frozenAuction(g);
|
||||
assert.equal(offer.recipient, 'harkonnen');
|
||||
assert.equal(offer.maximum, emperorSpice);
|
||||
assert.deepEqual(viewGame(g, 'emperor').emperorAuctionGiftPending, {owner:'emperor'});
|
||||
for (const other of ['harkonnen', 'atreides']) {
|
||||
assert.equal(viewGame(g, other).emperorAuctionGift, null);
|
||||
assert.deepEqual(viewGame(g, other).emperorAuctionGiftPending, {owner:'emperor'});
|
||||
}
|
||||
const before = copy(g);
|
||||
assert.throws(() => act(g, 'harkonnen', {type:'decision', karama:false}));
|
||||
assert.throws(() => act(g, 'harkonnen', {type:'emperorAuctionGiftContinue', event:offer.event}));
|
||||
assert.throws(() => act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:'stale'}));
|
||||
assert.deepEqual(g, before);
|
||||
let next = continued(g);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGift, null);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
||||
if (buyerKarama) {
|
||||
assert.deepEqual(frozenAuction(next), parent);
|
||||
assert.equal(next.decision?.kind, 'auctionPayment');
|
||||
next = completePayment(next);
|
||||
} else next = passResponses(next);
|
||||
assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card), 'original lot delivered once');
|
||||
assert.throws(() => act(next, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event}));
|
||||
});
|
||||
}
|
||||
|
||||
void test('a prepayment gift preserves the declared mixed payer split and Advanced payment routing', () => {
|
||||
let g = openAuction(true);
|
||||
seat(g, 'harkonnen').spice = 4;
|
||||
g = act(g, 'emperor', {type:'pledgeAid', amount:2});
|
||||
const bid = finishBid(g, 6, 2); g = bid.g;
|
||||
const parent = frozenAuction(g), event = opportunity(g).event;
|
||||
assert.equal(opportunity(g).maximum, 8);
|
||||
g = act(g, 'emperor', {type:'emperorGift', amount:1});
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
assert.equal(seat(g, 'emperor').spice, 8);
|
||||
g = passResponses(copy(g));
|
||||
assert.deepEqual(frozenAuction(g), parent);
|
||||
assert.equal(seat(g, 'harkonnen').spice, 5);
|
||||
assert.equal(seat(g, 'emperor').spice, 7);
|
||||
assert.equal(opportunity(g).event, event);
|
||||
g = continued(g);
|
||||
assert.equal(g.decision?.kind, 'auctionPayment');
|
||||
g = completePayment(g);
|
||||
assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card));
|
||||
assert.equal(seat(g, 'harkonnen').spice, 1);
|
||||
assert.equal(seat(g, 'emperor').spice, 11,
|
||||
'Own-price four returns to Emperor; its separately pledged two went to Bank.');
|
||||
});
|
||||
|
||||
void test('a real Karama cancels the gift without disturbing the auction or its payment', () => {
|
||||
let g = openAuction(true);
|
||||
const third = seat(g, 'atreides');
|
||||
let card = third.hand.find(row => row.effect === 'karama');
|
||||
if (!card) {
|
||||
const index = g.deck.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0);
|
||||
[card] = g.deck.splice(index, 1); third.hand.push(card);
|
||||
}
|
||||
assert.ok(card);
|
||||
const bid = finishBid(g); g = bid.g;
|
||||
const parent = frozenAuction(g), emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice;
|
||||
g = act(g, 'emperor', {type:'emperorGift', amount:2});
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
g = act(g, 'atreides', {type:'card', card:card.id, mode:'cancel'});
|
||||
assert.deepEqual(frozenAuction(g), parent);
|
||||
assert.equal(seat(g, 'emperor').spice, emperor);
|
||||
assert.equal(seat(g, 'harkonnen').spice, buyer);
|
||||
assert.equal(g.discard.filter(row => row.id === card!.id).length, 1);
|
||||
g = continued(g);
|
||||
g = completePayment(g);
|
||||
assert.ok(seat(g, 'harkonnen').hand.some(row => row.id === bid.card));
|
||||
});
|
||||
|
||||
void test('four minimal Emperor policies can continue the neutral prepayment opportunity', () => {
|
||||
const {g} = finishBid(openAuction(false, 0));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(g, 'emperor');
|
||||
view.players.find(p => p.id === 'emperor')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(actions, [{type:'emperorAuctionGiftContinue', event:opportunity(g).event}]);
|
||||
const next = act(copy(g), 'emperor', actions[0]);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Bene Gesserit may use a physical Worthless card to cancel the gift', () => {
|
||||
let g = createGame('EMPAUCTIONBG', newPlayer('emperor', 'Emperor', 'emperor'), true);
|
||||
joinGame(g, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(g, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
|
||||
for (const p of g.players) g = act(g, p.id, {type:'ready'});
|
||||
g = act(g, 'emperor', {type:'start', advancedPreview:true});
|
||||
for (let i = 0; i < 80 && g.status === 'setup'; i++) {
|
||||
const step = nextStrongholdFactionsNativeStep(g); assert.ok(step);
|
||||
g = act(g, step.actor, step.action);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
// Stage only the current alliance, test wallets and unopened ordinary lot;
|
||||
// the Worthless card is moved from the actual deck, never manufactured.
|
||||
seat(g, 'emperor').ally = 'harkonnen'; seat(g, 'harkonnen').ally = 'emperor';
|
||||
seat(g, 'emperor').spice = 10; seat(g, 'harkonnen').spice = 6;
|
||||
const bg = seat(g, 'beneGesserit');
|
||||
let worthless = bg.hand.find(card => card.kind === 'worthless');
|
||||
if (!worthless) {
|
||||
const index = g.deck.findIndex(card => card.kind === 'worthless'); assert.ok(index >= 0);
|
||||
[worthless] = g.deck.splice(index, 1);
|
||||
if (bg.hand.length >= 4) g.deck.push(bg.hand.pop()!);
|
||||
bg.hand.push(worthless);
|
||||
}
|
||||
assert.ok(worthless);
|
||||
// A genuine opposing Karama keeps the conversion response visible long
|
||||
// enough to exercise its continuation instead of automatically passing.
|
||||
const emperorSeat = seat(g, 'emperor');
|
||||
if (!emperorSeat.hand.some(card => card.effect === 'karama')) {
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [counter] = g.deck.splice(index, 1);
|
||||
if (emperorSeat.hand.length >= 4) g.deck.push(emperorSeat.hand.pop()!);
|
||||
emperorSeat.hand.push(counter);
|
||||
}
|
||||
const lot = g.deck.shift(); assert.ok(lot);
|
||||
Object.assign(g, {phase:3, active:'harkonnen', order:['harkonnen','emperor','beneGesserit'],
|
||||
response:null, decision:null, phaseOpening:null, ready:[],
|
||||
auction:{cards:[lot], index:0, bid:0, bidder:null, active:'harkonnen', passed:[], opener:0, peekKnown:false}});
|
||||
const bid = finishBid(g); g = bid.g;
|
||||
const original = frozenAuction(g), event = opportunity(g).event;
|
||||
const emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice;
|
||||
g = act(g, 'emperor', {type:'emperorGift', amount:2});
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
g = act(g, 'beneGesserit', {type:'card', card:worthless.id, mode:'cancel'});
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
g = passResponses(g);
|
||||
assert.deepEqual(frozenAuction(g), original);
|
||||
assert.equal(opportunity(g).event, event);
|
||||
assert.equal(seat(g, 'emperor').spice, emperor);
|
||||
assert.equal(seat(g, 'harkonnen').spice, buyer);
|
||||
assert.equal(g.discard.filter(card => card.id === worthless!.id).length, 1);
|
||||
g = continued(g);
|
||||
g = completePayment(g);
|
||||
assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card));
|
||||
});
|
||||
|
||||
void test('the normal bot scheduler advances a bot Emperor before the buyer payment', () => {
|
||||
const {g, card} = finishBid(openAuction(false, 0));
|
||||
seat(g, 'emperor').bot = 'Medium';
|
||||
const next = runBots(g, 8);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
||||
assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card));
|
||||
});
|
||||
Reference in new issue
Block a user