Files
Dune/tests/moritani-atomics-modules.test.ts
Quintine bb9d292c4c Compose native Ecaz Betrayal and Moritani Atomics
Retain original selected modules, typed native returns, advisors and hidden No-Field recall. Preserve a full unspent Guild/Sapho opening, paid ordered arrivals, capped provisional No-Field destruction and native World Sneak withdrawal. Bind desert Aftermath to its original Moritani Cunning placement.

Validation: frozen 16-file115/115, types/lint/build, eight finite original-action and GameTable SSR programmes, four minimal legal policies. Document provisional overlaps and corrected genuine recipes. No stored-game change or deployment; full combinations, comprehensive assurance and deployed acceptance remain required.
2026-10-18 11:21:28 +11:00

181 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { richeseCards } from '../game/richese-cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import {
actAtomicsModules, atomicsModulesPlayer as player, createAtomicsAftermathSneakContinuation,
createNoFieldAtomicsModulesFixture, createPairedAtomicsModulesFixture,
finishAtomicsModulesReveal, openLaterAtomicsModulesNexus,
} from './fixture-moritani-atomics-modules';
function stocks(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(), [...treacheryDeck(game.expansions), ...ecazTreacheryCards(),
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])].map(card => card.id).sort());
const nexus = game.nexusCards!.cards!;
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards),
...game.leaderSkills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
}
function handLimit(game: Game, actor: string): number {
return viewGame(game, actor).players.find(player => player.id === actor)!.handLimit;
}
function reducedExactlyOnce(before: Game, after: Game, targets: readonly string[]): void {
for (const target of targets) {
const original = player(before, target).hand.map(card => card.id);
assert.equal(original.length, 4, 'The paid bank bids filled the original hand');
assert.equal(player(after, target).hand.length, 3);
assert.equal(handLimit(after, target), 3);
assert.equal(player(after, target).atomicsHandLimitPenalty, true);
assert.equal(after.discard.filter(card => original.includes(card.id)).length -
before.discard.filter(card => original.includes(card.id)).length, 1);
}
for (const other of before.players.filter(player => !targets.includes(player.id)))
assert.deepEqual(player(after, other.id).hand, other.hand);
}
for (const modules of [false, true]) void test(`${modules ? 'Advanced selected all14/World/phase' : 'Basic selected'} actual Richese marker Atomics materializes capped native reserves before casualties and retains the original Guild invoice`, () => {
const fixture = createNoFieldAtomicsModulesFixture({ advanced: modules, skills: modules, homeworlds: modules,
tech: modules, strongholds: modules, expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'choam'] });
assert.equal(fixture.purchases.filter(bid => bid.step.actor === 'moritani').length, 3);
assert.equal(fixture.purchases.filter(bid => bid.step.actor === 'guild').length, 3);
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.richese, 'tleilaxu');
assert.equal(player(fixture.game, 'richese').noField!.deployed!.tokenId, fixture.noField);
assert.equal(player(fixture.game, 'richese').forces['carthag:11'] ?? 0, 0);
assert.equal(player(fixture.game, 'richese').reserves, 20);
assert.equal(player(fixture.game, 'richese').spice, player(fixture.beforeEntry, 'richese').spice - 1);
assert.equal(player(fixture.game, 'guild').spice, player(fixture.beforeEntry, 'guild').spice + 1);
const invoice = fixture.game.nexusGuildBetrayalHistory!.at(-1)!;
assert.equal(invoice.invoice.shipper, 'richese');
assert.equal(invoice.invoice.price, 1);
assert.equal(invoice.invoice.originalReceiver, 'guild');
assert.ok(fixture.actions.some(step => step.actor === 'richese' && step.action.type === 'guildBetrayalPass' && step.action.event === invoice.invoice.event));
const ownForces = structuredClone(player(fixture.game, 'moritani').forces);
const receiptCount = fixture.game.nexusGuildBetrayalHistory!.length;
const finished = finishAtomicsModulesReveal(fixture.game, fixture.actions), game = finished.game;
assert.deepEqual(finished.seen, ['atomics']);
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.equal(player(game, 'richese').noField!.lastShipped, fixture.noField);
assert.equal(player(game, 'richese').reserves, 17);
assert.equal(player(game, 'richese').tanks, player(fixture.game, 'richese').tanks + 3);
assert.equal(player(game, 'richese').forces['carthag:11'] ?? 0, 0);
assert.deepEqual(player(game, 'moritani').forces, ownForces, 'Atomics affects its actual territory, not the separate original Moritani start');
assert.deepEqual(game.moritaniAtomics, { territory: 'carthag', turn: finished.beforeActivation.turn, moritaniId: 'moritani',
allyAtActivation: 'guild', alliancePolicy: 'activation-targets' });
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === fixture.atomics)!.status, 'removed');
reducedExactlyOnce(finished.beforeActivation, game, ['moritani', 'guild']);
assert.equal(game.nexusGuildBetrayalHistory!.length, receiptCount);
assert.equal(player(game, 'richese').spice, player(fixture.game, 'richese').spice);
assert.equal(player(game, 'guild').spice, player(fixture.game, 'guild').spice);
assert.equal(player(game, 'richese').shipped, true);
assert.equal(game.active, 'richese');
assert.equal(game.nexusCards!.cards!.hands.richese, 'tleilaxu');
if (modules) {
const world = viewGame(game, 'richese').homeworlds!.worlds!.find(world => world.id === 'homeworld:richese'); assert.ok(world);
assert.deepEqual(world.forces.richese, { normal: 17, elite: 0 });
}
assert.throws(() => applyAction(game, 'moritani', { type: 'decision', reveal: true }));
stocks(game);
});
void test('paired native Ecaz Atomics consumes its original arrival queue once and destroys ordinary counters, Sardaukar and actual spiritual advisors', () => {
const fixture = createPairedAtomicsModulesFixture({ skills: true, homeworlds: true, tech: true, strongholds: true });
assert.ok(fixture.firstShipment);
assert.equal(fixture.firstShipment.step.actor, 'guild');
assert.ok(fixture.actions.some(step => step.actor === 'beneGesserit' && step.action.type === 'decision' && step.action.accept === true));
const queue = structuredClone(fixture.game.pendingArrivalOverlap!); assert.ok(queue);
assert.equal(queue.entrant, 'emperor');
assert.equal(queue.territory, 'carthag');
assert.equal(player(fixture.game, 'emperor').forces['carthag:11'], 3);
assert.equal(player(fixture.game, 'emperor').elites!.forces['carthag:11'], 2);
assert.equal(player(fixture.game, 'guild').forces['carthag:11'], 1);
const advisors = player(fixture.game, 'beneGesserit').forces['carthag:11']; assert.ok(advisors >= 1);
assert.ok(player(fixture.game, 'beneGesserit').advisors?.carthag);
const invoice = fixture.game.nexusGuildBetrayalHistory!.at(-1)!;
assert.equal(invoice.invoice.shipper, 'emperor');
assert.equal(invoice.invoice.price, 3);
assert.ok(fixture.actions.some(step => step.actor === 'emperor' && step.action.type === 'guildBetrayalPass' && step.action.event === invoice.invoice.event));
const finished = finishAtomicsModulesReveal(fixture.game, fixture.actions), game = finished.game;
assert.equal(finished.seen[0], queue.first === 'terror' ? 'atomics' : 'ambassador');
assert.equal(finished.seen.filter(entry => entry === 'atomics').length, 1);
assert.ok(finished.seen.filter(entry => entry === 'ambassador').length <= 1);
if (queue.first === 'ambassador') assert.deepEqual(finished.seen, ['ambassador', 'atomics']);
assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
assert.equal(player(game, 'emperor').tanks, player(fixture.game, 'emperor').tanks + 3);
assert.equal(player(game, 'emperor').elites!.tanks, player(fixture.game, 'emperor').elites!.tanks + 2);
assert.equal(player(game, 'guild').tanks, player(fixture.game, 'guild').tanks + 1);
assert.equal(player(game, 'beneGesserit').tanks, player(fixture.game, 'beneGesserit').tanks + advisors);
for (const actor of ['emperor', 'guild', 'beneGesserit']) assert.equal(player(game, actor).forces['carthag:11'] ?? 0, 0);
assert.equal(player(game, 'beneGesserit').advisors?.carthag, undefined);
assert.equal(game.moritaniAtomics!.allyAtActivation, 'ecaz');
reducedExactlyOnce(finished.beforeActivation, game, ['moritani', 'ecaz']);
assert.equal(handLimit(game, 'guild'), 4);
assert.equal(game.nexusGuildBetrayalHistory!.length, fixture.game.nexusGuildBetrayalHistory!.length);
assert.equal(player(game, 'emperor').spice, player(fixture.game, 'emperor').spice);
assert.equal(player(game, 'guild').spice, player(fixture.game, 'guild').spice);
stocks(game);
});
void test('module Atomics keeps its original hand targets after an actual later worm changes Moritani’s alliance', () => {
const fixture = createNoFieldAtomicsModulesFixture();
const detonation = finishAtomicsModulesReveal(fixture.game).game;
const aftermath = structuredClone(detonation.moritaniAtomics), discard = detonation.discard.map(card => card.id);
let game = openLaterAtomicsModulesNexus(detonation);
game = actAtomicsModules(game, 'moritani', { type: 'alliance', target: null });
game = actAtomicsModules(game, 'moritani', { type: 'alliance', target: 'emperor' });
game = actAtomicsModules(game, 'emperor', { type: 'alliance', target: 'moritani' });
assert.equal(player(game, 'guild').ally, null);
assert.equal(player(game, 'moritani').ally, 'emperor');
assert.equal(handLimit(game, 'moritani'), 3);
assert.equal(handLimit(game, 'guild'), 3);
assert.equal(handLimit(game, 'emperor'), 4);
assert.deepEqual(game.moritaniAtomics, aftermath);
assert.deepEqual(game.discard.map(card => card.id), discard);
assert.equal(player(game, 'richese').tanks, player(detonation, 'richese').tanks);
});
for (const modules of [false, true]) void test(`${modules ? 'Advanced all14/World/phase' : 'Basic'} Aftermath rejects a genuine new shipment while original adjacent movement and the later placed Sneak Attack remain available`, () => {
const fixture = createNoFieldAtomicsModulesFixture({ advanced: modules, skills: modules, homeworlds: modules,
tech: modules, strongholds: modules, expansions: modules ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'choam'] });
const detonation = finishAtomicsModulesReveal(fixture.game).game;
const continued = createAtomicsAftermathSneakContinuation(detonation);
assert.equal(player(continued.beforeShipment, 'emperor').shipped, false);
assert.throws(() => applyAction(continued.beforeShipment, 'emperor', continued.blockedShipment), /Atomics Aftermath/);
assert.equal(player(continued.beforeMove, 'emperor').forces['imperial_basin:11'], 1);
assert.equal(continued.movement.action.type, 'move');
assert.equal(continued.beforeSneak.pendingTerrorEntry!.stage, 'sneakAttack');
assert.equal(player(continued.game, 'emperor').forces['carthag:11'], 1);
assert.equal(player(continued.game, 'moritani').forces['carthag:11'], 1);
assert.equal(player(continued.game, 'moritani').reserves, player(continued.beforeSneak, 'moritani').reserves - 1);
assert.equal(continued.game.moritaniTerror!.tokens.find(token => token.id === continued.token)!.status, 'removed');
assert.deepEqual(continued.game.moritaniAtomics, detonation.moritaniAtomics);
assert.equal(player(continued.game, 'richese').tanks, player(detonation, 'richese').tanks);
assert.ok(!continued.game.pendingTerrorEntry);
if (modules) {
const world = (state: Game) => {
const home = viewGame(state, 'moritani').homeworlds!.worlds!.find(world => world.id === 'homeworld:moritani'); assert.ok(home);
return home.forces.moritani;
};
assert.deepEqual(world(continued.game), { normal: world(continued.beforeSneak).normal - 1, elite: 0 },
'Sneak Attack withdraws its actual ordinary counter from the original Grumman pool');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(continued.beforeSneak, 'moritani');
view.players.find(player => player.id === 'moritani')!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, `${difficulty} must expose a legal owned Sneak Attack control`);
assert.doesNotThrow(() => applyAction(continued.beforeSneak, 'moritani', action),
`${difficulty} uses the actual native World withdrawal without policy calibration`);
}
}
stocks(continued.game);
});