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.
294 lines
9.3 KiB
TypeScript
294 lines
9.3 KiB
TypeScript
import { reorderOrderedOpportunity } from './ordered-opportunity';
|
|
/** Auction mechanics only. The engine owns cards, funding, reactions and phase timing. */
|
|
export type RicheseAuctionMethod = 'normal' | 'onceAround' | 'silent';
|
|
export type RicheseAuctionSource = 'cache' | 'blackMarket';
|
|
export type RicheseAuctionOutcome =
|
|
| { kind: 'sold'; winner: string; amount: number }
|
|
| { kind: 'unbid' };
|
|
export type RicheseAuction = {
|
|
event: string;
|
|
cardId: string;
|
|
source: RicheseAuctionSource;
|
|
owner: string;
|
|
method: RicheseAuctionMethod;
|
|
eligible: string[];
|
|
order: string[];
|
|
/** Original native normal-lot opener; Sapho changes this lot's cycle only. */
|
|
saphoOriginalOpener?: string;
|
|
tieOrder: string[];
|
|
active: string | null;
|
|
bid: number;
|
|
bidder: string | null;
|
|
passed: string[];
|
|
acted: string[];
|
|
sealed: Record<string, number>;
|
|
outcome: RicheseAuctionOutcome | null;
|
|
};
|
|
export type RicheseAuctionInput = Pick<
|
|
RicheseAuction,
|
|
'event' | 'cardId' | 'source' | 'owner' | 'method'
|
|
> & {
|
|
eligible: readonly string[];
|
|
/** Physical direction for Once Around, ordinary opener order for normal. */
|
|
order: readonly string[];
|
|
/** Storm order, independent of direction and the ordinary opener. */
|
|
tieOrder: readonly string[];
|
|
};
|
|
export type RicheseBid = {
|
|
event: string;
|
|
actor: string;
|
|
amount: number | null;
|
|
};
|
|
export type RicheseAuctionView = Omit<RicheseAuction, 'sealed' | 'cardId'> & {
|
|
/** Black Market identity remains owner-only; the engine owns authorized inspection. */
|
|
cardId: string | null;
|
|
ownBid: number | null;
|
|
submitted: string[];
|
|
/** Simultaneous physical bids become public only after all have submitted. */
|
|
revealedBids: Record<string, number> | null;
|
|
};
|
|
|
|
function requireAuction(
|
|
condition: unknown,
|
|
message: string,
|
|
): asserts condition {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
function uniqueIds(ids: readonly string[], name: string) {
|
|
requireAuction(
|
|
ids.every((id) => typeof id === 'string' && id.length > 0) &&
|
|
new Set(ids).size === ids.length,
|
|
`${name} must contain distinct player IDs.`,
|
|
);
|
|
}
|
|
function finish(state: RicheseAuction) {
|
|
state.active = null;
|
|
state.outcome = state.bidder
|
|
? { kind: 'sold', winner: state.bidder, amount: state.bid }
|
|
: { kind: 'unbid' };
|
|
}
|
|
function advanceOnce(state: RicheseAuction) {
|
|
const remaining = state.order.filter((id) => !state.acted.includes(id));
|
|
// A cache free/remove or Black Market retain choice belongs to the engine.
|
|
if (
|
|
!remaining.length ||
|
|
(!state.bidder && remaining.every((id) => id === state.owner))
|
|
)
|
|
finish(state);
|
|
else state.active = remaining[0];
|
|
}
|
|
|
|
export function createRicheseAuction(
|
|
input: RicheseAuctionInput,
|
|
): RicheseAuction {
|
|
requireAuction(
|
|
typeof input.event === 'string' &&
|
|
input.event.length > 0 &&
|
|
typeof input.cardId === 'string' &&
|
|
input.cardId.length > 0 &&
|
|
typeof input.owner === 'string' &&
|
|
input.owner.length > 0,
|
|
'The auction needs an event, physical card and owner.',
|
|
);
|
|
requireAuction(
|
|
['normal', 'onceAround', 'silent'].includes(input.method),
|
|
'Unknown auction method.',
|
|
);
|
|
requireAuction(
|
|
['cache', 'blackMarket'].includes(input.source),
|
|
'Unknown auction source.',
|
|
);
|
|
requireAuction(
|
|
input.source !== 'cache' || input.method !== 'normal',
|
|
'A cache card requires a Once Around or Silent auction.',
|
|
);
|
|
uniqueIds(input.eligible, 'Eligibility');
|
|
uniqueIds(input.order, 'Auction order');
|
|
uniqueIds(input.tieOrder, 'Storm order');
|
|
requireAuction(
|
|
input.eligible.every(
|
|
(id) => input.order.includes(id) && input.tieOrder.includes(id),
|
|
),
|
|
'Every eligible player needs an auction and storm position.',
|
|
);
|
|
const order = input.order.filter((id) => input.eligible.includes(id));
|
|
if (input.method === 'onceAround' && order.includes(input.owner)) {
|
|
order.splice(order.indexOf(input.owner), 1);
|
|
order.push(input.owner);
|
|
}
|
|
const state: RicheseAuction = {
|
|
event: input.event,
|
|
cardId: input.cardId,
|
|
source: input.source,
|
|
owner: input.owner,
|
|
method: input.method,
|
|
eligible: [...input.eligible],
|
|
order,
|
|
tieOrder: input.tieOrder.filter((id) => input.eligible.includes(id)),
|
|
active: input.method === 'silent' ? null : (order[0] ?? null),
|
|
bid: 0,
|
|
bidder: null,
|
|
passed: [],
|
|
acted: [],
|
|
sealed: {},
|
|
outcome: null,
|
|
};
|
|
if (!order.length) finish(state);
|
|
else if (state.method === 'onceAround') advanceOnce(state);
|
|
return state;
|
|
}
|
|
|
|
/** Trusted maximum is supplied afresh by the engine, never read from a client bid. */
|
|
export function submitRicheseBid(
|
|
previous: RicheseAuction,
|
|
input: RicheseBid,
|
|
maxFundedBid: number,
|
|
): RicheseAuction {
|
|
requireAuction(
|
|
input.event === previous.event,
|
|
'This auction event has expired.',
|
|
);
|
|
requireAuction(!previous.outcome, 'This auction is already complete.');
|
|
requireAuction(
|
|
previous.eligible.includes(input.actor),
|
|
'This player cannot bid on this lot.',
|
|
);
|
|
requireAuction(
|
|
Number.isSafeInteger(maxFundedBid) && maxFundedBid >= 0,
|
|
'The funded bid limit must be a nonnegative integer.',
|
|
);
|
|
if (previous.method === 'silent') {
|
|
requireAuction(
|
|
!Object.hasOwn(previous.sealed, input.actor),
|
|
'This player already submitted a sealed bid.',
|
|
);
|
|
requireAuction(
|
|
input.amount !== null,
|
|
'Submit zero to decline a Silent auction.',
|
|
);
|
|
} else requireAuction(previous.active === input.actor, 'Wait for your bid.');
|
|
if (input.amount !== null) {
|
|
requireAuction(
|
|
Number.isSafeInteger(input.amount) && input.amount >= 0,
|
|
'Bid a nonnegative integer amount.',
|
|
);
|
|
requireAuction(
|
|
input.amount <= maxFundedBid,
|
|
'The bid exceeds its available funding.',
|
|
);
|
|
if (previous.method !== 'silent')
|
|
requireAuction(
|
|
input.amount > previous.bid,
|
|
'Raise the highest bid or pass.',
|
|
);
|
|
}
|
|
const state = structuredClone(previous);
|
|
if (state.method === 'silent') {
|
|
state.sealed = { ...state.sealed, [input.actor]: input.amount! };
|
|
state.acted.push(input.actor);
|
|
if (state.eligible.every((id) => Object.hasOwn(state.sealed, id))) {
|
|
// Traversal in storm order makes equal offers keep the earliest player.
|
|
for (const id of state.tieOrder) {
|
|
if (state.sealed[id] > state.bid) {
|
|
state.bid = state.sealed[id];
|
|
state.bidder = id;
|
|
}
|
|
}
|
|
finish(state);
|
|
}
|
|
return state;
|
|
}
|
|
if (input.amount === null) state.passed.push(input.actor);
|
|
else {
|
|
state.bid = input.amount;
|
|
state.bidder = input.actor;
|
|
if (state.method === 'normal') state.passed = [];
|
|
}
|
|
if (state.method === 'onceAround') {
|
|
state.acted.push(input.actor);
|
|
advanceOnce(state);
|
|
} else if (
|
|
state.eligible.every(
|
|
(id) => id === state.bidder || state.passed.includes(id),
|
|
)
|
|
) {
|
|
finish(state);
|
|
} else {
|
|
const at = state.order.indexOf(input.actor);
|
|
for (let distance = 1; distance <= state.order.length; distance++) {
|
|
const next = state.order[(at + distance) % state.order.length];
|
|
if (state.eligible.includes(next) && next !== state.bidder && !state.passed.includes(next)) {
|
|
state.active = next;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return state;
|
|
}
|
|
|
|
/**
|
|
* Internal card-effect hook. Engine validates card custody and printed timing.
|
|
* Never restore owner-last here: a legal Juice of Sapho effect can override it.
|
|
*/
|
|
export function moveRicheseBidderLast(
|
|
previous: RicheseAuction,
|
|
event: string,
|
|
player: string,
|
|
): RicheseAuction {
|
|
return reorderRicheseBidder(previous, event, player, 'last');
|
|
}
|
|
|
|
/** A finite Once Around lot; completed bids and physical storm tie order persist. */
|
|
export function reorderRicheseBidder(
|
|
previous: RicheseAuction,
|
|
event: string,
|
|
player: string,
|
|
position: 'first' | 'last',
|
|
): RicheseAuction {
|
|
requireAuction(event === previous.event, 'This auction event has expired.');
|
|
requireAuction(
|
|
!previous.outcome && previous.method === 'onceAround',
|
|
'An open Once Around auction is required.',
|
|
);
|
|
requireAuction(
|
|
previous.eligible.includes(player) && !previous.acted.includes(player),
|
|
'Only an eligible player who has not bid can change position.',
|
|
);
|
|
const completed = previous.order.filter((id) => previous.acted.includes(id));
|
|
const remaining = previous.order.filter((id) => !previous.acted.includes(id));
|
|
const ordered = reorderOrderedOpportunity(
|
|
{
|
|
event: previous.event,
|
|
eligible: previous.eligible,
|
|
completed,
|
|
remaining,
|
|
current: previous.active,
|
|
currentStarted: false,
|
|
},
|
|
{ event, player, position },
|
|
);
|
|
const state = structuredClone(previous);
|
|
// Keep completed positions as recorded; fill only the still-unacted slots.
|
|
let at = 0;
|
|
state.order = state.order.map((id) =>
|
|
previous.acted.includes(id) ? id : ordered.remaining[at++],
|
|
);
|
|
advanceOnce(state);
|
|
return state;
|
|
}
|
|
|
|
/** No opponent funding limits or sealed values leave the authoritative state. */
|
|
export function projectRicheseAuction(
|
|
state: RicheseAuction,
|
|
viewer: string,
|
|
): RicheseAuctionView {
|
|
const { sealed, cardId, ...publicState } = structuredClone(state);
|
|
return {
|
|
...publicState,
|
|
cardId: state.source === 'cache' || viewer === state.owner ? cardId : null,
|
|
ownBid: Object.hasOwn(sealed, viewer) ? sealed[viewer] : null,
|
|
submitted: state.eligible.filter((id) => Object.hasOwn(sealed, id)),
|
|
revealedBids: state.method === 'silent' && state.outcome ? sealed : null,
|
|
};
|
|
}
|