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

+7 -14
View File
@@ -49,7 +49,6 @@ import { NexusAdvisors } from './nexus-advisors';
import { NexusSardaukar } from './nexus-sardaukar';
import { TerrorBoardMarkers } from './terror-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { atomicsAllianceChangeBlocked } from '@/game/moritani-atomics';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport';
import { BiddingEnd } from './bidding-end';
@@ -3954,29 +3953,23 @@ export function GameTable({
<option value="">Choose a player</option>
{g.players
.filter((p) => p.id !== me.id)
.map((p) => {
const atomicsBlocked = atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, p.id);
return <option value={p.id} key={p.id}
disabled={!!g.homeworldAllianceBlocks?.[p.id] || atomicsBlocked}>
.map((p) => (
<option value={p.id} key={p.id}
disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
{p.name}
{g.homeworldAllianceBlocks?.[p.id] ? ` · ${g.homeworldAllianceBlocks[p.id]}` : ''}
{atomicsBlocked ? ' · Atomics alliance ruling pending' : ''}
{g.allianceOffers[p.id] === me.id ? ' · Invited you' : ''}
</option>;
})}
</option>
))}
</select>
</label>
{g.homeworldAllianceBlocks?.[target] && <p className="notice">{g.homeworldAllianceBlocks[target]}</p>}
{!!target && atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, target) &&
<p className="notice">Changing Moritani’s ally after Atomics awaits a hand-limit ruling.</p>}
{actionButton('Propose / accept alliance', {
type: 'alliance',
target,
}, !target || !!g.homeworldAllianceBlocks?.[target] ||
atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, target))}
}, !target || !!g.homeworldAllianceBlocks?.[target])}
{me.ally &&
actionButton('Break alliance', { type: 'alliance' },
atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, null))}
actionButton('Break alliance', { type: 'alliance' })}
</>
)}
{g.phase === 1 && !g.nexus && !g.spiceWindow && (
+5 -1
View File
@@ -264,6 +264,8 @@ export function MoritaniTerrorSupply({ game }: { game: GameView }) {
<p className="mt-1 mb-0">
A lasting hand-limit reduction of one applies to {affectedNames}.
Any excess cards were discarded at random when Atomics resolved.
The provisional penalty stays on these original players if alliances
later change; a new ally does not inherit it.
</p>
</div>
)}
@@ -597,7 +599,9 @@ export function MoritaniEntry({
otherwise legal movement or Sneak Attack. Your hand limit
falls by one starting this turn; your ally’s at activation
does too, if you have one. Each hand over its new limit
discards one random card per excess card.
discards one random card per excess card. Under the provisional
persistent-target interpretation, later alliance changes do not
move this penalty to a new ally.
</p>
)}
{entry.kind === 'sneakAttack' && sneak && (
+18
View File
@@ -29,6 +29,24 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Moritani Atomics after alliance change — 14 October.**
The recommended interpretation keeps the permanent hand-limit penalty on
Moritani and its original ally at detonation. Later Nexus and Enemy of My Enemy
alliances are legal without transferring it to an uninvolved new ally; an
unallied detonation never gains an ally target later. Original stored targets,
casualties and random discards remain authoritative. The choice is visibly
provisional, not publisher clarification.
Genuine Basic/Advanced setup, placement, worms, pact, paid entry and detonation
lead to a later Nexus or Robbery-based Enemy of My Enemy. A new
Moritani/Emperor pact preserves limits3/3/4 for Moritani/former Atreides/Emperor
and Tanks6/0/1. Legacy active policy adoption preserves input and effects.
Final six-file union112/112, types/lint/build pass; actual390px break/offer/
accept controls and the persistent-target notice are verified with no overflow
or browser errors. Four legal policies can accept the pact. Full Moritani,
other Atomics module combinations and deployed acceptance remain open.
[Contract](MORITANI_TERROR_RULES.md#recommended-persistent-activation-targets--14-october-2026).
**Basic shared Orgiz settlement — 14 October.**
Both modes now wait for the original Ecaz/ally allocation before Orgiz.
`quoteSharedOrgiz` replaces the Advanced-only path: Advanced charges once per
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 14 October 2026 — Persistent original Atomics hand-limit targets
Recommended-option priority: the permanent one-card reduction stays on
Moritani and its ally at detonation. Breaks, later Nexus pacts and Enemy of My
Enemy acceptance do not transfer it to a new ally. An unallied activation has
no future ally target. This is explicit provisional interpretation, not a new
publisher ruling or separately answered numerical user question.
Removed both Nexus pre/post-quote guards, Terror offer/accept guards, bot/UI
filters and dead alliance-status/test-only hand-limit helpers. The original
Aftermath receipt and player penalties remain the authoritative custody.
Fresh policy is `activation-targets`; old active unresolved records migrate
immutably before action/view/automatic integrity without replaying casualties
or card losses. Unknown policies and forged target custody still fail closed.
Wording-pinned static-markup tests were removed rather than re-pinned.
Genuine Basic/Advanced four-seat fixtures preserve original setup, forces,
wallets, cards, token placement, worm pacts, paid arrival and detonation.
Only unplayed original Spice Card order is staged. Later real Nexus and
Robbery-triggered Enemy of My Enemy both produce Moritani/Emperor alliances
while limits remain Moritani3/former Atreides3/new Emperor4 and original
Moritani6/Emperor1 Tanks losses remain. Unallied activation, exact legacy
target adoption, corruption rejection and four legal acceptance policies pass.
Final six-file union112/112, types/lint/build pass. Actual390×844 table
break/offer/accept controls form the later pact; former Atreides still has
capacity3, Emperor4, and the public notice states the provisional permanent
targets. No horizontal overflow or browser errors. Owned host/tab/scaffold
removed; proof outside Git. Existing build warnings remain. Bounded
classic-plus-Moritani mode, other Atomics compositions, complete modes,
assurance and deployed acceptance remain open.
## 14 October 2026 — Native Basic shared Ecaz Orgiz settlement
BEFORE: a genuine Basic equal split paid the real lot but withheld Orgiz:
+35 -1
View File
@@ -78,7 +78,7 @@ These are software integration recommendations, not newly asserted publisher tim
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Terror vs. Intrusion, spiritual advisors, ambassadors, Guild income or storm casualties from the same arrival | No complete priority table retrieved. Entry creates the opportunity, but the exact ordering of simultaneous reactions remains material. Do not claim the Ecaz-only interruption FAQ settles every Terror interaction. |
| Assassination victim pool | Ordinary live owned leaders are the straightforward case. The short paragraph does not resolve captured leaders, foreign Gholas, Duke Vidal custody or unavailable/already-dead discs. Do not silently pool every printed leader identity. |
| Atomics and future allies | The lasting reduction is explicit. Whether it follows Moritani's current ally, permanently marks the ally at detonation, or both after alliance changes is not specified by the retrieved passage. Persist enough history to implement the chosen authoritative clarification. |
| Atomics and future allies | The retrieved passage does not identify future-ally treatment. Under the choose-recommended-option priority, the prototype now keeps the permanent penalty on Moritani and the ally at detonation, allowing later Nexus and Enemy of My Enemy alliances without transferring it to an uninvolved new ally. Original target history is retained. This is a provisional implementation interpretation, not publisher clarification. |
| Extortion and other Mentat events | Collection precedes recovery/payment by the printed sequence. Relative order against the turn's placement opportunity and other factions' Mentat effects is not explicit. Returning Extortion and immediately placing it again must not emerge accidentally from handler order. |
| Sabotage recipient hand overflow | The normal case frees a slot before the gift. If there was no discardable card or another exceptional capacity change, this paragraph supplies no additional forced-discard procedure. |
| One placement; unrevealed token retained on decline; no normal movement ban from Aftermath | Ordinary composition of the singular option, optional trigger and shipment-specific prohibition. These do not need invented restrictions or a hypothetical FAQ before implementation. |
@@ -136,3 +136,37 @@ Independent review repaired current-payer affordability disclosure and continuat
The printed Atomics contract above is connected for a bounded classic-plus-Moritani prototype without Guild, Ecaz or optional modules and with Moritani's alliance stable after activation. On an eligible entry the owner may leave Atomics hidden or reveal it; revealing kills every faction's forces in the territory and records public Aftermath there. The public map and Terror notice identify the territory and lasting shipment ban without identifying a still-placed hidden token's face. The owner sees the consequences before choosing to reveal. Ordinary movement and Sneak Attack are not prohibited by Aftermath, but shipments into that territory, including Fremen reinforcements, are.
From activation turn forward, Moritani and the ally **at activation** each have a hand limit reduced by one. Each discards one random card per excess card on resolution, and their projected hand limits reflect the lasting penalty. The Aftermath record includes the territory, turn, Moritani seat and original ally seat; it is not a second secret Terror face. The publisher paragraph ([E3-M, p. 5](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf#page=5)) does not resolve what happens on later alliance change, so that transition remains gated rather than assigning the penalty to a new ally by inference. This checkpoint does not enable complete Moritani games or public expansion starts; other reactions and module combinations retain their own gates. Earlier absence statements above describe their dated checkpoints, not current behavior.
## Recommended persistent activation targets — 14 October 2026
The earlier stable-alliance gate is superseded. Moritani and its ally at
activation keep their one-card hand-limit penalties after a break, a later
Nexus pact or Enemy of My Enemy acceptance. No later ally inherits a penalty;
an originally unallied detonation never creates an ally target afterward.
Original Aftermath territory/turn/seat identities and hand-limit custody stay
authoritative. The publisher ambiguity above remains; this is the conservative
recommended prototype choice, not a separately answered numerical user ruling.
Fresh receipts use `alliancePolicy: 'activation-targets'`. Old active
`'unresolved'` receipts adopt the same stored original targets immutably in
action, automatic-continuation and view entry paths, without replaying
casualties or random discards. The unused alliance-status, alliance-block and
test-only hand-limit helpers were removed. Existing Homeworld alliance
restrictions, Karama timing and the bounded Atomics mode gate remain intact.
Genuine Basic/Advanced four-seat fixtures use original faction setup, Moritani
placement, real worms, a reciprocal pact, paid Emperor entry and detonation,
then a later real Nexus. Only original unplayed Spice Card order is staged.
The original Moritani6/Emperor1 casualties stay in Tanks; after a new
Moritani/Emperor pact, hand limits remain Moritani3, former Atreides3, Emperor4.
An unallied activation leaves the later ally at4. A real later Robbery arrival
also forms Enemy of My Enemy and returns the hidden token without moving the
penalty. Migration, corrupted target custody and all four legal acceptance
policies are exercised.
Final six-file union112/112, types/lint and app build pass. Actual390×844 table
break/offer/accept controls form the later pact while preserving the original
Aftermath and public provisional notice. Former Atreides still shows hand
capacity3; new Emperor remains4. No overflow or browser errors. Owned hosts,
tabs and scaffolding were removed; proof stays outside Git. Full Moritani,
other Atomics module combinations and deployed acceptance remain open.
+1 -1
View File
@@ -547,7 +547,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and shipment-classified entry effects remain guarded. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents |
| Moritani Atomics after alliance change | The publisher's persistent −1 hand-limit text names Moritani and “your ally,” but not whether the penalty follows a later ally, remains on the original ally or affects both. Requested online source searches found no clarification; the alliance-change case remains gated. | [Terror source boundary](MORITANI_TERROR_RULES.md#material-gaps-and-ordinary-compositions) |
| Moritani Atomics after alliance change | The publisher’s permanent −1 text does not identify later-ally treatment, and prior requested searches found no clarification. Under choose-recommended-option priority, retain the original affected pair permanently: Moritani and its ally at detonation. Later Nexus and Enemy of My Enemy alliances are allowed without moving the penalty to a new ally; an unallied detonation gains no later ally target. Existing stored activation targets migrate immutably, without replaying effects. This is a conservative provisional implementation interpretation, not publisher clarification or a separately answered numerical user ruling. | [Recommended persistent targets](MORITANI_TERROR_RULES.md#recommended-persistent-activation-targets--14-october-2026) |
| Moritani Sneak Attack entry classification | The printed verb is “Send”; no retrieved ruling classifies the off-planet reserve transfer as a shipment for other arrival powers. The engine now **explicitly adopts** the supported shipment composition: a positive Sneak Attack in Shipment and Movement accrues the Heighliners token once for its owner, then offers the Bene Gesserit Intrusion (Advanced, with fighters present) and the free spiritual-advisor shipment, with the committed Terror entry resolving first; a zero-force resolution accrues nothing and opens no reaction. This order is an implementation interpretation, not a publisher ruling. | [Source boundary and adopted classification](MORITANI_ASSASSINATION_SNEAK.md#adopted-shipment-classification-7-october-2026) |
| Orgiz collection | Basic retains the publisher-p13 per-blow ambiguity and provisional one-transfer-per-positive-deposit mapping. Authorized supplied Advanced rulebook physical24 says each time Collection occurs in a territory containing spice: one transfer per actual positive territory Collection per fighter occupant, never extra transfers for multiple source keys. Canonical ordinary deposit receipts identify actual payers, including co-present collectors; force presence and old wallets alone do not qualify. Under choose-recommended-option priority, owners and eligible foreign ordinary payers resolve in original storm order. Shared Ecaz lots in both modes wait for actual bilateral allocation, then consider positive foreign recipients Ecaz first, ally second; Advanced charges once per territory, Basic counts actually reduced positive source deposits in that territory. Self, zero shares and empty purses are skipped; balances track every transfer without debt. Ordering and the Basic mapping are provisional implementations, not separate numerical user answers or publisher rulings. | [Advanced source cutover and Basic boundary](DISCOVERY_PROTOTYPE.md#authorized-advanced-collection-wording) |
| Jacurutu physical dial | Under the 14 October choose-recommendation priority, the prototype selects the defeated-player exact ordinary/elite/paid reading before settlement. Unique readings remain automatic; actual returned counters never earn income. This replaces the earlier unpaid gap without claiming a separate numerical user answer or publisher ruling. | [Recommended runtime and source](DISCOVERY_PROTOTYPE.md#recommended-physical-dial-and-cumulative-storm-choices--14-october-2026) |
+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'],
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import {
applyAction,createGame,initializeFactionExpansionsGameForAudit,joinGame,newPlayer,
type Action,type Game,
} from '../game/engine';
import {nextSpiceBankerIncomeNativeStep,type SpiceBankerIncomeNativeStep} from './fixture-spice-banker-income';
const clean=(game:Game)=>!game.phaseOpening&&!game.response&&!game.decision;
/** Genuine printed setup, Terror placement, worm alliance, paid entry and
* detonation. Only original unplayed Spice Card order is controlled. */
export function createNativeAtomicsAllianceFixture(advanced=false,allied=true,
options:{laterAllianceToken?:boolean}={}) {
let game=createGame('NATIVEATOMICS',newPlayer('m','Moritani','moritani'),advanced,['ecaz']);
for(const [id,faction] of [['a','atreides'],['e','emperor'],['f','fremen']] as const)
joinGame(game,newPlayer(id,id,faction));
for(const p of game.players)if(!p.ready)game=applyAction(game,p.id,{type:'ready'});
const initial=structuredClone(game);
game=initializeFactionExpansionsGameForAudit(game);
const actions:SpiceBankerIncomeNativeStep[]=[];
const step=(actor:string,action:Action)=>{
game=applyAction(game,actor,action);actions.push({actor,action});
};
const advance=(stop:(game:Game)=>boolean)=>{
for(let i=0;i<1000&&!stop(game);i++) {
let next:SpiceBankerIncomeNativeStep|null;
if(game.decision?.kind==='moritaniSetup')
next={actor:'m',action:{type:'decision',territory:'carthag',sector:11}};
else if(game.decision?.kind==='moritaniPlacement')
next={actor:'m',action:{type:'decision',decline:true}};
else next=nextSpiceBankerIncomeNativeStep(game);
assert.ok(next,'A native Atomics control must retain legal continuation.');
step(next.actor,next.action);
}
assert.ok(stop(game),`Native Atomics did not reach its control: ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
};
const front=(position:number,predicate:(card:Game['spiceDeck'][number])=>boolean)=>{
const index=game.spiceDeck.findIndex((card,i)=>i>=position&&predicate(card));
assert.ok(index>=position,'The original unplayed card must remain in its deck.');
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
};
advance(g=>g.status==='playing');
assert.equal(game.players.find(p=>p.id==='m')!.forces['carthag:11'],6);
const ordinaryBlows=advanced?2:1;
for(let i=0;i<ordinaryBlows;i++)front(i,c=>'territory' in c&&!c.discovery&&c.territory!=='sietch_tabr');
front(ordinaryBlows,c=>'worm' in c&&!c.greatMaker);
// Keep the two following original blows ordinary, and reserve a second real
// worm for the later Nexus rather than relying on a random chain of worms.
for(let i=ordinaryBlows+1;i<=ordinaryBlows+2;i++)
front(i,c=>'territory' in c&&!c.discovery&&c.territory!=='sietch_tabr');
front(ordinaryBlows+3,c=>'worm' in c&&!c.greatMaker);
advance(g=>g.turn===1&&g.decision?.kind==='moritaniPlacement');
const token=game.moritaniTerror!.tokens.find(t=>t.kind==='atomics'&&t.status==='available')!;
assert.ok(token);
step('m',{type:'decision',token:token.id,territory:'carthag'});
advance(g=>g.turn===2&&g.phase===1&&g.nexus&&clean(g)&&!g.spiceWindow&&!g.spiceResolution);
if(allied) {
step('m',{type:'alliance',target:'a'});
step('a',{type:'alliance',target:'m'});
}
advance(g=>g.turn===2&&g.phase===5&&g.active==='e'&&clean(g));
step('e',{type:'ship',territory:'carthag',sector:11,amount:1,elite:0,allyPayment:0});
assert.equal(game.decision?.kind,'moritaniTerror');
step('m',{type:'decision',reveal:true});
let laterToken:string|null=null;
if(options.laterAllianceToken) {
advance(g=>g.turn===2&&g.decision?.kind==='moritaniPlacement');
const robbery=game.moritaniTerror!.tokens.find(t=>t.kind==='robbery'&&t.status==='available')!;
assert.ok(robbery);
laterToken=robbery.id;
step('m',{type:'decision',token:robbery.id,territory:'arrakeen'});
}
advance(g=>g.turn===2&&g.phase===8&&clean(g));
front(0,c=>'worm' in c&&!c.greatMaker);
advance(g=>g.turn===3&&g.phase===1&&g.nexus&&clean(g)&&!g.spiceWindow&&!g.spiceResolution);
assert.equal(game.moritaniAtomics!.allyAtActivation,allied?'a':null);
assert.equal(game.players.find(p=>p.id==='m')!.tanks,6);
return {initial,game,actions,token:token.id,laterToken,allied,advanced};
}
export function advanceNativeAtomicsFixture(initial:Game,stop:(game:Game)=>boolean) {
let game=initial;
for(let i=0;i<1000&&!stop(game);i++) {
const next=nextSpiceBankerIncomeNativeStep(game);
assert.ok(next,'The original post-Atomics phase must have a legal continuation.');
game=applyAction(game,next.actor,next.action);
}
assert.ok(stop(game));
return game;
}
-71
View File
@@ -1,71 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MoritaniEntry, MoritaniTerrorSupply } from '../components/moritani-terror';
import { TerrorBoardMarkers } from '../components/terror-board-markers';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { createTerrorState, placeTerror, revealTerror } from '../game/moritani-terror';
function fixture() {
const game = createGame('ATOMICS-CONTROLS', newPlayer('m', 'Moritani', 'moritani'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
game.players.find((player) => player.id === 'm')!.ally = 'a';
game.players.find((player) => player.id === 'a')!.ally = 'm';
game.moritaniTerror = createTerrorState(() => 0);
const token = game.moritaniTerror.tokens.find((item) => item.kind === 'atomics')!;
game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, 'arrakeen', 1);
return { game, token };
}
const supply = (view: GameView) =>
renderToStaticMarkup(createElement(MoritaniTerrorSupply, { game: view }));
const markers = (view: GameView) =>
renderToStaticMarkup(createElement('svg', null, createElement(TerrorBoardMarkers, {
tokens: view.moritaniTerror?.tokens ?? [], atomics: view.moritaniAtomics,
})));
void test('a placed hidden face stays private; only owner sees the Atomics reveal consequences', () => {
const { game } = fixture();
const owner = viewGame(game, 'm');
owner.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'f', territory: 'arrakeen' };
owner.terrorEntry = {
entrant: 'f', territory: 'arrakeen', sector: 10, cause: 'shipment', stage: 'offer',
kind: 'atomics', canReveal: true, canOfferAlliance: false,
};
const offer = renderToStaticMarkup(createElement(MoritaniEntry, { game: owner, busy: false, act() {} }));
assert.match(offer, /Reveal Atomics/);
assert.match(offer, /every faction’s forces/);
assert.match(offer, /including Fremen reinforcements/);
assert.match(offer, /discards one random card per excess/);
const rival = viewGame(game, 'f');
assert.match(supply(rival), /Inspect Hidden Terror/);
assert.doesNotMatch(supply(rival), /Inspect Atomics|Atomics Aftermath/);
assert.match(markers(rival), /hidden Terror token in Arrakeen/);
assert.doesNotMatch(markers(rival), /Atomics Aftermath/);
assert.equal(renderToStaticMarkup(createElement(MoritaniEntry, { game: rival, busy: false, act() {} })), '');
});
void test('after revelation all seats see persistent Aftermath and its exact shipment and hand-limit consequences', () => {
const { game, token } = fixture();
game.moritaniTerror = revealTerror(game.moritaniTerror!, token.id);
game.moritaniAtomics = { territory: 'arrakeen', turn: 2, moritaniId: 'm',
allyAtActivation: 'a', alliancePolicy: 'unresolved' };
game.turn = 2;
game.players.find((player) => player.id === 'm')!.atomicsHandLimitPenalty = true;
game.players.find((player) => player.id === 'a')!.atomicsHandLimitPenalty = true;
for (const id of ['m', 'a', 'f']) {
const view = viewGame(game, id);
const notice = supply(view);
assert.match(notice, /Atomics Aftermath · Arrakeen/);
assert.match(notice, /From turn 2, no faction may ship forces/);
assert.match(notice, /including Fremen reinforcements/);
assert.match(notice, /ordinary movement and Sneak Attack remain possible/);
assert.match(notice, /Moritani and Atreides/);
assert.match(notice, /lasting hand-limit reduction of one/);
const board = markers(view);
assert.match(board, /Atomics Aftermath in Arrakeen: permanent shipment ban/);
assert.doesNotMatch(board, /hidden Terror token in Arrakeen/);
}
});
+26 -15
View File
@@ -154,7 +154,7 @@ void test('Sneak Attack remains a free reserve entry into an Atomics Aftermath s
assert.equal(done.moritaniAtomics?.territory, 'arrakeen');
});
void test('later Moritani alliance change and forged hand penalties reject atomically', () => {
void test('later Moritani alliances preserve the original penalties while forged targets still reject atomically', () => {
const { g } = fixture(false, true);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
@@ -162,7 +162,17 @@ void test('later Moritani alliance change and forged hand penalties reject atomi
state.nexus = true;
state.spiceWindow = null;
state.spiceResolution = null;
rejected(state, 'm', { type: 'alliance', target: null }, /Atomics/);
const broken = applyAction(state, 'm', { type: 'alliance', target: null });
assert.equal(seat(broken, 'm').ally, null);
assert.equal(seat(broken, 'a').ally, null);
const offered = applyAction(broken, 'm', { type: 'alliance', target: 'e' });
const accepted = applyAction(offered, 'e', { type: 'alliance', target: 'm' });
assert.equal(seat(accepted, 'm').ally, 'e');
assert.equal(seat(accepted, 'e').ally, 'm');
assert.equal(seat(accepted, 'a').ally, null);
assert.deepEqual(accepted.moritaniAtomics, state.moritaniAtomics);
for (const [id, limit] of [['m',3],['a',3],['e',4]] as const)
assert.equal(viewGame(accepted,id).players.find(p=>p.id===id)!.handLimit,limit);
for (const mutate of [
(game: Game) => { delete seat(game, 'a').atomicsHandLimitPenalty; },
(game: Game) => { game.moritaniAtomics = { ...game.moritaniAtomics!, allyAtActivation: 'e' }; },
@@ -174,7 +184,7 @@ void test('later Moritani alliance change and forged hand penalties reject atomi
}
});
void test('a later Nexus filters impossible Moritani offers before bots can stall', () => {
void test('a later Nexus lets all four legal policies accept Moritani without moving its permanent penalty', () => {
const { g } = fixture(false, false);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
@@ -183,16 +193,17 @@ void test('a later Nexus filters impossible Moritani offers before bots can stal
state.spiceWindow = null;
state.spiceResolution = null;
state.ready = [];
state.allianceOffers = { m: 'e' };
for (const p of state.players) state.deck.push(...p.hand.splice(0));
seat(state, 'm').bot = 'Medium';
seat(state, 'e').bot = 'Medium';
rejected(state, 'e', { type: 'alliance', target: 'm' }, /Atomics/);
rejected(state, 'm', { type: 'alliance', target: 'e' }, /Atomics/);
assert.deepEqual(botActions(viewGame(state, 'm'))[0], { type: 'ready' });
assert.equal(botActions(viewGame(state, 'e')).some(action =>
action.type === 'alliance' && action.target === 'm'), false);
const withdrawn = applyAction(state, 'm', { type: 'alliance' });
assert.equal(withdrawn.allianceOffers.m, undefined);
assert.equal(withdrawn.players.find(p => p.id === 'm')!.ally, null);
const offered = applyAction(state, 'm', { type: 'alliance', target: 'e' });
for (const level of ['Easy','Medium','Hard','Brutal'] as const) {
const view = viewGame(offered, 'e');
view.players.find(player => player.id === 'e')!.bot = level;
const action = botActions(view)[0];
assert.ok(action);
const accepted = applyAction(offered,'e',action);
assert.equal(seat(accepted,'e').ally,'m');
assert.equal(seat(accepted,'m').ally,'e');
assert.equal(viewGame(accepted,'e').players.find(p=>p.id==='e')!.handLimit,4);
assert.equal(viewGame(accepted,'m').players.find(p=>p.id==='m')!.handLimit,3);
assert.equal(viewGame(accepted,'a').players.find(p=>p.id==='a')!.handLimit,4);
}
});
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction,normalizeAutomaticGame,viewGame,type Game} from '../game/engine';
import {createNativeAtomicsAllianceFixture,advanceNativeAtomicsFixture} from './fixture-moritani-atomics-native';
const limits=(game:Game)=>Object.fromEntries(game.players.map(p=>[p.id,
viewGame(game,p.id).players.find(seat=>seat.id===p.id)!.handLimit]));
for(const advanced of [false,true])for(const allied of [false,true]) {
void test(`${advanced?'Advanced':'Basic'} native Atomics ${allied?'original ally':'unallied'} targets persist after a real later worm Nexus`,()=>{
const f=createNativeAtomicsAllianceFixture(advanced,allied);
const original=structuredClone(f.game);
let game=f.game;
if(allied)game=applyAction(game,'m',{type:'alliance',target:null});
assert.deepEqual(limits(game),{m:3,a:allied?3:4,e:4,f:4});
const offered=applyAction(game,'m',{type:'alliance',target:'e'});
game=applyAction(offered,'e',{type:'alliance',target:'m'});
assert.deepEqual(game.players.map(p=>[p.id,p.ally]),[['m','e'],['a',null],['e','m'],['f',null]]);
assert.deepEqual(limits(game),{m:3,a:allied?3:4,e:4,f:4});
assert.deepEqual(game.moritaniAtomics,original.moritaniAtomics);
assert.deepEqual(game.players.map(p=>p.tanks),[6,0,1,0]);
assert.deepEqual(f.game,original);
});
}
void test('old unresolved Atomics receipts adopt exact original targets without mutating the saved input or replaying effects',()=>{
const f=createNativeAtomicsAllianceFixture(false,true);
const legacy=structuredClone(f.game);
Reflect.set(legacy.moritaniAtomics!,'alliancePolicy','unresolved');
const before=structuredClone(legacy);
const normalized=normalizeAutomaticGame(legacy);
assert.equal(normalized.moritaniAtomics!.alliancePolicy,'activation-targets');
assert.deepEqual(normalized.players,before.players);
assert.deepEqual(normalized.moritaniTerror,before.moritaniTerror);
assert.deepEqual(normalized.discard,before.discard);
const broken=applyAction(legacy,'m',{type:'alliance',target:null});
const offered=applyAction(broken,'m',{type:'alliance',target:'e'});
assert.equal(offered.moritaniAtomics!.alliancePolicy,'activation-targets');
assert.deepEqual(limits(offered),{m:3,a:3,e:4,f:4});
assert.deepEqual(legacy,before);
});
void test('native Enemy of My Enemy can replace the post-Atomics alliance without penalizing its new ally',()=>{
const f=createNativeAtomicsAllianceFixture(false,true,{laterAllianceToken:true});
let game=advanceNativeAtomicsFixture(f.game,g=>g.turn===3&&g.phase===5&&
g.active==='e'&&!g.phaseOpening&&!g.response&&!g.decision);
game=applyAction(game,'e',{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0});
game=applyAction(game,'m',{type:'decision',alliance:true});
game=advanceNativeAtomicsFixture(game,g=>g.pendingTerrorEntry?.stage==='allianceReply'&&!g.response);
game=applyAction(game,'e',{type:'decision',accept:true});
assert.deepEqual(limits(game),{m:3,a:3,e:4,f:4});
assert.equal(game.players.find(p=>p.id==='m')!.ally,'e');
assert.equal(game.players.find(p=>p.id==='a')!.ally,null);
assert.equal(game.players.find(p=>p.id==='e')!.ally,'m');
assert.deepEqual(game.moritaniAtomics,f.game.moritaniAtomics);
assert.equal(game.pendingTerrorEntry,null);
assert.equal(game.moritaniTerror!.tokens.find(token=>token.kind==='robbery')!.status,'available');
assert.equal(game.phase,5);
});
+1 -16
View File
@@ -1,8 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
atomicsAllianceStatus,
atomicsEffectiveHandLimit,
atomicsShipmentBlocked,
quoteMoritaniAtomics,
type AtomicsPlayer,
@@ -46,14 +44,8 @@ void test('no ally at activation: all owners lose typed forces in the territory,
assert.deepEqual(plan.handReductions,
[{ playerId: 'm', limit: 3, randomDiscards: 1 }]);
assert.deepEqual(plan.aftermath, { territory: 'arrakeen', turn: 3,
moritaniId: 'm', allyAtActivation: null, alliancePolicy: 'unresolved' });
moritaniId: 'm', allyAtActivation: null, alliancePolicy: 'activation-targets' });
assert.deepEqual(players, before);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'ix', null, 4), 4);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'm', null, 4), 3);
assert.equal(atomicsAllianceStatus(plan.aftermath, null), 'activation-alliance');
assert.equal(atomicsAllianceStatus(plan.aftermath, 'newAlly'), 'clarification-required');
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'm', 'newAlly', 4),
/alliance change/);
});
void test('activation ally is reduced with Moritani; counts exceed-one overflow without selecting cards', () => {
@@ -68,13 +60,6 @@ void test('activation ally is reduced with Moritani; counts exceed-one overflow
{ playerId: 'm', limit: 3, randomDiscards: 3 },
{ playerId: 'choam', limit: 4, randomDiscards: 2 },
]);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'choam', 'choam', 5), 4);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'other', 'choam', 8), 8);
for (const next of [null, 'other']) {
assert.equal(atomicsAllianceStatus(plan.aftermath, next), 'clarification-required');
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'other', next, 8),
/alliance change/);
}
});
void test('Aftermath blocks shipment by territory regardless of faction or sector, not other territories', () => {