247 lines
17 KiB
TypeScript
247 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||
import type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
|
||
import { quoteSukPhysicalRescue } from '../game/suk-graduate';
|
||
import { strongholdBenefit } from '../game/stronghold-cards';
|
||
import { ownedTech } from '../game/tech-tokens';
|
||
import { classicHomeworldSkillsBotOffers } from './fixture-homeworld-classic-skills';
|
||
import { homeworldSkillsDiscoveryRescueChoice, homeworldSkillsDiscoverySplitReturn } from './fixture-homeworld-skills-discovery';
|
||
import {
|
||
advanceClassicHomeworldNexusSkills as advance,
|
||
assertClassicHomeworldNexusSkillsInventory as inventory,
|
||
classicHomeworldNexusSkillsClean as clean,
|
||
classicHomeworldNexusSkillsPlayer as player,
|
||
classicHomeworldNexusSkillsPool as pool,
|
||
classicHomeworldNexusSkillsRevivalWindow as revivalWindow,
|
||
createClassicHomeworldNexusSkillsFixture as fixture,
|
||
createClassicHomeworldNexusSkillsLobby as lobby,
|
||
revealClassicHomeworldNexusSkillsBattle as reveal,
|
||
settleClassicHomeworldNexusSkillsBattle as settle,
|
||
} from './fixture-homeworld-classic-nexus-skills';
|
||
|
||
/** Root physical PDF21–23 authorizes original variant composition. These are
|
||
* actual original phase programmes, not a configuration matrix. External Suk
|
||
* allocations use the existing placement interpretation; printed normal revival
|
||
* is separately asserted to return Sardaukar to Salusa, never that Suk split. */
|
||
function legalPolicies(game: Game, actor: string): void {
|
||
for (const difficulty of DIFFICULTIES) {
|
||
const actions = classicHomeworldSkillsBotOffers(game, actor, difficulty);
|
||
assert.ok(actions.length, `${difficulty} needs a real owned continuation.`);
|
||
inventory(applyAction(structuredClone(game), actor, actions[0]));
|
||
}
|
||
}
|
||
function reject(game: Game, actor: string, action: Action): void {
|
||
const before = structuredClone(game);
|
||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||
}
|
||
function physicalContext(game: Game): HomeworldCombatLossContext {
|
||
return { advanced: game.advanced, players: game.players.map(p => {
|
||
const total = Object.values(p.forces).reduce((a, b) => a + b, 0);
|
||
const elite = Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0);
|
||
return { id: p.id, faction: p.faction, reserves: p.reserves, eliteReserves: p.elites?.reserves ?? 0,
|
||
tanks: p.tanks, eliteTanks: p.elites?.tanks ?? 0, battleLosses: p.battleLosses,
|
||
boardForces: { normal: total - elite, elite } };
|
||
}) };
|
||
}
|
||
/** Original loser played-card cleanup must already be committed before the
|
||
* winner is asked to choose Tech. No synthetic winner receipt is introduced. */
|
||
function finishWinnerTech(state: Game): Game {
|
||
if (state.decision?.kind !== 'techToken') { inventory(state); return state; }
|
||
const decision = state.decision, before = structuredClone(state.techTokens);
|
||
legalPolicies(state, decision.player);
|
||
assert.equal(state.battle, null, 'Original battle disposal precedes the reward choice.');
|
||
assert.equal(state.pendingSukRescue ?? null, null);
|
||
assert.equal(state.pendingWinnerDiscards ?? null, null);
|
||
for (const entry of state.lastBattleContext!.mandatoryDiscard?.entries ?? [])
|
||
assert.ok(state.discard.some(card => card.id === entry.card), 'Original played-card cleanup precedes mandatory winner Tech.');
|
||
const token = decision.choices[0], action: Action = { type: 'decision', token };
|
||
reject(state, decision.loser, action);
|
||
reject(state, decision.player, { type: 'decision', decline: true });
|
||
const done = settle(applyAction(JSON.parse(JSON.stringify(state)), decision.player, action));
|
||
assert.equal(done.techTokens![token].owner, decision.player);
|
||
for (const [id, old] of Object.entries(before!)) if (id !== token)
|
||
assert.deepEqual(done.techTokens![id as keyof NonNullable<typeof before>], old);
|
||
inventory(done); return done;
|
||
}
|
||
|
||
void test('genuine worm/alliance closing draw precedes a paid native withdrawal, local support and one original Shipping-end industry payment', () => {
|
||
const original = lobby({ advanced: true, discovery: true, tech: true, strongholds: true });
|
||
const f = fixture({ initial: original, kind: 'visitor-suk' });
|
||
assert.equal(player(f.beforeClosingDraw, f.owner).ally, null);
|
||
legalPolicies(f.beforeClosingDraw, f.owner);
|
||
reject(f.beforeClosingDraw, f.beforeClosingDraw.players.find(p => p.ally)!.id, f.closingDraw.action);
|
||
reject(f.beforeClosingDraw, f.owner, { ...f.closingDraw.action, turn: f.beforeClosingDraw.turn - 1 });
|
||
assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.owner], null);
|
||
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.owner], 'fremen');
|
||
assert.ok(!f.afterClosingDraw.nexusCards!.cards!.deck.includes('fremen'));
|
||
assert.equal(f.afterClosingDraw.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 0);
|
||
legalPolicies(f.beforeShipment, f.owner);
|
||
const before = player(f.beforeShipment, f.owner), after = player(f.afterShipment, f.owner);
|
||
assert.equal(after.reserves, before.reserves - 4);
|
||
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
|
||
assert.equal(after.spice, before.spice - 4, 'One original Homeworld invasion invoice, not Arrakis or doubled payment.');
|
||
assert.deepEqual(pool(f.afterShipment, f.owner, f.territory), { normal: 3, elite: 1 });
|
||
assert.deepEqual(pool(f.beforeShipment, f.owner, 'homeworld:emperor'), {
|
||
normal: pool(f.afterShipment, f.owner, 'homeworld:emperor').normal + 3, elite: 0,
|
||
});
|
||
assert.equal(pool(f.afterShipment, f.owner, 'homeworld:emperor:salusa').elite,
|
||
pool(f.beforeShipment, f.owner, 'homeworld:emperor:salusa').elite - 1);
|
||
const token = f.beforeShippingEnd.techTokens!.heighliners;
|
||
assert.equal(token.spice, 1); assert.equal(token.triggeredTurn, 2);
|
||
legalPolicies(f.beforeShippingEnd, f.shippingEnd.actor);
|
||
assert.equal(player(f.afterShippingEnd, token.owner!).spice, player(f.beforeShippingEnd, token.owner!).spice + token.spice);
|
||
assert.equal(f.afterShippingEnd.techTokens!.heighliners.spice, 0);
|
||
assert.equal(f.afterShippingEnd.techTokens!.axlotl.spice, 0);
|
||
assert.equal(f.afterShippingEnd.techTokens!.production.spice, 0);
|
||
assert.equal(f.game.strongholdCards!.owners.tueks_sietch, f.opponent, 'Original Guild board occupation earned the retained public card at first Mentat.');
|
||
assert.equal(strongholdBenefit(f.game.strongholdCards, f.opponent, f.territory), null, 'Held Tuek support is source-local, not a benefit on Guild Homeworld.');
|
||
for (const actor of [f.owner, f.opponent]) legalPolicies(f.game, actor);
|
||
const changed = { ...f, plans: f.plans.map(step => step.actor === f.opponent
|
||
? { actor: step.actor, action: { ...step.action, dial: 2, support: 1 } } : step) };
|
||
const revealed = reveal(changed), pending = settle(revealed, 'suk');
|
||
assert.equal(player(pending, f.owner).spice, player(f.game, f.owner).spice - 2,
|
||
'The invading army pays its actual support before Suk rescue.');
|
||
assert.equal(player(pending, f.opponent).spice, player(f.game, f.opponent).spice - 1,
|
||
'The free native battle-strength bonus does not waive ordinary support or borrow the held Tuek subsidy.');
|
||
inventory(pending);
|
||
});
|
||
|
||
for (const kind of ['native-suk', 'visitor-suk'] as const) {
|
||
void test(`Advanced original ${kind} saves its actual typed source under HW/Nexus/DS/Tech/Stronghold composition`, () => {
|
||
const f = fixture({ advanced: true, discovery: true, tech: true, strongholds: true, kind });
|
||
const revealed = reveal(f), pending = settle(revealed, 'suk');
|
||
assert.deepEqual(pending.techTokens, revealed.techTokens, 'Real winner Tech ownership cannot move ahead of the owned Suk cleanup.');
|
||
reject(f.game, f.owner, { type: 'battlePlan', leader: f.trainer,
|
||
dial: kind === 'native-suk' ? 12 : 8, support: 0 });
|
||
assert.ok(pending.decision?.kind === 'sukRescue');
|
||
const decision = pending.decision, receipt = pending.pendingSukRescue!;
|
||
const choice = homeworldSkillsDiscoveryRescueChoice(pending, kind === 'native-suk' ? 'elite' : 'normal');
|
||
const option = decision.options[choice], destinations = homeworldSkillsDiscoverySplitReturn(pending, choice);
|
||
assert.equal(receipt.territory, f.territory); assert.equal(receipt.skill.mode, 'skilled');
|
||
assert.equal(receipt.pool.length, 1); assert.equal(receipt.pool[0].key, f.territory);
|
||
assert.equal(option.normal + option.elite, 3);
|
||
const physical = quoteSukPhysicalRescue(physicalContext(pending), pending.homeworlds!.custody!, {
|
||
player: f.owner, territory: f.territory, skill: receipt.skill, pool: receipt.pool,
|
||
losses: receipt.losses!, option, destinations,
|
||
});
|
||
assert.equal(physical.receipt.source, kind === 'native-suk' ? 'native-homeworld' : 'visitor-homeworld');
|
||
legalPolicies(pending, f.owner);
|
||
const action: Action = { type: 'decision', event: decision.event, choice, ...(destinations ? { destinations } : {}) };
|
||
reject(pending, f.opponent, action);
|
||
reject(pending, f.owner, { ...action, event: 'stale-physical-rescue' });
|
||
reject(pending, f.owner, { ...action, destinations: { 'homeworld:guild': { normal: 1, elite: 1 } } });
|
||
if (kind === 'native-suk') {
|
||
assert.equal(decision.reserveHomes, undefined);
|
||
assert.equal(receipt.losses!.normal, 0); assert.equal(receipt.losses!.elite, 3);
|
||
reject(pending, f.owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 3 } } });
|
||
} else {
|
||
assert.equal(receipt.losses!.normal, 2); assert.equal(receipt.losses!.elite, 1);
|
||
assert.deepEqual(destinations, { 'homeworld:emperor': { normal: 0, elite: 1 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
|
||
reject(pending, f.owner, { type: 'decision', event: decision.event, choice });
|
||
reject(pending, f.owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 1 } } });
|
||
}
|
||
const resolved = settle(applyAction(JSON.parse(JSON.stringify(pending)), f.owner, action));
|
||
assert.deepEqual(resolved.homeworlds!.custody, physical.custody);
|
||
assert.equal(player(resolved, f.owner).reserves, physical.players.find(p => p.id === f.owner)!.reserves);
|
||
assert.equal(player(resolved, f.owner).elites!.reserves, physical.players.find(p => p.id === f.owner)!.eliteReserves);
|
||
assert.equal(player(resolved, f.owner).tanks - player(pending, f.owner).tanks,
|
||
receipt.losses!.normal + receipt.losses!.elite - option.normal - option.elite);
|
||
assert.equal(resolved.pendingSukRescue ?? null, null);
|
||
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
|
||
assert.ok(resolved.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === f.trainer && a.skill === 'suk-graduate'));
|
||
if (kind === 'native-suk') assert.deepEqual(resolved.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa,
|
||
'Saved native Sardaukar remain at the exact native Salusa source, with no internal move.');
|
||
else assert.deepEqual(pool(resolved, f.owner, f.territory), { normal: 2, elite: 0 });
|
||
const done = finishWinnerTech(resolved);
|
||
for (const token of ownedTech(revealed.techTokens, f.opponent))
|
||
assert.equal(done.techTokens![token].owner, f.owner, 'Original losing-owner Tech transfers only after real rescue and card cleanup.');
|
||
assert.equal(done.lastBattleContext!.winner, f.owner); inventory(done);
|
||
});
|
||
}
|
||
|
||
for (const advanced of [false, true]) {
|
||
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses return through the Fremen quota and normal Homeworld revival at the next actual Revival`, () => {
|
||
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
|
||
const shipper = f.shipment.actor;
|
||
assert.equal(f.afterShipment.pendingGuildBetrayal ?? null, null);
|
||
assert.equal(player(f.afterShipment, shipper).spice, player(f.beforeShipment, shipper).spice - 4);
|
||
assert.equal(player(f.afterShipment, shipper).reserves, player(f.beforeShipment, shipper).reserves - 4);
|
||
assert.deepEqual(pool(f.afterShipment, shipper, f.territory),
|
||
{ normal: advanced ? 3 : 4, elite: advanced ? 1 : 0 });
|
||
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
|
||
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
|
||
assert.ok(choice >= 0, 'The skilled original human option may genuinely decline all rescue.');
|
||
legalPolicies(pending, f.owner);
|
||
const done = finishWinnerTech(settle(applyAction(pending, f.owner, { type: 'decision', event: pending.decision.event, choice })));
|
||
assert.equal(player(done, f.owner).tanks, advanced ? 3 : 4);
|
||
assert.equal(player(done, f.owner).elites?.tanks ?? 0, advanced ? 1 : 0);
|
||
assert.equal(done.nexusCards!.cards!.hands[f.owner], 'fremen', 'Battle rescue never spends the unrelated physically held Nexus singleton.');
|
||
const opened = revivalWindow(done, f.actions, f.staging);
|
||
if (opened.revivalOrder) assert.ok(opened.revivalOrder.remaining.includes(f.owner));
|
||
const window = advance(opened, g => g.phase === 4 && clean(g) &&
|
||
(!g.revivalOrder || g.revivalOrder.remaining[0] === f.owner), f.actions);
|
||
const offer = viewGame(window, f.owner).nexusFremenRevival!;
|
||
assert.equal(offer.blocked, null);
|
||
assert.equal(viewGame(window, f.opponent).nexusFremenRevival, null);
|
||
legalPolicies(window, f.owner);
|
||
const action: Action = { type: 'nexusFremenRevival', event: offer.event };
|
||
reject(window, f.opponent, action);
|
||
reject(window, f.owner, { ...action, event: 'stale-free-three' });
|
||
const before = structuredClone(player(window, f.owner));
|
||
const kaitain = pool(window, f.owner, 'homeworld:emperor');
|
||
const salusa = advanced ? pool(window, f.owner, 'homeworld:emperor:salusa') : null;
|
||
const granted = applyAction(JSON.parse(JSON.stringify(window)), f.owner, action);
|
||
assert.deepEqual(player(granted, f.owner), before, 'Quota activation does not move forces or consume the ordinary return ledger');
|
||
assert.deepEqual(granted.homeworlds, window.homeworlds);
|
||
assert.deepEqual(granted.techTokens, window.techTokens, 'Axlotl activity belongs to the native return, not card activation');
|
||
if (advanced) reject(granted, f.owner, { type: 'revive', amount: 3, elite: 2 });
|
||
const paid = settle(applyAction(granted, f.owner, { type: 'revive', amount: 3, elite: advanced ? 1 : 0 }));
|
||
const after = player(paid, f.owner);
|
||
assert.equal(after.spice, before.spice, 'Free-three is not charged as paid revival or external Suk placement.');
|
||
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.reserves, before.reserves + 3);
|
||
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 3);
|
||
assert.deepEqual(pool(paid, f.owner, 'homeworld:emperor'), { normal: kaitain.normal + (advanced ? 2 : 3), elite: kaitain.elite });
|
||
if (advanced) {
|
||
assert.deepEqual(pool(paid, f.owner, 'homeworld:emperor:salusa'), { normal: salusa!.normal, elite: salusa!.elite + 1 });
|
||
assert.equal(after.elites!.tanks, before.elites!.tanks - 1);
|
||
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
|
||
assert.equal(after.elites!.revived, 1);
|
||
}
|
||
assert.deepEqual(paid.leaderSkills, window.leaderSkills, 'Normal force revival retains the original trained Skill custody');
|
||
if (advanced) assert.deepEqual(paid.discoveries, window.discoveries);
|
||
assert.equal(paid.nexusCards!.cards!.hands[f.owner], null);
|
||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||
reject(paid, f.owner, action);
|
||
reject(paid, f.owner, { type: 'revive', amount: 1, elite: 0 });
|
||
const token = paid.techTokens!.axlotl, holder = token.owner!;
|
||
assert.equal(token.spice, ownedTech(paid.techTokens, holder).length);
|
||
const end = advance(paid, g => g.phase === 4 && clean(g) && g.ready.length === g.players.length - 1);
|
||
const actor = end.players.find(p => !end.ready.includes(p.id))!.id;
|
||
legalPolicies(end, actor);
|
||
const ended = applyAction(end, actor, { type: 'ready' });
|
||
assert.equal(player(ended, holder).spice, player(end, holder).spice + token.spice);
|
||
assert.equal(ended.techTokens!.axlotl.spice, 0); inventory(ended);
|
||
});
|
||
}
|
||
|
||
void test('real lethal visitor battle returns the trained public Skill once before any original winner Tech reward', () => {
|
||
const f = fixture({ advanced: true, discovery: true, tech: true, strongholds: true, kind: 'visitor-death' });
|
||
const weapon = f.plans.find(step => step.actor === f.opponent)!.action.weapon as string;
|
||
const resolved = settle(reveal(f));
|
||
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
|
||
assert.ok(!resolved.leaderSkills!.assignments.some(a => a.leader === f.trainer));
|
||
assert.equal(resolved.leaderSkills!.deck.filter(skill => skill === 'mentat').length, 1);
|
||
assert.equal(player(resolved, f.owner).tanks, 4);
|
||
assert.deepEqual(pool(resolved, f.owner, f.territory), { normal: 0, elite: 0 });
|
||
const done = finishWinnerTech(resolved);
|
||
for (const token of ownedTech(f.game.techTokens, f.owner))
|
||
assert.equal(done.techTokens![token].owner, f.opponent, 'Killed trained disc and losing army settle before the original winner reward.');
|
||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||
assert.ok(player(done, f.opponent).hand.some(card => card.id === weapon), 'Original winner may keep the genuinely played projectile through ordinary cleanup.');
|
||
const later = advance(done, g => g.turn > done.turn);
|
||
assert.equal(later.leaderSkills!.deck.filter(skill => skill === 'mentat').length, 1);
|
||
inventory(later);
|
||
});
|