Retain selected decks and original optional modules through normal setup, distinguish native Ixian Betrayal from absent-faction replacement, route explicit CHOAM fuel before printed effects, and preserve native No-Field invoices and pair continuation. Connect canonical native cancellation stock and existing human/minimal AI consumers. Validation: 194 affected tests across 19 files, types/lint/build, bounded native invoice review and seven finite original-action/rendered-control programmes. Runtime ec5ad6c18996d8d958ec0d01132ef3e3ea6d23d36188902eca9a63d4db2efbf8; documentation-only proof overlay a5892004937434429916496497c07a3838a324b4a5d64d4a0eb804ab5212d5bd. Other envelopes, full combinations, comprehensive assurance and deployed acceptance remain open. No saved-room/provider changes.
177 lines
13 KiB
TypeScript
177 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 { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
|
||
import {
|
||
actSelectedIndependentNexus, advanceSelectedIndependentNexus, createSelectedIndependentNexusBattleFixture,
|
||
createSelectedIndependentNexusCacheFixture, createSelectedIndependentNexusCardsFixture,
|
||
revealSelectedIndependentNexusPlans, selectedIndependentChoamFuelAction,
|
||
selectedIndependentNexusClean as clean, selectedIndependentNexusPlayer as player,
|
||
} from './fixture-selected-independent-nexus-cards';
|
||
|
||
/** Component quantities only; no historical-save or seat assurance programme. */
|
||
function selectedStocks(game: Game): void {
|
||
const actual = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||
...(game.ixSetupCards ?? []), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])];
|
||
const expected = [...treacheryDeck(game.expansions), ...ecazTreacheryCards(),
|
||
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])];
|
||
assert.deepEqual(actual.map(card => card.id).sort(), expected.map(card => card.id).sort());
|
||
const cards = game.nexusCards!.cards!;
|
||
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.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());
|
||
}
|
||
|
||
const configurations: Array<IndependentCardModulesOptions & { factions: NonNullable<IndependentCardModulesOptions['factions']> }> = [
|
||
{ expansions: ['ix'], factions: ['atreides', 'guild'], skills: true, tech: false },
|
||
{ expansions: ['choam'], factions: ['harkonnen', 'emperor', 'guild'], homeworlds: true, tech: true },
|
||
{ expansions: ['ix', 'choam'], factions: ['richese', 'choam', 'guild', 'atreides'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||
{ expansions: ['ix'], factions: ['ixians', 'tleilaxu', 'atreides', 'emperor', 'guild'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||
{ expansions: ['ix', 'choam'], factions: ['ixians', 'tleilaxu', 'richese', 'choam', 'harkonnen', 'guild'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||
];
|
||
for (const configuration of configurations) void test(`fresh ${configuration.expansions!.join('+')} independent/Nexus ${configuration.factions.length}-seat native setup keeps selected components and original reaction stamps`, () => {
|
||
const fixture = createIndependentCardModulesFixture({ ...configuration, independent: true, nexus: true });
|
||
const game = fixture.game;
|
||
assert.deepEqual(game.expansions, configuration.expansions);
|
||
assert.equal(game.ecazTreachery, true);
|
||
assert.equal(game.status, 'playing');
|
||
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||
assert.equal(game.nexusCards!.phase, null);
|
||
assert.equal(!!game.nexusIxianReplacementPreview, !configuration.factions.includes('ixians'));
|
||
assert.equal(!!game.nexusIxianBetrayalPreview, configuration.factions.includes('ixians'));
|
||
assert.equal(!!game.nexusHarkonnenBetrayalPreview, configuration.factions.includes('harkonnen'));
|
||
assert.equal(!!game.guildBetrayalPreview, configuration.factions.includes('guild'));
|
||
if (configuration.homeworlds) assert.ok(game.homeworlds!.custody);
|
||
if (configuration.skills) assert.equal(game.leaderSkills!.assignments.length, configuration.factions.length);
|
||
if (configuration.strongholds) assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
|
||
selectedStocks(game);
|
||
});
|
||
|
||
void test('native CHOAM Cunning consumes a genuinely auctioned independent Recruits as Kulon fuel without playing Recruits', () => {
|
||
const fixture = createSelectedIndependentNexusCardsFixture({ advanced: true, skills: true, tech: true, strongholds: true });
|
||
assert.ok(player(fixture.afterPurchases, 'choam').hand.some(card => card.id === RECRUITS_CARD_ID));
|
||
assert.ok(fixture.actions.some(step => step.actor === 'choam' && step.action.type === 'bid' && step.action.amount === 1));
|
||
assert.equal(fixture.draws.find(draw => draw.step.actor === 'choam')!.after.nexusCards!.cards!.hands.choam, 'choam');
|
||
let game = advanceSelectedIndependentNexus(fixture.game, state => state.phase === 5 && state.active === 'choam' && clean(state));
|
||
assert.equal(game.recruits ?? null, null);
|
||
const action = selectedIndependentChoamFuelAction(game), spice = player(game, 'choam').spice;
|
||
game = actSelectedIndependentNexus(game, 'choam', action);
|
||
assert.equal(game.nexusCards!.cards!.hands.choam, null);
|
||
if (game.response) {
|
||
assert.equal(game.response.kind, 'choamWorthless');
|
||
assert.ok(player(game, 'choam').hand.some(card => card.id === RECRUITS_CARD_ID));
|
||
} else assert.equal(game.nexusChoamLast!.stage, 'complete',
|
||
'Without an eligible native counter, automatic completion needs no fabricated response');
|
||
game = advanceSelectedIndependentNexus(game, clean);
|
||
assert.equal(game.nexusChoamLast!.stage, 'complete');
|
||
assert.equal(game.choamMovement!.bonus, 1);
|
||
assert.equal(game.recruits ?? null, null, 'CHOAM conversion never runs the independent card’s printed Revival effect');
|
||
assert.equal(game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
|
||
assert.equal(player(game, 'choam').spice, spice);
|
||
assert.throws(() => applyAction(game, 'choam', action));
|
||
selectedStocks(game);
|
||
});
|
||
|
||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} native Richese two-marker Cunning materializes before independent Reinforcements pays its physical fee`, () => {
|
||
const fixture = createSelectedIndependentNexusBattleFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
|
||
assert.equal(player(fixture.afterFirstReveal, 'richese').reserves, 20);
|
||
assert.equal(player(fixture.afterPair, 'richese').reserves, 15);
|
||
assert.equal(player(fixture.afterPair, 'richese').forces[fixture.location], 5);
|
||
assert.ok(player(fixture.afterPair, 'richese').noField!.deployed);
|
||
assert.equal(fixture.afterPair.nexusCards!.cards!.hands.richese, null);
|
||
assert.equal(fixture.afterPair.nexusRicheseCunningLast!.stage, 'shipped');
|
||
const shipment = fixture.afterPair.nexusGuildBetrayalHistory!.at(-1)!;
|
||
assert.equal(shipment.invoice.source, 'reserve');
|
||
assert.equal(shipment.invoice.shipper, 'richese');
|
||
assert.equal(shipment.invoice.turn, 2);
|
||
assert.equal(shipment.invoice.price, 1);
|
||
assert.equal(shipment.invoice.originalReceiver, 'guild');
|
||
assert.equal(shipment.recipient, null);
|
||
assert.ok(fixture.actions.some(step => step.actor === 'choam' && step.action.type === 'guildBetrayalPass' && step.action.event === shipment.invoice.event),
|
||
'Cunning settles its genuine signed Guild fee acknowledgement before force custody changes');
|
||
const preview = viewGame(fixture.game, 'richese').battle!.reinforcements!;
|
||
assert.equal(preview.blocked, null);
|
||
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 12 });
|
||
assert.equal(preview.normal, 3);
|
||
let game = revealSelectedIndependentNexusPlans(fixture.game, fixture.plans);
|
||
assert.equal(player(game, 'richese').noField!.deployed, null);
|
||
assert.equal(player(game, 'richese').forces[fixture.location], 8);
|
||
assert.equal(player(game, 'richese').reserves, 12);
|
||
const tanks = player(game, 'richese').tanks;
|
||
game = advanceSelectedIndependentNexus(game, state => !state.battle && clean(state));
|
||
assert.equal(game.lastBattleContext!.winner, 'richese');
|
||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||
assert.equal(player(game, 'richese').reserves, 9, 'Three genuine uncommitted reserves pay the card after its own concealed army appears');
|
||
assert.equal(player(game, 'richese').tanks, tanks + 5, 'Three reserve-fee counters plus two actual dial casualties');
|
||
assert.equal(player(game, 'richese').forces[fixture.location], 6);
|
||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||
selectedStocks(game);
|
||
});
|
||
|
||
void test('an actual paid ten-force shipment leaves too few reserves for Reinforcements after the retained Cunning pair, while the plain plan remains legal', () => {
|
||
const fixture = createSelectedIndependentNexusBattleFixture({ drainReserves: true });
|
||
assert.equal(player(fixture.afterFirstReveal, 'richese').reserves, 20);
|
||
assert.deepEqual(player(fixture.afterFirstReveal, 'richese').noField!.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
|
||
const funding = fixture.reserveFunding, shipment = fixture.reserveShipment;
|
||
assert.ok(funding && shipment);
|
||
assert.equal(funding.step.action.type, 'bribe');
|
||
assert.equal(player(funding.after, 'choam').spice, player(funding.before, 'choam').spice - 9);
|
||
assert.equal(player(funding.after, 'richese').bribes, player(funding.before, 'richese').bribes + 9);
|
||
assert.equal(shipment.step.action.type, 'ship');
|
||
assert.equal(player(shipment.after, 'richese').spice, player(shipment.before, 'richese').spice - 10);
|
||
assert.equal(player(shipment.after, 'richese').reserves, 10);
|
||
assert.equal(player(shipment.after, 'richese').forces['habbanya_ridge_sietch:17'], 10);
|
||
assert.equal(shipment.after.nexusCards!.cards!.hands.richese, 'richese');
|
||
assert.equal(fixture.beforePair.turn, 3);
|
||
assert.equal(player(fixture.afterPair, 'richese').reserves, 5);
|
||
const preview = viewGame(fixture.game, 'richese').battle!.reinforcements!;
|
||
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 2 });
|
||
assert.match(preview.blocked!, /three.*reserves.*after materialization/i);
|
||
assert.throws(() => applyAction(fixture.game, 'richese', fixture.plans[0].action), /at least three own forces in reserves/i);
|
||
assert.ok(player(fixture.game, 'richese').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||
assert.deepEqual(fixture.game.battle!.plans, {});
|
||
const plans = fixture.plans.map(plan => plan.actor === 'richese' ? { ...plan, action: { ...plan.action, defense: null } } : plan);
|
||
let game = revealSelectedIndependentNexusPlans(fixture.game, plans);
|
||
assert.equal(player(game, 'richese').reserves, 2);
|
||
assert.equal(player(game, 'richese').forces[fixture.location], 8);
|
||
game = advanceSelectedIndependentNexus(game, state => !state.battle && clean(state));
|
||
assert.equal(game.lastBattleContext!.winner, 'richese');
|
||
assert.ok(player(game, 'richese').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 0);
|
||
selectedStocks(game);
|
||
});
|
||
|
||
void test('actual Richese cache purchase opens absent-Ix replacement and draws an original independent Harass without charging twice', () => {
|
||
const fixture = createSelectedIndependentNexusCacheFixture({ advanced: true, skills: true, tech: true, strongholds: true });
|
||
assert.equal(fixture.game.nexusCards!.cards!.hands.choam, 'ixians');
|
||
assert.equal(fixture.game.players.some(player => player.faction === 'ixians'), false);
|
||
assert.equal(player(fixture.game, 'choam').spice, player(fixture.beforePurchase, 'choam').spice - fixture.price);
|
||
assert.ok(player(fixture.game, 'choam').hand.some(card => card.id === fixture.card.id));
|
||
const paid = player(fixture.game, 'choam').spice, seller = player(fixture.game, 'richese').spice;
|
||
let game = actSelectedIndependentNexus(fixture.game, 'choam', fixture.useAction);
|
||
game = advanceSelectedIndependentNexus(game, state => !state.pendingNexusIxianReplacement && !state.currentAuctionSale && clean(state));
|
||
assert.equal(player(game, 'choam').spice, paid);
|
||
assert.equal(player(game, 'richese').spice, seller + fixture.price);
|
||
assert.ok(player(game, 'choam').hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||
assert.equal(fixture.replacement.id, HARASS_WITHDRAW_CARD);
|
||
assert.equal(player(game, 'choam').hand.some(card => card.id === fixture.card.id), false);
|
||
assert.equal(game.discard.filter(card => card.id === fixture.card.id).length, 1);
|
||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||
assert.equal(game.nexusCards!.cards!.hands.choam, null);
|
||
assert.equal(game.richeseCache!.some(card => card.id === fixture.card.id), false);
|
||
selectedStocks(game);
|
||
});
|