Support Sapho ordering for shared arrivals
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 05:29:51 +11:00
1 parent b18a767470
commit e457478f7d
18 files changed
+808 -23

No files matched your search

+14 -2
View File
@@ -4,6 +4,7 @@ import { caladanOwnsTerrorEntry, validateCaladanChildArrival, caladanEntryParent
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
import { validateSaphoArrivalOrder } from './sapho-arrival-order';
type Overlap = NonNullable<Game['pendingArrivalOverlap']> & { revivalArrival?: RevivalEntryParent };
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']> & { revivalArrival?: RevivalEntryParent };
@@ -28,7 +29,8 @@ export function arrivalOverlapSignature(record: Overlap): string {
record.ambassadorToken, record.terrorTokens,
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)]),
...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]]),
...(record.caladanArrival === undefined ? [] : [caladanEntryParentTuple(record.caladanArrival)])]);
...(record.caladanArrival === undefined ? [] : [caladanEntryParentTuple(record.caladanArrival)]),
...(record.saphoOrder === undefined ? [] : [['saphoArrivalOrder', record.saphoOrder]])]);
}
/** A fresh discard belongs to the active owner of an ordinary or bound revived entry.
@@ -52,9 +54,19 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
!['shipment', 'movement'].includes(overlap.cause)) return null;
const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani);
if (ecaz < 0 || moritani < 0 || ecaz === moritani ||
overlap.first !== (ecaz < moritani ? 'ambassador' : 'terror') ||
!g.players.some(p => p.id === overlap.ecaz && p.faction === 'ecaz') ||
!g.players.some(p => p.id === overlap.moritani && p.faction === 'moritani')) return null;
if (overlap.saphoOrder === undefined) {
if (overlap.first !== (ecaz < moritani ? 'ambassador' : 'terror')) return null;
} else {
const original: [string, string] = ecaz < moritani
? [overlap.ecaz, overlap.moritani] : [overlap.moritani, overlap.ecaz];
const selectedFirst = overlap.first === 'ambassador' ? overlap.ecaz : overlap.moritani;
try {
validateSaphoArrivalOrder(overlap.saphoOrder, overlap.event, original, selectedFirst);
if (overlap.saphoOrder.frame.passed.length !== original.length) return null;
} catch { return null; }
}
const active = overlap.stage === 'first' ? overlap.first :
overlap.first === 'ambassador' ? 'terror' : 'ambassador';
return active === owner ? overlap : null;
+7
View File
@@ -4358,6 +4358,13 @@ export function botActions(g: GameView): Action[] {
return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
? policyActions(g) : [];
if (g.homeworldTupileBlocked) return [];
if (g.saphoArrivalOrder) {
const own = g.players.find(player => player.id === g.me), order = g.saphoArrivalOrder;
return (own?.bot || own?.autopilot) && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending &&
order.order.includes(g.me) && !order.passed.includes(g.me)
? [{type: 'arrivalOrderPass', event: order.event, revision: order.revision}] : [];
}
const dealPayer = g.players.find(player => player.id === g.me);
if ((dealPayer?.bot || dealPayer?.autopilot) && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) {
+197 -2
View File
@@ -20,6 +20,8 @@ import { mobileStrongholdCollectionCapacity } from './mobile-stronghold-collecti
import { basicKullTechRoster, kullDefaultEligible } from './kull-default-profile';
import { advancedKullLobbyEligible } from './kull-advanced-profile';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
import { createSaphoArrivalOrder, validateSaphoArrivalOrder, quoteSaphoArrivalOrder,
passSaphoArrivalOrder, reorderSaphoArrivalOrder, type SaphoArrivalOrder } from './sapho-arrival-order';
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises';
@@ -1722,6 +1724,16 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoArrivalOrderDiscard';
player: string;
card: string;
event: string;
revision: number;
mode: 'first' | 'last';
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoMovementDiscard';
player: string;
@@ -2285,6 +2297,8 @@ export type Game = {
moritani: string;
first: 'ambassador' | 'terror';
stage: 'first' | 'second';
/** Present only for freshly opened public Sapho ordering windows. */
saphoOrder?: SaphoArrivalOrder;
ambassadorToken: string;
terrorTokens: string[];
ambassadorEvent?: string;
@@ -6357,6 +6371,14 @@ function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) {
type SaphoTruthOrderDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'], { kind: 'saphoTruthOrderDiscard' }
>;
type SaphoArrivalOrderDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'], { kind: 'saphoArrivalOrderDiscard' }
>;
function saphoArrivalOrderDiscardSignature(g: Game, c: SaphoArrivalOrderDiscardContinuation) {
return JSON.stringify({ player: c.player, card: c.card, event: c.event, revision: c.revision,
mode: c.mode, overlap: g.pendingArrivalOverlap, turn: g.turn, phase: g.phase,
active: g.active, order: g.order, movementRemaining: g.movementRemaining });
}
function saphoTruthOrderDiscardSignature(g: Game, c: SaphoTruthOrderDiscardContinuation) {
return JSON.stringify({player:c.player,card:c.card,event:c.event,revision:c.revision,mode:c.mode,
truth:c.truth,parent:nullentropyParentSignature(g,c.resume,c.event),turn:g.turn,phase:g.phase,
@@ -8194,6 +8216,16 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === distransDiscardSignature(g, c),
'The completed private Distrans transfer no longer matches its fresh discard.',
);
} else if (continuation?.kind === 'saphoArrivalOrderDiscard') {
const c = continuation, entry = batch.entries[0], overlap = g.pendingArrivalOverlap;
requireRule(batch.cause === 'sapho:arrivalOrder' && batch.entries.length === 1 && entry.publicFace &&
entry.discardedBy === c.player && entry.card.id === c.card &&
richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' &&
arrivalOrderWaiting(g) && overlap?.saphoOrder?.frame.event === c.event &&
overlap.saphoOrder.frame.revision === c.revision + 1 && overlap.saphoOrder.frame.passed.includes(c.player) &&
!c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === saphoArrivalOrderDiscardSignature(g, c),
'The used Sapho no longer matches its suspended arrival order.');
} else if (continuation?.kind === 'saphoTruthOrderDiscard') {
const c = continuation, entry = batch.entries[0], truth = c.truth;
requireRule(batch.cause === 'sapho:truthOrder' && batch.entries.length === 1 && entry.publicFace &&
@@ -9481,6 +9513,16 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
if (continuation.kind === 'saphoArrivalOrderDiscard') {
const order = g.pendingArrivalOverlap?.saphoOrder;
return g.semutaPreview === true && bareBasicSemutaProfile(g) && g.phase === 5 &&
arrivalOrderUnopened(g) && !!order && order.frame.event === continuation.event &&
order.frame.revision === continuation.revision + 1 && order.frame.passed.includes(continuation.player) &&
entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player &&
entries[0].card.id === continuation.card && entries[0].card.effect === 'juiceOfSapho' &&
!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening;
}
if (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'recruits')
return g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g) &&
g.status === 'playing' && g.phase === 4 && g.recruits?.turn === g.turn &&
@@ -10195,6 +10237,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
} else if (next.kind === 'saphoArrivalOrderDiscard') {
g.response = next.resume.response; g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening;
if (g.pendingArrivalOverlap?.saphoOrder?.frame.passed.length === 2) openFirstOrderedArrival(g);
} else if (next.kind === 'saphoTruthOrderDiscard') {
g.response = next.resume.response; g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening;
@@ -24199,11 +24245,30 @@ function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParen
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
!g.discoveryEnabled && !g.techTokens && !g.strongholdCards);
}
function arrivalOrderUnopened(g: Game): boolean {
const overlap = g.pendingArrivalOverlap;
return !!overlap?.saphoOrder && overlap.stage === 'first' &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !overlap.ambassadorEvent;
}
function arrivalOrderWaiting(g: Game): boolean {
return arrivalOrderUnopened(g) &&
(g.pendingArrivalOverlap!.saphoOrder!.frame.passed.length < 2 ||
g.pendingTreacheryDiscard?.continuation.kind === 'saphoArrivalOrderDiscard');
}
function arrivalOverlapIntegrity(g: Game) {
const record = g.pendingArrivalOverlap;
if (!record) return;
const ecazIndex = g.order.indexOf(record.ecaz);
const moritaniIndex = g.order.indexOf(record.moritani);
const originalOwners: [string, string] = ecazIndex < moritaniIndex
? [record.ecaz, record.moritani] : [record.moritani, record.ecaz];
const selectedFirst = record.first === 'ambassador' ? record.ecaz : record.moritani;
if (record.saphoOrder) try {
validateSaphoArrivalOrder(record.saphoOrder, record.event, originalOwners, selectedFirst);
} catch (error) {
if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message);
throw error;
}
const continuation = g.pendingTreacheryDiscard?.continuation;
const ambassador = g.pendingAmbassador ??
(continuation?.kind === 'ambassador' ? continuation.entry : null);
@@ -24228,7 +24293,7 @@ function arrivalOverlapIntegrity(g: Game) {
ecazIndex >= 0 && moritaniIndex >= 0 && ecazIndex !== moritaniIndex &&
g.order.lastIndexOf(record.ecaz) === ecazIndex &&
g.order.lastIndexOf(record.moritani) === moritaniIndex &&
record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror') &&
(!!record.saphoOrder || record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror')) &&
(record.stage === 'first' || record.stage === 'second') &&
Number.isSafeInteger(record.sector) &&
TERROR_STRONGHOLDS.includes(record.territory) &&
@@ -24241,7 +24306,14 @@ function arrivalOverlapIntegrity(g: Game) {
Array.isArray(record.terrorTokens) && record.terrorTokens.length > 0 &&
record.terrorTokens.every(id => typeof id === 'string' && id.length > 0) &&
new Set(record.terrorTokens).size === record.terrorTokens.length &&
(active === 'ambassador'
(arrivalOrderWaiting(g) || !record.saphoOrder || record.saphoOrder.frame.passed.length === 2) &&
(arrivalOrderWaiting(g)
? !g.response && !g.decision && !g.phaseOpening && !g.truthtrance &&
!!g.ecazAmbassadors?.tokens.some(token => token.id === record.ambassadorToken && token.zone === 'placed' && token.location === record.territory) &&
record.terrorTokens.every(id => g.moritaniTerror?.tokens.some(token => token.id === id && token.status === 'placed' && token.location === record.territory)) &&
(record.saphoOrder!.frame.passed.length < 2 ||
g.pendingTreacheryDiscard?.continuation.kind === 'saphoArrivalOrderDiscard')
: active === 'ambassador'
? !!ambassador && (!terror || ambassadorTerrorEntryMatches(g, terror)) &&
ambassador.event === record.ambassadorEvent &&
ambassador.owner === record.ecaz &&
@@ -24316,6 +24388,25 @@ function finishArrivalOverlap(g: Game, overlap: NonNullable<Game['pendingArrival
log(g, 'Both optional Ambassador and Terror reactions to the original entry have finished.');
if (overlap.resume === 'wormRide') nextWormRide(g);
}
/** Open the first original reaction once its public ordering window is complete. */
function openFirstOrderedArrival(g: Game) {
const overlap = g.pendingArrivalOverlap;
requireRule(overlap && arrivalOrderUnopened(g) && overlap.saphoOrder?.frame.passed.length === 2 &&
!g.pendingTreacheryDiscard, 'Finish the public arrival order before opening its first reaction.');
const entrant = getPlayer(g, overlap.entrant), owner = getPlayer(g, overlap.ecaz);
const token = g.ecazAmbassadors?.tokens.find(item => item.id === overlap.ambassadorToken &&
item.zone === 'placed' && item.location === overlap.territory);
requireRule(token, 'The original Ambassador must remain at the arrival.');
if (overlap.first === 'terror') {
requireRule(openTerrorEntry(g, entrant, overlap.territory, overlap.sector, overlap.amount,
overlap.elite, overlap.cause, 'none', undefined, overlap.revivalArrival, overlap.caladanArrival),
'The original Terror opportunity must remain available.');
} else {
openAmbassadorEntry(g, owner, token, entrant, overlap.territory, overlap.sector,
'none', overlap.cause, overlap.revivalArrival, overlap.caladanArrival);
overlap.ambassadorEvent = g.pendingAmbassador!.event;
}
}
/** The original entry is committed once; eligible overlapping owners act in storm order. */
function openTerritoryEntry(
g: Game,
@@ -24405,8 +24496,18 @@ function openTerritoryEntry(
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
...(caladanArrival ? {caladanArrival:structuredClone(caladanArrival)} : {}),
};
const saphoArrival = g.advanced === false && g.phase === 5 && g.semutaPreview === true &&
bareBasicSemutaProfile(g) && resume === 'none' && (cause === 'shipment' || cause === 'movement') &&
!g.pendingArrivalReaction?.length && !g.pendingChoamMove && !g.pendingIxMove && !g.pendingFremenMove &&
!g.ornithopter && !g.summonedWorm && g.wormRides.length === 0 && !revivalArrival && !caladanArrival;
if (saphoArrival) overlap.saphoOrder = createSaphoArrivalOrder(overlap.event,
first === 'ambassador' ? owner.id : moritani.id, first === 'ambassador' ? moritani.id : owner.id);
overlap.signature = arrivalOverlapSignature(overlap);
g.pendingArrivalOverlap = overlap;
if (overlap.saphoOrder) {
log(g, `${owner.name} and ${moritani.name} may adjust their provisional reaction order before either acts.`);
return true;
}
log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`);
if (first === 'terror')
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival, caladanArrival);
@@ -28465,6 +28566,81 @@ function saphoBattleCardReserved(g: Game, player: string, card: string): boolean
return !!battle && (!!plan && [plan.weapon, plan.defense, plan.leader].includes(card) ||
committedPlanElements(battle, player).some(element => element.value === card));
}
function saphoArrivalOrderChoices(g: Game, p: Player): {card: string; mode: 'first' | 'last'}[] {
const overlap = g.pendingArrivalOverlap, order = overlap?.saphoOrder;
if (!arrivalOrderWaiting(g) || !order || g.pendingTreacheryDiscard ||
printedRolePlayBlock(g, p.id, 'juiceOfSapho')) return [];
const card = p.hand.find(item => item.id === 'richese-juice-of-sapho' && item.effect === 'juiceOfSapho');
if (!card || !richeseCardDefinition(card) || p.hand.filter(item => item.id === card.id).length !== 1 ||
transferCardBlock(g, p, card) || giftReserved(g, p.id, card.id) || saphoBattleCardReserved(g, p.id, card.id)) return [];
try {
return quoteSaphoArrivalOrder(order, p.id).modes.filter(mode => {
const trial = structuredClone(g), actor = getPlayer(trial, p.id), parent = trial.pendingArrivalOverlap!;
const result = reorderSaphoArrivalOrder(parent.saphoOrder!, {
event: order.frame.event, revision: order.frame.revision, player: p.id, mode,
});
parent.saphoOrder = result.order;
parent.first = result.order.queue[0].player === parent.ecaz ? 'ambassador' : 'terror';
parent.signature = arrivalOverlapSignature(parent);
recordNativePrintedCardPlay(trial, p.id, 'juiceOfSapho');
const used = discard(trial, actor, card.id);
guardTurnActionPrintedCardCost(trial, actor, used);
const continuation: SaphoArrivalOrderDiscardContinuation = {
kind: 'saphoArrivalOrderDiscard', player: p.id, card: used.id, event: order.frame.event,
revision: order.frame.revision, mode,
resume: { response: trial.response, decision: trial.decision, pendingKarama: trial.pendingKarama, phaseOpening: trial.phaseOpening },
stateSignature: '',
};
return semutaOfferSupported(trial, continuation, [{card: used, discardedBy: p.id, publicFace: true}]);
}).map(mode => ({card: card.id, mode}));
} catch (error) {
if (error instanceof RuleError || error instanceof SaphoTruthOrderError) return [];
throw error;
}
}
function decideSaphoArrivalOrder(g: Game, p: Player, action: Action) {
const overlap = g.pendingArrivalOverlap, order = overlap?.saphoOrder;
requireRule(arrivalOrderWaiting(g) && overlap && order && !g.pendingTreacheryDiscard,
'This arrival-order opportunity is no longer open.');
const request = { event: action.event as string, revision: action.revision as number, player: p.id };
let result;
try {
if (action.type === 'arrivalOrderPass') {
requireRule(Object.keys(action).sort().join(',') === 'event,revision,type', 'Choose only the current arrival order.');
result = passSaphoArrivalOrder(order, request);
} else {
requireRule(action.type === 'saphoArrivalOrder' && Object.keys(action).sort().join(',') === 'card,event,mode,revision,type' &&
saphoArrivalOrderChoices(g, p).some(choice => choice.card === action.card && choice.mode === action.mode),
'This Sapho arrival-order choice is unavailable or has expired.');
result = reorderSaphoArrivalOrder(order, {...request, mode: action.mode as 'first' | 'last'});
}
} catch (error) {
if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message);
throw error;
}
overlap.saphoOrder = result.order;
overlap.first = result.order.queue[0].player === overlap.ecaz ? 'ambassador' : 'terror';
overlap.signature = arrivalOverlapSignature(overlap);
if (action.type === 'arrivalOrderPass') {
if (result.done) openFirstOrderedArrival(g);
return;
}
recordNativePrintedCardPlay(g, p.id, 'juiceOfSapho');
const used = discard(g, p, action.card as string);
guardTurnActionPrintedCardCost(g, p, used);
log(g, `${p.name} discarded Juice of Sapho to react ${String(action.mode)} in the shared arrival.`,
{ faction: p.faction, name: 'Juice of Sapho' });
const continuation: SaphoArrivalOrderDiscardContinuation = {
kind: 'saphoArrivalOrderDiscard', player: p.id, card: used.id, event: request.event,
revision: request.revision, mode: action.mode as 'first' | 'last',
resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
stateSignature: '',
};
const entries = [{card: used, discardedBy: p.id, publicFace: true}];
requireRule(semutaOfferSupported(g, continuation, entries), 'This arrival-order discard cannot safely resume its Semuta reaction.');
continuation.stateSignature = saphoArrivalOrderDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'sapho:arrivalOrder', entries, continuation);
}
/** Quote the exact public parent/discard continuation on a detached copy.
* This does not inspect whether anyone holds Semuta; it prevents offering a
* Sapho action whose mandatory supported reaction cannot be staged. */
@@ -41845,6 +42021,15 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
);
return normalizeAutomaticGame(state);
}
if (arrivalOrderWaiting(state)) {
getPlayer(state, id);
if (action?.type === 'advanceBots') return state;
requireRule(action?.type === 'arrivalOrderPass' || action?.type === 'saphoArrivalOrder',
'Finish the shared arrival order before another action.');
const game = structuredClone(state);
decideSaphoArrivalOrder(game, getPlayer(game, id), action);
return game.pendingTreacheryDiscard?.reaction ? game : normalizeAutomaticGame(game);
}
if (action.alliancePromiseEvent !== undefined) {
const guide = projectedAlliancePromiseGuide(state, id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
@@ -42113,6 +42298,7 @@ function finishActionContinuations(g: Game) {
finishTreacheryDiscard(g);
if (g.pendingTreacheryDiscard?.reaction) return;
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
if (arrivalOrderWaiting(g)) return;
if (g.pendingNexusEmperorPayment) return;
if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
if (pendingNexusTraitors(g)) return;
@@ -42170,6 +42356,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (arrivalOrderWaiting(g)) return;
if (g.victoryContinuation) return;
if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
@@ -42182,6 +42369,7 @@ function settleAutomaticContinuations(g: Game) {
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
if (arrivalOrderWaiting(g)) return;
if (g.victoryContinuation) return;
if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
@@ -47178,6 +47366,13 @@ export function viewGame(state: Game, id: string, now?: number) {
allyAtActivation: g.moritaniAtomics.allyAtActivation }
: null,
arrivalOverlapMode: arrivalOverlapModeSupported(g),
saphoArrivalOrder: arrivalOrderWaiting(g) && !g.pendingTreacheryDiscard
? { event: g.pendingArrivalOverlap!.saphoOrder!.frame.event,
revision: g.pendingArrivalOverlap!.saphoOrder!.frame.revision,
order: g.pendingArrivalOverlap!.saphoOrder!.queue.map(item => item.player),
passed: [...g.pendingArrivalOverlap!.saphoOrder!.frame.passed],
choices: saphoArrivalOrderChoices(g, me) }
: null,
arrivalOverlap: g.pendingArrivalOverlap
? {
first: g.pendingArrivalOverlap.first,
+1
View File
@@ -513,6 +513,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. At a clean boundary, the holder may pause its own claimed turn and resume last without repeating the claim or payment. Completed turns stay closed and physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.',
'When two or more players declare simultaneous Truthtrances in a quiet unrevealed battle, their public question-order window can use Sapho to move the holder’s group first or last. The current battle and committed plans remain in place. In the supported Basic Semuta preview, the discard and any exchange finish before the reordered questions resume.',
'In a supported Basic Richese game, a shared Ecaz Ambassador and Moritani Terror entry opens a public ordering opportunity before either reaction. Each owner may continue or use an eligible held Sapho to put its own reaction first or last. The original entry and tokens remain committed; the ordinary neutral Semuta response finishes before the queue continues. This ordering is provisional and other arrival combinations remain unfinished.',
'Finish pending shipment, movement and card preparations and 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, movement and ordered Charity/Revival discards pause after their 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. Charity/Revival retain their current participant, claims or requests and allowances through Semuta claim/pass. 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.',
+88
View File
@@ -0,0 +1,88 @@
import {
createSaphoTruthOrder,
passSaphoTruthOrder,
quoteSaphoTruthOrder,
reorderSaphoTruthOrder,
SaphoTruthOrderError,
validateSaphoTruthOrder,
type SaphoTruthOrderFrame,
type SaphoTruthOrderMode,
type SaphoTruthOrderRequest,
type SaphoTruthOrderResult,
} from './sapho-truth-order';
/** Public owner identities only: no card, token face or hidden choice is stored. */
export type SaphoArrivalOrder = {
frame: SaphoTruthOrderFrame;
queue: [{ player: string }, { player: string }];
};
export type SaphoArrivalOrderResult = { order: SaphoArrivalOrder; done: boolean };
type Entry = { player: string };
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new SaphoTruthOrderError(message);
}
/** The original pair is already in provisional storm order. */
export function createSaphoArrivalOrder(event: string, firstOwner: string, secondOwner: string): SaphoArrivalOrder {
const queue: SaphoArrivalOrder['queue'] = [{ player: firstOwner }, { player: secondOwner }];
const frame = createSaphoTruthOrder(queue, event);
check(frame && frame.holders.length === 2, 'Arrival ordering requires two distinct original owners.');
return { frame, queue };
}
function validateShape(order: SaphoArrivalOrder): void {
check(order && typeof order === 'object' && !Array.isArray(order) &&
Array.isArray(order.queue) && order.queue.length === 2 && order.frame &&
Array.isArray(order.frame.holders) && order.frame.holders.length === 2,
'Retain the two original arrival owners and ordering frame.');
validateSaphoTruthOrder(order.frame, order.queue);
check(order.queue[0].player === order.frame.holders[0] ||
order.frame.protectedFirst === order.queue[0].player ||
order.frame.protectedLast === order.queue[1].player,
'The reordered arrival must retain its original Sapho choice.');
}
/** Match a saved frame to the unchanged public parent, not a caller-supplied
* card or a newly sampled token. The queue may differ from original order only
* through a recorded Sapho choice. */
export function validateSaphoArrivalOrder(
order: SaphoArrivalOrder, event: string, originalOwners: readonly string[], selectedFirst: string,
): void {
validateShape(order);
check(order.frame.event === event && Array.isArray(originalOwners) && originalOwners.length === 2 &&
order.frame.holders[0] === originalOwners[0] && order.frame.holders[1] === originalOwners[1] &&
order.queue[0].player === selectedFirst,
'The arrival ordering no longer matches its original owners, event or selected first reaction.');
}
export function quoteSaphoArrivalOrder(order: SaphoArrivalOrder, player: string) {
validateShape(order);
return quoteSaphoTruthOrder(order.frame, order.queue, player);
}
function detach(result: SaphoTruthOrderResult<Entry>): SaphoArrivalOrderResult {
check(result.queue.length === 2, 'Retain both arrival owners.');
return {
order: {
frame: { ...result.frame, holders: [...result.frame.holders], passed: [...result.frame.passed] },
queue: [{ player: result.queue[0].player }, { player: result.queue[1].player }],
},
done: result.done,
};
}
/** Passing and using Sapho both retain the complete public receipt, even after
* the last owner has responded, so later continuation validates the order. */
export function passSaphoArrivalOrder(order: SaphoArrivalOrder, request: SaphoTruthOrderRequest): SaphoArrivalOrderResult {
validateShape(order);
return detach(passSaphoTruthOrder(order.frame, order.queue, request));
}
export function reorderSaphoArrivalOrder(
order: SaphoArrivalOrder, request: SaphoTruthOrderRequest & { mode: SaphoTruthOrderMode },
): SaphoArrivalOrderResult {
validateShape(order);
return detach(reorderSaphoTruthOrder(order.frame, order.queue, request));
}