Compose purchased shipment price with immune Junction source
This commit is contained in:
1 parent
11adb7dbf3
commit
8f9f980075
13 files changed
+540
-122
No files matched your search
@@ -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.'] : [])] };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user