Enable Semuta in public Discovery games
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 09:31:40 +11:00
1 parent b2b9a024bd
commit 98ead3a4b5
11 files changed
+436 -8

No files matched your search

+72 -3
View File
@@ -1802,6 +1802,15 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoStationDiscard';
player: string;
card: string;
event: string;
mode: 'first' | 'last';
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoAuctionDiscard';
cyclic?: 'normal' | 'blackMarket';
@@ -6458,6 +6467,17 @@ type SaphoExtortionDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoExtortionDiscard' }
>;
type SaphoStationDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoStationDiscard' }
>;
function saphoStationDiscardSignature(g: Game, c: SaphoStationDiscardContinuation) {
return JSON.stringify({player:c.player, card:c.card, event:c.event, mode:c.mode,
parent:nullentropyParentSignature(g,c.resume,c.event), turn:g.turn, phase:g.phase,
order:g.order, ready:g.ready, storm:g.storm, stormPending:g.stormPending,
source:g.stormMovementSource, station:g.ecologicalStorm, discoveries:g.discoveries,
players:g.players.map(p=>({id:p.id, faction:p.faction, forces:p.forces, advisors:p.advisors}))});
}
type SaphoCharityDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoCharityDiscard' }
@@ -8360,6 +8380,12 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === saphoExtortionDiscardSignature(g, c),
'The used Juice of Sapho no longer matches its current Extortion payer order.');
moritaniExtortionIntegrity(g);
} else if (continuation?.kind === 'saphoStationDiscard') {
const c = continuation;
requireRule(batch.cause === 'sapho:station' &&
semutaOfferSupported(g, c, batch.entries) &&
c.stateSignature === saphoStationDiscardSignature(g, c),
'The used Juice of Sapho no longer matches its Testing Station choice.');
} else if (continuation?.kind === 'saphoMovementDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -9594,6 +9620,19 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
if (continuation.kind === 'saphoStationDiscard') {
const frame = g.ecologicalStorm, decision = continuation.resume.decision, receipt = frame?.sapho;
return g.semutaPreview === true && g.discoveryEnabled === true && !!g.discoveries &&
discoveryModeSupported(g) && g.status === 'playing' && g.phase === 0 &&
frame?.stage === 'choose' && frame.queue?.choices.length === 0 &&
receipt?.event === continuation.event && receipt.holder === continuation.player &&
receipt.mode === continuation.mode && decision?.kind === 'ecologicalStorm' &&
decision.player === frame.owner && decision.event === frame.event && !g.decision &&
!g.response && !g.pendingKarama && !g.phaseOpening && !continuation.resume.response &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening &&
entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player &&
entries[0].card.id === continuation.card && entries[0].card.effect === 'juiceOfSapho';
}
if (continuation.kind === 'saphoArrivalOrderDiscard') {
const order = g.pendingArrivalOverlap?.saphoOrder;
return g.semutaPreview === true && bareBasicSemutaProfile(g) && g.phase === 5 &&
@@ -10364,6 +10403,11 @@ 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 === 'saphoStationDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
} else if (next.kind === 'saphoMovementDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -11025,6 +11069,17 @@ function publicSemutaSkillsProfile(g: Game): boolean {
!g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.ecazTreachery &&
!g.leaderSkills && !g.kullPreview && !g.nexusKullPreview;
}
/** Fresh public Discovery entry for the existing native Richese Semuta profile. */
function publicSemutaDiscoveryProfile(g: Game): boolean {
return g.status === 'lobby' && typeof g.advanced === 'boolean' &&
g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.length === 1 && g.expansions[0] === 'choam' && !!byFaction(g, 'richese') &&
g.players.every(p => p.faction === 'richese' || p.faction === 'choam' || CLASSIC_FACTIONS[p.faction] === true) &&
(!g.techTokens || g.players.length >= 3) && (!g.strongholdCards || g.advanced) &&
!g.twoPlayerControllers && !g.shortAllianceVictory && !g.coalitionPreview &&
!g.homeworlds && !g.nexusCards && g.discoveryEnabled === true && !g.ecazTreachery &&
!g.leaderSkills && !g.kullPreview && !g.nexusKullPreview && discoveryModuleProfile(g);
}
/** Route a normal start through the existing genuine setup for the selected modules. */
function startModules(state: Game): Game {
const options = state.lobbyOptions ?? {};
@@ -11061,7 +11116,8 @@ function startModules(state: Game): Game {
return pair && pair.every(id => g.players.some(p => p.faction === id))
? initializePairedNexusGameForAudit(g) : initializeNexusGameForAudit(g);
}
if (g.discoveryEnabled) return initializeDiscoveryGameForAudit(g);
if (g.discoveryEnabled) return publicSemutaDiscoveryProfile(g)
? initializeSemutaGameForAudit(g) : initializeDiscoveryGameForAudit(g);
if (g.homeworlds)
return g.expansions.length ? initializeHomeworldOccupationGameForAudit(g) : initializeHomeworldGameForAudit(g);
if (g.strongholdCards) return initializeStrongholdFactionsGameForAudit(g);
@@ -29173,7 +29229,7 @@ function saphoExtortionDecision(g: Game) {
}
function saphoTestingStationDecision(g: Game) {
const station = g.ecologicalStorm, decision = g.decision;
return g.phase === 0 && !g.pendingTreacheryDiscard && !g.semutaPreview &&
return g.phase === 0 && !g.pendingTreacheryDiscard &&
!g.semutaAdvancedOrdinaryPreview && !g.semutaRecruitsPreview &&
station?.stage === 'choose' && decision?.kind === 'ecologicalStorm' &&
decision.player === station.owner && decision.event === station.event;
@@ -29546,11 +29602,24 @@ function playSapho(g: Game, p: Player, action: Action) {
const frame = nexusRule(() => reorderDiscoveryStorm(g, g.ecologicalStorm!, {
holder: p.id, event: option.event, mode: option.mode, nextEvent: crypto.randomUUID(),
}));
discard(g, p, 'richese-juice-of-sapho');
const used = discard(g, p, 'richese-juice-of-sapho');
g.ecologicalStorm = frame;
g.decision = {kind:'ecologicalStorm', player:frame.owner, event:frame.event};
log(g, `${p.name} discarded Juice of Sapho to take the Testing Station choice ${option.mode}. Both occupants retain one adjustment and the original storm movement remains.`,
{faction:p.faction,name:'Juice of Sapho'});
if (g.semutaPreview) {
const continuation: SaphoStationDiscardContinuation = {
kind:'saphoStationDiscard', 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}];
g.decision = null;
requireRule(semutaOfferSupported(g, continuation, entries),
'This Testing Station discard cannot safely resume its Semuta reaction.');
continuation.stateSignature = saphoStationDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'sapho:station', entries, continuation);
}
} else if (option.scope === 'battleAggressor') {
const b = g.battle!;
const next = saphoAggressorRule(() => changeSaphoAggressor(b.saphoAggressor,