Files
Dune/tests/kull-guild-semuta.test.ts
Quintine 71009d73e3
Verify and publish checkpoint container / container (push) Failing after 6m28s
Support Kull during Basic Guild and revival responses
2026-10-28 20:57:25 +11:00

133 lines
7.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { act, createKullGuildSemutaFixture, inventory, player, type GuildRoute } from './fixture-kull-guild-semuta';
type Fixture = ReturnType<typeof createKullGuildSemutaFixture>;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const held = (game: Game, id: string, card: string) => player(game, id).hand.some(item => item.id === card);
function originalUnpaid(game: Game, f: Fixture) {
const actual = player(game, f.guild), before = player(f.beforeShipment, f.guild);
assert.equal(actual.spice, before.spice);
assert.equal(actual.reserves, before.reserves);
assert.deepEqual(actual.forces, before.forces);
assert.equal(actual.shipped, false);
}
function attempt(f: Fixture) {
const game = act(reload(f.game), f.actor, { type: 'card', mode: 'cancel', card: f.karama });
assert.equal(game.pendingKull?.stage, 'offer'); originalUnpaid(game, f);
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
assert.ok(choice.plays.some(row => row.source === 'printed' && row.card.id === f.kull && !row.blocked),
'The public declared Guild-rate cancellation must offer the held Kull.');
return game;
}
function declare(game: Game, f: Fixture) {
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
const next = act(game, f.reactor, { type: 'kullDecision', event: choice.event, source: 'printed', card: f.kull });
assert.equal(next.pendingKull?.stage, 'counter'); originalUnpaid(next, f); return next;
}
function passResponsesToDiscard(game: Game) {
for (let count = 0; count < 12 && !game.pendingTreacheryDiscard; count++) {
assert.ok(game.response);
const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, { type: 'passResponse' });
}
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); return game;
}
function react(game: Game, f: Fixture, card: string, claim: boolean) {
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
const event = pending.batch.event;
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [card]);
originalUnpaid(game, f);
if (claim) {
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
game = act(game, f.holder, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, f.holder, { type: 'semutaSelect', event, card });
} else {
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
const seat = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, { type: 'semutaPass', event });
}
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
assert.equal(held(game, f.holder, card), claim);
assert.deepEqual(inventory(game), f.originalCards);
assert.throws(() => act(game, f.holder, { type: 'semutaCommit', event }));
return game;
}
/** Resume only the original response and its fresh discards, not a new shipment. */
function finishShipment(game: Game, f: Fixture) {
for (let count = 0; count < 32; count++) {
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const reaction = game.pendingTreacheryDiscard.reaction, event = game.pendingTreacheryDiscard.batch.event;
const seat = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, { type: 'semutaPass', event }); continue;
}
if (game.response) {
const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, { type: 'passResponse' }); continue;
}
break;
}
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(game.response ?? null, null);
assert.equal(game.pendingKull ?? null, null);
assert.deepEqual(inventory(game), f.originalCards);
return game;
}
function settled(game: Game, f: Fixture, rateCanceled: boolean) {
const before = player(f.beforeShipment, f.guild), after = player(game, f.guild);
const half = (f.game.pendingShipment ?? f.game.pendingGuildTransport)!.cost;
const full = f.route === 'return' ? 2 : 4;
const cost = rateCanceled ? full : half;
if (rateCanceled && before.spice < full) {
assert.equal(after.shipped, false); assert.equal(after.spice, before.spice);
assert.equal(after.reserves, before.reserves); assert.deepEqual(after.forces, before.forces);
return;
}
assert.equal(after.shipped, true); assert.equal(after.spice, before.spice - cost);
assert.equal(after.reserves, before.reserves + (f.route === 'reserve' ? -2 : f.route === 'return' ? 2 : 0));
assert.equal(after.forces[f.source] ?? 0, (before.forces[f.source] ?? 0) - (f.route === 'reserve' ? 0 : 2));
if (f.route !== 'return') assert.equal(after.forces[f.target] ?? 0, 2);
}
for (const route of ['reserve', 'cross', 'return'] as GuildRoute[]) for (const claim of [false, true]) {
void test(`Basic Kull stops Guild ${route} rate cancellation and Semuta ${claim ? 'claims' : 'passes'}`, () => {
const f = createKullGuildSemutaFixture({ route });
let game = passResponsesToDiscard(declare(attempt(f), f));
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'kullSuccessDiscard');
game = react(game, f, f.kull, claim);
assert.ok(held(game, f.actor, f.karama));
game = finishShipment(game, f); settled(game, f, false);
});
void test(`Basic Guild ${route} Kull counter and Semuta ${claim ? 'claim' : 'pass'} resume the original cancellation`, () => {
const f = createKullGuildSemutaFixture({ route });
let game = act(declare(attempt(f), f), f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'kullCounterDiscard');
game = react(game, f, f.counter, claim);
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim);
assert.equal(held(game, f.actor, f.karama), false);
game = finishShipment(game, f); settled(game, f, true);
});
}
void test('Declining Kull continues the original Guild-rate Karama and its separate Semuta offer', () => {
const f = createKullGuildSemutaFixture(); let game = attempt(f);
game = act(game, f.reactor, { type: 'kullDecision', event: game.pendingKull!.event, decline: true });
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'karamaShipmentDiscard');
game = react(game, f, f.karama, true);
assert.ok(held(game, f.reactor, f.kull));
game = finishShipment(game, f); settled(game, f, true);
});
void test('Kull counter cannot make an unaffordable full-price Guild shipment occur', () => {
const f = createKullGuildSemutaFixture({ poor: true });
let game = act(declare(attempt(f), f), f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
game = react(game, f, f.counter, false);
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, true);
game = finishShipment(game, f); settled(game, f, true);
});