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.
181 lines
13 KiB
TypeScript
181 lines
13 KiB
TypeScript
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);
|
||
});
|