Add Sapho first and last for cyclic auction lots
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.
This commit is contained in:
1 parent
561152d58e
commit
cb13a4d674
15 files changed
+920
-30
No files matched your search
@@ -0,0 +1,169 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actSaphoCyclic, completeSaphoCyclicAuction, createSaphoCyclicAuctionFixture, saphoCyclicAction,
|
||||
saphoCyclicBid, saphoCyclicLot, saphoCyclicPlayer as player, type SaphoCyclicAuctionFixture,
|
||||
} from './fixture-sapho-cyclic-auction';
|
||||
|
||||
function source(f: SaphoCyclicAuctionFixture) {
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquired).hand.some(card => card.id === f.card));
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(saphoCyclicLot(f.game).bid, 0);
|
||||
assert.equal(player(f.game).hand.some(card => card.id === f.card), true);
|
||||
if (f.kind === 'blackMarket') {
|
||||
assert.equal(f.game.advanced, true);
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'seatPosition' && step.action.position === 5));
|
||||
assert.equal(f.lobby.playerPositions![f.owner], 5,
|
||||
'The actual pre-start circle choice leaves genuine forward first/last options after the native second Storm');
|
||||
assert.equal(f.blackMarketStep!.action.method, 'normal');
|
||||
assert.ok(player(f.afterSetup, f.seller).hand.some(card => card.id === f.target));
|
||||
assert.ok(player(f.game, f.seller).hand.some(card => card.id === f.target));
|
||||
}
|
||||
}
|
||||
function finished(f: SaphoCyclicAuctionFixture, result: ReturnType<typeof completeSaphoCyclicAuction>) {
|
||||
const before = saphoCyclicLot(result.beforeSapho), ordered = saphoCyclicLot(result.ordered);
|
||||
assert.equal(before.bid, 1);
|
||||
assert.equal(ordered.bid, before.bid);
|
||||
assert.equal(ordered.bidder, before.bidder);
|
||||
assert.deepEqual(ordered.passed, before.passed);
|
||||
assert.deepEqual(ordered.funding, before.funding);
|
||||
assert.deepEqual(result.ordered.order, result.beforeSapho.order);
|
||||
assert.deepEqual(result.ordered.players.map(player => player.spice), result.beforeSapho.players.map(player => player.spice));
|
||||
assert.equal(result.ordered.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(result.ordered).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(player(result.beforeSapho).spice - player(result.game).spice, 4,
|
||||
'Native sale debits the final strict winning bid once');
|
||||
assert.equal(player(result.game).hand.filter(card => card.id === f.target).length, 1);
|
||||
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(result.game.order, f.game.order);
|
||||
const raises = result.bids.filter(bid => bid.step.actor === f.owner && bid.step.action.amount !== undefined && bid.step.action.amount !== null);
|
||||
assert.deepEqual(raises.map(bid => bid.step.action.amount), [2, 4], 'Going first/last does not remove ordinary cyclic bidding on a later wrap');
|
||||
assert.throws(() => actSaphoCyclic(result.game, f.owner, result.action), /Sapho|card|hand|opportunity|expired/i);
|
||||
}
|
||||
|
||||
void test('genuine paid Sapho first and last preserve a prior funded raise and ordinary next-lot rotation in Basic and Advanced', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createSaphoCyclicAuctionFixture({ advanced });
|
||||
source(f);
|
||||
for (const mode of ['first', 'last'] as const) {
|
||||
const result = completeSaphoCyclicAuction(f, mode);
|
||||
finished(f, result);
|
||||
assert.equal(result.action.scope, 'cyclicAuction');
|
||||
assert.equal(saphoCyclicLot(result.ordered).active === f.owner, mode === 'first');
|
||||
assert.equal(result.ordered.auction!.opener, f.originalOpener);
|
||||
assert.equal(result.game.auction!.index, f.game.auction!.index + 1);
|
||||
assert.equal(result.game.auction!.opener, (f.originalOpener + 1) % f.game.order.length);
|
||||
assert.equal(result.game.auction!.active, f.game.order[(f.originalOpener + 1) % f.game.order.length]);
|
||||
assert.equal(result.game.auction!.bid, 0);
|
||||
assert.equal(result.game.auction!.bidder, null);
|
||||
assert.deepEqual(result.game.auction!.passed, []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine Advanced Black Market Sapho keeps the original sale price, seller payment and following normal opener', () => {
|
||||
const f = createSaphoCyclicAuctionFixture({ advanced: true, blackMarket: true });
|
||||
source(f);
|
||||
for (const mode of ['first', 'last'] as const) {
|
||||
const result = completeSaphoCyclicAuction(f, mode);
|
||||
finished(f, result);
|
||||
assert.equal(player(result.game, f.seller).hand.some(card => card.id === f.target), false);
|
||||
assert.equal(player(result.game, f.seller).spice - player(result.beforeSapho, f.seller).spice, 4,
|
||||
'The native original seller receives the full sale once');
|
||||
assert.equal(result.game.richeseBidding!.blackMarketSold, true);
|
||||
assert.equal(result.game.auction!.opener, (f.originalOpener + 1) % f.game.order.length);
|
||||
assert.equal(result.game.auction!.active, f.game.order[(f.originalOpener + 1) % f.game.order.length]);
|
||||
assert.equal(result.game.auction!.index, 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual current-first, highest-bidder and passed-holder positions cannot spend Sapho for a no-op or renewed chance', () => {
|
||||
const f = createSaphoCyclicAuctionFixture();
|
||||
const initial = saphoCyclicAction(f.game, 'first');
|
||||
let current = structuredClone(f.game);
|
||||
for (let count = 0; saphoCyclicLot(current).active !== f.owner && count < 8; count++) {
|
||||
const step = saphoCyclicBid(current, null); current = actSaphoCyclic(current, step.actor, step.action);
|
||||
}
|
||||
assert.equal(saphoCyclicLot(current).active, f.owner);
|
||||
assert.equal(viewGame(current, f.owner).saphoOptions.some(option => option.scope === 'cyclicAuction' && option.mode === 'first'), false);
|
||||
assert.throws(() => actSaphoCyclic(current, f.owner, initial), /Sapho|opportunity|expired|no.op/i);
|
||||
const highStep = saphoCyclicBid(current, 1), highest = actSaphoCyclic(current, highStep.actor, highStep.action);
|
||||
assert.equal(saphoCyclicLot(highest).bidder, f.owner);
|
||||
assert.equal(viewGame(highest, f.owner).saphoOptions.some(option => option.scope === 'cyclicAuction'), false);
|
||||
assert.throws(() => actSaphoCyclic(highest, f.owner, initial), /Sapho|opportunity|expired|bid/i);
|
||||
const passStep = saphoCyclicBid(current, null), passed = actSaphoCyclic(current, passStep.actor, passStep.action);
|
||||
assert.ok(saphoCyclicLot(passed).passed.includes(f.owner));
|
||||
assert.equal(viewGame(passed, f.owner).saphoOptions.some(option => option.scope === 'cyclicAuction'), false);
|
||||
assert.throws(() => actSaphoCyclic(passed, f.owner, initial), /Sapho|opportunity|expired|pass/i);
|
||||
for (const game of [current, highest, passed]) {
|
||||
assert.ok(player(game).hand.some(card => card.id === f.card));
|
||||
assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a genuinely full hand from paid purchases gains one current-lot slot by discarding Sapho', () => {
|
||||
const f = createSaphoCyclicAuctionFixture({ fullHand: true });
|
||||
source(f);
|
||||
assert.equal(player(f.game).hand.length, 4);
|
||||
assert.equal(f.actions.filter(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1).length, 2);
|
||||
const result = completeSaphoCyclicAuction(f, 'first');
|
||||
finished(f, result);
|
||||
assert.equal(player(result.ordered).hand.length, 3);
|
||||
assert.equal(saphoCyclicLot(result.ordered).active, f.owner);
|
||||
assert.equal(player(result.game).hand.length, 4);
|
||||
});
|
||||
|
||||
void test('all four minimal policies use legal original ordinary and Black Market cyclic Sapho options', () => {
|
||||
for (const blackMarket of [false, true]) {
|
||||
const f = createSaphoCyclicAuctionFixture({ advanced: blackMarket, blackMarket });
|
||||
const raise = saphoCyclicBid(f.game, 1), game = actSaphoCyclic(f.game, raise.actor, raise.action);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, f.owner);
|
||||
view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'card' && action.card === f.card && action.scope === 'cyclicAuction');
|
||||
assert.ok(action, `${difficulty} retains the owned original cyclic Sapho option`);
|
||||
const next = actSaphoCyclic(structuredClone(game), f.owner, action);
|
||||
assert.deepEqual(next.order, game.order);
|
||||
assert.equal(saphoCyclicLot(next).bid, 1);
|
||||
assert.equal(saphoCyclicLot(next).bidder, saphoCyclicLot(game).bidder);
|
||||
assert.equal(next.discard.filter(card => card.id === f.card).length, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine pending passes survive Sapho and cannot receive a repeat turn before a real raise', () => {
|
||||
for (const blackMarket of [false,true]) {
|
||||
const f = createSaphoCyclicAuctionFixture({advanced:blackMarket,blackMarket});
|
||||
const raised = saphoCyclicBid(f.game,1);
|
||||
let game = actSaphoCyclic(f.game,raised.actor,raised.action);
|
||||
const earlier = saphoCyclicBid(game,null);
|
||||
game = actSaphoCyclic(game,earlier.actor,earlier.action);
|
||||
assert.equal(saphoCyclicLot(game).active,f.owner);
|
||||
const before = saphoCyclicLot(game);
|
||||
game = actSaphoCyclic(game,f.owner,saphoCyclicAction(game,'last'));
|
||||
assert.deepEqual(saphoCyclicLot(game).passed,before.passed);
|
||||
const nextPass = saphoCyclicBid(game,null);
|
||||
game = actSaphoCyclic(game,nextPass.actor,nextPass.action);
|
||||
assert.equal(saphoCyclicLot(game).active,f.owner,
|
||||
'The earlier passed seat is skipped in the changed cycle');
|
||||
const standingPrice = saphoCyclicBid(game,1), snapshot = structuredClone(game);
|
||||
assert.throws(() => actSaphoCyclic(game,standingPrice.actor,standingPrice.action),/bid|raise/i);
|
||||
assert.deepEqual(game,snapshot);
|
||||
const newRaise = saphoCyclicBid(game,2);
|
||||
game = actSaphoCyclic(game,newRaise.actor,newRaise.action);
|
||||
assert.deepEqual(saphoCyclicLot(game).passed,[],
|
||||
'A genuine raise reopens the previously passed native opportunity');
|
||||
assert.deepEqual(game.order,f.game.order);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
reorderSaphoCyclicAuction,
|
||||
SaphoCyclicAuctionError,
|
||||
type SaphoCyclicAuctionContext,
|
||||
type SaphoCyclicAuctionRequest,
|
||||
} from '../game/sapho-cyclic-auction';
|
||||
import { quoteNormalAuctionBid, quoteNormalAuctionNext } from '../game/normal-auction';
|
||||
import { createRicheseAuction, submitRicheseBid, type RicheseAuction } from '../game/richese-auction';
|
||||
import { quoteAuctionContinuation, type AuctionContinuationInput } from '../game/auction-continuation-quote';
|
||||
import { baseDeck } from '../game/cards';
|
||||
|
||||
const context = (): SaphoCyclicAuctionContext => ({
|
||||
event: 'ordinary-lot-2', order: ['a', 'full', 'b', 'c', 'd', 'p'],
|
||||
eligible: ['a', 'b', 'c', 'd', 'p'], active: 'c', bidder: 'a', passed: ['b', 'p'],
|
||||
});
|
||||
const reorder = (state: SaphoCyclicAuctionContext, player: string, mode: 'first' | 'last') =>
|
||||
reorderSaphoCyclicAuction(state, { event: state.event, player, mode });
|
||||
const submit = (state: RicheseAuction, actor: string, amount: number | null, maximum = 20) =>
|
||||
submitRicheseBid(state, { event: state.event, actor, amount }, maximum);
|
||||
|
||||
void test('Sapho first rotates the current lot and preserves all high, passed and full-hand placeholders', () => {
|
||||
const state = context(), before = structuredClone(state);
|
||||
const result = reorder(state, 'd', 'first');
|
||||
assert.deepEqual(result, { order: ['d', 'c', 'p', 'a', 'full', 'b'], active: 'd' });
|
||||
assert.deepEqual([...result.order].sort(), [...state.order].sort());
|
||||
assert.deepEqual(state, before);
|
||||
assert.deepEqual(quoteNormalAuctionNext({ ...state, ...result, bid: 4, passed: [...state.passed, 'd'] }),
|
||||
{ kind: 'bid', player: 'c' });
|
||||
assert.deepEqual(quoteNormalAuctionNext({
|
||||
...state, ...result, active: 'c', bid: 4, passed: [...state.passed, 'd', 'c'],
|
||||
}), { kind: 'payment', player: 'a' });
|
||||
});
|
||||
|
||||
void test('Sapho last lets the current pending actor yield without repeating earlier passes when the cycle wraps', () => {
|
||||
const state = context(), result = reorder(state, 'c', 'last');
|
||||
assert.deepEqual(result, { order: ['d', 'p', 'a', 'full', 'b', 'c'], active: 'd' });
|
||||
assert.deepEqual(quoteNormalAuctionNext({ ...state, ...result, bid: 4, passed: [...state.passed, 'd'] }),
|
||||
{ kind: 'bid', player: 'c' });
|
||||
});
|
||||
|
||||
void test('a currently last pending bidder can meaningfully move behind placeholders that reenter after a raise', () => {
|
||||
const state = context(), result = reorder(state, 'd', 'last');
|
||||
assert.deepEqual(result, { order: ['c', 'p', 'a', 'full', 'b', 'd'], active: 'c' });
|
||||
assert.deepEqual(quoteNormalAuctionNext({ ...state, ...result, bid: 5, bidder: 'c', passed: [] }),
|
||||
{ kind: 'bid', player: 'p' }, 'An actual raise restores passes in the altered full cycle');
|
||||
assert.deepEqual(quoteNormalAuctionNext({ ...state, order: ['c', 'd', 'p', 'a', 'full', 'b'],
|
||||
bid: 5, bidder: 'c', passed: [] }), { kind: 'bid', player: 'd' });
|
||||
});
|
||||
|
||||
void test('true full-cycle no-ops are unavailable but changing the next actor in a two-seat cycle is meaningful', () => {
|
||||
assert.throws(() => reorder(context(), 'c', 'first'), /already.*position/);
|
||||
const simple = { event: 'lot', order: ['a', 'b', 'c'], eligible: ['a', 'b', 'c'],
|
||||
active: 'a', bidder: null, passed: [] };
|
||||
assert.throws(() => reorder(simple, 'c', 'last'), SaphoCyclicAuctionError);
|
||||
const single = { event: 'lot', order: ['a', 'high', 'passed', 'full'], eligible: ['a', 'high', 'passed'],
|
||||
active: 'a', bidder: 'high', passed: ['passed'] };
|
||||
assert.throws(() => reorder(single, 'a', 'first'), SaphoCyclicAuctionError);
|
||||
assert.throws(() => reorder(single, 'a', 'last'), SaphoCyclicAuctionError,
|
||||
'Moving the only pending actor around skipped seats merely rotates the same effective cycle');
|
||||
const pair = { ...simple, order: ['a', 'b'], eligible: ['a', 'b'] };
|
||||
assert.deepEqual(reorder(pair, 'b', 'first'), { order: ['b', 'a'], active: 'b' });
|
||||
assert.deepEqual(reorder(pair, 'a', 'last'), { order: ['b', 'a'], active: 'b' });
|
||||
});
|
||||
|
||||
void test('stale events, invalid permutations, nonpending actors and unavailable holders reject without repairing the lot', () => {
|
||||
const state = context(), before = structuredClone(state);
|
||||
for (const request of [
|
||||
{ event: 'old-lot', player: 'd', mode: 'first' },
|
||||
{ event: state.event, player: 'full', mode: 'first' },
|
||||
{ event: state.event, player: 'b', mode: 'first' },
|
||||
{ event: state.event, player: 'a', mode: 'first' },
|
||||
{ event: state.event, player: 'missing', mode: 'last' },
|
||||
{ event: state.event, player: 'd', mode: 'middle' },
|
||||
]) assert.throws(() => reorderSaphoCyclicAuction(state, request as SaphoCyclicAuctionRequest), SaphoCyclicAuctionError);
|
||||
for (const invalid of [
|
||||
{ ...state, event: '' }, { ...state, order: [] },
|
||||
{ ...state, order: ['a', 'b', 'c', 'd', 'p', 'p'] },
|
||||
{ ...state, order: ['a', 'b', 'c', 'p'] },
|
||||
{ ...state, eligible: [...state.eligible, 'c'] },
|
||||
{ ...state, eligible: [...state.eligible, 'outsider'] },
|
||||
{ ...state, passed: ['p', 'p'] }, { ...state, passed: ['outsider'] },
|
||||
{ ...state, bidder: 'outsider' }, { ...state, bidder: 'p' },
|
||||
{ ...state, active: 'a' }, { ...state, active: 'b' }, { ...state, active: 'full' },
|
||||
]) assert.throws(() => reorder(invalid, 'd', 'first'), SaphoCyclicAuctionError);
|
||||
assert.deepEqual(state, before);
|
||||
});
|
||||
|
||||
void test('ordinary cyclic traversal skips prior passes on wrap and preserves strict raises and terminal outcomes', () => {
|
||||
const state = { order: ['a', 'b', 'c', 'd'], eligible: ['a', 'b', 'c', 'd'],
|
||||
active: 'd', bid: 4, bidder: 'c', passed: ['a', 'd'] };
|
||||
assert.deepEqual(quoteNormalAuctionNext(state), { kind: 'bid', player: 'b' });
|
||||
assert.deepEqual(quoteNormalAuctionNext({ ...state, active: 'b', passed: ['a', 'b', 'd'] }),
|
||||
{ kind: 'payment', player: 'c' });
|
||||
assert.deepEqual(quoteNormalAuctionNext({ ...state, bid: 0, bidder: null, passed: ['a', 'b', 'c', 'd'] }),
|
||||
{ kind: 'unbid' });
|
||||
assert.deepEqual(quoteNormalAuctionBid({ currentBid: 4, amount: 4, maximum: 10 }),
|
||||
{ ok: false, reason: 'Bid must be an integer from 5 to 10.' });
|
||||
assert.deepEqual(quoteNormalAuctionBid({ currentBid: 4, amount: 5, maximum: 10 }), { ok: true, amount: 5 });
|
||||
});
|
||||
|
||||
void test('normal Richese bidding skips an earlier pass after Sapho and a real raise restores that seat', () => {
|
||||
let lot = createRicheseAuction({
|
||||
event: 'black-market-2', cardId: 'physical-card', source: 'blackMarket', owner: 'r', method: 'normal',
|
||||
eligible: ['a', 'b', 'c', 'r'], order: ['a', 'b', 'c', 'r'], tieOrder: ['a', 'b', 'c', 'r'],
|
||||
});
|
||||
lot = submit(lot, 'a', 3);
|
||||
lot = submit(lot, 'b', null);
|
||||
const moved = reorder({ ...lot, active: lot.active! }, 'c', 'last');
|
||||
lot = { ...lot, ...moved, saphoOriginalOpener: lot.order[0] };
|
||||
assert.deepEqual(lot.order, ['r', 'a', 'b', 'c']);
|
||||
assert.equal(lot.active, 'r');
|
||||
lot = submit(lot, 'r', null);
|
||||
assert.equal(lot.active, 'c', 'Neither the high bidder nor the already passed b repeats an opportunity');
|
||||
assert.throws(() => submit(lot, 'c', 3), /Raise/);
|
||||
assert.throws(() => submit(lot, 'c', 4, 3), /funding/);
|
||||
lot = submit(lot, 'c', 4);
|
||||
assert.deepEqual(lot.passed, []);
|
||||
assert.equal(lot.active, 'r');
|
||||
lot = submit(lot, 'r', null);
|
||||
assert.equal(lot.active, 'a');
|
||||
lot = submit(lot, 'a', null);
|
||||
assert.equal(lot.active, 'b', 'The raise made the original passer eligible again');
|
||||
lot = submit(lot, 'b', null);
|
||||
assert.deepEqual(lot.outcome, { kind: 'sold', winner: 'c', amount: 4 });
|
||||
assert.equal(lot.active, null);
|
||||
assert.equal(lot.saphoOriginalOpener, 'a');
|
||||
});
|
||||
|
||||
/** Controlled post-sale quote inputs only; these do not claim an original
|
||||
* action-only game history, card acquisition or payment programme. */
|
||||
function continuation(): AuctionContinuationInput {
|
||||
const cards = baseDeck().slice(0, 2);
|
||||
return {
|
||||
status: 'playing', phase: 3, turn: 2, advanced: true, order: ['a', 'b', 'r'], physicalCards: cards,
|
||||
players: [
|
||||
{ id: 'a', faction: 'atreides', spice: 10, hand: [cards[0]], handLimit: 4 },
|
||||
{ id: 'b', faction: 'fremen', spice: 10, hand: [], handLimit: 4 },
|
||||
{ id: 'r', faction: 'richese', spice: 10, hand: [], handLimit: 4 },
|
||||
],
|
||||
auction: { cards, index: 0, bid: 4, bidder: 'a', active: 'a', passed: ['b', 'r'], opener: 0,
|
||||
saphoOrder: ['r', 'b', 'a'] },
|
||||
sale: { winner: 'a', amount: 4, free: false, origin: 'normal', seller: null },
|
||||
};
|
||||
}
|
||||
|
||||
void test('the next normal lot drops its local Sapho override and retains the original physical opener rotation', () => {
|
||||
const input = continuation(), before = structuredClone(input);
|
||||
const result = quoteAuctionContinuation(input, { kind: 'next' });
|
||||
assert.equal(result.next.kind, 'normalLot');
|
||||
assert.ok(result.next.kind === 'normalLot');
|
||||
assert.equal(result.next.active, 'b');
|
||||
assert.equal(result.next.auction.opener, 1);
|
||||
assert.equal(result.next.auction.index, 1);
|
||||
assert.equal(Object.hasOwn(result.next.auction, 'saphoOrder'), false);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
|
||||
void test('a reordered normal Black Market sale rotates the next opener from its original native first bidder', () => {
|
||||
const input = continuation();
|
||||
let lot = createRicheseAuction({ event: 'black-market-2', cardId: input.auction!.cards[0].id,
|
||||
source: 'blackMarket', owner: 'r', method: 'normal', eligible: ['a', 'b', 'r'],
|
||||
order: ['a', 'b', 'r'], tieOrder: ['a', 'b', 'r'] });
|
||||
lot = submit(lot, 'a', 4);
|
||||
const moved = reorder({ ...lot, active: lot.active! }, 'r', 'first');
|
||||
lot = { ...lot, ...moved, saphoOriginalOpener: lot.order[0] };
|
||||
lot = submit(lot, 'r', null);
|
||||
lot = submit(lot, 'b', null);
|
||||
assert.equal(lot.order[0], 'r');
|
||||
input.auction = null;
|
||||
input.richeseAuction = lot;
|
||||
input.sale = { winner: 'a', amount: 4, free: false, origin: 'blackMarket', seller: 'r' };
|
||||
input.richeseBidding = { owner: 'r', event: 'declaration-2', turn: 2, stage: 'lot', position: null,
|
||||
normalCount: null, blackMarketSold: false, cacheCanceled: false };
|
||||
input.richeseCacheCount = 8;
|
||||
const result = quoteAuctionContinuation(input, { kind: 'next' });
|
||||
assert.ok(result.next.kind === 'richeseEnd');
|
||||
assert.equal(result.next.opener, 1);
|
||||
const legacy = structuredClone(input); delete legacy.richeseAuction!.saphoOriginalOpener;
|
||||
const old = quoteAuctionContinuation(legacy, { kind: 'next' });
|
||||
assert.ok(old.next.kind === 'richeseEnd');
|
||||
assert.equal(old.next.opener, 0, 'Unstamped lots retain their old first-position opener');
|
||||
});
|
||||
Reference in new issue
Block a user