266 lines
10 KiB
TypeScript
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);
|
|
});
|