Connect bounded Moritani Atomics Aftermath
This commit is contained in:
1 parent
b2603f6715
commit
66a427ab55
17 files changed
+1036
-68
No files matched your search
@@ -46,6 +46,8 @@ export function botArrivalBlock(g: GameView, action: Action): string | null {
|
||||
};
|
||||
let order: CompletedMovementArrivalInput['order'];
|
||||
if (action.type === 'ship') {
|
||||
if (g.moritaniAtomics?.territory === to)
|
||||
return 'Atomics Aftermath permanently blocks shipments into this territory.';
|
||||
const amount = action.noField === undefined ? action.amount : 1;
|
||||
if (
|
||||
typeof amount !== 'number' ||
|
||||
|
||||
+4
-1
@@ -21,6 +21,7 @@ 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';
|
||||
@@ -2844,13 +2845,15 @@ 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],
|
||||
!player.ally && !g.homeworldAllianceBlocks?.[player.id] &&
|
||||
!atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, player.id),
|
||||
);
|
||||
if (target) return [{ type: 'alliance', target: target.id }];
|
||||
}
|
||||
|
||||
+178
-7
@@ -58,6 +58,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 } from './nexus-fremen-betrayal';
|
||||
import { atomicsAllianceChangeBlocked, atomicsAllianceStatus, 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';
|
||||
@@ -591,6 +592,8 @@ export type Player = {
|
||||
ixMovementBlocked?: { turn: number; move: number };
|
||||
fremenMovementBlocked?: { turn: number; move: number };
|
||||
fremenNexusMovementBlockedTurn?: number;
|
||||
/** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */
|
||||
atomicsHandLimitPenalty?: boolean;
|
||||
elites?: {
|
||||
reserves: number;
|
||||
tanks: number;
|
||||
@@ -1155,6 +1158,12 @@ export type Game = {
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>;
|
||||
discardedHandSize: number;
|
||||
}
|
||||
| {
|
||||
kind: 'terrorAtomics';
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>;
|
||||
aftermath: AtomicsAftermath;
|
||||
handBefore: { player: string; count: number }[];
|
||||
}
|
||||
| {
|
||||
kind: 'battleResolved';
|
||||
event: string;
|
||||
@@ -1414,6 +1423,7 @@ export type Game = {
|
||||
ambassadorEvent?: string;
|
||||
} | null;
|
||||
moritaniTerror?: TerrorState;
|
||||
moritaniAtomics?: AtomicsAftermath | null;
|
||||
moritaniExtortion?: ExtortionState;
|
||||
grummanCollection?: GrummanCollection;
|
||||
pendingMoritaniPlacement?: {
|
||||
@@ -4217,8 +4227,81 @@ function richeseGiftView(g: Game, viewer: Player) {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
export const handLimit = (p: Pick<Player, 'faction'>) =>
|
||||
p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4;
|
||||
export const handLimit = (
|
||||
p: Pick<Player, 'faction'> & Partial<Pick<Player, 'atomicsHandLimitPenalty'>>,
|
||||
) =>
|
||||
(p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4) -
|
||||
Number(p.atomicsHandLimitPenalty === true);
|
||||
|
||||
function atomicsRule<T>(run: () => T): T {
|
||||
try { return run(); }
|
||||
catch (error) {
|
||||
if (error instanceof Error) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Only the clean printed-territory entry is connected; other arrival parents remain gated. */
|
||||
function atomicsModeSupported(g: Game): boolean {
|
||||
return !!byFaction(g, 'moritani') && !byFaction(g, 'ecaz') &&
|
||||
!byFaction(g, 'guild') &&
|
||||
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true || p.faction === 'moritani') &&
|
||||
g.expansions.every(expansion => expansion === 'ecaz') &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills &&
|
||||
!g.discoveryEnabled && !g.techTokens && !g.strongholdCards &&
|
||||
!g.ecazTreachery && !g.pendingArrivalOverlap;
|
||||
}
|
||||
function currentAtomicsQuote(
|
||||
g: Game,
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
): AtomicsQuote {
|
||||
requireRule(atomicsModeSupported(g) && !g.moritaniAtomics && !g.moritaniExtortion,
|
||||
'Atomics with these combined modules or another unresolved Terror effect is not yet supported.');
|
||||
const token = g.moritaniTerror?.tokens.find(row => row.id === entry.token);
|
||||
requireRule(token, 'The Atomics token has lost its original stronghold.');
|
||||
return atomicsRule(() => quoteMoritaniAtomics({
|
||||
token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id,
|
||||
turn: g.turn, aftermath: g.moritaniAtomics ?? null,
|
||||
players: g.players.map(player => ({
|
||||
...player, handSize: player.hand.length, baseHandLimit: handLimit(player),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
function resolveAtomics(
|
||||
g: Game,
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
quote: AtomicsQuote,
|
||||
) {
|
||||
g.moritaniAtomics = quote.aftermath;
|
||||
for (const casualty of quote.casualties) {
|
||||
const player = getPlayer(g, casualty.playerId);
|
||||
kill(g, player, casualty.location, casualty.normal + casualty.elite, false, casualty.elite);
|
||||
}
|
||||
for (const player of g.players)
|
||||
if (!at(player, quote.aftermath.territory) && player.advisors)
|
||||
delete player.advisors[quote.aftermath.territory];
|
||||
observeOccupation(g);
|
||||
const handBefore: { player: string; count: number }[] = [];
|
||||
const entries: FreshDiscardBatch['entries'][number][] = [];
|
||||
for (const reduction of quote.handReductions) {
|
||||
const player = getPlayer(g, reduction.playerId);
|
||||
handBefore.push({ player: player.id, count: player.hand.length });
|
||||
player.atomicsHandLimitPenalty = true;
|
||||
requireRule(handLimit(player) === reduction.limit,
|
||||
'Atomics has lost its original hand-limit reduction.');
|
||||
for (let i = 0; i < reduction.randomDiscards; i++) {
|
||||
const card = player.hand[Math.floor(random() * player.hand.length)];
|
||||
entries.push({ card: discard(g, player, card.id),
|
||||
discardedBy: player.id, publicFace: false });
|
||||
}
|
||||
}
|
||||
log(g, `Atomics destroyed every force in ${territory(quote.aftermath.territory).name}. Aftermath permanently blocks shipments there, including Fremen reinforcements. ${quote.aftermath.allyAtActivation ? 'Moritani and its ally each lost' : 'Moritani lost'} one Treachery hand slot; ${entries.length} excess ${entries.length === 1 ? 'card was' : 'cards were'} discarded at random.`);
|
||||
if (entries.length) {
|
||||
g.pendingTerrorEntry = null;
|
||||
g.decision = null;
|
||||
stageTreacheryDiscard(g, 'terror:atomics', entries,
|
||||
{ kind: 'terrorAtomics', entry, aftermath: quote.aftermath, handBefore });
|
||||
} else finishTerrorEntry(g);
|
||||
}
|
||||
export function newPlayer(id: string, name: string, f: FactionId): Player {
|
||||
requireRule(
|
||||
name.trim().length > 0 && name.trim().length <= 32,
|
||||
@@ -5207,6 +5290,37 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(sabotage || discardedHandSize > handLimit(owner)),
|
||||
'The saved Terror discard does not match its consumed entry and hand.',
|
||||
);
|
||||
} else if (continuation?.kind === 'terrorAtomics') {
|
||||
const { entry, aftermath, handBefore } = continuation;
|
||||
atomicsIntegrity(g);
|
||||
const owner = byFaction(g, 'moritani');
|
||||
const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token);
|
||||
requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm &&
|
||||
entry && entry.turn === g.turn && entry.phase === g.phase &&
|
||||
entry.stage === 'offer' && entry.resume === (entry.cause === 'wormRide' ? 'wormRide' : 'none') &&
|
||||
(entry.cause !== 'wormRide' || (g.phase === 1 &&
|
||||
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen'))) &&
|
||||
['shipment', 'movement', 'advisor', 'wormRide'].includes(entry.cause) &&
|
||||
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
|
||||
validGameLocation(g, entry.territory, entry.sector) &&
|
||||
token?.kind === 'atomics' && token.status === 'removed' &&
|
||||
token.location === null &&
|
||||
batch.cause === 'terror:atomics' && batch.entries.length > 0 &&
|
||||
batch.entries.every(row => !row.publicFace &&
|
||||
(row.discardedBy === owner?.id || row.discardedBy === aftermath.allyAtActivation)) &&
|
||||
Array.isArray(handBefore) &&
|
||||
handBefore.length === (aftermath.allyAtActivation ? 2 : 1) &&
|
||||
handBefore.every((row, index) => {
|
||||
const id = index === 0 ? owner?.id : aftermath.allyAtActivation;
|
||||
const player = g.players.find(seat => seat.id === id);
|
||||
const discarded = batch.entries.reduce((count, card) =>
|
||||
count + Number(card.discardedBy === row.player), 0);
|
||||
return player && row.player === id &&
|
||||
Number.isSafeInteger(row.count) && row.count >= 0 &&
|
||||
row.count === player.hand.length + discarded &&
|
||||
discarded === Math.max(0, row.count - handLimit(player));
|
||||
}),
|
||||
'The saved Atomics random discards lost their revealed entry or hand custody.');
|
||||
} else if (continuation?.kind === 'ixAllyCard') {
|
||||
const sale = g.currentAuctionSale;
|
||||
const buyer = g.players.find((p) => p.id === continuation.player);
|
||||
@@ -5581,6 +5695,9 @@ function finishTreacheryDiscard(g: Game) {
|
||||
} else if (next.kind === 'terrorDiscard') {
|
||||
g.pendingTerrorEntry = next.entry;
|
||||
continueTerrorDiscard(g, next.source);
|
||||
} else if (next.kind === 'terrorAtomics') {
|
||||
g.pendingTerrorEntry = next.entry;
|
||||
finishTerrorEntry(g);
|
||||
} else {
|
||||
finishBattle(g);
|
||||
}
|
||||
@@ -5988,7 +6105,7 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
|
||||
p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined &&
|
||||
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
|
||||
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined &&
|
||||
p.faceDancers === undefined &&
|
||||
p.atomicsHandLimitPenalty === undefined && p.faceDancers === undefined &&
|
||||
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
|
||||
}), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
|
||||
requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)),
|
||||
@@ -12943,7 +13060,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
function terrorEntryIntegrity(g: Game) {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const entries = [g.pendingTerrorEntry,
|
||||
continuation?.kind === 'terrorDiscard' ? continuation.entry : null];
|
||||
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics')
|
||||
? continuation.entry : null];
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue;
|
||||
homeworldRule(() => validateTerrorEntrySignature(entry));
|
||||
@@ -12953,7 +13071,8 @@ function terrorEntryIntegrity(g: Game) {
|
||||
g.players.filter((p) => p.faction === 'moritani').length === 1,
|
||||
'The saved Terror entry has lost its original turn or seated participants.');
|
||||
if (entry.candidates) {
|
||||
const committedDiscard = continuation?.kind === 'terrorDiscard' && entry === continuation.entry;
|
||||
const committedDiscard = (continuation?.kind === 'terrorDiscard' ||
|
||||
continuation?.kind === 'terrorAtomics') && entry === continuation.entry;
|
||||
const reserved = entry.candidates.filter((id) => id !== entry.token ||
|
||||
(!committedDiscard && ['select', 'offer', 'allianceResponse', 'allianceReply'].includes(entry.stage)));
|
||||
requireRule(reserved.every((id) => g.moritaniTerror?.tokens.filter((token) =>
|
||||
@@ -13097,6 +13216,13 @@ function terrorRevealBlocked(
|
||||
): string | null {
|
||||
const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount);
|
||||
if (homeworldBlock) return homeworldBlock;
|
||||
if (kind === 'atomics') {
|
||||
try { currentAtomicsQuote(g, entry); return null; }
|
||||
catch (error) {
|
||||
if (error instanceof RuleError) return error.message;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack' || kind === 'extortion')
|
||||
return null;
|
||||
if (kind !== 'assassination')
|
||||
@@ -13169,6 +13295,8 @@ 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);
|
||||
@@ -13185,6 +13313,8 @@ 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;
|
||||
@@ -13300,7 +13430,8 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
action.reveal === true,
|
||||
'Reveal the Terror token or leave it hidden.',
|
||||
);
|
||||
const blocked = terrorRevealBlocked(g, entry, token.kind);
|
||||
const atomics = token.kind === 'atomics' ? currentAtomicsQuote(g, entry) : null;
|
||||
const blocked = atomics ? null : terrorRevealBlocked(g, entry, token.kind);
|
||||
requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.');
|
||||
g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id);
|
||||
if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id];
|
||||
@@ -13334,6 +13465,8 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
}, extortionContext(g))).state;
|
||||
log(g, 'Five bank spice is reserved for Moritani until Mentat Pause; it is not yet spendable.');
|
||||
finishTerrorEntry(g);
|
||||
} else if (atomics) {
|
||||
resolveAtomics(g, entry, atomics);
|
||||
} else {
|
||||
const victimCard = shuffle(entrant.hand)[0];
|
||||
if (victimCard) {
|
||||
@@ -15738,6 +15871,26 @@ function gholaOptions(g: Game, p: Player) {
|
||||
reason,
|
||||
};
|
||||
}
|
||||
function atomicsIntegrity(g: Game) {
|
||||
const aftermath = g.moritaniAtomics;
|
||||
if (!aftermath) {
|
||||
requireRule(g.players.every(player => player.atomicsHandLimitPenalty === undefined),
|
||||
'Atomics hand-limit penalties require the physical Aftermath.');
|
||||
return;
|
||||
}
|
||||
const owner = byFaction(g, 'moritani');
|
||||
const token = g.moritaniTerror?.tokens.find(row => row.kind === 'atomics');
|
||||
requireRule(atomicsModeSupported(g) && owner?.id === aftermath.moritaniId &&
|
||||
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' &&
|
||||
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.');
|
||||
}
|
||||
function marketGholaIntegrity(g: Game) {
|
||||
battleOrderIntegrity(g);
|
||||
saphoAggressorIntegrity(g);
|
||||
@@ -15766,6 +15919,7 @@ function marketGholaIntegrity(g: Game) {
|
||||
grummanCollectionIntegrity(g);
|
||||
homeworldVictoryReturnIntegrity(g);
|
||||
homeworldRevivalReturnIntegrity(g);
|
||||
atomicsIntegrity(g);
|
||||
terrorEntryIntegrity(g);
|
||||
homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection));
|
||||
currentFactionPayment(g);
|
||||
@@ -20465,6 +20619,8 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
|
||||
return true;
|
||||
}
|
||||
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
||||
requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory),
|
||||
'Atomics Aftermath permanently blocks shipments into this territory.');
|
||||
if (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
|
||||
(g.leaderSkills && shipment.noField)) validateLeaderSkillNoFieldShipment(g, shipment);
|
||||
if (shipment.smuggler) requireRule(
|
||||
@@ -21674,7 +21830,9 @@ function nexusMoritaniIntegrity(g: Game) {
|
||||
'The Terror token has no valid original placement for this territory.');
|
||||
}
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
for (const entry of [g.pendingTerrorEntry,continuation?.kind === 'terrorDiscard' ? continuation.entry : null])
|
||||
for (const entry of [g.pendingTerrorEntry,
|
||||
continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics'
|
||||
? continuation.entry : null])
|
||||
if (entry) requireRule(terrorEntryLocationAllowed(g,entry),
|
||||
'The Terror arrival has lost its original Cunning placement permission.');
|
||||
}
|
||||
@@ -25262,6 +25420,9 @@ 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;
|
||||
@@ -25285,6 +25446,11 @@ 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) {
|
||||
@@ -26963,6 +27129,11 @@ export function viewGame(state: Game, id: string) {
|
||||
moritaniTerror: g.moritaniTerror
|
||||
? projectTerror(g.moritaniTerror, me.faction === 'moritani')
|
||||
: null,
|
||||
moritaniAtomics: g.moritaniAtomics
|
||||
? { territory: g.moritaniAtomics.territory, turn: g.moritaniAtomics.turn,
|
||||
moritaniId: g.moritaniAtomics.moritaniId,
|
||||
allyAtActivation: g.moritaniAtomics.allyAtActivation }
|
||||
: null,
|
||||
arrivalOverlapMode: arrivalOverlapModeSupported(g),
|
||||
arrivalOverlap: g.pendingArrivalOverlap
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { splitLocation, validLocation } from './board';
|
||||
import type { Player } from './engine';
|
||||
import type { TerrorToken } from './moritani-terror';
|
||||
import { TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
|
||||
/** The sole Aftermath piece and the ally affected at activation belong in saved Game state. */
|
||||
export type AtomicsAftermath = Readonly<{
|
||||
territory: string;
|
||||
turn: number;
|
||||
moritaniId: string;
|
||||
allyAtActivation: string | null;
|
||||
/** A later alliance change has no source-backed hand-limit interpretation yet. */
|
||||
alliancePolicy: 'unresolved';
|
||||
}>;
|
||||
|
||||
export type AtomicsPlayer = Pick<
|
||||
Player,
|
||||
'id' | 'faction' | 'forces' | 'elites' | 'advisors' | 'ally' | 'noField' | 'tanks'
|
||||
> & { handSize: number; baseHandLimit: number };
|
||||
|
||||
export type AtomicsCasualty = Readonly<{
|
||||
playerId: string;
|
||||
location: string;
|
||||
normal: number;
|
||||
elite: number;
|
||||
advisor: number;
|
||||
}>;
|
||||
export type AtomicsHandReduction = Readonly<{
|
||||
playerId: string;
|
||||
limit: number;
|
||||
randomDiscards: number;
|
||||
}>;
|
||||
export type AtomicsQuote = Readonly<{
|
||||
casualties: readonly AtomicsCasualty[];
|
||||
aftermath: AtomicsAftermath;
|
||||
handReductions: readonly AtomicsHandReduction[];
|
||||
}>;
|
||||
|
||||
const whole = (value: number) => Number.isSafeInteger(value) && value >= 0;
|
||||
function requireAtomics(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
/** Plan, but never apply, the single revealed token's initial effect. No card identities enter this quote. */
|
||||
export function quoteMoritaniAtomics(input: {
|
||||
token: TerrorToken;
|
||||
territory: string;
|
||||
players: readonly AtomicsPlayer[];
|
||||
moritaniId: string;
|
||||
turn: number;
|
||||
aftermath: AtomicsAftermath | null;
|
||||
}): AtomicsQuote {
|
||||
const { token, players, moritaniId, turn, territory } = input;
|
||||
requireAtomics(token.kind === 'atomics' && token.status === 'placed' &&
|
||||
token.location === territory && TERROR_STRONGHOLDS.includes(territory),
|
||||
'Atomics requires its still-placed token in the entered ordinary stronghold.');
|
||||
requireAtomics(input.aftermath === null, 'Atomics Aftermath is already on the board.');
|
||||
requireAtomics(Number.isSafeInteger(turn) && turn >= 1, 'Atomics needs a valid turn.');
|
||||
requireAtomics(players.length > 0 && new Set(players.map(p => p.id)).size === players.length &&
|
||||
players.every(p => !!p.id), 'Atomics needs distinct physical players.');
|
||||
const moritani = players.find(p => p.id === moritaniId);
|
||||
requireAtomics(moritani?.faction === 'moritani', 'Atomics requires Moritani ownership.');
|
||||
const ally = moritani.ally === null ? null : players.find(p => p.id === moritani.ally);
|
||||
requireAtomics(moritani.ally === null || (!!ally && ally.id !== moritani.id &&
|
||||
ally.ally === moritani.id), 'Atomics requires a current mutual ally.');
|
||||
|
||||
const casualties: AtomicsCasualty[] = [];
|
||||
for (const p of players) {
|
||||
requireAtomics(whole(p.tanks) && (!p.elites ||
|
||||
(whole(p.elites.tanks) && p.elites.tanks <= p.tanks)),
|
||||
'Atomics needs valid Tanks custody.');
|
||||
let total = 0, eliteTotal = 0;
|
||||
// A marker with zero counters is not a casualty. A concealed No-Field with
|
||||
// physical counters needs its own reveal/custody ruling before this effect.
|
||||
requireAtomics(p.noField?.deployed?.location.territory !== token.location,
|
||||
'Atomics with a concealed No-Field awaits its reveal and casualty rules.');
|
||||
for (const key of new Set([...Object.keys(p.forces),
|
||||
...Object.keys(p.elites?.forces ?? {})])) {
|
||||
const { territory, sector } = splitLocation(key);
|
||||
if (territory !== token.location) continue;
|
||||
const amount = p.forces[key] ?? 0;
|
||||
const elite = p.elites?.forces[key] ?? 0;
|
||||
requireAtomics(key === `${territory}:${sector}` && validLocation(territory, sector) &&
|
||||
whole(amount) && whole(elite) && elite <= amount,
|
||||
'Atomics needs valid typed forces in the stronghold.');
|
||||
total += amount;
|
||||
eliteTotal += elite;
|
||||
if (amount) casualties.push({ playerId: p.id, location: key,
|
||||
normal: amount - elite, elite,
|
||||
advisor: p.faction === 'beneGesserit' && p.advisors?.[territory]
|
||||
? amount : 0 });
|
||||
}
|
||||
requireAtomics(whole(p.tanks + total) && (!p.elites ||
|
||||
whole(p.elites.tanks + eliteTotal)), 'Atomics casualties overflow Tanks.');
|
||||
}
|
||||
|
||||
const handReductions = [moritani, ...(ally ? [ally] : [])].map(p => {
|
||||
requireAtomics(whole(p.handSize) && Number.isSafeInteger(p.baseHandLimit) &&
|
||||
p.baseHandLimit >= 1, 'Atomics requires authoritative hand counts and limits.');
|
||||
const limit = p.baseHandLimit - 1;
|
||||
return { playerId: p.id, limit, randomDiscards: Math.max(0, p.handSize - limit) };
|
||||
});
|
||||
return {
|
||||
casualties,
|
||||
aftermath: { territory: token.location, turn, moritaniId,
|
||||
allyAtActivation: ally?.id ?? null, alliancePolicy: 'unresolved' },
|
||||
handReductions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ordinary and Fremen shipments are both blocked; movement and Sneak Attack are not shipments. */
|
||||
export function atomicsShipmentBlocked(
|
||||
aftermath: AtomicsAftermath | null | undefined,
|
||||
destinationTerritory: string,
|
||||
): boolean {
|
||||
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;
|
||||
}
|
||||
+11
-11
@@ -2802,9 +2802,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Six hidden tokens threaten stronghold entrants. Moritani starts remain unavailable.',
|
||||
'Six hidden tokens threaten stronghold entrants. Bounded Atomics revelation is available in classic-plus-Moritani tables; complete Moritani starts remain unavailable.',
|
||||
steps: [
|
||||
'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack and ordinary native-leader Assassination. You may instead leave a token hidden. Arrivals with competing reactions remain unavailable while their timing is clarified; Moritani starts remain disabled.',
|
||||
'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack, Extortion, ordinary native-leader Assassination and bounded Atomics. You may instead leave a token hidden. Arrivals with unsupported competing reactions remain gated; Moritani starts remain disabled.',
|
||||
'Begin with all six Terror tokens in your hidden supply. Once during Mentat Pause, you may either place one from supply or move one already placed to another eligible stronghold. There is no spice cost.',
|
||||
'The destination must have no Terror token. A stronghold under storm is eligible, but the Hidden Mobile Stronghold and Homeworlds are excluded.',
|
||||
'When another faction ships or moves into the marked stronghold, you may reveal its token and apply the effect to that entrant. Your ally does not trigger this opportunity. Bene Gesserit advisors do.',
|
||||
@@ -2831,14 +2831,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary:
|
||||
'Random assassination, territorial destruction, deferred income, theft, sabotage and reserve entry.',
|
||||
steps: [
|
||||
'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination and Extortion 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. Atomics and complete Moritani games remain unfinished.',
|
||||
'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 Atomics Aftermath there. Its lasting shipment prohibition and hand-limit penalty are explained in the Aftermath topic.',
|
||||
'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.',
|
||||
'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.',
|
||||
'Sneak Attack: send up to five of your reserve forces into the triggered territory at no cost, respecting storm and occupancy restrictions. This particular entry is allowed even when Atomics Aftermath is present.',
|
||||
'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception does not mean the unfinished Atomics lifecycle is enabled.',
|
||||
'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception allows this reserve entry even after Atomics resolves.',
|
||||
'The random Assassination token is separate from the advanced Assassinate Leaders advantage. Neither ability supplies the other’s selection or timing rules.',
|
||||
],
|
||||
related: [
|
||||
@@ -2852,16 +2852,16 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'moritani-aftermath',
|
||||
title: 'Moritani Atomics and Aftermath',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Planned',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Destroy the territory’s forces, prohibit future shipment there, and reduce hand limits.',
|
||||
'Bounded Atomics reveals destroy the territory’s forces, prohibit future shipment there, and reduce hand limits.',
|
||||
steps: [
|
||||
'When the Atomics Terror token resolves, all forces in that territory go to the Tanks. Put the separate Atomics Aftermath marker in the territory.',
|
||||
'On a supported classic-plus-Moritani table without Guild, Ecaz or optional modules, revealing Atomics sends every faction’s forces in the triggered territory to the Tanks and places a public Atomics Aftermath marker there. Hidden tokens remain face down until revealed.',
|
||||
'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 this turn, Moritani’s hand limit and its ally’s hand limit are each reduced by one. If a hand exceeds its reduced limit, discard a random card.',
|
||||
'The lasting penalty’s treatment after a later alliance change is unresolved. This topic does not decide whether the penalty remains with an old ally or follows a new one.',
|
||||
'The Atomics Terror token and Family Atomics Treachery Card are separate components with different effects. Aftermath is not the destroyed Shield Wall.',
|
||||
'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.',
|
||||
'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'],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user