Files
Dune/tests/semuta-homeworld-capture-karama.test.ts
T

270 lines
15 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, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldCaptureKaramaFixture, disposeSemutaHomeworldCaptureKarama,
finishSemutaHomeworldCaptureKarama, semutaHomeworldCaptureKaramaResources as resources,
semutaHomeworldCaptureKaramaStock as stock, type SemutaHomeworldCaptureKaramaFixture,
} from './fixture-semuta-homeworld-capture-karama';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected controls must not mutate the original native source');
}
function originalBattle(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
const h = player(g, f.captor), e = player(g, f.victim);
assert.equal(g.turn, 1);
assert.equal(g.lastBattleContext?.winner, f.captor);
assert.equal(g.lastBattleContext?.result, 'normal');
assert.equal(g.lastBattleContext?.territory, 'carthag');
assert.equal(g.battle, null);
assert.equal(player(f.setup, f.captor).forces[f.location], 10);
assert.equal(player(f.beforeBattle, f.captor).forces[f.location], 10);
assert.equal(h.forces[f.location], 9);
assert.equal(h.reserves, 10);
assert.equal(h.tanks, 1);
assert.equal(h.battleLosses, player(f.beforeBattle, f.captor).battleLosses + 1);
assert.equal(e.forces[f.location] ?? 0, 0);
assert.equal(e.reserves, 18);
assert.equal(e.tanks, 2);
assert.equal(e.battleLosses, player(f.beforeBattle, f.victim).battleLosses + 2);
assert.equal(h.leaders.find(l => l.id === f.winnerLeader)?.dead, false);
assert.equal(e.leaders.find(l => l.id === f.loserLeader)?.dead, false);
assert.equal(h.spice, player(f.beforeBattle, f.captor).spice - 1,
'Only the original Harkonnen supported dial leaves its wallet; no fabricated bounty is created');
assert.equal(e.spice, player(f.beforeBattle, f.victim).spice,
'The original Emperor payer receives no battle income for this one-spice support');
assert.deepEqual(g.homeworlds?.custody, f.paidShipment.after.homeworlds?.custody);
for (const seat of g.players.filter(p => ![f.captor, f.victim].includes(p.id))) {
const before = player(f.beforeBattle, seat.id);
assert.deepEqual(seat.forces, before.forces);
assert.deepEqual(seat.elites, before.elites);
assert.equal(seat.spice, before.spice);
assert.equal(seat.reserves, before.reserves);
assert.equal(seat.tanks, before.tanks);
}
assert.deepEqual(stock(g), stock(f.setup));
}
function unchangedPrivateSource(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
for (const seat of g.players) {
assert.deepEqual(seat.leaders, player(f.gameBeforeCost, seat.id).leaders,
'No candidate is sampled, captured, concealed, transferred, killed or rewarded by a canceled capture');
assert.deepEqual(seat.traitors, player(f.gameBeforeCost, seat.id).traitors);
assert.ok(seat.leaders.every(l => !l.capturedBy && !l.concealed));
}
assert.deepEqual(g.traitorReserve, f.gameBeforeCost.traitorReserve);
assert.deepEqual(g.homeworldOccupationHistory, f.gameBeforeCost.homeworldOccupationHistory);
assert.deepEqual(g.homeworldOccupiedIncomeHistory, f.gameBeforeCost.homeworldOccupiedIncomeHistory);
assert.deepEqual(g.homeworldOccupiedPercentageLedger, f.gameBeforeCost.homeworldOccupiedPercentageLedger);
assert.deepEqual(g.tupileIntelligence, f.gameBeforeCost.tupileIntelligence);
assert.deepEqual(g.giediCollection, f.gameBeforeCost.giediCollection);
}
function pausedCost(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
originalBattle(f, g);
unchangedPrivateSource(f, g);
assert.equal(f.nativecaptureOffer?.decision?.kind, 'captureOffer');
assert.equal(f.nativecaptureOffer?.decision?.player, f.captor);
assert.equal(f.originalresponse?.response?.kind, 'capture');
assert.equal(f.originalresponse?.response?.owner, f.captor);
assert.deepEqual(g.pendingCapture,
{ player: f.captor, loser: f.victim, territory: 'carthag' });
assert.equal(g.response, null, 'Printed cancellation response is detached while Semuta owns controls');
const pending = g.pendingTreacheryDiscard;
assert.ok(pending);
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(pending.continuation.kind, 'karamaBattleDiscard');
assert.equal(pending.batch.cause, 'karama:capture');
assert.deepEqual(pending.batch.entries.map(e => ({ card: e.card.id, player: e.discardedBy, publicFace: e.publicFace })),
[{ card: f.card, player: f.actor, publicFace: true }]);
assert.equal(player(g, f.actor).hand.some(c => c.id === f.card), false);
assert.equal(g.discard.filter(c => c.id === f.card).length, 1);
assert.equal(viewGame(g, f.holder).semutaReaction?.canCommit, true);
assert.equal(g.decision?.kind === 'capturedLeader', false);
assert.deepEqual(resources(g), resources(f.gameBeforeCost));
}
function canceledExchange(f: SemutaHomeworldCaptureKaramaFixture, pending: Game, g: Game, claim: boolean) {
originalBattle(f, g);
unchangedPrivateSource(f, g);
assert.equal(g.pendingCapture, null);
assert.equal(g.response, null);
assert.equal(g.pendingTreacheryDiscard, null);
assert.notEqual(g.decision?.kind, 'capturedLeader');
assert.deepEqual(resources(g), resources(pending));
for (const seat of g.players) {
const expected = player(pending, seat.id).hand.map(c => c.id);
if (seat.id === f.holder && claim) expected.splice(expected.indexOf(SEMUTA_DRUG_ID), 1, f.card);
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort(),
'Only Richese exchanges its actual Semuta for Emperor’s spent printed Karama; no native hand draws');
}
assert.equal(player(g, f.actor).hand.some(c => c.id === f.card), false);
assert.equal(player(g, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
assert.equal(g.discard.filter(c => c.id === f.card).length, Number(!claim));
assert.equal(g.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.equal(player(g, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
}
function finishOnce(f: SemutaHomeworldCaptureKaramaFixture, state: Game) {
const finished = finishSemutaHomeworldCaptureKarama(state);
const expected = resources(finished.beforeCollection);
for (const receipt of finished.collection.receipts)
expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
expected.spice = finished.collection.spice;
assert.equal(finished.collection.receipts.find(r => r.player === f.captor)!.strongholds, 2,
'Nine surviving Harkonnen counters on Carthag collect the two original stronghold points');
assert.equal(finished.collection.receipts.find(r => r.player === f.captor)!.desert, 0,
'The winner has no original desert presence to collect');
assert.equal(finished.game.phase, 7);
assert.equal(finished.game.turn, 1);
assert.deepEqual(resources(finished.game), expected,
'Only original Collection balances change: paid shipment, accepted casualties and living leaders never repeat');
assert.deepEqual(stock(finished.game), stock(f.setup));
assert.deepEqual(finished.game.players.map(p => p.hand), state.players.map(p => p.hand));
assert.deepEqual(finished.game.players.map(p => p.traitors), state.players.map(p => p.traitors));
assert.equal(finished.game.pendingCapture, null);
rejected(finished.game, f.captor, { type: 'decision', accept: true });
rejected(finished.game, f.actor, f.action);
return finished.game;
}
for (const fullHolder of [false, true]) for (const claim of [false, true]) {
void test(`Advanced native Homeworld capture cost: ${fullHolder ? 'full four' : 'thin'} Richese ${claim ? 'claim' : 'all-pass'} cancels capture before one original Collection`, () => {
const f = createSemutaHomeworldCaptureKaramaFixture(fullHolder);
const paid = f.paidShipment;
assert.equal(player(paid.before, f.victim).reserves, 20);
assert.equal(player(paid.after, f.victim).reserves, 18);
assert.equal(player(paid.after, f.victim).forces[f.location], 2);
assert.equal(player(paid.after, f.victim).spice, player(paid.before, f.victim).spice - 2);
assert.deepEqual(player(paid.after, f.victim).elites, player(paid.before, f.victim).elites);
const pending = disposeSemutaHomeworldCaptureKarama(f);
pausedCost(f, pending);
const event = pending.pendingTreacheryDiscard!.batch.event;
rejected(pending, f.actor, f.action);
rejected(pending, f.actor, { type: 'passResponse' });
rejected(pending, f.captor, { type: 'decision', accept: true });
rejected(pending, f.captor, { type: 'decision', mode: 'execute' });
rejected(pending, f.captor, { type: 'semutaCommit', event });
rejected(pending, f.actor, { type: 'semutaCommit', event });
rejected(pending, f.holder, { type: 'semutaCommit', event: `${event}-retired` });
rejected(pending, f.holder, { type: 'semutaSelect', event, card: f.card });
const acknowledged = applyAction(pending, f.actor, { type: 'semutaPass', event });
assert.deepEqual(resources(acknowledged), resources(pending));
assert.equal(viewGame(acknowledged, f.holder).semutaReaction?.canCommit, true);
rejected(acknowledged, f.actor, { type: 'semutaPass', event });
const done = reactSemutaHomeworldChoam(claim ? acknowledged : pending, f.holder, claim);
canceledExchange(f, pending, done, claim);
assert.equal(player(done, f.holder).hand.length, fullHolder ? handLimit(player(done, f.holder)) : 1);
for (const action of [{ type: 'semutaCommit', event }, { type: 'semutaPass', event },
{ type: 'semutaSelect', event, card: f.card }, f.action]) rejected(done, f.holder, action);
const collected = finishOnce(f, done);
rejected(collected, f.holder, { type: 'semutaCommit', event });
});
}
void test('Native capture cost private forks expose only the holder’s current entitlement, not leaders or Giedi/Traitor custody', () => {
const f = createSemutaHomeworldCaptureKaramaFixture(true);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.gameBeforeCost, f.holder);
const pending = disposeSemutaHomeworldCaptureKarama(f);
const neutral = disposeSemutaHomeworldCaptureKarama(f, absent);
pausedCost(f, pending);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))),
'A conserved hidden Richese card replacement cannot reveal entitlement to another actor');
assert.deepEqual(viewGame(pending, seat.id).semutaReaction?.candidates, []);
}
const event = neutral.pendingTreacheryDiscard!.batch.event;
rejected(neutral, f.holder, { type: 'semutaCommit', event });
const claimed = reactSemutaHomeworldChoam(pending, f.holder, true);
const passed = reactSemutaHomeworldChoam(neutral, f.holder, false);
assert.deepEqual(resources(claimed), resources(passed));
canceledExchange(f, pending, claimed, true);
finishOnce(f, claimed);
finishOnce(f, passed);
});
void test('Native capture cost refuses changed source, original owner, casualty context and clock without granting a claim', () => {
const f = createSemutaHomeworldCaptureKaramaFixture();
const pending = disposeSemutaHomeworldCaptureKarama(f);
const event = pending.pendingTreacheryDiscard!.batch.event;
const mutations: ((g: Game) => void)[] = [
g => { g.pendingCapture!.player = f.victim; },
g => { g.pendingCapture!.loser = 'a'; },
g => { g.pendingCapture!.territory = 'arrakeen'; },
g => { g.pendingCapture = null; },
g => { g.phase = 7; },
g => { g.advanced = false; },
g => { player(g, f.captor).forces[f.location]++; },
g => { player(g, f.victim).tanks--; },
g => { g.lastBattleContext!.winner = f.victim; },
g => { g.lastBattleContext!.territory = 'arrakeen'; },
g => {
const c = g.pendingTreacheryDiscard!.continuation;
assert.equal(c.kind, 'karamaBattleDiscard');
if (c.kind !== 'karamaBattleDiscard') throw Error('Expected native printed cost');
c.use.response.owner = f.victim;
},
g => { g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = f.captor; },
];
for (const mutate of mutations) {
const changed = structuredClone(pending);
mutate(changed);
rejected(changed, f.holder, { type: 'semutaCommit', event });
rejected(changed, f.actor, { type: 'semutaPass', event });
}
pausedCost(f, pending);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum original capture-cost consumer retires the reward without capture`, () => {
const f = createSemutaHomeworldCaptureKaramaFixture(true);
const pending = disposeSemutaHomeworldCaptureKarama(f);
pausedCost(f, pending);
const view = viewGame(pending, f.holder);
view.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
let game = applyAction(pending, f.holder, action);
if (game.pendingTreacheryDiscard) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const seat of game.players) {
if (!game.pendingTreacheryDiscard) break;
const reaction = game.pendingTreacheryDiscard.reaction;
assert.ok(reaction?.stage === 'offer');
if (!reaction.passed.includes(seat.id)) game = applyAction(game, seat.id, { type: 'semutaPass', event });
}
}
canceledExchange(f, pending, game, action.type === 'semutaCommit');
finishOnce(f, game);
});
void test('Basic companion original Homeworld battle never produces native capture or a fabricated cancel response', () => {
const f = createSemutaHomeworldCaptureKaramaFixture(false, false);
assert.equal(f.acceptedBattle.pendingCapture, null);
assert.equal(f.nativecaptureOffer, null);
assert.equal(f.originalresponse, null);
assert.equal(f.game.pendingCapture, null);
assert.equal(f.game.phase, 7);
assert.equal(f.game.lastBattleContext?.winner, f.captor);
assert.equal(player(f.game, f.captor).forces[f.location], 9);
assert.equal(player(f.game, f.victim).tanks, 2);
assert.deepEqual(f.game.players.map(p => p.leaders), f.acceptedBattle.players.map(p => p.leaders));
assert.equal(f.game.discard.some(c => c.id === f.card), false);
assert.equal(player(f.game, f.actor).hand.some(c => c.id === f.card), true);
rejected(f.game, f.actor, f.action);
rejected(f.game, f.captor, { type: 'decision', accept: true });
assert.deepEqual(stock(f.game), stock(f.setup));
});