Files
Dune/tests/stronghold-factions-runtime.test.ts
T
Quintine e9830b6ced Accelerate rules-first feedback and integrate native Strongholds
Apply user priority: save/recovery/privacy/custody assurance after all rules. Add quick types/lint, concise failure-preserving reporter, verbose mode, actual empty-execution guard, signal handling and focused verify name selection; compact canonical context/handoffs without mass moves. Tooling15, six negative CLI, named/empty source-bound smokes,206 doc links; same15-case278B vs2455B,2.35s vs2.36s no compute speedclaim. Native Advanced Ix/CHOAM47-card Stronghold profile: actual firstMentat/HMS/sixeffects,18native/four legalpolicies,five genuine2..6games3129actions/no rejection, human390px LAUDR2WNv14 bank2/personal1 plan and real battle. Earlier162/6513 checks before assurance amendment; finalquick25/build/guide pass. No new preservation/privacy audit or public/deployed/full-mode claim.
2026-10-02 09:30:52 +10:00

374 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, mobileRoutes, splitLocation, territory } from '../game/board';
import { isAuditorLeader, treacheryDeck } from '../game/cards';
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { createStrongholdFactionsFixture, nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsFixture, type StrongholdFactionsFixtureOptions } from './fixture-stronghold-factions';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
function inventory(game: Game) {
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
}
function custody(game: Game) {
assert.deepEqual(inventory(game), treacheryDeck(['ix', 'choam']).map(c => c.id).sort());
for (const seat of game.players) {
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, n) => sum + n, 0), 20, seat.faction);
if (seat.elites) assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0), seat.faction === 'ixians' ? 7 : seat.faction === 'emperor' ? 5 : 3, seat.faction);
for (const [key, count] of Object.entries(seat.elites?.forces ?? {})) assert.ok(count <= seat.forces[key]);
}
}
function prepared(fixture: StrongholdFactionsFixture): Game {
let game = reload(fixture.game);
if (fixture.copyAction) game = applyAction(game, fixture.owner, fixture.copyAction);
for (let count = 0; count < 120; count++) {
const next = nextStrongholdFactionsNativeStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (game.decision?.kind === 'choamBattleFunding' && fixture.fundingAction) game = applyAction(game, fixture.fundingAction.actor, fixture.fundingAction.action);
else game = applyAction(game, next.actor, next.action);
}
throw Error('Native plan boundary did not open');
}
function reveal(fixture: StrongholdFactionsFixture): Game {
let game = prepared(fixture);
for (const plan of fixture.planActions) {
game = applyAction(game, plan.actor, plan.action);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
}
assert.ok(game.battle?.revealed);
return game;
}
function finish(fixture: StrongholdFactionsFixture, state = reveal(fixture), callers: string[] = []) {
let game = state;
for (let count = 0; count < 160; count++) {
if (!game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game;
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor);
game = applyAction(reload(game), next.actor, next.action);
custody(game);
}
throw Error('Actual native battle suffix did not finish');
}
/** Verify source money independently. Ix opening must close before native
* Collection pays bank stronghold income or removes any board deposits. */
function money(fixture: StrongholdFactionsFixture, revealed: Game, resolved: Game) {
assert.deepEqual(resolved.strongholdCards, fixture.beforeBattle.strongholdCards, 'battle loss does not transfer retained card custody before final Mentat');
const quote = quoteStrongholdFactionsBattle(revealed);
const collection = resolved.phase === 7 && !resolved.phaseOpening ? quoteSpiceCollection({ ...resolved, players: resolved.players.map(p => ({ ...p, spice: 0 })) }).receipts : [];
for (const seat of revealed.players) {
const ownPayment = quote.payments.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.ownPayment, 0);
const stronghold = quote.strongholdIncome.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.amount, 0);
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
const choam = quote.choamIncome?.owner === seat.id ? quote.choamIncome.amount : 0;
const collected = collection.filter(p => p.player === seat.id).reduce((sum, p) => sum + p.strongholds + p.collected, 0);
assert.equal(player(resolved, seat.id).spice, seat.spice - ownPayment + stronghold + bounty + choam + collected, `${seat.faction}: own debit, module income, native bounty, CHOAM and Collection remain separate`);
}
assert.equal(resolved.lastBattleContext?.winner, quote.winner);
assert.deepEqual(normalizeAutomaticGame(reload(resolved)), resolved, 'JSON normalization cannot repay a resolved native source');
return quote;
}
function effect(options: StrongholdFactionsFixtureOptions) {
const fixture = createStrongholdFactionsFixture(options);
const revealed = reveal(fixture);
const resolved = finish(fixture, revealed);
const quote = money(fixture, revealed, resolved);
return { fixture, revealed, resolved, quote };
}
void test('fresh native 2–6 seats conserve canonical 47 cards, twenty counters, native stars, Auditor and six initially unowned Stronghold Cards', () => {
for (let count = 2; count <= 6; count++) {
const fixture = createStrongholdFactionsFixture({ seatIds: Array.from({ length: count }, (_, index) => `seat-${count}-${index}`) });
custody(fixture.initial); custody(fixture.afterSetup); custody(fixture.beforeBattle);
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'ixians')!.elites!.reserves, 4);
assert.equal(fixture.afterSetup.players.find(p => p.faction === 'ixians')!.elites!.forces[MOBILE_LOCATION], 3);
const choam = fixture.afterSetup.players.find(p => p.faction === 'choam')!;
assert.equal(choam.leaders.filter(isAuditorLeader).length, 1);
assert.equal(choam.leaders.filter(l => !isAuditorLeader(l)).length, 5);
assert.equal(Object.keys(fixture.initial.strongholdCards!.owners).length, 6);
assert.ok(Object.values(fixture.initial.strongholdCards!.owners).every(owner => owner === null));
assert.equal(fixture.initial.strongholdCards!.claimedTurn, 0);
assert.equal(fixture.afterFirstMentat.status, 'playing', 'first custody must not prematurely end a three-hold fixture');
assert.equal(fixture.afterFirstMentat.turn, 2);
}
});
void test('original CLI setup remains immutable and first custody is produced by the actual final Mentat action, not a redeal or assigned owner', () => {
const original = createStrongholdFactionsFixture().initial;
const before = reload(original);
const fixture = createStrongholdFactionsFixture({ initial: original });
assert.deepEqual(original, before);
assert.deepEqual(fixture.initial, before);
assert.equal(fixture.beforeFirstMentat.phase, 8);
assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
const settled = applyAction(reload(fixture.beforeFirstMentat), fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
assert.equal(settled.turn, 2);
assert.deepEqual(settled.strongholdCards, fixture.afterFirstMentat.strongholdCards);
assert.deepEqual(settled.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, true));
for (const card of STRONGHOLD_CARDS) assert.equal(fixture.beforeBattle.strongholdCards!.owners[card.id], settled.strongholdCards!.owners[card.id]);
const absent = createStrongholdFactionsFixture({ initial: original, copyChoices: 0 });
assert.equal(absent.beforeBattle.strongholdCards!.owners.arrakeen, absent.owner);
assert.equal(strongholdControllers(absent.beforeBattle.players, true).arrakeen, null);
assert.equal(absent.game.battle!.strongholdCopy, undefined, 'departed card custody alone supplies no mobile copy');
});
void test('Arrakeen subsidy is capped by actual native Cyborg support, never becomes cash, and CHOAM receives the bank-funded share', () => {
for (const support of [0, 1, 2, 3]) {
const { fixture, revealed, quote } = effect({ kind: 'arrakeen', ownerDial: support * 2, support, ownerSpice: Math.max(0, support - 2) });
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.bankSupport, Math.min(2, support));
assert.equal(payment.ownPayment, Math.max(0, support - 2));
assert.equal(quote.choamIncome?.amount ?? 0, Math.floor(support / 2));
assert.equal(player(revealed, fixture.owner).spice, Math.max(0, support - 2), 'the plan seals without crediting two spendable spice');
assert.equal(viewGame(revealed, fixture.owner).battle!.ownForces!.normalFixedHalf, true);
}
const fixture = createStrongholdFactionsFixture({ kind: 'arrakeen', ownerDial: 6, support: 3, ownerSpice: 0 });
const ready = prepared(fixture), before = reload(ready);
assert.throws(() => applyAction(ready, fixture.owner, fixture.planActions[0].action));
assert.deepEqual(ready, before);
});
void test('native CHOAM donor escrow excludes its own donation from income but includes Arrakeen bank support, with no wallet subsidy', () => {
const fixture = createStrongholdFactionsFixture({ kind: 'arrakeen', ownerFaction: 'guild', ownerDial: 4, support: 4, allyPayment: 2, choamFunding: 2, ownerSpice: 0 });
const ready = prepared(fixture);
assert.equal(player(ready, fixture.owner).ally, fixture.choam);
assert.equal(player(ready, fixture.choam).ally, fixture.owner);
assert.equal(player(ready, fixture.choam).spice, 18, 'original native funding action actually paid escrow');
assert.equal(ready.aid[fixture.choam].amount, 2);
const revealed = reveal(fixture), resolved = finish(fixture, revealed), quote = money(fixture, revealed, resolved);
assert.deepEqual(quote.payments.find(p => p.player === fixture.owner), { player: fixture.owner, cost: 2, ownPayment: 0, allyPayment: 2, donor: fixture.choam, bankSupport: 2, freeByTraitor: false });
assert.equal(quote.choamIncome!.amount, 1, 'bank two earns one; native own-donor two earns nothing');
assert.equal(resolved.aid[fixture.choam]?.amount ?? 0, 0);
});
void test('Carthag native physical defense gains poison coverage only without a poison-role weapon; Shield still explodes and Tooth still kills', () => {
for (const scenario of [
{ defense: 'shield', own: undefined, enemy: 'poison', dead: false, explosion: false },
{ defense: 'weirdingWay', own: 'projectile', enemy: 'poison', dead: false, explosion: false },
{ defense: 'shield', own: 'poison', enemy: 'poison', dead: true, explosion: false },
{ defense: 'shield', own: 'chemistry', enemy: 'poison', dead: true, explosion: false },
{ defense: 'worthless', own: undefined, enemy: 'poison', dead: true, explosion: false },
{ defense: 'shield', own: undefined, enemy: 'poisonTooth', dead: true, explosion: false },
{ defense: 'shield', own: undefined, enemy: 'lasgun', dead: true, explosion: true },
] as const) {
const fixture = createStrongholdFactionsFixture({ kind: 'carthag', ownerDefense: scenario.defense, ownerWeapon: scenario.own, opponentWeapon: scenario.enemy });
let revealed = reveal(fixture);
while (revealed.decision || revealed.response) {
const next = nextStrongholdFactionsNativeStep(revealed); assert.ok(next);
revealed = applyAction(revealed, next.actor, next.action);
}
const resolved = finish(fixture, revealed), quote = money(fixture, revealed, resolved);
const leader = String(fixture.planActions[0].action.leader);
assert.equal(player(resolved, fixture.owner).leaders.find(l => l.id === leader)!.dead, scenario.dead, JSON.stringify(scenario));
assert.equal(quote.explosion, scenario.explosion);
}
});
void test('Habbanya native holder wins a printed-supported tie as defender, but ties do not override mutual traitors', () => {
const { fixture, revealed, resolved, quote } = effect({ kind: 'habbanya_ridge_sietch', ownerFaction: 'guild' });
assert.equal(revealed.battle!.defender, fixture.owner);
assert.equal(quote.scores!.attacker, quote.scores!.defender);
assert.equal(quote.winner, fixture.owner);
assert.equal(player(resolved, fixture.opponent).forces[`habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`], undefined);
const mutual = createStrongholdFactionsFixture({ kind: 'habbanya_ridge_sietch', ownerFaction: 'guild', traitors: 'both' });
const ended = finish(mutual, reveal(mutual), [mutual.owner, mutual.opponent]);
assert.equal(ended.lastBattleContext!.winner, null);
custody(ended);
});
void test('Tabr pays floor of opposing declared fractional dial once; native traitor bounty is separate and explosions pay no winner', () => {
const ordinary = effect({ kind: 'sietch_tabr', ownerDial: 6, support: 3, opponentDial: 1.5 });
assert.deepEqual(ordinary.quote.strongholdIncome, [{ player: ordinary.fixture.owner, amount: 1 }]);
const traitor = createStrongholdFactionsFixture({ kind: 'sietch_tabr', ownerDial: 6, support: 3, opponentDial: 1.5, traitors: 'owner' });
const ended = finish(traitor, reveal(traitor), [traitor.owner]);
assert.equal(ended.lastBattleContext!.winner, traitor.owner);
const explosion = effect({ kind: 'sietch_tabr', opponentDial: 1.5, ownerDefense: 'shield', opponentWeapon: 'lasgun' });
assert.equal(explosion.quote.winner, null);
assert.deepEqual(explosion.quote.strongholdIncome, []);
});
void test('Tuek pays each actually played Worthless card on defeat and explosion, but pays neither side for mutual traitors', () => {
const loser = effect({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', opponentDial: 2, opponentSupport: 2 });
assert.equal(loser.quote.winner, loser.fixture.opponent);
assert.deepEqual(loser.quote.strongholdIncome, [{ player: loser.fixture.owner, amount: 4 }]);
for (const field of ['weapon', 'defense'] as const) assert.ok(loser.resolved.discard.some(card => card.id === loser.fixture.planActions[0].action[field]));
const exploded = effect({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'shield', opponentWeapon: 'lasgun' });
assert.equal(exploded.quote.explosion, true);
assert.deepEqual(exploded.quote.strongholdIncome, [{ player: exploded.fixture.owner, amount: 2 }]);
const mutual = createStrongholdFactionsFixture({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'both' });
const ended = finish(mutual, reveal(mutual), [mutual.owner, mutual.opponent]);
assert.equal(ended.lastBattleContext!.winner, null);
assert.equal(ended.log.filter(entry => entry.automatic?.name === 'Stronghold income').length, 0);
const single = createStrongholdFactionsFixture({ kind: 'tueks_sietch', ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'owner', ownerDial: 6, support: 3 });
const won = finish(single, reveal(single), [single.owner]);
assert.equal(won.lastBattleContext!.winner, single.owner);
const nativeCollection = won.phase === 7 ? quoteSpiceCollection(won).receipts.find(receipt => receipt.player === single.owner)!.collected : 0;
assert.equal(player(won, single.owner).spice, 20 + 4 + 5 + nativeCollection, 'four Worthless income plus native five-strength traitor bounty; waived support and separate ordinary Collection');
});
void test('native HMS declaration uses current physical control, not donor card custody; rejected event/actor/choice and early plans leave source unchanged', () => {
const fixture = createStrongholdFactionsFixture();
const game = fixture.game, before = reload(game), decision = game.decision;
assert.equal(decision?.kind, 'strongholdCopy');
if (decision?.kind !== 'strongholdCopy') throw Error('Missing actual native copy decision');
assert.deepEqual(decision.choices, ['arrakeen', 'sietch_tabr']);
assert.notEqual(game.strongholdCards!.owners.sietch_tabr, fixture.owner);
for (const [actor, action] of [
[fixture.opponent, fixture.copyAction!],
[fixture.owner, { ...fixture.copyAction!, event: 'foreign-event' }],
[fixture.owner, { ...fixture.copyAction!, stronghold: 'carthag' }],
[fixture.opponent, fixture.planActions[1].action],
] as const) {
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
const accepted = applyAction(game, fixture.owner, fixture.copyAction!);
assert.equal(accepted.battle!.strongholdCopy, 'arrakeen');
assert.throws(() => applyAction(accepted, fixture.owner, fixture.copyAction!));
const retained = reload(accepted);
// Move the copy-control marker back to reserves after declaration: the
// accepted event remains fixed and does not silently ask again.
const owner = player(retained, fixture.owner);
owner.reserves += owner.forces['arrakeen:10']; delete owner.forces['arrakeen:10'];
const normalized = normalizeAutomaticGame(retained);
assert.equal(normalized.battle!.strongholdCopy, 'arrakeen');
for (const seat of normalized.players) assert.equal(viewGame(normalized, seat.id).battle!.strongholdEffects[fixture.owner], 'arrakeen');
const sole = createStrongholdFactionsFixture({ copyChoices: 1 });
assert.equal(sole.game.battle!.strongholdCopy, 'arrakeen');
assert.notEqual(sole.game.decision?.kind, 'strongholdCopy');
custody(normalized);
});
void test('native HMS copies each existing advantage on its interior without relocating the printed battle site', () => {
for (const copy of ['arrakeen', 'carthag', 'habbanya_ridge_sietch', 'sietch_tabr', 'tueks_sietch'] as const) {
const options: StrongholdFactionsFixtureOptions = { copy, copyChoices: 1 };
if (copy === 'arrakeen') { options.ownerDial = 6; options.support = 3; }
if (copy === 'carthag') { options.ownerDefense = 'shield'; options.opponentWeapon = 'poison'; }
if (copy === 'sietch_tabr') { options.ownerDial = 6; options.support = 3; options.opponentDial = 1.5; }
if (copy === 'tueks_sietch') { options.ownerWeapon = 'worthless'; options.opponentDial = 2; options.opponentSupport = 2; }
const { fixture, revealed, quote } = effect(options);
assert.equal(revealed.battle!.territory, MOBILE_STRONGHOLD);
assert.equal(revealed.battle!.strongholdCopy, copy);
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], copy);
assert.equal(revealed.mobileStronghold!.location, 'polar_sink:0');
assert.equal(player(revealed, fixture.owner).forces['polar_sink:0'], undefined, 'interior army does not leak into the pointing territory');
if (copy === 'arrakeen') assert.equal(quote.payments.find(p => p.player === fixture.owner)!.bankSupport, 2);
if (copy === 'carthag') assert.equal(revealed.battle!.attacker === fixture.owner ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender, false);
if (copy === 'habbanya_ridge_sietch') assert.equal(quote.winner, fixture.owner);
if (copy === 'sietch_tabr') assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]);
if (copy === 'tueks_sietch') assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 2 }]);
}
});
void test('real HMS relocation, native cyborg casualty/substitution decisions and sealed plans survive JSON without revealing foreign private plans', () => {
const original = createStrongholdFactionsFixture();
const route = mobileRoutes(original.afterFirstMentat, 3).find(route => {
const target = splitLocation(route.at(-1)!);
return route.length > 1 && target.sector >= 8 && territory(target.territory).type !== 'stronghold';
});
assert.ok(route, 'An actual connected native mobile route is available outside this next Storm band');
const fixture = createStrongholdFactionsFixture({ initial: original.initial, mobileRoute: route, ownerDial: 6, support: 3, opponentDial: 1.5 });
assert.equal(fixture.game.mobileStronghold!.location, route.at(-1));
let game = prepared(fixture);
const first = fixture.planActions[0]; game = applyAction(game, first.actor, first.action);
const ownView = viewGame(reload(game), fixture.owner), otherView = viewGame(reload(game), fixture.opponent);
assert.equal(ownView.battle!.plans[fixture.owner].dial, 6);
assert.deepEqual(otherView.battle!.plans, {});
assert.equal(otherView.battle!.revealed, false);
const second = fixture.planActions[1]; game = applyAction(reload(game), second.actor, second.action);
const quote = quoteStrongholdFactionsBattle(game);
assert.equal(quote.casualties!.forces.normalFixedHalf, true);
assert.ok(quote.casualties!.options.every(loss => loss.elite === 3 && loss.normal === 0));
for (let count = 0; game.decision?.kind !== 'ixSubstitution' && count < 100; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
game = applyAction(reload(game), next.actor, next.action);
}
assert.equal(game.decision?.kind, 'ixSubstitution');
assert.equal(player(game, fixture.owner).elites!.tanks, 3);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
const resolved = finish(fixture, game);
custody(resolved);
});
void test('all four existing projected policies choose legal native current-control copies and affordable native plans without bank cash', () => {
const fixture = createStrongholdFactionsFixture({ ownerSpice: 0, ownerDefense: 'shield' });
for (const difficulty of DIFFICULTIES) {
const copyView = viewGame(fixture.game, fixture.owner);
copyView.players.find(p => p.id === fixture.owner)!.bot = difficulty;
const copy = botActions(copyView)[0]; assert.ok(copy);
let game = applyAction(reload(fixture.game), fixture.owner, copy);
while (nextStrongholdFactionsNativeStep(game)) {
const next = nextStrongholdFactionsNativeStep(game)!;
game = applyAction(game, next.actor, next.action);
}
const view = viewGame(game, fixture.owner);
view.players.find(p => p.id === fixture.owner)!.bot = difficulty;
const plan = botActions(view).find(action => action.type === 'battlePlan'); assert.ok(plan);
const accepted = applyAction(game, fixture.owner, plan);
assert.equal(player(accepted, fixture.owner).spice, 0);
assert.ok((accepted.battle!.plans[fixture.owner].support ?? 0) <= 2, 'free bank support is capped, not unlimited personal cash');
custody(accepted);
}
});
void test('free native Fremen support neither spends Arrakeen allowance nor mints CHOAM income', () => {
const { fixture, quote } = effect({ kind: 'arrakeen', seatIds: ['i', 'c', 'g', 'e', 'f'], ownerFaction: 'fremen', ownerDial: 3, support: 0, ownerSpice: 0 });
assert.deepEqual(quote.payments.find(payment => payment.player === fixture.owner), { player: fixture.owner, cost: 0, ownPayment: 0, allyPayment: 0, donor: null, bankSupport: 0, freeByTraitor: false });
assert.equal(quote.choamIncome, null);
});
void test('native CHOAM aid opens a real Karama response only for an actual conserved eligible hand; cancellation cannot leave an affordable phantom ally payment', () => {
const fixture = createStrongholdFactionsFixture({ ownerFaction: 'guild', copy: 'arrakeen', choamFunding: 2, allyPayment: 2, ownerDial: 4, support: 4, ownerSpice: 0, counter: true });
let game = applyAction(reload(fixture.game), fixture.owner, fixture.copyAction!);
assert.equal(game.decision?.kind, 'choamBattleFunding');
game = applyAction(game, fixture.fundingAction!.actor, fixture.fundingAction!.action);
assert.equal(game.response?.owner, fixture.choam);
const counter = player(game, fixture.opponent).hand.find(card => card.effect === 'karama'); assert.ok(counter);
const before = reload(game);
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
assert.equal(next.actor, fixture.opponent);
assert.equal(next.action.type, 'passResponse');
game = applyAction(game, fixture.opponent, { type: 'card', card: counter.id, mode: 'cancel' });
for (let count = 0; nextStrongholdFactionsNativeStep(game) && count < 120; count++) {
const step = nextStrongholdFactionsNativeStep(game)!; game = applyAction(game, step.actor, step.action);
}
assert.ok(game.battle!.choamAidBlocked);
assert.ok(game.discard.some(card => card.id === counter.id));
assert.ok(before.players.find(p => p.id === fixture.opponent)!.hand.some(card => card.id === counter.id), 'original counter custody remains immutable');
const preserved = reload(game);
assert.throws(() => applyAction(game, fixture.owner, fixture.planActions[0].action));
assert.deepEqual(game, preserved);
const accepted = applyAction(game, fixture.owner, { ...fixture.planActions[0].action, dial: 2, support: 2, allyPayment: 0 });
assert.equal(player(accepted, fixture.owner).spice, 0);
custody(accepted);
});
void test('actual native battle, Collection and final Mentat settle current custody and terminal victory once, including retained departure custody until that checkpoint', () => {
const fixture = createStrongholdFactionsFixture({ ownerDial: 6, support: 3, opponentDial: 1.5 });
let game = finish(fixture);
assert.deepEqual(game.strongholdCards, fixture.afterFirstMentat.strongholdCards);
let finalSource: Game | undefined;
for (let count = 0; game.status !== 'finished' && count < 300; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
const before = reload(game);
game = applyAction(reload(game), next.actor, next.action);
if (game.status === 'finished') finalSource = before;
custody(game);
}
assert.equal(game.status, 'finished');
assert.equal(finalSource!.phase, 8);
assert.equal(finalSource!.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.claimedTurn, 2);
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(finalSource!.players, true));
assert.equal(game.lastBattleContext!.winner, fixture.owner);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
assert.equal(nextStrongholdFactionsNativeStep(game), null);
});