Files
Dune/tests/native-guild-homeworld-rate.test.ts

213 lines
13 KiB
TypeScript

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 { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { junctionTransportChoice } from '../game/junction-transport-options';
import {
createNativeGuildHomeworldRate, createNativeGuildHomeworldRateScene,
createNativeImmuneJunctionRate, nativeGuildHomeworldAction,
passNativeGuildHomeworldResponses, type NativeGuildHomeworldRateFixture,
createControlledGuildCunningHomeworldRateScene,
} from './fixture-native-guild-homeworld-rate';
import { pairedE3NexusPlayer as player, stepPairedE3Nexus } from './fixture-paired-e3-nexus-modules';
const act = (g: Game, actor: string, action: Action) => applyAction(structuredClone(g), actor, action);
function context(g: Game, nativeGuildRateCanceled?: boolean) {
return { ...homeworldContext(g), nativeGuildRateCanceled,
players: homeworldContext(g).players.map(p => ({ ...p, ally: player(g, p.id).ally })),
storm: g.storm, mobileStronghold: g.mobileStronghold?.location ?? null,
discoveries: g.discoveries,
board: Object.fromEntries(g.players.map(p => [p.id, { forces: p.forces, eliteForces: p.elites?.forces ?? {} }])),
};
}
function assertUnpaid(f: NativeGuildHomeworldRateFixture, state: Game) {
const before = f.beforeAction;
assert.deepEqual(state.homeworlds, before.homeworlds);
assert.deepEqual(state.aid, before.aid);
for (const id of [f.guild, f.entrant]) {
assert.equal(player(state, id).spice, player(before, id).spice);
assert.equal(player(state, id).reserves, player(before, id).reserves);
assert.deepEqual(player(state, id).forces, player(before, id).forces);
assert.equal(player(state, id).shipped, player(before, id).shipped);
}
assert.ok(!state.pendingArrivalReaction);
assert.ok(!state.pendingAmbassador);
}
function assertPaid(f: NativeGuildHomeworldRateFixture, paid: Game, amount: number, cost: number) {
const before = f.beforeAction;
const old = player(before, f.guild), guild = player(paid, f.guild);
// A Guild shipper pays its own share to the bank. The approved Moritani
// share independently earns the Guild its existing FAQ contributor income.
const settled = passNativeGuildHomeworldResponses(paid, 'guildIncome').game;
assert.equal(player(settled, f.guild).spice, old.spice - cost + 2);
// Pledging already debited the donor wallet before this declaration.
assert.equal(player(settled, f.entrant).spice, player(before, f.entrant).spice);
assert.equal(settled.aid[f.entrant].amount, 0);
assert.equal(guild.shipped, true);
assert.equal(guild.reserves, old.reserves + (f.route === 'return' ? amount : f.route === 'world' ? -amount : 0));
if (f.route !== 'world')
assert.equal(guild.forces[f.source] ?? 0, old.forces[f.source] - amount);
else assert.deepEqual(guild.forces, old.forces);
if (f.route !== 'return') {
assert.deepEqual(settled.homeworlds!.custody!.visitors[f.destination][f.guild], { normal: amount, elite: 0 });
} else assert.equal(settled.homeworlds!.custody!.visitors[f.destination]?.[f.guild], undefined);
assert.ok(!settled.pendingHomeworldShipment);
assert.ok(!settled.pendingArrivalReaction);
assert.ok(!settled.pendingAmbassador);
homeworldGameIntegrity(settled);
const worlds = homeworldForceGroups(context(settled), settled.homeworlds!.custody!);
const physical = Object.values(player(settled, f.guild).forces).reduce((n, count) => n + count, 0) +
worlds.reduce((n, w) => n + (w.forces[f.guild]?.normal ?? 0) + (w.forces[f.guild]?.elite ?? 0), 0);
assert.equal(physical, 20);
return settled;
}
for (const route of ['world', 'arrakisForeign', 'return'] as const) {
void test(`original native Homeworld ${route}: actual auctioned Ecaz Karama precedes every debit and physical transfer`, () => {
const scene = createNativeGuildHomeworldRateScene(route);
assertUnpaid(scene, scene.game);
assert.equal(scene.game.pendingHomeworldShipment?.cost, 2);
assert.equal(scene.game.pendingHomeworldShipment?.allyPayment, 1);
assert.deepEqual(scene.game.pendingHomeworldShipment?.sources, scene.sources);
const stored = structuredClone(scene.game);
assert.throws(() => act(stored, scene.entrant, scene.cancel.action));
assert.deepEqual(stored, scene.game);
const canceled = stepPairedE3Nexus(scene.game, scene.cancel);
const settled = assertPaid(scene, canceled, 3, 3);
assert.equal(settled.discard.filter(c => c.id === scene.karama).length, 1);
assert.throws(() => act(settled, scene.guild, scene.action));
const next = act(settled, scene.guild, { type: 'endMovement' });
assert.notEqual(next.active, scene.guild);
assert.deepEqual(player(next, scene.guild).forces, player(settled, scene.guild).forces);
});
void test(`original native Homeworld ${route}: passing preserves the rounded half tariff and original approved split`, () => {
const scene = createNativeGuildHomeworldRateScene(route);
assertUnpaid(scene, scene.game);
const allowed = passNativeGuildHomeworldResponses(scene.game, 'guildRate').game;
const settled = assertPaid(scene, allowed, 3, 2);
assert.ok(player(settled, scene.ecaz).hand.some(c => c.id === scene.karama));
});
void test(`original native Homeworld ${route}: unaffordable cancellation releases unchanged typed custody and supports a full-price replacement`, () => {
const scene = createNativeGuildHomeworldRateScene(route, { unaffordable: true });
assert.equal(player(scene.beforeAction, scene.guild).spice, 1);
assertUnpaid(scene, scene.game);
const released = stepPairedE3Nexus(scene.game, scene.cancel);
assertUnpaid(scene, released);
assert.ok(!released.pendingHomeworldShipment);
assert.equal(released.response, null);
assert.equal(released.decision, null);
assert.equal(viewGame(released, scene.guild).guildRateCanceled, true);
assert.equal(viewGame(released, scene.ecaz).guildRateCanceled, false);
const view = viewGame(released, scene.guild);
const consumer = route === 'world' ? homeworldShipmentChoice : guildHomeworldShipmentChoice;
const unchanged = consumer(view, scene.destination, scene.sources, 1);
assert.equal(unchanged.cost, 3);
assert.equal(unchanged.ownPayment, 2);
assert.ok(unchanged.blocked);
const action = nativeGuildHomeworldAction(scene, released, 2, 1);
assert.deepEqual(action.sources, { [scene.source]: { normal: 2, elite: 0 } });
let replacement = act(released, scene.guild, action);
if (replacement.decision?.kind === 'homeworldShipmentGuild')
replacement = act(replacement, scene.guild, { type: 'decision', event: replacement.decision.event, allow: true });
assert.notEqual(replacement.response?.kind, 'guildRate');
const settled = assertPaid(scene, replacement, 2, 2);
assert.equal(settled.discard.filter(c => c.id === scene.karama).length, 1);
});
}
void test('non-Guild world-to-world remains full price; controlled Secret Ally owner quote stays independent of native cancellation', () => {
const f = createNativeGuildHomeworldRate('world'), g = f.beforeAction;
const intent = { player: f.ecaz, destination: 'homeworld:moritani',
sources: { 'homeworld:ecaz': { normal: 3, elite: 0 } } };
assert.equal(quoteHomeworldShipment(context(g, true), g.homeworlds!.custody!, intent).cost, 3);
// Narrow controlled presentation fixture, not an original action producer:
// Secret Ally legally requires Guild absent, no alliance, and an owned card.
const original = viewGame(g, f.ecaz);
const event = JSON.stringify(['nexusGuildSecretAlly', original.turn, f.ecaz]);
const view = { ...original, active: f.ecaz, guildRateCanceled: true,
players: original.players.filter(p => p.id !== f.guild).map(p => ({ ...p, ally: null, shipped: false })),
homeworlds: { ...original.homeworlds!, worlds: original.homeworlds!.worlds!.filter(w => w.native !== f.guild) },
homeworldShipment: { event: 'controlled-secret-world-quote', blocked: null },
nexusCards: { ...original.nexusCards!, card: 'guild', waiting: [] },
nexusGuildSecretAlly: { event, blocked: null },
} as GameView;
const ordinary = homeworldShipmentChoice(view, intent.destination, intent.sources);
const secret = homeworldShipmentChoice(view, intent.destination, intent.sources, 0, event);
assert.equal(ordinary.cost, 3);
assert.equal(secret.cost, 2);
assert.equal(secret.blocked, null);
});
for (const rate of ['half', 'full'] as const) {
void test(`original immune Junction ${rate} offer after actual native cancellation keeps its printed tariff and no second rate queue`, () => {
const f = createNativeImmuneJunctionRate(rate), before = f.beforeAction;
const cost = rate === 'half' ? 2 : 3;
assert.equal(viewGame(before, f.entrant).guildRateCanceled, true);
const view = viewGame(before, f.entrant);
assert.equal(junctionTransportChoice(view, f.destination, f.sources).cost, cost);
const done = act(before, f.entrant, f.action);
assert.notEqual(done.response?.kind, 'guildRate');
assert.equal(done.decision, null);
assert.ok(!done.pendingHomeworldShipment);
assert.equal(player(done, f.entrant).spice, player(before, f.entrant).spice - cost);
assert.equal(player(done, f.guild).spice, player(before, f.guild).spice);
assert.equal(player(done, f.entrant).reserves, player(before, f.entrant).reserves + 3);
assert.equal(player(done, f.entrant).forces[f.source], player(before, f.entrant).forces[f.source] - 3);
assert.equal(player(done, f.entrant).shipped, true);
assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
homeworldGameIntegrity(done);
});
}
for (const outcome of ['allow', 'cancel', 'unaffordable'] as const) {
void test(`controlled Cunning Homeworld ${outcome}: the native rate retains one original second-shipment allowance`, () => {
const f = createControlledGuildCunningHomeworldRateScene(outcome === 'unaffordable');
const before = f.beforeAction, record = before.nexusGuildCunningHistory!.at(-1)!;
assert.equal(record.stage, 'secondShipment');
assert.equal(record.shipment, undefined);
assert.deepEqual(f.game.homeworlds, before.homeworlds);
assert.equal(player(f.game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(f.game, f.owner).reserves, player(before, f.owner).reserves);
assert.equal(f.game.nexusGuildCunningHistory!.at(-1)!.shipment?.stage, 'pending');
assert.equal(f.game.pendingHomeworldShipment?.guildNexusEvent, record.receipt.event);
let done = outcome === 'allow'
? passNativeGuildHomeworldResponses(f.game, 'guildRate').game
: stepPairedE3Nexus(f.game, f.cancel);
if (outcome === 'unaffordable') {
assert.equal(player(done, f.owner).spice, 2);
assert.deepEqual(done.homeworlds, before.homeworlds);
assert.equal(player(done, f.owner).reserves, player(before, f.owner).reserves);
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment');
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.shipment, undefined);
assert.ok(!done.pendingHomeworldShipment);
const choice = homeworldShipmentChoice(viewGame(done, f.owner), f.destination,
{ 'homeworld:guild': { normal: 2, elite: 0 } }, 0);
assert.equal(choice.cost, 2);
assert.ok(choice.action);
done = act(done, f.owner, choice.action);
assert.notEqual(done.response?.kind, 'guildRate');
}
const amount = outcome === 'unaffordable' ? 2 : 3;
const cost = outcome === 'cancel' ? 3 : 2;
assert.equal(player(done, f.owner).spice, player(before, f.owner).spice - cost);
assert.equal(player(done, f.owner).reserves, player(before, f.owner).reserves - amount);
assert.deepEqual(done.homeworlds!.custody!.visitors[f.destination][f.owner], { normal: amount, elite: 0 });
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.shipment?.stage, 'complete');
assert.equal(done.nexusGuildCunningHistory!.at(-1)!.receipt.event, record.receipt.event);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
assert.equal(done.discard.filter(card => card.id === f.karama).length, outcome === 'allow' ? 0 : 1);
const another = homeworldShipmentChoice(viewGame(done, f.owner), f.destination,
{ 'homeworld:guild': { normal: 1, elite: 0 } }, 0);
assert.equal(another.action, null);
assert.ok(another.blocked);
homeworldGameIntegrity(done);
});
}