Unify fresh/live classic and native phase admission, retaining original counts, earned custody, separate income ledgers and physical card/skill continuations. Plain paired E3 phases no longer require cards or Nexus. Coupled44-file471/471, types/lint/build, genuine programmes and390px normal entry pass. Controlled card inputs explicit; no save retrofit or deployment.
210 lines
14 KiB
TypeScript
210 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
|
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
|
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
|
import {
|
|
assertIndependentSkillsBattleCustody, createIndependentSkillsBattleFixture,
|
|
createIndependentSkillsBattleSetup, advanceIndependentSkillsBattle, independentSkillsBattleClean,
|
|
finishIndependentSkillsBattle, independentSkillsBattlePlayer,
|
|
quoteIndependentSkillsBattle, type IndependentSkillsBattleFixture,
|
|
} from './fixture-independent-skills-battle';
|
|
|
|
const owner = (fixture: IndependentSkillsBattleFixture, game: Game) =>
|
|
independentSkillsBattlePlayer(game, fixture.owner);
|
|
const opponent = (fixture: IndependentSkillsBattleFixture, game: Game) =>
|
|
independentSkillsBattlePlayer(game, fixture.opponent);
|
|
const held = (game: Game, id: string) => [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)]
|
|
.filter((card) => card.id === id);
|
|
|
|
void test('the same-plan programme pays the three-counter fee, returns the undialed complement and scores the real Warmaster bonus', () => {
|
|
for (const advanced of [false, true]) {
|
|
const fixture = createIndependentSkillsBattleFixture({ case: 'same-plan', advanced });
|
|
assert.equal(fixture.location, 'pasty_mesa');
|
|
assert.equal(owner(fixture, fixture.beforeShipment).reserves, 20);
|
|
assert.equal(owner(fixture, fixture.beforeShipment).spice, 10);
|
|
assert.equal(owner(fixture, fixture.afterShipment).reserves, 16);
|
|
assert.equal(owner(fixture, fixture.afterShipment).spice, 2);
|
|
assert.equal(owner(fixture, fixture.afterShipment).forces[fixture.first], 4);
|
|
assert.equal(opponent(fixture, fixture.afterShipment).spice, 13, 'the paid eight-spice shipment reached the Guild');
|
|
assert.deepEqual(owner(fixture, fixture.beforeBattle).forces,
|
|
{ [fixture.first]: 2, [fixture.second]: 2 });
|
|
assert.deepEqual(opponent(fixture, fixture.beforeBattle).forces, { [fixture.second]: 5 });
|
|
const worthless = fixture.plans[1].action.weapon;
|
|
const quote = quoteIndependentSkillsBattle(fixture.game, { returns: fixture.returns });
|
|
assert.deepEqual(quote.reinforcements,
|
|
[{ player: fixture.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
|
assert.deepEqual(quote.leaderSkillBonuses.defender,
|
|
{ bonus: 3, applied: [{ skill: 'warmaster', amount: 3, mode: 'skilled' }] });
|
|
assert.deepEqual(quote.leaderSkillBonuses.attacker, { bonus: 0, applied: [] });
|
|
// Dial 2 + Reinforcements 2 + Captain Aramsham 5 beats Staban Tuek 5 + Warmaster 3.
|
|
assert.deepEqual(quote.scores, { attacker: 9, defender: 8 });
|
|
assert.deepEqual(quote.harassWithdraw?.map((entry) => ({
|
|
player: entry.player, returned: entry.returned,
|
|
remaining: { normal: entry.remaining.normal, elite: entry.remaining.elite }, locations: entry.locations })),
|
|
[{ player: fixture.owner, returned: { normal: 2, elite: 0 }, remaining: { normal: 2, elite: 0 },
|
|
locations: { [fixture.first]: { normal: 1, elite: 0 }, [fixture.second]: { normal: 1, elite: 0 } } }]);
|
|
assert.equal(fixture.game.decision?.kind, 'harassWithdraw');
|
|
assert.throws(() => quoteIndependentSkillsBattle(fixture.game,
|
|
{ returns: { [fixture.first]: { normal: 1, elite: 0 } } }),
|
|
/exact undialed complement of a legal physical commitment/);
|
|
const done = finishIndependentSkillsBattle(fixture.game, { return: fixture.returns });
|
|
assert.equal(done.phase, 7);
|
|
assert.equal(done.lastBattleContext?.winner, fixture.owner);
|
|
assert.equal(done.lastBattleContext?.result, 'normal');
|
|
assert.equal(owner(fixture, done).reserves, 15, 'twenty reserves, minus four shipped, minus the fee, plus two returned');
|
|
assert.equal(owner(fixture, done).tanks, 5, 'the three fee counters plus the two dialed casualties');
|
|
assert.deepEqual(owner(fixture, done).forces, {});
|
|
assert.equal(owner(fixture, done).spice, advanced ? 0 : 2);
|
|
assert.equal(opponent(fixture, done).tanks, 5, 'the defeated Guild army reached the Tanks');
|
|
assert.deepEqual(opponent(fixture, done).forces, {});
|
|
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, worthless as string]) {
|
|
assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
|
|
assert.ok(done.players.every((player) => !player.hand.some((held) => held.id === card)), card);
|
|
}
|
|
assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries,
|
|
[{ card: worthless, owner: fixture.opponent }]);
|
|
assertIndependentSkillsBattleCustody(done);
|
|
}
|
|
});
|
|
|
|
void test('the trained Suk Graduate rescues both committed casualties without refunding the fee or leaving phantom forces', () => {
|
|
for (const advanced of [false, true]) {
|
|
const fixture = createIndependentSkillsBattleFixture({ case: 'suk', advanced });
|
|
const quote = quoteIndependentSkillsBattle(fixture.game, { returns: fixture.returns });
|
|
// Dial 2 + Reinforcements 2 + Hasimir Fenring 6 against Guild Representative 1 + 2.
|
|
assert.deepEqual(quote.scores, { attacker: 10, defender: 3 });
|
|
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
|
|
const fee = finishIndependentSkillsBattle(fixture.game,
|
|
{ return: fixture.returns, until: (state) => state.decision?.kind === 'sukRescue' });
|
|
const rescue = fee.decision;
|
|
assert.equal(rescue?.kind, 'sukRescue');
|
|
assert.equal(rescue?.mode, 'skilled');
|
|
assert.deepEqual(owner(fixture, fee).forces,
|
|
{ [fixture.first]: 1, [fixture.second]: 1 }, 'the Harass return left exactly the committed two');
|
|
assert.equal(owner(fixture, fee).reserves, 15, 'the fee preceded the two returned counters');
|
|
assert.equal(owner(fixture, fee).tanks, 3, 'only the Reinforcements fee reached the Tanks');
|
|
assert.equal(owner(fixture, fee).spice, advanced ? 0 : 2);
|
|
const options = rescue?.kind === 'sukRescue' ? rescue.options : [];
|
|
const kept = options.find((option) => option.kept?.kind === 'normal' && option.normal + option.elite === 2);
|
|
assert.ok(kept, 'saving both casualties leaves one counter in its original sector');
|
|
assert.deepEqual(kept.kept, { key: fixture.first, kind: 'normal' });
|
|
assert.deepEqual(options.find((option) => option.normal + option.elite === 0),
|
|
{ normal: 0, elite: 0, kept: null });
|
|
const chosen = options.indexOf(kept);
|
|
const done = finishIndependentSkillsBattle(fee,
|
|
{ return: fixture.returns, rescue: () => chosen });
|
|
assert.equal(done.lastBattleContext?.sukRescue?.completed, true);
|
|
assert.deepEqual(owner(fixture, done).forces, { [fixture.first]: 1 },
|
|
'one rescued counter stayed in Pasty Mesa sector 5 and the rest returned to reserves');
|
|
assert.equal(owner(fixture, done).reserves, 16, 'the fee was not refunded by the rescue');
|
|
assert.equal(owner(fixture, done).tanks, 3);
|
|
assert.equal(done.pendingSukRescue, null);
|
|
assert.equal(opponent(fixture, done).tanks, 5);
|
|
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
|
|
assert.equal(held(done, card).length, 1, card);
|
|
assert.equal(done.discard.filter((entry) => entry.id === card).length, 1, card);
|
|
}
|
|
assert.equal(done.discard.some((entry) => entry.kind === 'worthless'), false);
|
|
assertIndependentSkillsBattleCustody(done);
|
|
}
|
|
});
|
|
|
|
void test('an opposing real Traitor cancels the Harass return while the fee still reaches the Tanks', () => {
|
|
for (const advanced of [false, true]) {
|
|
const fixture = createIndependentSkillsBattleFixture({ case: 'traitor-cancel', advanced });
|
|
assert.equal(fixture.traitor, fixture.plans[0].action.leader);
|
|
assert.ok(opponent(fixture, fixture.game).traitors.includes(fixture.traitor!));
|
|
const canceled = quoteIndependentSkillsBattle(fixture.game,
|
|
{ callers: [fixture.opponent], returns: fixture.returns });
|
|
assert.equal(canceled.result, 'traitor');
|
|
assert.equal(canceled.winner, fixture.opponent);
|
|
assert.equal(canceled.scores, null, 'a revealed Traitor carries no battle totals');
|
|
assert.equal(canceled.harassWithdraw, undefined, 'an opposing successful traitor cancels the return');
|
|
assert.deepEqual(canceled.reinforcements,
|
|
[{ player: fixture.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
|
const done = finishIndependentSkillsBattle(fixture.game,
|
|
{ callers: [fixture.opponent], return: fixture.returns });
|
|
assert.equal(done.lastBattleContext?.result, 'traitor');
|
|
assert.equal(done.lastBattleContext?.winner, fixture.opponent);
|
|
assert.deepEqual(owner(fixture, done).forces, {}, 'the canceled return left no phantom counters');
|
|
assert.equal(owner(fixture, done).reserves, 13, 'twenty reserves, minus four shipped and the fee; nothing returned');
|
|
assert.equal(owner(fixture, done).tanks, 7, 'the fee plus the four counters left in Pasty Mesa');
|
|
assert.deepEqual(opponent(fixture, done).forces, { [fixture.second]: 5 });
|
|
assert.equal(opponent(fixture, done).tanks, 0);
|
|
assert.equal(opponent(fixture, done).spice, 18, 'five spice, the eight-spice shipment and the five-strength bounty');
|
|
assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries,
|
|
[{ card: REINFORCEMENTS_CARD, owner: fixture.owner }, { card: HARASS_WITHDRAW_CARD, owner: fixture.owner }]);
|
|
assertIndependentSkillsBattleCustody(done);
|
|
}
|
|
});
|
|
|
|
void test('normal Bureaucrat remains available in independent all14 play and diverts a real bribe without advancing its escrow', () => {
|
|
const f = createIndependentSkillsBattleSetup({
|
|
skills: { owner: 'planetologist', opponent: 'warmaster', observer: 'bureaucrat' },
|
|
});
|
|
let game = advanceIndependentSkillsBattle(f.afterSetup,
|
|
state => state.phase === 4 && independentSkillsBattleClean(state));
|
|
const payer = independentSkillsBattlePlayer(game, f.owner).spice;
|
|
const payee = independentSkillsBattlePlayer(game, f.opponent).spice;
|
|
game = applyAction(game, f.owner, { type: 'bribe', target: f.opponent, amount: 5 });
|
|
const offer = viewGame(game, f.observer).bureaucrat!.pending!;
|
|
assert.ok(offer);
|
|
game = applyAction(game, f.observer, { type: 'decision', event: offer.event, redirect: true });
|
|
assert.equal(independentSkillsBattlePlayer(game, f.owner).spice, payer - 5);
|
|
assert.equal(independentSkillsBattlePlayer(game, f.opponent).spice, payee);
|
|
assert.equal(independentSkillsBattlePlayer(game, f.opponent).bribes, 3);
|
|
assert.equal(viewGame(game, f.observer).bureaucrat!.usedThisPhase, true);
|
|
});
|
|
|
|
void test('earned Advanced Arrakeen funds support without personal spice, changing neither the fee, the real Suk commitment nor mandatory disposal', () => {
|
|
const f = createIndependentSkillsBattleFixture({ advanced: true, tech: true, strongholds: true, case: 'suk' });
|
|
assert.equal(f.afterFirstMentat!.strongholdCards!.claimedTurn, 1);
|
|
assert.equal(f.afterFirstMentat!.strongholdCards!.owners.arrakeen, f.owner);
|
|
assert.equal(owner(f, f.afterFirstMentat!).forces[f.first], 4, 'The original paid shipment earned and held the card');
|
|
assert.equal(owner(f, f.beforePlans).spice, 0, 'A real bribe emptied the wallet before support was sealed');
|
|
assert.equal(f.plans[0].action.support, 2);
|
|
assert.throws(() => applyAction(structuredClone(f.beforePlans), f.owner,
|
|
{ ...f.plans[0].action, dial: 3, support: 3 }),
|
|
'The funded two-spice support budget cannot become a third personal spice');
|
|
const quote = quoteIndependentSkillsBattle(f.game, { returns: f.returns });
|
|
assert.deepEqual(quote.payments.find(payment => payment.player === f.owner),
|
|
{ player: f.owner, cost: 0, ownPayment: 0, allyPayment: 0, donor: null, bankSupport: 2, freeByTraitor: false });
|
|
assert.deepEqual(quote.reinforcements,
|
|
[{ player: f.owner, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
|
|
assert.deepEqual(quote.harassWithdraw?.[0].returned, { normal: 2, elite: 0 });
|
|
const fee = finishIndependentSkillsBattle(f.game,
|
|
{ return: f.returns, until: state => state.decision?.kind === 'sukRescue' });
|
|
assert.equal(owner(f, fee).spice, 0, 'The Bank paid support directly, never to the wallet');
|
|
assert.equal(owner(f, fee).reserves, 15);
|
|
assert.equal(owner(f, fee).tanks, 3, 'The three physical Reinforcements fee counters are not rescue income');
|
|
assert.deepEqual(owner(f, fee).forces, { [f.first]: 2 }, 'Only the two dialed casualties remain after Harass');
|
|
assert.equal(fee.techTokens!.axlotl.spice, 0, 'A physical fee and Harass return are not a Revival-phase income trigger');
|
|
assert.equal(fee.decision?.kind, 'sukRescue');
|
|
const rescue = fee.decision!;
|
|
const options = rescue.kind === 'sukRescue' ? rescue.options : [];
|
|
assert.equal(Math.max(...options.map(option => option.normal + option.elite)), 2,
|
|
'Suk can rescue the committed casualties, never the three-counter fee');
|
|
const done = finishIndependentSkillsBattle(fee, { return: f.returns });
|
|
assert.equal(done.turn, 2);
|
|
assert.equal(done.phase, 7);
|
|
assert.equal(done.lastBattleContext?.winner, f.owner);
|
|
assert.equal(done.lastBattleContext?.sukRescue?.completed, true);
|
|
assert.equal(done.pendingSukRescue, null);
|
|
assert.equal(owner(f, done).reserves, 16);
|
|
assert.equal(owner(f, done).tanks, 3);
|
|
assert.deepEqual(owner(f, done).forces, { [f.first]: 1 });
|
|
assert.equal(owner(f, done).spice, 2, 'Only the original Collection income arrives after the battle');
|
|
assert.equal(opponent(f, done).tanks, 5);
|
|
assert.equal(opponent(f, done).reserves, 10);
|
|
assert.deepEqual(opponent(f, done).forces, { 'tueks_sietch:5': 5 }, 'The native army was not replaced to build this battle');
|
|
const worthless = f.plans[1].action.weapon;
|
|
assert.ok(typeof worthless === 'string');
|
|
assert.deepEqual(done.lastBattleContext?.mandatoryDiscard?.entries, [{ card: worthless, owner: f.opponent }]);
|
|
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, worthless]) {
|
|
assert.equal(held(done, card).length, 1);
|
|
assert.equal(done.discard.filter(entry => entry.id === card).length, 1);
|
|
}
|
|
assertIndependentSkillsBattleCustody(done);
|
|
});
|