Compose purchased shipment price with immune Junction source

This commit is contained in:
Quintine committed 2026-10-15 23:30:11 +11:00
1 parent 11adb7dbf3
commit 8f9f980075
13 files changed
+540 -122

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { viewGame, type Action } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { junctionTransportChoice } from '../game/junction-transport-options';
import { createPurchasedHomeworldRate } from './fixture-purchased-homeworld-rate';
import { pairedE3NexusReload, stepPairedE3Nexus, type PairedE3NexusStep } from './fixture-paired-e3-nexus-modules';
import { holdGuildCunningCard } from './fixture-nexus-guild-cunning';
export type PurchasedJunctionRoute = 'world' | 'arrakisReturn' | 'nativeArrival';
/** Actual auctioned card and original Guild high offer in either order. No
* played wallet/army/phase/response is assigned. Poverty is an actual bribe;
* the optional native-denial SECOND physical card is explicitly controlled. */
export function createJunctionPurchasedPrice(options: {
rate?: 'half' | 'full'; order?: 'cardFirst' | 'offerFirst';
route?: PurchasedJunctionRoute; poor?: boolean; nativeDenied?: boolean;
} = {}) {
const original = createPurchasedHomeworldRate('world');
const shipper = original.shipper, guild = original.guild, cardOwner = original.ecaz;
const order = options.order ?? 'cardFirst', rate = options.rate ?? 'full', route = options.route ?? 'world';
const actions: PairedE3NexusStep[] = [];
let game = pairedE3NexusReload(order === 'cardFirst' ? original.beforeAction : original.beforePurchase);
const source = pairedE3NexusReload(game);
if (options.poor) game = stepPairedE3Nexus(game, { actor: shipper,
action: { type: 'bribe', target: cardOwner, amount: game.players.find(p => p.id === shipper)!.spice - 2 } }, actions);
const offer = () => {
const projected = viewGame(game, guild).junctionTransport!;
assert.ok(projected.canOffer);
game = stepPairedE3Nexus(game, { actor: guild,
action: { type: 'offerJunctionTransport', event: projected.offerEvent, rate } }, actions);
};
if (order === 'offerFirst') {
offer();
game = stepPairedE3Nexus(game, { actor: cardOwner,
action: { type: 'card', mode: 'shipment', card: original.karama, target: shipper } }, actions);
}
if (options.nativeDenied) {
const second = holdGuildCunningCard(game, cardOwner, 'karama');
game = stepPairedE3Nexus(game, { actor: shipper, action: { type: 'guildShip',
from: 'polar_sink:0', territory: 'carthag', sector: 11, amount: 3, elite: 0, allyPayment: 0 } }, actions);
assert.equal(game.response?.kind, 'guildTransportPermission');
game = stepPairedE3Nexus(game, { actor: cardOwner,
action: { type: 'card', mode: 'cancel', card: second.id } }, actions);
assert.equal(viewGame(game, shipper).guildTransportCanceled, true);
}
if (order === 'cardFirst') offer();
const sand = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => Object.keys(p.forces).every(key => key.split(':')[0] !== t.id)))!;
assert.ok(sand);
const origin = route === 'arrakisReturn' ? 'polar_sink:0' : 'homeworld:moritani';
const destination = route === 'arrakisReturn' ? 'homeworld:moritani' : route === 'nativeArrival'
? `${sand.id}:${sand.sectors.find(s => s !== game.storm)!}` : 'homeworld:ecaz';
const sources = { [origin]: { normal: 3, elite: 0 } };
const quote = junctionTransportChoice(viewGame(game, shipper), destination, sources, 0);
assert.equal(quote.forfeitPurchasedRate, true);
return { original, source, beforeAction: pairedE3NexusReload(game), actions, shipper, guild,
cardOwner, card: original.karama, rate, order, route, origin, destination, sources, quote,
action: quote.action as Action | null,
provenance: [...original.provenance,
`Actual ${order} order; immutable ${rate} Junction offer, original typed ${route} group.`,
...(options.poor ? ['Actual bribe leaves beneficiary two available spice.'] : []),
...(options.nativeDenied ? ['CONTROLLED conserved second physical Karama; actual native permission denial, original purchased grant retained.'] : [])] };
}
+181
View File
@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { junctionTransportChoice, junctionTransportActions } from '../game/junction-transport-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { createJunctionPurchasedPrice } from './fixture-junction-purchased-price';
import { createPurchasedHomeworldRate, completePurchasedHomeworldRate } from './fixture-purchased-homeworld-rate';
import { holdGuildCunningCard } from './fixture-nexus-guild-cunning';
import { createGuildBetrayalFixture, settleGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
import { settleNativeGuildRateModules } from './fixture-native-guild-rate-modules';
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action);
function reject(g: Game, actor: string, action: Action) {
const copy = structuredClone(g), before = structuredClone(copy);
assert.throws(() => applyAction(copy, actor, action));
assert.deepEqual(copy, before);
}
for (const rate of ['half', 'full'] as const) for (const order of ['cardFirst', 'offerFirst'] as const) {
void test(`original ${order} ${rate} Junction source retains immune fee and requires explicit unused-card acceptance`, () => {
const f = createJunctionPurchasedPrice({ rate, order }), before = f.beforeAction;
assert.ok(f.action);
const cost = rate === 'half' ? 2 : 3;
assert.equal(f.quote.cost, cost);
assert.equal(f.quote.forfeitPurchasedRate, true);
assert.equal(f.action.forfeitPurchasedRate, true);
assert.equal(before.junctionOffer?.rate, rate);
const missing = { ...f.action }; delete missing.forfeitPurchasedRate;
reject(before, f.shipper, missing);
reject(before, f.shipper, { ...f.action, forfeitPurchasedRate: false });
const done = act(before, f.shipper, f.action);
assert.equal(player(done, f.shipper).spice, player(before, f.shipper).spice - cost);
assert.equal(player(done, f.guild).spice, player(before, f.guild).spice);
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves - 3);
assert.deepEqual(done.homeworlds!.custody!.visitors[f.destination][f.shipper], { normal: 3, elite: 0 });
assert.equal(player(done, f.shipper).shipped, true);
assert.equal(player(done, f.shipper).moved, player(before, f.shipper).moved);
assert.ok(!done.karamaShipping);
assert.equal(done.discard.filter(c => c.id === f.card).length, 1);
assert.ok(!player(done, f.cardOwner).hand.some(c => c.id === f.card));
assert.ok(!done.response);
assert.ok(!done.decision);
reject(done, f.shipper, f.action);
homeworldGameIntegrity(done);
const next = act(done, f.shipper, { type: 'endMovement' });
assert.notEqual(next.active, f.shipper);
assert.equal(player(next, f.shipper).spice, player(done, f.shipper).spice);
});
}
void test('original unaffordable immune full offer preserves the purchased grant; half replacement needs its own current identity', () => {
const f = createJunctionPurchasedPrice({ poor: true }), before = f.beforeAction;
assert.equal(player(before, f.shipper).spice, 2);
assert.equal(f.quote.cost, 3);
assert.ok(!f.action);
assert.ok(f.quote.blocked);
const option = viewGame(before, f.shipper).junctionTransport!;
const original: Action = { type: 'junctionShip', event: option.event, offer: option.offer!.event,
destination: f.destination, sources: f.sources, allyPayment: 0, forfeitPurchasedRate: true };
reject(before, f.shipper, original);
assert.ok(player(before, f.cardOwner).hand.every(c => c.id !== f.card));
assert.equal(before.karamaShipping?.card, f.card);
const sponsor = viewGame(before, f.guild).junctionTransport!;
const changed = act(before, f.guild, { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' });
reject(changed, f.shipper, original);
assert.equal(changed.karamaShipping?.card, f.card);
const replacement = junctionTransportChoice(viewGame(changed, f.shipper), f.destination, f.sources);
assert.equal(replacement.cost, 2);
assert.ok(replacement.action);
const done = act(changed, f.shipper, replacement.action);
assert.equal(player(done, f.shipper).spice, 0);
assert.deepEqual(done.homeworlds!.custody!.visitors[f.destination][f.shipper], { normal: 3, elite: 0 });
assert.ok(!done.karamaShipping);
});
void test('original optional Junction offer does not force forfeiture when the recipient chooses its ordinary purchased route', () => {
const f = createJunctionPurchasedPrice({ rate: 'full' });
const done = completePurchasedHomeworldRate(act(f.beforeAction, f.shipper, f.original.action)).game;
assert.equal(player(done, f.shipper).spice, player(f.beforeAction, f.shipper).spice - 2);
assert.deepEqual(done.homeworlds!.custody!.visitors[f.destination][f.shipper], { normal: 3, elite: 0 });
assert.equal(done.discard.filter(c => c.id === f.card).length, 1);
assert.ok(!done.karamaShipping);
assert.equal(player(done, f.guild).spice, player(f.beforeAction, f.guild).spice);
});
void test('a Junction quote made before actual purchased-card activation cannot silently accept the new unused-grant effect', () => {
const f = createPurchasedHomeworldRate('world');
let g = structuredClone(f.beforePurchase);
const sponsor = viewGame(g, f.guild).junctionTransport!;
g = act(g, f.guild, { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'full' });
const old = junctionTransportChoice(viewGame(g, f.shipper), f.destination, f.sources);
assert.ok(old.action);
assert.equal(old.cost, 3);
g = act(g, f.ecaz, { type: 'card', mode: 'shipment', card: f.karama, target: f.shipper });
reject(g, f.shipper, old.action);
const current = junctionTransportChoice(viewGame(g, f.shipper), f.destination, f.sources);
assert.ok(current.action);
assert.equal(current.cost, 3);
const without = { ...current.action }; delete without.forfeitPurchasedRate;
reject(g, f.shipper, without);
assert.equal(g.karamaShipping?.card, f.karama);
});
void test('original unused purchased price does not cancel the separate native Guild receipt on sponsored reserve arrival', () => {
const f = createJunctionPurchasedPrice({ route: 'nativeArrival', rate: 'full' });
assert.ok(f.action);
assert.equal(f.quote.cost, 6);
const done = settleNativeGuildRateModules(act(f.beforeAction, f.shipper, f.action)).game;
assert.equal(player(done, f.shipper).spice, player(f.beforeAction, f.shipper).spice - 6);
assert.equal(player(done, f.guild).spice, player(f.beforeAction, f.guild).spice + 6);
assert.equal(player(done, f.shipper).forces[f.destination], 3);
assert.ok(!done.karamaShipping);
});
void test('controlled second-card native denial does not remove immune Junction permission or change its acknowledged full tariff', () => {
const f = createJunctionPurchasedPrice({ nativeDenied: true, route: 'arrakisReturn', rate: 'full' });
assert.equal(viewGame(f.beforeAction, f.shipper).guildTransportCanceled, true);
assert.equal(f.beforeAction.karamaShipping?.card, f.card);
assert.ok(f.action);
assert.equal(f.quote.cost, 3);
const done = act(f.beforeAction, f.shipper, f.action);
assert.equal(player(done, f.shipper).spice, player(f.beforeAction, f.shipper).spice - 3);
assert.equal(player(done, f.shipper).reserves, player(f.beforeAction, f.shipper).reserves + 3);
assert.equal(player(done, f.shipper).forces[f.origin], player(f.beforeAction, f.shipper).forces[f.origin] - 3);
assert.ok(!done.karamaShipping);
assert.ok(!done.response);
});
for (const use of [false, true]) {
void test(`controlled purchased Junction acknowledgement survives deferred Betrayal ${use ? 'Use' : 'Pass'} until actual delivery`, () => {
const f = createGuildBetrayalFixture({ source: 'junction', payer: 'holder' });
const before = structuredClone(f.before);
// Explicit controlled physical acquisition on the existing native invoice
// fixture; gift, offer acceptance and both source continuations are real.
const card = holdGuildCunningCard(before, f.guild, 'karama');
let g = act(before, f.guild, { type: 'card', mode: 'shipment', card: card.id, target: f.shipper });
const choice = junctionTransportChoice(viewGame(g, f.shipper), String(f.declaration.destination),
f.declaration.sources as Record<string, { normal: number; elite: number }>, 0);
assert.ok(choice.action);
assert.equal(choice.cost, f.price);
const purchased = structuredClone(g);
g = act(g, f.shipper, choice.action);
assert.ok(g.pendingGuildBetrayal);
assert.equal(g.karamaShipping?.card, card.id);
assert.equal(player(g, f.shipper).spice, player(purchased, f.shipper).spice);
assert.deepEqual(g.homeworlds, purchased.homeworlds);
const event = g.pendingGuildBetrayal.invoice.event;
if (use) g = act(g, f.holder, { type: 'guildBetrayalUse', event });
g = settleGuildBetrayalFixture(g);
assert.ok(!g.karamaShipping);
assert.equal(player(g, f.shipper).shipped, true);
assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
assert.ok(!player(g, f.guild).hand.some(c => c.id === card.id));
assert.deepEqual(player(g, f.shipper).forces, player(f.original, f.shipper).forces);
assert.equal(player(g, f.shipper).reserves, player(f.original, f.shipper).reserves);
assert.equal(player(g, f.shipper).spice, player(purchased, f.shipper).spice - (use ? 0 : f.price));
assert.ok(!g.pendingGuildBetrayal);
});
}
void test('all four original sponsored-route policies acknowledge the unused purchase and execute the immune quote once', () => {
const f = createJunctionPurchasedPrice({ rate: 'full' });
for (const difficulty of [0, 1, 2, 3]) {
const view = viewGame(f.beforeAction, f.shipper);
const action = junctionTransportActions(view, difficulty)[0];
assert.ok(action);
const quote = junctionTransportChoice(view, String(action.destination),
action.sources as Record<string, { normal: number; elite: number }>, Number(action.allyPayment));
const done = settleNativeGuildRateModules(act(f.beforeAction, f.shipper, action)).game;
assert.equal(player(done, f.shipper).spice, player(f.beforeAction, f.shipper).spice - quote.cost);
assert.equal(player(done, f.shipper).shipped, true);
assert.equal(player(done, f.shipper).moved, player(f.beforeAction, f.shipper).moved);
assert.ok(!done.karamaShipping);
assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
if (String(action.destination).startsWith('homeworld:'))
assert.deepEqual(done.homeworlds!.custody!.visitors[String(action.destination)][f.shipper],
{ normal: quote.amount, elite: 0 });
else assert.equal(player(done, f.shipper).forces[String(action.destination)],
(player(f.beforeAction, f.shipper).forces[String(action.destination)] ?? 0) + quote.amount);
}
});