Preserve purchased price through Semuta without duplicate stop refund

This commit is contained in:
Quintine committed 2026-10-16 00:55:57 +11:00
1 parent 8386b41015
commit 44c2bf0da4
11 files changed
+414 -16

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { createSemutaGuildTransport } from './fixture-semuta-guild-transport';
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
export function createSemutaShipmentPrice(options: { advanced?: boolean; homeworlds?: boolean } = {}) {
const original = createSemutaGuildTransport({ advanced: options.advanced,
route: options.homeworlds ? 'world' : 'reserve' });
let game = structuredClone(original.boughtKarama);
const actions: { actor: string; action: Action }[] = [];
for (let n = 0; !(game.phase === 5 && game.active === 'a' && !game.response &&
!game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard) && n < 1600; n++) {
const next = nextSemutaGuildIncomeNativeStep(game); actions.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 5); assert.equal(game.active, 'a');
assert.ok(game.players.find(p => p.id === 'e')!.hand.some(card => card.id === original.karama));
assert.ok(game.players.find(p => p.id === 'g')!.hand.some(card => card.id === original.semuta));
const beforeAction = structuredClone(game);
const action: Action = { type: 'card', card: original.karama, mode: 'shipment', target: 'a' };
game = applyAction(game, 'e', action);
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:shipment');
assert.equal(game.karamaShipping, null);
return { original, actions, beforeAction, action, game,
owner: 'e', beneficiary: 'a', guild: 'g', holder: 'g',
karama: original.karama, semuta: original.semuta,
provenance: [...original.provenance,
'Fork from actual paid Karama purchase; real native progression to the original Atreides shipment turn.',
'Emperor actually spends its own printed card for the other active player; no played state assigned.'] };
}
export function reactSemutaShipmentPrice(state: Game, claim: boolean) {
const pending = state.pendingTreacheryDiscard; assert.ok(pending);
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
const event = pending.batch.event;
let game = state;
if (claim) return applyAction(game, 'g', { type: 'semutaCommit', event });
for (const seat of game.players.filter(p => !reaction.passed.includes(p.id)))
game = applyAction(game, seat.id, { type: 'semutaPass', event });
return game;
}
export function semutaPurchasedShipment(game: Game, homeworlds = false) {
if (homeworlds) {
const quote = homeworldShipmentChoice(viewGame(game, 'a'), 'homeworld:emperor',
{ 'homeworld:atreides': { normal: 3, elite: 0 } });
assert.ok(quote.action, quote.blocked ?? undefined);
return { action: quote.action, target: 'homeworld:emperor', amount: 3, cost: 2 };
}
const destination = TERRITORIES.find(t => t.type === 'sand' &&
t.sectors.some(s => s !== game.storm) && game.players.every(p => presenceAt(p, t.id) === 0));
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
const action: Action = { type: 'ship', territory: destination.id, sector,
amount: 3, elite: 0, allyPayment: 0 };
return { action, target: `${destination.id}:${sector}`, amount: 3, cost: 3 };
}
export function allowSemutaPurchasedShipment(state: Game) {
let game = state;
if (game.decision?.kind === 'guildShipment')
game = applyAction(game, 'g', { type: 'decision', allow: true });
else if (game.decision?.kind === 'homeworldShipmentGuild')
game = applyAction(game, 'g', { type: 'decision', event: game.decision.event, allow: true });
return game;
}
+110
View File
@@ -0,0 +1,110 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createSemutaShipmentPrice, reactSemutaShipmentPrice,
semutaPurchasedShipment, allowSemutaPurchasedShipment,
} from './fixture-semuta-shipment-price';
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
for (const advanced of [false, true]) for (const homeworlds of [false, true])
void test(`${advanced ? 'Advanced' : 'Basic'} ${homeworlds ? 'typed world' : 'reserve'} price survives Semuta claim or decline and pays only bank once`, () => {
for (const claim of [false, true]) {
const f = createSemutaShipmentPrice({ advanced, homeworlds });
const beneficiary = player(f.game, f.beneficiary);
const spice = beneficiary.spice, reserves = beneficiary.reserves;
const guildSpice = player(f.game, f.guild).spice;
assert.equal(f.game.karamaShipping, null);
assert.equal(beneficiary.shipped, false);
reject(f.game, f.beneficiary, { type: 'endMovement' });
let game = reactSemutaShipmentPrice(f.game, claim);
assert.equal(viewGame(game, f.beneficiary).karamaShipping!.owner, f.owner);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.karama), claim);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.semuta), !claim);
assert.equal(player(game, f.beneficiary).spice, spice);
assert.equal(player(game, f.beneficiary).reserves, reserves);
const shipment = semutaPurchasedShipment(game, homeworlds);
game = applyAction(game, f.beneficiary, shipment.action);
game = allowSemutaPurchasedShipment(game);
assert.equal(player(game, f.beneficiary).spice, spice - shipment.cost);
assert.equal(player(game, f.beneficiary).reserves, reserves - 3);
assert.equal(player(game, f.guild).spice, guildSpice);
assert.equal(player(game, f.beneficiary).shipped, true);
assert.equal(player(game, f.beneficiary).moved, 0);
if (homeworlds) assert.deepEqual(game.homeworlds!.custody!.visitors[shipment.target][f.beneficiary], { normal: 3, elite: 0 });
else assert.equal(player(game, f.beneficiary).forces[shipment.target], 3);
assert.equal(game.karamaShipping, null);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.karama), claim);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, claim ? 1 : 0);
reject(game, f.beneficiary, shipment.action);
}
});
for (const homeworlds of [false, true])
void test(`the SAME recovered price card can stop ${homeworlds ? 'typed world' : 'reserve'} shipment without being refunded to its former owner`, () => {
const f = createSemutaShipmentPrice({ homeworlds });
let game = reactSemutaShipmentPrice(f.game, true);
const original = structuredClone(player(game, f.beneficiary));
const shipment = semutaPurchasedShipment(game, homeworlds);
game = applyAction(game, f.beneficiary, shipment.action);
game = applyAction(game, f.guild, { type: 'card', card: f.karama, mode: 'special' });
const shipper = player(game, f.beneficiary);
assert.equal(shipper.spice, original.spice);
assert.equal(shipper.reserves, original.reserves);
assert.deepEqual(shipper.forces, original.forces);
assert.equal(shipper.shipped, true);
assert.equal(shipper.moved, 0);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.karama), false);
assert.equal(player(game, f.guild).hand.some(card => card.id === f.karama), false);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(game.karamaShipping, null);
if (homeworlds) assert.equal(game.homeworlds!.custody!.visitors[shipment.target]?.[f.beneficiary], undefined);
else assert.equal(shipper.forces[shipment.target] ?? 0, 0);
game = applyAction(game, f.beneficiary, { type: 'endMovement' });
assert.notEqual(game.active, f.beneficiary);
});
void test('declining the price cost retains its existing exact-card refund when another physical card pays the stop', () => {
for (const homeworlds of [false, true]) {
const f = createSemutaShipmentPrice({ homeworlds });
let game = reactSemutaShipmentPrice(f.game, false);
// CONTROLLED second-card acquisition, not an original-action producer.
// The original price and actual Guild stop still use distinct physical IDs.
const at = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0);
const stop = game.deck.splice(at, 1)[0]; player(game, f.guild).hand.push(stop);
const before = structuredClone(player(game, f.beneficiary));
const shipment = semutaPurchasedShipment(game, homeworlds);
game = applyAction(game, f.beneficiary, shipment.action);
game = applyAction(game, f.guild, { type: 'card', card: stop.id, mode: 'special' });
assert.equal(player(game, f.beneficiary).spice, before.spice);
assert.equal(player(game, f.beneficiary).reserves, before.reserves);
assert.equal(player(game, f.beneficiary).shipped, true);
assert.equal(player(game, f.owner).hand.filter(card => card.id === f.karama).length, 1);
assert.equal(game.discard.some(card => card.id === f.karama), false);
assert.equal(game.discard.filter(card => card.id === stop.id).length, 1);
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.equal(game.karamaShipping, null);
}
});
void test('wrong claimant and stale price-cost events cannot activate, duplicate or replace the original benefit', () => {
const f = createSemutaShipmentPrice();
const event = f.game.pendingTreacheryDiscard!.batch.event;
reject(f.game, f.owner, { type: 'semutaCommit', event });
reject(f.game, f.holder, { type: 'semutaCommit', event: 'old' });
reject(f.game, f.owner, f.action);
const game = reactSemutaShipmentPrice(f.game, true);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'card', card: f.karama, mode: 'shipment', target: f.beneficiary });
const shipment = semutaPurchasedShipment(game);
const done = allowSemutaPurchasedShipment(applyAction(game, f.beneficiary, shipment.action));
assert.equal(player(done, f.beneficiary).spice, 7);
assert.equal(player(done, f.beneficiary).forces[shipment.target], 3);
});