188 lines
12 KiB
TypeScript
188 lines
12 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
});
|