Allow Sapho order behind a started turn

This commit is contained in:
Quintine committed 2026-10-25 23:33:04 +11:00
1 parent 3bf7d26e80
commit 8e6602633d
17 files changed
+1121 -62

No files matched your search

+1 -1
View File
@@ -1182,7 +1182,7 @@ function saphoAction(g: GameView): Action | null {
(options[0].scope === 'movement' && earlySpice) || battleFirst ? 'first' : 'last';
const option =
options.find((candidate) => candidate.mode === preferred) ?? options[0];
return { type: 'card', card: card.id, ...option };
return { type: 'card', card: card.id, scope: option.scope, event: option.event, mode: option.mode };
}
function tupileCleanupActions(g: GameView): Action[] {
const me = g.players.find((player) => player.id === g.me);
+48 -37
View File
@@ -381,11 +381,10 @@ import {
import { canceledResponseSource, KaramaContextError } from './karama-context';
import { validateSavedTruthtrance } from './truthtrance';
import {
reorderOrderedOpportunity,
protectOrderedOpportunityLast,
validateOrderedOpportunity,
type OrderedOpportunity,
} from './ordered-opportunity';
import { reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError, type SaphoMovementOrderContext } from './sapho-movement-order';
import {
matchesShipment,
parseShipmentClaim,
@@ -1523,6 +1522,8 @@ export type Game = {
event: string;
mode: 'first' | 'last';
previousActive: string;
/** New queued order retains this already-started combined turn. */
afterCurrent?: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
@@ -5626,6 +5627,7 @@ function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuat
return JSON.stringify({
player: c.player, card: c.card, event: c.event, mode: c.mode,
previousActive: c.previousActive,
...(c.afterCurrent !== undefined ? { afterCurrent: c.afterCurrent } : {}),
parent: nullentropyParentSignature(g, c.resume, c.event),
turn: g.turn, phase: g.phase, advanced: g.advanced,
active: g.active, order: g.order,
@@ -6658,8 +6660,13 @@ function treacheryDiscardIntegrity(g: Game) {
new Set(g.movementRemaining).size === g.movementRemaining.length &&
g.movementRemaining.every(id => g.order.includes(id)) &&
g.movementRemaining.includes(c.previousActive) &&
(c.afterCurrent === undefined ||
typeof c.afterCurrent === 'string' && c.afterCurrent === c.previousActive &&
c.afterCurrent !== c.player && g.movementRemaining[0] === c.afterCurrent &&
saphoMovementStarted(g, getPlayer(g,c.afterCurrent)) &&
g.movementRemaining.filter(id=>id!==c.afterCurrent).every(id=>!saphoMovementStarted(g,getPlayer(g,id)))) &&
(c.mode === 'first'
? g.movementRemaining[0] === c.player &&
? g.movementRemaining[c.afterCurrent === undefined ? 0 : 1] === c.player &&
c.previousActive !== c.player && !g.saphoMovementLast
: c.mode === 'last' &&
g.movementRemaining.at(-1) === c.player &&
@@ -21811,6 +21818,8 @@ type SaphoOption = {
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion';
event: string;
mode: 'first' | 'last';
/** Display only; action submission keeps scope/event/mode. */
afterCurrent?: string;
} | { scope: 'battleAggressor'; event: string; mode: 'aggressor' };
function saphoCyclicRule<T>(quote: () => T): T {
try { return quote(); }
@@ -21953,6 +21962,24 @@ function saphoMovementQueue(g: Game): OrderedOpportunity | null {
validateOrderedOpportunity(state);
return state;
}
function saphoMovementOrderContext(g: Game): SaphoMovementOrderContext | null {
const queue=saphoMovementQueue(g);
if (!queue) return null;
const active=getPlayer(g,queue.current!);
// A prepared extra move alone is not the clean post-shipment/movement
// boundary supported by queued order. Finish that preparation first.
if (queue.currentStarted && !active.shipped && active.moved===0) return null;
const guild=g.advanced ? byFaction(g,'guild') : undefined;
return {queue,started:queue.remaining.filter(id=>saphoMovementStarted(g,getPlayer(g,id))),
guild:guild ? {player:guild.id,granted:!!g.guildTimingGranted,locked:!!g.guildTimingLocked} : null};
}
function saphoMovementRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) {
if (error instanceof SaphoMovementOrderError) throw new RuleError(error.message);
throw error;
}
}
/** Own custody and public finite scopes only; no opponent hand scan. */
function saphoOptions(g: Game, p: Player): SaphoOption[] {
if (!saphoCleanWindow(g)) return [];
@@ -22017,24 +22044,15 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
if (remaining.at(-1) !== p.id)
options.push({ scope: 'onceAround', event: lot.event, mode: 'last' });
}
const queue = saphoMovementQueue(g);
if (
queue &&
!queue.currentStarted &&
queue.remaining.includes(p.id) &&
!g.saphoMovementLast
) {
if (
!queue.remaining.some((id) => saphoMovementStarted(g, getPlayer(g, id))) &&
!(
g.advanced && !g.guildTimingLocked && g.guildTimingGranted &&
getPlayer(g, queue.current!).faction === 'guild'
) &&
queue.current !== p.id
)
options.push({ scope: 'movement', event: queue.event, mode: 'first' });
if (queue.remaining.at(-1) !== p.id)
options.push({ scope: 'movement', event: queue.event, mode: 'last' });
const movement=saphoMovementOrderContext(g);
if (movement) for (const mode of ['first','last'] as const) {
try {
const quote=reorderSaphoMovementOrder(movement,{event:saphoMovementOrderEvent(movement),player:p.id,mode});
options.push({scope:'movement',event:quote.event,mode,
...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})});
} catch(error) {
if (!(error instanceof SaphoMovementOrderError)) throw error;
}
}
return options;
}
@@ -22201,31 +22219,24 @@ function playSapho(g: Game, p: Player, action: Action) {
}
}
} else {
const queue = saphoMovementQueue(g)!;
const next =
option.mode === 'last'
? protectOrderedOpportunityLast(queue, {
event: option.event,
player: p.id,
})
: reorderOrderedOpportunity(queue, {
event: option.event,
player: p.id,
position: 'first',
});
g.movementRemaining = [...next.remaining];
const context=saphoMovementOrderContext(g);
requireRule(context,'The original movement order is no longer available.');
const quote=saphoMovementRule(()=>reorderSaphoMovementOrder(context,
{event:option.event,player:p.id,mode:option.mode}));
g.movementRemaining = [...quote.next.remaining];
if (option.mode === 'last')
g.saphoMovementLast = { event: option.event, turn: g.turn, player: p.id };
g.saphoMovementLast = { event: context.queue.event, turn: g.turn, player: p.id };
const used = discard(g, p, 'richese-juice-of-sapho');
log(
g,
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
`${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the current combined turn unchanged; only later unstarted turns reorder.` : ''} Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
{ faction: p.faction, name: 'Juice of Sapho' },
);
if (g.semutaPreview) {
const continuation: SaphoMovementDiscardContinuation = {
kind: 'saphoMovementDiscard', player: p.id, card: used.id,
event: option.event, mode: option.mode, previousActive: g.active!,
event: context.queue.event, mode: option.mode, previousActive: g.active!,
...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}),
resume: { response: g.response, decision: g.decision,
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
stateSignature: '',
+9 -3
View File
@@ -497,13 +497,14 @@ export const RULE_TOPICS: RuleTopic[] = [
'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, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.',
'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. The current actor keeps its entire turn and original counters/resources. This does not split your own started turn or interrupt pending card/arrival/payment preparations. Guild retains its next-boundary timing choice. The panel names the actor who must finish.',
'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.',
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.',
'Finish existing shipment, movement, card preparation and pending decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions 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; an aggressor discard pauses after changing ordinary tie priority. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.',
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure and cyclic lot duration are provisional conventions. Other phase ordering and partly completed combined-turn intervention remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure, cyclic lot duration and queued movement timing are provisional conventions. Other phase ordering and splitting your own started combined turn remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
],
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.',
@@ -518,13 +519,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering, and clean late aggressor in fresh Richese battles. Public final readiness closes before physical allocation/settlement; plans, votes, original participants and committed cards remain. Local cycles preserve passes/funding and native following openers. Other timing modes remain unfinished.',
'Finite Once Around, current-lot cyclic bidding, clean combined-turn and queued unstarted-tail movement, remaining battle-choice and late aggressor modes are connected. Queued movement retains the started actor, resources/counters and native Guild choice; exact Semuta disposal preserves that same current actor. Tail timing is visibly provisional. Other phases and split-own-turn mode remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Owner-only first/last and clean unresolved-battle aggressor actions, public aggressor/tie priority and remaining battle order. Persistent final controls show both combatants and fresh-event readiness; a public Sapho use clears both finish marks. Cyclic controls retain current-lot duration and pending-bid limits. Older battles keep early timing.',
'Owner-only first/last and unresolved-battle aggressor controls retain public priority. Queued movement names the started actor who finishes first, explains provisional tail ordering and sends only original action fields; afterCurrent is display metadata. Final battle/cyclic controls retain their existing scope. Older battles keep early timing.',
},
{
area: 'AI',
@@ -544,6 +545,11 @@ export const RULE_TOPICS: RuleTopic[] = [
detail:
'18 October Extortion:9-file115/115, types/lint/build and six original-action/rendered-table programmes pass. Public Basic/Advanced cache1, placed/revealed Terror, paid entry1, native Mentat placement-before-bank5 and an earlier decline use120/139 source actions. First/payment3 and last/payment3 or all-decline use2–4 continuations, preserving one payment and native token retirement/rotation. Four profiles make eight legal holder/payer proposals and complete. Pure replay/no-op/recovered reuse cases are separate. One deliberate-invalid-mode test cast was repaired; all checks then passed. Prior Guild, aggressor and cyclic evidence remains in linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, complete-mode or final-assurance acceptance',
evidence: [
'game/sapho-movement-order.ts',
'tests/sapho-movement-order.test.ts',
'tests/sapho-started-movement-controls.test.tsx',
'tests/fixture-sapho-started-movement.ts',
'tests/sapho-started-movement-engine.test.ts',
'game/sapho-extortion.ts',
'tests/sapho-extortion.test.ts',
'tests/fixture-sapho-extortion.ts',
+88
View File
@@ -0,0 +1,88 @@
import {
protectOrderedOpportunityLast, reorderOrderedOpportunity, validateOrderedOpportunity,
type OrderedOpportunity,
} from './ordered-opportunity';
export type SaphoMovementOrderContext = {
queue: OrderedOpportunity;
started: readonly string[];
/** Present only for Advanced Guild timing. */
guild: { player: string; granted: boolean; locked: boolean } | null;
};
export class SaphoMovementOrderError extends Error {
constructor(message: string) {
super(message);
this.name = 'SaphoMovementOrderError';
}
}
function requireMovement(value: unknown, message: string): asserts value {
if (!value) throw new SaphoMovementOrderError(message);
}
const identifier = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
function validate(context: SaphoMovementOrderContext): OrderedOpportunity {
requireMovement(context && typeof context === 'object', 'Choose the current movement order.');
const queue = context.queue;
validateOrderedOpportunity(queue);
requireMovement(queue.current !== null && queue.remaining.length > 0,
'The combined movement turns have already finished.');
requireMovement(!queue.protectedLast, 'Juice of Sapho already protects the last turn in this phase.');
requireMovement(Array.isArray(context.started) && context.started.every(identifier) &&
new Set(context.started).size === context.started.length &&
context.started.every(player => queue.eligible.includes(player)),
'Started movement turns must name unique participants in this phase.');
const started = queue.remaining.filter(player => context.started.includes(player));
requireMovement(queue.currentStarted
? started.length === 1 && started[0] === queue.current
: started.length === 0,
'Only the current combined turn may have started among the remaining participants.');
requireMovement(context.guild === null || context.guild && typeof context.guild === 'object' &&
identifier(context.guild.player) && queue.eligible.includes(context.guild.player) &&
typeof context.guild.granted === 'boolean' && typeof context.guild.locked === 'boolean',
'Guild timing must retain its original participant and public grant state.');
return queue;
}
function eventFor(queue: OrderedOpportunity): string {
return queue.currentStarted
? `sapho-after:${encodeURIComponent(JSON.stringify([queue.event, queue.current, queue.remaining]))}`
: queue.event;
}
function wrap<T>(calculate: () => T): T {
try {
return calculate();
} catch (error) {
if (error instanceof SaphoMovementOrderError) throw error;
throw new SaphoMovementOrderError(error instanceof Error ? error.message : 'Invalid Sapho movement order.');
}
}
/** A started turn binds the whole public tail, rather than permitting a stale
* declaration to displace a different actor or newly reordered participants. */
export function saphoMovementOrderEvent(context: SaphoMovementOrderContext): string {
return wrap(() => eventFor(validate(context)));
}
export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, request: {
event: string;
player: string;
mode: 'first' | 'last';
}): { event: string; afterCurrent: string | null; next: OrderedOpportunity } {
return wrap(() => {
const queue = validate(context), event = eventFor(queue);
requireMovement(request && request.event === event, 'This Sapho movement opportunity has expired.');
requireMovement(request.mode === 'first' || request.mode === 'last', 'Choose first or last.');
requireMovement(identifier(request.player) && queue.remaining.includes(request.player),
'Only a pending participant may change its movement position.');
requireMovement(!context.started.includes(request.player),
'Juice of Sapho cannot split a combined turn that has already started.');
const grantedGuild = context.guild && context.guild.player === queue.current &&
context.guild.granted && !context.guild.locked;
requireMovement(queue.currentStarted || request.mode !== 'first' || !grantedGuild,
'The Guild has already been granted the next combined turn.');
const next = request.mode === 'last'
? protectOrderedOpportunityLast(queue, { event: queue.event, player: request.player })
: reorderOrderedOpportunity(queue, { event: queue.event, player: request.player, position: 'first' });
requireMovement(next.remaining.some((player, index) => player !== queue.remaining[index]),
'This participant is already in that remaining movement position.');
return { event, afterCurrent: queue.currentStarted ? queue.current : null, next };
});
}