Files
Dune/tests/ix-independent-skills-runtime.test.ts
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

186 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
advanceIxIndependentSkills, assertIxIndependentSkillsCustody, createIxIndependentSkillsFixture,
createIxIndependentSkillsRevivalFixture, finishIxIndependentSkillsBattle,
ixIndependentSkillsClean, ixIndependentSkillsPlayer as player, ixIndependentSkillsPolicyActions,
quoteIxIndependentSkillsBattle,
revealIxIndependentSkillsBattle, stepIxIndependentSkills,
} from './fixture-ix-independent-skills';
void test('original Ix deal and paid shipment preserve Cyborg/Suboid custody through the typed Harass commitment', () => {
for (const advanced of [false, true]) {
const f = createIxIndependentSkillsFixture({ advanced });
assert.equal(f.beforeIxCard.setupStage, 'skillTreachery');
assert.ok(f.beforeIxCard.players.every(p => p.hand.length === 0));
assert.equal(f.beforeIxCard.ixSetupCards!.length, 3);
assert.equal(f.offered.setupStage, 'leaderSkills');
assert.equal(player(f.offered, f.ixians).hand[0].id, f.ixCard.action.card);
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.ixians)!.skill, 'suk-graduate');
const before = player(f.beforeShipment, f.ixians), shipped = player(f.afterShipment, f.ixians);
assert.equal(before.reserves, 14); assert.equal(before.elites!.reserves, 4);
assert.equal(shipped.reserves, 10); assert.equal(shipped.elites!.reserves, 2);
assert.equal(shipped.spice, before.spice - 8, 'four desert shipments cost two spice per physical force');
assert.equal(player(f.afterShipment, f.opponent).spice, player(f.beforeShipment, f.opponent).spice + 8);
assert.equal(shipped.forces[f.first], 4); assert.equal(shipped.elites!.forces[f.first], 2);
const moved = player(f.afterMove, f.ixians);
for (const key of [f.first, f.second]) {
assert.equal(moved.forces[key], 2); assert.equal(moved.elites!.forces[key], 1);
}
const revealed = revealIxIndependentSkillsBattle(f), quote = quoteIxIndependentSkillsBattle(f, revealed);
assert.deepEqual(quote.reinforcements,
[{ player: f.ixians, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.scores, { attacker: advanced ? 8.5 : 9.5, defender: 1 },
'Suboids always fight at half strength; unsupported Advanced Cyborgs are half their Basic strength');
const withdrawal = quote.harassWithdraw![0];
assert.deepEqual(withdrawal.returned, { normal: 1, elite: 1 });
assert.deepEqual(withdrawal.locations, f.returns);
assert.equal(withdrawal.remaining.normal, 1); assert.equal(withdrawal.remaining.elite, 1);
assert.equal(revealed.decision?.kind, 'harassWithdraw');
if (revealed.decision?.kind !== 'harassWithdraw') throw Error('Missing original typed withdrawal control');
const event = revealed.decision.event;
assert.throws(() => stepIxIndependentSkills(revealed, { actor: f.ixians,
action: { type: 'decision', event,
returns: { [f.first]: { normal: 2, elite: 0 } } } }), /undialed|physical|return|allocation/i);
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
const p = player(rescue, f.ixians);
assert.equal(p.reserves, 9); assert.equal(p.elites!.reserves, 3);
assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0, 'only Suboids paid the reserve fee');
assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1);
assert.equal(p.forces[f.second], 1); assert.equal(p.elites!.forces[f.second] ?? 0, 0);
assertIxIndependentSkillsCustody(rescue);
}
});
void test('the real trained Suk rescues a committed Cyborg and Suboid without refunding reserve fees', () => {
for (const advanced of [false, true]) {
const f = createIxIndependentSkillsFixture({ advanced });
const rescue = finishIxIndependentSkillsBattle(f, { stop: 'rescue' });
assert.equal(rescue.decision?.kind, 'sukRescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original native rescue controls');
assert.equal(rescue.decision.mode, 'skilled');
assert.ok(rescue.decision.options.some(o => o.normal === 1 && o.elite === 1 &&
o.kept?.kind === 'elite' && o.kept.key === f.first));
const saved = finishIxIndependentSkillsBattle(f, { state: rescue });
assert.equal(saved.lastBattleContext!.winner, f.ixians);
assert.equal(saved.lastBattleContext!.sukRescue!.completed, true);
const p = player(saved, f.ixians);
assert.equal(p.reserves, 10); assert.equal(p.elites!.reserves, 3);
assert.equal(p.tanks, 3); assert.equal(p.elites!.tanks, 0);
assert.equal(p.forces[f.first], 1); assert.equal(p.elites!.forces[f.first], 1);
assert.equal(p.forces[f.second] ?? 0, 0);
assert.equal(p.spice, 2, 'unsupported Cyborgs do not invent a support charge');
assert.equal(player(saved, f.opponent).tanks, 5);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(saved.discard.filter(c => c.id === id).length, 1);
assertIxIndependentSkillsCustody(saved);
const lost = finishIxIndependentSkillsBattle(f, { state: rescue, rescue: 'none' });
assert.equal(player(lost, f.ixians).reserves, 9);
assert.equal(player(lost, f.ixians).elites!.reserves, 3);
assert.equal(player(lost, f.ixians).tanks, 5);
assert.equal(player(lost, f.ixians).elites!.tanks, 1);
assert.equal(player(lost, f.ixians).forces[f.first] ?? 0, 0);
assertIxIndependentSkillsCustody(lost);
}
});
void test('Recruits counts prior native free returns, preserves the cap and charges the paid Cyborg surcharge', () => {
for (const advanced of [false, true]) {
const f = createIxIndependentSkillsRevivalFixture({ advanced });
const original = viewGame(f.beforeRecruits, f.ixians).revival;
assert.equal(original.limit, 3); assert.equal(original.freeRemaining, 1);
assert.equal(player(f.beforeRecruits, f.ixians).tanks, 5);
assert.equal(player(f.beforeRecruits, f.ixians).elites!.tanks, 1);
assert.equal(player(f.beforeRecruits, f.ixians).spice, 5, 'real turn-one escrow settled at the real Mentat Pause');
assert.equal(viewGame(f.beforePlay, f.ixians).revival.freeRemaining, 0);
assert.equal(player(f.beforePlay, f.ixians).spice, player(f.beforeRecruits, f.ixians).spice);
assert.equal(player(f.beforePlay, f.tleilaxu).spice, player(f.beforeRecruits, f.tleilaxu).spice + 1,
'native Tleilaxu receive one spice from the Bank for the real free revival');
const active = viewGame(f.beforeRevival, f.ixians).revival;
assert.equal(active.limit, 7); assert.equal(active.freeRemaining, 1);
assert.equal(viewGame(f.beforeRevival, f.tleilaxu).revival.limit, 20,
'the global card never reduces native unlimited revival to seven');
assert.deepEqual(f.price, { free: 1, normalCost: 5, cost: 5 },
'one free Suboid plus one paid Suboid at two and one paid Cyborg at three');
const before = player(f.beforeRevival, f.ixians), after = player(f.afterRevival, f.ixians);
assert.equal(after.spice, before.spice - 5);
assert.equal(player(f.afterRevival, f.tleilaxu).spice, player(f.beforeRevival, f.tleilaxu).spice + 5,
'the actual paid native revival reaches Tleilaxu, not an invented Bank sink');
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.elites!.tanks, 0);
assert.equal(after.revived, 4); assert.equal(after.freeForcesRevived, 2);
assert.equal(f.afterRevival.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1);
assert.equal(viewGame(f.afterRevival, f.ixians).revival.freeRemaining, 0);
assertIxIndependentSkillsCustody(f.afterRevival);
}
});
void test('all four minimal policies can seal native paid plans and execute projected typed withdrawal and rescue controls', () => {
const f = createIxIndependentSkillsFixture({ advanced: true });
const revealed = revealIxIndependentSkillsBattle(f);
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
for (const difficulty of DIFFICULTIES) {
const plan = ixIndependentSkillsPolicyActions(f.game, f.ixians, difficulty).find(a => a.type === 'battlePlan'); assert.ok(plan);
const sealed = stepIxIndependentSkills(f.game, { actor: f.ixians, action: plan });
assert.equal(player(sealed, f.ixians).reserves, player(f.game, f.ixians).reserves,
'sealing alone cannot spend the fee or dial physical counters');
assert.equal(player(sealed, f.ixians).spice, player(f.game, f.ixians).spice);
assert.equal(player(sealed, f.ixians).tanks, 0);
const returns = ixIndependentSkillsPolicyActions(revealed, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(returns);
const allocated = stepIxIndependentSkills(revealed, { actor: f.ixians, action: returns });
const committed = finishIxIndependentSkillsBattle(f, { state: allocated, stop: 'rescue' });
assert.equal(player(committed, f.ixians).reserves, 9);
assert.equal(player(committed, f.ixians).elites!.reserves, 3);
assert.equal(player(committed, f.ixians).tanks, 3);
const choice = ixIndependentSkillsPolicyActions(rescue, f.ixians, difficulty).find(a => a.type === 'decision'); assert.ok(choice);
const saved = stepIxIndependentSkills(rescue, { actor: f.ixians, action: choice });
const done = advanceIxIndependentSkills(saved, g => g.phase === 7 && ixIndependentSkillsClean(g));
assert.equal(player(done, f.ixians).tanks, 3, 'policy rescue saves the commitment, never the fee');
assert.equal(player(done, f.ixians).elites!.tanks, 0);
assertIxIndependentSkillsCustody(done);
}
});
void test('original native Tech and Stronghold phases keep the typed Suboid fee outside Suk and carry the winner into paid Recruits', () => {
const f = createIxIndependentSkillsRevivalFixture({ advanced: true, tech: true, strongholds: true });
const revealed = revealIxIndependentSkillsBattle(f);
const quote = quoteIxIndependentSkillsBattle(f, revealed);
assert.deepEqual(quote.reinforcements,
[{ player: f.ixians, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].locations, f.returns);
const rescue = finishIxIndependentSkillsBattle(f, { state: revealed, stop: 'rescue' });
const initial = player(f.game, f.ixians), charged = player(rescue, f.ixians);
assert.equal(charged.reserves, initial.reserves - 3 + 2,
'Three actual Suboids pay before the physical Suboid and Cyborg return');
assert.equal(charged.elites!.reserves, initial.elites!.reserves + 1);
assert.equal(charged.tanks, 3);
assert.equal(charged.elites!.tanks, 0);
const saved = finishIxIndependentSkillsBattle(f, { state: rescue });
assert.equal(saved.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(saved, f.ixians).tanks, 3, 'Suk cannot rescue the reserve fee');
assert.equal(player(saved, f.ixians).elites!.tanks, 0);
assert.equal(f.afterBattle.techTokens!.production.owner, f.ixians,
'The real winner takes the original unowned token, not a staged claim');
assert.equal(f.beforeRevival.strongholdCards!.claimedTurn, 1);
assert.equal(f.beforeRevival.strongholdCards!.owners.hidden_mobile_stronghold, f.ixians);
assert.deepEqual(f.price, { free: 1, normalCost: 5, cost: 5 });
const before = player(f.beforeRevival, f.ixians), after = player(f.afterRevival, f.ixians);
assert.equal(after.spice, before.spice - 5);
assert.equal(player(f.afterRevival, f.tleilaxu).spice, player(f.beforeRevival, f.tleilaxu).spice + 5,
'Native paid Cyborg/Suboid revival still pays the real Tleilaxu recipient');
assert.equal(after.reserves, before.reserves + 3);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.tanks, before.tanks - 3);
assert.equal(after.elites!.tanks, 0);
assert.equal(viewGame(f.afterRevival, f.ixians).revival.freeRemaining, 0);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, RECRUITS_CARD_ID])
assert.equal(f.afterRevival.discard.filter(card => card.id === id).length, 1);
assertIxIndependentSkillsCustody(saved);
assertIxIndependentSkillsCustody(f.afterRevival);
});