Files
Dune/tests/native-guild-transport-permission.test.ts

188 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
});