Implement Guild Nexus Secret Ally shipping routes and recovery
This commit is contained in:
1 parent
90bf699627
commit
06d0a66fc5
25 files changed
+2095
-52
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user