242 lines
14 KiB
TypeScript
242 lines
14 KiB
TypeScript
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 { holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
|
import { semutaHomeworldChoamMovementResources as resources,
|
|
semutaHomeworldChoamMovementStock as stock } from './fixture-semuta-homeworld-choam-movement';
|
|
import {
|
|
createSemutaHomeworldEconomicKaramaFixture, disposeSemutaHomeworldEconomicKarama,
|
|
finishSemutaHomeworldEconomicKarama, reactSemutaHomeworldEconomicKarama,
|
|
withoutSemutaHomeworldEconomicHolder, type SemutaHomeworldEconomicKaramaFixture,
|
|
} from './fixture-semuta-homeworld-economic-karama';
|
|
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before, 'A rejected stale/cross-actor action has no effect');
|
|
}
|
|
|
|
function assertNativeEconomy(f: SemutaHomeworldEconomicKaramaFixture) {
|
|
assert.equal(f.game.turn, 1);
|
|
assert.equal(player(f.game, f.owner).faction, 'choam');
|
|
assert.equal(player(f.game, f.canceller).faction, 'emperor');
|
|
assert.equal(player(f.game, f.holder).faction, 'richese');
|
|
assert.equal(player(f.setup, f.owner).spice, 2);
|
|
assert.equal(player(f.game, f.canceller).spice, 10);
|
|
assert.deepEqual(f.game.homeworlds?.custody, f.setup.homeworlds?.custody);
|
|
for (const seat of f.game.players) {
|
|
assert.equal(seat.reserves, player(f.setup, seat.id).reserves);
|
|
assert.equal(seat.tanks, player(f.setup, seat.id).tanks);
|
|
assert.deepEqual(seat.forces, player(f.setup, seat.id).forces);
|
|
assert.deepEqual(seat.elites, player(f.setup, seat.id).elites);
|
|
}
|
|
const ownerView = viewGame(f.game, f.owner);
|
|
assert.equal(ownerView.homeworlds!.worlds!.find(world => world.native === f.owner)!.population, 20,
|
|
'Original ordinary Tupile population is preserved without any shipment');
|
|
assert.equal(ownerView.charity.incomeHomeworld, 0, 'Original full Tupile has no low-population bonus');
|
|
assert.equal(ownerView.charity.multiplier, 1);
|
|
if (f.scope === 'charity') {
|
|
assert.equal(f.game.phase, 2);
|
|
assert.equal(player(f.game, f.owner).spice, 2, 'Native eight-spice CHOAM opening income is still unpaid');
|
|
assert.equal(ownerView.charity.incomePending, true);
|
|
} else {
|
|
assert.equal(f.game.phase, 8);
|
|
assert.equal(player(f.game, f.owner).spice, 10, 'First ordinary native income paid exactly eight');
|
|
assert.equal(f.game.inflationAttemptTurn, 1, 'Actual placement, not Karama spending, consumes the attempt');
|
|
assert.ok(f.beforeCollection && f.collectionQuote);
|
|
for (const receipt of f.collectionQuote.receipts)
|
|
assert.equal(player(f.game, receipt.player).spice, receipt.balance,
|
|
'Native Collection credits including Advanced strongholds precede Mentat');
|
|
assert.equal(player(f.beforeCollection, f.target).spice, 10);
|
|
assert.equal(player(f.game, f.target).spice, f.game.advanced ? 12 : 10);
|
|
}
|
|
}
|
|
|
|
function assertDenied(f: SemutaHomeworldEconomicKaramaFixture, done: Game, claimed: boolean) {
|
|
assert.ok(!done.pendingTreacheryDiscard && !done.response);
|
|
assert.equal(done.phase, f.game.phase);
|
|
assert.equal(done.turn, 1);
|
|
assert.deepEqual(resources(done), resources(f.game), 'Cost and denial never transfer/refund spice or move original custody');
|
|
assert.deepEqual(stock(done), stock(f.game));
|
|
assert.equal(player(done, f.canceller).hand.some(c => c.id === f.card), false);
|
|
assert.equal(player(done, f.holder).hand.some(c => c.id === f.card), claimed);
|
|
assert.equal(player(done, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claimed);
|
|
assert.equal(done.discard.filter(c => c.id === f.card).length, Number(!claimed));
|
|
assert.equal(done.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claimed));
|
|
for (const seat of done.players)
|
|
assert.equal(seat.hand.length, player(f.game, seat.id).hand.length - Number(seat.id === f.canceller));
|
|
if (f.scope === 'charity') {
|
|
assert.deepEqual(done.choamCharity, { turn: 1, canceled: true });
|
|
assert.equal(player(done, f.owner).spice, 2);
|
|
assert.equal(viewGame(done, f.owner).charity.incomeCanceled, true);
|
|
assert.equal(viewGame(done, f.owner).charity.payer, null, 'Canceled income leaves charity bank-paid');
|
|
} else {
|
|
assert.equal(done.inflationAttemptTurn, 1);
|
|
assert.ok(!done.inflation && !done.inflationUsed);
|
|
assert.equal(player(done, f.owner).spice, 10);
|
|
reject(done, f.owner, { type: 'choamInflation', side: 'double' });
|
|
reject(done, f.owner, { type: 'choamInflation', side: 'cancel' });
|
|
}
|
|
reject(done, f.canceller, f.action);
|
|
reject(done, f.canceller, { type: 'passResponse' });
|
|
if (claimed) reject(done, f.holder, f.action);
|
|
}
|
|
|
|
function assertSuffix(f: SemutaHomeworldEconomicKaramaFixture, done: Game) {
|
|
const suffix = finishSemutaHomeworldEconomicKarama(done, f);
|
|
assert.equal(suffix.status, 'playing');
|
|
const settled = f.afterCancel!;
|
|
assert.deepEqual(suffix.homeworlds?.custody, settled.homeworlds?.custody);
|
|
for (const seat of suffix.players) {
|
|
assert.deepEqual(seat.forces, player(settled, seat.id).forces);
|
|
assert.equal(seat.reserves, player(settled, seat.id).reserves);
|
|
assert.equal(seat.tanks, player(settled, seat.id).tanks);
|
|
}
|
|
if (f.scope === 'charity') {
|
|
assert.equal(suffix.phase, 3);
|
|
assert.equal(suffix.turn, 1);
|
|
assert.deepEqual(resources(suffix), resources(done), 'The completed opening denial cannot repay its eight spice');
|
|
assert.deepEqual(suffix.choamCharity, { turn: 1, canceled: true });
|
|
} else {
|
|
assert.equal(suffix.phase, 2);
|
|
assert.equal(suffix.turn, 2);
|
|
assert.ok(!suffix.inflation && !suffix.inflationUsed);
|
|
assert.equal(suffix.choamMentatPending, false);
|
|
assert.equal(viewGame(suffix, f.owner).charity.multiplier, 1);
|
|
assert.deepEqual(suffix.choamCharity, { turn: 2, canceled: false });
|
|
assert.ok(f.beforeNextIncome);
|
|
assert.equal(player(f.beforeNextIncome, f.owner).spice, 10);
|
|
assert.equal(player(suffix, f.owner).spice, 18, 'Original next-turn income remains ordinary eight, not sixteen/zero');
|
|
for (const seat of suffix.players.filter(p => p.id !== f.owner))
|
|
assert.equal(seat.spice, player(done, seat.id).spice);
|
|
reject(suffix, f.owner, { type: 'decision', done: true });
|
|
}
|
|
}
|
|
|
|
for (const advanced of [false, true]) for (const scope of ['charity', 'inflation'] as const) {
|
|
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld ${scope} printed Karama`;
|
|
for (const claimed of [false, true]) void test(`${label}: ${claimed ? 'claim' : 'pass'} resumes exactly one denial without native economic replay`, () => {
|
|
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced);
|
|
assertNativeEconomy(f);
|
|
const pending = disposeSemutaHomeworldEconomicKarama(f);
|
|
const cost = pending.pendingTreacheryDiscard;
|
|
assert.ok(cost);
|
|
assert.equal(cost.continuation.kind, scope === 'charity' ? 'karamaCharityDiscard' : 'karamaInflationDiscard');
|
|
assert.equal(cost.batch.cause, scope === 'charity' ? 'karama:choamCharity' : 'karama:choamInflation');
|
|
assert.deepEqual(cost.batch.entries.map(e => ({ card: e.card.id, actor: e.discardedBy, face: e.publicFace })),
|
|
[{ card: f.card, actor: f.canceller, face: true }]);
|
|
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
|
|
assert.equal(player(pending, f.canceller).hand.some(c => c.id === f.card), false);
|
|
assert.deepEqual(resources(pending), resources(f.game));
|
|
assert.deepEqual(pending.choamCharity, f.game.choamCharity, 'Printed denial has not happened during its cost offer');
|
|
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
|
reject(pending, f.canceller, f.action);
|
|
reject(pending, f.owner, { type: 'ready' });
|
|
reject(pending, f.canceller, { type: 'passResponse' });
|
|
reject(pending, f.target, { type: 'semutaCommit', event: cost.batch.event });
|
|
reject(pending, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}:stale` });
|
|
const done = reactSemutaHomeworldEconomicKarama(JSON.parse(JSON.stringify(pending)), f.holder, claimed);
|
|
assertDenied(f, done, claimed);
|
|
reject(done, f.holder, { type: 'semutaCommit', event: cost.batch.event });
|
|
reject(done, f.target, { type: 'semutaPass', event: cost.batch.event });
|
|
assertSuffix(f, done);
|
|
});
|
|
|
|
void test(`${label}: full four-card holder exchanges one card; equal-size absent-Semuta substitution is private`, () => {
|
|
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced, true);
|
|
const pending = disposeSemutaHomeworldEconomicKarama(f);
|
|
const neutral = disposeSemutaHomeworldEconomicKarama(f, withoutSemutaHomeworldEconomicHolder(f.game, f.holder));
|
|
assert.equal(player(f.game, f.holder).hand.length, 4);
|
|
assert.equal(handLimit(player(f.game, f.holder)), 4);
|
|
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
|
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
|
reject(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event });
|
|
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
|
|
assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id));
|
|
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
|
|
}
|
|
const done = reactSemutaHomeworldEconomicKarama(pending, f.holder, true);
|
|
assertDenied(f, done, true);
|
|
assert.equal(player(done, f.holder).hand.length, 4);
|
|
const passed = reactSemutaHomeworldEconomicKarama(neutral, f.holder, false);
|
|
assert.deepEqual(resources(passed), resources(done));
|
|
assert.equal(passed.discard.filter(c => c.id === f.card).length, 1);
|
|
assertSuffix(f, done);
|
|
});
|
|
|
|
void test(`${label}: non-BG Worthless, absent Ix Kull and wrong-seat cancellation remain excluded`, () => {
|
|
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced);
|
|
const game = structuredClone(f.game);
|
|
const worthless = holdSemutaHomeworldChoamCard(game, f.canceller, 'Baliset');
|
|
const kull = 'ix-kull-wahad';
|
|
assert.equal(stock(game).includes(kull), false, 'Exact CHOAM profile has no printed Ix Kull to substitute');
|
|
assert.ok(!game.players.some(p => p.faction === 'beneGesserit'));
|
|
assert.ok(!viewGame(game, f.canceller).responseControls?.cancelCards.includes(worthless));
|
|
reject(game, f.canceller, { type: 'card', card: worthless, mode: 'cancel' });
|
|
reject(game, f.owner, { type: 'card', card: kull, mode: 'cancel' });
|
|
reject(game, f.holder, f.action);
|
|
reject(game, f.canceller, { type: 'choamInflation', side: 'double' });
|
|
reject(game, f.owner, { type: 'card', card: kull, mode: 'kull', target: f.canceller });
|
|
const pending = disposeSemutaHomeworldEconomicKarama(f, game);
|
|
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.card]);
|
|
assert.equal(player(pending, f.owner).hand.some(c => c.id === kull), false);
|
|
assert.equal(player(pending, f.canceller).hand.some(c => c.id === worthless), true);
|
|
});
|
|
|
|
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: ${label} uses actual Semuta and the original CHOAM next-actor controls`, () => {
|
|
const f = createSemutaHomeworldEconomicKaramaFixture(scope, advanced);
|
|
let game = disposeSemutaHomeworldEconomicKarama(f);
|
|
const view = viewGame(game, f.holder);
|
|
view.players.find(p => p.id === f.holder)!.bot = difficulty;
|
|
const action = botActions(view)[0];
|
|
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
|
game = applyAction(game, f.holder, action);
|
|
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldEconomicKarama(game, f.holder, false);
|
|
assertDenied(f, game, action.type === 'semutaCommit');
|
|
const nextView = viewGame(game, f.owner);
|
|
nextView.players.find(p => p.id === f.owner)!.bot = difficulty;
|
|
const next = botActions(nextView)[0];
|
|
assert.ok(next);
|
|
const after = applyAction(game, f.owner, next);
|
|
assert.equal(after.turn, 1);
|
|
assert.equal(player(after, f.owner).spice, player(game, f.owner).spice);
|
|
assertSuffix(f, after);
|
|
});
|
|
}
|
|
|
|
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'}: original Cancel-side Inflation denial also consumes its attempt without zeroing next charity`, () => {
|
|
const base = createSemutaHomeworldEconomicKaramaFixture('inflation', advanced);
|
|
const declaration: Action = { type: 'choamInflation', side: 'cancel' };
|
|
const game = applyAction(base.beforeDeclaration, base.owner, declaration);
|
|
const f = { ...base, game, originalResponse: structuredClone(game), declaration };
|
|
assert.equal(game.response?.intent, 'cancel');
|
|
const pending = disposeSemutaHomeworldEconomicKarama(f);
|
|
const done = reactSemutaHomeworldEconomicKarama(pending, f.holder, true);
|
|
assertDenied(f, done, true);
|
|
assertSuffix(f, done);
|
|
});
|
|
|
|
void test('Advanced Medium: a legal native Gamont follow-on preserves the canceled Inflation attempt and original close', () => {
|
|
const f = createSemutaHomeworldEconomicKaramaFixture('inflation', true);
|
|
const gamont = holdSemutaHomeworldChoamCard(f.game, f.owner, 'Trip to Gamont');
|
|
const done = reactSemutaHomeworldEconomicKarama(disposeSemutaHomeworldEconomicKarama(f), f.holder, true);
|
|
const own = viewGame(done, f.owner);
|
|
own.players.find(p => p.id === f.owner)!.bot = 'Medium';
|
|
const proposal = botActions(own)[0];
|
|
assert.ok(proposal && proposal.card === gamont);
|
|
const declared = applyAction(done, f.owner, proposal);
|
|
assert.equal(declared.response?.kind, 'choamWorthless');
|
|
assert.equal(declared.inflationAttemptTurn, 1);
|
|
assert.equal(player(declared, f.target).forces['arrakeen:10'], 10);
|
|
assertSuffix(f, declared);
|
|
assert.equal(player(f.targetSuffix!, f.target).forces['arrakeen:10'], 9);
|
|
assert.equal(player(f.targetSuffix!, f.target).reserves, 11);
|
|
assert.equal(player(f.targetSuffix!, f.owner).spice, 18);
|
|
assert.equal(player(f.targetSuffix!, f.target).spice, 12);
|
|
assert.equal(f.targetSuffix!.discard.filter(c => c.id === gamont).length, 1);
|
|
});
|