Prototype Semuta after Recruits in Basic play
Verify and publish checkpoint container / container (push) Successful in 10m20s

This commit is contained in:
Quintine committed 2026-10-28 22:19:12 +11:00
1 parent 0e9209531a
commit 1ba942138d
9 files changed
+311 -18

No files matched your search

+40 -3
View File
@@ -1659,7 +1659,7 @@ export type Game = {
kind: 'ordinaryCardDiscard';
player: string;
card: string;
effect: 'hajr' | 'ghola' | 'harvester' | 'weather' | 'atomics';
effect: 'hajr' | 'ghola' | 'harvester' | 'weather' | 'atomics' | 'recruits';
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
@@ -2605,6 +2605,8 @@ export type Game = {
moritaniAssassinatePreview?: true;
/** Explicit development-only neutral Semuta reaction profile. */
semutaPreview?: true;
/** Fresh Basic independent-card preview for Semuta after Recruits only. */
semutaRecruitsPreview?: true;
moritaniAssassinate?: MoritaniAssassinateState;
moritaniAssassinateCallEvents?: string[];
moritaniAssassinateResume?: {
@@ -6230,6 +6232,8 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
...(g.grummanCollection === undefined ? {} : { grummanCollection: g.grummanCollection }),
dukeVidal: g.dukeVidal,
revivalFreeIncome: g.revivalFreeIncome,
...(next.effect === 'recruits' ? {recruits: g.recruits, freeRevival: g.freeRevival,
revivalRules: g.revivalRules, revivalOrder: g.revivalOrder} : {}),
players: g.players.map((p) => ({
id: p.id,
faction: p.faction,
@@ -6243,6 +6247,7 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
moved: p.moved,
shipped: p.shipped,
revived: p.revived,
...(next.effect === 'recruits' ? {freeForcesRevived: p.freeForcesRevived} : {}),
revivalCycle: p.revivalCycle,
...(p.ecazRevivalCohort === undefined ? {} : { ecazRevivalCohort: p.ecazRevivalCohort }),
leaderRevived: p.leaderRevived,
@@ -7809,6 +7814,9 @@ function tupileDiscardSignature(g: Game, c: TupileDiscardContinuation) {
}
function treacheryDiscardIntegrity(g: Game) {
const discovery = g.discoveryEnabled === true && !!g.discoveries && discoveryModeSupported(g);
requireRule(g.semutaRecruitsPreview === undefined || g.semutaRecruitsPreview === true &&
basicIndependentSemutaProfile(g) && !g.semutaPreview,
'This Recruits Semuta preview no longer matches its original Basic profile.');
requireRule(g.semutaPreview === undefined || g.semutaPreview === true &&
g.expansions.includes('choam') && !!byFaction(g, 'richese') &&
(!g.homeworlds || g.homeworldOccupationPreview === true && semutaHomeworldRoster(g)) &&
@@ -7999,7 +8007,7 @@ function treacheryDiscardIntegrity(g: Game) {
const owner = g.players.find((p) => p.id === c.player);
requireRule(
owner &&
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(
['hajr', 'ghola', 'harvester', 'weather', 'atomics', 'recruits'].includes(
c.effect,
) &&
batch.cause === `ordinary:${c.effect}` &&
@@ -8022,7 +8030,11 @@ function treacheryDiscardIntegrity(g: Game) {
'The consumed card no longer matches its pending revival income.',
);
requireRule(
c.effect === 'hajr'
c.effect === 'recruits'
? g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) &&
g.phase === 4 && g.recruits?.turn === g.turn && g.recruits.player === c.player &&
g.recruits.card === c.card
: c.effect === 'hajr'
? g.phase === 5 &&
g.active === c.player &&
g.hajr.filter((id) => id === c.player).length === 1
@@ -9282,6 +9294,14 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
if (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'recruits')
return g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) &&
g.status === 'playing' && g.phase === 4 && g.recruits?.turn === g.turn &&
g.recruits.player === continuation.player && g.recruits.card === continuation.card &&
entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player &&
entries[0].card.id === RECRUITS_CARD_ID && entries[0].card.effect === 'recruits' &&
!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening;
if (continuation.kind === 'kullSuccessDiscard' || continuation.kind === 'kullCounterDiscard')
return g.semutaPreview === true && g.advanced === false && bareBasicSemutaProfile(g) &&
!!byFaction(g, 'richese') && kullSemutaParentSupported(kullSource(g, continuation.frame), continuation.frame) &&
@@ -12928,6 +12948,17 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
'Assassination requires Advanced Moritani in a supported selected native roster and module composition.');
return initializeFactionExpansionsGameForAudit(state);
}
/** Independent Ecaz cards with Richese and classic factions, without other
* modules, reuse the already supported Basic Semuta discard machinery. */
function basicIndependentSemutaProfile(g: Game): boolean {
return g.advanced === false && g.ecazTreachery === true && g.expansions.length === 1 &&
g.expansions[0] === 'choam' && !!byFaction(g, 'richese') &&
g.players.length >= 2 && g.players.length <= 6 &&
g.players.every(p => p.faction === 'richese' || faction(p.faction).expansion === 'base') &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
!g.kullPreview && !g.nexusKullPreview && !g.ecazOccupyPreview && !g.moritaniAssassinatePreview;
}
/** Bare native Basic starts share the original deck/roster validation. Optional
* modules retain their own explicit Semuta envelopes; saved games are not upgraded. */
function bareBasicSemutaProfile(g: Game): boolean {
@@ -13123,6 +13154,8 @@ function independentCardModulesProfile(g: Game): boolean {
/** Shared fresh setup for the independent three-card Ecaz variant. */
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
requireRule(state.semutaRecruitsPreview === undefined,
'Fresh independent cards cannot inherit an existing Recruits Semuta marker.');
const g = structuredClone(state);
g.ecazTreachery = true;
const discovery = independentDiscoveryProfile(g) && discoveryModuleProfile(g);
@@ -13139,6 +13172,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
requireFreshFactionInventory(g);
}
const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, discovery, false, false, true, true);
if (basicIndependentSemutaProfile(initialized)) initialized.semutaRecruitsPreview = true;
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
@@ -41692,6 +41726,8 @@ function applyActionInner(
`${p.name} played Recruits. Current free revival rates are doubled and the ordinary force revival limit is seven for this turn.`,
{ faction: p.faction, name: 'Recruits' },
);
if (execution === 'live' && g.semutaRecruitsPreview && basicIndependentSemutaProfile(g))
stageOrdinaryCardDiscard(g, p, card);
return g;
}
if (t === 'card' && action.card === 'richese-juice-of-sapho') {
@@ -45915,6 +45951,7 @@ export function viewGame(state: Game, id: string, now?: number) {
botsPending: g.botsPending ?? false,
semutaReaction: projectedSemutaReaction(g, me),
semutaPreview: g.semutaPreview === true,
semutaRecruitsPreview: g.semutaRecruitsPreview === true,
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g) || tupileAutomatic(g),
botNextActionAt: g.botNextActionAt ?? null,
code: g.code,