183 lines
10 KiB
TypeScript
183 lines
10 KiB
TypeScript
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);
|
|
});
|