Separate Guild transport permission, purchase price and loan approval

This commit is contained in:
Quintine committed 2026-10-15 22:52:37 +11:00
1 parent a12250320c
commit 11adb7dbf3
39 files changed
+1576 -121

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import {
alliedMarkerRatePlayer as player, createAlliedMarkerGuildRate, createAlliedMarkerGuildRateScene,
declareAlliedMarkerGuildRate, passAlliedMarkerRateResponses, stepAlliedMarkerRate,
type AlliedMarkerGuildRateFixture,
} from './fixture-allied-marker-guild-rate';
function conserve(g: Game) {
for (const p of g.players)
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)];
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
}
function uncommitted(f: AlliedMarkerGuildRateFixture, g: Game) {
for (const id of [f.owner, f.recipient]) {
const p = player(g, id), before = player(f.beforeAction, id);
assert.equal(p.spice, before.spice);
assert.equal(p.reserves, before.reserves);
assert.deepEqual(p.forces, before.forces);
assert.equal(p.shipped, before.shipped);
assert.deepEqual(p.noField, before.noField);
assert.equal(p.noFieldEvent, before.noFieldEvent);
}
conserve(g);
}
function settled(f: AlliedMarkerGuildRateFixture, before: Game, after: Game, cost: number,
payer = f.payer) {
const owner = player(after, f.owner), recipient = player(after, f.recipient);
assert.equal(owner.spice, player(before, f.owner).spice - (payer === f.owner ? cost : 0));
assert.equal(recipient.spice, player(before, f.recipient).spice - (payer === f.recipient ? cost : 0));
assert.equal(owner.forces[f.priorSource], 3);
assert.equal(owner.reserves, 17);
assert.equal(owner.forces[f.target] ?? 0, 0);
assert.equal(recipient.forces[f.target] ?? 0, f.amount);
assert.equal(recipient.reserves, 20 - f.amount);
assert.equal(recipient.shipped, true);
assert.equal(owner.shipped, false);
assert.equal(owner.noField!.lastShipped, f.token);
assert.equal(owner.noField!.deployed, null);
assert.deepEqual(owner.noField!.tokens, player(before, f.owner).noField!.tokens);
assert.notEqual(owner.noFieldEvent, player(before, f.owner).noFieldEvent);
assert.equal(recipient.noField, player(before, f.recipient).noField);
assert.equal(after.pendingShipment, null);
assert.equal(after.richeseAllyOffer, null);
assert.equal(after.response, null);
assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0),
before.players.reduce((sum, p) => sum + p.spice, 0) - cost,
'The original Guild-recipient contract still pays the bank.');
conserve(after);
}
for (const token of ['zero', 'five'] as const) {
void test(`controlled allied ${token}: native cancellation charges one full-price marker, then materializes only Guild reserves`, () => {
const f = createAlliedMarkerGuildRateScene({ token });
assert.equal(f.accepted.response?.kind, 'richeseNoField');
uncommitted(f, f.game);
assert.deepEqual(player(f.game, f.owner).noField!.deployed, {
tokenId: 'three', controller: f.owner, location: { territory: 'red_chasm', sector: 7 },
});
const pending = f.game.pendingShipment!;
assert.equal(pending.player, f.recipient);
assert.equal(pending.amount, f.amount);
assert.equal(pending.cost, 1);
assert.equal(pending.alliedNoField!.owner, f.owner);
assert.equal(pending.alliedNoField!.recipient, f.recipient);
assert.equal(pending.alliedNoField!.tokenId, token);
assert.equal(pending.alliedNoField!.payer, f.recipient);
const done = stepAlliedMarkerRate(f.game, f.cancel);
settled(f, f.beforeAction, done, 2);
assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
assert.equal(player(done, f.observer).hand.some(c => c.id === f.karama), false);
const beforeReplay = structuredClone(done);
assert.throws(() => stepAlliedMarkerRate(done, f.cancel));
assert.deepEqual(done, beforeReplay);
});
}
void test('controlled Basic allied loan: allowing the native rate pays one bank spice and preserves actual five-force custody', () => {
const f = createAlliedMarkerGuildRateScene({ advanced: false });
const done = passAlliedMarkerRateResponses(f.game, 'guildRate');
settled(f, f.beforeAction, done, 1);
assert.ok(player(done, f.observer).hand.some(c => c.id === f.karama));
});
void test('controlled recipient poverty: cancellation releases token, wallets and army; ordinary full-price replacement remains legal', () => {
const f = createAlliedMarkerGuildRateScene({ unaffordable: true });
assert.equal(player(f.beforeAction, f.recipient).spice, 1);
const released = stepAlliedMarkerRate(f.game, f.cancel);
uncommitted(f, released);
assert.equal(released.pendingShipment, null);
assert.equal(released.response, null);
assert.equal(viewGame(released, f.recipient).guildRateCanceled, true);
assert.equal(released.discard.filter(c => c.id === f.karama).length, 1);
const beforeInvalid = structuredClone(released);
assert.throws(() => stepAlliedMarkerRate(released, { actor: f.recipient,
action: { type: 'ship', amount: 1, territory: f.territory, sector: f.sector } }));
assert.deepEqual(released, beforeInvalid);
let done = stepAlliedMarkerRate(released, { actor: f.recipient,
action: { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 } });
if (done.decision?.kind === 'guildShipment') done = stepAlliedMarkerRate(done,
{ actor: f.recipient, action: { type: 'decision', allow: true } });
assert.equal(done.response, null);
assert.equal(done.pendingShipment, null);
assert.equal(player(done, f.recipient).spice, 0);
assert.equal(player(done, f.recipient).forces['arrakeen:10'], 1);
assert.equal(player(done, f.recipient).reserves, 19);
assert.equal(player(done, f.recipient).shipped, true);
assert.deepEqual(player(done, f.owner).noField, player(f.beforeAction, f.owner).noField);
assert.equal(player(done, f.owner).spice, 10);
conserve(done);
});
void test('controlled supplier-funded half-price loan cannot enlarge lender approval; a new full-price offer needs new recipient consent', () => {
const f = createAlliedMarkerGuildRateScene({ payer: 'supplier' });
assert.equal(player(f.game, f.owner).spice, 10, 'Release must not depend on supplier poverty.');
const released = stepAlliedMarkerRate(f.game, f.cancel);
uncommitted(f, released);
assert.equal(released.pendingShipment, null);
assert.equal(viewGame(released, f.recipient).guildRateCanceled, true);
assert.equal(released.discard.filter(c => c.id === f.karama).length, 1);
const newOffer = stepAlliedMarkerRate(released, { actor: f.owner, action: f.action });
assert.notEqual(newOffer.richeseAllyOffer!.event, f.offer.event);
assert.equal(newOffer.richeseAllyOffer!.payer, f.owner);
assert.equal(viewGame(newOffer, f.recipient).richeseNoField!.allyOffer!.cost, 2);
uncommitted(f, newOffer);
const declined = stepAlliedMarkerRate(newOffer, { actor: f.recipient,
action: { type: 'decision', event: newOffer.richeseAllyOffer!.event, decline: true } });
uncommitted(f, declined);
const replacement = declareAlliedMarkerGuildRate(f, declined);
assert.notEqual(replacement.game.response?.kind, 'guildRate');
settled(f, f.beforeAction, replacement.game, 2);
assert.equal(player(replacement.game, f.recipient).spice, 10, 'Never rewrite the original supplier payer.');
});
void test('controlled canceled native rate does not cancel a subsequently played independent Karama loan price', () => {
const f = createAlliedMarkerGuildRateScene({ unaffordable: true, independentCard: true });
const released = stepAlliedMarkerRate(f.game, f.cancel);
uncommitted(f, released);
assert.ok(f.independentKarama);
const purchased = stepAlliedMarkerRate(released, { actor: f.observer,
action: { type: 'card', mode: 'shipment', target: f.recipient, card: f.independentKarama } });
assert.equal(viewGame(purchased, f.recipient).guildRateCanceled, true);
assert.equal(purchased.karamaShipping?.player, f.recipient);
assert.equal(purchased.karamaShipping?.card, f.independentKarama);
assert.equal(purchased.discard.filter(c => c.id === f.independentKarama).length, 1);
const replacement = declareAlliedMarkerGuildRate(f, purchased);
assert.notEqual(replacement.game.response?.kind, 'guildRate');
settled(f, f.beforeAction, replacement.game, 1);
assert.equal(replacement.game.karamaShipping, null);
assert.equal(replacement.game.discard.filter(c => c.id === f.karama).length, 1);
assert.equal(replacement.game.discard.filter(c => c.id === f.independentKarama).length, 1);
});
void test('controlled already-active independent Karama price bypasses the native rate window without changing Guild-recipient bank payee', () => {
const f = createAlliedMarkerGuildRate();
const purchased = stepAlliedMarkerRate(f.beforeAction, { actor: f.observer,
action: { type: 'card', mode: 'shipment', target: f.recipient, card: f.karama } });
const done = declareAlliedMarkerGuildRate(f, purchased).game;
assert.notEqual(done.response?.kind, 'guildRate');
settled(f, f.beforeAction, done, 1);
assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
});
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
export interface AlliedMarkerRateStep { actor: string; action: Action }
export interface AlliedMarkerGuildRateOptions {
token?: 'zero' | 'five';
payer?: 'supplier' | 'recipient';
unaffordable?: boolean;
independentCard?: boolean;
advanced?: boolean;
}
export interface AlliedMarkerGuildRateFixture {
initial: Game;
beforeAction: Game;
actions: AlliedMarkerRateStep[];
action: Action;
owner: string;
recipient: string;
observer: string;
token: 'zero' | 'five';
payer: string;
territory: string;
sector: number;
target: string;
priorSource: string;
karama: string;
independentKarama: string | null;
amount: number;
provenance: string[];
}
export const alliedMarkerRatePlayer = (g: Game, id: string) => {
const p = g.players.find(seat => seat.id === id); assert.ok(p); return p;
};
export const alliedMarkerRateReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export function stepAlliedMarkerRate(g: Game, step: AlliedMarkerRateStep, actions?: AlliedMarkerRateStep[]) {
const next = applyAction(alliedMarkerRateReload(g), step.actor, step.action);
actions?.push(structuredClone(step));
return next;
}
/** Bounded CONTROLLED source from richese-allied-no-field.test.ts, not an
* original setup/action-only chronology. Its turn, alliance, empty armies,
* ten-spice wallets and prior three-marker deployment are assigned here.
* Karama cards move out of the actual base deck, conserving physical identity;
* they are controlled acquisitions, not auction purchases. The original paired
* E3 producer excludes Richese from its supported roster. Every subsequent
* wallet reduction, offer, consent, response and settlement is an actual action.
*/
export function createAlliedMarkerGuildRate(options: AlliedMarkerGuildRateOptions = {}): AlliedMarkerGuildRateFixture {
const owner = 'r', recipient = 'g', observer = 'a';
let game = createGame('ALLIEDMARKERRATE', newPlayer(owner, 'Richese', 'richese'),
options.advanced ?? true, ['choam']);
game.players.push(newPlayer(recipient, 'Guild', 'guild'), newPlayer(observer, 'Atreides', 'atreides'));
game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = recipient;
game.order = [owner, recipient, observer]; game.movementRemaining = [recipient, owner, observer];
game.storm = 18; game.deck = baseDeck();
for (const p of game.players) {
p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 10; p.hand = [];
p.traitorChoices = []; p.traitors = [];
}
const supplier = alliedMarkerRatePlayer(game, owner);
supplier.ally = recipient; alliedMarkerRatePlayer(game, recipient).ally = owner;
supplier.noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), {
tokenId: 'three', controller: owner, location: { territory: 'red_chasm', sector: 7 },
});
supplier.noFieldEvent = 'event-a';
const cards: string[] = [];
for (let n = 0; n < (options.independentCard ? 2 : 1); n++) {
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1);
alliedMarkerRatePlayer(game, observer).hand.push(card); cards.push(card.id);
}
const initial = alliedMarkerRateReload(game), actions: AlliedMarkerRateStep[] = [];
if (options.unaffordable) game = stepAlliedMarkerRate(game, { actor: recipient,
action: { type: 'bribe', target: observer, amount: 9 } }, actions);
const token = options.token ?? 'five', payer = options.payer === 'supplier' ? owner : recipient;
const territory = 'imperial_basin', sector = 10;
const action: Action = { type: 'offerRicheseNoField', token, event: supplier.noFieldEvent,
payer, territory, sector };
return { initial, beforeAction: alliedMarkerRateReload(game), actions, action,
owner, recipient, observer, token, payer, territory, sector, target: `${territory}:${sector}`,
priorSource: 'red_chasm:7', karama: cards[0], independentKarama: cards[1] ?? null,
amount: token === 'zero' ? 0 : 5,
provenance: [
'CONTROLLED existing allied-loan setup: turn 2 Shipment, Richese/Guild alliance, 20 counters and 10 spice per seat, prior Richese-controlled three-marker in Red Chasm sector 7.',
'One or two real Karama identities are removed from the undealt base deck into Atreides custody; no original auction purchase is claimed.',
'Optional Guild poverty comes only from an actual nine-spice bribe. All loan offers, recipient consents, responses and physical settlement are actual actions from this bounded source.',
] };
}
/** Only pass a current physical card holder's actual cancel opportunity. */
export function passAlliedMarkerRateResponses(state: Game, kind: 'richeseNoField' | 'guildRate',
actions?: AlliedMarkerRateStep[]) {
let game = alliedMarkerRateReload(state);
for (let n = 0; game.response?.kind === kind && n < 20; n++) {
const actor = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
})?.id;
assert.ok(actor, 'The pending response needs a real eligible card holder.');
game = stepAlliedMarkerRate(game, { actor, action: { type: 'passResponse' } }, actions);
}
assert.notEqual(game.response?.kind, kind);
return game;
}
/** Includes original source fields before any price cancellation. */
export function declareAlliedMarkerGuildRate(f: AlliedMarkerGuildRateFixture, state = f.beforeAction,
action = f.action) {
const actions: AlliedMarkerRateStep[] = [];
let game = stepAlliedMarkerRate(state, { actor: f.owner, action }, actions);
const offer = structuredClone(game.richeseAllyOffer); assert.ok(offer);
const beforeAcceptance = alliedMarkerRateReload(game);
game = stepAlliedMarkerRate(game, { actor: f.recipient,
action: { type: 'decision', event: offer.event, accept: true, elite: 0 } }, actions);
const accepted = alliedMarkerRateReload(game);
game = passAlliedMarkerRateResponses(game, 'richeseNoField', actions);
if (game.decision?.kind === 'guildShipment') {
assert.equal(game.decision.amount, 1);
game = stepAlliedMarkerRate(game, { actor: f.recipient,
action: { type: 'decision', allow: true } }, actions);
}
return { game, actions, beforeAcceptance, accepted, offer };
}
/** API/UI-loadable genuine pending rate scene; no response or decision is staged. */
export function createAlliedMarkerGuildRateScene(options: AlliedMarkerGuildRateOptions = {}) {
const fixture = createAlliedMarkerGuildRate(options);
const declared = declareAlliedMarkerGuildRate(fixture);
assert.equal(declared.game.response?.kind, 'guildRate');
assert.equal(declared.game.pendingShipment?.cost, 1);
assert.ok(viewGame(declared.game, fixture.observer).responseControls?.cancelCards.includes(fixture.karama));
return { ...fixture, ...declared, declarationActions: declared.actions,
actions: [...fixture.actions, ...declared.actions],
original: {
offer: declared.offer, cost: 1, bankOnly: true,
source: { owner: fixture.owner, controller: fixture.recipient, tokenId: fixture.token,
tokenEvent: alliedMarkerRatePlayer(fixture.beforeAction, fixture.owner).noFieldEvent,
territory: fixture.territory, sector: fixture.sector },
supplierPayment: fixture.payer === fixture.owner ? 1 : 0,
recipientPayment: fixture.payer === fixture.recipient ? 1 : 0,
},
cancel: { actor: fixture.observer,
action: { type: 'card', mode: 'cancel', card: fixture.karama } as Action },
};
}
@@ -90,19 +90,32 @@ export function createGuildAmbassadorIncomeArrival(options: Pick<PairedE3NexusOp
] };
}
/** Stop at the real income/counter window, after physical transport but before entry. */
/** Retain the actual unpaid native permission declaration as one engine transition. */
export function declareGuildAmbassadorIncomeArrival(fixture: {
beforeAction: Game; entrant: string; action: Action;
}): PairedE3NexusTransition {
const before = pairedE3NexusReload(fixture.beforeAction);
const step = { actor: fixture.entrant, action: fixture.action };
const after = stepPairedE3Nexus(before, step);
assert.equal(after.response?.kind, 'guildIncome');
assert.equal(after.pendingArrivalReaction?.length, 1);
assert.equal(after.response?.kind, 'guildTransportPermission');
assert.ok(!after.pendingArrivalReaction);
assert.ok(!after.pendingAmbassador);
return { before, step, after };
}
/** Pass only the actual native permission window; rate/income choices stay separate. */
export function passNativeGuildTransportPermission(state: Game) {
let game = pairedE3NexusReload(state);
const actions: PairedE3NexusStep[] = [];
for (let n = 0; game.response?.kind === 'guildTransportPermission' && n < 20; n++) {
const step = nextPairedE3NexusStep(game); assert.ok(step);
assert.equal(step.action.type, 'passResponse');
game = stepPairedE3Nexus(game, step, actions);
}
assert.notEqual(game.response?.kind, 'guildTransportPermission');
return { game, actions };
}
/** Pass the actual persisted income queue; do not synthesize or clear controls. */
export function allowGuildAmbassadorIncomeArrival(state: Game) {
let game = pairedE3NexusReload(state);
+5 -1
View File
@@ -3,7 +3,7 @@ import { viewGame, type Action, type Game } from '../game/engine';
import { homeworldShipmentChoice, type HomeworldShipmentSources } from '../game/homeworld-shipment-options';
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { junctionTransportChoice } from '../game/junction-transport-options';
import { createGuildAmbassadorIncomeArrival } from './fixture-guild-ambassador-income-arrival';
import { createGuildAmbassadorIncomeArrival, passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
import {
advancePairedE3Nexus, nextPairedE3NexusStep, pairedE3NexusClean,
pairedE3NexusPlayer, pairedE3NexusReload, quietPairedE3NexusChoice,
@@ -91,6 +91,8 @@ export function declareNativeGuildHomeworldRate(f: NativeGuildHomeworldRateFixtu
game = stepPairedE3Nexus(game, { actor: f.guild,
action: { type: 'decision', event: game.decision.event, allow: true } }, actions);
}
const permission = passNativeGuildTransportPermission(game);
game = permission.game; actions.push(...permission.actions);
assert.equal(game.response?.kind, 'guildRate');
assert.ok(viewGame(game, f.ecaz).responseControls?.cancelCards.includes(f.karama));
return { game, actions };
@@ -133,6 +135,8 @@ export function createNativeImmuneJunctionRate(rate: 'half' | 'full' = 'half') {
action: { type: 'bribe', target: base.ecaz, amount: excess } }, actions);
game = stepPairedE3Nexus(game, { actor: base.entrant,
action: { ...base.action, amount: 6 } }, actions);
const permission = passNativeGuildTransportPermission(game);
game = permission.game; actions.push(...permission.actions);
assert.equal(game.response?.kind, 'guildRate');
game = stepPairedE3Nexus(game, { actor: base.ecaz,
action: { type: 'card', mode: 'cancel', card: base.karama } }, actions);
+3 -1
View File
@@ -3,7 +3,7 @@ import { viewGame, type Action, type Game } from '../game/engine';
import { distance, TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { quoteSmugglerShipment } from '../game/smuggler-shipment';
import { createGuildAmbassadorIncomeArrival } from './fixture-guild-ambassador-income-arrival';
import { createGuildAmbassadorIncomeArrival, passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
import {
nextPairedE3NexusStep, pairedE3NexusClean, pairedE3NexusPlayer,
pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus,
@@ -50,6 +50,8 @@ export function declareNativeGuildRateModules(fixture: {
if (game.decision?.kind === 'guildShipment')
game = stepPairedE3Nexus(game, { actor: fixture.guild,
action: { type: 'decision', allow: true } }, actions);
const permission = passNativeGuildTransportPermission(game);
game = permission.game; actions.push(...permission.actions);
assert.equal(game.response?.kind, 'guildRate');
return { before, actions, game };
}
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { createGuildAmbassadorIncomeArrival } from './fixture-guild-ambassador-income-arrival';
import { createNativeGuildHomeworldRate } from './fixture-native-guild-homeworld-rate';
import { advancePairedE3Nexus, pairedE3NexusClean, pairedE3NexusReload,
pairedE3NexusPlayer, quietPairedE3NexusChoice, stepPairedE3Nexus,
type PairedE3NexusStep } from './fixture-paired-e3-nexus-modules';
export type NativeGuildPermissionRoute = 'allyCross' | 'guildReturn' | 'homeworldReturn';
/** Original native setup/actions only. The typed return also retains its real
* pledge; no wallet, force, phase, card, alliance or pending state is staged. */
export function createNativeGuildTransportPermission(route: NativeGuildPermissionRoute = 'allyCross',
options: { amount?: number; homeworlds?: boolean } = {}) {
if (route === 'homeworldReturn') {
const f = createNativeGuildHomeworldRate('return');
return { ...f, route, shipper: f.guild, cancelor: f.ecaz,
provenance: [...f.provenance, 'Original typed return requests native transport permission before its separate rate.'] };
}
const f = createGuildAmbassadorIncomeArrival({ homeworlds: options.homeworlds ?? false });
const actions = [...f.actions], amount = options.amount ?? 3;
let beforeAction = pairedE3NexusReload(f.beforeAction), shipper = f.entrant, source = f.source;
let action: Action = { ...f.action, amount };
if (route === 'guildReturn') {
beforeAction = advancePairedE3Nexus(beforeAction,
g => g.phase === 5 && g.active === f.guild && pairedE3NexusClean(g), actions, quietPairedE3NexusChoice);
shipper = f.guild;
const selected = Object.keys(pairedE3NexusPlayer(beforeAction, shipper).forces)
.find(key => pairedE3NexusPlayer(beforeAction, shipper).forces[key] >= amount && Number(key.split(':')[1]) !== beforeAction.storm);
assert.ok(selected); source = selected;
action = { type: 'guildShip', from: source, territory: 'reserves', amount, elite: 0, allyPayment: 0 };
}
return { ...f, actions, beforeAction, route, shipper, source, action, cancelor: f.ecaz,
provenance: [...f.provenance, 'Original native cross/return permission is independent of its tariff.'] };
}
export function declareNativeGuildTransportPermission(f: {
beforeAction: Game; shipper: string; guild: string; cancelor: string; karama: string; action: Action;
}) {
const actions: PairedE3NexusStep[] = [];
let game = stepPairedE3Nexus(f.beforeAction, { actor: f.shipper, action: f.action }, actions);
if (game.decision?.kind === 'homeworldShipmentGuild')
game = stepPairedE3Nexus(game, { actor: f.guild,
action: { type: 'decision', event: game.decision.event, allow: true } }, actions);
assert.equal(game.response?.kind, 'guildTransportPermission');
assert.ok(viewGame(game, f.cancelor).responseControls?.cancelCards.includes(f.karama));
return { game, actions };
}
export function createNativeGuildTransportPermissionScene(route: NativeGuildPermissionRoute = 'allyCross',
options: { amount?: number; homeworlds?: boolean } = {}) {
const fixture = createNativeGuildTransportPermission(route, options);
const declaration = declareNativeGuildTransportPermission(fixture);
return { ...fixture, game: declaration.game, declarationActions: declaration.actions,
cancel: { actor: fixture.cancelor, action: { type: 'card', mode: 'cancel', card: fixture.karama } as Action } };
}
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { homeworldShipmentChoice, type HomeworldShipmentSources } from '../game/homeworld-shipment-options';
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { createGuildAmbassadorIncomeArrival, passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
import {
createNativeGuildHomeworldRate, createNativeGuildHomeworldRateScene,
type NativeGuildHomeworldRoute,
} from './fixture-native-guild-homeworld-rate';
import { holdGuildCunningCard } from './fixture-nexus-guild-cunning';
import {
pairedE3NexusReload, stepPairedE3Nexus, type PairedE3NexusStep,
} from './fixture-paired-e3-nexus-modules';
export type PurchasedHomeworldRoute = 'world' | 'guildWorld' | 'arrakisForeign' | 'return';
export interface PurchasedHomeworldRateFixture {
route: PurchasedHomeworldRoute;
shipper: string;
ecaz: string;
guild: string;
entrant: string;
karama: string;
source: string;
destination: string;
sources: HomeworldShipmentSources;
allyPayment: number;
beforePurchase: Game;
beforeAction: Game;
action: Action;
actions: PairedE3NexusStep[];
provenance: string[];
}
function purchase(base: {
beforeAction: Game; ecaz: string; guild: string; entrant: string; karama: string;
actions: PairedE3NexusStep[]; provenance: string[];
}, route: PurchasedHomeworldRoute, source: string, destination: string, allyPayment: number) {
const actions = [...base.actions];
const shipper = route === 'world' ? base.entrant : base.guild;
const beforePurchase = pairedE3NexusReload(base.beforeAction);
assert.equal(beforePurchase.active, shipper);
assert.ok(beforePurchase.players.find(p => p.id === base.ecaz)!.hand.some(c => c.id === base.karama));
const game = stepPairedE3Nexus(beforePurchase, { actor: base.ecaz,
action: { type: 'card', mode: 'shipment', card: base.karama, target: shipper } }, actions);
assert.deepEqual(game.karamaShipping, { player: shipper, owner: base.ecaz, card: base.karama });
const sources = { [source]: { normal: 3, elite: 0 } };
const view = viewGame(game, shipper);
const quote = route === 'world' || route === 'guildWorld'
? homeworldShipmentChoice(view, destination, sources, allyPayment)
: guildHomeworldShipmentChoice(view, destination, sources, allyPayment);
assert.equal(quote.blocked, null);
assert.equal(quote.cost, 2);
assert.ok(quote.action);
return { ...base, route, shipper, source, destination, sources, allyPayment,
actions, beforePurchase, beforeAction: pairedE3NexusReload(game), action: quote.action,
provenance: [...base.provenance,
'Ecaz plays its actual held Karama in shipment mode for the active beneficiary; no card identity is transferred.',
'The original typed three-normal-counter route and explicit approved share are quoted after the independent purchase.'],
} satisfies PurchasedHomeworldRateFixture;
}
/** Original printed setup and auctioned Ecaz card from the native Homeworld
* factory. No played hand, wallet, army, phase, grant or decision is assigned.
* The shared factories disclose conserved undealt Treachery/Spice ordering. */
export function createPurchasedHomeworldRate(route: PurchasedHomeworldRoute = 'world') {
if (route === 'world') {
const base = createGuildAmbassadorIncomeArrival({ homeworlds: true });
return purchase(base, route, 'homeworld:moritani', 'homeworld:ecaz', 0);
}
const nativeRoute: NativeGuildHomeworldRoute = route === 'guildWorld' ? 'world' : route;
const base = createNativeGuildHomeworldRate(nativeRoute);
return purchase(base, route, base.source, base.destination, 1);
}
/** Actual declaration; the original automatic stop policy may complete it immediately. */
export function declarePurchasedHomeworldRate(f: PurchasedHomeworldRateFixture) {
const actions: PairedE3NexusStep[] = [];
const game = stepPairedE3Nexus(f.beforeAction, { actor: f.shipper, action: f.action }, actions);
assert.notEqual(game.response?.kind, 'guildRate');
return { game, actions };
}
export function createPurchasedHomeworldRateScene(route: PurchasedHomeworldRoute = 'world') {
const fixture = createPurchasedHomeworldRate(route);
const declared = declarePurchasedHomeworldRate(fixture);
return { ...fixture, game: declared.game, declarationActions: declared.actions,
allow: declared.game.decision?.kind === 'homeworldShipmentGuild'
? { actor: fixture.guild, action: { type: 'decision',
event: declared.game.decision.event, allow: true } as Action } : null };
}
/** Continue only actual Guild interception/permission controls, never a native rate or income. */
export function completePurchasedHomeworldRate(state: Game) {
const actions: PairedE3NexusStep[] = [];
let game = pairedE3NexusReload(state);
if (game.decision?.kind === 'homeworldShipmentGuild')
game = stepPairedE3Nexus(game, { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, allow: true } }, actions);
const permission = passNativeGuildTransportPermission(game);
return { game: permission.game, actions: [...actions, ...permission.actions] };
}
/** Explicitly controlled SECOND card acquisition only: the first auctioned
* Ecaz Karama really cancels an unaffordable native Guild return. The existing
* controlled physical-card helper supplies a second Karama to Ecaz; its later
* purchase and typed return are real actions. Not original action-only custody. */
export function createControlledPurchasedHomeworldCanceledRate() {
const native = createNativeGuildHomeworldRateScene('return', { unaffordable: true });
const actions = [...native.actions, ...native.declarationActions];
let game = stepPairedE3Nexus(native.game, native.cancel, actions);
assert.equal(viewGame(game, native.guild).guildRateCanceled, true);
assert.ok(!game.pendingHomeworldShipment);
assert.equal(game.players.find(p => p.id === native.guild)!.shipped, false);
const karama = holdGuildCunningCard(game, native.ecaz, 'karama').id;
game = pairedE3NexusReload(game);
const fixture = purchase({ ...native, beforeAction: game, karama, actions,
provenance: [...native.provenance,
'CONTROLLED second physical Karama acquisition via the existing held-card helper; no original-auction claim for this second card.'],
}, 'return', native.source, native.destination, 1);
return { ...fixture, canceledKarama: native.karama };
}
@@ -7,7 +7,7 @@ import {
allowGuildAmbassadorIncomeArrival, cancelGuildAmbassadorIncomeArrival,
completeGuildAmbassadorIncomeArrival,
createGuildAmbassadorIncomeArrival, declareGuildAmbassadorIncomeArrival,
guildAmbassadorIncomeChoice,
guildAmbassadorIncomeChoice, passNativeGuildTransportPermission,
} from './fixture-guild-ambassador-income-arrival';
import { pairedE3NexusPolicy, pairedE3NexusInventory as guildAmbassadorIncomeInventory,
pairedE3NexusPlayer as player, pairedE3NexusReload as reload } from './fixture-paired-e3-nexus-modules';
@@ -46,9 +46,9 @@ for (const trigger of [true, false]) {
assert.equal(botArrivalBlock(view, f.action), null,
'A future earned receipt is not an already-active arrival response');
// This original engine path already commits; owner previews must agree.
// Permission is a distinct unpaid window; income follows actual allowance.
const declaration = declareGuildAmbassadorIncomeArrival(f);
const paid = declaration.after;
const paid = passNativeGuildTransportPermission(declaration.after).game;
assert.ok(!paid.pendingGuildTransport);
assert.equal(paid.response?.kind, 'guildIncome');
assert.equal(paid.response?.amount, 1);
@@ -131,7 +131,7 @@ for (const trigger of [true, false]) {
void test('actual Ecaz owner-view policies complete the queued native Guild entry without replaying payment or counters', () => {
const f = createGuildAmbassadorIncomeArrival();
const paid = declareGuildAmbassadorIncomeArrival(f).after;
const paid = passNativeGuildTransportPermission(declareGuildAmbassadorIncomeArrival(f).after).game;
const offered = allowGuildAmbassadorIncomeArrival(paid).game;
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const action = pairedE3NexusPolicy(reload(offered), f.ecaz, difficulty)[0];
+60 -1
View File
@@ -11,6 +11,8 @@ import {
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
@@ -52,9 +54,10 @@ function declare(g: Game, id: string, sources: Record<string, number> | 'reserve
const selection = sources === 'reserves'
? { from: 'reserves', amount: 3 }
: { forces: sources, ...(eliteForces ? { eliteForces } : {}) };
return applyAction(g, id, {
const permission = applyAction(g, id, {
type: 'guildShip', ...selection, territory: destination, sector, allyPayment,
});
return passNativeGuildTransportPermission(permission).game;
}
function canceled(g: Game, card: string): Game {
assert.equal(g.response?.kind, 'guildRate');
@@ -350,3 +353,59 @@ void test('all AI profiles pass pending transport and a blocked ally still selec
assert.equal(player(done, 'e').shipped, true);
assert.ok(player(done, 'e').spice < 2);
});
void test('controlled southern-reserve permission denial preserves typed Fremen counters and their separate free reinforcement', () => {
const { g: initial, karama } = fixture('f', true);
player(initial, 'f').elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 };
const offered = applyAction(reload(initial), 'f', { type: 'guildShip', from: 'reserves',
territory: 'carthag', sector: 11, amount: 3, elite: 1, allyPayment: 0 });
assert.equal(offered.response?.kind, 'guildTransportPermission');
const denied = applyAction(offered, 'a', { type: 'card', mode: 'cancel', card: karama.id });
assert.deepEqual(physical(denied, 'f'), physical(initial, 'f'));
assert.equal(viewGame(denied, 'f').guildTransportCanceled, true);
assert.equal(viewGame(denied, 'f').guildRateCanceled, false);
const sector = territory('the_great_flat').sectors.find(value => value !== denied.storm)!;
const done = applyAction(denied, 'f', { type: 'ship', territory: 'the_great_flat',
sector, amount: 3, elite: 1, allyPayment: 0 });
assert.equal(player(done, 'f').spice, player(initial, 'f').spice);
assert.equal(player(done, 'g').spice, player(initial, 'g').spice);
assert.equal(player(done, 'f').reserves, 17);
assert.equal(player(done, 'f').elites?.reserves, 2);
assert.equal(player(done, 'f').forces[`the_great_flat:${sector}`], 3);
assert.equal(player(done, 'f').elites?.forces[`the_great_flat:${sector}`], 1);
assert.equal(player(done, 'f').shipped, true);
});
for (const counterConversion of [false, true]) {
void test(`controlled BG conversion ${counterConversion ? 'countered' : 'allowed'} keeps the original native permission source and physical group`, () => {
const { g: initial, karama } = fixture('g', true);
stationed(initial, 'g', { 'pasty_mesa:5': 5 });
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
initial.order.push('b'); initial.movementRemaining!.push('b');
const index = initial.deck.findIndex(card => card.kind === 'worthless');
assert.ok(index >= 0);
const worthless = initial.deck.splice(index, 1)[0];
player(initial, 'b').hand.push(worthless);
let g = applyAction(reload(initial), 'g', { type: 'guildShip', from: 'pasty_mesa:5',
territory: 'carthag', sector: 11, amount: 3, elite: 0, allyPayment: 0 });
assert.equal(g.response?.kind, 'guildTransportPermission');
g = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: worthless.id });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.deepEqual(physical(g, 'g'), physical(initial, 'g'));
g = applyAction(g, 'a', counterConversion
? { type: 'card', mode: 'cancel', card: karama.id } : { type: 'passResponse' });
if (counterConversion) {
assert.equal(player(g, 'g').spice, 18);
assert.equal(player(g, 'g').forces['pasty_mesa:5'], 2);
assert.equal(player(g, 'g').forces['carthag:11'], 3);
assert.equal(player(g, 'g').shipped, true);
assert.ok(!viewGame(g, 'g').guildTransportCanceled);
} else {
assert.deepEqual(physical(g, 'g'), physical(initial, 'g'));
assert.equal(viewGame(g, 'g').guildTransportCanceled, true);
assert.ok(player(g, 'a').hand.some(card => card.id === karama.id));
}
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
assert.equal(g.discard.filter(card => card.id === karama.id).length, counterConversion ? 1 : 0);
});
}
+2
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, type Game } from '../game/engine';
import { createGuildBetrayalFixture, settleGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
for (const source of ['reserve', 'guildTransport', 'homeworld'] as const) {
@@ -19,6 +20,7 @@ for (const source of ['reserve', 'guildTransport', 'homeworld'] as const) {
if (g.decision?.kind === 'guildShipment' || g.decision?.kind === 'homeworldShipmentGuild')
g = applyAction(g, f.guild, { type: 'decision', allow: true,
...(g.decision.kind === 'homeworldShipmentGuild' ? { event: g.decision.event } : {}) });
g = passNativeGuildTransportPermission(g).game;
assert.equal(g.response?.kind, 'guildRate');
const amount = g.pendingShipment?.amount ?? g.pendingGuildTransport?.amount ?? g.pendingHomeworldShipment!.amount;
assert.ok(amount > 1);
+4 -3
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createGuildAmbassadorIncomeArrival, allowGuildAmbassadorIncomeArrival,
completeGuildAmbassadorIncomeArrival,
completeGuildAmbassadorIncomeArrival, passNativeGuildTransportPermission,
} from './fixture-guild-ambassador-income-arrival';
import {
advancePairedE3Nexus, nextPairedE3NexusStep, pairedE3NexusClean,
@@ -23,7 +23,7 @@ function declare(f: NativeFixture, before: Game, route: 'reserve' | 'transport',
assert.deepEqual(player(g, f.entrant).forces, player(before, f.entrant).forces);
g = act(g, f.guild, { type: 'decision', allow: true });
}
return g;
return passNativeGuildTransportPermission(g).game;
}
function allowRate(state: Game) {
let g = state;
@@ -117,8 +117,9 @@ void test('original expansion/Nexus Guild return has its own prepayment rate win
const p = player(before, f.guild);
const source = Object.keys(p.forces).find(key => p.forces[key] >= 3)!;
assert.ok(source);
const offered = act(before, f.guild, { type: 'guildShip', from: source,
const permission = act(before, f.guild, { type: 'guildShip', from: source,
territory: 'reserves', amount: 3, elite: 0, allyPayment: 0 });
const offered = passNativeGuildTransportPermission(permission).game;
assert.equal(offered.response?.kind, 'guildRate');
assert.deepEqual(player(offered, f.guild).forces, p.forces);
const done = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama });
@@ -0,0 +1,187 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { guildTransportQuote } from '../game/transport-quote';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { junctionTransportChoice } from '../game/junction-transport-options';
import { botActions } from '../game/bots';
import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
import { createNativeGuildTransportPermissionScene } from './fixture-native-guild-transport-permission';
import { settleNativeGuildRateModules } from './fixture-native-guild-rate-modules';
import { nexusGuildCunningFixture, originalGuildCunningTurn, nexusGuildCunningRequest,
nexusGuildCunningAllow } from './fixture-nexus-guild-cunning';
import { pairedE3NexusPlayer as player, stepPairedE3Nexus } from './fixture-paired-e3-nexus-modules';
const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action);
const stock = (g: Game, id: string) => {
const p = player(g, id);
return { spice: p.spice, reserves: p.reserves, forces: p.forces, elites: p.elites,
shipped: p.shipped, moved: p.moved };
};
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 route of ['allyCross', 'guildReturn', 'homeworldReturn'] as const) {
void test(`original ${route}: preventing native permission spends no fee or force, keeps ordinary shipment and does not cancel its price`, () => {
const f = createNativeGuildTransportPermissionScene(route), before = f.beforeAction;
assert.deepEqual(stock(f.game, f.shipper), stock(before, f.shipper));
if (before.homeworlds) assert.deepEqual(f.game.homeworlds, before.homeworlds);
assert.ok(!f.game.pendingArrivalReaction);
assert.ok(!f.game.pendingAmbassador);
reject(f.game, f.shipper, f.action);
const denied = stepPairedE3Nexus(f.game, f.cancel);
assert.deepEqual(stock(denied, f.shipper), stock(before, f.shipper));
if (before.homeworlds) assert.deepEqual(denied.homeworlds, before.homeworlds);
assert.equal(denied.discard.filter(c => c.id === f.karama).length, 1);
assert.equal(viewGame(denied, f.shipper).guildTransportCanceled, true);
assert.equal(viewGame(denied, f.shipper).guildRateCanceled, false);
reject(denied, f.shipper, f.action);
const view = viewGame(denied, f.shipper);
if (route === 'homeworldReturn') {
assert.ok(guildHomeworldShipmentChoice(view, f.destination,
{ [f.source]: { normal: 3, elite: 0 } }, 1).blocked);
// Common world-to-world shipment does not use Guild's cross/return privilege.
const alternative = homeworldShipmentChoice(view, 'homeworld:ecaz',
{ 'homeworld:guild': { normal: 3, elite: 0 } }, 1);
assert.ok(alternative.action);
assert.equal(alternative.cost, 2);
const done = settleNativeGuildRateModules(act(denied, f.shipper, alternative.action)).game;
assert.deepEqual(done.homeworlds!.custody!.visitors['homeworld:ecaz'][f.shipper], { normal: 3, elite: 0 });
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves - 3);
assert.equal(player(done, f.shipper).forces[f.source], player(before, f.shipper).forces[f.source]);
} else {
assert.ok(guildTransportQuote(view, f.action).unavailableReasons.length);
const alternative: Action = { type: 'ship', territory: route === 'allyCross' ? f.destination : 'carthag',
sector: route === 'allyCross' ? f.sector : 11, amount: 3, elite: 0, allyPayment: 0 };
const done = settleNativeGuildRateModules(act(denied, f.shipper, alternative)).game;
assert.equal(player(done, f.shipper).spice, player(before, f.shipper).spice - 2);
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves - 3);
assert.equal(player(done, f.shipper).forces[f.source], player(before, f.shipper).forces[f.source]);
assert.equal(player(done, f.shipper).shipped, true);
const next = act(done, f.shipper, { type: 'endMovement' });
assert.notEqual(next.active, f.shipper);
}
});
void test(`original ${route}: allowing permission still permits a separate full-price rate cancellation`, () => {
const f = createNativeGuildTransportPermissionScene(route), before = f.beforeAction;
const priced = passNativeGuildTransportPermission(f.game).game;
assert.equal(priced.response?.kind, 'guildRate');
assert.deepEqual(stock(priced, f.shipper), stock(before, f.shipper));
const done = settleNativeGuildRateModules(act(priced, f.cancelor,
{ type: 'card', mode: 'cancel', card: f.karama })).game;
assert.equal(player(done, f.shipper).shipped, true);
assert.ok(!viewGame(done, f.shipper).guildTransportCanceled);
assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
assert.equal(player(done, f.shipper).spice, player(before, f.shipper).spice - (route === 'homeworldReturn' ? 1 : 3));
if (route === 'homeworldReturn') {
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves + 3);
assert.equal(done.aid[f.entrant].amount, 0);
} else if (route === 'guildReturn') {
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves + 3);
} else {
assert.equal(player(done, f.shipper).forces[f.target], 3);
assert.equal(player(done, f.shipper).reserves, player(before, f.shipper).reserves);
}
});
}
void test('original Junction sponsorship survives prevention of the same entrant’s native Guild cross/return privilege', () => {
const f = createNativeGuildTransportPermissionScene('allyCross', { homeworlds: true });
let g = stepPairedE3Nexus(f.game, f.cancel);
const before = structuredClone(g);
assert.equal(viewGame(g, f.shipper).guildTransportCanceled, true);
const offer = viewGame(g, f.guild).junctionTransport!;
assert.ok(offer.canOffer);
g = act(g, f.guild, { type: 'offerJunctionTransport', event: offer.offerEvent, rate: 'half' });
const choice = junctionTransportChoice(viewGame(g, f.shipper), 'homeworld:moritani',
{ [f.source]: { normal: 3, elite: 0 } }, 0);
assert.ok(choice.action);
assert.equal(choice.cost, 2);
g = act(g, f.shipper, choice.action);
assert.notEqual(g.response?.kind, 'guildTransportPermission');
assert.equal(player(g, f.shipper).spice, player(before, f.shipper).spice - 2);
assert.equal(player(g, f.shipper).reserves, player(before, f.shipper).reserves + 3);
assert.equal(player(g, f.shipper).forces[f.source], player(before, f.shipper).forces[f.source] - 3);
assert.equal(player(g, f.guild).spice, player(before, f.guild).spice);
});
void test('controlled Cunning second opportunity denial retains the first shipment and can use a legal paid reserve replacement', () => {
const f = nexusGuildCunningFixture({ advanced: true, karama: true });
let g = originalGuildCunningTurn(f, false);
g = nexusGuildCunningAllow(act(g, f.owner, nexusGuildCunningRequest(f, g)));
const before = structuredClone(g);
g = act(g, f.owner, { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 3 });
assert.equal(g.response?.kind, 'guildTransportPermission');
g = act(g, f.target, { type: 'card', mode: 'cancel', card: f.karama! });
assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
assert.equal(player(g, f.owner).shipped, true);
assert.deepEqual(stock(g, f.owner), stock(before, f.owner));
assert.ok(!g.nexusGuildCunningHistory!.at(-1)!.shipment);
const done = nexusGuildCunningAllow(act(g, f.owner,
{ type: 'ship', territory: 'carthag', sector: 11, amount: 3 }));
// Advanced Guild's original interception still needs its own real allowance.
const paid = settleNativeGuildRateModules(done).game;
assert.equal(player(paid, f.owner).spice, player(before, f.owner).spice - 2);
assert.equal(player(paid, f.owner).reserves, player(before, f.owner).reserves - 3);
assert.equal(player(paid, f.owner).forces['arrakeen:10'], 5);
assert.equal(player(paid, f.owner).forces['carthag:11'], 3);
assert.equal(paid.nexusGuildCunningLast?.stage, 'extraMove');
reject(paid, f.owner, { type: 'move', from: 'arrakeen:10', territory: 'hagga_basin', sector: 12, amount: 1 });
});
void test('controlled actual denied ordinary privilege does not remove the separately earned Cunning opportunity', () => {
const f = nexusGuildCunningFixture({ advanced: true, karama: true });
// Explicit controlled initial stock extension, not action-only provenance.
// Preserve twenty counters; every subsequent denial/replacement/grant is real.
player(f.g, f.owner).reserves -= 5;
player(f.g, f.owner).forces['tueks_sietch:5'] = 5;
let g = act(f.g, f.owner,
{ type: 'guildShip', from: 'tueks_sietch:5', territory: 'carthag', sector: 11, amount: 3 });
g = act(g, f.target, { type: 'card', mode: 'cancel', card: f.karama! });
assert.equal(viewGame(g, f.owner).guildTransportCanceled, true);
g = settleNativeGuildRateModules(act(g, f.owner,
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 3 })).game;
g = nexusGuildCunningAllow(act(g, f.owner, nexusGuildCunningRequest(f, g)));
assert.ok(!viewGame(g, f.owner).guildTransportCanceled);
const before = structuredClone(g);
g = settleNativeGuildRateModules(act(g, f.owner,
{ type: 'guildShip', from: 'tueks_sietch:5', territory: 'carthag', sector: 11, amount: 3 })).game;
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice - 2);
assert.equal(player(g, f.owner).forces['tueks_sietch:5'], 2);
assert.equal(player(g, f.owner).forces['arrakeen:10'], 3);
assert.equal(player(g, f.owner).forces['carthag:11'], 3);
assert.equal(g.nexusGuildCunningLast?.stage, 'extraMove');
});
void test('all four original owner policies retain an executable alternative after native transport permission is prevented', () => {
const f = createNativeGuildTransportPermissionScene();
const denied = stepPairedE3Nexus(f.game, f.cancel);
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(denied, f.shipper); view.players.find(p => p.id === f.shipper)!.bot = level;
const actions = botActions(view); assert.ok(actions[0]);
assert.ok(actions.every(action => action.type !== 'guildShip'));
const action = actions[0];
const done = settleNativeGuildRateModules(act(denied, f.shipper, action)).game;
if (action.type === 'ship') {
assert.equal(player(done, f.shipper).shipped, true);
assert.equal(player(done, f.shipper).reserves, player(denied, f.shipper).reserves - Number(action.amount));
const destination = `${String(action.territory)}:${String(action.sector)}`;
assert.equal(player(done, f.shipper).forces[destination],
(player(denied, f.shipper).forces[destination] ?? 0) + Number(action.amount));
} else if (action.type === 'move') {
assert.equal(player(done, f.shipper).moved, player(denied, f.shipper).moved + 1);
assert.equal(player(done, f.shipper).forces[String(action.from)] ?? 0,
player(denied, f.shipper).forces[String(action.from)] - Number(action.amount));
assert.equal(player(done, f.shipper).spice, player(denied, f.shipper).spice);
} else {
assert.equal(action.type, 'endMovement');
assert.notEqual(done.active, f.shipper);
assert.deepEqual(player(done, f.shipper).forces, player(denied, f.shipper).forces);
assert.equal(player(done, f.shipper).spice, player(denied, f.shipper).spice);
}
}
});
+2
View File
@@ -7,6 +7,7 @@ import {
type Game,
type Action,
} from '../game/engine';
import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
import {
nexusGuildCunningFixture,
originalGuildCunningTurn,
@@ -503,6 +504,7 @@ for (const route of ['reserve', 'transport'] as const) {
assert.equal(g.decision?.kind, 'guildShipment');
g = applyAction(g, f.owner, { type: 'decision', allow: true });
}
g = passNativeGuildTransportPermission(g).game;
assert.equal(g.response?.kind, 'guildRate');
assert.equal(g.players[0].shipped, true);
assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
+205
View File
@@ -0,0 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction, viewGame, type Action, type Game, type GameView,
} from '../game/engine';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { quoteHomeworldShipment } from '../game/homeworld-shipment';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { guildHomeworldShipmentActions, guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import {
createPurchasedHomeworldRate, createPurchasedHomeworldRateScene,
createControlledPurchasedHomeworldCanceledRate, declarePurchasedHomeworldRate,
completePurchasedHomeworldRate, type PurchasedHomeworldRateFixture,
} from './fixture-purchased-homeworld-rate';
import { holdGuildCunningCard } from './fixture-nexus-guild-cunning';
import { pairedE3NexusPlayer as player } from './fixture-paired-e3-nexus-modules';
const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action);
function context(g: Game, purchasedGuildRate: boolean, nativeGuildRateCanceled = false) {
return { ...homeworldContext(g), purchasedGuildRate, nativeGuildRateCanceled,
players: homeworldContext(g).players.map(p => ({ ...p, ally: player(g, p.id).ally })),
storm: g.storm, discoveries: g.discoveries, mobileStronghold: g.mobileStronghold?.location ?? null,
board: Object.fromEntries(g.players.map(p => [p.id, { forces: p.forces, eliteForces: p.elites?.forces ?? {} }])),
};
}
function unpaid(f: PurchasedHomeworldRateFixture, g: Game) {
assert.deepEqual(g.homeworlds, f.beforeAction.homeworlds);
assert.deepEqual(g.aid, f.beforeAction.aid);
for (const id of [f.shipper, f.guild, f.entrant]) {
assert.equal(player(g, id).spice, player(f.beforeAction, id).spice);
assert.equal(player(g, id).reserves, player(f.beforeAction, id).reserves);
assert.equal(player(g, id).shipped, player(f.beforeAction, id).shipped);
assert.deepEqual(player(g, id).forces, player(f.beforeAction, id).forces);
}
assert.deepEqual(g.karamaShipping, f.beforeAction.karamaShipping);
}
function paid(f: PurchasedHomeworldRateFixture, g: Game) {
const before = f.beforeAction, old = player(before, f.shipper), p = player(g, f.shipper);
assert.equal(p.spice, old.spice - (2 - f.allyPayment));
if (f.shipper !== f.guild) assert.equal(player(g, f.guild).spice, player(before, f.guild).spice);
if (f.allyPayment) {
assert.equal(player(g, f.entrant).spice, player(before, f.entrant).spice);
assert.equal(g.aid[f.entrant].amount, before.aid[f.entrant].amount - f.allyPayment);
}
assert.notEqual(g.response?.kind, 'guildIncome', 'Every purchased-price contribution goes to the bank, including an allied donor share.');
assert.notEqual(g.response?.kind, 'guildRate', 'Native cancellation cannot target an independent card price.');
assert.equal(p.shipped, true);
assert.equal(p.moved, old.moved);
assert.equal(g.karamaShipping, null);
assert.ok(!g.pendingHomeworldShipment);
assert.equal(g.discard.filter(c => c.id === f.karama).length, 1);
assert.ok(!player(g, f.ecaz).hand.some(c => c.id === f.karama));
assert.equal(p.reserves, old.reserves + (f.route === 'return' ? 3 : f.route === 'world' || f.route === 'guildWorld' ? -3 : 0));
if (f.route === 'return' || f.route === 'arrakisForeign')
assert.equal(p.forces[f.source] ?? 0, old.forces[f.source] - 3);
else assert.deepEqual(p.forces, old.forces);
if (f.route !== 'return')
assert.deepEqual(g.homeworlds!.custody!.visitors[f.destination][f.shipper], { normal: 3, elite: 0 });
else assert.equal(g.homeworlds!.custody!.visitors[f.destination]?.[f.shipper], undefined);
homeworldGameIntegrity(g);
const worlds = homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!);
assert.equal(Object.values(p.forces).reduce((sum, n) => sum + n, 0) + p.tanks + worlds.reduce((sum, w) =>
sum + (w.forces[f.shipper]?.normal ?? 0) + (w.forces[f.shipper]?.elite ?? 0), 0), 20);
assert.throws(() => act(g, f.shipper, f.action));
const ended = act(g, f.shipper, { type: 'endMovement' });
assert.notEqual(ended.active, f.shipper);
assert.deepEqual(player(ended, f.shipper).forces, p.forces);
}
for (const route of ['world', 'guildWorld', 'arrakisForeign', 'return'] as const) {
void test(`original purchased Homeworld ${route}: actual auctioned gift pays the entire approved price to the bank only on completion`, () => {
const f = createPurchasedHomeworldRateScene(route);
const prior = viewGame(f.beforePurchase, f.shipper);
const oldQuote = route === 'world' || route === 'guildWorld'
? homeworldShipmentChoice(prior, f.destination, f.sources, f.allyPayment)
: guildHomeworldShipmentChoice(prior, f.destination, f.sources, f.allyPayment);
assert.equal(oldQuote.cost, route === 'world' ? 3 : 2);
assert.ok(player(f.beforePurchase, f.ecaz).hand.some(c => c.id === f.karama));
assert.equal(player(f.beforeAction, f.ecaz).spice, player(f.beforePurchase, f.ecaz).spice);
if (f.game.pendingHomeworldShipment) unpaid(f, f.game);
const completed = completePurchasedHomeworldRate(f.game).game;
paid(f, completed);
});
}
void test('controlled second card after actual unaffordable native cancellation restores only the independent half price', () => {
const f = createControlledPurchasedHomeworldCanceledRate();
const before = viewGame(f.beforePurchase, f.guild);
const native = guildHomeworldShipmentChoice(before, f.destination, f.sources, 1);
assert.equal(native.cost, 3);
assert.ok(native.blocked);
assert.equal(viewGame(f.beforeAction, f.guild).guildRateCanceled, true);
assert.equal(guildHomeworldShipmentChoice(viewGame(f.beforeAction, f.guild), f.destination, f.sources, 1).cost, 2);
const declared = declarePurchasedHomeworldRate(f).game;
if (declared.pendingHomeworldShipment) unpaid(f, declared);
const done = completePurchasedHomeworldRate(declared).game;
paid(f, done);
assert.equal(done.discard.filter(c => c.id === f.canceledKarama).length, 1);
});
void test('purchased price changes neither typed physical identities nor original route permissions', () => {
const f = createPurchasedHomeworldRate('world'), g = f.beforeAction;
const ctx = context(g, true, true), custody = g.homeworlds!.custody!;
const intent = { player: f.shipper, destination: f.destination, sources: f.sources };
assert.equal(quoteHomeworldShipment(ctx, custody, intent).cost, 2);
assert.equal(quoteHomeworldShipment({ ...ctx, purchasedGuildRate: false }, custody, intent).cost, 3);
const own = homeworldShipmentChoice(viewGame(g, f.shipper), 'homeworld:moritani',
{ 'homeworld:moritani': { normal: 3, elite: 0 } });
assert.equal(own.action, null);
assert.ok(own.blocked);
const invalid: Action[] = [
{ ...f.action, destination: 'homeworld:guild' }, // Its actual allied Homeworld.
{ ...f.action, sources: { [f.source]: { normal: 0, elite: 3 } } },
{ ...f.action, type: 'guildHomeworldShip', sources: { [Object.keys(player(g, f.shipper).forces)[0]]: { normal: 3, elite: 0 } } },
];
for (const action of invalid) {
const unchanged = structuredClone(g);
assert.throws(() => applyAction(unchanged, f.shipper, action));
assert.deepEqual(unchanged, g);
}
const otherOwner = { ...viewGame(g, f.shipper), karamaShipping: { player: f.ecaz, owner: f.ecaz, card: f.karama } };
assert.equal(homeworldShipmentChoice(otherOwner, f.destination, f.sources).cost, 3);
});
void test('controlled second physical Guild card stops the original gifted purchase and refunds its card to the original owner without payment or departure', () => {
const f = createPurchasedHomeworldRate('world');
// This second physical acquisition is controlled, not an auction provenance claim.
const stop = holdGuildCunningCard(f.beforeAction, f.guild, 'karama');
const scene = declarePurchasedHomeworldRate(f).game;
const done = act(scene, f.guild, { type: 'card', mode: 'special', card: stop.id });
assert.deepEqual(done.homeworlds, f.beforeAction.homeworlds);
assert.deepEqual(done.aid, f.beforeAction.aid);
assert.equal(player(done, f.shipper).spice, player(f.beforeAction, f.shipper).spice);
assert.deepEqual(player(done, f.shipper).forces, player(f.beforeAction, f.shipper).forces);
assert.equal(done.karamaShipping, null);
assert.ok(player(done, f.ecaz).hand.some(c => c.id === f.karama));
assert.ok(!player(done, f.shipper).hand.some(c => c.id === f.karama));
assert.equal(done.discard.filter(c => c.id === f.karama).length, 0);
assert.equal(player(done, f.shipper).shipped, true);
assert.equal(player(done, f.shipper).moved, player(f.beforeAction, f.shipper).moved);
assert.equal(player(done, f.guild).specialKaramaUsed, true);
assert.equal(done.discard.filter(c => c.id === stop.id).length, 1);
assert.ok(!done.pendingHomeworldShipment);
homeworldGameIntegrity(done);
});
void test('controlled second physical Karama cannot stack another grant on the original active beneficiary', () => {
const f = createPurchasedHomeworldRate('world');
// Only this second card's physical acquisition is controlled.
const second = holdGuildCunningCard(f.beforeAction, f.ecaz, 'karama');
const before = structuredClone(f.beforeAction);
assert.notEqual(second.id, f.karama);
assert.throws(() => applyAction(f.beforeAction, f.ecaz,
{ type: 'card', mode: 'shipment', card: second.id, target: f.shipper }));
assert.deepEqual(f.beforeAction, before);
assert.ok(player(f.beforeAction, f.ecaz).hand.some(c => c.id === second.id));
const declared = declarePurchasedHomeworldRate(f).game;
const done = completePurchasedHomeworldRate(declared).game;
paid(f, done);
assert.ok(player(done, f.ecaz).hand.some(c => c.id === second.id));
});
void test('controlled owner-view permission denial blocks native Guild routes but leaves purchased ordinary world-to-world transport legal', () => {
// Narrow public permission-flag projections; original auction/action scenes above are unchanged.
const f = createPurchasedHomeworldRate('guildWorld');
const view = { ...viewGame(f.beforeAction, f.guild), guildTransportCanceled: true as const };
const foreign = homeworldShipmentChoice(view, f.destination, f.sources, 1);
assert.equal(foreign.cost, 2);
assert.ok(foreign.action);
const ownReturn = homeworldShipmentChoice(view, 'homeworld:guild',
{ 'homeworld:ecaz': { normal: 3, elite: 0 } }, 1);
assert.equal(ownReturn.action, null);
assert.ok(ownReturn.blocked);
const r = createPurchasedHomeworldRate('return');
const denied = { ...viewGame(r.beforeAction, r.guild), guildTransportCanceled: true as const };
const arrakisReturn = guildHomeworldShipmentChoice(denied, r.destination, r.sources, 1);
assert.equal(arrakisReturn.action, null);
assert.ok(arrakisReturn.blocked);
assert.deepEqual(guildHomeworldShipmentActions(denied, 0), []);
});
void test('controlled Secret Ally projection keeps its explicit source separate from an already purchased card price', () => {
// Secret Ally needs Guild absent and no alliance; this is not a played-game chronology.
const f = createPurchasedHomeworldRate('world'), original = viewGame(f.beforeAction, f.shipper);
const event = JSON.stringify(['nexusGuildSecretAlly', original.turn, original.me]);
const view = { ...original, guildRateCanceled: true,
players: original.players.filter(p => p.id !== f.guild).map(p => ({ ...p, ally: null })),
homeworlds: { ...original.homeworlds!, worlds: original.homeworlds!.worlds!.filter(w => w.native !== f.guild) },
nexusCards: { ...original.nexusCards!, card: 'guild', waiting: [] },
nexusGuildSecretAlly: { event, blocked: null },
aid: { ...original.aid, available: 0 },
} as GameView;
assert.equal(homeworldShipmentChoice(view, f.destination, f.sources).cost, 2);
const stacked = homeworldShipmentChoice(view, f.destination, f.sources, 0, event);
assert.equal(stacked.action, null);
assert.ok(stacked.blocked);
const withoutPurchase = { ...view, karamaShipping: null };
assert.equal(homeworldShipmentChoice(withoutPurchase, f.destination, f.sources).cost, 3);
const nexus = homeworldShipmentChoice(withoutPurchase, f.destination, f.sources, 0, event);
assert.equal(nexus.cost, 2);
assert.ok(nexus.action);
});