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

+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,