Provisionally apply first/last to the current ordinary or native Advanced normal Black Market lot. Preserve bids, funding and passes; skip passed seats until a real raise. Use a local full cycle and actual post-discard hand capacity. Keep the original Black Market opener in both settlement consumers and clear ordinary overrides for the next lot. Connect persistent controls, native disposal and existing minimal AI options. Validation:10-file134/134, types/lint/build and eight original-action/rendered-table continuations. Public setup/cache1, real lobby circle choice, native Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policies are legal. Corrected fixture opener/completion assumptions and external default-history/final-lot assertions; only runtime was rerun against identical source. Bounded consumer review found no concrete defect. Other timing scopes, direct combined Semuta claim/capture, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
170 lines
11 KiB
TypeScript
170 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
|
|
|
const canonicalCard = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(canonicalCard);
|
|
export const CYCLIC_SAPHO = canonicalCard.id;
|
|
export type SaphoCyclicStep = { actor: string; action: Action };
|
|
export const saphoCyclicPlayer = (game: Game, actor = 'emperor') => {
|
|
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
|
};
|
|
export const saphoCyclicClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
|
!game.truthtrance && !game.pendingTreacheryDiscard && !game.currentAuctionSale;
|
|
export function actSaphoCyclic(game: Game, actor: string, action: Action, actions: SaphoCyclicStep[] = []): Game {
|
|
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
|
actions.push({ actor, action: structuredClone(action) }); return next;
|
|
}
|
|
export function nextSaphoCyclicStep(game: Game, fillHand = false): SaphoCyclicStep | null {
|
|
if (!game.response && !game.phaseOpening) {
|
|
if (game.decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === CYCLIC_SAPHO))
|
|
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
|
card: CYCLIC_SAPHO, method: 'onceAround', direction: 'clockwise' } };
|
|
if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player,
|
|
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
|
}
|
|
if (saphoCyclicClean(game) && game.richeseAuction?.cardId === CYCLIC_SAPHO &&
|
|
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
|
|
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
|
if (fillHand && saphoCyclicClean(game) && game.auction?.active === 'emperor' && game.auction.bid === 0 &&
|
|
saphoCyclicPlayer(game).hand.length < 4)
|
|
return { actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
|
return nextRicheseStrongholdsNativeStep(game);
|
|
}
|
|
export function advanceSaphoCyclic(game: Game, until: (state: Game) => boolean,
|
|
actions: SaphoCyclicStep[] = [], fillHand = false): Game {
|
|
for (let count = 0; count < 1800; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The original Sapho cyclic programme ended before its next owned lot');
|
|
const step = nextSaphoCyclicStep(game, fillHand); assert.ok(step, 'Complete the original private plan before continuing');
|
|
game = actSaphoCyclic(game, step.actor, step.action, actions);
|
|
}
|
|
throw Error('The original Sapho cyclic programme did not reach its finite boundary');
|
|
}
|
|
function orderLand(game: Game) {
|
|
const front: Game['spiceDeck'] = [];
|
|
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
|
|
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
|
front.push(game.spiceDeck.splice(at, 1)[0]);
|
|
}
|
|
game.spiceDeck.unshift(...front);
|
|
}
|
|
|
|
/** Original public host/setup and native cache1 purchase. Full-hand source
|
|
* buys two further real normal lots; Black Market keeps the bought Sapho until
|
|
* the next turn and offers Richese's genuinely dealt card using normal bidding.
|
|
* Its holder chooses a vacant printed circle before readiness, leaving another
|
|
* bidder before and after it at the original second-turn Storm boundary.
|
|
* Only original scalar lotteries and unopened Spice positions are selected. */
|
|
export function createSaphoCyclicAuctionFixture(options: {
|
|
advanced?: boolean; blackMarket?: boolean; fullHand?: boolean;
|
|
} = {}) {
|
|
const advanced = options.advanced ?? false;
|
|
assert.ok(!options.blackMarket || advanced, 'Native Black Market is Advanced-only');
|
|
assert.ok(!options.blackMarket || !options.fullHand);
|
|
let game = createGame('SAPHOCYCLICAUCTION', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
|
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
|
const actions: SaphoCyclicStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actSaphoCyclic(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoCyclic(game, until, actions, options.fullHand); };
|
|
if (options.blackMarket) act('emperor', { type: 'seatPosition', position: 5 });
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
const lobby = structuredClone(game);
|
|
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
|
const setup = structuredClone(game);
|
|
reach(state => state.status === 'playing');
|
|
const afterSetup = structuredClone(game);
|
|
orderLand(game);
|
|
reach(state => saphoCyclicPlayer(state).hand.some(card => card.id === CYCLIC_SAPHO));
|
|
const acquired = structuredClone(game);
|
|
let beforeBlackMarket: Game | null = null, blackMarketStep: SaphoCyclicStep | null = null;
|
|
if (options.blackMarket) {
|
|
reach(state => state.turn === 2 && state.phase === 1 && saphoCyclicClean(state));
|
|
orderLand(game);
|
|
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseBlackMarket');
|
|
const offered = saphoCyclicPlayer(game, 'richese').hand[0]; assert.ok(offered);
|
|
assert.ok(saphoCyclicPlayer(afterSetup, 'richese').hand.some(card => card.id === offered.id),
|
|
'The native seller offers its actual original starting card');
|
|
beforeBlackMarket = structuredClone(game);
|
|
blackMarketStep = { actor: 'richese', action: { type: 'decision', event: game.richeseBidding!.event,
|
|
card: offered.id, method: 'normal' } };
|
|
act(blackMarketStep.actor, blackMarketStep.action);
|
|
reach(state => state.richeseAuction?.method === 'normal' && !state.richeseAuction.outcome && saphoCyclicClean(state));
|
|
} else reach(state => !!state.auction && saphoCyclicClean(state) &&
|
|
(!options.fullHand || saphoCyclicPlayer(state).hand.length === 4));
|
|
const lot = saphoCyclicLot(game);
|
|
return { game, lobby, setup, afterSetup, acquired, beforeBlackMarket, blackMarketStep,
|
|
owner: 'emperor', challenger: 'atreides', seller: 'richese', card: CYCLIC_SAPHO, target: lot.card,
|
|
kind: lot.kind, originalOpener: lot.opener, fullHand: options.fullHand ?? false,
|
|
sourceActionCount: actions.length, actions,
|
|
staging: ['Native host/setup and real cache1 purchase supply Sapho. Optional full-hand holdings are two actual normal1 purchases; Advanced Black Market uses an accepted Emperor circle5 lobby choice and offers the original seller-held card next turn. The native second Storm leaves meaningful first/last choices after a funded prior raise. Only unopened Spice positions/scalar lotteries are selected, with no postdeal custody, funds, phase, order or grant installed.'] };
|
|
}
|
|
export type SaphoCyclicAuctionFixture = ReturnType<typeof createSaphoCyclicAuctionFixture>;
|
|
|
|
/** Current public cyclic mechanics plus exact private source identity for the
|
|
* original owner fixture; normal and Black Market retain their native engines. */
|
|
export function saphoCyclicLot(game: Game) {
|
|
const black = game.richeseAuction;
|
|
if (black?.method === 'normal') return { kind: 'blackMarket' as const, card: black.cardId,
|
|
active: black.active!, bid: black.bid, bidder: black.bidder, passed: [...black.passed],
|
|
opener: game.order.indexOf(black.order[0]), event: black.event, funding: structuredClone(game.richeseFunding) };
|
|
const normal = game.auction; assert.ok(normal, 'An original cyclic lot must be open');
|
|
return { kind: 'normal' as const, card: normal.cards[normal.index].id, active: normal.active,
|
|
bid: normal.bid, bidder: normal.bidder, passed: [...normal.passed], opener: normal.opener,
|
|
event: null, funding: normal.allyPayment ?? 0 };
|
|
}
|
|
export function saphoCyclicBid(game: Game, amount: number | null): SaphoCyclicStep {
|
|
const lot = saphoCyclicLot(game);
|
|
return { actor: lot.active, action: lot.kind === 'blackMarket'
|
|
? { type: 'richeseBid', event: lot.event!, amount, allyPayment: 0 }
|
|
: amount === null ? { type: 'passBid' } : { type: 'bid', amount, allyPayment: 0 } };
|
|
}
|
|
export function saphoCyclicAction(game: Game, mode: 'first' | 'last'): Action {
|
|
const option = viewGame(game, 'emperor').saphoOptions.find(option => option.scope === 'cyclicAuction' && option.mode === mode);
|
|
assert.ok(option, `The actual owned cyclic lot needs a meaningful Sapho-${mode} option`);
|
|
return { type: 'card', card: CYCLIC_SAPHO, ...option };
|
|
}
|
|
|
|
/** A funded prior raise survives Sapho. The holder raises2, a genuine rival
|
|
* raises3 and the holder gets a normal cyclic wrap to raise4. Native payment
|
|
* and the next original lot/control then complete without another Sapho use. */
|
|
export function completeSaphoCyclicAuction(fixture: SaphoCyclicAuctionFixture, mode: 'first' | 'last') {
|
|
let game = structuredClone(fixture.game);
|
|
const actions: SaphoCyclicStep[] = [], bids: { before: Game; step: SaphoCyclicStep; after: Game }[] = [];
|
|
const bid = (amount: number | null) => {
|
|
const before = structuredClone(game), step = saphoCyclicBid(game, amount);
|
|
game = actSaphoCyclic(game, step.actor, step.action, actions);
|
|
bids.push({ before, step, after: structuredClone(game) });
|
|
};
|
|
bid(1);
|
|
const beforeSapho = structuredClone(game), action = saphoCyclicAction(game, mode);
|
|
game = actSaphoCyclic(game, fixture.owner, action, actions);
|
|
const ordered = structuredClone(game);
|
|
let firstRaise = false, rivalRaise = false, lastRaise = false;
|
|
for (let count = 0; count < 240; count++) {
|
|
// A Black Market delivery precedes the original declaration/cache-first
|
|
// programme. Its clean cache-bidding boundary is not the following normal
|
|
// pool whose opener this programme must observe.
|
|
const followingNormal = fixture.kind !== 'blackMarket' ||
|
|
!!game.auction && !game.richeseAuction && game.richeseBidding?.stage === 'normal';
|
|
if (followingNormal && saphoCyclicPlayer(game).hand.some(card => card.id === fixture.target) && saphoCyclicClean(game)) {
|
|
assert.ok(firstRaise && rivalRaise && lastRaise, 'Original cyclic bidding must include the rival and holder wrap');
|
|
return { game, beforeSapho, action, ordered, bids, actions, mode };
|
|
}
|
|
const live = game.auction?.cards[game.auction.index]?.id === fixture.target ||
|
|
game.richeseAuction?.cardId === fixture.target && !game.richeseAuction.outcome;
|
|
if (live && saphoCyclicClean(game)) {
|
|
const lot = saphoCyclicLot(game);
|
|
if (!firstRaise && lot.active === fixture.owner) { bid(2); firstRaise = true; }
|
|
else if (firstRaise && !rivalRaise && lot.active === fixture.challenger) { bid(3); rivalRaise = true; }
|
|
else if (rivalRaise && !lastRaise && lot.active === fixture.owner) { bid(4); lastRaise = true; }
|
|
else bid(null);
|
|
} else {
|
|
const step = nextSaphoCyclicStep(game); assert.ok(step, 'Finish the real purchase continuation before its next lot');
|
|
game = actSaphoCyclic(game, step.actor, step.action, actions);
|
|
}
|
|
}
|
|
throw Error('The genuine Sapho cyclic lot did not settle its finite raise/pass programme');
|
|
}
|