Integrate native Banker payments and Tleilaxu Ixian Nexus replacement
This commit is contained in:
1 parent
d72f3a2172
commit
b83d75d9d3
17 files changed
+912
-46
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user