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