Enforce Atomics shipment ban across transport
Verify and publish checkpoint container / container (push) Successful in 10m7s

This commit is contained in:
Quintine committed 2026-10-29 03:03:18 +11:00
1 parent 4feb418488
commit 0f8e0b4153
17 files changed
+477 -19

No files matched your search

+27 -5
View File
@@ -7911,7 +7911,7 @@ 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,
basicIndependentRecruitsSemutaProfile(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') &&
@@ -8130,7 +8130,7 @@ function treacheryDiscardIntegrity(g: Game) {
);
requireRule(
c.effect === 'recruits'
? g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) &&
? g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g) &&
g.phase === 4 && g.recruits?.turn === g.turn && g.recruits.player === c.player &&
g.recruits.card === c.card
: c.effect === 'hajr'
@@ -9469,7 +9469,7 @@ function semutaOfferSupported(
entries: FreshDiscardBatch['entries'],
) {
if (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'recruits')
return g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) &&
return g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(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 &&
@@ -13183,6 +13183,20 @@ function basicIndependentSemutaProfile(g: Game): boolean {
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
!g.kullPreview && !g.nexusKullPreview && !g.ecazOccupyPreview && !g.moritaniAssassinatePreview;
}
/** Recruits' completed Revival discard has no faction-specific continuation.
* Its separate admission does not broaden other independent-card reactions. */
function basicIndependentRecruitsSemutaProfile(g: Game): boolean {
return g.advanced === false && g.ecazTreachery === true &&
g.expansions.includes('choam') && !!byFaction(g, 'richese') &&
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
g.players.length >= 2 && g.players.length <= 6 &&
g.players.every(p => faction(p.faction).expansion === 'base' ||
g.expansions.includes(faction(p.faction).expansion)) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
!g.kullPreview && !g.nexusKullPreview && !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 {
@@ -13396,7 +13410,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 (basicIndependentRecruitsSemutaProfile(initialized)) initialized.semutaRecruitsPreview = true;
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
@@ -38024,6 +38038,8 @@ function quoteJunctionDeclaration(g: Game, p: Player, declaration: JunctionTrans
const advisors = arrival ? arrivalAsAdvisor(g, p, arrival.territory,
quote.originKind === 'arrakis' ? quote.origin : undefined) : false;
if (arrival) {
requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, arrival.territory),
'Atomics Aftermath prevents shipment into this territory.');
requireRule(arrival.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians',
'Only Ixians may ship directly into the mobile stronghold.');
requireRule(advisors || sourceLock !== g.turn ||
@@ -39088,6 +39104,8 @@ function validateGuildTransportForCommit(
'New advisors cannot become fighters this turn.');
requireRule(!frame.noField || to === 'reserves' || !to.startsWith('homeworld:'),
'A concealed No-Field cannot be placed on a Homeworld.');
requireRule(to === 'reserves' || !atomicsShipmentBlocked(g.moritaniAtomics, to),
'Atomics Aftermath prevents shipment into this territory.');
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
const halfRate = !!guildSecretEvent || g.karamaShipping?.player === p.id ||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id);
@@ -39112,6 +39130,8 @@ function commitGuildTransport(
) {
requireRule(!g.guildBetrayalPreview || !guildSecretEvent,
'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.');
requireRule(frame.to === 'reserves' || !atomicsShipmentBlocked(g.moritaniAtomics, frame.to),
'Atomics Aftermath prevents shipment into this territory.');
if (!guildSecretEvent || frame.noField) validateGuildTransportForCommit(g, p, frame, fromReserves, guildSecretEvent);
if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return;
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
@@ -42643,7 +42663,7 @@ 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))
if (execution === 'live' && g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g))
stageOrdinaryCardDiscard(g, p, card);
return g;
}
@@ -45976,6 +45996,8 @@ function applyActionInner(
);
const to = stringField(action.territory ?? 'reserves');
const sector = integer(action.sector ?? 0, 0, 18);
requireRule(to === 'reserves' || !atomicsShipmentBlocked(g.moritaniAtomics, to),
'Atomics Aftermath prevents shipment into this territory.');
requireRule(fromReserves || to === 'reserves' || to !== origin, 'Cross-shipment requires a different destination territory.');
requireRule(
to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent,