Files
Dune/tests/choam-independent-skills-runtime.test.ts
T
Quintine b9330d489e Compose independent Skills with original Tech and Stronghold modules
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.
2026-10-17 22:12:44 +11:00

213 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 { forceRevivalLimit, forceRevivalQuote } from '../game/revival';
import type { BattleResolutionQuote } from '../game/battle-resolution-quote';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
import {
assertChoamIndependentSkillsCustody, choamIndependentSkillsPlayer,
createChoamIndependentSkillsFixture, createChoamIndependentSkillsHeldSupportFixture,
finishChoamIndependentSkillsBattle, openChoamIndependentSkillsBattle,
sealChoamIndependentSkillsBoth, type ChoamIndependentSkillsFixture,
} from './fixture-choam-independent-skills';
const owner = (fixture: ChoamIndependentSkillsFixture, game: Game) =>
choamIndependentSkillsPlayer(game, fixture.richese);
const rival = (fixture: ChoamIndependentSkillsFixture, game: Game) =>
choamIndependentSkillsPlayer(game, fixture.opponent);
/** The quote side belongs to whichever combatant the order-based label names. */
const side = (game: Game, actor: string) =>
game.battle!.attacker === actor ? 'attacker' as const : 'defender' as const;
const bonusOf = (game: Game, quote: BattleResolutionQuote, actor: string) =>
quote.leaderSkillBonuses[side(game, actor)];
const scoreOf = (game: Game, quote: BattleResolutionQuote, actor: string) => {
assert.ok(quote.scores, 'A resolved battle carries totals');
return quote.scores[side(game, actor)];
};
const held = (game: Game, id: string) =>
[...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand)]
.filter((card) => card.id === id);
for (const advanced of [false, true] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} CHOAM/Richese independent all14 funds Reinforcements after the concealed marker materializes and returns the real undialed army`, () => {
const fixture = createChoamIndependentSkillsFixture(advanced ? { advanced: true } : {});
// The independent three-card marker rides the selected E2 ordinary deck, and
// Richese keeps its own separate ten-card cache alongside all fourteen skills.
assert.equal(fixture.offered.richeseCache!.length, 10);
// Live plan window: the preview already forecasts the capped materialization
// and the three reserves the fee must find afterwards.
const open = openChoamIndependentSkillsBattle(fixture);
assert.deepEqual(open.battle!.plans, {});
assert.equal(owner(fixture, open).reserves, advanced ? 20 : 6);
const preview = viewGame(open, fixture.richese).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.equal(preview.noField!.materializes, 3);
assert.equal(preview.noField!.reservesAfterReveal, advanced ? 17 : 3);
assert.equal(preview.normal, 3);
const withdraw = viewGame(open, fixture.richese).battle!.harassWithdraw!;
assert.equal(withdraw.blocked, null);
assert.equal(withdraw.forces.normal, 3);
assert.equal(withdraw.forces.elite, 0);
assert.deepEqual(withdraw.locations, { [fixture.location]: { normal: 3, elite: 0 } });
// The reveal materializes the capped physical counters; the fee follows them.
const revealed = sealChoamIndependentSkillsBoth(fixture, 'both', open);
assert.equal(revealed.battle!.revealed, true);
const shown = owner(fixture, revealed);
assert.equal(shown.forces[fixture.location], 3);
assert.equal(shown.reserves, advanced ? 17 : 3);
assert.equal(shown.tanks, 0);
assert.equal(shown.noField!.deployed, null);
assert.equal(shown.noField!.lastShipped, fixture.token.id);
assert.equal(new Set(shown.noField!.tokens.map((token) => token.id)).size, 3);
// The revealed totals: dial + Reinforcements + trained Mentat + physical leader.
const quote = quoteIndependentSkillsBattle(revealed, { returns: fixture.returns });
assert.deepEqual(quote.reinforcements,
[{ player: fixture.richese, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw?.map((entry) => ({ returned: entry.returned, locations: entry.locations })),
[{ returned: { normal: advanced ? 2 : 1, elite: 0 },
locations: { [fixture.location]: { normal: advanced ? 2 : 1, elite: 0 } } }]);
assert.deepEqual(bonusOf(revealed, quote, fixture.richese),
{ bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] });
assert.deepEqual(bonusOf(revealed, quote, fixture.opponent), { bonus: 0, applied: [] });
assert.equal(scoreOf(revealed, quote, fixture.richese) - scoreOf(revealed, quote, fixture.opponent), 1);
// The whole undialed army lies in the marker's own sector, so the native
// return is unambiguous and needs no typed allocation.
const spice = owner(fixture, revealed).spice;
const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns });
assert.equal(done.lastBattleContext?.winner, fixture.richese);
assert.equal(done.lastBattleContext?.result, 'normal');
const after = owner(fixture, done);
assert.equal(after.reserves, advanced ? 16 : 1,
'Revealed reserves, minus the three-reserve fee, plus the returned undialed counter');
assert.equal(after.tanks, advanced ? 4 : 5);
assert.deepEqual(after.forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 });
assert.equal(after.spice, spice - Number(fixture.plans.both.action.support ?? 0));
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.ok(done.players.every((player) => !player.hand.some((face) => face.id === card)), card);
}
assertChoamIndependentSkillsCustody(fixture, done);
// The same physical dial without Reinforcements loses: its +2 score,
// rather than another force or support payment, supplies the winning margin.
const plain = finishChoamIndependentSkillsBattle(
sealChoamIndependentSkillsBoth(fixture, 'plain', open), { return: fixture.returns });
assert.equal(plain.lastBattleContext?.winner, fixture.opponent);
assert.equal(owner(fixture, plain).reserves, advanced ? 17 : 3);
assert.equal(owner(fixture, plain).tanks, 3);
assert.deepEqual(owner(fixture, plain).forces, advanced ? {} : { 'broken_land:12': 5, 'carthag:11': 9 });
assertChoamIndependentSkillsCustody(fixture, plain);
});
void test('A reserve-capped marker five at three reserves refuses the funded plan before the reveal and still plays the real Harass fallback', () => {
const fixture = createChoamIndependentSkillsFixture({ marker: 5, reserves: 3, stopBeforePlans: true });
const preview = viewGame(fixture.beforePlans, fixture.richese).battle!.reinforcements!;
assert.equal(preview.noField!.materializes, 3, 'The reveal is capped by the physical reserves');
assert.equal(preview.noField!.reservesAfterReveal, 0);
assert.equal(preview.normal + preview.elite, 0);
const saved = structuredClone(fixture.beforePlans);
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
assert.throws(() => applyAction(fixture.beforePlans, fixture.richese, plan.action));
assert.deepEqual(fixture.beforePlans, saved, 'A refused plan reserves no counter, card or window');
}
const revealed = sealChoamIndependentSkillsBoth(fixture, 'harass');
assert.equal(revealed.battle!.revealed, true);
const shown = owner(fixture, revealed);
assert.equal(shown.forces[fixture.location], 3, 'The capped marker still materializes its three counters');
assert.equal(shown.reserves, 0);
assert.equal(shown.tanks, 0);
const done = finishChoamIndependentSkillsBattle(revealed, { return: fixture.returns });
assert.equal(done.lastBattleContext?.winner, fixture.opponent);
const after = owner(fixture, done);
assert.equal(after.reserves, 1, 'Only the undialed counter returned; no fee was charged');
assert.equal(after.tanks, 2, 'The two dialed counters reached the Tanks');
assert.deepEqual(after.forces, { 'broken_land:12': 5, 'carthag:11': 12 });
assertChoamIndependentSkillsCustody(fixture, done);
});
void test('The independent CHOAM/Richese setup keeps the separate cache and each seat printed revival exception', () => {
const fixture = createChoamIndependentSkillsFixture({});
assert.equal(fixture.offered.richeseCache!.length, 10);
assert.ok(fixture.offered.richeseCache!.every((card) => card.id.startsWith('richese-')),
'The cache holds only Richese faces, never ordinary deck cards');
for (const card of fixture.offered.richeseCache!)
assert.ok(!fixture.offered.deck.some((face) => face.id === card.id),
`${card.id} stays out of the ordinary deck`);
const choam = choamIndependentSkillsPlayer(fixture.afterSetup, fixture.choam);
assert.equal(forceRevivalLimit(fixture.afterSetup, choam), 20);
assert.deepEqual(forceRevivalQuote(fixture.afterSetup, choam, 5),
{ free: 0, normalCost: 10, cost: 5 },
'CHOAM pays one spice per returned force with no free revival and no normal limit');
for (const actor of [fixture.richese, fixture.classic]) {
const seat = choamIndependentSkillsPlayer(fixture.afterSetup, actor);
assert.equal(forceRevivalLimit(fixture.afterSetup, seat), 3);
assert.deepEqual(forceRevivalQuote(fixture.afterSetup, seat, 5),
{ free: 2, normalCost: 6, cost: 6 });
}
const done = finishChoamIndependentSkillsBattle(
sealChoamIndependentSkillsBoth(fixture, 'both'), { return: fixture.returns });
assertChoamIndependentSkillsCustody(fixture, done);
// Every seat keeps its own counters and the printed hand limit end to end.
for (const player of done.players) {
const pool = player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, count) => sum + count, 0);
assert.equal(pool, 20, player.faction);
}
assert.equal(rival(fixture, done).tanks, rival(fixture, fixture.beforePlans).tanks +
(rival(fixture, fixture.beforePlans).forces[fixture.location] ?? 0),
'The defeated garrison, not armies elsewhere, reached the Tanks');
});
void test('the first real Richese Mentat claim supports a funded second marker without paying or refunding its reserve fee', () => {
const f = createChoamIndependentSkillsHeldSupportFixture({ tech: true, strongholds: true });
const { first } = f;
assert.equal(first.offered.richeseCache!.length, 10);
assert.notEqual(first.trainers[first.choam], 'choam-auditor');
assert.equal(f.firstAfterBattle.strongholdCards!.owners.arrakeen, null);
assert.equal(owner(first, f.firstAfterBattle).forces[first.location], 2,
'The first machine winner really keeps the two undialed marker counters');
assert.equal(f.afterFirstMentat.strongholdCards!.claimedTurn, 1);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, first.richese);
assert.equal(owner(first, f.afterFirstMentat).forces[first.location], 2);
const shipped = owner(first, f.afterMarkerShipment), beforeShipment = owner(first, f.beforeMarkerShipment);
assert.equal(shipped.reserves, beforeShipment.reserves,
'The different concealed token does not borrow physical counters before reveal');
assert.notEqual(shipped.noField!.lastShipped, owner(first, first.afterShipment).noField!.lastShipped,
'The original same-token consecutive-shipment limit remains in force');
assert.equal(owner(first, f.beforePlans).forces[first.location], 2);
assert.equal(rival(first, f.beforePlans).reserves, rival(first, f.beforeEnemyShipment).reserves - 4);
assert.equal(rival(first, f.beforePlans).spice, rival(first, f.beforeEnemyShipment).spice - 4,
'The returning enemy army paid its real four-force stronghold invoice');
const preview = viewGame(f.beforePlans, first.richese).battle!;
assert.equal(preview.strongholdEffects![first.richese], 'arrakeen');
assert.equal(preview.reinforcements!.noField!.materializes, 5);
assert.equal(preview.reinforcements!.noField!.reservesAfterReveal, 9);
assert.equal(preview.reinforcements!.normal, 3);
assert.equal(owner(first, f.revealed).forces[first.location], 7);
assert.equal(owner(first, f.revealed).reserves, 9);
const quote = quoteIndependentSkillsBattle(f.revealed, { returns: f.returns });
const payment = quote.payments.find(entry => entry.player === first.richese)!;
assert.equal(payment.bankSupport, 2);
assert.equal(payment.ownPayment, 0);
assert.equal(payment.cost, 0);
assert.deepEqual(bonusOf(f.revealed, quote, first.richese),
{ bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] });
assert.deepEqual(quote.reinforcements,
[{ player: first.richese, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.equal(quote.auditor, null, 'The native Auditor never becomes an ordinary trained battle leader');
const after = owner(first, f.afterBattle), revealed = owner(first, f.revealed);
assert.equal(f.afterBattle.lastBattleContext!.winner, first.richese);
assert.equal(after.spice, revealed.spice, 'Held bank support is a discount, never personal income');
assert.equal(after.reserves, revealed.reserves - 3 + 5,
'The three-reserve card fee is still paid before the five real undialed forces return');
assert.equal(after.tanks, revealed.tanks + 3 + 2,
'Free support neither funds extra reserve counters nor rescues the fee from Tanks');
assert.equal(after.forces[first.location] ?? 0, 0);
assert.equal(rival(first, f.afterBattle).tanks, rival(first, f.revealed).tanks + 4);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(f.afterBattle.discard.filter(card => card.id === id).length, 1);
assertChoamIndependentSkillsCustody(first, f.afterBattle);
});