Separate Guild transport permission, purchase price and loan approval
This commit is contained in:
1 parent
a12250320c
commit
11adb7dbf3
39 files changed
+1576
-121
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 } };
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user