Connect native Nexus reactions to classic independent cards

This commit is contained in:
Quintine committed 2026-10-18 04:51:33 +11:00
1 parent c466107452
commit 366c967f8a
15 files changed
+483 -29

No files matched your search

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { createIndependentHarkonnenCall, createIndependentIxianPurchase,
reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
import { settleHarkonnenBetrayalNativeCounters } from './fixture-nexus-harkonnen-betrayal';
void test('independent Harass survives a canceled native call and Harkonnen draws the actual Mentat replacement', () => {
const f = createIndependentHarkonnenCall();
const frame = f.game.pendingNexusHarkonnenBetrayal!;
assert.equal(reactionPlayer(f.game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
let game = reactionAction(f.game, 'atreides', { type: 'nexusHarkonnenBetrayalUse', event: frame.source.event });
assert.equal(game.nexusCards!.cards!.hands.atreides, null);
assert.deepEqual(game.nexusCards!.cards!.discard, ['harkonnen']);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.includes(f.identity), false);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 3);
assert.equal(game.traitorReserve!.includes(f.identity), true);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.beforeShipment, 'atreides').reserves);
assert.equal(reactionPlayer(game, 'atreides').tanks, 0);
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
const top = game.traitorReserve![0];
game = advanceClassicIndependentNexus(game, state => state.phase === 8 && classicDiscoveryNexusClean(state));
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.equal(game.nexusHarkonnenReplacementHistory!.length, 1);
assert.equal(game.nexusHarkonnenReplacementHistory![0].drawn, top);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 4);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.filter(identity => identity === top).length, 1);
});
void test('all14 independent Harkonnen Pass preserves the actual traitor outcome and cancels Harass', () => {
const f = createIndependentHarkonnenCall({ advanced: true, skills: true });
const game = reactionAction(f.game, 'atreides', { type: 'nexusHarkonnenBetrayalPass',
event: f.game.pendingNexusHarkonnenBetrayal!.source.event });
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen');
assert.deepEqual(game.nexusCards!.cards!.discard, []);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.includes(f.identity), true);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 4);
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, true);
assert.equal(reactionPlayer(game, 'atreides').tanks, 1);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.afterShipment, 'atreides').reserves);
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.deepEqual(game.nexusHarkonnenReplacementHistory, []);
});
void test('all14 printed allied counter precedes independent Harkonnen acknowledgement without Nexus spending', () => {
const f = createIndependentHarkonnenCall({ advanced: true, skills: true, remote: true });
assert.equal(f.game.response?.kind, 'harkonnenTraitor');
assert.equal(f.game.pendingNexusHarkonnenBetrayal, null);
const game = reactionAction(f.game, 'guild', { type: 'card', card: f.counter, mode: 'cancel' });
assert.equal(game.pendingNexusHarkonnenBetrayal, null);
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen');
assert.deepEqual(game.nexusCards!.cards!.discard, []);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.includes(f.identity), true);
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.beforeShipment, 'atreides').reserves);
assert.equal(game.discard.filter(card => card.id === f.counter).length, 1);
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
});
void test('all14 allowed allied call reaches independent Nexus Use before its original battle and Mentat', () => {
const f = createIndependentHarkonnenCall({ advanced: true, skills: true, remote: true });
let game = settleHarkonnenBetrayalNativeCounters(f.game);
const source = game.pendingNexusHarkonnenBetrayal!.source;
assert.equal(source.declaration.beneficiary, 'guild');
game = reactionAction(game, 'atreides', { type: 'nexusHarkonnenBetrayalUse', event: source.event });
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
assert.equal(reactionPlayer(game, 'atreides').tanks, 0);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.beforeShipment, 'atreides').reserves);
assert.equal(reactionPlayer(game, 'guild').hand.some(card => card.id === f.counter), true);
const top = game.traitorReserve![0];
game = advanceClassicIndependentNexus(game, state => state.phase === 8 && classicDiscoveryNexusClean(state));
assert.equal(game.nexusHarkonnenReplacementHistory!.length, 1);
assert.equal(game.nexusHarkonnenReplacementHistory![0].drawn, top);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 4);
});
void test('all14 Ixian replacement discards the delivered independent lot without repeating its paid invoice', () => {
const f = createIndependentIxianPurchase({ advanced: true, skills: true });
const paid = reactionPlayer(f.game, 'atreides'), top = f.game.deck[0];
assert.equal(paid.spice, reactionPlayer(f.beforePayment, 'atreides').spice - 2);
assert.equal(paid.hand.some(card => card.id === REINFORCEMENTS_CARD), true);
const game = reactionAction(f.game, 'atreides', { type: 'nexusIxianReplacementUse',
event: f.game.pendingNexusIxianReplacement!.event });
assert.equal(reactionPlayer(game, 'atreides').spice, paid.spice);
assert.equal(reactionPlayer(game, 'atreides').hand.length, paid.hand.length);
assert.equal(reactionPlayer(game, 'atreides').hand.some(card => card.id === REINFORCEMENTS_CARD), false);
assert.deepEqual(reactionPlayer(game, 'atreides').hand.find(card => card.id === top.id), top);
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
assert.deepEqual(game.nexusCards!.cards!.discard, ['ixians']);
assert.equal(game.nexusCards!.cards!.hands.atreides, null);
assert.equal(game.auction?.index, f.game.auction!.index + 1);
});
void test('Harkonnen keeps the independent purchase and gains one native bonus when passing Ixian replacement', () => {
const f = createIndependentIxianPurchase({ owner: 'harkonnen' });
const paid = reactionPlayer(f.game, 'harkonnen'), top = f.game.deck[0];
const game = reactionAction(f.game, 'harkonnen', { type: 'nexusIxianReplacementPass',
event: f.game.pendingNexusIxianReplacement!.event });
const buyer = reactionPlayer(game, 'harkonnen');
assert.equal(buyer.spice, reactionPlayer(f.beforePayment, 'harkonnen').spice - 2);
assert.equal(buyer.hand.length, paid.hand.length + 1);
assert.equal(buyer.hand.some(card => card.id === REINFORCEMENTS_CARD), true);
assert.deepEqual(buyer.hand.find(card => card.id === top.id), top);
assert.equal(game.discard.some(card => card.id === REINFORCEMENTS_CARD), false);
assert.equal(game.nexusCards!.cards!.hands.harkonnen, 'ixians');
assert.deepEqual(game.nexusCards!.cards!.discard, []);
assert.equal(game.auction?.index, f.game.auction!.index + 1);
});
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { territory } from '../game/board';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export const reactionPlayer = (game: Game, id: string) => {
const player = game.players.find(seat => seat.id === id);
assert.ok(player);
return player;
};
export function reactionAction(game: Game, actor: string, action: Action, actions?: ClassicIndependentNexusStep[]) {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
/** Original setup and turn-one play, followed by a real worm, reciprocal
* alliance and closing draw. Only unused original deck singletons move. */
function drawIndependentReaction(face: 'harkonnen' | 'ixians', owner: string,
options: ClassicIndependentNexusOptions) {
const fixture = createClassicIndependentNexusFixture({ ...options,
roster: ['atreides', 'harkonnen', 'guild'] });
const actions = fixture.actions;
let game = fixture.game;
const take = (worm: boolean) => {
const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card : 'territory' in card);
assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
};
game.spiceDeck.unshift(take(false), ...(game.advanced ? [take(false)] : []),
take(true), take(false), ...(game.advanced ? [take(false)] : []));
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus &&
!state.spiceWindow && !state.spiceResolution && classicDiscoveryNexusClean(state), actions);
const others = game.players.filter(player => player.id !== owner);
game = reactionAction(game, others[0].id, { type: 'alliance', target: others[1].id }, actions);
game = reactionAction(game, others[1].id, { type: 'alliance', target: others[0].id }, actions);
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game);
game = reactionAction(game, owner, { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }, actions);
return { ...fixture, game, owner, actions, beforeDraw, afterDraw: structuredClone(game) };
}
export function createIndependentHarkonnenCall(options: { advanced?: boolean; skills?: boolean; remote?: boolean } = {}) {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const fixture = drawIndependentReaction('harkonnen', 'atreides', { ...options,
startingCards: { atreides: [HARASS_WITHDRAW_CARD], guild: [counter] } });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 5 &&
state.active === 'atreides' && classicDiscoveryNexusClean(state), actions);
const identity = reactionPlayer(game, 'atreides').leaders.find(leader =>
reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id;
assert.ok(identity, 'Use a real offered opposing Traitor, never assign one.');
const opponent = options.remote ? 'guild' : 'harkonnen';
const location = options.remote ? 'tueks_sietch' : 'carthag';
const sector = territory(location).sectors[0], key = `${location}:${sector}`;
assert.ok(!territory(location).sectors.includes(game.storm));
const beforeShipment = structuredClone(game);
game = reactionAction(game, 'atreides', { type: 'ship', territory: location, sector,
amount: 1, elite: 0, allyPayment: 0 }, actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && classicDiscoveryNexusClean(state), actions);
const afterShipment = structuredClone(game);
game = reactionAction(game, game.active!, { type: 'chooseBattle', territory: location,
target: game.active === 'atreides' ? opponent : 'atreides' }, actions);
for (let count = 0; count < 100; count++) {
const next = nextClassicDiscoveryNexusStep(game);
if (!next) break;
if (next.action.type === 'leaderSkillVisibility') next.action.hide = true;
game = applyClassicDiscoveryNexusStep(game, next, actions);
}
assert.ok(game.battle && !game.battle.revealed && classicDiscoveryNexusClean(game));
const opponentLeader = reactionPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((one, two) => two.strength - one.strength)[0].id;
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
game = reactionAction(game, actor, { type: 'battlePlan', dial: 0, support: 0,
leader: actor === 'atreides' ? identity : opponentLeader,
weapon: actor === 'atreides' ? HARASS_WITHDRAW_CARD : null, defense: null }, actions);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.ok(game.battle?.revealed);
for (const voter of viewGame(game, 'atreides').battle!.traitorVoters)
if (voter !== 'harkonnen' && game.battle!.traitorCalls[voter] === undefined)
game = reactionAction(game, voter, { type: 'traitorCall', call: false }, actions);
const beforeCall = structuredClone(game);
game = reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true }, actions);
return { ...fixture, game, beforeCall, beforeShipment, afterShipment, identity, opponent, key, counter };
}
export function createIndependentIxianPurchase(options: { advanced?: boolean; skills?: boolean; owner?: 'atreides' | 'harkonnen' } = {}) {
const owner = options.owner ?? 'atreides';
const fixture = drawIndependentReaction('ixians', owner, { ...options,
startingCards: { [owner]: ['ecaz-recruits'] } });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 2 && classicDiscoveryNexusClean(state), actions);
const ordered = [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD].map(id => {
const index = game.deck.findIndex(card => card.id === id); assert.ok(index >= 0);
return game.deck.splice(index, 1)[0];
});
game.deck.unshift(...ordered);
game = advanceClassicIndependentNexus(game, state => state.phase === 3 && !!state.auction &&
classicDiscoveryNexusClean(state), actions);
let beforeBid: Game | undefined;
for (let count = 0; game.decision?.kind !== 'auctionPayment' && count < 100; count++) {
if (classicDiscoveryNexusClean(game) && game.auction?.active === owner && game.auction.bid === 0) {
beforeBid = structuredClone(game);
game = reactionAction(game, owner, { type: 'bid', amount: 2, allyPayment: 0 }, actions);
} else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.ok(beforeBid);
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision.player, owner);
const beforePayment = structuredClone(game);
game = reactionAction(game, owner, { type: 'decision', karama: false }, actions);
return { ...fixture, game, beforeBid, beforePayment };
}
+6 -1
View File
@@ -25,7 +25,12 @@ export function createClassicIndependentNexusFixture(options: ClassicIndependent
let game = createGame('CLASSICINDEPENDENTNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, []);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const actions: ClassicIndependentNexusStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => { actions.push({ actor, action }); game = applyAction(game, actor, action); };
const act = (actor: string, action: Action) => {
actions.push({ actor, action });
game = game.status === 'setup'
? applyClassicDiscoveryNexusStep(game, { actor, action })
: applyAction(game, actor, action);
};
for (const option of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: option, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });