Files
Dune/tests/nexus-guild-cunning-controls.test.ts
T

266 lines
10 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import {
nexusGuildCunningAction,
nexusGuildShipmentAvailable,
nexusGuildMovementAvailable,
nexusGuildSkipShipmentAction,
} from '../game/nexus-guild-cunning-options';
import { ordinaryCardAvailability } from '../game/card-availability';
import { guildTransportQuote } from '../game/transport-quote';
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import {
nexusGuildCunningFixture,
originalGuildCunningTurn,
nexusGuildCunningRequest,
nexusGuildCunningAllow,
settleGuildCunningShipment,
nexusGuildCunningInventory,
holdGuildCunningCard,
} from './fixture-nexus-guild-cunning';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image'
? 'vinext/shims/image'
: specifier === 'next/link'
? 'vinext/shims/link'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(
createElement(GameTable, {
game,
busy,
send: async () => {},
onExit() {},
}),
);
function second(homeworlds = false) {
const f = nexusGuildCunningFixture({ homeworlds });
const original = originalGuildCunningTurn(f);
const g = nexusGuildCunningAllow(
applyAction(original, f.owner, nexusGuildCunningRequest(f, original)),
);
return { f, original, g };
}
void test('Guild declaration controls bind the owned current ending opportunity, leave first-turn history intact and protect observers', () => {
for (const advanced of [false, true]) {
const f = nexusGuildCunningFixture({ advanced, karama: true });
const original = originalGuildCunningTurn(f);
const v = viewGame(original, f.owner),
event = v.nexusGuildCunning!.offer!.event;
const before = structuredClone(v);
const action = nexusGuildCunningAction(v, event)!;
assert.deepEqual(action, { type: 'endMovement', nexus: event });
assert.equal(nexusGuildCunningAction(v, 'stale'), null);
assert.match(html(v), /Finish ordinary turn and declare Guild Cunning/);
assert.match(html(v, true), /disabled=""/);
const pending = applyAction(original, f.owner, action);
assert.equal(pending.response?.kind, 'nexusGuildCunning');
const pv = viewGame(pending, f.owner);
assert.match(
html(pv),
/Allow one second Guild shipment at its normal price/,
);
assert.doesNotMatch(
html(pv),
/"nexusGuildCunning"|\["nexusGuildCunning"/,
);
assert.equal(nexusGuildShipmentAvailable(pv), false);
assert.equal(nexusGuildMovementAvailable(pv), false);
assert.equal(pv.players.find((p) => p.id === f.owner)!.shipped, true);
assert.equal(pv.players.find((p) => p.id === f.owner)!.moved, 1);
for (const id of [f.target, f.observer]) {
const other = viewGame(original, id);
assert.equal(other.nexusGuildCunning, null);
Object.defineProperty(other, 'nexusGuildCunning', {
get() {
throw Error('Private Guild grant read');
},
});
assert.equal(nexusGuildCunningAction(other, event), null);
}
assert.deepEqual(v, before);
}
});
void test('second shipment controls and ordinary transport quote reopen only the new shipment despite shipped true', () => {
const { f, g } = second();
holdGuildCunningCard(g, f.owner, 'karama');
const v = viewGame(g, f.owner);
const before = structuredClone(v);
assert.equal(v.players.find((p) => p.id === f.owner)!.shipped, true);
assert.equal(nexusGuildShipmentAvailable(v), true);
assert.equal(nexusGuildMovementAvailable(v), false);
const markup = html(v);
assert.match(markup, /Ship from reserves/);
assert.match(markup, /Guild rates for/);
assert.match(markup, /Decline second shipment and finish/);
assert.match(markup, /This does not reopen ordinary movement/);
const action = {
type: 'guildShip',
from: 'hagga_basin:12',
territory: 'carthag',
sector: 11,
amount: 3,
};
const same = { ...action, territory: 'hagga_basin', sector: 12 };
assert.ok(
guildTransportQuote(v, same).unavailableReasons.some((reason) =>
/another territory/.test(reason),
),
);
const originalState = structuredClone(g);
assert.throws(
() => applyAction(g, f.owner, same),
/another territory|same territory|different destination territory/,
);
assert.deepEqual(g, originalState);
const ordinary = nexusGuildCunningFixture();
// Conserved ordinary board source before any declaration, not a fabricated grant.
ordinary.g.players[0].reserves -= 3;
ordinary.g.players[0].forces['hagga_basin:12'] = 3;
const ordinaryView = viewGame(ordinary.g, ordinary.owner);
assert.equal(nexusGuildShipmentAvailable(ordinaryView), true);
assert.ok(
guildTransportQuote(ordinaryView, same).unavailableReasons.some((reason) =>
/another territory/.test(reason),
),
);
const ordinaryBefore = structuredClone(ordinary.g);
assert.throws(
() => applyAction(ordinary.g, ordinary.owner, same),
/another territory|same territory|different destination territory/,
);
assert.deepEqual(ordinary.g, ordinaryBefore);
const quote = guildTransportQuote(v, action);
assert.deepEqual(quote.unavailableReasons, []);
const next = settleGuildCunningShipment(applyAction(g, f.owner, action));
assert.equal(
next.players.find((p) => p.id === f.owner)!.forces['carthag:11'],
3,
);
nexusGuildCunningInventory(next);
const stale = structuredClone(v);
stale.nexusGuildCunning!.active!.event = 'stale';
assert.equal(nexusGuildShipmentAvailable(stale), false);
assert.deepEqual(v, before);
});
void test('both native Homeworld shipment controls consume the second grant with typed source quotes', () => {
const { f, g } = second(true),
v = viewGame(g, f.owner);
assert.equal(nexusGuildShipmentAvailable(v), true);
const outward = homeworldShipmentChoice(v, 'homeworld:harkonnen', {
'homeworld:guild': { normal: 2, elite: 0 },
});
assert.equal(outward.blocked, null);
assert.ok(outward.action);
nexusGuildCunningInventory(
settleGuildCunningShipment(applyAction(g, f.owner, outward.action)),
);
const back = guildHomeworldShipmentChoice(v, 'homeworld:guild', {
'hagga_basin:12': { normal: 3, elite: 0 },
});
assert.equal(back.blocked, null);
assert.ok(back.action);
const restored = settleGuildCunningShipment(
applyAction(g, f.owner, back.action),
);
assert.equal(restored.players.find((p) => p.id === f.owner)!.reserves, 18);
nexusGuildCunningInventory(restored);
assert.match(html(v), /Ship to another Homeworld/);
});
void test('Hajr availability uses the explicit unused-card permission rather than the derived movement ceiling', () => {
const f = nexusGuildCunningFixture();
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
const original = originalGuildCunningTurn(f);
let g = nexusGuildCunningAllow(
applyAction(original, f.owner, nexusGuildCunningRequest(f, original)),
);
g = settleGuildCunningShipment(
applyAction(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 2,
}),
);
const v = viewGame(g, f.owner);
assert.equal(v.nexusGuildCunning!.active!.stage, 'extraMove');
assert.equal(ordinaryCardAvailability(v, hajr.id)?.available, true);
const played = applyAction(g, f.owner, { type: 'card', card: hajr.id });
const pv = viewGame(played, f.owner);
assert.equal(nexusGuildMovementAvailable(pv), true);
assert.equal(pv.nexusGuildCunning!.active!.hajrAvailable, false);
// Consumer-only alias of a retained physical card tests the explicit flag, without engine evidence claims.
pv.players.find((p) => p.id === f.owner)!.hand!.push(hajr);
pv.players.find((p) => p.id === f.owner)!.movesAllowed = 1;
assert.equal(ordinaryCardAvailability(pv, hajr.id)?.available, false);
nexusGuildCunningInventory(played);
});
void test('skipping only the second shipment retains a real Hajr choice with no shipment payment or force movement', () => {
const f = nexusGuildCunningFixture({ spice: 3 });
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
const first = originalGuildCunningTurn(f);
const g = nexusGuildCunningAllow(
applyAction(first, f.owner, nexusGuildCunningRequest(f, first)),
);
const v = viewGame(g, f.owner),
event = v.nexusGuildCunning!.active!.event;
assert.match(html(v), /Skip second shipment/);
assert.match(html(v), /Decline second shipment and finish/);
assert.equal(nexusGuildSkipShipmentAction(v, 'stale'), null);
const before = g.players.find((p) => p.id === f.owner)!;
const skipped = applyAction(
g,
f.owner,
nexusGuildSkipShipmentAction(v, event)!,
);
const sv = viewGame(skipped, f.owner),
after = skipped.players.find((p) => p.id === f.owner)!;
assert.equal(sv.nexusGuildCunning!.active!.stage, 'extraMove');
assert.equal(nexusGuildShipmentAvailable(sv), false);
assert.equal(nexusGuildMovementAvailable(sv), false);
assert.equal(ordinaryCardAvailability(sv, hajr.id)?.available, true);
assert.equal(after.spice, before.spice);
assert.equal(after.reserves, before.reserves);
assert.deepEqual(after.forces, before.forces);
assert.equal(after.shipped, before.shipped);
assert.equal(nexusGuildSkipShipmentAction(sv, event), null);
const prepared = applyAction(skipped, f.owner, {
type: 'card',
card: hajr.id,
});
assert.equal(nexusGuildMovementAvailable(viewGame(prepared, f.owner)), true);
nexusGuildCunningInventory(prepared);
});