Integrate native Banker payments and Tleilaxu Ixian Nexus replacement

This commit is contained in:
Quintine committed 2026-10-02 21:12:52 +10:00
1 parent d72f3a2172
commit b83d75d9d3
17 files changed
+912 -46

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit,
joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { forceRevivalQuote } from '../game/revival';
import {
createSpiceBankerIncomeFixture, nextSpiceBankerIncomeNativeStep,
type SpiceBankerIncomeFixture,
} from './fixture-spice-banker-income';
export const NATIVE_BANKER_FAMILIES = ['ixians', 'tleilaxu', 'choam', 'moritani'] as const;
export type NativeBankerFamily = typeof NATIVE_BANKER_FAMILIES[number];
export type NativeBankerIncomeOptions = {
/** Continue an original fresh lobby or its undealt admitted setup. */
initial?: Game;
family: NativeBankerFamily;
/** Only the Ixian family may include the original Tleilaxu setup as well. */
withTleilaxu?: boolean;
bankerFaction?: FactionId;
payerFaction?: FactionId;
kind?: 'auction' | 'force-revival';
/** Auction price, or number of real Tanks to revive. */
amount?: number;
/** Conserved cyborg subset of the revival casualties, Ixians only. */
elite?: number;
stageIncomeCounter?: boolean;
};
/** Control only the original all14 skill shuffle. Treachery, traitors, Face
* Dancers, Terror tokens and every later native shuffle retain real entropy. */
function withNativeBankerSkillShuffle<T>(initialize: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
type RandomInput = Parameters<typeof original>[0];
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === 'spice-banker');
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (shuffleIndex <= 0) {
original(array as RandomInput);
return array;
}
assert.ok(array instanceof Uint32Array && array.length === 1,
'The initializer must begin with the original skill shuffle.');
array[0] = shuffleIndex-- === target ? 0 : 0xffffffff;
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Fresh ready Basic native lobby admitted through the real Banker initializer;
* no played state is retrofitted and no existing deck or offer is replaced. */
export function initializeNativeBankerIncomeSetup(options: NativeBankerIncomeOptions): Game {
assert.ok(!options.withTleilaxu || options.family === 'ixians');
const native: FactionId[] = [options.family];
if (options.withTleilaxu) native.push('tleilaxu');
const banker = options.bankerFaction ?? options.family;
const seats: FactionId[] = [banker, ...native, 'atreides', 'emperor'];
const factions = seats.filter((faction, index, all) => all.indexOf(faction) === index);
assert.ok(native.includes(banker) || banker === 'atreides' || banker === 'emperor');
const expansion: Game['expansions'][number] = options.family === 'choam' ? 'choam'
: options.family === 'moritani' ? 'ecaz' : 'ix';
let game = options.initial ? structuredClone(options.initial)
: createGame('NATBANKR', newPlayer(banker, banker, banker), false, [expansion]);
if (options.initial) {
assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, [expansion]);
if (game.status === 'setup') {
assert.ok(game.spiceBankerIncomePreview && game.turn === 1 && game.phase === 0);
return game;
}
assert.equal(game.status, 'lobby');
} else for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withNativeBankerSkillShuffle(() => initializeSpiceBankerIncomeGameForAudit(game));
}
function step(game: Game): Game {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return applyAction(game, next.actor, next.action);
}
/** Original Ix starting-card selection, skill assignments, Traitors/Face Dancers
* and Moritani placement finish through existing own-view actions. */
export function completeNativeBankerIncomeSetup(state: Game): Game {
let game = state;
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player;
const card = viewGame(game, actor).ixTechnology!.setup![0];
assert.ok(card);
game = applyAction(game, actor, { type: 'decision', card: card.id });
} else if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.includes('spice-banker') ? 'spice-banker'
: offer.cards.find(card => !view.unavailableSkills?.[card]);
assert.ok(skill);
assert.ok(view.eligibleLeaders[0]);
game = applyAction(game, actor, {
type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id,
});
} else if (game.setupStage === 'traitors' && !game.decision) {
const player = game.players.find(p => p.faction !== 'tleilaxu' && p.traitorChoices.length);
assert.ok(player);
game = applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
} else if (game.decision?.kind === 'moritaniSetup') {
game = applyAction(game, game.decision.player, { type: 'decision', territory: 'polar_sink', sector: 0 });
} else game = step(game);
}
assert.equal(game.status, 'playing');
assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!.owner, game.players[0].id);
return game;
}
/** Advance original phase controls without staging a phase or payment receipt. */
export function advanceNativeBankerIncomeToPhase(state: Game, target: number): Game {
let game = state;
const turn = state.turn;
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision)
&& game.status === 'playing' && game.turn === turn && i < 1000; i++) game = step(game);
assert.equal(game.turn, turn);
assert.equal(game.phase, target);
assert.ok(!game.phaseOpening);
assert.equal(game.response, null);
assert.equal(game.decision, null);
return game;
}
/** Reusable native runtime case: actual other-payer Emperor self-auction to the
* Bank by default. Revival stages only conserved normal/cyborg casualties after genuine
* setup; original quotes, responses, force returns and recipient credits execute. */
export function createNativeBankerIncomeFixture(options: NativeBankerIncomeOptions): SpiceBankerIncomeFixture {
const initial = initializeNativeBankerIncomeSetup(options);
if ((options.kind ?? 'auction') === 'auction') {
return createSpiceBankerIncomeFixture({
initial, kind: 'auction', payerFaction: options.payerFaction ?? 'emperor',
amount: options.amount ?? 4, stageIncomeCounter: options.stageIncomeCounter,
});
}
let game = completeNativeBankerIncomeSetup(initial);
game = advanceNativeBankerIncomeToPhase(game, 4);
const assignment = game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!;
const payer = game.players.find(p => p.faction === (options.payerFaction ?? 'emperor'))!;
assert.ok(payer);
const count = options.amount ?? 3;
assert.ok(payer.reserves >= count);
// Labelled conserved rule-unit casualty position, not fabricated earned income.
payer.reserves -= count;
payer.tanks += count;
const elite = options.elite ?? 0;
if (elite) {
assert.equal(payer.faction, 'ixians');
assert.ok(elite <= count && payer.elites && payer.elites.reserves >= elite);
payer.elites.reserves -= elite;
payer.elites.tanks += elite;
}
if (options.stageIncomeCounter) {
const owner = game.players.find(p => p.id === assignment.owner)!;
if (!owner.hand.some(card => card.effect === 'karama')) {
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0, 'A genuine native Karama must remain for conserved counter staging.');
owner.hand.push(game.deck.splice(index, 1)[0]);
}
}
const amount = forceRevivalQuote(game, payer, count, elite).cost;
return {
game, beforePayment: structuredClone(game), actor: payer.id, payer: payer.id,
paymentAction: { type: 'revive', amount: count, ...(elite ? { elite } : {}) }, owner: assignment.owner,
leader: assignment.leader, event: game.code, kind: 'force-revival', amount,
bankLegs: [{ payer: payer.id, amount }],
};
}
/** Continue the real action and its original private/native response suffix. */
export function payNativeBankerIncomeFixture(fixture: SpiceBankerIncomeFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let i = 0; (game.response || game.decision || game.pendingRevival) && i < 200; i++) game = step(game);
assert.equal(game.response, null);
assert.equal(game.decision, null);
assert.ok(!game.pendingRevival);
return game;
}
+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeIxianNexusReplacementGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { treacheryDeck, type Card } from '../game/cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { advanceToNextStorm } from './fixture-advanced-source';
import {
settleIxianReplacementFixture, type IxianReplacementFixture,
} from './fixture-nexus-ixian-replacement';
export type NativeIxianReplacementOptions = {
initial?: Game;
buyerFaction?: 'atreides' | 'tleilaxu';
purchasedId?: string;
replacementId?: string;
endingAuction?: boolean;
};
export type NativeIxianReplacementFixture = IxianReplacementFixture & {
setup: Game;
beforeNexusDraw: Game;
nexusDrawAction: Action;
nextCard: Card;
};
export function assertNativeIxianReplacementInventory(game: Game): void {
const physical = [
...game.players.flatMap(player => player.hand), ...game.deck, ...game.discard,
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
];
assert.deepEqual(physical.map(card => card.id).sort(), treacheryDeck(['ix']).map(card => card.id).sort());
const nexus = game.nexusCards!.cards!;
validateNexusCards(nexus, game.players);
assert.deepEqual([
...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(face => face !== null),
].sort(), [...NEXUS_FACTIONS].sort());
}
function nativeStep(game: Game): Game {
if (game.response || game.pendingTreacheryDiscard) return settleIxianReplacementFixture(game);
if (game.phaseOpening) return applyAction(game,
game.players.find(player => !game.phaseOpening!.passed.includes(player.id))!.id, { type: 'ready' });
if (game.status === 'playing' && !game.decision) {
if (game.phase === 0 && game.stormPending === null) {
const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined);
assert.ok(dialer);
return applyAction(game, dialer, { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 });
}
if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, { type: 'passBid' });
if (game.phase === 5) return applyAction(game, game.active!, { type: 'endMovement' });
}
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const actions = botActions(view);
const action = actions.find(candidate => candidate.type === 'ready') ?? actions[0];
if (action) return applyAction(game, player.id, action);
}
throw new Error(`Native replacement fixture stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
/** Only reorder undealt original cards; never manufacture hand, sale or Nexus receipts. */
function orderStartingCards(game: Game, buyer: string, reserved: readonly string[]): void {
const pool = [...game.deck];
const front: Card[] = [];
for (const player of game.players) {
for (let count = 0; count < (player.faction === 'harkonnen' ? 2 : 1); count++) {
const index = pool.findIndex(card => !reserved.includes(card.id) &&
(player.id === buyer && count === 0 ? card.effect === 'karama' : card.effect !== 'karama'));
assert.ok(index >= 0);
front.push(pool.splice(index, 1)[0]);
}
}
// Keep the requested turn-two cards outside the native turn-one auction row.
game.deck = [...front, ...pool.filter(card => !reserved.includes(card.id)),
...pool.filter(card => reserved.includes(card.id))];
}
/** Basic native Tleilaxu + classic setup, a full first turn, an actual alliance-
* qualified closing Nexus draw, and an original paid normal-auction purchase.
* The unplayed deck is ordered before native auction allocation, not the row. */
export function createNativeIxianReplacementFixture(
options: NativeIxianReplacementOptions = {},
): NativeIxianReplacementFixture {
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
game.nexusIxianReplacementPreview === true && game.turn === 1 && game.phase === 0 &&
game.players.every(player => player.hand.length === 0)));
} else {
game = createGame('NATIXREP', newPlayer('a', 'Atreides', 'atreides'), false, ['ix']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
}
assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, ['ix']);
assert.ok(game.players.some(player => player.faction === 'tleilaxu'));
const buyer = game.players.find(player => player.faction === (options.buyerFaction ?? 'atreides'))!.id;
const partners = game.players.filter(player => player.id !== buyer);
assert.ok(partners.length >= 2);
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game.nexusCards ??= { cards: null, phase: null };
game = initializeIxianNexusReplacementGameForAudit(game);
}
const purchasedId = options.purchasedId ?? 'ix-thumper';
const replacementId = options.replacementId ?? 'ix-amal';
assert.notEqual(purchasedId, replacementId);
assert.ok(game.deck.some(card => card.id === purchasedId));
assert.ok(game.deck.some(card => card.id === replacementId));
orderStartingCards(game, buyer, [purchasedId, replacementId]);
const nexus = game.nexusCards!.cards!;
assert.ok(Object.values(nexus.hands).every(face => face === null));
nexus.deck = ['ixians', ...nexus.deck.filter(face => face !== 'ixians')];
const setup = structuredClone(game);
for (let step = 0; game.status === 'setup' && step < 100; step++) game = nativeStep(game);
assert.equal(game.status, 'playing');
assert.equal(game.players.find(player => player.faction === 'tleilaxu')!.faceDancers!.length, 3);
assertNativeIxianReplacementInventory(game);
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card, at) => at >= position && 'territory' in card);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = advanceToNextStorm(game);
assert.equal(game.turn, 2);
for (let step = 0; game.phase !== 1 && step < 200; step++) game = nativeStep(game);
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed);
assert.ok(worm >= 0);
game.spiceDeck.splice(0, 0, game.spiceDeck.splice(worm, 1)[0]);
for (let step = 0; !(game.nexus && !game.spiceWindow && !game.spiceResolution &&
!game.phaseOpening && !game.response && !game.decision) && step < 200; step++) game = nativeStep(game);
assert.equal(game.nexus, true);
game = applyAction(game, partners[0].id, { type: 'alliance', target: partners[1].id });
game = applyAction(game, partners[1].id, { type: 'alliance', target: partners[0].id });
for (let step = 0; game.nexusCards?.phase?.stage !== 'drawing' && step < 200; step++) game = nativeStep(game);
assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const beforeNexusDraw = structuredClone(game);
const nexusDrawAction: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
game = applyAction(game, buyer, nexusDrawAction);
assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians');
for (const id of game.nexusCards!.phase!.eligible)
if (!game.nexusCards!.phase!.done.includes(id))
game = applyAction(game, id, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 });
const rowSize = game.players.filter(player => player.hand.length < (player.faction === 'harkonnen' ? 8 : 4)).length;
const auctionOffset = options.endingAuction ? rowSize - 1 : 0;
const purchasedIndex = game.deck.findIndex(card => card.id === purchasedId);
assert.ok(purchasedIndex >= 0);
const originalPurchase = game.deck.splice(purchasedIndex, 1)[0];
const replacementIndex = game.deck.findIndex(card => card.id === replacementId);
assert.ok(replacementIndex >= 0);
const originalReplacement = game.deck.splice(replacementIndex, 1)[0];
game.deck.splice(auctionOffset, 0, originalPurchase);
// Native allocation takes one lot per non-full seat; its remaining deck top
// is the requested original replacement, not an unsold reserved lot.
game.deck.splice(rowSize, 0, originalReplacement);
for (let step = 0; !(game.decision?.kind === 'auctionPayment' && game.decision.player === buyer) && step < 300; step++) {
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game, game.decision.player, { type: 'decision', karama: false });
else if (game.phaseOpening || game.response || game.decision || !game.auction) game = nativeStep(game);
else if (options.endingAuction && game.auction.index < game.auction.cards.length - 1) {
const active = game.players.find(player => player.id === game.auction!.active)!;
game = applyAction(game, active.id, active.id !== buyer && active.faction !== 'harkonnen' && game.auction.bid === 0 ?
{ type: 'bid', amount: 1 } : { type: 'passBid' });
} else game = applyAction(game, game.auction.active,
game.auction.active === buyer && game.auction.bid === 0 ? { type: 'bid', amount: 2 } : { type: 'passBid' });
}
assert.deepEqual(game.decision, { kind: 'auctionPayment', player: buyer });
assert.equal(game.auction!.cards[game.auction!.index].id, purchasedId);
assert.equal(game.deck[0].id, replacementId);
assert.equal(game.auction!.bid, 2);
assert.equal(game.auction!.allyPayment ?? 0, 0);
const beforePayment = structuredClone(game);
const beforeSpice = game.players.find(player => player.id === buyer)!.spice;
const emperor = game.players.find(player => player.faction === 'emperor')?.id ?? null;
const beforeEmperorSpice = emperor ? game.players.find(player => player.id === emperor)!.spice : null;
const purchased = structuredClone(game.auction!.cards[game.auction!.index]);
const paymentAction: Action = { type: 'decision', karama: false };
game = applyAction(game, buyer, paymentAction);
assert.ok(game.pendingNexusIxianReplacement);
assertNativeIxianReplacementInventory(game);
return {
setup, beforeNexusDraw, nexusDrawAction, beforePayment, game, buyer,
event: game.pendingNexusIxianReplacement.event, purchased, paymentAction, beforeSpice,
paidSpice: game.players.find(player => player.id === buyer)!.spice, emperor, beforeEmperorSpice,
nextCard: structuredClone(game.deck[0]), auctionIndex: beforePayment.auction!.index,
};
}
+1 -1
View File
@@ -56,7 +56,7 @@ function step(game:Game):Game {
}
export function advanceSpiceBankerIncomeToMentat(state:Game):Game {
let game=state;const turn=game.turn;
for(let i=0;game.phase!==8&&game.turn===turn&&game.status==='playing'&&i<1000;i++) game=step(game);
for(let i=0;(game.phase!==8||game.phaseOpening)&&game.turn===turn&&game.status==='playing'&&i<1000;i++) game=step(game);
assert.equal(game.turn,turn);assert.equal(game.phase,8);
return game;
}
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { forceRevivalQuote } from '../game/revival';
import { advanceSpiceBankerIncomeToMentat, nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
import {
advanceNativeBankerIncomeToPhase, createNativeBankerIncomeFixture,
payNativeBankerIncomeFixture, type NativeBankerIncomeOptions,
} from './fixture-native-banker-income';
const nativeCases: NativeBankerIncomeOptions[] = [
{ family: 'ixians' },
{ family: 'ixians', withTleilaxu: true },
{ family: 'tleilaxu' },
{ family: 'choam' },
{ family: 'moritani' },
];
for (const options of nativeCases) {
void test(`Basic ${options.family}${options.withTleilaxu ? '+Tleilaxu' : ''}: original bank auction buys its card once, defers Banker, and collects only at native Mentat`, () => {
const fixture = createNativeBankerIncomeFixture(options);
const buyer = fixture.game.players.find(p => p.id === fixture.payer)!;
const owner = fixture.game.players.find(p => p.id === fixture.owner)!;
const purchased = fixture.game.auction!.cards[fixture.game.auction!.index];
assert.notEqual(buyer.id, owner.id);
const paid = payNativeBankerIncomeFixture(fixture);
assert.equal(paid.players.find(p => p.id === buyer.id)!.spice, buyer.spice - 4);
assert.equal(paid.players.find(p => p.id === buyer.id)!.hand.filter(c => c.id === purchased.id).length, 1);
assert.equal(paid.players.find(p => p.id === owner.id)!.spice, owner.spice);
assert.deepEqual(viewGame(paid, owner.id).spiceBankerIncome!.deferred, [{ owner: owner.id, amount: 1 }]);
assert.deepEqual(paid.spiceBankerIncome!.sources.find(row => row.grant)!.source.bankLegs,
[{ payer: buyer.id, amount: 4 }]);
// Ordinary auction/Shipment/Battle/Collection suffixes continue naturally;
// Collection's own native income is already in this baseline wallet.
const collection = advanceNativeBankerIncomeToPhase(paid, 7);
const balance = collection.players.find(p => p.id === owner.id)!.spice;
assert.deepEqual(viewGame(collection, owner.id).spiceBankerIncome!.deferred, [{ owner: owner.id, amount: 1 }]);
const mentat = advanceSpiceBankerIncomeToMentat(collection);
assert.equal(mentat.players.find(p => p.id === owner.id)!.spice, balance + 1);
assert.deepEqual(viewGame(mentat, owner.id).spiceBankerIncome!.deferred, []);
assert.equal(mentat.players.find(p => p.id === buyer.id)!.hand.filter(c => c.id === purchased.id).length, 1);
});
}
void test('Basic native Tleilaxu Banker does not count a real auction paid to Emperor as a bank payment', () => {
const fixture = createNativeBankerIncomeFixture({ family: 'tleilaxu', payerFaction: 'atreides' });
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const emperor = fixture.game.players.find(p => p.faction === 'emperor')!;
const card = fixture.game.auction!.cards[fixture.game.auction!.index];
const paid = payNativeBankerIncomeFixture(fixture);
assert.equal(paid.players.find(p => p.id === payer.id)!.spice, payer.spice - 4);
assert.equal(paid.players.find(p => p.id === emperor.id)!.spice, emperor.spice + 4);
assert.equal(paid.players.find(p => p.id === payer.id)!.hand.filter(c => c.id === card.id).length, 1);
assert.deepEqual(viewGame(paid, fixture.owner).spiceBankerIncome!.deferred, []);
});
for (const amount of [1, 3]) {
void test(`Basic native Tleilaxu revival recipient: ${amount === 1 ? 'zero payer debit/free reward' : 'four-spice player payment'} returns original forces without Banker income`, () => {
const fixture = createNativeBankerIncomeFixture({ family: 'tleilaxu', kind: 'force-revival', amount });
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const recipient = fixture.game.players.find(p => p.faction === 'tleilaxu')!;
const quote = forceRevivalQuote(fixture.game, payer, amount);
assert.equal(quote.free, 1);
assert.equal(quote.cost, amount === 1 ? 0 : 4);
const paid = payNativeBankerIncomeFixture(fixture);
const revived = paid.players.find(p => p.id === payer.id)!;
assert.equal(revived.spice, payer.spice - quote.cost);
assert.equal(revived.reserves, payer.reserves + amount);
assert.equal(revived.tanks, payer.tanks - amount);
assert.equal(revived.revived, payer.revived + amount);
// The printed free-revival bonus comes from the Bank, not from this payer.
assert.equal(paid.players.find(p => p.id === recipient.id)!.spice, recipient.spice + quote.cost + 1);
assert.deepEqual(viewGame(paid, fixture.owner).spiceBankerIncome!.deferred, []);
assert.equal(paid.spiceBankerIncome!.sources.some(row => row.grant), false);
});
}
void test('Basic native Tleilaxu own paid revival really pays the Bank: another native Banker earns one, not the free-income reward', () => {
const fixture = createNativeBankerIncomeFixture({
family: 'tleilaxu', bankerFaction: 'atreides', payerFaction: 'tleilaxu',
kind: 'force-revival', amount: 6,
});
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const owner = fixture.game.players.find(p => p.id === fixture.owner)!;
const quote = forceRevivalQuote(fixture.game, payer, 6);
assert.equal(quote.free, 2);
assert.equal(quote.cost, 4);
const paid = payNativeBankerIncomeFixture(fixture);
const revived = paid.players.find(p => p.id === payer.id)!;
assert.equal(revived.reserves, payer.reserves + 6);
assert.equal(revived.tanks, payer.tanks - 6);
assert.equal(revived.spice, payer.spice - 4 + 1);
assert.equal(paid.players.find(p => p.id === owner.id)!.spice, owner.spice);
assert.deepEqual(viewGame(paid, owner.id).spiceBankerIncome!.deferred, [{ owner: owner.id, amount: 1 }]);
assert.deepEqual(paid.spiceBankerIncome!.sources.find(row => row.grant)!.source.bankLegs,
[{ payer: payer.id, amount: 4 }]);
});
for (const cost of [0, 3, 4]) {
void test(`canceled native Tleilaxu income: actual payer debit ${cost}, not the extra free reward, controls the Banker threshold`, () => {
const options: NativeBankerIncomeOptions = cost === 3
? { family: 'ixians', withTleilaxu: true, bankerFaction: 'atreides', payerFaction: 'ixians', amount: 2, elite: 2 }
: { family: 'tleilaxu', bankerFaction: 'atreides', payerFaction: 'emperor', amount: cost === 0 ? 1 : 3 };
const fixture = createNativeBankerIncomeFixture({ ...options, kind: 'force-revival', stageIncomeCounter: true });
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const recipient = fixture.game.players.find(p => p.faction === 'tleilaxu')!;
const owner = fixture.game.players.find(p => p.id === fixture.owner)!;
const counter = owner.hand.find(card => card.effect === 'karama')!;
assert.equal(fixture.amount, cost);
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let i = 0; game.response?.kind !== 'revivalIncome' && i < 100; i++) {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(game.response?.kind === 'revivalIncome');
assert.equal(game.response.amount, cost + 1);
assert.equal(game.players.find(p => p.id === payer.id)!.spice, payer.spice - cost);
assert.equal(game.players.find(p => p.id === recipient.id)!.spice, recipient.spice);
assert.deepEqual(viewGame(game, owner.id).spiceBankerIncome!.deferred, []);
game = applyAction(game, owner.id, { type: 'card', card: counter.id, mode: 'cancel' });
for (let i = 0; (game.response || game.decision) && i < 100; i++) {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(!game.response && !game.decision && !game.pendingRevival);
assert.equal(game.players.find(p => p.id === payer.id)!.spice, payer.spice - cost);
assert.equal(game.players.find(p => p.id === recipient.id)!.spice, recipient.spice);
assert.equal(game.players.find(p => p.id === owner.id)!.spice, owner.spice);
assert.equal(game.players.find(p => p.id === payer.id)!.tanks, payer.tanks - options.amount!);
if (cost === 3) {
assert.equal(game.players.find(p => p.id === payer.id)!.elites!.tanks, payer.elites!.tanks - 2);
assert.equal(game.players.find(p => p.id === payer.id)!.elites!.reserves, payer.elites!.reserves + 2);
}
assert.equal(game.discard.filter(card => card.id === counter.id).length, 1);
assert.deepEqual(viewGame(game, owner.id).spiceBankerIncome!.deferred,
cost === 4 ? [{ owner: owner.id, amount: 1 }] : []);
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), cost === 4);
if (cost > 0) assert.deepEqual(game.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,
[{ payer: payer.id, amount: cost }]);
});
}
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
createIxianReplacementSource, IxianReplacementError,
type IxianReplacementContext,
} from '../game/nexus-ixian-replacement';
import {
assertNativeIxianReplacementInventory, createNativeIxianReplacementFixture,
} from './fixture-native-ixian-replacement';
import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, id: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
for (const buyerFaction of ['atreides', 'tleilaxu'] as const)
void test(`Basic native ${buyerFaction} paid Ix special replacement preserves its purchase and settles the real suffix once`, () => {
const endingAuction = buyerFaction === 'tleilaxu';
const f = createNativeIxianReplacementFixture({ buyerFaction, endingAuction });
assert.equal(f.beforeNexusDraw.nexusCards!.cards!.hands[f.buyer], null);
assert.equal(owner(f.beforeNexusDraw, f.buyer).ally, null);
const partners = f.beforeNexusDraw.players.filter(player => player.id !== f.buyer);
assert.equal(partners[0].ally, partners[1].id);
assert.equal(partners[1].ally, partners[0].id);
assert.equal(applyAction(reload(f.beforeNexusDraw), f.buyer, f.nexusDrawAction).nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.equal(f.purchased.id, 'ix-thumper');
assert.equal(f.nextCard.id, 'ix-amal');
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
assert.equal(offer.canUse, true);
assert.equal(offer.canPass, true);
assert.deepEqual(offer.purchased, f.purchased);
assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 2);
assert.equal(owner(f.game, f.emperor!).spice, f.beforeEmperorSpice);
const deckBefore = structuredClone(f.game.deck);
const unsold = structuredClone(f.game.auction!.cards.slice(f.auctionIndex + 1));
const use = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementUse', event: offer.event }));
assert.deepEqual(owner(use, f.buyer).hand, [...owner(f.beforePayment, f.buyer).hand, f.nextCard]);
assert.deepEqual(use.deck, deckBefore.slice(1));
assert.deepEqual(use.discard, [...f.game.discard, f.purchased]);
assert.equal(use.nexusCards!.cards!.hands[f.buyer], null);
assert.deepEqual(use.nexusCards!.cards!.discard, [...f.game.nexusCards!.cards!.discard, 'ixians']);
assert.equal(owner(use, f.buyer).spice, f.paidSpice);
assert.equal(owner(use, f.emperor!).spice, f.beforeEmperorSpice! + 2);
assert.equal(use.currentAuctionSale, null);
assert.equal(use.pendingNexusIxianReplacement, null);
if (endingAuction) {
assert.equal(use.auction, null);
assert.equal(use.phase, 4);
} else {
assert.equal(use.auction!.index, f.auctionIndex + 1);
assert.deepEqual(use.auction!.cards.slice(use.auction!.index), unsold);
}
reject(use, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event });
reject(use, f.buyer, { type: 'nexusIxianReplacementPass', event: f.event });
});
void test('native Pass keeps the purchased Ix special and genuinely acquired Nexus without a draw or repayment', () => {
const f = createNativeIxianReplacementFixture();
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
const pass = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementPass', event: offer.event }));
assert.deepEqual(owner(pass, f.buyer).hand, owner(f.game, f.buyer).hand);
assert.deepEqual(pass.deck, f.game.deck);
assert.deepEqual(pass.discard, f.game.discard);
assert.deepEqual(pass.nexusCards!.cards, f.game.nexusCards!.cards);
assert.equal(owner(pass, f.buyer).spice, f.paidSpice);
assert.equal(owner(pass, f.emperor!).spice, f.beforeEmperorSpice! + 2);
assert.equal(pass.auction!.index, f.auctionIndex + 1);
assert.equal(pass.currentAuctionSale, null);
assertNativeIxianReplacementInventory(pass);
reject(pass, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event });
});
void test('native descriptor admission rejects forged Ix effects and unrelated Richese faces even with matching row and hand', () => {
const f = createNativeIxianReplacementFixture();
const source = f.game.pendingNexusIxianReplacement!;
for (const corrupt of [
{ ...f.purchased, effect: 'amal' },
{ ...f.purchased, name: 'Amal' },
{ ...f.purchased, kind: 'worthless' },
{ ...f.purchased, extra: true },
richeseCards()[0],
] as Card[]) {
const ctx: IxianReplacementContext = {
status: f.game.status, phase: f.game.phase, turn: f.game.turn, sequence: source.sequence,
auction: { ...f.game.auction!, cards: f.game.auction!.cards.map((card, index) =>
index === f.auctionIndex ? corrupt : card) },
sale: f.game.currentAuctionSale!,
players: f.game.players.map(player => ({
id: player.id, handLimit: 4,
hand: player.hand.map(card => card.id === f.purchased.id ? corrupt : card),
})),
physicalCards: [
...f.game.players.flatMap(player => player.hand), ...f.game.deck, ...f.game.discard,
...f.game.auction!.cards.slice(f.auctionIndex + 1),
].map(card => card.id === f.purchased.id ? corrupt : card),
};
assert.throws(() => createIxianReplacementSource(ctx, source.event, source.parent), IxianReplacementError);
}
const parent = reload(f.beforePayment);
parent.auction!.cards[parent.auction!.index].effect = 'amal';
reject(parent, f.buyer, f.paymentAction);
});