Extend Basic Emperor auction gifts to Nexus and Richese
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
b258c6867f
commit
dac1b432db
11 files changed
+509
-14
No files matched your search
@@ -19,7 +19,9 @@ export function EmperorAuctionGift({ game, act, busy }: {
|
||||
|
||||
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>
|
||||
<p className="fine">{offer.source === 'richese'
|
||||
? 'The winning bid and its committed ally payment split remain unchanged. Continue settles the Richese sale with that payment after any gift response.'
|
||||
: '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)} />
|
||||
|
||||
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Emperor Nexus and Richese cache auction gifts — 30 October.
|
||||
Prototyped / Partial.** Supported Nexus ordinary auctions now offer the
|
||||
Emperor's gift before the existing funding choice. Supported Richese Once
|
||||
Around and Silent cache sales preserve the winner, bid and pledged split
|
||||
through the gift, Karama, Kull and Semuta before continuing the original sale.
|
||||
Thirteen related files pass 78 cases; type checking, lint and production build
|
||||
pass. The ordering is provisional; combined cache and Nexus, other modules and
|
||||
timing parents, complete games and deployed acceptance remain open.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-nexus-and-richese-cache-auction-gifts--30-october-2026).
|
||||
|
||||
**Basic Emperor allied auction gifts — 30 October. Prototyped / Partial.**
|
||||
In supported Basic ordinary auctions, the Emperor can give spice to the
|
||||
winning mutual ally before payment. The original bid and pledged split survive
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Emperor alliance gift timing
|
||||
|
||||
## Basic Nexus and Richese cache auction gifts — 30 October 2026
|
||||
|
||||
Supported Basic ordinary Nexus auctions offer the Emperor's gift before the
|
||||
existing Nexus payment choice. Continuing preserves the original winning bid
|
||||
and pledged shares; the existing Nexus funding or payment follows. Supported
|
||||
Basic Richese Once Around and Silent cache sales also offer a gift before the
|
||||
committed sale settles, retaining the quoted winner, price and funding split.
|
||||
The cache control explains that Continue settles the original sale. Karama,
|
||||
Kull and supported Semuta responses retain the same opportunity and physical
|
||||
cards. Minimal bots can participate in these paths.
|
||||
|
||||
The publisher FAQ cited below permits direct gifts; this ordering and Continue
|
||||
are provisional online interpretations. Evidence includes public-start
|
||||
[Nexus auctions](../tests/emperor-auction-gift-nexus-basic.test.ts),
|
||||
[cache sales](../tests/emperor-richese-cache-gift-basic-public.test.ts),
|
||||
[Kull and counter-Karama](../tests/emperor-richese-cache-gift-kull-basic.test.ts),
|
||||
and [cache controls](../tests/emperor-auction-gift-cache-controls.test.tsx).
|
||||
Fixtures stage selected balances, cards and unopened Spice order. Cache sales
|
||||
combined with Nexus or other optional modules, other special auctions, wider
|
||||
timing interactions, complete games and deployed acceptance remain open.
|
||||
|
||||
## Basic allied auction payment — 30 October 2026
|
||||
|
||||
In a supported Basic ordinary auction, the Emperor can give spice to the
|
||||
@@ -16,8 +37,9 @@ permits the Emperor to give a mutual ally spice directly for any reason. This
|
||||
uniform online priority and explicit Continue are provisional timing, not a
|
||||
publisher ruling. Public-start fixtures use real setup, worm, alliance and
|
||||
bids with staged balances, card custody and selected unopened Spice order.
|
||||
Nexus and other optional modules, special Richese auctions, other timing
|
||||
parents, complete games and deployed acceptance remain open.
|
||||
At that checkpoint Nexus, special Richese auctions, other optional modules,
|
||||
other timing parents, complete games and deployed acceptance remained open;
|
||||
the newer section above describes the additional supported paths.
|
||||
|
||||
## Basic Emperor gift during Ix phase openings — 29 October 2026
|
||||
|
||||
|
||||
+51
-7
@@ -2127,6 +2127,8 @@ export type Game = {
|
||||
turn: number;
|
||||
signature: string;
|
||||
giftAmount?: number;
|
||||
source?: 'richese';
|
||||
quote?: Extract<RicheseSettlementQuote,{kind:'sold'}>;
|
||||
} | null;
|
||||
richeseGiftBlocked?: { turn: number; phase: number; cards: string[] };
|
||||
richeseAllyOffer?: RicheseAllyOffer | null;
|
||||
@@ -6720,8 +6722,10 @@ function emperorAuctionGiftResumeMatches(g: Game, response: ResponseWindow, deci
|
||||
const pending = g.pendingEmperorAuctionGift;
|
||||
return !!pending && response.kind === 'emperorGift' && response.emperorAuctionGiftEvent === pending.event &&
|
||||
response.owner === pending.owner && response.recipient === pending.recipient &&
|
||||
decision?.kind === 'auctionPayment' && decision.player === pending.recipient &&
|
||||
g.phase === 3 && !!g.auction && g.auction.bidder === pending.recipient && !g.currentAuctionSale && !g.richeseAuction;
|
||||
g.phase === 3 && !g.currentAuctionSale && (pending.source === 'richese'
|
||||
? !decision && !!g.richeseAuction && !g.auction && pending.quote?.winner === pending.recipient
|
||||
: decision?.kind === 'auctionPayment' && decision.player === pending.recipient &&
|
||||
!!g.auction && g.auction.bidder === pending.recipient && !g.richeseAuction);
|
||||
}
|
||||
/** Preserve physical identity: a Shrine authorizes the role, not a manufactured card. */
|
||||
function semutaKaramaRoleCost(g: Game, owner: string, card: Card): boolean {
|
||||
@@ -7202,7 +7206,8 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKul
|
||||
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement &&
|
||||
!g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingExchange &&
|
||||
!g.pendingRichesePurchaseIncome && (!g.pendingChoamMarketGhola || marketSupported) && (!g.currentAuctionSale || saleSupported) &&
|
||||
(!g.choamMarket || marketSupported) && (!g.richeseAuction || saleSupported) && !g.pendingFremenMove && !g.pendingIxMove &&
|
||||
(!g.choamMarket || marketSupported) && (!g.richeseAuction || saleSupported ||
|
||||
!!frame && use?.kind === 'cancel' && emperorAuctionGiftResumeMatches(g, use.response, frame.resume.decision)) && !g.pendingFremenMove && !g.pendingIxMove &&
|
||||
!g.pendingMobileMove && !g.pendingChoamMove && !g.ornithopter && !g.ecologicalStorm &&
|
||||
!g.summonedWorm && !g.wormRides.length;
|
||||
}
|
||||
@@ -10260,7 +10265,10 @@ function semutaOfferSupported(
|
||||
(advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
|
||||
continuation.kind === 'ixAllyCard' ||
|
||||
(continuation.kind === 'karamaIncomeDiscard' &&
|
||||
continuation.use.response.kind === 'emperorIncome') || richeseTruthDiscard || richeseBoxDiscard || richeseDistransDiscard || !g.richeseAuction) &&
|
||||
continuation.use.response.kind === 'emperorIncome') ||
|
||||
continuation.kind === 'karamaEmperorShareDiscard' &&
|
||||
emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision) ||
|
||||
richeseTruthDiscard || richeseBoxDiscard || richeseDistransDiscard || !g.richeseAuction) &&
|
||||
(richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || occupyDiscard || continuation.kind === 'saphoAggressorDiscard' ||
|
||||
(continuation.kind === 'saphoTruthOrderDiscard' && !!continuation.battleEvent &&
|
||||
g.phase === 6 && g.battle?.event === continuation.battleEvent && !g.battle.revealed && !g.battle.preparation &&
|
||||
@@ -15969,6 +15977,7 @@ function settleRicheseLot(g: Game) {
|
||||
return;
|
||||
}
|
||||
if (beginRicheseBetrayal(g,quote)) return;
|
||||
if (beginEmperorRicheseAuctionGift(g,quote)) return;
|
||||
settleRicheseSoldLot(g,quote,false);
|
||||
}
|
||||
const EMPEROR_SELLER_POLICY = 'Provisional seller policy: retain your own full original price after proving it. The seller stays unpaid; no Spice Bank reimbursement, payer invoice or payment-derived income is created. Original card acquisition and native bonus/lot continuation still resolve.';
|
||||
@@ -18914,6 +18923,12 @@ function auctionNext(g: Game) {
|
||||
return;
|
||||
}
|
||||
if (beginNormalRicheseBetrayal(g)) return;
|
||||
if (!g.advanced && g.nexusCards) {
|
||||
g.decision = { kind: 'auctionPayment', player: next.player };
|
||||
g.active = next.player;
|
||||
beginEmperorAuctionGift(g);
|
||||
if (g.pendingEmperorAuctionGift) return;
|
||||
}
|
||||
if (beginNexusEmperorPayment(g)) return;
|
||||
g.decision = { kind: 'auctionPayment', player: next.player };
|
||||
g.active = next.player;
|
||||
@@ -18931,15 +18946,32 @@ function emperorAuctionGiftParked(g: Game) {
|
||||
return g.pendingKull?.resume ?? null;
|
||||
}
|
||||
function emperorAuctionGiftSignature(g: Game): string {
|
||||
if (g.pendingEmperorAuctionGift?.source === 'richese')
|
||||
return JSON.stringify(['richese',g.turn,g.phase,g.active,g.richeseAuction,g.richeseBidding,
|
||||
g.richeseFunding,g.aid,g.pendingEmperorAuctionGift.quote]);
|
||||
return JSON.stringify([g.turn, g.phase, g.active, g.decision ?? emperorAuctionGiftParked(g)?.decision, g.order, g.auction, g.aid]);
|
||||
}
|
||||
function beginEmperorRicheseAuctionGift(g: Game, quote: Extract<RicheseSettlementQuote,{kind:'sold'}>): boolean {
|
||||
if (g.advanced || g.status !== 'playing' || g.phase !== 3 || g.richeseAuction?.source !== 'cache' ||
|
||||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
|
||||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.ecazTreachery ||
|
||||
g.pendingEmperorAuctionGift || g.response || g.decision || g.phaseOpening || g.truthtrance ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingNullentropy ||
|
||||
g.currentAuctionSale || g.auction || g.ixAuction) return false;
|
||||
const emperor = byFaction(g,'emperor'), buyer = g.players.find(player => player.id === quote.winner);
|
||||
if (!emperor || !buyer || emperor.id === buyer.id || emperor.ally !== buyer.id || buyer.ally !== emperor.id) return false;
|
||||
g.pendingEmperorAuctionGift = {event:crypto.randomUUID(), owner:emperor.id, recipient:buyer.id,
|
||||
turn:g.turn, source:'richese', quote:structuredClone(quote), signature:''};
|
||||
g.pendingEmperorAuctionGift.signature = emperorAuctionGiftSignature(g);
|
||||
return true;
|
||||
}
|
||||
/** 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.status !== 'playing' || g.phase !== 3 ||
|
||||
(g.advanced ? g.expansions.length !== 0 || g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))
|
||||
: g.expansions.length > 0 && !selectedPhaseModuleRoster(g)) ||
|
||||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
|
||||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || (g.advanced && g.nexusCards) ||
|
||||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.pendingEmperorAuctionGift ||
|
||||
g.response || g.phaseOpening || g.truthtrance || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingNullentropy ||
|
||||
@@ -18968,8 +19000,12 @@ function emperorAuctionGiftIntegrity(g: Game) {
|
||||
requireRule(g.status === 'playing' && 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 &&
|
||||
decision?.kind === 'auctionPayment' && decision.player === recipient.id &&
|
||||
g.auction?.bidder === recipient.id && pending.signature === emperorAuctionGiftSignature(g),
|
||||
(pending.source === 'richese'
|
||||
? !decision && !g.auction && g.richeseAuction?.source === 'cache' && !!pending.quote &&
|
||||
pending.quote.winner === recipient.id && JSON.stringify(pending.quote) === JSON.stringify(richeseSettlementQuote(g))
|
||||
: pending.source === undefined && pending.quote === undefined &&
|
||||
decision?.kind === 'auctionPayment' && decision.player === recipient.id && g.auction?.bidder === recipient.id) &&
|
||||
pending.signature === emperorAuctionGiftSignature(g),
|
||||
'The Emperor auction gift lost its original payment opportunity.');
|
||||
const response = parked?.response ?? (g.pendingKarama?.use.kind === 'cancel'
|
||||
? g.pendingKarama.use.response : g.response);
|
||||
@@ -44636,6 +44672,13 @@ function applyActionInner(
|
||||
requireRule(Object.keys(action).sort().join(',') === 'event,type' && action.event === pending.event,
|
||||
'This Emperor auction opportunity is no longer current.');
|
||||
g.pendingEmperorAuctionGift = null;
|
||||
if (pending.source === 'richese') {
|
||||
requireRule(pending.quote, 'The Richese auction gift lost its committed sale.');
|
||||
settleRicheseSoldLot(g,pending.quote,false);
|
||||
return g;
|
||||
}
|
||||
if (!g.advanced && g.nexusCards && !beginNexusEmperorPayment(g))
|
||||
recoverAuctionPayment(g, getPlayer(g, pending.recipient));
|
||||
return g;
|
||||
}
|
||||
requireRule(t === 'emperorGift' && Object.keys(action).sort().join(',') === 'amount,type',
|
||||
@@ -50122,6 +50165,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
? { owner: g.pendingEmperorAuctionGift.owner } : null,
|
||||
emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama
|
||||
? { event: g.pendingEmperorAuctionGift.event, recipient: g.pendingEmperorAuctionGift.recipient,
|
||||
...(g.pendingEmperorAuctionGift.source ? {source:g.pendingEmperorAuctionGift.source} : {}),
|
||||
maximum: emperorAuctionGiftMaximum(g, me) } : null,
|
||||
coalitionEmperor: (() => {
|
||||
const group = coalitionEmperorGroup(g, me);
|
||||
|
||||
@@ -86,7 +86,7 @@ export const FACTION_RULES: Record<
|
||||
'Start with all twenty forces in reserve and ten spice. One force revival is free each turn.',
|
||||
'Receive payments for other players’ auction purchases. Your own purchases pay the bank; a Karama purchase pays no spice. A cancellation sends one auction payment to the bank.',
|
||||
'Give spice directly to your ally. After the response window it is immediately spendable, unlike a deferred bribe between non-allies.',
|
||||
'In a supported Basic ordinary auction, you may give your mutual ally spice after they win and before their original payment. Continue preserves the bid and pledged split; a canceled gift transfers nothing.',
|
||||
'In supported Basic ordinary auctions, including Nexus games, and Richese cache sales, you may give your mutual ally spice after they win and before their original payment. Continue preserves the bid and pledged split; a canceled gift transfers nothing.',
|
||||
'In supported Moritani games, you may also give spice while your ally is choosing whether to pay Extortion. The original choice resumes after the gift’s Karama response.',
|
||||
'In supported Basic Ix-deck games with classic factions, Ixians or Tleilaxu, you may give an ally spice during the Amal phase opening, even after you have passed. The original opening resumes after the gift response.',
|
||||
'During Revival, pay two spice per extra allied force, up to three extra forces per turn. These are separate from the ally’s normal three-force allowance and may be purchased before or after ordinary revivals.',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { EmperorAuctionGift } from '../components/emperor-auction-gift';
|
||||
|
||||
/** Synthetic owner projection: this verifies the rendered distinction, not
|
||||
* a claim that a real cache-sale journey has already reached this control. */
|
||||
const view = (source?: 'richese') => ({
|
||||
players: [{id:'ally', name:'Ally'}],
|
||||
emperorAuctionGift: {event:'gift-event', recipient:'ally', maximum:5, ...(source ? {source} : {})},
|
||||
}) as unknown as GameView;
|
||||
const render = (game: GameView, busy = false) => renderToStaticMarkup(
|
||||
createElement(EmperorAuctionGift, {game, busy, act() {}}),
|
||||
);
|
||||
|
||||
void test('Richese sale describes the committed payment and retains gift and Continue controls', () => {
|
||||
const html = render(view('richese'));
|
||||
assert.match(html, /Share spice with Ally/);
|
||||
assert.match(html, /Continue settles the Richese sale with that payment after any gift response/);
|
||||
assert.doesNotMatch(html, /Your ally chooses how to pay/);
|
||||
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
|
||||
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Continue to payment<\/button>/);
|
||||
assert.match(render(view('richese'), true), /<button\b[^>]*disabled=""[^>]*>Continue to payment<\/button>/);
|
||||
});
|
||||
|
||||
void test('ordinary auction keeps its original buyer-payment guidance', () => {
|
||||
const html = render(view());
|
||||
assert.match(html, /Your ally chooses how to pay after the gift’s Karama response/);
|
||||
assert.doesNotMatch(html, /Continue settles the Richese sale/);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
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} from '../game/bots';
|
||||
import {DIFFICULTIES} from '../game/bot-profiles';
|
||||
import {SEMUTA_DRUG_ID} from '../game/semuta-drug';
|
||||
import {advanceSemutaNexusTrade, swapSemutaNexusTradeIdentity} from './fixture-semuta-nexus-trade';
|
||||
import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards';
|
||||
|
||||
const act=(game:Game, actor:string, action:Action)=>applyAction(game,actor,action);
|
||||
const seat=(game:Game,id:string)=>{const player=game.players.find(row=>row.id===id);assert.ok(player);return player;};
|
||||
const clean=(game:Game)=>!game.response&&!game.decision&&!game.phaseOpening&&!game.truthtrance&&!game.pendingTreacheryDiscard;
|
||||
function inventory(game:Game):string[] {
|
||||
const cards=[...game.deck,...game.discard,...(game.richeseCache??[]),...(game.richeseRemoved??[]),
|
||||
...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin==='normal'))??[]),
|
||||
...(game.ixAuction?.cards??[]),...game.players.flatMap(player=>player.hand)].map(card=>card.id).sort();
|
||||
assert.equal(new Set(cards).size,cards.length);return cards;
|
||||
}
|
||||
function acquire(game:Game, owner:string, predicate:(card:Game['deck'][number])=>boolean) {
|
||||
const player=seat(game,owner), held=player.hand.find(predicate);if(held)return held;
|
||||
const sources=[game.deck,game.discard,game.richeseCache!,...game.players.filter(row=>row.id!==owner).map(row=>row.hand)];
|
||||
const source=sources.find(zone=>zone.some(predicate));assert.ok(source);
|
||||
const index=source.findIndex(predicate),card=source[index], displaced=player.hand.pop();
|
||||
source.splice(index,1,...(displaced?[displaced]:[]));player.hand.push(card);return card;
|
||||
}
|
||||
/** A public Basic lobby earns its Nexus draw through a real worm and alliance.
|
||||
* Only unopened Spice order, force locations, wallets and physical cards are staged. */
|
||||
function fixture() {
|
||||
let game=createGame('BASICNEXUSGIFT',newPlayer('c','CHOAM','choam'),false,['choam','ix']);
|
||||
for(const [id,faction] of [['r','richese'],['e','emperor'],['h','harkonnen'],['a','atreides']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game=act(game,'c',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for(const player of game.players) game=act(game,player.id,{type:'ready'});
|
||||
game=act(game,'c',{type:'start'});assert.equal(game.semutaPreview,true);assert.equal(game.kullPreview,true);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.status==='playing');
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null));
|
||||
const original=inventory(game);
|
||||
for(const player of game.players) {player.reserves+=Object.values(player.forces).reduce((a,b)=>a+b,0);player.forces={};}
|
||||
game=advanceSemutaNexusTrade(game,beforeIxSpiceDraw);orderIxSpiceCards(game,['land','land']);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.turn===2&&beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game,['worm','land','land']);
|
||||
game=advanceSemutaNexusTrade(game,state=>!!state.nexus&&!state.spiceWindow&&clean(state));
|
||||
game=act(game,'e',{type:'alliance',target:'h'});game=act(game,'h',{type:'alliance',target:'e'});
|
||||
game=advanceSemutaNexusTrade(game,state=>state.nexusCards?.phase?.stage==='drawing');
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
|
||||
game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
swapSemutaNexusTradeIdentity(game,'a','emperor');
|
||||
// Stage physical cards before the original auction draws its lot, so none of
|
||||
// these required cards can be randomly sealed in that lot.
|
||||
const counter=acquire(game,'a',card=>card.effect==='karama');
|
||||
const buyerKarama=acquire(game,'h',card=>card.effect==='karama'&&card.id!==counter.id);
|
||||
const semuta=acquire(game,'r',card=>card.id===SEMUTA_DRUG_ID);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.phase===3&&!!state.auction&&!state.richeseAuction&&clean(state));
|
||||
while(game.auction!.active!=='h')game=act(game,game.auction!.active,{type:'passBid'});
|
||||
seat(game,'e').spice=8;seat(game,'h').spice=4;
|
||||
assert.deepEqual(inventory(game),original);
|
||||
game=act(game,'e',{type:'pledgeAid',amount:2});
|
||||
const lot=game.auction!.cards[game.auction!.index].id;
|
||||
game=act(game,'h',{type:'bid',amount:6,allyPayment:2});
|
||||
for(let n=0;game.auction&&!game.response&&!game.decision&&n<15;n++)game=act(game,game.auction.active,{type:'passBid'});
|
||||
assert.equal(game.decision?.kind,'auctionPayment');
|
||||
assert.ok(game.pendingNexusEmperorPayment==null,'Gift precedes the Nexus funding receipt');
|
||||
const offer=viewGame(game,'e').emperorAuctionGift;assert.ok(offer);
|
||||
return {game,original,counter:counter.id,buyerKarama:buyerKarama.id,semuta:semuta.id,lot,event:offer.event};
|
||||
}
|
||||
function passResponse(game:Game) {
|
||||
for(let n=0;game.response&&n<20;n++) {const p=game.players.find(row=>!game.response!.passed.includes(row.id));assert.ok(p);game=act(game,p.id,{type:'passResponse'});}
|
||||
return game;
|
||||
}
|
||||
for(const mode of ['allow-use','allow-pass','cancel-claim-use','cancel-pass-pass'] as const) void test(`Basic public Nexus auction gift ${mode} resumes original sale`,()=>{
|
||||
const f=fixture(),cancel=mode.startsWith('cancel'),use=mode.endsWith('use');
|
||||
let game=act(f.game,'e',{type:'emperorGift',amount:2});
|
||||
if(cancel) {
|
||||
game=act(game,'a',{type:'card',mode:'cancel',card:f.counter});
|
||||
assert.ok(game.pendingKull,'CHOAM may interrupt the actual Karama');
|
||||
game=act(game,'c',{type:'kullDecision',event:game.pendingKull.event,decline:true});
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
const event=game.pendingTreacheryDiscard!.batch.event;
|
||||
if(mode==='cancel-claim-use')game=act(game,'r',{type:'semutaCommit',event});
|
||||
else for(const player of game.players)game=act(game,player.id,{type:'semutaPass',event});
|
||||
assert.equal(seat(game,'r').hand.some(card=>card.id===f.counter),mode==='cancel-claim-use');
|
||||
assert.equal(game.discard.some(card=>card.id===f.semuta),mode==='cancel-claim-use');
|
||||
} else game=passResponse(game);
|
||||
assert.equal(viewGame(game,'e').emperorAuctionGift!.event,f.event);
|
||||
assert.equal(seat(game,'h').spice,cancel?4:6);assert.equal(seat(game,'e').spice,cancel?6:4);
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(game,'e');view.players.find(p=>p.id==='e')!.bot=difficulty;
|
||||
const action=botActions(view).find(row=>row.type==='emperorAuctionGiftContinue');assert.ok(action);
|
||||
assert.doesNotThrow(()=>act(game,'e',action));
|
||||
}
|
||||
game=act(game,'e',{type:'emperorAuctionGiftContinue',event:f.event});
|
||||
assert.equal(game.decision?.kind,'nexusEmperorPayment');
|
||||
while(game.decision?.kind==='nexusEmperorPayment') {
|
||||
const actor=game.decision.player, offer=viewGame(game,actor).nexusEmperorPayment;assert.ok(offer);
|
||||
game=act(game,actor,{type:'nexusEmperorPayment',event:offer.event,use:use&&actor==='a'});
|
||||
}
|
||||
const receipt=game.nexusEmperorPayment!.completed.at(-1)!;
|
||||
assert.equal(receipt.stage,use?'used':'passed');
|
||||
assert.equal(game.nexusCards!.cards!.hands.a,use?null:'emperor');
|
||||
if(!use) {
|
||||
assert.equal(game.decision?.kind,'auctionPayment');
|
||||
assert.equal(game.decision.player,'h');
|
||||
assert.equal(seat(game,'h').spice,cancel?4:6);
|
||||
assert.ok(seat(game,'h').hand.some(card=>card.id===f.buyerKarama));
|
||||
game=act(game,'h',{type:'decision',karama:false});
|
||||
}
|
||||
assert.equal(seat(game,'h').spice,use?(cancel?4:6):(cancel?0:2));
|
||||
assert.equal(seat(game,'e').spice,use?(cancel?2:0):(cancel?6:4));
|
||||
assert.equal(game.response?.kind,'emperorIncome');
|
||||
assert.equal(game.currentAuctionSale?.winner,'h');assert.equal(game.currentAuctionSale?.amount,6);
|
||||
assert.equal(seat(game,'h').hand.filter(card=>card.id===f.lot).length,1);
|
||||
game=passResponse(game);
|
||||
assert.equal(seat(game,'e').spice,use?(cancel?8:6):(cancel?12:10));
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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} from '../game/bots';
|
||||
import {DIFFICULTIES} from '../game/bot-profiles';
|
||||
import {takeKullCard} from './fixture-choam-kull';
|
||||
import {advanceSemutaNexusTrade, nextSemutaNexusTradeNativeStep} from './fixture-semuta-nexus-trade';
|
||||
import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards';
|
||||
|
||||
const act=(g:Game,id:string,action:Action)=>applyAction(g,id,action);
|
||||
const player=(g:Game,id:string)=>{const p=g.players.find(row=>row.id===id);assert.ok(p);return p;};
|
||||
const quiet=(g:Game)=>!g.phaseOpening&&!g.response&&!g.decision&&!g.truthtrance&&!g.pendingTreacheryDiscard;
|
||||
function inventory(g:Game) {
|
||||
const cards=[...g.deck,...g.discard,...(g.richeseCache??[]),...(g.richeseRemoved??[]),
|
||||
...(g.auction?.cards.slice(g.auction.index+Number(g.currentAuctionSale?.origin==='normal'))??[]),
|
||||
...(g.ixAuction?.cards??[]),...g.players.flatMap(row=>row.hand)].map(card=>card.id).sort();
|
||||
assert.equal(new Set(cards).size,cards.length);return cards;
|
||||
}
|
||||
function passResponse(g:Game) {
|
||||
for(let n=0;g.response&&n<g.players.length+2;n++) {
|
||||
const seat=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(seat);
|
||||
g=act(g,seat.id,{type:'passResponse'});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function passSemuta(g:Game) {
|
||||
for(let n=0;g.pendingTreacheryDiscard?.reaction?.stage==='offer'&&n<g.players.length;n++) {
|
||||
const pending=g.pendingTreacheryDiscard, reaction=pending.reaction;
|
||||
assert.ok(reaction&&reaction.stage==='offer');
|
||||
const seat=g.players.find(p=>!reaction.passed.includes(p.id));assert.ok(seat);
|
||||
g=act(g,seat.id,{type:'semutaPass',event:pending.batch.event});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Genuine public Basic setup, qualifying worm, alliance and paid cache bid.
|
||||
* Staged only unopened Spice, reserve positions, purses and conserved physical
|
||||
* hand/deck ownership before Ix's auction pool is drawn. */
|
||||
function fixture(method:'onceAround'|'silent') {
|
||||
let g=createGame('BASICRICHGIFT',newPlayer('e','Emperor','emperor'),false,['choam','ix']);
|
||||
for(const [id,faction] of [['g','guild'],['c','choam'],['r','richese'],['a','atreides']] as const)
|
||||
joinGame(g,newPlayer(id,faction,faction));
|
||||
for(const p of g.players) g=act(g,p.id,{type:'ready'});
|
||||
g=act(g,'e',{type:'start'});
|
||||
assert.equal(g.kullPreview,true);assert.equal(g.semutaPreview,true);
|
||||
g=advanceSemutaNexusTrade(g,s=>s.status==='playing');
|
||||
for(const p of g.players){p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};}
|
||||
g=advanceSemutaNexusTrade(g,beforeIxSpiceDraw);orderIxSpiceCards(g,['land','land']);
|
||||
g=advanceSemutaNexusTrade(g,s=>s.turn===2&&beforeIxSpiceDraw(s));orderIxSpiceCards(g,['worm','land','land']);
|
||||
g=advanceSemutaNexusTrade(g,s=>!!s.nexus&&!s.spiceWindow&&quiet(s));
|
||||
g=act(g,'e',{type:'alliance',target:'g'});g=act(g,'g',{type:'alliance',target:'e'});
|
||||
g=advanceSemutaNexusTrade(g,s=>s.phase===3&&s.phaseOpening?.initialize===true);
|
||||
for(const p of g.players)g.deck.push(...p.hand.splice(0));
|
||||
const semuta=takeKullCard(g,'richese-semuta-drug');player(g,'r').hand.push(semuta);
|
||||
const kull=takeKullCard(g,'ix-kull-wahad');player(g,'c').hand.push(kull);
|
||||
const karama=takeKullCard(g,'karama');player(g,'a').hand.push(karama);
|
||||
player(g,'g').spice=7;player(g,'e').spice=8;
|
||||
g=advanceSemutaNexusTrade(g,s=>s.decision?.kind==='richeseCache');
|
||||
const selected=g.richeseCache!.find(card=>card.id!==semuta.id);assert.ok(selected);
|
||||
const original=inventory(g),card=selected.id;
|
||||
g=act(g,'r',{type:'decision',event:g.richeseBidding!.event,card,method,
|
||||
...(method==='onceAround'?{direction:'clockwise'}:{})});
|
||||
g=act(g,'e',{type:'pledgeAid',amount:1});
|
||||
let bid=false;
|
||||
for(let n=0;!g.pendingEmperorAuctionGift&&n<300;n++) {
|
||||
const lot=g.richeseAuction;
|
||||
if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='onceAround'&&lot.active) {
|
||||
const seat=lot.active, buy=seat==='g'&&!bid;
|
||||
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:null,...(buy?{allyPayment:1}:{})});
|
||||
if(buy)bid=true;
|
||||
} else if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='silent') {
|
||||
const seat=lot.eligible.find(id=>!Object.hasOwn(lot.sealed,id));assert.ok(seat);
|
||||
const buy=seat==='g'&&!bid;
|
||||
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:0,...(buy?{allyPayment:1}:{})});
|
||||
if(buy)bid=true;
|
||||
} else {
|
||||
const next=nextSemutaNexusTradeNativeStep(g);g=act(g,next.actor,next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(bid,true);assert.ok(g.pendingEmperorAuctionGift,'Emperor receives a prepayment opportunity');
|
||||
assert.equal(g.richeseAuction?.outcome?.kind,'sold');assert.equal(g.currentAuctionSale??null,null);
|
||||
assert.equal(viewGame(g,'e').emperorAuctionGift?.recipient,'g');
|
||||
return {g,card,kull:kull.id,karama:karama.id,original};
|
||||
}
|
||||
function finishSale(g:Game, card:string) {
|
||||
const pending=g.pendingEmperorAuctionGift;assert.ok(pending);
|
||||
g=act(g,'e',{type:'emperorAuctionGiftContinue',event:pending.event});
|
||||
g=passResponse(g);
|
||||
assert.ok(player(g,'g').hand.some(row=>row.id===card));
|
||||
return g;
|
||||
}
|
||||
|
||||
for(const method of ['onceAround','silent'] as const) for(const share of [false,true]) void test(
|
||||
`Basic ${method} cache sale retains committed split after Emperor ${share?'gift':'continue'}`,()=>{
|
||||
const f=fixture(method),before=structuredClone(f.g.richeseAuction), funding=structuredClone(f.g.richeseFunding);
|
||||
const e=player(f.g,'e').spice,buyer=player(f.g,'g').spice,seller=player(f.g,'r').spice;
|
||||
const view=viewGame(f.g,'e');for(const level of DIFFICULTIES) {
|
||||
const bot=structuredClone(view);bot.players.find(row=>row.id==='e')!.bot=level;
|
||||
const choice=botActions(bot).find(action=>action.type==='emperorAuctionGiftContinue');assert.ok(choice);
|
||||
assert.doesNotThrow(()=>act(f.g,'e',choice));
|
||||
}
|
||||
let g=f.g;
|
||||
if(share){g=act(g,'e',{type:'emperorGift',amount:2});g=passResponse(g);}
|
||||
assert.deepEqual(g.richeseAuction,before);assert.deepEqual(g.richeseFunding,funding);
|
||||
assert.equal(player(g,'e').spice,e-(share?2:0));assert.equal(player(g,'g').spice,buyer+(share?2:0));
|
||||
g=finishSale(g,f.card);
|
||||
assert.equal(player(g,'g').spice,buyer+(share?2:0)-2);
|
||||
assert.equal(player(g,'e').spice,e-(share?2:0));
|
||||
assert.equal(g.aid.e?.amount??0,0);
|
||||
assert.equal(player(g,'r').spice,seller+3);assert.deepEqual(inventory(g),f.original);
|
||||
});
|
||||
|
||||
for(const claim of [false,true]) void test(`Basic cache sale gift cancellation survives Kull decline and Semuta ${claim?'claim':'pass'}`,()=>{
|
||||
const f=fixture('onceAround'),e=player(f.g,'e').spice,buyer=player(f.g,'g').spice;
|
||||
const event=f.g.pendingEmperorAuctionGift!.event;
|
||||
let g=act(f.g,'e',{type:'emperorGift',amount:2});g=act(g,'a',{type:'card',mode:'cancel',card:f.karama});
|
||||
assert.equal(g.pendingKull?.stage,'offer');g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,decline:true});
|
||||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');const discard=g.pendingTreacheryDiscard!.batch.event;
|
||||
if(claim)g=act(g,'r',{type:'semutaCommit',event:discard});else g=passSemuta(g);
|
||||
assert.equal(player(g,'r').hand.some(card=>card.id===f.karama),claim);
|
||||
assert.equal(player(g,'e').spice,e);assert.equal(player(g,'g').spice,buyer);
|
||||
assert.equal(g.pendingEmperorAuctionGift?.event,event);g=finishSale(g,f.card);
|
||||
assert.equal(player(g,'e').spice,e);assert.equal(g.aid.e?.amount??0,0);
|
||||
assert.equal(player(g,'g').spice,buyer-2);
|
||||
assert.deepEqual(inventory(g),f.original);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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 {takeKullCard} from './fixture-choam-kull';
|
||||
import {advanceSemutaNexusTrade, nextSemutaNexusTradeNativeStep} from './fixture-semuta-nexus-trade';
|
||||
import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards';
|
||||
|
||||
const act=(g:Game,id:string,action:Action)=>applyAction(g,id,action);
|
||||
const player=(g:Game,id:string)=>{const p=g.players.find(row=>row.id===id);assert.ok(p);return p;};
|
||||
const quiet=(g:Game)=>!g.phaseOpening&&!g.response&&!g.decision&&!g.truthtrance&&!g.pendingTreacheryDiscard;
|
||||
function inventory(g:Game) {
|
||||
const cards=[...g.deck,...g.discard,...(g.richeseCache??[]),...(g.richeseRemoved??[]),
|
||||
...(g.auction?.cards.slice(g.auction.index+Number(g.currentAuctionSale?.origin==='normal'))??[]),
|
||||
...(g.ixAuction?.cards??[]),...g.players.flatMap(row=>row.hand)].map(card=>card.id).sort();
|
||||
assert.equal(new Set(cards).size,cards.length);return cards;
|
||||
}
|
||||
function passResponse(g:Game) {
|
||||
for(let n=0;g.response&&n<g.players.length+2;n++) {
|
||||
const seat=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(seat);
|
||||
g=act(g,seat.id,{type:'passResponse'});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function passSemuta(g:Game) {
|
||||
for(let n=0;g.pendingTreacheryDiscard?.reaction?.stage==='offer'&&n<g.players.length;n++) {
|
||||
const pending=g.pendingTreacheryDiscard, reaction=pending.reaction;
|
||||
assert.ok(reaction&&reaction.stage==='offer');
|
||||
const seat=g.players.find(p=>!reaction.passed.includes(p.id));assert.ok(seat);
|
||||
g=act(g,seat.id,{type:'semutaPass',event:pending.batch.event});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Genuine public Basic setup, qualifying worm, alliance and paid cache bid.
|
||||
* Staged only unopened Spice, reserve positions, purses and conserved physical
|
||||
* hand/deck ownership before Ix's auction pool is drawn. */
|
||||
function fixture(method:'onceAround'|'silent') {
|
||||
let g=createGame('BASICRICHGIFT',newPlayer('e','Emperor','emperor'),false,['choam','ix']);
|
||||
for(const [id,faction] of [['g','guild'],['c','choam'],['r','richese'],['a','atreides']] as const)
|
||||
joinGame(g,newPlayer(id,faction,faction));
|
||||
for(const p of g.players) g=act(g,p.id,{type:'ready'});
|
||||
g=act(g,'e',{type:'start'});
|
||||
assert.equal(g.kullPreview,true);assert.equal(g.semutaPreview,true);
|
||||
g=advanceSemutaNexusTrade(g,s=>s.status==='playing');
|
||||
for(const p of g.players){p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};}
|
||||
g=advanceSemutaNexusTrade(g,beforeIxSpiceDraw);orderIxSpiceCards(g,['land','land']);
|
||||
g=advanceSemutaNexusTrade(g,s=>s.turn===2&&beforeIxSpiceDraw(s));orderIxSpiceCards(g,['worm','land','land']);
|
||||
g=advanceSemutaNexusTrade(g,s=>!!s.nexus&&!s.spiceWindow&&quiet(s));
|
||||
g=act(g,'e',{type:'alliance',target:'g'});g=act(g,'g',{type:'alliance',target:'e'});
|
||||
g=advanceSemutaNexusTrade(g,s=>s.phase===3&&s.phaseOpening?.initialize===true);
|
||||
for(const p of g.players)g.deck.push(...p.hand.splice(0));
|
||||
const semuta=takeKullCard(g,'richese-semuta-drug');player(g,'r').hand.push(semuta);
|
||||
const kull=takeKullCard(g,'ix-kull-wahad');player(g,'c').hand.push(kull);
|
||||
const karama=takeKullCard(g,'karama');player(g,'a').hand.push(karama);
|
||||
const counter=takeKullCard(g,'karama');player(g,'g').hand.push(counter);
|
||||
player(g,'g').spice=7;player(g,'e').spice=8;
|
||||
g=advanceSemutaNexusTrade(g,s=>s.decision?.kind==='richeseCache');
|
||||
const selected=g.richeseCache!.find(card=>card.id!==semuta.id);assert.ok(selected);
|
||||
const original=inventory(g),card=selected.id;
|
||||
g=act(g,'r',{type:'decision',event:g.richeseBidding!.event,card,method,
|
||||
...(method==='onceAround'?{direction:'clockwise'}:{})});
|
||||
g=act(g,'e',{type:'pledgeAid',amount:1});
|
||||
let bid=false;
|
||||
for(let n=0;!g.pendingEmperorAuctionGift&&n<300;n++) {
|
||||
const lot=g.richeseAuction;
|
||||
if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='onceAround'&&lot.active) {
|
||||
const seat=lot.active, buy=seat==='g'&&!bid;
|
||||
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:null,...(buy?{allyPayment:1}:{})});
|
||||
if(buy)bid=true;
|
||||
} else if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='silent') {
|
||||
const seat=lot.eligible.find(id=>!Object.hasOwn(lot.sealed,id));assert.ok(seat);
|
||||
const buy=seat==='g'&&!bid;
|
||||
g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:0,...(buy?{allyPayment:1}:{})});
|
||||
if(buy)bid=true;
|
||||
} else {
|
||||
const next=nextSemutaNexusTradeNativeStep(g);g=act(g,next.actor,next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(bid,true);assert.ok(g.pendingEmperorAuctionGift,'Emperor receives a prepayment opportunity');
|
||||
assert.equal(g.richeseAuction?.outcome?.kind,'sold');assert.equal(g.currentAuctionSale??null,null);
|
||||
assert.equal(viewGame(g,'e').emperorAuctionGift?.recipient,'g');
|
||||
return {g,card,kull:kull.id,karama:karama.id,counter:counter.id,semuta:semuta.id,original};
|
||||
}
|
||||
function finishSale(g:Game, card:string) {
|
||||
const pending=g.pendingEmperorAuctionGift;assert.ok(pending);
|
||||
g=act(g,'e',{type:'emperorAuctionGiftContinue',event:pending.event});
|
||||
g=passResponse(g);
|
||||
assert.ok(player(g,'g').hand.some(row=>row.id===card));
|
||||
return g;
|
||||
}
|
||||
|
||||
for(const counterKull of [false,true]) for(const claim of [false,true]) void test(
|
||||
`Basic cache gift Kull ${counterKull?'countered':'succeeds'} and Semuta ${claim?'claims':'passes'} preserve the original sale`,()=>{
|
||||
const f=fixture('onceAround'), auction=structuredClone(f.g.richeseAuction);
|
||||
const funding=structuredClone(f.g.richeseFunding), pending=structuredClone(f.g.pendingEmperorAuctionGift);
|
||||
const emperor=player(f.g,'e').spice, buyer=player(f.g,'g').spice, seller=player(f.g,'r').spice;
|
||||
let g=act(f.g,'e',{type:'emperorGift',amount:2});
|
||||
g=act(g,'a',{type:'card',mode:'cancel',card:f.karama});
|
||||
assert.equal(g.pendingKull?.stage,'offer');
|
||||
g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull});
|
||||
assert.equal(g.pendingKull?.stage,'counter');
|
||||
if(counterKull) {
|
||||
assert.ok(viewGame(g,'g').responseControls?.cancelCards.includes(f.counter));
|
||||
g=act(g,'g',{type:'card',mode:'cancel',card:f.counter});
|
||||
} else g=passResponse(g);
|
||||
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,counterKull?f.counter:f.kull);
|
||||
const discard=g.pendingTreacheryDiscard!.batch.event;
|
||||
if(claim)g=act(g,'r',{type:'semutaCommit',event:discard});else g=passSemuta(g);
|
||||
assert.equal(player(g,'r').hand.some(card=>card.id===(counterKull?f.counter:f.kull)),claim);
|
||||
if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g);
|
||||
if(g.response)g=passResponse(g);
|
||||
assert.deepEqual(g.pendingEmperorAuctionGift,pending);
|
||||
assert.deepEqual(g.richeseAuction,auction);assert.deepEqual(g.richeseFunding,funding);
|
||||
assert.equal(player(g,'e').spice,emperor-(counterKull?0:2));
|
||||
assert.equal(player(g,'g').spice,buyer+(counterKull?0:2));
|
||||
assert.equal(player(g,'a').hand.some(card=>card.id===f.karama),!counterKull);
|
||||
assert.equal(g.discard.some(card=>card.id===f.semuta),claim);
|
||||
g=finishSale(g,f.card);
|
||||
assert.equal(player(g,'g').hand.filter(card=>card.id===f.card).length,1);
|
||||
assert.equal(player(g,'g').spice,buyer+(counterKull?0:2)-2);
|
||||
assert.equal(player(g,'e').spice,emperor-(counterKull?0:2));
|
||||
assert.equal(player(g,'r').spice,seller+3);
|
||||
assert.equal(g.aid.e?.amount??0,0);
|
||||
assert.deepEqual(inventory(g),f.original);
|
||||
});
|
||||
@@ -65,6 +65,10 @@ export function stepNexusEmperorPayment(game: Game, step: NexusEmperorPaymentSte
|
||||
return scalarEntropy(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function nextNexusEmperorPaymentStep(game: Game): NexusEmperorPaymentStep {
|
||||
if (!game.response && game.pendingEmperorAuctionGift) {
|
||||
const pending = game.pendingEmperorAuctionGift;
|
||||
return { actor: pending.owner, action: { type: 'emperorAuctionGiftContinue', event: pending.event } };
|
||||
}
|
||||
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
|
||||
assert.ok(actor);
|
||||
|
||||
@@ -14,8 +14,8 @@ function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const prepaidAid of [0, 1]) for (const cancelIncome of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} genuine allied purchase reuses ${prepaidAid} prepaid spice and ${cancelIncome ? 'cancels' : 'allows'} original Emperor income`, () => {
|
||||
for (const prepaidAid of [0, 1]) for (const cancelIncome of (advanced ? [false] : [false, true])) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} genuine allied purchase reuses ${prepaidAid} prepaid spice and ${advanced ? 'sends Emperor contribution to Bank' : `${cancelIncome ? 'cancels' : 'allows'} original Emperor income`}`, () => {
|
||||
const f = createNexusEmperorPaymentFixture({ advanced, prepaidAid });
|
||||
assert.equal(f.game.decision!.player, f.neutral);
|
||||
assert.deepEqual(f.game.pendingNexusEmperorPayment!.receipt.required, [f.neutral, f.owner]);
|
||||
@@ -51,6 +51,20 @@ for (const advanced of [false, true]) {
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor - (f.price - prepaidAid),
|
||||
'Prepaid credit is already deducted; only the missing spice is newly escrowed');
|
||||
assert.deepEqual(game.aid[f.emperor], { recipient: f.winner, amount: 0 });
|
||||
if (advanced) {
|
||||
// The entire invoice is now Emperor's contribution, which Advanced
|
||||
// sends to Bank. No Emperor income exists to cancel; the original lot
|
||||
// therefore advances directly to the next Atreides inspection.
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperorBeforeAid - f.price);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.equal(game.auction!.index, f.game.auction!.index + 1);
|
||||
assert.equal(game.response!.kind, 'atreidesAuction');
|
||||
assert.equal(game.nexusEmperorPayment!.completed.length, 1);
|
||||
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
|
||||
assert.deepEqual(nexusEmperorPaymentCards(game), f.originalCards);
|
||||
nexusEmperorPaymentInventory(game);
|
||||
return;
|
||||
}
|
||||
assert.equal(game.auction!.bid, f.price); assert.equal(game.auction!.bidder, f.winner);
|
||||
assert.equal(game.auction!.allyPayment, f.price);
|
||||
assert.equal(game.auction!.index, f.game.auction!.index);
|
||||
@@ -107,7 +121,7 @@ for (const advanced of [false, true]) {
|
||||
game = stepNexusEmperorPayment(game, { actor: f.winner, action: { type: 'decision', karama: false } });
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner - f.price + prepaidAid);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
|
||||
assert.equal(game.response!.kind, 'emperorIncome');
|
||||
assert.equal(game.response!.kind, advanced && prepaidAid === f.price ? 'atreidesAuction' : 'emperorIncome');
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.equal(game.nexusEmperorPayment!.completed.length, 1, 'Ordinary settlement does not reopen the same obligation');
|
||||
nexusEmperorPaymentInventory(game);
|
||||
|
||||
Reference in new issue
Block a user