Compose native Richese skills and Advanced contribution receipts
This commit is contained in:
1 parent
10d74f8db8
commit
032b41aa87
23 files changed
+1132
-111
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user