Files
Dune/tests/nexus-guild-cunning-engine.test.ts
Quintine b82441c834 Allow replay of the recovered physical Hajr
Count one movement grant per accepted Hajr play after original paid Box recovery. Preserve physical card custody, Basic timing and fresh Advanced post-movement opportunities. Carry unused counts through Guild Cunning with counted declarations and signed final history while retaining legacy receipt compatibility.

Validation: frozen12-file102/102, types/lint/build and two original-action/rendered-table programmes; four minimal policies retain third moves in both modes. Real cache and normal purchases, paid shipment and Box2 recovery reuse the single original Hajr. Replay is an explicit source interpretation. Native Cunning replay has pure/related evidence; wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
2026-10-18 12:33:50 +11:00

540 lines
19 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { passNativeGuildTransportPermission } from './fixture-guild-ambassador-income-arrival';
import {
nexusGuildCunningFixture,
originalGuildCunningTurn,
nexusGuildCunningRequest,
nexusGuildCunningAllow,
nexusGuildCunningReload,
nexusGuildCunningInventory,
settleGuildCunningShipment,
holdGuildCunningCard,
nexusGuildCunningAllianceFixture,
} from './fixture-nexus-guild-cunning';
function rejected(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function stable(g: Game) {
nexusGuildCunningInventory(g);
for (const p of g.players)
assert.deepEqual(
viewGame(nexusGuildCunningReload(g), p.id),
viewGame(g, p.id),
);
assert.deepEqual(
normalizeAutomaticGame(nexusGuildCunningReload(g)),
nexusGuildCunningReload(g),
);
}
void test('an actual Moritani alliance formed on the second arrival preserves the earned Guild Hajr continuation', () => {
const f = nexusGuildCunningAllianceFixture();
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
let g = originalGuildCunningTurn(f);
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
g = settleGuildCunningShipment(
applyAction(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
}),
);
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
g = nexusGuildCunningAllow(
applyAction(g, f.target, { type: 'decision', alliance: true }),
);
stable(g);
const before = structuredClone(g.players[0]);
g = applyAction(nexusGuildCunningReload(g), f.owner, {
type: 'decision',
accept: true,
});
assert.equal(g.players[0].ally, f.target);
assert.equal(g.players[1].ally, f.owner);
assert.equal(g.players[0].spice, before.spice);
assert.equal(g.players[0].reserves, before.reserves);
stable(g);
g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
g = applyAction(g, f.owner, {
type: 'move',
from: 'carthag:11',
territory: 'hagga_basin',
sector: 12,
amount: 3,
});
assert.equal(g.players[0].forces['hagga_basin:12'], 8);
assert.equal(g.players[0].spice, before.spice);
if (g.active === f.owner)
g = applyAction(g, f.owner, { type: 'endMovement' });
assert.notEqual(g.active, f.owner);
stable(g);
});
void test('Guild special Karama stops its own second shipment without payment or arrival and preserves only the unused Hajr move', () => {
for (const homeworlds of [false, true]) {
const f = nexusGuildCunningFixture({ advanced: true, homeworlds });
const karama = holdGuildCunningCard(f.g, f.owner, 'karama');
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
let g = originalGuildCunningTurn(f);
g = applyAction(g, f.owner, {type:'card',card:hajr.id});
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
const before = structuredClone(g.players[0]);
const shipment: Action = homeworlds
? {
type: 'homeworldShip',
event: viewGame(g, f.owner).homeworldShipment!.event,
destination: 'homeworld:harkonnen',
sources: { 'homeworld:guild': { normal: 3, elite: 0 } },
}
: { type: 'ship', territory: 'carthag', sector: 11, amount: 3 };
g = applyAction(g, f.owner, shipment);
assert.equal(
g.decision?.kind,
homeworlds ? 'homeworldShipmentGuild' : 'guildShipment',
);
stable(g);
g = applyAction(nexusGuildCunningReload(g), f.owner, {
type: 'card',
mode: 'special',
card: karama.id,
});
assert.equal(
g.nexusGuildCunningHistory!.at(-1)!.shipment!.stage,
'stopped',
);
assert.equal(g.players[0].spice, before.spice);
assert.equal(g.players[0].reserves, before.reserves);
assert.deepEqual(g.players[0].forces, before.forces);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
1,
);
if (homeworlds)
assert.equal(
g.homeworlds!.custody!.visitors['homeworld:harkonnen']?.[f.owner],
undefined,
);
const move: Action = {
type: 'move',
from: 'hagga_basin:12',
territory: 'plastic_basin',
sector: 12,
amount: 5,
};
rejected(g, f.owner, shipment);
assert.equal(viewGame(g,f.owner).nexusGuildCunning!.active!.movesLeft,1);
// Supplied Advanced timing played Hajr after the first actual movement;
// the stopped second shipment preserves that already earned unused move.
g = applyAction(g, f.owner, move);
rejected(g, f.owner, {
type: 'move',
from: 'plastic_basin:12',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});
assert.equal(g.players[0].spice, before.spice);
stable(g);
}
});
void test('native and Cunning cross-shipments cannot manufacture a shipment within the same territory', () => {
const f = nexusGuildCunningFixture();
const native = nexusGuildCunningFixture();
native.g.players[0].reserves -= 5;
native.g.players[0].forces['arrakeen:10'] = 5;
nexusGuildCunningInventory(native.g);
let g = originalGuildCunningTurn(f, false);
const same: Action = {
type: 'guildShip',
from: 'arrakeen:10',
territory: 'arrakeen',
sector: 10,
amount: 1,
};
rejected(native.g, native.owner, same);
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
rejected(g, f.owner, same);
stable(g);
});
void test('Guild Cunning adds a paid native reserve, cross or return shipment after the actual original movement without resetting flags', () => {
for (const advanced of [false, true])
for (const route of ['reserve', 'cross', 'return'] as const) {
const f = nexusGuildCunningFixture({ advanced, karama: true });
let g = originalGuildCunningTurn(f);
const before = nexusGuildCunningReload(g);
const request = nexusGuildCunningRequest(f, g);
g = applyAction(g, f.owner, request);
assert.equal(g.response?.kind, 'nexusGuildCunning');
assert.equal(g.players[0].shipped, true);
assert.equal(g.players[0].moved, 1);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
stable(g);
g = nexusGuildCunningAllow(nexusGuildCunningReload(g));
assert.equal(
viewGame(g, f.owner).nexusGuildCunning!.active!.stage,
'secondShipment',
);
const action: Action =
route === 'reserve'
? { type: 'ship', territory: 'carthag', sector: 11, amount: 5 }
: {
type: 'guildShip',
from: 'hagga_basin:12',
territory: route === 'return' ? 'reserves' : 'carthag',
sector: route === 'return' ? 0 : 11,
amount: 5,
};
g = settleGuildCunningShipment(applyAction(g, f.owner, action));
assert.equal(g.players[0].spice, before.players[0].spice - 3);
assert.equal(g.players[0].moved, 1);
assert.equal(g.players[0].shipped, true);
assert.equal(
g.players[0].reserves,
route === 'reserve' ? 10 : route === 'return' ? 20 : 15,
);
if (route !== 'return')
assert.equal(g.players[0].forces['carthag:11'], 5);
rejected(g, f.owner, action);
rejected(g, f.owner, {
type: 'move',
from: 'carthag:11',
territory: 'hagga_basin',
sector: 12,
amount: 1,
});
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
1,
);
stable(g);
}
});
void test('canceling Guild Cunning preserves the first paid shipment and movement and does not reset the real turn queue', () => {
for (const advanced of [false, true]) {
const f = nexusGuildCunningFixture({ advanced, karama: true });
let g = originalGuildCunningTurn(f);
const before = nexusGuildCunningReload(g),
request = nexusGuildCunningRequest(f, g);
g = applyAction(g, f.owner, request);
g = applyAction(nexusGuildCunningReload(g), f.target, {
type: 'card',
mode: 'cancel',
card: f.karama,
});
assert.deepEqual(g.players[0].forces, before.players[0].forces);
assert.equal(g.players[0].spice, before.players[0].spice);
assert.equal(g.players[0].moved, 1);
assert.equal(g.players[0].shipped, true);
assert.equal(g.movementRemaining!.includes(f.owner), false);
assert.notEqual(g.active, f.owner);
assert.equal(g.discard.filter((c) => c.id === f.karama).length, 1);
rejected(g, f.owner, request);
rejected(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 1,
});
stable(g);
}
});
void test('declining ordinary movement does not leave a free movement after the second Guild shipment', () => {
for (const originalShip of [false, true]) {
const f = nexusGuildCunningFixture();
let g = originalShip ? originalGuildCunningTurn(f, false) : f.g;
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
g = settleGuildCunningShipment(
applyAction(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
}),
);
assert.equal(g.players[0].moved, 0);
rejected(g, f.owner, {
type: 'move',
from: 'carthag:11',
territory: 'hagga_basin',
sector: 12,
amount: 3,
});
stable(g);
}
});
void test('Hajr grants only its remaining extra movement before or after the second shipment and cannot create a third move', () => {
for (const mode of ['late', 'early-unspent', 'early-used'] as const) {
const f = nexusGuildCunningFixture();
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
let g = originalGuildCunningTurn(f);
if (mode !== 'late')
g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
if (mode === 'early-used')
g = applyAction(g, f.owner, {
type: 'move',
from: 'hagga_basin:12',
territory: 'arsunt',
sector: 12,
amount: 5,
});
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
g = settleGuildCunningShipment(
applyAction(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
}),
);
if (mode === 'late')
g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
const move: Action = {
type: 'move',
from: 'carthag:11',
territory: 'hagga_basin',
sector: 12,
amount: 3,
};
if (mode === 'early-used') rejected(g, f.owner, move);
else {
g = applyAction(g, f.owner, move);
assert.equal(g.players[0].moved, 2);
rejected(g, f.owner, {
type: 'move',
from: 'hagga_basin:12',
territory: 'arrakeen',
sector: 10,
amount: 1,
});
}
assert.equal(g.discard.filter((c) => c.id === hajr.id).length, 1);
stable(g);
}
});
void test('declining the second shipment preserves paid first forces and permits only an unused Hajr move after declining ordinary movement', () => {
const f = nexusGuildCunningFixture();
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
let g = originalGuildCunningTurn(f, false);
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
const skip: Action = {
type: 'nexusGuildSkipShipment',
event: viewGame(g, f.owner).nexusGuildCunning!.active!.event,
};
rejected(g, f.target, skip);
rejected(g, f.owner, { ...skip, event: 'stale' });
g = applyAction(g, f.owner, skip);
assert.equal(g.players[0].spice, 17);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].shipped, true);
assert.equal(g.players[0].moved, 0);
stable(g);
const move: Action = {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
};
rejected(g, f.owner, move);
rejected(g, f.owner, skip);
g = applyAction(nexusGuildCunningReload(g), f.owner, {
type: 'card',
card: hajr.id,
});
g = applyAction(g, f.owner, move);
assert.equal(g.players[0].forces['hagga_basin:12'], 5);
assert.equal(g.players[0].spice, 17);
rejected(g, f.owner, {
type: 'move',
from: 'hagga_basin:12',
territory: 'arsunt',
sector: 12,
amount: 5,
});
assert.equal(g.discard.filter((c) => c.id === hajr.id).length, 1);
stable(g);
});
void test('Guild Cunning keeps declared ownership private before play and rejects stale, foreign or duplicate declarations', () => {
const f = nexusGuildCunningFixture();
let g = originalGuildCunningTurn(f);
const request = nexusGuildCunningRequest(f, g);
for (const id of [f.target, f.observer])
assert.equal(viewGame(g, id).nexusGuildCunning, null);
rejected(g, f.owner, { ...request, nexus: 'old' });
rejected(g, f.target, request);
g = nexusGuildCunningAllow(applyAction(g, f.owner, request));
rejected(g, f.owner, request);
for (const p of g.players)
assert.equal(
Object.hasOwn(viewGame(g, p.id), 'nexusGuildCunningHistory'),
false,
);
stable(g);
});
void test('a second Guild shipment can return actual Arrakis counters to Junction through the typed native Homeworld route', () => {
const f = nexusGuildCunningFixture({ homeworlds: true });
let g = originalGuildCunningTurn(f);
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
const option = viewGame(g, f.owner).guildHomeworldShipment!;
assert.equal(option.blocked, null);
g = applyAction(g, f.owner, {
type: 'guildHomeworldShip',
event: option.event,
destination: 'homeworld:guild',
sources: { 'hagga_basin:12': { normal: 5, elite: 0 } },
});
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players[0].forces['hagga_basin:12'], undefined);
assert.equal(g.players[0].spice, 14);
stable(g);
const outward = nexusGuildCunningFixture({ homeworlds: true });
let next = originalGuildCunningTurn(outward);
next = nexusGuildCunningAllow(
applyAction(next, outward.owner, nexusGuildCunningRequest(outward, next)),
);
const outwardOption = viewGame(next, outward.owner).homeworldShipment!;
assert.equal(outwardOption.blocked, null);
next = applyAction(next, outward.owner, {
type: 'homeworldShip',
event: outwardOption.event,
destination: 'homeworld:harkonnen',
sources: { 'homeworld:guild': { normal: 2, elite: 0 } },
});
assert.equal(next.players[0].reserves, 13);
assert.equal(
next.homeworlds!.custody!.visitors['homeworld:harkonnen'][outward.owner]
.normal,
2,
);
assert.equal(next.players[0].spice, 16);
stable(next);
});
void test('Advanced Guild Cunning finishes within a genuinely granted out-of-order combined turn', () => {
const f = nexusGuildCunningFixture({ advanced: true, karama: true });
let g = f.g;
// The first phase-five position is only fixture staging. Enter its native
// turn queue through an actual completed Revival boundary before acting.
Object.assign(g, {
phase: 4,
active: null,
ready: [],
order: [f.target, f.observer, f.owner],
});
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'guildTiming');
g = applyAction(g, f.owner, { type: 'decision', take: true });
g = nexusGuildCunningAllow(g);
assert.equal(g.active, f.owner);
assert.equal(g.guildTimingGranted, true);
g = originalGuildCunningTurn(f, true, g);
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
g = settleGuildCunningShipment(
applyAction(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
}),
);
if (g.active === f.owner)
g = applyAction(g, f.owner, { type: 'endMovement' });
assert.equal(g.movementRemaining!.includes(f.owner), false);
assert.equal(g.active, f.target);
assert.equal(new Set(g.movementRemaining).size, g.movementRemaining!.length);
stable(g);
});
for (const route of ['reserve', 'transport'] as const) {
for (const outcome of ['allow', 'cancel', 'unaffordable'] as const) {
void test(`Guild Cunning ${route} preserves its second allowance through native rate ${outcome}`, () => {
// Existing controlled final-setup fixture; distinct from the original
// action-only expansion/Nexus producer in native-guild-rate-karama.
const f = nexusGuildCunningFixture({ advanced: true, karama: true });
let g = originalGuildCunningTurn(f, false);
g = nexusGuildCunningAllow(
applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
);
if (outcome === 'unaffordable')
g = applyAction(g, f.owner, { type: 'bribe', target: f.observer,
amount: g.players[0].spice - 2 });
const before = structuredClone(g.players[0]);
const shipment: Action = route === 'reserve'
? { type: 'ship', territory: 'carthag', sector: 11, amount: 3 }
: { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 3 };
g = applyAction(g, f.owner, shipment);
if (route === 'reserve') {
assert.equal(g.decision?.kind, 'guildShipment');
g = applyAction(g, f.owner, { type: 'decision', allow: true });
}
g = passNativeGuildTransportPermission(g).game;
assert.equal(g.response?.kind, 'guildRate');
assert.equal(g.players[0].shipped, true);
assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
assert.equal(g.players[0].spice, before.spice);
assert.equal(g.players[0].reserves, before.reserves);
assert.deepEqual(g.players[0].forces, before.forces);
g = outcome === 'allow' ? nexusGuildCunningAllow(g)
: applyAction(g, f.target, { type: 'card', mode: 'cancel', card: f.karama! });
let amount = 3, cost = outcome === 'allow' ? 2 : 3;
if (outcome === 'unaffordable') {
assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment');
assert.ok(!g.nexusGuildCunningHistory!.at(-1)!.shipment);
assert.equal(g.players[0].spice, before.spice);
assert.equal(g.players[0].reserves, before.reserves);
assert.deepEqual(g.players[0].forces, before.forces);
assert.equal(viewGame(g, f.owner).guildRateCanceled, true);
g = settleGuildCunningShipment(applyAction(g, f.owner, { ...shipment, amount: 2 }));
amount = cost = 2;
}
assert.equal(g.nexusGuildCunningLast?.stage, 'extraMove');
assert.equal(g.nexusGuildCunningHistory!.at(-1)!.shipment!.stage, 'complete');
assert.equal(g.players[0].spice, before.spice - cost);
assert.equal(g.players[0].reserves, before.reserves - (route === 'reserve' ? amount : 0));
assert.equal(g.players[0].forces['arrakeen:10'],
before.forces['arrakeen:10'] - (route === 'transport' ? amount : 0));
assert.equal(g.players[0].forces['carthag:11'], amount);
assert.equal(g.discard.filter(card => card.id === f.karama).length, outcome === 'allow' ? 0 : 1);
g = applyAction(g, f.owner, { type: 'endMovement' });
assert.notEqual(g.active, f.owner);
assert.equal(g.nexusGuildCunningLast?.stage, 'completed');
});
}
}