Implement Guild Nexus Cunning second shipment and recovery

This commit is contained in:
Quintine committed 2026-09-10 13:35:24 +10:00
1 parent 6ba1050a92
commit 90bf699627
27 files changed
+2657 -43

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
import {
nexusRicheseFixture,
holdNexusRicheseCard,
type NexusRicheseFixtureOptions,
} from './fixture-nexus-richese';
import { nexusTraitorInventory } from './fixture-nexus-traitors';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
import { orderNexusSpice } from './fixture-nexus-cards';
import { botActions } from '../game/bots';
export type NexusGuildCunningFixture = {
g: Game;
owner: string;
target: string;
observer: string;
karama?: string;
};
/** Reuse genuine final native Guild setup, then exchange two physical Nexus
* identities before any Cunning declaration; no grant or progress is invented. */
export function nexusGuildCunningFixture(
options: Pick<
NexusRicheseFixtureOptions,
| 'seatIds'
| 'advanced'
| 'homeworlds'
| 'spice'
| 'karama'
| 'opponentFaction'
> = {},
): NexusGuildCunningFixture {
const f = nexusRicheseFixture({ ...options, ownerFaction: 'guild' });
const cards = f.g.nexusCards!.cards!,
index = cards.deck.indexOf('guild');
assert.ok(index >= 0);
assert.equal(cards.hands[f.owner], 'richese');
cards.deck[index] = 'richese';
cards.hands[f.owner] = 'guild';
nexusGuildCunningInventory(f.g);
return f;
}
/** Allow only the already declared shipment and its ordinary response chain. */
export function settleGuildCunningShipment(g: Game): Game {
if (g.decision?.kind === 'guildShipment')
g = applyAction(g, g.decision.player, { type: 'decision', allow: true });
return nexusAllow(g);
}
export function originalGuildCunningTurn(
f: NexusGuildCunningFixture,
move = true,
g = f.g,
): Game {
g = settleGuildCunningShipment(
applyAction(g, f.owner, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
}),
);
if (move)
g = applyAction(g, f.owner, {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});
nexusGuildCunningInventory(g);
return g;
}
export function nexusGuildCunningRequest(
f: NexusGuildCunningFixture,
g = f.g,
): Action {
return {
type: 'endMovement',
nexus: viewGame(g, f.owner).nexusGuildCunning!.offer!.event,
};
}
export const holdGuildCunningCard = holdNexusRicheseCard;
export function nexusGuildCunningInventory(g: Game) {
// The shared card census also checks a pre-Homeworld aggregate force total.
// Count foreign visitors in that detached census; validate actual typed
// Homeworld custody against the unchanged game separately.
const census = structuredClone(g);
for (const p of census.players)
p.reserves += Object.values(g.homeworlds?.custody?.visitors ?? {}).reduce(
(sum, visitors) => {
const group = visitors[p.id];
return sum + (group?.normal ?? 0) + (group?.elite ?? 0);
},
0,
);
nexusTraitorInventory(census);
if (g.homeworlds) homeworldGameIntegrity(g);
}
export const nexusGuildCunningReload = nexusReload;
export const nexusGuildCunningAllow = nexusAllow;
/** Place an actual native Terror token at the first Mentat, then complete the
* next Storm/Spice/auction/Revival before returning the Guild's real turn. */
export function nexusGuildCunningAllianceFixture(
seatIds?: [string, string, string],
): NexusGuildCunningFixture {
const f = nexusGuildCunningFixture({ seatIds, opponentFaction: 'moritani' });
let g = f.g;
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'moritaniPlacement');
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
g = nexusAllow(
applyAction(g, f.target, {
type: 'decision',
token: token.id,
territory: 'carthag',
}),
);
let ordered = false;
for (
let step = 0;
!(
g.turn === 2 &&
g.phase === 5 &&
!g.response &&
!g.decision &&
!g.phaseOpening
) && step < 180;
step++
) {
if (g.turn === 2 && g.phase === 1 && !ordered) {
orderNexusSpice(g, ['land', 'land']);
ordered = true;
}
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const a = botActions(v)[0];
if (a) {
next = applyAction(g, p.id, a);
break;
}
}
assert.ok(
next,
`Guild alliance fixture stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
);
g = next;
}
assert.equal(g.turn, 2);
assert.equal(g.phase, 5);
while (g.active !== f.owner)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'guild');
nexusGuildCunningInventory(g);
return { ...f, g };
}
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
nexusGuildCunningFixture,
originalGuildCunningTurn,
nexusGuildCunningAllow,
settleGuildCunningShipment,
nexusGuildCunningInventory,
holdGuildCunningCard,
} from './fixture-nexus-guild-cunning';
void test('all Guild profiles finish the first turn through Cunning then propose a second shipment without ordinary movement', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusGuildCunningFixture();
const first = originalGuildCunningTurn(f);
const v = viewGame(first, f.owner);
v.players.find((p) => p.id === f.owner)!.bot = profile;
const action = botActions(v)[0];
assert.equal(action.type, 'endMovement');
assert.equal(action.nexus, v.nexusGuildCunning!.offer!.event);
const g = nexusGuildCunningAllow(applyAction(first, f.owner, action));
const next = viewGame(g, f.owner);
next.players.find((p) => p.id === f.owner)!.bot = profile;
const candidates = botActions(next);
assert.ok(
candidates.some((a) => a.type === 'ship' || a.type === 'guildShip'),
);
assert.ok(candidates.every((a) => a.type !== 'move'));
const shipment = candidates.find(
(a) => a.type === 'ship' || a.type === 'guildShip',
)!;
nexusGuildCunningInventory(
settleGuildCunningShipment(applyAction(g, f.owner, shipment)),
);
}
});
void test('all Guild profiles use only an offered unused Hajr after the second shipment and preserve blocked ordinary fallback', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusGuildCunningFixture();
const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
const first = originalGuildCunningTurn(f);
const v = viewGame(first, f.owner);
v.players.find((p) => p.id === f.owner)!.bot = profile;
const declared = botActions(v)[0];
v.nexusGuildCunning!.offer!.blocked = 'Unavailable';
assert.equal(botActions(v)[0].nexus, undefined);
let g = nexusGuildCunningAllow(applyAction(first, f.owner, declared));
g = settleGuildCunningShipment(
applyAction(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
}),
);
const after = viewGame(g, f.owner);
after.players.find((p) => p.id === f.owner)!.bot = profile;
assert.deepEqual(botActions(after)[0], { type: 'card', card: hajr.id });
const prepared = applyAction(g, f.owner, botActions(after)[0]);
const pv = viewGame(prepared, f.owner);
pv.players.find((p) => p.id === f.owner)!.bot = profile;
const move = botActions(pv).find((a) => a.type === 'move');
assert.ok(move);
nexusGuildCunningInventory(applyAction(prepared, f.owner, move));
}
});
void test('all Guild profiles skip an unaffordable second shipment to retain their owned unused Hajr', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusGuildCunningFixture({ spice: 3 }),
hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
const first = originalGuildCunningTurn(f),
fv = viewGame(first, f.owner);
fv.players[0].bot = profile;
const g = nexusGuildCunningAllow(
applyAction(first, f.owner, botActions(fv)[0]),
);
const v = viewGame(g, f.owner);
v.players[0].bot = profile;
const skip = botActions(v)[0];
assert.equal(skip.type, 'nexusGuildSkipShipment');
const next = applyAction(g, f.owner, skip),
nv = viewGame(next, f.owner);
nv.players[0].bot = profile;
assert.deepEqual(botActions(nv)[0], { type: 'card', card: hajr.id });
nexusGuildCunningInventory(applyAction(next, f.owner, botActions(nv)[0]));
}
});
void test('a new answered Truthtrance binds the second shipment and all profiles honor its six-force witness despite shipped true', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusGuildCunningFixture();
const truth = holdGuildCunningCard(f.g, f.target, 'truthtrance');
const first = originalGuildCunningTurn(f);
const fv = viewGame(first, f.owner);
fv.players[0].bot = profile;
let g = nexusGuildCunningAllow(
applyAction(first, f.owner, botActions(fv)[0]),
);
g = applyAction(g, f.target, { type: 'card', card: truth.id });
for (let n = 0; n < 10 && g.truthtrance?.stage === 'priority'; n++)
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = applyAction(g, f.target, {
type: 'truthAsk',
question: {
kind: 'shipment',
target: f.owner,
territory: 'carthag',
minimum: 6,
},
});
assert.ok(viewGame(g, f.owner).truthShipmentAnswers!.includes('yes'));
g = applyAction(g, f.owner, { type: 'truthAnswer', answer: 'yes' });
const v = viewGame(g, f.owner);
v.players[0].bot = profile;
assert.equal(v.players[0].shipped, true);
const actions = botActions(v);
assert.ok(actions.length);
assert.ok(
actions.every(
(a) =>
a.type === 'ship' &&
a.territory === 'carthag' &&
Number(a.amount) >= 6,
),
);
const done = settleGuildCunningShipment(
applyAction(g, f.owner, actions[0]),
);
assert.ok(
done.players.find((p) => p.id === f.owner)!.forces['carthag:11'] >= 6,
);
assert.ok(done.shipmentPromises!.every((p) => p.fulfilled && !p.released));
nexusGuildCunningInventory(done);
}
});
+265
View File
@@ -0,0 +1,265 @@
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),
/&quot;nexusGuildCunning&quot;|\["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);
});
+482
View File
@@ -0,0 +1,482 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
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, false);
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: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
};
rejected(g, f.owner, move);
rejected(g, f.owner, shipment);
g = applyAction(nexusGuildCunningReload(g), f.owner, {
type: 'card',
card: hajr.id,
});
g = applyAction(g, f.owner, move);
rejected(g, f.owner, {
type: 'move',
from: 'hagga_basin:12',
territory: 'arsunt',
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);
});
@@ -0,0 +1,160 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import {
nexusGuildCunningFixture,
originalGuildCunningTurn,
nexusGuildCunningRequest,
holdGuildCunningCard,
nexusGuildCunningAllow,
nexusGuildCunningInventory,
nexusGuildCunningReload,
} from './fixture-nexus-guild-cunning';
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)), g);
}
function nested() {
const f = nexusGuildCunningFixture({
advanced: true,
opponentFaction: 'beneGesserit',
});
const worthless = holdGuildCunningCard(f.g, f.target, 'worthless');
const karama = holdGuildCunningCard(f.g, f.observer, 'karama');
let g = originalGuildCunningTurn(f, false);
assert.equal(g.decision?.kind, 'advisor');
g = applyAction(g, f.target, { type: 'decision', accept: false });
g = applyAction(g, f.owner, {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});
const original = nexusGuildCunningReload(g);
g = applyAction(g, f.owner, nexusGuildCunningRequest(f, g));
assert.equal(g.response?.kind, 'nexusGuildCunning');
const response = structuredClone(g.response);
g = applyAction(g, f.target, {
type: 'card',
card: worthless.id,
mode: 'cancel',
});
assert.equal(g.response?.kind, 'worthlessKarama');
assert.equal(g.pendingKarama?.use.kind, 'cancel');
if (g.pendingKarama?.use.kind === 'cancel')
assert.deepEqual(g.pendingKarama.use.response, response);
assert.equal(g.discard.filter((c) => c.id === worthless.id).length, 1);
stable(g);
return { f, g, original, worthless, karama };
}
void test('Guild Cunning survives an actual paid BG Worthless cancellation and its printed Karama counter with exact original movement and custody', () => {
for (const counter of [false, true]) {
const { f, original, worthless, karama, g: pending } = nested();
let g = counter
? applyAction(nexusGuildCunningReload(pending), f.observer, {
type: 'card',
card: karama.id,
mode: 'cancel',
})
: nexusGuildCunningAllow(nexusGuildCunningReload(pending));
assert.equal(
g.nexusGuildCunningHistory!.at(-1)!.stage,
counter ? 'secondShipment' : 'canceled',
);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
1,
);
assert.deepEqual(g.players[0].forces, original.players[0].forces);
assert.equal(g.players[0].spice, original.players[0].spice);
assert.equal(g.players[0].moved, 1);
assert.equal(g.players[0].shipped, true);
assert.equal(g.discard.filter((c) => c.id === worthless.id).length, 1);
assert.equal(
g.discard.filter((c) => c.id === karama.id).length,
counter ? 1 : 0,
);
assert.equal(g.pendingKarama, null);
assert.equal(g.active === f.owner, counter);
assert.equal(g.movementRemaining!.includes(f.owner), counter);
stable(g);
if (counter) {
g = applyAction(g, f.owner, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 2,
});
if (g.decision?.kind === 'guildShipment')
g = applyAction(g, f.owner, { type: 'decision', allow: true });
if (g.decision?.kind === 'advisor')
g = applyAction(g, f.target, { type: 'decision', accept: false });
g = nexusGuildCunningAllow(g);
assert.equal(g.players[0].forces['carthag:11'], 2);
assert.equal(g.players[0].spice, original.players[0].spice - 1);
assert.equal(g.nexusGuildCunningHistory!.at(-1)!.stage, 'extraMove');
stable(g);
}
}
});
void test('nested Guild cancellation rejects altered parent, last marker and saved native source without mutation', () => {
const { f, g: pending, karama } = nested();
const changes: ((g: Game) => void)[] = [
(g) => {
g.nexusGuildCunningHistory![0].parent = '[]';
},
(g) => {
g.nexusGuildCunningLast!.stage = 'secondShipment';
},
(g) => {
delete g.nexusGuildCunningLast;
},
(g) => {
delete g.nexusGuildCunningHistory;
},
(g) => {
if (g.pendingKarama!.use.kind === 'cancel')
g.pendingKarama!.use.response.intent = 'old';
},
(g) => {
if (g.pendingKarama!.use.kind === 'cancel')
g.pendingKarama!.use.response.owner = f.observer;
},
(g) => {
g.players[0].moved = 0;
},
(g) => {
g.movementRemaining = g.movementRemaining!.filter((id) => id !== f.owner);
},
];
for (const change of changes) {
const bad = nexusGuildCunningReload(pending);
change(bad);
const original = nexusGuildCunningReload(bad);
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
assert.throws(() => normalizeAutomaticGame(bad));
assert.throws(() =>
applyAction(bad, f.observer, {
type: 'card',
card: karama.id,
mode: 'cancel',
}),
);
assert.deepEqual(bad, original);
}
});
+566
View File
@@ -0,0 +1,566 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unitStore } from './fixture-nexus-room-store';
import {
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import {
nexusGuildCunningFixture,
originalGuildCunningTurn,
nexusGuildCunningRequest,
nexusGuildCunningInventory,
holdGuildCunningCard,
nexusGuildCunningAllianceFixture,
} from './fixture-nexus-guild-cunning';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(
hajr = false,
originalMove = true,
selfStop = false,
homeworlds = false,
alliance = false,
) {
const store = unitStore(),
made = await store.rooms.createRoom(
'Guild Cunning SQL',
'guild',
false,
[],
),
code = made.view.code,
tokens = [made.token];
for (const faction of ['harkonnen', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((t) => store.rooms.authenticate(code, t)),
),
ids = auths.map((a) => a.playerId) as [string, string, string];
const f = alliance
? nexusGuildCunningAllianceFixture(ids)
: nexusGuildCunningFixture({
seatIds: ids,
karama: true,
advanced: selfStop,
homeworlds,
});
if (alliance) f.karama = holdGuildCunningCard(f.g, f.target, 'karama').id;
const ownKarama = selfStop
? holdGuildCunningCard(f.g, f.owner, 'karama').id
: undefined;
const card = hajr ? holdGuildCunningCard(f.g, f.owner, 'hajr').id : undefined;
f.g = originalGuildCunningTurn(f, originalMove);
f.g.code = code;
f.g.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) =>
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
save(f.g);
store.writes.length = 0;
return {
...store,
...f,
code,
tokens,
auths,
ids,
save,
hajr: card,
ownKarama,
};
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
async function act(f: Fixture, seat: number, action: Action) {
const g = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
return f.restart().readRoom(f.code);
}
async function restored(f: Fixture, g: Game) {
const before = row(f),
rooms = f.restart();
for (const [i, t] of f.tokens.entries()) {
const auth = await rooms.authenticate(f.code, t),
v = await rooms.readSeatView(f.code, auth);
assert.deepEqual(v, viewGame(g, f.ids[i]));
assert.equal(Object.hasOwn(v, 'nexusGuildCunningHistory'), false);
if (i !== 0) assert.equal(v.nexusGuildCunning, null);
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusGuildCunningInventory(g);
}
async function compete(f: Fixture, version: number, action: Action) {
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
const results = await Promise.allSettled(
[0, 1].map(() =>
f.restart().act(f.code, f.auths[0], version, action, clock),
),
);
clearTimeout(timer);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
}
void test('SQLite restores the real second-arrival Moritani alliance reply and continues the spent Guild Nexus through Hajr once', async () => {
const f = await fixture(true, true, false, false, true);
try {
await act(f, 0, nexusGuildCunningRequest(f));
let g = await f.restart().readRoom(f.code);
while (g.response) {
const voter = g.players.find(
(p) => !g.response!.passed.includes(p.id),
)!.id;
g = await act(f, f.ids.indexOf(voter), { type: 'passResponse' });
}
g = await act(f, 0, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
});
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
g = await act(f, 1, { type: 'decision', alliance: true });
while (g.response) {
const voter = g.players.find(
(p) => !g.response!.passed.includes(p.id),
)!.id;
g = await act(f, f.ids.indexOf(voter), { type: 'passResponse' });
}
await restored(f, g);
const spice = g.players[0].spice,
reserves = g.players[0].reserves;
const accepted = await act(f, 0, { type: 'decision', accept: true });
assert.equal(accepted.players[0].ally, f.target);
assert.equal(accepted.players[1].ally, f.owner);
assert.equal(accepted.players[0].spice, spice);
assert.equal(accepted.players[0].reserves, reserves);
await restored(f, accepted);
const before = row(f);
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[0],
accepted.version,
{ type: 'decision', accept: true },
clock,
),
);
assert.deepEqual(row(f), before);
await act(f, 0, { type: 'card', card: f.hajr });
let done = await act(f, 0, {
type: 'move',
from: 'carthag:11',
territory: 'hagga_basin',
sector: 12,
amount: 3,
});
assert.equal(done.players[0].forces['hagga_basin:12'], 8);
assert.equal(done.players[0].spice, spice);
assert.equal(done.players[0].reserves, reserves);
if (done.active === f.owner)
done = await act(f, 0, { type: 'endMovement' });
assert.notEqual(done.active, f.owner);
await restored(f, done);
} finally {
f.sqlite.close();
}
});
void test('SQLite restores self-stopped second Guild shipment with no second cost or arrival and only one Hajr movement', async () => {
const f = await fixture(true, false, true);
try {
await act(f, 0, nexusGuildCunningRequest(f));
await act(f, 1, { type: 'passResponse' });
const pending = await act(f, 0, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
});
assert.equal(pending.decision?.kind, 'guildShipment');
await restored(f, pending);
const stopped = await act(f, 0, {
type: 'card',
mode: 'special',
card: f.ownKarama,
});
assert.equal(
stopped.nexusGuildCunningHistory!.at(-1)!.shipment!.stage,
'stopped',
);
assert.equal(stopped.players[0].spice, 17);
assert.equal(stopped.players[0].reserves, 15);
assert.equal(stopped.players[0].forces['carthag:11'], undefined);
assert.equal(
stopped.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
1,
);
await restored(f, stopped);
const move: Action = {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
};
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], stopped.version, move, clock),
);
assert.deepEqual(row(f), before);
await act(f, 0, { type: 'card', card: f.hajr });
const done = await act(f, 0, move);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].reserves, 15);
assert.equal(done.players[0].forces['hagga_basin:12'], 5);
assert.equal(done.discard.filter((c) => c.id === f.ownKarama).length, 1);
await restored(f, done);
const completed = row(f);
await assert.rejects(
f.restart().act(
f.code,
f.auths[0],
done.version,
{
type: 'move',
from: 'hagga_basin:12',
territory: 'arsunt',
sector: 12,
amount: 5,
},
clock,
),
);
assert.deepEqual(row(f), completed);
} finally {
f.sqlite.close();
}
});
void test('SQLite rejects lost or changed second-shipment source, paid frame and Guild interception with zero writes', async () => {
const f = await fixture(false, true, true, true);
try {
await act(f, 0, nexusGuildCunningRequest(f));
await act(f, 1, { type: 'passResponse' });
const pending = await act(f, 0, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
homeworldSources: { 'homeworld:guild': { normal: 3, elite: 0 } },
});
assert.equal(pending.decision?.kind, 'guildShipment');
await restored(f, pending);
for (const edit of [
(g: Game) => {
delete g.pendingShipment!.guildNexusEvent;
},
(g: Game) => {
g.pendingShipment!.guildNexusEvent = 'other';
},
(g: Game) => {
g.pendingShipment!.amount = 2;
},
(g: Game) => {
g.pendingShipment!.cost = 1;
},
(g: Game) => {
g.pendingShipment!.homeworldSources!['homeworld:guild'].normal = 2;
},
(g: Game) => {
g.pendingShipment!.territory = 'arrakeen';
g.pendingShipment!.sector = 10;
},
(g: Game) => {
g.nexusGuildCunningHistory!.at(-1)!.shipment = {
kind: 'reserve',
stage: 'pending',
frame: '{}',
};
},
(g: Game) => {
g.decision = null;
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.amount = 2;
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.shipper = f.target;
},
]) {
const bad = structuredClone(pending);
edit(bad);
f.save(bad);
f.writes.length = 0;
const before = row(f),
rooms = f.restart();
for (const auth of f.auths)
await assert.rejects(rooms.readSeatView(f.code, auth));
assert.throws(() => normalizeAutomaticGame(bad));
await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]);
await assert.rejects(
rooms.act(
f.code,
f.auths[0],
bad.version,
{ type: 'decision', allow: true },
clock,
),
);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
});
void test('SQLite restores the first Guild turn and lost Cunning declaration then commits exactly one raced second shipment', async () => {
const f = await fixture();
try {
const other = await f.rooms.createRoom(
'Unrelated original room',
'atreides',
false,
[],
),
otherBefore = f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(other.view.code);
const pending = await act(f, 0, nexusGuildCunningRequest(f));
await restored(f, pending);
assert.equal(pending.players[0].spice, 17);
assert.equal(pending.players[0].moved, 1);
assert.equal(pending.players[0].forces['hagga_basin:12'], 5);
const allowed = await act(f, 1, { type: 'passResponse' });
await restored(f, allowed);
f.writes.length = 0;
const shipment: Action = {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
};
await compete(f, allowed.version, shipment);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].spice, 15);
assert.equal(done.players[0].reserves, 12);
assert.equal(done.players[0].forces['carthag:11'], 3);
assert.equal(done.players[0].moved, 1);
assert.equal(
done.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
1,
);
await restored(f, done);
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], done.version, shipment, clock),
);
assert.deepEqual(row(f), before);
assert.deepEqual(
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(other.view.code),
otherBefore,
);
} finally {
f.sqlite.close();
}
});
void test('SQLite canceled Guild Cunning preserves the already moved army and first payment without granting another turn', async () => {
const f = await fixture();
try {
await act(f, 0, nexusGuildCunningRequest(f));
const canceled = await act(f, 1, {
type: 'card',
mode: 'cancel',
card: f.karama,
});
assert.equal(canceled.players[0].spice, 17);
assert.equal(canceled.players[0].reserves, 15);
assert.equal(canceled.players[0].forces['hagga_basin:12'], 5);
assert.equal(canceled.players[0].moved, 1);
assert.equal(canceled.movementRemaining!.includes(f.owner), false);
assert.equal(canceled.discard.filter((c) => c.id === f.karama).length, 1);
await restored(f, canceled);
const before = row(f);
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[0],
canceled.version,
nexusGuildCunningRequest(f),
clock,
),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
void test('SQLite late Hajr restores one extra move after the second shipment without replaying either shipment', async () => {
const f = await fixture(true);
try {
await act(f, 0, nexusGuildCunningRequest(f));
await act(f, 1, { type: 'passResponse' });
const shipped = await act(f, 0, {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 3,
});
await restored(f, shipped);
const extra = await act(f, 0, { type: 'card', card: f.hajr });
await restored(f, extra);
const done = await act(f, 0, {
type: 'move',
from: 'carthag:11',
territory: 'hagga_basin',
sector: 12,
amount: 3,
});
assert.equal(done.players[0].spice, 15);
assert.equal(done.players[0].reserves, 12);
assert.equal(done.players[0].forces['hagga_basin:12'], 8);
assert.equal(done.players[0].moved, 2);
assert.equal(done.discard.filter((c) => c.id === f.hajr).length, 1);
await restored(f, done);
} finally {
f.sqlite.close();
}
});
void test('SQLite changed Guild Cunning history or original movement proof cannot project, normalize or write a continuation', async () => {
const f = await fixture();
try {
const pending = await act(f, 0, nexusGuildCunningRequest(f));
for (const edit of [
(g: Game) => {
delete g.nexusGuildCunningHistory;
},
(g: Game) => {
g.nexusGuildCunningHistory![0].signature = 'changed';
},
(g: Game) => {
g.response = null;
},
(g: Game) => {
g.players[0].moved = 0;
},
(g: Game) => {
g.players[0].shipped = false;
},
(g: Game) => {
g.response!.owner = f.target;
},
]) {
const bad = structuredClone(pending);
edit(bad);
f.save(bad);
f.writes.length = 0;
const before = row(f),
rooms = f.restart();
for (const auth of f.auths)
await assert.rejects(rooms.readSeatView(f.code, auth));
assert.throws(() => normalizeAutomaticGame(bad));
await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]);
await assert.rejects(
rooms.act(
f.code,
f.auths[1],
bad.version,
{ type: 'passResponse' },
clock,
),
);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
});
void test('SQLite restores a declined second shipment and races exactly one Hajr move after the original movement was declined', async () => {
const f = await fixture(true, false);
try {
await act(f, 0, nexusGuildCunningRequest(f));
const allowed = await act(f, 1, { type: 'passResponse' });
const skip: Action = {
type: 'nexusGuildSkipShipment',
event: viewGame(allowed, f.owner).nexusGuildCunning!.active!.event,
};
const skipped = await act(f, 0, skip);
assert.equal(skipped.players[0].spice, 17);
assert.equal(skipped.players[0].reserves, 15);
assert.equal(skipped.players[0].moved, 0);
assert.equal(skipped.players[0].shipped, true);
await restored(f, skipped);
const move: Action = {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
};
const before = row(f);
for (const action of [move, skip])
await assert.rejects(
f.restart().act(f.code, f.auths[0], skipped.version, action, clock),
);
assert.deepEqual(row(f), before);
const extra = await act(f, 0, { type: 'card', card: f.hajr });
await restored(f, extra);
f.writes.length = 0;
await compete(f, extra.version, move);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].forces['hagga_basin:12'], 5);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].reserves, 15);
assert.equal(done.discard.filter((c) => c.id === f.hajr).length, 1);
await restored(f, done);
const completed = row(f);
await assert.rejects(
f.restart().act(
f.code,
f.auths[0],
done.version,
{
type: 'move',
from: 'hagga_basin:12',
territory: 'arsunt',
sector: 12,
amount: 5,
},
clock,
),
);
assert.deepEqual(row(f), completed);
} finally {
f.sqlite.close();
}
});
+251
View File
@@ -0,0 +1,251 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createNexusGuildCunning,
validateNexusGuildCunning,
nexusGuildCunningMoves,
type NexusGuildCunningContext,
type NexusGuildCunningReceipt,
} from '../game/nexus-guild-cunning';
const context = {
turn: 3,
phase: 5,
active: 'g',
players: [
{ id: 'a', faction: 'atreides' as const },
{ id: 'g', faction: 'guild' as const },
{ id: 'f', faction: 'fremen' as const },
],
};
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const make = (moved = 0, hajrUsed = false, shipped = false) =>
createNexusGuildCunning(context, 'g', { shipped, moved, hajrUsed });
void test('Guild Cunning records the ended original combined turn whether its shipment and movement were used or declined', () => {
for (const shipped of [false, true])
for (const hajrUsed of [false, true])
for (let moved = 0; moved <= (hajrUsed ? 2 : 1); moved++) {
const before = { shipped, moved, hajrUsed },
original = clone(before);
const receipt = createNexusGuildCunning(context, 'g', before);
assert.equal(
receipt.event,
JSON.stringify(['nexusGuildCunning', 3, 'g']),
);
assert.deepEqual(receipt.before, original);
assert.deepEqual(before, original);
assert.notEqual(receipt.before, before);
assert.equal(receipt.mode, 'cunning');
validateNexusGuildCunning(context, clone(receipt));
}
});
void test('second shipment never supplies an ordinary move, including a declined first movement', () => {
for (const moved of [0, 1]) {
const receipt = make(moved, false, true);
assert.equal(nexusGuildCunningMoves(receipt, false), 0);
assert.throws(() => nexusGuildCunningMoves(receipt, false, 1));
assert.equal(
nexusGuildCunningMoves(receipt, true),
1,
'later Hajr grants exactly its extra slot',
);
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
}
});
void test('previously played Hajr keeps an unused extra slot after decline, but an already consumed Hajr grants no third move', () => {
for (const moved of [0, 1]) {
const receipt = make(moved, true);
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
assert.throws(() => nexusGuildCunningMoves(receipt, false));
}
const consumed = make(2, true);
assert.equal(nexusGuildCunningMoves(consumed, true), 0);
assert.throws(() => nexusGuildCunningMoves(consumed, true, 1));
for (const count of [-1, 0.5, 2, NaN, Infinity])
assert.throws(() => nexusGuildCunningMoves(make(1, true), true, count));
});
void test('only native unallied Guild can create the receipt at its own phase-five boundary', () => {
const before = { shipped: false, moved: 0, hajrUsed: false };
for (const phase of [0, 4, 6, 8])
assert.throws(() =>
createNexusGuildCunning({ ...context, phase }, 'g', before),
);
assert.throws(() =>
createNexusGuildCunning({ ...context, active: 'a' }, 'g', before),
);
assert.throws(() =>
createNexusGuildCunning({ ...context, active: 'a' }, 'a', before),
);
assert.throws(() =>
createNexusGuildCunning(
{
...context,
players: context.players.map((p) => ({
...p,
ally: p.id === 'g' ? 'a' : null,
})),
},
'g',
before,
),
);
for (const bad of [
{ ...before, moved: 2 },
{ ...before, moved: 3, hajrUsed: true },
{ ...before, moved: -1 },
{ ...before, moved: 0.5 },
{ ...before, moved: NaN },
{ ...before, shipped: 0 },
{ ...before, hajrUsed: 1 },
{ ...before, extra: 1 },
])
assert.throws(() =>
createNexusGuildCunning(context, 'g', bad as typeof before),
);
});
void test('completed original receipts survive later alliances, turns and resource changes without reading private fields', () => {
const receipt = make(1, true, true);
const trap = () => {
throw new Error('private field was read');
};
const later: NexusGuildCunningContext = {
turn: 7,
players: context.players.map((p) => ({
...p,
get ally() {
return trap();
},
get hand() {
return trap();
},
get spice() {
return trap();
},
get shipped() {
return trap();
},
get moved() {
return trap();
},
get reserves() {
return trap();
},
})),
};
validateNexusGuildCunning(later, receipt);
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
assert.throws(() =>
validateNexusGuildCunning({ ...later, turn: 2 }, receipt),
);
assert.throws(() =>
validateNexusGuildCunning(
{ ...later, players: later.players.slice(0, 2) },
receipt,
),
);
});
void test('creation reads only public roster, owner alliance, timing and the supplied original allowance', () => {
const trap = () => {
throw new Error('private field was read');
};
const publicContext = {
...context,
players: context.players.map((p) =>
Object.freeze({
...p,
ally: null,
get hand() {
return trap();
},
get spice() {
return trap();
},
get forces() {
return trap();
},
}),
),
};
Object.freeze(publicContext.players);
Object.freeze(publicContext);
const before = Object.freeze({ shipped: true, moved: 1, hajrUsed: true });
const receipt = createNexusGuildCunning(publicContext, 'g', before);
assert.deepEqual(Object.keys(receipt).sort(), [
'before',
'event',
'mode',
'owner',
'phase',
'roster',
'signature',
'turn',
'version',
]);
assert.equal(JSON.stringify(receipt).includes('hand'), false);
assert.equal(nexusGuildCunningMoves(Object.freeze(receipt), true, 1), 0);
});
void test('changed original facts, foreign fields, deleted proofs and malformed roster identities reject immutably', () => {
const receipt = make(1, true, true);
const mutations: ((r: NexusGuildCunningReceipt) => void)[] = [
(r) => {
r.before.moved = 0;
},
(r) => {
r.before.shipped = false;
},
(r) => {
r.before.hajrUsed = false;
},
(r) => {
r.event = 'stale';
},
(r) => {
r.owner = 'a';
},
(r) => {
r.turn = 2;
},
(r) => {
r.signature = '';
},
(r) => {
r.roster.reverse();
},
(r) => {
r.roster[0].id = 'changed';
},
(r) => {
delete (r as Partial<NexusGuildCunningReceipt>).before;
},
(r) => {
Object.assign(r, { stage: 'secondShipment' });
},
];
for (const mutate of mutations) {
const bad = clone(receipt);
mutate(bad);
const original = clone(bad);
assert.throws(() => validateNexusGuildCunning(context, bad));
assert.deepEqual(bad, original);
}
for (const players of [
[...context.players, context.players[0]],
context.players.map((p) => ({ ...p, id: 'same' })),
context.players.map((p) => ({ ...p, faction: 'guild' as const })),
context.players.map((p) => ({
...p,
id: p.id === 'a' ? '__proto__' : p.id,
})),
])
assert.throws(() =>
validateNexusGuildCunning({ ...context, players }, receipt),
);
});