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:
Quintine committed 2026-10-18 13:28:34 +11:00
1 parent 561152d58e
commit cb13a4d674
15 files changed
+920 -30

No files matched your search

+6 -2
View File
@@ -257,6 +257,8 @@ function calculate(
ids(lot.order) &&
lot.order.length > 0 &&
lot.order.every((id) => input.order.includes(id)) &&
(lot.saphoOriginalOpener === undefined ||
text(lot.saphoOriginalOpener) && lot.order.includes(lot.saphoOriginalOpener)) &&
lot.order.includes(s.winner) &&
['normal', 'onceAround', 'silent'].includes(lot.method) &&
!(lot.source === 'cache' && lot.method === 'normal'),
@@ -431,7 +433,7 @@ function calculate(
...(lot.method === 'normal'
? {
opener:
(input.order.indexOf(lot.order[0]) + 1) % input.order.length,
(input.order.indexOf(lot.saphoOriginalOpener ?? lot.order[0]) + 1) % input.order.length,
}
: {}),
});
@@ -481,10 +483,12 @@ function calculate(
: atreides
? { kind: 'atreidesAuction', owner: atreides.id, passed: [] }
: null;
const nextAuction = structuredClone(a);
delete nextAuction.saphoOrder;
return result({
kind: 'normalLot',
auction: {
...structuredClone(a),
...nextAuction,
index,
bid: 0,
allyPayment: 0,
+124 -14
View File
@@ -261,6 +261,7 @@ import {
quoteNormalAuctionBid,
quoteNormalAuctionNext,
} from './normal-auction';
import { reorderSaphoCyclicAuction, SaphoCyclicAuctionError } from './sapho-cyclic-auction';
import {
quoteNormalAuctionPeek,
quoteIxTechnologyCancellation,
@@ -828,6 +829,8 @@ export type Battle = {
voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean };
};
export type Auction = {
/** Sapho changes this lot's cycle, never physical order or the next opener. */
saphoOrder?: string[];
peekKnown?: boolean;
occupiedEvent?: string;
cards: Card[];
@@ -1395,6 +1398,7 @@ export type Game = {
}
| {
kind: 'saphoAuctionDiscard';
cyclic?: 'normal' | 'blackMarket';
player: string;
card: string;
event: string;
@@ -5441,6 +5445,7 @@ type SaphoAuctionDiscardContinuation = Extract<
function saphoAuctionDiscardSignature(g: Game, c: SaphoAuctionDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card, event: c.event, mode: c.mode,
...(c.cyclic === undefined ? {} : { cyclic: c.cyclic, auction: g.auction }),
parent: nullentropyParentSignature(g, c.resume, c.event),
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
lot: g.richeseAuction, bidding: g.richeseBidding, funding: g.richeseFunding,
@@ -6456,21 +6461,27 @@ function treacheryDiscardIntegrity(g: Game) {
} else if (continuation?.kind === 'saphoAuctionDiscard') {
const c = continuation, entry = batch.entries[0], lot = g.richeseAuction;
const remaining = lot?.order.filter(id => !lot.acted.includes(id));
const cyclic = c.cyclic === undefined ? null : saphoCyclicContext(g);
requireRule(
g.phase === 3 && lot?.method === 'onceAround' &&
g.phase === 3 && (c.cyclic === undefined ? lot?.method === 'onceAround' &&
lot.event === c.event && !lot.outcome &&
lot.eligible.includes(c.player) && !lot.acted.includes(c.player) &&
(c.mode === 'first'
? remaining?.[0] === c.player && lot.active === c.player
: c.mode === 'last' && remaining?.at(-1) === c.player) &&
g.active === lot.active && batch.cause === 'sapho:auction' &&
g.active === lot.active : cyclic && cyclic.source === c.cyclic &&
cyclic.event === c.event && cyclic.eligible.includes(c.player) &&
!cyclic.passed.includes(c.player) && cyclic.bidder !== c.player &&
(c.mode === 'first' ? cyclic.order[0] === c.player && cyclic.active === c.player
: c.mode === 'last' && cyclic.order.at(-1) === c.player) &&
g.active === cyclic.active) && batch.cause === 'sapho:auction' &&
batch.entries.length === 1 && entry.publicFace &&
entry.discardedBy === c.player && entry.card.id === c.card &&
richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' &&
!c.resume.response && !c.resume.decision &&
!c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === saphoAuctionDiscardSignature(g, c),
'The used Juice of Sapho no longer matches its committed Once Around order.',
'The used Juice of Sapho no longer matches its committed auction order.',
);
} else if (continuation?.kind === 'saphoBattleOrderDiscard') {
const c = continuation, entry = batch.entries[0], order = g.battleOrder;
@@ -7673,7 +7684,7 @@ function semutaOfferSupported(
continuation.use.response.kind === 'emperorIncome'
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
: !g.auction)) &&
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
(bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' &&
@@ -7913,7 +7924,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
settleRicheseLot(g);
if (next.cyclic === undefined) settleRicheseLot(g);
} else if (next.kind === 'saphoBattleOrderDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -12355,7 +12366,7 @@ function finishRicheseLot(g: Game) {
richeseDeclarationCache(g);
round.blackMarketSold = lot.outcome?.kind === 'sold';
if (lot.method === 'normal') {
const opener = lot.order[0];
const opener = lot.saphoOriginalOpener ?? lot.order[0];
round.opener = (g.order.indexOf(opener) + 1) % g.order.length;
}
}
@@ -13593,7 +13604,7 @@ function auctionNext(g: Game) {
return p.hand.length < handLimit(p);
});
const next = quoteNormalAuctionNext({
order: g.order,
order: a.saphoOrder ?? g.order,
eligible,
active: a.active,
bid: a.bid,
@@ -15464,12 +15475,18 @@ function recoverAuctionPayment(g: Game, p: Player) {
a.bidder = null;
a.allyPayment = 0;
a.passed = [];
while (
getPlayer(g, g.order[a.opener]).hand.length >=
handLimit(getPlayer(g, g.order[a.opener]))
)
a.opener = (a.opener + 1) % g.order.length;
a.active = g.order[a.opener];
if (a.saphoOrder) {
const first = a.saphoOrder.find(id => getPlayer(g,id).hand.length < handLimit(getPlayer(g,id)));
requireRule(first, 'The original Sapho lot has no eligible bidder to resume.');
a.active = first;
} else {
while (
getPlayer(g, g.order[a.opener]).hand.length >=
handLimit(getPlayer(g, g.order[a.opener]))
)
a.opener = (a.opener + 1) % g.order.length;
a.active = g.order[a.opener];
}
g.active = a.active;
log(
g,
@@ -19328,10 +19345,58 @@ function offerMoritaniDuke(g: Game) {
return true;
}
type SaphoOption = {
scope: 'onceAround' | 'movement' | 'battleOrder';
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder';
event: string;
mode: 'first' | 'last';
} | { scope: 'battleAggressor'; event: string; mode: 'aggressor' };
function saphoCyclicRule<T>(quote: () => T): T {
try { return quote(); }
catch (error) {
if (error instanceof SaphoCyclicAuctionError) throw new RuleError(error.message);
throw error;
}
}
function saphoCyclicIntegrity(g: Game) {
const cycle = g.auction?.saphoOrder;
if (cycle !== undefined) requireRule(g.phase === 3 && Array.isArray(cycle) &&
cycle.length === g.order.length && new Set(cycle).size === cycle.length &&
cycle.every(id => g.order.includes(id)),
'The current lot has lost its original Sapho bidder cycle.');
const lot = g.richeseAuction;
if (lot?.saphoOriginalOpener !== undefined) requireRule(g.phase === 3 &&
lot.source === 'blackMarket' && lot.method === 'normal' &&
g.order.includes(lot.saphoOriginalOpener) && lot.order.includes(lot.saphoOriginalOpener),
'The Black Market has lost its original opener after Sapho.');
}
/** A concrete current lot only. Post-discard eligibility can admit a full hand;
* neither a past pass nor the standing high bid creates another pending bid. */
function saphoCyclicContext(g: Game, spending?: Player) {
if (g.status !== 'playing' || g.phase !== 3 || g.currentAuctionSale) return null;
saphoCyclicIntegrity(g);
const a = g.auction, lot = g.richeseAuction;
if (a && !lot && a.cards[a.index] && g.active === a.active) {
const order = a.saphoOrder ?? g.order;
return {source:'normal' as const, event:JSON.stringify(['saphoCyclic',g.turn,'normal',a.index]),
order, active:a.active, bidder:a.bidder, passed:a.passed,
eligible:order.filter(id => {
const player = getPlayer(g,id);
return player.hand.length - Number(spending?.id === id) < handLimit(player);
})};
}
if (lot?.source === 'blackMarket' && lot.method === 'normal' && !lot.outcome &&
lot.active && g.active === lot.active) {
const order = spending && !lot.order.includes(spending.id) ? [...lot.order,spending.id] : lot.order;
return {source:'blackMarket' as const, event:JSON.stringify(['saphoCyclic',g.turn,'blackMarket',lot.event]),
order, active:lot.active, bidder:lot.bidder, passed:lot.passed,
eligible:order.filter(id => {
const player = getPlayer(g,id);
return (lot.eligible.includes(id) || spending?.id === id) &&
richeseBidFitsHand(lot.source,lot.owner,id,
player.hand.length - Number(spending?.id === id),handLimit(player));
})};
}
return null;
}
function saphoMovementIntegrity(g: Game) {
const last = g.saphoMovementLast;
if (last === undefined || last === null) return;
@@ -19433,6 +19498,15 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
)
return [];
const options: SaphoOption[] = [];
const cyclic = saphoCyclicContext(g,p);
if (cyclic) for (const mode of ['first','last'] as const) {
try {
reorderSaphoCyclicAuction(cyclic,{event:cyclic.event,player:p.id,mode});
options.push({scope:'cyclicAuction',event:cyclic.event,mode});
} catch (error) {
if (!(error instanceof SaphoCyclicAuctionError)) throw error;
}
}
const battle = g.battle;
if (g.phase === 6 && battle?.event && battle.preLeader?.event === battle.event &&
!battle.preLeader.closed && !battle.preLeader.ready.includes(p.id) &&
@@ -19525,6 +19599,41 @@ function playSapho(g: Game, p: Player, action: Action) {
stageTreacheryDiscard(g, 'sapho:aggressor', entries, continuation);
}
}
} else if (option.scope === 'cyclicAuction') {
const context = saphoCyclicContext(g,p);
requireRule(context, 'The cyclic Sapho auction is no longer open.');
const next = saphoCyclicRule(() => reorderSaphoCyclicAuction(context,
{event:option.event,player:p.id,mode:option.mode}));
const used = discard(g,p,'richese-juice-of-sapho');
if (context.source === 'normal') {
g.auction!.saphoOrder = next.order;
g.auction!.active = next.active;
} else {
const lot = g.richeseAuction!;
lot.saphoOriginalOpener ??= lot.order[0];
if (!lot.eligible.includes(p.id)) {
lot.eligible.push(p.id);
lot.tieOrder = g.order.filter(id => lot.eligible.includes(id));
}
lot.order = next.order;
lot.active = next.active;
}
g.active = next.active;
log(g, `${p.name} discarded Juice of Sapho to bid ${option.mode} in this cyclic auction. Provisional scope: the current lot only. Existing bids, funding and passes remain committed; every purchase still requires a raised bid.`,
{faction:p.faction,name:'Juice of Sapho'});
if (g.semutaPreview) {
const continuation: SaphoAuctionDiscardContinuation = {
kind:'saphoAuctionDiscard',cyclic:context.source,player:p.id,card:used.id,
event:option.event,mode:option.mode,
resume:{response:g.response,decision:g.decision,pendingKarama:g.pendingKarama,phaseOpening:g.phaseOpening},
stateSignature:'',
};
const entries = [{card:used,discardedBy:p.id,publicFace:true}];
if (semutaOfferSupported(g,continuation,entries)) {
continuation.stateSignature = saphoAuctionDiscardSignature(g,continuation);
stageTreacheryDiscard(g,'sapho:auction',entries,continuation);
}
}
} else if (option.scope === 'onceAround') {
const lot = g.richeseAuction!;
// Discarding this card frees a slot even when a full hand excluded its holder
@@ -22012,6 +22121,7 @@ function atomicsIntegrity(g: Game) {
'Atomics Aftermath has lost its revealed token, original targets or hand-limit custody.');
}
function marketGholaIntegrity(g: Game) {
saphoCyclicIntegrity(g);
battleOrderIntegrity(g);
saphoAggressorIntegrity(g);
nexusCardsIntegrity(g);
+1 -1
View File
@@ -30,7 +30,7 @@ export function quoteNormalAuctionNext(context: {
for (let offset = 1; offset <= context.order.length; offset++) {
const index = (Math.max(activeIndex, -1) + offset) % context.order.length;
const player = context.order[index];
if (eligible.has(player) && player !== context.bidder)
if (eligible.has(player) && player !== context.bidder && !context.passed.includes(player))
return { kind: 'bid', player };
}
+12 -5
View File
@@ -494,14 +494,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'Choose an available order change or become battle aggressor before plans, then discard Sapho. Later intervention and other timing modes remain unfinished.',
steps: [
'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.',
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. A completed bid or pass cannot be repeated, and a finished lot cannot reopen.',
'In ordinary cyclic bidding or native Advanced normal Black Market, provisional first/last changes the current lot only, anchored at the live next bidder. Later circuits use that local cycle. Keep standing bids, allied funding and passes; a passed holder waits for a real raise, and the high bidder has no pending bid to reorder. Every purchase still requires a strict raise. Full effective-cycle no-ops cannot consume the card. The next lot uses its normal physical opener.',
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.',
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.',
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
'During your open pre-plan preparation, use Sapho to become the current battle’s aggressor before declaring ready. You win ordinary ties, while the Habbanya Stronghold advantage still takes precedence. Physical participants, plans and later battle-choice order do not swap. The accepted priority survives refresh.',
'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.',
'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.',
'In the explicitly opted-in Semuta preview, clean Once Around and movement-order discards pause after their bidder/turn queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; a clean pre-plan aggressor discard pauses after changing ordinary tie priority. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unsealed battle then continues once.',
'These are the supported development controls, not additional printed restrictions. Later aggressor intervention, ordinary cyclic auction scope, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.',
'These are the supported development controls, not additional printed restrictions. Cyclic lot duration and next-opportunity eligibility are explicit provisional conventions. Later aggressor intervention, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.',
],
example:
'Atreides bids 2. Before taking its bid, the Emperor uses Sapho to go last. Richese bids 3, then the Emperor must bid at least 4 to win. Atreides does not receive another bid.',
@@ -516,13 +517,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Finite Once Around, clean combined-turn and remaining Battle-phase ordering; authoritative chooser/participant separation, phase priority, physical discard, full-hand auction eligibility and persisted last-over-Guild movement protection. Remaining timing modes are guarded.',
'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering. Local cycles preserve passes/funding; both native consumers retain the original following opener. Authoritative chooser/participant separation, full-hand eligibility and last-over-Guild protection remain. Other timing modes stay unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Owner-only first/last and pre-plan aggressor actions, public aggressor/tie priority and remaining battle order, owner-relative targets, readable inspector and timing explanations. Later aggressor and cyclic-auction controls remain unfinished.',
'Owner-only first/last and pre-plan aggressor actions, public aggressor/tie priority and remaining battle order, owner-relative targets, readable inspector and timing explanations. Cyclic first/last explains current-lot duration and pending-bid limits; later aggressor controls remain unfinished.',
},
{
area: 'AI',
@@ -540,8 +541,14 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Finite-order, engine, all-profile and real persisted-room tests cover once-only play, funding, full-hand eligibility, privacy, Guild deferral and battle scheduling through actual outcomes. Full expansion compliance remains unverified.',
'18 October cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.',
evidence: [
'game/sapho-cyclic-auction.ts',
'components/juice-of-sapho.tsx',
'tests/sapho-cyclic-auction.test.ts',
'tests/fixture-sapho-cyclic-auction.ts',
'tests/sapho-cyclic-auction-engine.test.ts',
'docs/JUICE_OF_SAPHO_RUNTIME.md',
'tests/ordered-opportunity.test.ts',
'tests/juice-of-sapho-engine.test.ts',
'tests/juice-of-sapho-engine-review.test.ts',
+3 -1
View File
@@ -13,6 +13,8 @@ export type RicheseAuction = {
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;
@@ -215,7 +217,7 @@ export function submitRicheseBid(
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 (next !== state.bidder) {
if (state.eligible.includes(next) && next !== state.bidder && !state.passed.includes(next)) {
state.active = next;
break;
}
+77
View File
@@ -0,0 +1,77 @@
export type SaphoCyclicAuctionContext = {
event: string;
order: readonly string[];
eligible: readonly string[];
active: string;
bidder: string | null;
passed: readonly string[];
};
export type SaphoCyclicAuctionRequest = {
event: string;
player: string;
mode: 'first' | 'last';
};
export class SaphoCyclicAuctionError extends Error {
constructor(message: string) {
super(message);
this.name = 'SaphoCyclicAuctionError';
}
}
function requireAuction(value: unknown, message: string): asserts value {
if (!value) throw new SaphoCyclicAuctionError(message);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const ids = (value: unknown): value is readonly string[] =>
Array.isArray(value) && value.every(identifier) && new Set(value).size === value.length;
function rotate(order: readonly string[], first: string): string[] {
const at = order.indexOf(first);
return [...order.slice(at), ...order.slice(0, at)];
}
/** Current-lot/next-opportunity composition, not a publisher timing ruling.
* Keep the complete local cycle: a passed, full-hand or high-bid placeholder
* may matter after a later raise. Eligibility and card custody belong to the
* engine; this quote changes neither bids nor the physical storm-order opener. */
export function reorderSaphoCyclicAuction(
context: SaphoCyclicAuctionContext,
request: SaphoCyclicAuctionRequest,
): { order: string[]; active: string } {
requireAuction(context && typeof context === 'object', 'Choose a valid current auction lot.');
requireAuction(identifier(context.event), 'The auction requires its current lot event.');
requireAuction(
ids(context.order) && context.order.length > 0 && ids(context.eligible) && ids(context.passed) &&
context.eligible.every(player => context.order.includes(player)) &&
context.passed.every(player => context.order.includes(player)) &&
(context.bidder === null || identifier(context.bidder) && context.order.includes(context.bidder)) &&
(context.bidder === null || !context.passed.includes(context.bidder)),
'Auction order, eligible seats, passes and high bidder must form a valid unique lot roster.',
);
const pending = (player: string) => context.eligible.includes(player) &&
!context.passed.includes(player) && player !== context.bidder;
requireAuction(
identifier(context.active) && pending(context.active),
'The current auction actor must have a pending eligible bid opportunity.',
);
requireAuction(
request && typeof request === 'object' && request.event === context.event,
'This auction lot event has expired.',
);
requireAuction(request.mode === 'first' || request.mode === 'last', 'Choose first or last.');
requireAuction(
identifier(request.player) && pending(request.player),
'Only an eligible unpassed player other than the current high bidder can change position.',
);
const original = rotate(context.order, context.active);
const others = original.filter(player => player !== request.player);
const order = request.mode === 'first' ? [request.player, ...others] : [...others, request.player];
const active = order.find(pending)!;
// Compare complete effective cycles, including their next active actor.
// Merely rotating a single pending actor around skipped placeholders is a
// no-op, while moving behind a placeholder that can reenter is meaningful.
requireAuction(
JSON.stringify(original) !== JSON.stringify(rotate(order, active)),
'This player is already in that effective auction position.',
);
return { order, active };
}