Implement Guild Nexus Secret Ally shipping routes and recovery

This commit is contained in:
Quintine committed 2026-09-10 13:56:36 +10:00
1 parent 90bf699627
commit 06d0a66fc5
25 files changed
+2095 -52

No files matched your search

+10 -1
View File
@@ -80,7 +80,12 @@ export function nexusGuildCunningRequest(
nexus: viewGame(g, f.owner).nexusGuildCunning!.offer!.event,
};
}
export const holdGuildCunningCard = holdNexusRicheseCard;
export function holdGuildCunningCard(g: Game, owner: string, kind: string) {
const held = g.players
.find((p) => p.id === owner)!
.hand.find((c) => c.kind === kind || c.effect === kind);
return held ?? holdNexusRicheseCard(g, owner, kind);
}
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
@@ -106,6 +111,10 @@ export function nexusGuildCunningAllianceFixture(
seatIds?: [string, string, string],
): NexusGuildCunningFixture {
const f = nexusGuildCunningFixture({ seatIds, opponentFaction: 'moritani' });
// Reserve this actual card before the intervening real auction can distribute
// all copies. The later scenario reuses this owned identity rather than
// assuming a needed card remains in the randomly shuffled draw pile.
holdGuildCunningCard(f.g, f.owner, 'hajr');
let g = f.g;
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
nexusRicheseFixture,
nexusRicheseInventory,
holdNexusRicheseCard,
type NexusRicheseFixtureOptions,
} from './fixture-nexus-richese';
import {
nexusReload,
nexusAllow,
orderNexusSpice,
} from './fixture-nexus-cards';
import { botActions } from '../game/bots';
import { nexusTraitorInventory } from './fixture-nexus-traitors';
import { homeworldGameIntegrity } from '../game/homeworld-game';
export type NexusGuildSecretAllyFixtureOptions = Omit<
NexusRicheseFixtureOptions,
'guild' | 'ownerFaction' | 'opponentFaction'
> & {
ownerFaction?: Exclude<NexusRicheseFixtureOptions['ownerFaction'], 'guild'>;
opponentFaction?: Exclude<
NexusRicheseFixtureOptions['opponentFaction'],
'guild'
>;
};
/** Genuine final native setup without Guild. Exchange physical Nexus identities
* before any declaration; no effect receipt or shipment allowance is staged. */
export function nexusGuildSecretAllyFixture(
options: NexusGuildSecretAllyFixtureOptions = {},
) {
const f = nexusRicheseFixture({
...options,
opponentFaction:
options.opponentFaction ??
(options.ownerFaction === 'harkonnen' ? 'beneGesserit' : 'harkonnen'),
});
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';
assert.equal(
f.g.players.some((p) => p.faction === 'guild'),
false,
);
nexusRicheseInventory(f.g);
return f;
}
export function nexusGuildSecretAllyRequest(
g: Game,
owner: string,
action: Action = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
},
): Action {
return { ...action, nexus: viewGame(g, owner).nexusGuildSecretAlly!.event };
}
export function nexusGuildSecretAllyInventory(g: Game) {
const census = structuredClone(g);
for (const p of census.players)
p.reserves += Object.values(g.homeworlds?.custody?.visitors ?? {}).reduce(
(sum, visitors) =>
sum + (visitors[p.id]?.normal ?? 0) + (visitors[p.id]?.elite ?? 0),
0,
);
nexusTraitorInventory(census);
if (g.homeworlds) homeworldGameIntegrity(g);
}
export const nexusGuildSecretAllyReload = nexusReload;
export const holdGuildSecretAllyCard = holdNexusRicheseCard;
export function nexusGuildSecretAllianceFixture(
seatIds?: [string, string, string],
) {
const f = nexusGuildSecretAllyFixture({
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: 'arrakeen',
}),
);
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,
`Secret Ally alliance progression 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');
nexusGuildSecretAllyInventory(g);
return { ...f, g };
}
+2 -1
View File
@@ -47,7 +47,8 @@ async function fixture(
advanced: selfStop,
homeworlds,
});
if (alliance) f.karama = holdGuildCunningCard(f.g, f.target, 'karama').id;
// The genuine alliance scenario passes whichever actual responders remain;
// it does not need to acquire another Karama after its intervening auction.
const ownKarama = selfStop
? holdGuildCunningCard(f.g, f.owner, 'karama').id
: undefined;
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
import { fremenReserveEntry } from '../game/bot-mobility';
import {
nexusGuildSecretAllyFixture,
nexusGuildSecretAllyInventory,
} from './fixture-nexus-guild-secret-ally';
import { nexusAllow } from './fixture-nexus-cards';
void test('all profiles consider Guild-priced groups before ordinary affordability and retain legal normal fallback', () => {
for (const advanced of [false, true])
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusGuildSecretAllyFixture({ advanced, spice: 2 }),
v = viewGame(f.g, f.owner);
v.players[0].bot = profile;
const before = structuredClone(v);
const actions = botActions(v),
action = actions.find(
(a) =>
a.type === 'ship' &&
a.nexus === v.nexusGuildSecretAlly!.event &&
Number(a.amount) > 2,
);
assert.ok(action, profile);
assert.ok(botHomeworldShipmentPaymentAllowed(v, action));
const next = nexusAllow(applyAction(f.g, f.owner, action));
assert.equal(next.players[0].spice, 0);
assert.equal(next.players[0].shipped, true);
assert.equal(next.players[0].moved, 0);
nexusGuildSecretAllyInventory(next);
assert.deepEqual(v, before);
assert.ok(
actions.some((a) => a.type === 'ship' && a.nexus === undefined),
);
assert.equal(
botHomeworldShipmentPaymentAllowed(v, { ...action, nexus: 'stale' }),
false,
);
}
});
void test('Fremen policies keep free reinforcement and only spend Guild Nexus on a paid route beyond native range', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'fremen',
advanced: true,
spice: 20,
}),
v = viewGame(f.g, f.owner);
v.players[0].bot = profile;
const actions = botActions(v),
free = actions.find((a) => a.type === 'ship' && a.nexus === undefined);
assert.ok(free, profile);
assert.ok(fremenReserveEntry(String(free.territory)));
const paid = actions.find(
(a) => a.type === 'ship' && a.nexus === v.nexusGuildSecretAlly!.event,
);
assert.ok(paid, profile);
assert.equal(fremenReserveEntry(String(paid.territory)), false);
assert.notEqual(paid.sector, v.storm);
assert.ok(
actions.every(
(a) => !(a.type === 'guildShip' && a.from === 'reserves' && a.nexus),
),
);
const next = nexusAllow(applyAction(f.g, f.owner, paid));
nexusGuildSecretAllyInventory(next);
const zero = nexusGuildSecretAllyFixture({
ownerFaction: 'fremen',
spice: 0,
}),
zv = viewGame(zero.g, zero.owner);
zv.players[0].bot = profile;
assert.ok(botActions(zv).every((a) => a.nexus === undefined));
}
});
void test('all profiles can select a physically typed cross-shipment with the explicit source and never borrow flexible Guild timing', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor',
advanced: true,
}),
p = f.g.players[0];
// Conserved pre-declaration position, independent of the Nexus receipt.
p.forces['the_great_flat:15'] = 5;
p.reserves -= 5;
p.elites!.forces['the_great_flat:15'] = 2;
p.elites!.reserves -= 2;
const v = viewGame(f.g, f.owner);
v.players[0].bot = profile;
const action = botActions(v).find(
(a) =>
a.type === 'guildShip' && a.nexus === v.nexusGuildSecretAlly!.event,
);
assert.ok(action, profile);
const next = nexusAllow(applyAction(f.g, f.owner, action));
nexusGuildSecretAllyInventory(next);
assert.equal(next.players[0].moved, 0);
assert.equal(next.players[0].shipped, true);
const other = viewGame(f.g, f.target);
other.players.find((p) => p.id === f.target)!.bot = profile;
assert.ok(botActions(other).every((a) => a.nexus === undefined));
}
});
@@ -0,0 +1,340 @@
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 { MOBILE_STRONGHOLD, gameTerritories } from '../game/board';
import { HomeworldShipment } from '../components/homeworld-shipment';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { guildTransportQuote } from '../game/transport-quote';
import { withNativeShipmentSources } from '../game/homeworld-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import {
nexusGuildSecretAllyAction,
nexusGuildSecretAllyCanAct,
nexusGuildSecretAllyQuote,
} from '../game/nexus-guild-secret-ally-options';
import {
nexusGuildSecretAllyFixture,
nexusGuildSecretAllyInventory,
nexusGuildSecretAllyReload,
} from './fixture-nexus-guild-secret-ally';
import { nexusAllow } from './fixture-nexus-cards';
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() {},
}),
);
void test('Basic and Advanced private shipment panel defaults to ordinary price; explicit Guild source spends once without another action', () => {
for (const advanced of [false, true]) {
const f = nexusGuildSecretAllyFixture({ advanced, spice: 3 });
const v = viewGame(f.g, f.owner),
before = structuredClone(v);
const checkbox = html(v).match(
/<input[^>]*type="checkbox"[^>]*>[\s\S]{0,150}Use Guild Nexus Secret Ally/,
)?.[0];
assert.ok(checkbox);
assert.doesNotMatch(checkbox, /checked=""/);
assert.match(
html(v, true).match(
/<input[^>]*type="checkbox"[^>]*>[\s\S]{0,150}Use Guild Nexus Secret Ally/,
)![0],
/disabled=""/,
);
assert.match(html(v), /normal movement remains available/);
assert.equal(nexusGuildSecretAllyQuote(v, 'arrakeen', 5)!.cost, 3);
assert.equal(nexusGuildSecretAllyQuote(v, 'arrakeen', 4)!.cost, 2);
const action = nexusGuildSecretAllyAction(
v,
v.nexusGuildSecretAlly!.event,
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 5 },
)!;
assert.ok(action);
const next = nexusAllow(applyAction(f.g, f.owner, action));
const me = next.players.find((p) => p.id === f.owner)!;
assert.equal(me.spice, 0);
assert.equal(me.reserves, 15);
assert.equal(me.shipped, true);
assert.equal(me.moved, 0);
assert.equal(viewGame(next, f.owner).nexusCards!.card, null);
assert.equal(
nexusGuildSecretAllyAction(
viewGame(next, f.owner),
String(action.nexus),
action,
),
null,
);
nexusGuildSecretAllyInventory(next);
assert.deepEqual(v, before);
}
});
void test('Fremen free native reinforcement remains distinct from explicit paid beyond-range shipping and its storm exclusion', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'fremen',
advanced: true,
});
const v = viewGame(f.g, f.owner),
event = v.nexusGuildSecretAlly!.event;
assert.match(html(v), /Leave this unchecked for free Fremen reinforcement/);
const free = nexusAllow(
applyAction(nexusGuildSecretAllyReload(f.g), f.owner, {
type: 'ship',
territory: 'the_great_flat',
sector: 15,
amount: 5,
}),
);
assert.equal(free.players[0].spice, 20);
assert.equal(viewGame(free, f.owner).nexusCards!.card, 'guild');
const paid = nexusGuildSecretAllyAction(v, event, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
})!;
const next = nexusAllow(applyAction(f.g, f.owner, paid));
assert.equal(next.players[0].spice, 17);
assert.equal(next.players[0].forces['arrakeen:10'], 5);
const storm = nexusGuildSecretAllyReload(f.g);
storm.storm = 10;
const before = structuredClone(storm);
assert.throws(() => applyAction(storm, f.owner, paid), /storm/i);
assert.deepEqual(storm, before);
nexusGuildSecretAllyInventory(free);
nexusGuildSecretAllyInventory(next);
});
void test('borrowed cross and return quotes preserve typed physical groups; no native Guild identity or extra shipment is created', () => {
for (const destination of ['carthag', 'reserves']) {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor',
advanced: true,
});
// Conserved pre-declaration Arrakis staging; all typed counters retain their real faction.
const p = f.g.players[0];
p.reserves -= 5;
p.forces['hagga_basin:12'] = 5;
p.elites!.reserves -= 2;
p.elites!.forces['hagga_basin:12'] = 2;
const v = viewGame(f.g, f.owner),
action = nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
type: 'guildShip',
from: 'hagga_basin:12',
territory: destination,
sector: destination === 'reserves' ? 0 : 11,
amount: 5,
elite: 2,
})!;
assert.ok(action);
const quote = guildTransportQuote(v, action);
assert.deepEqual(quote.unavailableReasons, []);
assert.equal(quote.quote!.cost, 3);
const next = nexusAllow(applyAction(f.g, f.owner, action)),
me = next.players[0];
assert.equal(me.faction, 'emperor');
assert.equal(me.spice, 17);
assert.equal(me.shipped, true);
assert.equal(me.moved, 0);
assert.equal(me.reserves, destination === 'reserves' ? 20 : 15);
assert.equal(me.elites!.reserves, destination === 'reserves' ? 5 : 3);
assert.match(
guildTransportQuote(v, {
...action,
nexus: 'stale',
}).unavailableReasons.join(' '),
/current Guild/,
);
assert.match(
guildTransportQuote(v, {
...action,
territory: 'hagga_basin',
sector: 12,
}).unavailableReasons.join(' '),
/another territory/,
);
nexusGuildSecretAllyInventory(next);
}
});
void test('Homeworld selection discounts independently legal typed custody; own world and Arrakis return remain unavailable', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor',
advanced: true,
homeworlds: true,
spice: 3,
});
const v = viewGame(f.g, f.owner),
worlds = v.homeworlds!.worlds!;
const native = worlds.filter((w) => w.native === f.owner),
target = worlds.find((w) => w.native === f.target)!;
const selection = Object.fromEntries(
native.map((w) => [
w.id,
w.secondary ? { normal: 0, elite: 2 } : { normal: 3, elite: 0 },
]),
);
const plain = homeworldShipmentChoice(v, target.id, selection);
assert.equal(plain.cost, 5);
assert.equal(plain.action, null);
const discounted = homeworldShipmentChoice(
v,
target.id,
selection,
0,
v.nexusGuildSecretAlly!.event,
);
assert.equal(discounted.cost, 3);
assert.equal(discounted.amount, 5);
assert.equal(discounted.elite, 2);
assert.ok(discounted.action);
assert.match(
renderToStaticMarkup(
createElement(HomeworldShipment, { game: v, act() {}, busy: false }),
),
/Use Guild Nexus Secret Ally for Homeworld shipment/,
);
const next = nexusAllow(applyAction(f.g, f.owner, discounted.action));
assert.equal(next.players[0].spice, 0);
assert.equal(next.players[0].reserves, 15);
homeworldGameIntegrity(next);
assert.equal(
homeworldShipmentChoice(
v,
native[0].id,
selection,
0,
v.nexusGuildSecretAlly!.event,
).action,
null,
);
assert.equal(nexusGuildSecretAllyQuote(v, 'reserves', 5), null);
assert.equal(
nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
type: 'guildHomeworldShip',
amount: 5,
}),
null,
);
const reserve = withNativeShipmentSources(
v,
nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
elite: 2,
})!,
)!;
assert.ok(reserve.homeworldSources);
nexusGuildSecretAllyInventory(nexusAllow(applyAction(f.g, f.owner, reserve)));
});
void test('current source fences reject stale, foreign, interrupted and concealed routes without inspecting another private hand', () => {
const f = nexusGuildSecretAllyFixture(),
v = viewGame(f.g, f.owner),
event = v.nexusGuildSecretAlly!.event;
assert.equal(nexusGuildSecretAllyCanAct(v), true);
assert.equal(
nexusGuildSecretAllyAction(v, event, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
noField: 'hidden',
}),
null,
);
assert.equal(
nexusGuildSecretAllyAction(v, event, {
type: 'guildShip',
from: 'reserves',
territory: 'arrakeen',
sector: 10,
amount: 1,
}),
null,
);
assert.equal(
nexusGuildSecretAllyAction(v, 'stale', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
}),
null,
);
for (const id of [f.target, f.observer]) {
const other = viewGame(f.g, id);
assert.equal(other.nexusGuildSecretAlly, null);
Object.defineProperty(
other.players.find((p) => p.id === f.owner)!,
'hand',
{
get() {
throw new Error('other hand read');
},
},
);
assert.equal(nexusGuildSecretAllyCanAct(other), false);
assert.doesNotMatch(html(other), /Use Guild Nexus Secret Ally/);
}
v.nexusGuildSecretAlly!.blocked = 'Finish response';
assert.equal(nexusGuildSecretAllyCanAct(v), false);
});
void test('native Ixian HMS reserve access uses the current board while another holder gains no mobile-stronghold permission', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'ixians',
advanced: true,
}),
v = viewGame(f.g, f.owner);
const hms = gameTerritories(v).find((t) => t.id === MOBILE_STRONGHOLD)!;
const action = nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
type: 'ship',
territory: hms.id,
sector: hms.sectors[0],
amount: 5,
elite: 2,
})!;
assert.ok(action);
nexusGuildSecretAllyInventory(nexusAllow(applyAction(f.g, f.owner, action)));
const other = nexusGuildSecretAllyFixture(),
ov = viewGame(other.g, other.owner);
assert.equal(nexusGuildSecretAllyQuote(ov, MOBILE_STRONGHOLD, 1), null);
});
@@ -0,0 +1,306 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import {
nexusGuildSecretAllyFixture,
nexusGuildSecretAllyRequest,
nexusGuildSecretAllyInventory,
nexusGuildSecretAllyReload,
holdGuildSecretAllyCard,
nexusGuildSecretAllianceFixture,
} from './fixture-nexus-guild-secret-ally';
import { nexusAllow } from './fixture-nexus-cards';
function rejected(g: Game, owner: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
function stable(g: Game) {
nexusGuildSecretAllyInventory(g);
for (const p of g.players)
assert.deepEqual(
viewGame(nexusGuildSecretAllyReload(g), p.id),
viewGame(g, p.id),
);
assert.deepEqual(
normalizeAutomaticGame(nexusGuildSecretAllyReload(g)),
nexusGuildSecretAllyReload(g),
);
}
void test('Guild Secret Ally charges the native tariff once without a Guild response and retains ordinary faction movement', () => {
for (const advanced of [false, true])
for (const desert of [false, true]) {
const f = nexusGuildSecretAllyFixture({ advanced });
const action = nexusGuildSecretAllyRequest(f.g, f.owner, {
type: 'ship',
territory: desert ? 'hagga_basin' : 'arrakeen',
sector: desert ? 12 : 10,
amount: 5,
});
let g = applyAction(f.g, f.owner, action);
assert.equal(g.players[0].spice, desert ? 15 : 17);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].shipped, true);
assert.equal(g.players[0].moved, 0);
assert.equal(g.response, null);
assert.equal(g.decision, null);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
1,
);
rejected(g, f.owner, action);
g = applyAction(nexusGuildSecretAllyReload(g), f.owner, {
type: 'move',
from: desert ? 'hagga_basin:12' : 'arrakeen:10',
territory: desert ? 'arsunt' : 'hagga_basin',
sector: 12,
amount: 5,
});
assert.equal(g.players[0].moved, 1);
stable(g);
}
});
void test('Guild Secret Ally cross and return preserve actual Emperor normal and Sardaukar counters', () => {
for (const returns of [false, true]) {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor',
advanced: true,
});
const p = f.g.players[0];
p.reserves -= 5;
p.elites!.reserves -= 2;
p.forces['arrakeen:10'] = 5;
p.elites!.forces['arrakeen:10'] = 2;
stable(f.g);
const action = nexusGuildSecretAllyRequest(f.g, f.owner, {
type: 'guildShip',
from: 'arrakeen:10',
territory: returns ? 'reserves' : 'carthag',
sector: returns ? 0 : 11,
amount: 5,
elite: 2,
});
const g = applyAction(f.g, f.owner, action);
assert.equal(g.players[0].spice, 17);
assert.equal(g.players[0].reserves, returns ? 20 : 15);
assert.equal(g.players[0].elites!.reserves, returns ? 5 : 3);
assert.equal(g.players[0].forces['arrakeen:10'], undefined);
if (!returns) {
assert.equal(g.players[0].forces['carthag:11'], 5);
assert.equal(g.players[0].elites!.forces['carthag:11'], 2);
}
rejected(g, f.owner, action);
stable(g);
}
});
void test('Fremen paid Guild reserve shipment has no free-reinforcement range or storm privilege', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'fremen',
advanced: true,
homeworlds: true,
});
rejected(f.g, f.owner, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
});
const action = nexusGuildSecretAllyRequest(f.g, f.owner, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
elite: 2,
});
const storm = structuredClone(f.g);
storm.storm = 10;
rejected(storm, f.owner, action);
rejected(
f.g,
f.owner,
nexusGuildSecretAllyRequest(f.g, f.owner, {
type: 'guildShip',
from: 'reserves',
territory: 'arrakeen',
sector: 10,
amount: 5,
}),
);
const g = applyAction(f.g, f.owner, action);
assert.equal(g.players[0].spice, 17);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].elites!.reserves, 1);
assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2);
stable(g);
const ordinary = nexusGuildSecretAllyFixture({
ownerFaction: 'fremen',
advanced: true,
});
const free = applyAction(ordinary.g, ordinary.owner, {
type: 'ship',
territory: 'the_great_flat',
sector: 15,
amount: 5,
});
assert.equal(free.players[0].spice, 20);
assert.equal(free.nexusCards!.cards!.hands[ordinary.owner], 'guild');
stable(free);
});
void test('Guild Secret Ally reserve and interplanetary shipments withdraw exact native Homeworld types', () => {
for (const interplanetary of [false, true]) {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor',
advanced: true,
homeworlds: true,
});
const sources = {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
};
const action = nexusGuildSecretAllyRequest(
f.g,
f.owner,
interplanetary
? {
type: 'homeworldShip',
event: viewGame(f.g, f.owner).homeworldShipment!.event,
destination: 'homeworld:harkonnen',
sources,
}
: {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
elite: 2,
homeworldSources: sources,
},
);
const g = applyAction(f.g, f.owner, action);
assert.equal(g.players[0].spice, 17);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].elites!.reserves, 3);
if (interplanetary)
assert.deepEqual(
g.homeworlds!.custody!.visitors['homeworld:harkonnen'][f.owner],
{ normal: 3, elite: 2 },
);
else assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2);
stable(g);
}
});
void test('Guild Secret Ally rejects foreign, stale, duplicated and concealed requests without revealing another hand', () => {
const f = nexusGuildSecretAllyFixture();
const request = nexusGuildSecretAllyRequest(f.g, f.owner);
for (const id of [f.target, f.observer]) {
assert.equal(viewGame(f.g, id).nexusGuildSecretAlly, null);
rejected(f.g, id, request);
}
rejected(f.g, f.owner, { ...request, nexus: 'stale' });
rejected(f.g, f.owner, { ...request, noField: 'hidden' });
const g = applyAction(f.g, f.owner, request);
for (const p of g.players)
assert.equal(
Object.hasOwn(viewGame(g, p.id), 'nexusGuildSecretHistory'),
false,
);
stable(g);
});
void test('a binding Truthtrance answer can rely on privately held Guild pricing and then fulfill the original shipment', () => {
const f = nexusGuildSecretAllyFixture({ spice: 3 });
const card = holdGuildSecretAllyCard(f.g, f.target, 'truthtrance');
let g = applyAction(f.g, f.target, { type: 'card', card: card.id });
while (g.truthtrance?.stage === 'priority')
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: 'arrakeen',
minimum: 5,
},
});
assert.deepEqual(viewGame(g, f.owner).truthShipmentAnswers, ['yes', 'no']);
g = applyAction(g, f.owner, { type: 'truthAnswer', answer: 'yes' });
const witness = viewGame(g, f.owner).shipmentCompletion!;
assert.ok(
witness.actions.some(
(a) =>
a.type === 'ship' &&
a.nexus === viewGame(g, f.owner).nexusGuildSecretAlly!.event,
),
);
for (const id of [f.target, f.observer])
assert.equal(viewGame(g, id).shipmentCompletion, null);
rejected(g, f.owner, { type: 'endMovement' });
rejected(
g,
f.owner,
nexusGuildSecretAllyRequest(g, f.owner, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 4,
}),
);
g = applyAction(
nexusGuildSecretAllyReload(g),
f.owner,
nexusGuildSecretAllyRequest(g, f.owner),
);
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.shipmentPromises![0].fulfilled, true);
stable(g);
});
void test('a genuine Moritani arrival alliance preserves completed Guild Secret Ally history and subsequent native movement', () => {
const f = nexusGuildSecretAllianceFixture();
let g = applyAction(f.g, f.owner, nexusGuildSecretAllyRequest(f.g, f.owner));
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
const before = structuredClone(g.players[0]),
history = structuredClone(g.nexusGuildSecretHistory);
g = nexusAllow(
applyAction(g, f.target, { type: 'decision', alliance: true }),
);
stable(g);
g = applyAction(nexusGuildSecretAllyReload(g), f.owner, {
type: 'decision',
accept: true,
});
assert.equal(g.players[0].ally, f.target);
assert.equal(g.players[1].ally, f.owner);
assert.deepEqual(g.nexusGuildSecretHistory, history);
assert.equal(g.players[0].spice, before.spice);
assert.equal(g.players[0].reserves, before.reserves);
stable(g);
g = applyAction(g, f.owner, {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});
assert.equal(g.players[0].forces['hagga_basin:12'], 5);
g = applyAction(g, f.owner, { type: 'endMovement' });
assert.notEqual(g.active, f.owner);
stable(g);
});
@@ -0,0 +1,339 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unitStore } from './fixture-nexus-room-store';
import {
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import {
nexusGuildSecretAllyFixture,
nexusGuildSecretAllyInventory,
nexusGuildSecretAllyRequest,
nexusGuildSecretAllianceFixture,
} from './fixture-nexus-guild-secret-ally';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(homeworlds = false, alliance = false) {
const store = unitStore();
const made = await store.rooms.createRoom(
'Guild Secret Ally SQL',
'emperor',
true,
[],
);
const 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)),
);
const ids = auths.map((a) => a.playerId) as [string, string, string];
const f = alliance
? nexusGuildSecretAllianceFixture(ids)
: nexusGuildSecretAllyFixture({
seatIds: ids,
ownerFaction: 'emperor',
advanced: true,
homeworlds,
});
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 };
}
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 restored(f: Fixture, g: Game) {
const before = row(f),
rooms = f.restart();
for (const [i, token] of f.tokens.entries()) {
const auth = await rooms.authenticate(f.code, token),
v = await rooms.readSeatView(f.code, auth);
assert.deepEqual(v, viewGame(g, f.ids[i]));
assert.equal(Object.hasOwn(v, 'nexusGuildSecretHistory'), false);
if (i !== 0) assert.equal(v.nexusGuildSecretAlly, null);
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusGuildSecretAllyInventory(g);
}
async function act(f: Fixture, action: Action, seat = 0) {
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);
}
void test('SQLite saves the genuine post-shipment Moritani alliance reply and cannot replay its shipment when accepting', async () => {
const f = await fixture(false, true);
try {
let g = await act(f, nexusGuildSecretAllyRequest(f.g, f.owner));
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
const spice = g.players[0].spice,
reserves = g.players[0].reserves;
g = await act(f, { type: 'decision', alliance: true }, 1);
while (g.response)
g = await act(
f,
{ type: 'passResponse' },
f.ids.indexOf(
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
),
);
await restored(f, g);
const accepted = await act(f, { 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);
assert.equal(accepted.nexusGuildSecretHistory!.length, 1);
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);
const moved = await act(f, {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});
assert.equal(moved.players[0].spice, spice);
assert.equal(moved.players[0].reserves, reserves);
assert.equal(moved.players[0].forces['hagga_basin:12'], 5);
await restored(f, moved);
const ended = await act(f, { type: 'endMovement' });
assert.notEqual(ended.active, f.owner);
await restored(f, ended);
} finally {
f.sqlite.close();
}
});
void test('SQLite racing Guild Secret Ally commits one paid typed shipment and lost acknowledgment cannot repeat it', async () => {
for (const homeworlds of [false, true]) {
const f = await fixture(homeworlds);
try {
const other = await f.rooms.createRoom(
'Unrelated SQL room',
'atreides',
false,
[],
);
const otherRow = () =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(other.view.code);
const unrelated = otherRow();
const action = nexusGuildSecretAllyRequest(
f.g,
f.owner,
homeworlds
? {
type: 'homeworldShip',
event: viewGame(f.g, f.owner).homeworldShipment!.event,
destination: 'homeworld:harkonnen',
sources: {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
},
}
: {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
elite: 2,
},
);
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], f.g.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],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].reserves, 15);
assert.equal(done.players[0].elites!.reserves, 3);
assert.equal(done.nexusGuildSecretHistory!.length, 1);
assert.equal(
done.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
1,
);
if (homeworlds)
assert.deepEqual(
done.homeworlds!.custody!.visitors['homeworld:harkonnen'][f.owner],
{ normal: 3, elite: 2 },
);
else assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
await restored(f, done);
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], done.version, action, clock),
);
assert.deepEqual(row(f), before);
assert.deepEqual(otherRow(), unrelated);
} finally {
f.sqlite.close();
}
}
});
void test('SQLite saved Guild Secret Ally completion preserves native movement and does not reread held Nexus identity', async () => {
const f = await fixture();
try {
const shipped = await act(
f,
nexusGuildSecretAllyRequest(f.g, f.owner, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
elite: 2,
}),
);
await restored(f, shipped);
const moved = await act(f, {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
elite: 2,
});
assert.equal(moved.players[0].spice, 17);
assert.equal(moved.players[0].reserves, 15);
assert.equal(moved.players[0].elites!.forces['hagga_basin:12'], 2);
assert.equal(moved.players[0].moved, 1);
await restored(f, moved);
const ended = await act(f, { type: 'endMovement' });
assert.notEqual(ended.active, f.owner);
await restored(f, ended);
} finally {
f.sqlite.close();
}
});
void test('SQLite damaged Guild Secret Ally terminal route proofs reject all projections and actions without writes', async () => {
const f = await fixture(true);
try {
const done = await act(
f,
nexusGuildSecretAllyRequest(f.g, f.owner, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
elite: 2,
homeworldSources: {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
},
}),
);
const editFrame = (
g: Game,
edit: (frame: Record<string, unknown>) => void,
) => {
const record = g.nexusGuildSecretHistory![0];
const frame = JSON.parse(record.frame) as Record<string, unknown>;
edit(frame);
record.frame = JSON.stringify(frame);
};
for (const edit of [
(g: Game) => {
delete g.nexusGuildSecretHistory;
},
(g: Game) => {
delete g.nexusGuildSecretLast;
},
(g: Game) => {
g.nexusGuildSecretHistory![0].signature = 'changed';
},
(g: Game) =>
editFrame(g, (frame) => {
delete frame.guildSecretEvent;
}),
(g: Game) =>
editFrame(g, (frame) => {
frame.amount = 4;
}),
(g: Game) =>
editFrame(g, (frame) => {
frame.cost = 1;
}),
(g: Game) =>
editFrame(g, (frame) => {
frame.elite = 1;
}),
(g: Game) =>
editFrame(g, (frame) => {
frame.homeworldSources = {
'homeworld:emperor': { normal: 5, elite: 0 },
};
}),
]) {
const bad = structuredClone(done);
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: 'endMovement' },
clock,
),
);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
});
+237
View File
@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FACTIONS } from '../game/catalog';
import {
createNexusGuildSecretAlly,
validateNexusGuildSecretAlly,
quoteNexusGuildSecretShipment,
type NexusGuildSecretAllyContext,
type NexusGuildSecretAllyReceipt,
} from '../game/nexus-guild-secret-ally';
const context: NexusGuildSecretAllyContext = {
turn: 4,
players: [
{ id: 'a', faction: 'atreides' },
{ id: 'e', faction: 'emperor' },
{ id: 'f', faction: 'fremen' },
],
};
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
void test('Guild Secret Ally quotes all physical groups at Guild rates with one upward rounding and no force-count substitution', () => {
for (const type of [
'stronghold',
'sand',
'rock',
'polar',
'reserves',
'homeworld',
])
for (let amount = 1; amount <= 20; amount++)
assert.deepEqual(quoteNexusGuildSecretShipment(type, amount), {
physicalAmount: amount,
cost: ['stronghold', 'reserves', 'homeworld'].includes(type)
? Math.ceil(amount / 2)
: amount,
source: 'nexusGuildSecretAlly',
});
assert.equal(quoteNexusGuildSecretShipment('homeworld', 5).cost, 3);
assert.equal(
quoteNexusGuildSecretShipment('reserves', 5).cost,
3,
'return tariff is final, not halved a second time',
);
assert.equal(
quoteNexusGuildSecretShipment('sand', 5).cost,
5,
'paid Fremen cross-route must not inherit free native reinforcement',
);
});
void test('physical shipment quote rejects zero, fractional, excess, coerced or unknown destinations', () => {
for (const value of [
0,
-1,
21,
0.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER,
'5',
null,
undefined,
])
assert.throws(() =>
quoteNexusGuildSecretShipment('stronghold', value as number),
);
for (const type of [
'',
'arrakeen',
'homeworld:atreides',
'space',
'Stronghold',
null,
undefined,
])
assert.throws(() => quoteNexusGuildSecretShipment(type as string, 5));
});
void test('each non-Guild native faction can own a canonically bound absent-Guild receipt without changing the supplied roster', () => {
for (const faction of FACTIONS.filter((f) => f.id !== 'guild')) {
const other = FACTIONS.filter(
(f) => f.id !== 'guild' && f.id !== faction.id,
).slice(0, 2);
const ctx = {
turn: 2,
players: [
{ id: 'owner', faction: faction.id },
...other.map((f, i) => ({ id: `other${i}`, faction: f.id })),
],
};
const before = clone(ctx),
receipt = createNexusGuildSecretAlly(ctx, 'owner');
assert.equal(
receipt.event,
JSON.stringify(['nexusGuildSecretAlly', 2, 'owner']),
);
assert.equal(receipt.phase, 5);
assert.equal(receipt.mode, 'secretAlly');
assert.deepEqual(ctx, before);
assert.notEqual(receipt.roster, ctx.players);
validateNexusGuildSecretAlly(ctx, clone(receipt));
assert.deepEqual(
createNexusGuildSecretAlly(
{ ...ctx, players: [...ctx.players].reverse() },
'owner',
),
receipt,
);
}
});
void test('seated Guild, unknown owner, malformed identities and duplicated seats or factions cannot issue receipts', () => {
const badRosters = [
[...context.players, { id: 'g', faction: 'guild' as const }],
[...context.players, context.players[0]],
context.players.map((p) => ({ ...p, id: 'same' })),
context.players.map((p) => ({ ...p, faction: 'fremen' as const })),
context.players.map((p) => ({
...p,
id: p.id === 'a' ? '__proto__' : p.id,
})),
[],
];
for (const players of badRosters) {
const ctx = { ...context, players },
before = clone(ctx);
assert.throws(() => createNexusGuildSecretAlly(ctx, 'a'));
assert.deepEqual(ctx, before);
}
assert.throws(() => createNexusGuildSecretAlly(context, 'outsider'));
for (const turn of [0, -1, 1.5, NaN, Infinity])
assert.throws(() => createNexusGuildSecretAlly({ ...context, turn }, 'a'));
});
void test('completed historical receipt survives later turns, alliance changes and private custody changes without reading them', () => {
const receipt = createNexusGuildSecretAlly(context, 'a');
const trap = () => {
throw new Error('private state read');
};
const historical = {
turn: 8,
players: context.players.map((p) =>
Object.freeze({
...p,
get hand() {
return trap();
},
get spice() {
return trap();
},
get reserves() {
return trap();
},
get forces() {
return trap();
},
get shipped() {
return trap();
},
get ally() {
return trap();
},
}),
),
};
validateNexusGuildSecretAlly(historical, receipt);
assert.deepEqual(
createNexusGuildSecretAlly({ ...historical, turn: 4 }, 'a'),
receipt,
);
assert.throws(() =>
validateNexusGuildSecretAlly({ ...historical, turn: 3 }, receipt),
);
assert.throws(() =>
validateNexusGuildSecretAlly(
{ ...historical, players: historical.players.slice(0, 2) },
receipt,
),
);
});
void test('exact owner, event, roster, phase and signature corruption rejects immutably after JSON recovery', () => {
const receipt = createNexusGuildSecretAlly(context, 'a');
const changes: ((r: NexusGuildSecretAllyReceipt) => void)[] = [
(r) => {
r.owner = 'e';
},
(r) => {
r.turn = 3;
},
(r) => {
r.event = 'old';
},
(r) => {
r.signature = '';
},
(r) => {
Object.assign(r, { phase: 4 });
},
(r) => {
Object.assign(r, { mode: 'cunning' });
},
(r) => {
Object.assign(r, { version: 2 });
},
(r) => {
r.roster.reverse();
},
(r) => {
r.roster[0].id = 'foreign';
},
(r) => {
r.roster[0].faction = 'guild';
},
(r) => {
delete (r as Partial<NexusGuildSecretAllyReceipt>).signature;
},
(r) => {
Object.assign(r, { cost: 0 });
},
];
for (const change of changes) {
const bad = clone(receipt);
change(bad);
const before = clone(bad);
assert.throws(() => validateNexusGuildSecretAlly(context, bad));
assert.deepEqual(bad, before);
}
const inherited = Object.assign(
Object.create({ untrusted: true }),
receipt,
) as NexusGuildSecretAllyReceipt;
assert.throws(() => validateNexusGuildSecretAlly(context, inherited));
assert.equal(JSON.stringify(receipt).includes('hand'), false);
assert.equal(JSON.stringify(receipt).includes('spice'), false);
});