Compose Homeworld storm and native Worthless counter costs with Semuta

This commit is contained in:
Quintine committed 2026-10-13 02:08:42 +11:00
1 parent 506bf25bc6
commit 7004977e75
12 files changed
+776 -13

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, 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 { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { splitLocation } from '../game/board';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { semutaHomeworldChoamMovementResources as resources,
semutaHomeworldChoamMovementStock as stock,
withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import { createSemutaHomeworldChoamStormFixture, disposeSemutaHomeworldChoamStorm,
finishSemutaHomeworldChoamStorm, type SemutaHomeworldChoamStormFixture } from './fixture-semuta-homeworld-choam-storm';
/** Sectors that the fixture's actual Weather dial really traverses this turn. */
function stormCrossedSectors(fixture: SemutaHomeworldChoamStormFixture): number[] {
return Array.from({ length: fixture.weatherAmount },
(_, step) => (fixture.beforeWeather.storm + step) % 18 + 1);
}
function assertConservedCounters(game: Game, original: Game) {
for (const seat of game.players) {
const before = player(original, seat.id);
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0),
before.reserves + before.tanks + Object.values(before.forces).reduce((sum, amount) => sum + amount, 0));
assert.ok(Object.values(seat.forces).every(amount => amount > 0));
if (seat.elites && before.elites) {
assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, amount) => sum + amount, 0),
before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((sum, amount) => sum + amount, 0));
for (const [key, amount] of Object.entries(seat.elites.forces))
assert.ok(amount >= 0 && amount <= (seat.forces[key] ?? 0));
}
}
assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody);
}
for (const advanced of [false, true]) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Jubba`;
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} protects the genuinely shipped original counter and resumes one Weather storm`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const inventory = stock(f.game), paid = resources(f.game);
assert.equal(f.afterShipment.turn, 1);
assert.equal(f.afterShipment.phase, 5);
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 2);
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 1);
assert.equal(player(f.afterShipment, f.owner).forces[f.origin], 1);
assert.equal(player(f.game, f.owner).reserves, player(f.afterShipment, f.owner).reserves);
assert.equal(f.beforeWeather.turn, 2);
assert.equal(f.beforeWeather.phase, 0);
assert.equal(f.beforeWeather.storm, 1);
assert.notEqual(f.weatherOwner, f.owner);
assert.equal(player(f.beforeWeather, f.weatherOwner).hand.filter(card => card.id === f.weatherCard).length, 1);
assert.equal(player(f.afterWeather, f.weatherOwner).hand.some(card => card.id === f.weatherCard), false);
assert.equal(f.afterWeather.stormPending, f.weatherAmount);
assert.equal(f.game.stormMovementSource?.kind, 'weather');
assert.equal(f.game.stormMovementSource?.distance, f.weatherAmount);
assert.equal(f.game.discard.filter(card => card.id === f.weatherCard).length, 1);
assert.equal(player(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true,
'The ordinary Weather receipt was passed natively, not claimed');
assert.equal(f.game.decision?.kind, 'choamStorm');
if (f.game.decision?.kind !== 'choamStorm') throw Error('Missing original Storm protection decision');
assert.equal(f.game.decision.player, f.owner);
assert.throws(() => applyAction(f.game, f.weatherOwner, f.action),
'Another original seat cannot protect itself using CHOAM’s printed card');
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, territory: 'arrakeen' }),
'The original foreign Atreides territory is not a CHOAM protection target');
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, target: f.weatherOwner }),
'The real CHOAM decision cannot redirect protection to a foreign faction');
assert.deepEqual(stock(f.game), stock(f.setup));
assertConservedCounters(f.game, f.setup);
const declared = applyAction(f.game, f.owner, f.action);
assert.equal(declared.response?.kind, 'choamWorthless');
assert.equal(declared.pendingTreacheryDiscard, null, 'Jubba cannot be disposed before original Karama passes');
assert.equal(player(declared, f.owner).hand.some(card => card.id === f.card), true);
const offered = disposeSemutaHomeworldChoamStorm(f);
assert.equal(offered.pendingTreacheryDiscard?.continuation.kind, 'choamJubbaDiscard');
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(offered.storm, f.game.storm);
assert.equal(offered.stormResolution?.distance, f.weatherAmount);
assert.deepEqual(offered.stormResolution?.choamProtected ?? [], []);
assert.deepEqual(resources(offered), paid);
assert.deepEqual(stock(offered), inventory);
assert.equal(offered.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
const event = offered.pendingTreacheryDiscard!.batch.event;
assert.throws(() => applyAction(offered, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(offered, f.owner, f.action));
assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: `${event}:stale` }));
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.stormResolution, null);
assert.equal(resumed.storm, 7);
assert.equal(resumed.decision?.kind, 'choamMarket');
assert.equal(resumed.turn, 2);
assert.equal(player(resumed, f.owner).forces[f.origin], 1);
assert.equal(player(resumed, f.owner).tanks, 0);
assert.deepEqual(resources(resumed).players, paid.players,
'Protection moves no counter, pays no new cost and does not repeat the shipment');
const protectedSpice = Object.fromEntries(Object.entries(f.game.spice)
.filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector)));
assert.deepEqual(resumed.spice, protectedSpice,
'The original storm destroys spice only in the sectors it crosses');
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0);
assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1);
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
assert.equal(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(resumed.discard.filter(card => card.id === f.weatherCard).length, 1);
assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1);
assert.deepEqual(stock(resumed), inventory);
assertConservedCounters(resumed, f.setup);
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaPass', event }));
assert.throws(() => applyAction(resumed, f.owner, f.action));
assert.throws(() => applyAction(resumed, f.holder, f.action));
const completed = finishSemutaHomeworldChoamStorm(resumed);
assert.equal(completed.turn, 2);
assert.equal(completed.phase, 1);
assert.equal(completed.storm, 7);
assert.equal(player(completed, f.owner).forces[f.origin], 1);
assert.equal(player(completed, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(completed, f.owner).spice, player(f.game, f.owner).spice);
assert.deepEqual(stock(completed), inventory);
assertConservedCounters(completed, f.setup);
});
void test(`${label}: full-hand singleton exchange keeps entitlement private and rejects replayed receipt controls`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced, true);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const offered = disposeSemutaHomeworldChoamStorm(f);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const neutral = disposeSemutaHomeworldChoamStorm(f, absent);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
const event = offered.pendingTreacheryDiscard!.batch.event;
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id));
assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(offered, seat.id, { type: 'semutaCommit', event }));
}
assert.throws(() => applyAction(offered, 'outside-seat', { type: 'semutaPass', event }));
const passed = applyAction(offered, f.owner, { type: 'semutaPass', event });
assert.throws(() => applyAction(passed, f.owner, { type: 'semutaPass', event }));
assert.equal(passed.storm, 1);
assert.equal(player(passed, f.owner).forces[f.origin], 1);
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.card).length, 1);
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.deepEqual(stock(claimed), stock(f.game));
assertConservedCounters(claimed, f.setup);
assert.equal(player(finishSemutaHomeworldChoamStorm(claimed), f.owner).forces[f.origin], 1);
});
void test(`${label}: all four minimal bot profiles legally claim or pass and finish the original Weather storm`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const offered = disposeSemutaHomeworldChoamStorm(f);
for (const difficulty of DIFFICULTIES) {
let game: Game = structuredClone(offered);
const own = viewGame(game, f.holder);
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action, `${difficulty} has an entitled receipt action`);
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
game = applyAction(game, f.holder, action);
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer');
const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!;
game = applyAction(game, actor.id, { type: 'semutaPass', event: pending.batch.event });
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.storm, 7);
assert.equal(player(game, f.owner).forces[f.origin], 1);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), action.type === 'semutaCommit');
assert.deepEqual(resources(game).players, resources(f.game).players);
const botSpice = Object.fromEntries(Object.entries(f.game.spice)
.filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector)));
assert.deepEqual(game.spice, botSpice);
assert.deepEqual(stock(game), stock(f.game));
assertConservedCounters(game, f.setup);
const completed = finishSemutaHomeworldChoamStorm(game);
assert.equal(completed.phase, 1);
assert.equal(player(completed, f.owner).forces[f.origin], 1);
}
});
void test(`${label}: native decline leaves Jubba undisposed and the single unprotected original counter dies once`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const declined = applyAction(f.game, f.owner, { type: 'decision', decline: true });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.equal(declined.storm, 7);
assert.equal(declined.stormResolution, null);
assert.equal(declined.decision?.kind, 'choamMarket');
assert.equal(player(declined, f.owner).forces[f.origin] ?? 0, 0);
assert.equal(player(declined, f.owner).tanks, 1);
assert.equal(player(declined, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(declined, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(declined, f.owner).hand.filter(card => card.id === f.card).length, 1);
assert.equal(declined.discard.filter(card => card.id === f.card).length, 0);
assert.equal(player(declined, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
assert.equal(player(declined, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1);
assert.deepEqual(stock(declined), stock(f.game));
assertConservedCounters(declined, f.setup);
assert.throws(() => applyAction(declined, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(declined, f.owner, f.action));
assert.equal(finishSemutaHomeworldChoamStorm(declined).phase, 1);
});
for (const claim of [false, true]) void test(`${label}: real Karama cancellation Semuta ${claim ? 'claim' : 'pass'} decides the physical Karama while Jubba stays and the counter dies unprotected`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const inventory = stock(f.game), paid = resources(f.game);
const karama = player(f.game, f.canceller).hand.find(card => card.name === 'Karama');
assert.ok(karama);
const declared = applyAction(f.game, f.owner, f.action);
assert.equal(declared.response?.kind, 'choamWorthless');
const cancelled = applyAction(declared, f.canceller, { type: 'card', mode: 'cancel', card: karama.id });
assert.equal(cancelled.response, null);
assert.equal(cancelled.pendingTreacheryDiscard?.continuation.kind, 'karamaChoamWorthlessDiscard');
assert.equal(cancelled.decision, null);
assert.equal(cancelled.storm, f.game.storm);
assert.equal(player(cancelled, f.owner).forces[f.origin], 1);
assert.equal(player(cancelled, f.owner).tanks, 0);
assert.equal(player(cancelled, f.owner).hand.filter(card => card.id === f.card).length, 1,
'A cancelled declaration never disposes the retained Jubba');
assert.equal(viewGame(cancelled, f.holder).semutaReaction?.canCommit, true);
const event = cancelled.pendingTreacheryDiscard!.batch.event;
for (const seat of cancelled.players.filter(seat => seat.id !== f.holder))
assert.throws(() => applyAction(cancelled, seat.id, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(cancelled, f.canceller, f.action),
'Jubba cannot be declared again during the Karama receipt');
assert.throws(() => applyAction(cancelled, f.canceller, { type: 'decision', decline: true }));
assert.equal(cancelled.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const resumed = reactSemutaHomeworldChoam(structuredClone(cancelled), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.decision?.kind, 'choamStorm', 'The safe original protection decision is reoffered');
assert.equal(player(resumed, f.owner).forces[f.origin], 1);
assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.card).length, 1);
assert.equal(resumed.discard.filter(card => card.id === f.card).length, 0);
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === karama.id).length, claim ? 1 : 0);
assert.equal(resumed.discard.filter(card => card.id === karama.id).length, claim ? 0 : 1);
assert.equal(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
assert.deepEqual(stock(resumed), inventory);
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
const resolved = applyAction(resumed, f.owner, { type: 'decision', decline: true });
assert.equal(resolved.storm, 7);
assert.equal(resolved.stormResolution, null);
assert.equal(resolved.decision?.kind, 'choamMarket');
assert.equal(player(resolved, f.owner).forces[f.origin] ?? 0, 0, 'Only the threatened original counter is lost');
assert.equal(player(resolved, f.owner).tanks, 1);
assert.equal(player(resolved, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(resolved, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(resolved, f.owner).hand.filter(card => card.id === f.card).length, 1);
assert.equal(player(resolved, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1);
assert.equal(player(resolved, f.holder).hand.filter(card => card.id === karama.id).length, claim ? 1 : 0);
assert.deepEqual(resources(resolved).players, paid.players.map(seat => seat.id === f.owner
? { ...seat, forces: {}, tanks: 1 }
: seat), 'Only the genuinely threatened original counter becomes a Tank');
const crossedSpice = Object.fromEntries(Object.entries(f.game.spice)
.filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector)));
assert.deepEqual(resolved.spice, crossedSpice);
assert.deepEqual(stock(resolved), inventory);
assertConservedCounters(resolved, f.setup);
assert.throws(() => applyAction(resolved, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(resolved, f.canceller, { type: 'card', mode: 'cancel', card: karama.id }));
const completed = finishSemutaHomeworldChoamStorm(resolved);
assert.equal(completed.phase, 1);
assert.equal(completed.turn, 2);
assert.equal(player(completed, f.owner).forces[f.origin] ?? 0, 0);
assert.equal(player(completed, f.owner).tanks, 1);
assert.deepEqual(stock(completed), inventory);
assertConservedCounters(completed, f.setup);
});
}