Compose native Richese skills and Advanced contribution receipts

This commit is contained in:
Quintine committed 2026-10-03 17:46:10 +10:00
1 parent 10d74f8db8
commit 032b41aa87
23 files changed
+1132 -111

No files matched your search

+62
View File
@@ -19,6 +19,7 @@ import {
} from '../game/engine';
import {
quoteAuctionContinuation,
auctionEmperorIncomeAmount,
AuctionContinuationError,
type AuctionContinuationInput,
type AuctionContinuationOperation,
@@ -365,6 +366,67 @@ void test('sold Richese lots credit their seller exactly once before bonus, reta
'phase',
);
});
void test('paid Richese donor receipts route only their bound share, including Emperor buyer and full donor funding', () => {
for (const contribution of [1, 4]) {
const g = richese('cache', 'e');
g.sale!.richeseContribution = contribution;
// Payment is already committed: no live alliance/funds reconstruction.
g.players.find(p => p.id === 'r')!.spice = 0;
const q = quoteAuctionContinuation(g, { kind: 'sale', free: false });
assert.deepEqual(q.steps, [
{ kind: 'sellerCredit', player: 'r', amount: 4 - contribution, balance: 4 - contribution },
]);
assert.equal(auctionEmperorIncomeAmount(q.sale), contribution);
assert.ok(q.next.kind === 'response' && q.next.response.kind === 'emperorIncome');
const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'));
assert.deepEqual(canceled.steps, []);
assert.ok(canceled.next.kind === 'richeseEnd' && canceled.next.after === 'normalPool');
g.sale!.recipient = 'bank';
assert.equal(auctionEmperorIncomeAmount(g.sale!), 0);
assert.deepEqual(quoteAuctionContinuation(g, { kind: 'sale', free: false }).steps, []);
assert.throws(() => quoteAuctionContinuation(g, cancel('emperorIncome', 'e')), /not payable/);
}
});
void test('Richese donor scalars reject impossible source, rule band and amount without changing the receipt', () => {
const invalid: ((g: AuctionContinuationInput) => void)[] = [
g => { g.sale!.richeseContribution = 0; },
g => { g.sale!.richeseContribution = -1; },
g => { g.sale!.richeseContribution = 1.5; },
g => { g.sale!.richeseContribution = 5; },
g => { g.advanced = false; },
];
for (const mutate of invalid) {
const g = richese('cache');
g.sale!.richeseContribution = 2;
mutate(g);
const before = structuredClone(g);
assert.throws(() => quoteAuctionContinuation(g, { kind: 'sale', free: false }), /contribution/);
assert.deepEqual(g, before);
}
for (const g of [fixture(), richese('cache', 'r')]) {
g.sale!.richeseContribution = 2;
const before = structuredClone(g);
assert.throws(() => quoteAuctionContinuation(g, { kind: 'next' }), /contribution/);
assert.deepEqual(g, before);
}
});
void test('canceling a partial Richese-paid Emperor invoice advances to native Harkonnen bonus without replaying seller income', () => {
const g = richese('blackMarket');
g.sale!.richeseContribution = 2;
const paid = quoteAuctionContinuation(g, { kind: 'sale', free: false });
assert.deepEqual(paid.steps, [
{ kind: 'sellerCredit', player: 'r', amount: 2, balance: 22 },
]);
assert.ok(paid.next.kind === 'response' && paid.next.response.kind === 'emperorIncome');
const before = structuredClone(g);
const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'));
assert.deepEqual(canceled.steps, []);
assert.ok(canceled.next.kind === 'response' && canceled.next.response.kind === 'harkonnenBonus');
assert.deepEqual(g, before);
});
void test('Black Market cache exhaustion is checked only when reaching that declaration boundary; seller balance overflow rejects before credit', () => {
const g = richese();
g.richeseCacheCount = 0;
+35
View File
@@ -264,6 +264,9 @@ void test('Moritani Skills samples preserve full Basic and Advanced native decks
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(cards).size, 14);
assert.equal(cards.length, 14);
const advancedResumed = join(area, 'advanced-resumed');
assert.equal(run(advancedResumed, '--resume', join(advanced, 'failed-moritani-skills-2-advanced.json'), '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(advancedResumed, 'moritani-skills-2-advanced', 1249, true);
});
void test('Tleilaxu Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
@@ -289,6 +292,9 @@ void test('Tleilaxu Skills samples preserve full Basic and Advanced native decks
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(cards).size, 14);
assert.equal(cards.length, 14);
const continued = join(area, `advanced-resumed-${name}`);
assert.equal(run(continued, '--resume', join(advanced, `failed-${name}.json`), '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(continued, name, seed, true);
}
});
@@ -315,6 +321,9 @@ void test('Ixian Skills samples preserve full Basic and Advanced native decks th
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(cards).size, 14);
assert.equal(cards.length, 14);
const continued = join(area, `advanced-resumed-${name}`);
assert.equal(run(continued, '--resume', join(advanced, `failed-${name}.json`), '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(continued, name, seed, true);
}
});
@@ -340,6 +349,32 @@ void test('CHOAM Skills samples preserve full Basic and Advanced native decks th
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(cards).size, 14);
assert.equal(cards.length, 14);
const advancedResumed = join(area, 'advanced-resumed');
assert.equal(run(advancedResumed, '--resume', join(advanced, 'failed-choam-skills-2-advanced.json'), '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(advancedResumed, 'choam-skills-2-advanced', 1225, true);
});
void test('native Richese skill captures retain all fourteen skills, ten cache cards and original Basic or Advanced CLI continuation', (t) => {
const area = temporary(t);
for (const rules of ['basic', 'advanced'] as const) {
const out = join(area, rules);
assert.equal(run(out, '--profile', 'richese-skills', '--rules', rules, '--players', '2', '--seed', '1000', '--max-actions', '1').status, 1);
const samples = rules === 'basic'
? [['richese-skills-2-basic', 1254], ['richese-skills-richese-choam-2-basic', 1264]] as const
: [['richese-skills-2-advanced', 1259], ['richese-skills-richese-choam-2-advanced', 1269], ['richese-skills-ix-deck-2-advanced', 1278]] as const;
for (const [name, seed] of samples) {
assertFailedEvidence(out, name, seed, false, samples.length);
const snapshot = join(out, `failed-${name}.json`);
const state = json<{richeseCache:{id:string}[];leaderSkills:{deck:string[];offers:Record<string,{cards:string[]}>;assignments:{skill:string}[]}}>(snapshot);
const skills = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(skills).size, 14);
assert.equal(skills.length, 14);
assert.equal(new Set(state.richeseCache.map(c => c.id)).size, 10);
const continued = join(area, `resumed-${name}`);
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(continued, name, seed, true);
}
}
});
void test('Homeworld and Nexus sample snapshots resume only their matching module and roster', (t) => {
const area = temporary(t);
+20 -5
View File
@@ -10,11 +10,13 @@ import { newRevivalRules } from '../game/revival';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type AdvancedNativeSkillsOptions = {
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani';
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani' | 'richese';
requestedSkill?: LeaderSkillId;
skillOwner?: FactionId;
opponents?: readonly FactionId[];
bankerIncome?: boolean;
/** Richese also exercises its already-admitted Basic native rule band. */
rules?: 'basic' | 'advanced';
/** Continue this actual authenticated lobby/setup without replacing its seats. */
initial?: Game;
};
@@ -53,16 +55,17 @@ function withSkillShuffle<T>(skill: LeaderSkillId, ownerIndex: number, initializ
* physical skill offers. No played save is converted and no deck is cherry-picked. */
export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game {
const ownerFaction = options.skillOwner ?? options.family;
const expansion = options.family === 'moritani' ? 'ecaz' : options.family === 'choam' ? 'choam' : 'ix';
const expansion = options.family === 'moritani' ? 'ecaz' :
options.family === 'choam' || options.family === 'richese' ? 'choam' : 'ix';
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions = [ownerFaction, options.family, ...(options.opponents ?? ['atreides', 'emperor'] as FactionId[])]
.filter((faction, index, all) => all.indexOf(faction) === index);
game = createGame('ADVNATIVE', newPlayer(factions[0], factions[0], factions[0]), true, [expansion]);
game = createGame('ADVNATIVE', newPlayer(factions[0], factions[0], factions[0]), options.rules !== 'basic', [expansion]);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
}
assert.equal(game.advanced, true);
assert.equal(game.advanced, options.rules !== 'basic');
assert.ok(game.expansions.includes(expansion));
const owner = advancedNativePlayer(game, ownerFaction);
if (game.status === 'setup') {
@@ -112,6 +115,13 @@ export function advancedNativeStep(game: Game): Game {
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
if (game.decision?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage))
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
const lot = game.richeseAuction;
if (lot && !lot.outcome && !game.response && !game.decision && !game.phaseOpening) {
const actor = lot.method === 'silent'
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor, 'Original native lot must retain its next bidder.');
return applyAction(game, actor, { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null });
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return applyAction(game, next.actor, next.action);
@@ -216,9 +226,14 @@ export function stageAdvancedTleilaxuForeignRevival(state: Game): {
export function openAdvancedNativeSkillBattle(state: Game, actor: string, target: string, hideSkills = true): Game {
let game = applyAction(state, actor, { type: 'chooseBattle', territory: 'wind_pass', target });
for (let i = 0; (game.response || game.decision || game.battle?.preparation) && i < 100; i++) {
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
if (game.decision?.kind === 'leaderSkillVisibility') {
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: hideSkills });
} else if (!game.response && !game.decision && game.battle?.preLeader?.closed === false) {
const stage = game.battle.preLeader;
const next = stage.ready.includes(game.battle.attacker) ? game.battle.defender : game.battle.attacker;
assert.ok(!stage.ready.includes(next), 'Original pre-leader preparation must close after both acknowledgments.');
game = applyAction(game, next, { type: 'battlePreparationReady', event: stage.event });
} else game = advancedNativeStep(game);
}
assert.equal(game.decision, null);
+182
View File
@@ -0,0 +1,182 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, type Game } from '../game/engine';
import {
advancedNativePlayer,
advancedNativeStep,
assertAdvancedNativeCustody,
completedAdvancedNativeSkillsGame,
finishAdvancedNativeSkillAftermath,
rejectAdvancedNativeAction,
stageAdvancedNativeSkillBattle,
} from './fixture-advanced-native-skills';
/** Original fresh all14 setup and original cache auction. Only the selected lot
* and the other seats' passes are controlled; no cache card is injected. */
function acquireGreen(card: string, rules: 'basic' | 'advanced', voice = false): Game {
let game = completedAdvancedNativeSkillsGame({
family: 'richese', requestedSkill: 'planetologist', rules,
opponents: voice ? ['beneGesserit', 'emperor'] : ['atreides', 'emperor'],
});
const owner = advancedNativePlayer(game, 'richese').id;
for (let i = 0; !advancedNativePlayer(game, 'richese').hand.some(c => c.id === card) && i < 500; i++) {
if (game.response || game.phaseOpening) game = advancedNativeStep(game);
else if (game.decision?.kind === 'richeseBlackMarket') {
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, decline: true });
} else if (game.decision?.kind === 'richeseDeclaration') {
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
} else if (game.decision?.kind === 'richeseCache') {
game = applyAction(game, owner, {
type: 'decision', event: game.richeseBidding!.event, card,
method: 'onceAround', direction: 'counterclockwise',
});
} else if (game.decision?.kind === 'richeseUnbid') {
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, keep: true });
} else if (game.richeseAuction && !game.richeseAuction.outcome) {
game = applyAction(game, game.richeseAuction.active!, {
type: 'richeseBid', event: game.richeseAuction.event, amount: null,
});
} else game = advancedNativeStep(game);
}
assert.ok(advancedNativePlayer(game, 'richese').hand.some(c => c.id === card));
assert.equal(game.richeseCache!.some(c => c.id === card), false);
for (let i = 0; (game.phase !== 4 || game.response || game.decision || game.phaseOpening) && i < 500; i++)
game = advancedNativeStep(game);
assert.equal(game.phase, 4);
assert.ok(!game.response && !game.decision && !game.phaseOpening);
return game;
}
/** The shared controlled battle position returns hands to the deck. Restore
* their actual held physical cards, including the acquired cache lot, rather
* than treating that staging operation as a new acquisition. */
function heldCardBattle(state: Game, target: string): Game {
const owner = advancedNativePlayer(state, 'richese').id;
const game = stageAdvancedNativeSkillBattle(state, owner, target);
for (const original of state.players) {
const player = game.players.find(p => p.id === original.id)!;
for (const card of original.hand) {
const index = game.deck.findIndex(c => c.id === card.id);
assert.ok(index >= 0);
player.hand.push(...game.deck.splice(index, 1));
}
}
return game;
}
/** Explicit acknowledgments prevent fixture bots from executing the green
* component's independent preleader effect before it is used as a substitute. */
function openBattle(state: Game, target: string, voice?: boolean): Game {
const owner = advancedNativePlayer(state, 'richese').id;
let game = applyAction(state, owner, { type: 'chooseBattle', territory: 'wind_pass', target });
for (let i = 0; i < 100; i++) {
if (game.response || game.phaseOpening) game = advancedNativeStep(game);
else if (game.decision?.kind === 'leaderSkillVisibility') {
game = applyAction(game, game.decision.player, {
type: 'leaderSkillVisibility', event: game.decision.event, hide: true,
});
} else if (game.battle?.preLeader && !game.battle.preLeader.closed) {
const actor = [owner, target].find(id => !game.battle!.preLeader!.ready.includes(id));
assert.ok(actor);
game = applyAction(game, actor, { type: 'battlePreparationReady', event: game.battle.preLeader.event });
} else if (game.battle?.preparation) {
const preparation = game.battle.preparation;
game = applyAction(game, preparation.owner,
preparation.kind === 'voice' && voice !== undefined
? { type: 'voice', kind: 'poison', must: voice }
: { type: 'declineBattlePower' });
} else if (game.decision) game = advancedNativeStep(game);
else return game;
}
assert.fail('Original native pre-plan windows did not close.');
}
function resolveGreen(state: Game, target: string, card: string): Game {
const owner = advancedNativePlayer(state, 'richese');
const trainer = state.leaderSkills!.assignments.find(a => a.owner === owner.id && a.skill === 'planetologist')!.leader;
const disc = owner.leaders.find(l => l.id === trainer)!;
const enemy = state.players.find(p => p.id === target)!;
const enemyTrainer = state.leaderSkills!.assignments.find(a => a.owner === target)!.leader;
const opposing = enemy.leaders.find(l => !l.dead && l.id !== enemyTrainer && l.strength <= disc.strength + 1 && l.strength >= disc.strength - 3)!;
assert.ok(opposing);
// The opponent scores one above the unmodified trainer. Only the surviving
// green substitution can cross this score boundary; no paid support is used.
const dial = (disc.strength + 1 - opposing.strength) * (state.advanced ? 2 : 1);
let game = applyAction(state, owner.id, { type: 'battlePlan', dial: 0, support: 0, leader: trainer, weapon: card });
game = applyAction(game, target, { type: 'battlePlan', dial, support: 0, leader: opposing.id });
game = applyAction(game, owner.id, { type: 'traitorCall', call: false });
game = applyAction(game, target, { type: 'traitorCall', call: false });
assert.equal(game.lastBattleContext?.winner, owner.id);
return finishAdvancedNativeSkillAftermath(game);
}
void test('Basic native Richese uses an acquired Nullentropy Box for winning strength without paying or searching', () => {
const card = 'richese-nullentropy-box';
let game = acquireGreen(card, 'basic');
const target = advancedNativePlayer(game, 'emperor').id;
game = heldCardBattle(game, target);
const index = game.deck.findIndex(c => c.kind === 'worthless');
assert.ok(index >= 0);
const recoverable = game.deck.splice(index, 1)[0];
game.discard.push(recoverable); // Conserved card gives the native Box a real search target.
game = openBattle(game, target);
const wallet = advancedNativePlayer(game, 'richese').spice;
game = resolveGreen(game, target, card);
const owner = advancedNativePlayer(game, 'richese');
assert.equal(owner.spice, wallet, 'No two-spice native Box payment.');
assert.equal(owner.hand.some(c => c.id === recoverable.id), false);
assert.ok(game.discard.some(c => c.id === recoverable.id), 'No native discard search or recovery.');
assert.equal(game.discard.filter(c => c.id === card).length, 1);
assert.equal(owner.hand.some(c => c.id === card), false);
assertAdvancedNativeCustody(game);
});
void test('Advanced marker-only Richese keeps Residual Poison inert, obeys actual-Weapon Voice, and discards its winning substitute', () => {
const card = 'richese-residual-poison';
let game = acquireGreen(card, 'advanced', true);
const target = advancedNativePlayer(game, 'beneGesserit').id;
game = heldCardBattle(game, target);
const owner = advancedNativePlayer(game, 'richese');
owner.reserves += owner.forces['wind_pass:14'];
owner.forces = {};
const opponent = advancedNativePlayer(game, 'beneGesserit');
opponent.reserves += Object.values(opponent.forces).reduce((sum, n) => sum + n, 0);
opponent.forces = {};
const poisonIndex = game.deck.findIndex(c => c.kind === 'poison');
assert.ok(poisonIndex >= 0);
owner.hand.push(...game.deck.splice(poisonIndex, 1)); // Gives mandatory Voice an actual Weapon alternative.
Object.assign(game, { phase: 5, active: owner.id, movementRemaining: [...game.order] });
owner.shipped = false;
const token = owner.noField!.tokens.find(t => t.value === 0)!;
game = applyAction(game, owner.id, {
type: 'ship', noField: token.id, event: owner.noFieldEvent, territory: 'wind_pass', sector: 14,
});
for (let i = 0; (game.response || game.decision || game.phaseOpening) && i < 100; i++) game = advancedNativeStep(game);
assert.equal(advancedNativePlayer(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.ok(advancedNativePlayer(game, 'richese').noField!.deployed);
const fighters = advancedNativePlayer(game, 'beneGesserit');
fighters.reserves += Object.values(fighters.forces).reduce((sum, n) => sum + n, 0);
fighters.forces = {};
fighters.reserves -= 8;
fighters.forces['wind_pass:14'] = 8;
delete fighters.advisors?.wind_pass;
// Conserved opposing fighter position after original shipment/arrival decisions.
Object.assign(game, { phase: 6, active: owner.id, ready: [], movementRemaining: [] });
const required = openBattle(game, target, true);
const trainer = required.leaderSkills!.assignments.find(a => a.owner === owner.id)!.leader;
rejectAdvancedNativeAction(required, owner.id,
{ type: 'battlePlan', dial: 0, support: 0, leader: trainer, weapon: card }, /comply with the Voice/);
game = openBattle(game, target, false);
assert.deepEqual(game.battle!.noFieldPlayers, [owner.id]);
const untrained = advancedNativePlayer(game, 'richese').leaders.find(l => l.id !== trainer)!;
rejectAdvancedNativeAction(game, owner.id,
{ type: 'battlePlan', dial: 0, support: 0, leader: untrained.id, weapon: card }, /eligible Planetologist Special/);
const before = advancedNativePlayer(game, 'beneGesserit').leaders.map(l => ({ id: l.id, dead: l.dead, deaths: l.deaths }));
game = resolveGreen(game, target, card);
assert.deepEqual(advancedNativePlayer(game, 'beneGesserit').leaders.map(l => ({ id: l.id, dead: l.dead, deaths: l.deaths })), before,
'Residual Poison did not send any randomly selected enemy leader to the Tanks.');
assert.equal(advancedNativePlayer(game, 'richese').noField!.deployed, null);
assert.equal(advancedNativePlayer(game, 'richese').hand.some(c => c.id === card), false);
assert.equal(game.discard.filter(c => c.id === card).length, 1);
assertAdvancedNativeCustody(game);
});
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody as custody,
completedAdvancedNativeSkillsGame, finishAdvancedNativeSkillAftermath,
initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle,
rejectAdvancedNativeAction as reject, stageAdvancedNativeSkillBattle,
} from './fixture-advanced-native-skills';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
const trainer = (g: Game, id: string) => g.leaderSkills!.assignments.find(a => a.owner === id)!;
const untrained = (g: Game, id: string) => g.players.find(p => p.id === id)!.leaders
.filter(l => !l.dead && l.id !== trainer(g, id).leader).sort((a, b) => b.strength - a.strength)[0].id;
/** Genuine full native setup, then explicitly conserved shipment/battle position.
* Native ship creates the physical marker; no fabricated deployed token. */
function markerGame(value: 0 | 3 | 5, requestedSkill: 'suk-graduate' | 'mentat' | 'smuggler',
rules: 'basic' | 'advanced' = 'advanced', reserves = 20, companion = false) {
const initial = completedAdvancedNativeSkillsGame({ family: 'richese', requestedSkill, rules });
const actor = player(initial, 'richese').id, target = player(initial, 'emperor').id;
let game = stageAdvancedNativeSkillBattle(initial, actor, target);
const owner = player(game, 'richese');
owner.reserves += owner.forces['wind_pass:14'];
owner.forces = {};
owner.tanks += owner.reserves - reserves;
owner.reserves = reserves;
if (companion) {
const enemy = player(game, 'emperor');
enemy.reserves += enemy.forces['wind_pass:14'];
enemy.forces = {};
}
owner.shipped = false; owner.moved = 0;
Object.assign(game, { phase: 5, active: actor, ready: [], phaseOpening: null, response: null, decision: null });
const token = owner.noField!.tokens.find(t => t.value === value)!;
const before = owner.spice;
game = applyAction(game, actor, { type: 'ship', noField: token.id, event: owner.noFieldEvent,
territory: 'wind_pass', sector: 14, smuggler: companion });
for (let n = 0; (game.response || game.decision) && n < 100; n++) game = advancedNativeStep(game);
assert.equal(player(game, 'richese').spice, before - 2);
assert.ok(player(game, 'richese').noField!.deployed);
if (companion) {
const enemy = player(game, 'emperor');
enemy.reserves -= 8;
enemy.forces['wind_pass:14'] = 8;
} // Explicit later opposing position; the actual companion arrived in an empty territory.
const beforeBattle = reload(game);
Object.assign(game, { phase: 6, active: actor, ready: [], phaseOpening: null, response: null, decision: null });
custody(game);
return { game, beforeBattle, actor, target };
}
void test('native Basic and Advanced Richese retain source-ordered all14 and their separate original ten-card cache', () => {
for (const rules of ['basic', 'advanced'] as const) {
const offered = initializeAdvancedNativeSkillsSetup({ family: 'richese', requestedSkill: 'suk-graduate', rules });
assert.equal(offered.setupStage, 'leaderSkills');
assert.equal(offered.richeseCache!.length, 10);
for (const seat of offered.players) assert.equal(seat.hand.length, 1);
const physical = [...offered.leaderSkills!.deck, ...Object.values(offered.leaderSkills!.offers).flatMap(o => o.cards)];
assert.deepEqual([...physical].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
const rival = offered.players.find(p => p.faction !== 'richese')!;
const own = player(offered, 'richese');
assert.deepEqual(viewGame(offered, rival.id).leaderSkills!.eligibleLeaders.map(l => l.id).sort(),
rival.leaders.filter(l => !l.dead && !l.capturedBy && !l.gholaBy).map(l => l.id).sort());
assert.equal(viewGame(offered, own.id).leaderSkills!.offer!.cards.includes('suk-graduate'), true);
custody(completedAdvancedNativeSkillsGame({ family: 'richese', requestedSkill: 'suk-graduate', rules }));
}
});
void test('scarce native No-Field five materializes only three after both plans and skilled Suk saves only real paid losses', () => {
const staged = markerGame(5, 'suk-graduate', 'advanced', 3);
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target);
const trained = trainer(game, staged.actor).leader;
reject(game, staged.actor, { type: 'battlePlan', dial: 4, support: 3, leader: trained });
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 3, support: 3, leader: trained });
assert.ok(player(game, 'richese').noField!.deployed);
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.equal(viewGame(game, staged.target).richeseNoField!.private, null);
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.equal(player(game, 'richese').forces['wind_pass:14'], 3);
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Native real casualties must retain skilled rescue.');
const pending = game;
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14');
assert.ok(choice >= 0);
game = applyAction(reload(game), staged.actor, { type: 'decision', event: game.decision.event, choice });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(player(game, 'richese').forces['wind_pass:14'], 1);
assert.equal(player(game, 'richese').reserves, 2);
assert.equal(player(game, 'richese').tanks, 17);
assert.equal(player(game, 'richese').spice, 0);
custody(game);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(pending), staged.actor);
view.players.find(p => p.id === staged.actor)!.bot = difficulty;
const rescued = applyAction(reload(pending), staged.actor, botActions(view)[0]);
assert.equal(player(rescued, 'richese').tanks, 17);
assert.equal(player(rescued, 'richese').spice, 0);
custody(finishAdvancedNativeSkillAftermath(rescued));
}
});
void test('normal native Suk returns one actual No-Field casualty under both rule-band support costs', () => {
for (const rules of ['basic', 'advanced'] as const) {
const staged = markerGame(3, 'suk-graduate', rules);
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target, false);
const balance = player(game, 'richese').spice;
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 2, support: rules === 'advanced' ? 2 : 0,
leader: untrained(game, staged.actor) });
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.lastBattleContext!.winner, staged.actor);
assert.equal(player(game, 'richese').forces['wind_pass:14'], 1);
assert.equal(player(game, 'richese').reserves, 18);
assert.equal(player(game, 'richese').tanks, 1);
assert.equal(player(game, 'richese').spice, balance - (rules === 'advanced' ? 2 : 0));
custody(game);
}
});
void test('zero native No-Field retains an actual skilled leader battle and no phantom forces or support', () => {
const staged = markerGame(0, 'mentat');
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target);
const trained = trainer(game, staged.actor).leader;
const before = player(game, 'richese').spice;
reject(game, staged.actor, { type: 'battlePlan', dial: 1, support: 0, leader: trained });
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: trained });
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.lastBattleContext!.winner, staged.actor);
assert.equal(player(game, 'richese').reserves, 20);
assert.equal(player(game, 'richese').tanks, 0);
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.equal(player(game, 'richese').spice, before);
custody(game);
});
void test('original Smuggler companion remains physical and mixed No-Field battle stays guarded until voluntary native reveal', () => {
const staged = markerGame(5, 'smuggler', 'advanced', 20, true);
const owner = player(staged.game, 'richese');
assert.equal(owner.reserves, 19);
assert.equal(owner.forces['wind_pass:14'], 1);
reject(staged.game, staged.actor, { type: 'chooseBattle', territory: 'wind_pass', target: staged.target });
const token = owner.noField!.deployed!.tokenId;
reject(staged.game, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
let game = applyAction(staged.beforeBattle, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
assert.equal(player(game, 'richese').forces['wind_pass:14'], 6);
assert.equal(player(game, 'richese').reserves, 14);
Object.assign(game, { phase: 6, active: staged.actor, ready: [], phaseOpening: null, response: null, decision: null });
game = openAdvancedNativeSkillBattle(game, staged.actor, staged.target);
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.actor) });
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.lastBattleContext!.winner, staged.target);
assert.equal(player(game, 'richese').tanks, 6);
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
assert.equal(player(game, 'richese').reserves, 14);
custody(game);
});
+21 -38
View File
@@ -1,10 +1,9 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
import { baseDeck, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
PLANETOLOGIST_BASE_SPECIALS,
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
type BattleLeaderSkill,
} from '../game/leader-skill-combat';
import {
@@ -44,40 +43,23 @@ const assignment = (
captured,
});
void test('the alternate battle role is bound to the nine exact base green Specials', () => {
const eligible = baseDeck().filter(isPlanetologistBattleSpecialCard);
assert.deepEqual(
eligible.map((card) => card.id),
PLANETOLOGIST_BASE_SPECIALS.map((card) => card.id),
);
assert.deepEqual(
eligible.map((card) => card.name),
[
'Family Atomics',
'Weather Control',
'Hajr',
'Tleilaxu Ghola',
'Harvester',
'Karama',
'Karama',
'Truthtrance',
'Truthtrance',
],
);
assert.equal(
baseDeck()
.filter((card) => card.kind === 'hero')
.some(isPlanetologistBattleSpecialCard),
false,
);
assert.equal(ixBattleCards().some(isPlanetologistBattleSpecialCard), false);
assert.equal(
isPlanetologistBattleSpecialCard({
...special(),
id: 'forged-green-special',
}),
false,
);
void test('Richese green identities cannot be forged or borrowed by weapon-special and defense components', () => {
const green = richeseCards().find(card => card.effect === 'ornithopter')!;
const eligible = (card: Card) => canUsePlanetologistBattleSpecial({
assignments: [assignment('leader')],
selectedLeader: 'leader',
card,
});
assert.equal(eligible(green), true);
for (const forged of [
{ ...green, id: 'forged-green-special' },
{ ...green, name: 'Stone Burner' },
{ ...green, kind: 'projectile' as const },
{ ...green, effect: 'stoneBurner' },
]) assert.equal(eligible(forged), false);
for (const effect of ['stoneBurner', 'mirrorWeapon', 'portableSnooper'])
assert.equal(eligible(richeseCards().find(card => card.effect === effect)!), false);
assert.equal(eligible(baseDeck().find(card => card.kind === 'hero')!), false);
});
void test('only the actual concealed or captured assigned leader may use the physical substitute', () => {
@@ -195,7 +177,8 @@ void test('a surviving skilled disc gains two without activating the Special and
void test('leader death suppresses the two-point band but never releases the revealed Special', () => {
const input = battle();
const card = playPlanetologist(input, special('weather'));
const card = playPlanetologist(input, richeseCards().find(card => card.effect === 'residualPoison')!);
input.physicalCards = [...input.physicalCards, card];
const poison = baseDeck().find((candidate) => candidate.kind === 'poison')!;
input.defender.hand = [poison];
input.defender.plan.weapon = poison.id;
+248
View File
@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
import type { FactionId } from '../game/catalog';
import {
initializeAdvancedNativeSkillsSetup, advancedNativePlayer, advancedNativeStep,
advanceAdvancedNativeSkillsToPhase, rejectAdvancedNativeAction,
} from './fixture-advanced-native-skills';
import { advanceSpiceBankerIncomeToMentat } from './fixture-spice-banker-income';
type Options = {
rules?: 'basic' | 'advanced'; source?: 'cache' | 'blackMarket';
emperor?: boolean; buyer?: 'atreides' | 'emperor' | 'richese';
banker?: boolean; counter?: boolean;
};
type NativeRichesePaymentFixture = {
game: Game; seller: string; buyer: string; observer: string; counter?: string; card: string;
};
function choose(game: Game, values: Record<string, unknown>): Game {
return applyAction(game, game.decision!.player, {
type: 'decision', event: game.richeseBidding!.event, ...values,
} as Action);
}
function allowOriginalResponses(state: Game): Game {
let game = state;
for (let i = 0; game.response && i < 100; i++) {
const actor = game.players.find(p => p.id !== game.response!.owner && !game.response!.passed.includes(p.id));
assert.ok(actor, 'The original counter window must retain an unpassed participant.');
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
function prepare(options: Options = {}): NativeRichesePaymentFixture {
const opponents: FactionId[] = ['atreides', 'beneGesserit'];
if (options.emperor !== false) opponents.push('emperor');
let game = initializeAdvancedNativeSkillsSetup({
family: 'richese', rules: options.rules ?? 'advanced', opponents,
requestedSkill: options.banker ? 'spice-banker' : 'suk-graduate',
skillOwner: options.banker ? 'beneGesserit' : 'richese', bankerIncome: options.banker,
});
const skillOwner = advancedNativePlayer(game, options.banker ? 'beneGesserit' : 'richese').id;
const requested = options.banker ? 'spice-banker' : 'suk-graduate';
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = actor === skillOwner ? requested :
offer.cards.find(card => card !== 'bureaucrat' && !own.unavailableSkills?.[card]);
assert.ok(skill && offer.cards.includes(skill));
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id });
} else game = advancedNativeStep(game);
} // Choose genuine alternate offers: unresolved Bureaucrat split funding is not this rule.
game = advanceAdvancedNativeSkillsToPhase(game, 2);
const seller = advancedNativePlayer(game, 'richese').id;
const buyer = advancedNativePlayer(game, options.buyer ?? 'atreides').id;
const observer = advancedNativePlayer(game, 'beneGesserit').id;
// Explicit controlled alliance position after original all14/native setup;
// wallets, force inventories, skills and original starting hands stay intact.
if (buyer !== seller) {
game.players.find(p => p.id === seller)!.ally = buyer;
game.players.find(p => p.id === buyer)!.ally = seller;
}
let counter: string | undefined;
if (options.counter) {
const owner = game.players.find(p => p.id === observer)!;
let card = owner.hand.find(c => c.effect === 'karama');
if (!card) {
const index = game.deck.findIndex(c => c.effect === 'karama');
if (index >= 0) card = game.deck.splice(index, 1)[0];
else {
const holder = game.players.find(p => p.id !== observer && p.hand.some(c => c.effect === 'karama'))!;
assert.ok(holder, 'Transfer an existing original physical Karama, never manufacture one.');
card = holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1)[0];
}
owner.hand.push(card);
}
counter = card.id;
}
for (let i = 0; game.phase === 2 && i < 100; i++) game = advancedNativeStep(game);
assert.equal(game.phase, 3);
if (game.advanced) {
assert.equal(game.decision?.kind, 'richeseBlackMarket');
if (options.source === 'blackMarket') {
const card = game.players.find(p => p.id === seller)!.hand[0];
assert.ok(card);
game = choose(game, { card: card.id, method: 'silent' });
} else game = choose(game, { decline: true });
}
if (options.source !== 'blackMarket') {
assert.equal(game.decision?.kind, 'richeseDeclaration');
game = choose(game, { position: 'first' });
game = allowOriginalResponses(game);
game = choose(game, { card: game.richeseCache![0].id, method: 'silent' });
}
// Pass only the original pre-sale counter windows. Never substitute a receipt.
game = allowOriginalResponses(game);
assert.ok(game.richeseAuction && !game.richeseAuction.outcome);
return { game, seller, buyer, observer, counter, card: game.richeseAuction.cardId };
}
function bid(game: Game, actor: string, amount: number, allyPayment = 0): Game {
return applyAction(game, actor, { type: 'richeseBid', event: game.richeseAuction!.event, amount, allyPayment });
}
function purchase(fixture: NativeRichesePaymentFixture, donation = 4, price = 8): Game {
let game = fixture.game;
if (donation) game = applyAction(game, fixture.seller, { type: 'pledgeAid', amount: donation });
game = bid(game, fixture.buyer, price, donation);
if (donation) {
rejectAdvancedNativeAction(game, fixture.seller, { type: 'pledgeAid', amount: donation - 1 }, /Ally funding/);
rejectAdvancedNativeAction(game, fixture.buyer, {
type: 'richeseBid', event: game.richeseAuction!.event, amount: 1,
}, /already submitted/);
}
for (const actor of game.richeseAuction!.order)
if (actor !== fixture.buyer) game = bid(game, actor, 0);
return game;
}
function finishResponses(state: Game): Game {
let game = state;
for (let i = 0; (game.response || (game.decision && !['richeseDeclaration', 'richeseCache'].includes(game.decision.kind))) && i < 200; i++)
game = advancedNativeStep(game);
assert.equal(game.response, null);
return game;
}
function balance(game: Game, actor: string): number { return game.players.find(p => p.id === actor)!.spice; }
function exactCard(game: Game, buyer: string, id: string): void {
assert.equal(game.players.find(p => p.id === buyer)!.hand.filter(c => c.id === id).length, 1);
const physical = [
...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...(game.ixAuction?.cards ?? []),
];
assert.equal(physical.filter(c => c.id === id).length, 1);
}
for (const source of ['cache', 'blackMarket'] as const) {
for (const mode of ['allow', 'cancel', 'emperorBuyer'] as const) {
void test(`Advanced native ${source}: Richese 4 plus buyer 4, Emperor ${mode}, settles card/escrow once`, () => {
const f = prepare({ source, counter: true, buyer: mode === 'emperorBuyer' ? 'emperor' : 'atreides' });
const before = f.game;
const emperor = advancedNativePlayer(before, 'emperor').id;
let game = purchase(f);
// Seller receives ONLY the non-Richese leg; donation is not refunded.
assert.equal(balance(game, f.seller), balance(before, f.seller));
assert.equal(balance(game, f.buyer), balance(before, f.buyer) - 4);
assert.equal(balance(game, emperor), balance(before, emperor) - (f.buyer === emperor ? 4 : 0));
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
exactCard(game, f.buyer, f.card);
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.currentAuctionSale?.richeseContribution, 4);
if (mode === 'cancel') game = applyAction(game, f.observer, { type: 'card', card: f.counter!, mode: 'cancel' });
game = finishResponses(game);
assert.equal(balance(game, f.seller), balance(before, f.seller));
assert.equal(balance(game, f.buyer), balance(before, f.buyer) - 4 + (mode !== 'cancel' && f.buyer === emperor ? 4 : 0));
assert.equal(balance(game, emperor), balance(before, emperor) - (f.buyer === emperor ? 4 : 0) + (mode === 'cancel' ? 0 : 4));
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
exactCard(game, f.buyer, f.card);
if (mode === 'cancel') assert.equal(game.discard.filter(c => c.id === f.counter).length, 1);
if (source === 'blackMarket') {
assert.equal(game.decision?.kind, 'richeseDeclaration');
assert.equal(game.richeseBidding?.blackMarketSold, true);
} else assert.ok(game.auction);
});
}
}
void test('Basic native cache keeps its original publisher seller receipt, not an inferred Advanced contributor rule', () => {
const f = prepare({ rules: 'basic', counter: true });
let game = purchase(f);
game = finishResponses(game);
assert.equal(balance(game, f.seller), balance(f.game, f.seller) + 4);
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
const emperor = advancedNativePlayer(game, 'emperor').id;
assert.equal(balance(game, emperor), balance(f.game, emperor));
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
exactCard(game, f.buyer, f.card);
});
for (const rules of ['basic', 'advanced'] as const) {
void test(`${rules} native Richese self-cache pays bank once, with no seller refund`, () => {
const f = prepare({ rules, emperor: false, buyer: 'richese' });
const game = finishResponses(purchase(f, 0, 4));
assert.equal(balance(game, f.seller), balance(f.game, f.seller) - 4);
exactCard(game, f.buyer, f.card);
});
}
for (const source of ['cache', 'blackMarket'] as const) {
void test(`Advanced native ${source}: only Richese bank-routed contribution qualifies separate Banker, deferred until native Mentat`, () => {
const f = prepare({ source, emperor: false, banker: true });
const ownerBalance = balance(f.game, f.observer);
let game = finishResponses(purchase(f));
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
assert.equal(balance(game, f.observer), ownerBalance);
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, [{ owner: f.observer, amount: 1 }]);
const grants = game.spiceBankerIncome!.sources.filter(row => row.grant);
assert.equal(grants.length, 1);
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.seller, amount: 4 }]);
exactCard(game, f.buyer, f.card);
// Native phase suffix, retaining cache-first when a Black Market sale ends.
game = advanceAdvancedNativeSkillsToPhase(game, 7);
const beforeMentat = balance(game, f.observer);
rejectAdvancedNativeAction(game, f.observer, { type: 'bribe', target: f.buyer, amount: beforeMentat + 1 });
game = advanceSpiceBankerIncomeToMentat(game);
assert.equal(balance(game, f.observer), beforeMentat + 1);
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
exactCard(game, f.buyer, f.card);
});
}
void test('Advanced Emperor contribution cancellation qualifies Banker for donor leg only, not the unrelated buyer leg', () => {
const f = prepare({ banker: true, counter: true });
let game = purchase(f);
assert.equal(game.response?.kind, 'emperorIncome');
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
game = applyAction(game, f.observer, { type: 'card', card: f.counter!, mode: 'cancel' });
game = finishResponses(game);
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, [{ owner: f.observer, amount: 1 }]);
assert.deepEqual(game.spiceBankerIncome!.sources.find(row => row.grant)!.source.bankLegs,
[{ payer: f.seller, amount: 4 }]);
exactCard(game, f.buyer, f.card);
});
void test('Advanced native bank contribution below four never combines unrelated buyer-to-seller payment for Banker', () => {
const f = prepare({ banker: true, emperor: false });
const game = finishResponses(purchase(f, 3, 8));
assert.equal(balance(game, f.seller), balance(f.game, f.seller) + 2);
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 5);
assert.equal(balance(game, f.observer), balance(f.game, f.observer));
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), false);
exactCard(game, f.buyer, f.card);
});
void test('Advanced native allowed Emperor contribution is faction income, never Banker bank income', () => {
const f = prepare({ banker: true, counter: true });
const game = finishResponses(purchase(f));
const emperor = advancedNativePlayer(game, 'emperor').id;
assert.equal(balance(game, emperor), balance(f.game, emperor) + 4);
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), false);
exactCard(game, f.buyer, f.card);
});