Keep Atomics penalties on original targets after alliance changes

This commit is contained in:
Quintine committed 2026-10-14 21:06:32 +11:00
1 parent 1f38f12beb
commit 4bf6cfc875
15 files changed
+303 -181

No files matched your search

+1 -4
View File
@@ -23,7 +23,6 @@ import { fremenBetrayalBotActions } from './nexus-fremen-betrayal-options';
import { ecazInquiryBotActions } from './nexus-ecaz-inquiry-options';
import { nexusEcazDukeBotActions } from './nexus-ecaz-duke-options';
import { ecazBetrayalOffer } from './nexus-ecaz-betrayal';
import { atomicsAllianceChangeBlocked } from './moritani-atomics';
import { fremenBetrayalOffer } from './nexus-fremen-betrayal';
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
@@ -2993,15 +2992,13 @@ function policyActions(g: GameView): Action[] {
const offer = g.players.find(
(player) => player.id !== me.id &&
g.allianceOffers[player.id] === me.id &&
!atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, player.id) &&
!g.homeworldAllianceBlocks?.[player.id],
);
if (offer) return [{ type: 'alliance', target: offer.id }];
if (level > 0 && !g.ready.includes(me.id) && !g.allianceOffers[me.id]) {
const target = g.players.find(
(player) => player.id !== me.id &&
!player.ally && !g.homeworldAllianceBlocks?.[player.id] &&
!atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, player.id),
!player.ally && !g.homeworldAllianceBlocks?.[player.id],
);
if (target) return [{ type: 'alliance', target: target.id }];
}
+23 -16
View File
@@ -68,7 +68,7 @@ import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal';
import { atomicsAllianceChangeBlocked, atomicsAllianceStatus, atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics';
import { atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics';
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
@@ -17508,8 +17508,6 @@ function terrorAllianceBlocked(
): string | null {
if (entry.allianceBlocked)
return 'Karama prevented this alliance opportunity; you may still reveal the token or leave it hidden.';
if (g.moritaniAtomics)
return 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.';
const entrant = getPlayer(g, entry.entrant);
const owner = byFaction(g, 'moritani')!;
const homeworldBlock = homeworldAllianceReason(g, owner.id, entrant.id);
@@ -17526,8 +17524,6 @@ function terrorAllianceBlocked(
function formTerrorAlliance(g: Game, owner: Player, entrant: Player) {
const blocked = homeworldAllianceReason(g, owner.id, entrant.id);
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
requireRule(!g.moritaniAtomics,
'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.');
const changed = new Set([owner.id, entrant.id]);
for (const member of [owner, entrant]) {
if (member.ally) getPlayer(g, member.ally).ally = null;
@@ -20530,6 +20526,21 @@ function gholaOptions(g: Game, p: Player) {
reason,
};
}
/** Old Aftermath already records its exact affected seats. Adopt those targets
* without rewriting forces, hands, alliances or the original activation. */
function migrateAtomicsTargets(state: Game): Game {
const aftermath = state.moritaniAtomics;
if (!aftermath ||
(aftermath as unknown as { alliancePolicy?: unknown }).alliancePolicy !== 'unresolved')
return state;
const migrated = {
...state,
moritaniAtomics: { ...aftermath, alliancePolicy: 'activation-targets' as const },
};
atomicsIntegrity(migrated);
return migrated;
}
function atomicsIntegrity(g: Game) {
const aftermath = g.moritaniAtomics;
if (!aftermath) {
@@ -20543,12 +20554,13 @@ function atomicsIntegrity(g: Game) {
Number.isSafeInteger(aftermath.turn) && aftermath.turn >= 1 &&
aftermath.turn <= g.turn && TERROR_STRONGHOLDS.includes(aftermath.territory) &&
token?.status === 'removed' && token.location === null &&
aftermath.alliancePolicy === 'unresolved' &&
atomicsAllianceStatus(aftermath, owner.ally) === 'activation-alliance' &&
aftermath.alliancePolicy === 'activation-targets' &&
(aftermath.allyAtActivation === null || g.players.some(player =>
player.id === aftermath.allyAtActivation && player.id !== owner.id)) &&
g.players.every(player =>
player.atomicsHandLimitPenalty ===
(player.id === owner.id || player.id === aftermath.allyAtActivation ? true : undefined)),
'Atomics Aftermath has lost its revealed token, stable alliance or hand-limit custody.');
'Atomics Aftermath has lost its revealed token, original targets or hand-limit custody.');
}
function marketGholaIntegrity(g: Game) {
battleOrderIntegrity(g);
@@ -28150,6 +28162,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
state = migrateAtomicsTargets(state);
if (state.spiceBankerIncomePreview) {
const keysBySource: Record<string, readonly string[]> = {
bid: ['type','amount','allyPayment'], passBid: ['type'],
@@ -28550,6 +28563,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
state = migrateAtomicsTargets(state);
harkonnenNexusBetrayalIntegrity(state);
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
@@ -31011,9 +31025,6 @@ function applyActionInner(
'Alliances change only during a Nexus.',
);
const target = action.target ? stringField(action.target) : null;
requireRule(!atomicsAllianceChangeBlocked(g.moritaniAtomics, id, target) ||
(target === null && !p.ally),
'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.');
const formerAlly = p.ally ? getPlayer(g, p.ally).name : null;
const formerOffer = g.allianceOffers[id];
const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name;
@@ -31037,11 +31048,6 @@ function applyActionInner(
},
),
);
if (g.moritaniAtomics) {
const moritani = byFaction(g, 'moritani')!;
requireRule(quote.allies[moritani.id] === moritani.ally,
'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.');
}
for (const player of g.players) player.ally = quote.allies[player.id];
g.allianceOffers = quote.offers;
if (target === null) {
@@ -32633,6 +32639,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
state = migrateAtomicsTargets(state);
harkonnenNexusBetrayalIntegrity(state);
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
+3 -40
View File
@@ -9,8 +9,8 @@ export type AtomicsAftermath = Readonly<{
turn: number;
moritaniId: string;
allyAtActivation: string | null;
/** A later alliance change has no source-backed hand-limit interpretation yet. */
alliancePolicy: 'unresolved';
/** Provisional permanent targets: Moritani and its ally at activation. */
alliancePolicy: 'activation-targets';
}>;
export type AtomicsPlayer = Pick<
@@ -103,7 +103,7 @@ export function quoteMoritaniAtomics(input: {
return {
casualties,
aftermath: { territory: token.location, turn, moritaniId,
allyAtActivation: ally?.id ?? null, alliancePolicy: 'unresolved' },
allyAtActivation: ally?.id ?? null, alliancePolicy: 'activation-targets' },
handReductions,
};
}
@@ -116,40 +116,3 @@ export function atomicsShipmentBlocked(
return aftermath?.territory === destinationTerritory;
}
/** A public pre-offer guard avoids promises whose acceptance would change Moritani's unresolved penalty recipient. */
export function atomicsAllianceChangeBlocked(
aftermath: Pick<AtomicsAftermath, 'moritaniId' | 'allyAtActivation'> | null | undefined,
actor: string,
target: string | null,
): boolean {
return !!aftermath && (actor === aftermath.moritaniId ||
actor === aftermath.allyAtActivation ||
target === aftermath.moritaniId ||
target === aftermath.allyAtActivation);
}
/** Must be checked before interpreting lasting hand limits after any ally change. */
export function atomicsAllianceStatus(
aftermath: AtomicsAftermath,
currentAllyId: string | null,
): 'activation-alliance' | 'clarification-required' {
return aftermath.alliancePolicy === 'unresolved' &&
currentAllyId === aftermath.allyAtActivation
? 'activation-alliance' : 'clarification-required';
}
/** A queried player retains the initial reduction only while the alliance remains unchanged. */
export function atomicsEffectiveHandLimit(
aftermath: AtomicsAftermath | null | undefined,
playerId: string,
currentAllyId: string | null,
baseLimit: number,
): number {
requireAtomics(Number.isSafeInteger(baseLimit) && baseLimit >= 1,
'Atomics requires an authoritative base hand limit.');
if (!aftermath) return baseLimit;
requireAtomics(atomicsAllianceStatus(aftermath, currentAllyId) === 'activation-alliance',
'A later Moritani alliance change needs an authoritative Atomics hand-limit ruling.');
return playerId === aftermath.moritaniId ||
playerId === aftermath.allyAtActivation ? baseLimit - 1 : baseLimit;
}
+2 -2
View File
@@ -2955,7 +2955,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination, Extortion and bounded Atomics have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Complete Moritani games remain unfinished.',
'Assassination: randomly select one of the entrant’s leaders and send it to the Tanks. Moritani collects spice for that leader’s value; Zoal pays three. Revealing the token resolves the random selection without letting Moritani choose a victim. This token needs neither a battle loss nor a matching Traitor Card. Current controls support ordinary native leaders; unresolved captured, foreign or special-leader pools block revelation and show Moritani the reason privately.',
'Atomics: send every faction’s forces in the territory to the Tanks and place public Atomics Aftermath there. Supported revelation is limited to classic-plus-Moritani without Guild, Ecaz or optional modules and with a stable Moritani alliance. Its lasting shipment prohibition and hand-limit penalty are explained in the Aftermath topic.',
'Atomics sends every faction’s forces in the territory to the Tanks and places public Aftermath. Supported revelation remains bounded to classic-plus-Moritani without Guild, Ecaz or optional modules. Later alliances now retain the original affected hand-limit targets under the explicit provisional interpretation in the Aftermath topic.',
'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.',
'Robbery: choose between taking half the entrant’s spice, rounded up, and drawing the top Treachery Card. If the draw exceeds your hand limit, choose a card to discard afterward; a full hand does not remove the draw option.',
'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional.',
@@ -2982,7 +2982,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Aftermath permanently prohibits shipping forces into that territory, including Fremen reinforcements. The restriction is on shipment: it does not prohibit otherwise legal ordinary movement into the territory.',
'Sneak Attack expressly permits its reserve forces to enter despite Aftermath. Its storm and occupancy restrictions still apply.',
'Beginning on the activation turn, Moritani’s hand limit and its ally’s at activation are each reduced by one. If either hand exceeds its reduced limit on resolution, discard one random card per excess card. The public Aftermath notice identifies the affected players; their individual projected hand limits reflect the penalty.',
'The lasting penalty’s treatment after a later alliance change is unresolved. Supported play gates later Moritani alliance changes rather than guessing whether the penalty remains with an old ally or follows a new one.',
'Under the recommended persistent-target interpretation, later Nexus or Enemy of My Enemy alliances do not move the penalty: Moritani and the ally at detonation remain reduced; a later ally keeps its normal limit. An initially unallied detonation gains no ally target afterward. The notice labels this provisional, not a publisher ruling. Original stored targets migrate without replaying casualties or card discards. Genuine Basic/Advanced later-pact, special-alliance, migration and corruption boundaries pass112/112 in6 related files, with types/lint/build and actual390px controls verified. Broader Atomics compositions and complete modes remain open.',
'The Atomics Terror token and Family Atomics Treachery Card are separate components with different effects. Aftermath is not the destroyed Shield Wall. Complete Moritani games and public expansion starts remain unavailable.',
],
related: ['moritani-terror-effects', 'movement', 'card-atomics', 'bidding'],