Implement Bene Gesserit Nexus advisor conversion with durable responses
This commit is contained in:
1 parent
40912498b1
commit
b05125de9a
23 files changed
+2298
-16
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusAllow } from './fixture-nexus-cards';
|
||||
|
||||
/** Final identities and Advanced rules precede genuine Nexus setup. Only
|
||||
* conserved board positions and the held physical Nexus identity are staged. */
|
||||
export function nexusAdvisorFixture(
|
||||
options: {
|
||||
seatIds?: [string, string, string];
|
||||
advanced?: boolean;
|
||||
opponentFaction?: 'guild' | 'harkonnen' | 'richese';
|
||||
} = {},
|
||||
): Game {
|
||||
const g = nexusTraitorFixture({
|
||||
ownerFaction: 'beneGesserit',
|
||||
opponentFaction: options.opponentFaction,
|
||||
advanced: options.advanced ?? true,
|
||||
seatIds: options.seatIds,
|
||||
phase: 5,
|
||||
});
|
||||
const [owner, opponent] = g.players;
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('beneGesserit');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck[index] = cards.hands[owner.id]!;
|
||||
cards.hands[owner.id] = 'beneGesserit';
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.tanks = 0;
|
||||
p.shipped = false;
|
||||
p.moved = 0;
|
||||
if (p.elites) {
|
||||
p.elites.forces = {};
|
||||
p.elites.reserves = 3;
|
||||
p.elites.tanks = 0;
|
||||
}
|
||||
delete p.advisors;
|
||||
}
|
||||
owner.forces = {
|
||||
'arrakeen:10': 2,
|
||||
'pasty_mesa:5': 1,
|
||||
'pasty_mesa:6': 2,
|
||||
'carthag:11': 1,
|
||||
'polar_sink:0': 1,
|
||||
};
|
||||
owner.reserves = 13;
|
||||
if (g.advanced)
|
||||
owner.advisors = { arrakeen: {}, pasty_mesa: {}, carthag: {} };
|
||||
opponent.forces = { 'arrakeen:10': 1, 'pasty_mesa:5': 1, 'carthag:11': 1 };
|
||||
opponent.reserves = 17;
|
||||
Object.assign(g, {
|
||||
active: owner.id,
|
||||
order: g.players.map((p) => p.id),
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
movementRemaining: null,
|
||||
});
|
||||
nexusAdvisorInventory(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
export const nexusAdvisorPlayer = (g: Game) =>
|
||||
g.players.find((p) => p.faction === 'beneGesserit')!;
|
||||
export const nexusAdvisorInventory = nexusTraitorInventory;
|
||||
|
||||
export function holdAdvisorKarama(g: Game, seatId = g.players[1].id): string {
|
||||
const index = g.deck.findIndex((card) => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
g.players.find((p) => p.id === seatId)!.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
export function beginNexusAdvisorFlip(g: Game, territories: string[]): Game {
|
||||
const owner = nexusAdvisorPlayer(g);
|
||||
const offer = viewGame(g, owner.id).nexusAdvisors!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
return applyAction(g, owner.id, {
|
||||
type: 'nexusAdvisors',
|
||||
event: offer.event,
|
||||
territories,
|
||||
});
|
||||
}
|
||||
|
||||
export const allowNexusAdvisorFlip = nexusAllow;
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { nexusAdvisorBotActions } from '../game/nexus-advisor-options';
|
||||
import {
|
||||
nexusAdvisorFixture,
|
||||
nexusAdvisorInventory,
|
||||
beginNexusAdvisorFlip,
|
||||
allowNexusAdvisorFlip,
|
||||
holdAdvisorKarama,
|
||||
} from './fixture-nexus-advisors';
|
||||
|
||||
void test('all profiles announce favorable whole advisor groups and settle one conserved real conversion', () => {
|
||||
const initial = nexusAdvisorFixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(initial, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
const before = structuredClone(v);
|
||||
const actions = botActions(v);
|
||||
assert.equal(actions.length, 1);
|
||||
assert.equal(actions[0].type, 'nexusAdvisors');
|
||||
assert.deepEqual([...(actions[0].territories as string[])].sort(), [
|
||||
'arrakeen',
|
||||
'pasty_mesa',
|
||||
]);
|
||||
assert.deepEqual(v, before);
|
||||
const g = allowNexusAdvisorFlip(applyAction(initial, 'p', actions[0]));
|
||||
assert.equal(g.players[0].advisors?.arrakeen, undefined);
|
||||
assert.equal(g.players[0].advisors?.pasty_mesa, undefined);
|
||||
assert.ok(g.players[0].advisors?.carthag);
|
||||
assert.deepEqual(g.players[0].forces, initial.players[0].forces);
|
||||
assert.equal(g.players[0].spice, initial.players[0].spice);
|
||||
assert.equal(g.players[0].moved, initial.players[0].moved);
|
||||
assert.equal(g.players[0].shipped, initial.players[0].shipped);
|
||||
nexusAdvisorInventory(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('advisor policy respects blocked groups and uses only public fighter presence', () => {
|
||||
const v = viewGame(nexusAdvisorFixture(), 'p');
|
||||
v.players[0].bot = 'Brutal';
|
||||
const offer = v.nexusAdvisors!.offer!;
|
||||
offer.territories.find((t) => t.territory === 'arrakeen')!.blocked =
|
||||
'Fresh advisor conversion awaits its ruling.';
|
||||
for (const player of v.players)
|
||||
for (const key of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Object.defineProperty(player, key, {
|
||||
get() {
|
||||
throw new Error(`Private ${key} read`);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(nexusAdvisorBotActions(v)[0]?.territories, ['pasty_mesa']);
|
||||
v.players[1].forces['pasty_mesa:5'] = 10;
|
||||
assert.deepEqual(nexusAdvisorBotActions(v), []);
|
||||
v.players[1].forces = {};
|
||||
assert.deepEqual(nexusAdvisorBotActions(v), []);
|
||||
Object.defineProperty(v.players[1], 'noField', {
|
||||
value: {
|
||||
deployed: {
|
||||
location: { territory: 'pasty_mesa', sector: 5 },
|
||||
get value() {
|
||||
throw new Error('Read No-Field denomination');
|
||||
},
|
||||
get tokenId() {
|
||||
throw new Error('Read No-Field identity');
|
||||
},
|
||||
},
|
||||
get tokens() {
|
||||
throw new Error('Read private No-Field inventory');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(nexusAdvisorBotActions(v)[0]?.territories, ['pasty_mesa']);
|
||||
});
|
||||
|
||||
void test('response bots use the ordinary whole-use Karama controls when their fighters are threatened', () => {
|
||||
const initial = nexusAdvisorFixture();
|
||||
const card = holdAdvisorKarama(initial);
|
||||
const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(g, 'q');
|
||||
v.players.find((p) => p.id === 'q')!.bot = difficulty;
|
||||
const action = botActions(v)[0];
|
||||
assert.ok(action);
|
||||
if (difficulty === 'Easy' || difficulty === 'Medium')
|
||||
assert.deepEqual(action, { type: 'passResponse' });
|
||||
else assert.deepEqual(action, { type: 'card', card, mode: 'cancel' });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
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 { viewGame, type GameView } from '../game/engine';
|
||||
import { NexusAdvisors } from '../components/nexus-advisors';
|
||||
import {
|
||||
nexusAdvisorAction,
|
||||
nexusAdvisorCanAct,
|
||||
} from '../game/nexus-advisor-options';
|
||||
import {
|
||||
nexusAdvisorFixture,
|
||||
beginNexusAdvisorFlip,
|
||||
holdAdvisorKarama,
|
||||
} from './fixture-nexus-advisors';
|
||||
|
||||
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((match) => [
|
||||
match[1],
|
||||
match[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(NexusAdvisors, { game, busy, act() {} }));
|
||||
|
||||
void test('advisor controls name whole territories and counts with an explicit empty initial selection', () => {
|
||||
const v = viewGame(nexusAdvisorFixture(), 'p');
|
||||
const offer = v.nexusAdvisors!.offer!;
|
||||
const before = structuredClone(v);
|
||||
const text = html(v);
|
||||
assert.match(text, /Pasty Mesa: 3 advisors/);
|
||||
assert.match(text, /Arrakeen: 2 advisors/);
|
||||
assert.equal((text.match(/class="decision-checkbox min-h-11"/g) ?? []).length, offer.territories.length);
|
||||
assert.match(text, /Select all available/);
|
||||
assert.match(text, /0 territories selected/);
|
||||
assert.match(text, /across all sectors/);
|
||||
assert.match(
|
||||
text,
|
||||
/costs no spice and uses no shipment or movement allowance/,
|
||||
);
|
||||
assert.doesNotMatch(text, /checked=""/);
|
||||
assert.match(text, /disabled=""[^>]*>Convert selected advisors/);
|
||||
const selected = offer.territories
|
||||
.filter((choice) => !choice.blocked)
|
||||
.map((choice) => choice.territory);
|
||||
assert.ok(selected.length >= 2);
|
||||
assert.deepEqual(nexusAdvisorAction(v, offer.event, selected), {
|
||||
type: 'nexusAdvisors',
|
||||
event: offer.event,
|
||||
territories: selected,
|
||||
});
|
||||
assert.deepEqual(v, before);
|
||||
assert.match(html(v, true), /disabled=""/);
|
||||
});
|
||||
|
||||
void test('shared advisor selection binds event, unique whole groups and projected source boundary reasons', () => {
|
||||
const v = viewGame(nexusAdvisorFixture(), 'p');
|
||||
const offer = v.nexusAdvisors!.offer!;
|
||||
const place = offer.territories.find(
|
||||
(choice) => choice.territory === 'arrakeen',
|
||||
)!;
|
||||
assert.ok(nexusAdvisorAction(v, offer.event, ['arrakeen']));
|
||||
for (const choices of [
|
||||
[],
|
||||
['arrakeen', 'arrakeen'],
|
||||
['not-a-territory'],
|
||||
['arrakeen:10'],
|
||||
])
|
||||
assert.equal(nexusAdvisorAction(v, offer.event, choices), null);
|
||||
assert.equal(nexusAdvisorAction(v, 'stale', ['arrakeen']), null);
|
||||
// Consumer-only projections prove reasons are displayed rather than guessed
|
||||
// from a source rule or the current territory's board graphics.
|
||||
place.blocked =
|
||||
'Newly accompanied advisors await the same-turn conversion ruling.';
|
||||
offer.territories.find(
|
||||
(choice) => choice.territory === 'pasty_mesa',
|
||||
)!.blocked = 'Conversion in storm awaits its timing ruling.';
|
||||
assert.equal(nexusAdvisorAction(v, offer.event, ['arrakeen']), null);
|
||||
assert.match(html(v), /Newly accompanied advisors await/);
|
||||
assert.match(html(v), /Conversion in storm awaits/);
|
||||
offer.blocked = 'Finish the current movement response first.';
|
||||
assert.equal(nexusAdvisorCanAct(v), false);
|
||||
assert.match(html(v), /Finish the current movement response first/);
|
||||
});
|
||||
|
||||
void test('advisor selection rejects other actors, Basic, wrong phase and all interrupted windows', () => {
|
||||
const original = viewGame(nexusAdvisorFixture(), 'p');
|
||||
for (const mutate of [
|
||||
(v: GameView) => {
|
||||
v.me = 'q';
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.advanced = false;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.phase = 4;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.active = 'q';
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.players[0].ally = 'q';
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.nexusCards!.card = null;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.automaticContinuationPending = true;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.truthtrance = {
|
||||
stage: 'priority',
|
||||
queue: [],
|
||||
passed: [],
|
||||
question: null,
|
||||
};
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.nexusCards!.waiting = ['q'];
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.nexusTraitors = {
|
||||
offer: null,
|
||||
pending: {
|
||||
event: 'other',
|
||||
owner: 'q',
|
||||
mode: 'cunning',
|
||||
count: 1,
|
||||
choices: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
]) {
|
||||
const v = structuredClone(original);
|
||||
mutate(v);
|
||||
assert.equal(
|
||||
nexusAdvisorAction(v, v.nexusAdvisors!.offer!.event, ['arrakeen']),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('public pending territory set explains one Karama cancellation without exposing the private offer', () => {
|
||||
const initial = nexusAdvisorFixture();
|
||||
holdAdvisorKarama(initial);
|
||||
const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']);
|
||||
assert.equal(g.response?.kind, 'nexusAdvisorFlip');
|
||||
for (const id of ['p', 'q', 'r']) {
|
||||
const v = viewGame(g, id);
|
||||
assert.ok(v.nexusAdvisors!.pending);
|
||||
Object.defineProperty(v.nexusAdvisors!, 'offer', {
|
||||
get() {
|
||||
throw new Error('Read offer during public pending response');
|
||||
},
|
||||
});
|
||||
for (const player of v.players)
|
||||
Object.defineProperty(player, 'hand', {
|
||||
get() {
|
||||
throw new Error('Read private hand');
|
||||
},
|
||||
});
|
||||
const text = html(v);
|
||||
assert.match(text, /Arrakeen, Pasty Mesa/);
|
||||
assert.match(text, /Karama can cancel the entire selection/);
|
||||
assert.doesNotMatch(text, /checkbox|Convert selected advisors/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual response panel names Nexus conversion and offers Karama without printing its internal event', () => {
|
||||
const initial = nexusAdvisorFixture();
|
||||
holdAdvisorKarama(initial);
|
||||
const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']);
|
||||
const v = viewGame(g, 'q');
|
||||
// Real component rendering with CSS selector names preserved above; this is
|
||||
// structural response verification, not a browser or visual acceptance claim.
|
||||
const text = renderToStaticMarkup(
|
||||
createElement(GameTable, {
|
||||
game: v,
|
||||
send: async () => {},
|
||||
onExit() {},
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
assert.match(text, /Bene Gesserit Nexus advisor conversion/);
|
||||
assert.match(text, /Cancel with Karama/);
|
||||
assert.match(text, /Allow this power/);
|
||||
assert.doesNotMatch(text, /nexusAdvisors/);
|
||||
const circleTitles = [
|
||||
...text.matchAll(/<title>(Player circle [^<]+)<\/title>/g),
|
||||
].map((match) => match[1]);
|
||||
assert.equal(circleTitles.length, 6);
|
||||
assert.ok(
|
||||
circleTitles.every((title) =>
|
||||
/^Player circle [1-6], sector \d+: .+/.test(title),
|
||||
),
|
||||
);
|
||||
assert.ok(circleTitles.some((title) => title.includes('Bene Gesserit')));
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusAdvisorFixture,
|
||||
nexusAdvisorPlayer,
|
||||
holdAdvisorKarama,
|
||||
beginNexusAdvisorFlip,
|
||||
allowNexusAdvisorFlip,
|
||||
} from './fixture-nexus-advisors';
|
||||
import { nexusReload } from './fixture-nexus-cards';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
|
||||
function inventory(g: Game) {
|
||||
assert.deepEqual(
|
||||
[
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...g.richeseCache!,
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
[...treacheryDeck(g.expansions), ...richeseCards()]
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
);
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
function paidSearch() {
|
||||
let g = nexusAdvisorFixture({ opponentFaction: 'richese' });
|
||||
const boxIndex = g.richeseCache!.findIndex(
|
||||
(card) => card.effect === 'nullentropyBox',
|
||||
);
|
||||
assert.ok(boxIndex >= 0);
|
||||
const box = g.richeseCache!.splice(boxIndex, 1)[0];
|
||||
g.players[1].hand.push(box);
|
||||
const karama = holdAdvisorKarama(g);
|
||||
for (const kind of ['shield', 'projectile']) {
|
||||
const index = g.deck.findIndex((card) => card.kind === kind);
|
||||
assert.ok(index >= 0);
|
||||
g.discard.push(g.deck.splice(index, 1)[0]);
|
||||
}
|
||||
g.players[1].spice = 10;
|
||||
const original = nexusReload(g);
|
||||
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
||||
const response = structuredClone(g.response);
|
||||
g = applyAction(g, 'q', { type: 'card', card: box.id });
|
||||
assert.equal(g.decision?.kind, 'nullentropy');
|
||||
assert.equal(g.pendingNullentropy?.resume.response?.kind, 'nexusAdvisorFlip');
|
||||
assert.deepEqual(g.pendingNullentropy!.resume.response, response);
|
||||
assert.equal(g.players[1].spice, 8);
|
||||
inventory(g);
|
||||
return { g, original, response, karama, box: box.id };
|
||||
}
|
||||
|
||||
function finishSearch(g: Game) {
|
||||
return applyAction(g, 'q', {
|
||||
type: 'decision',
|
||||
event: g.pendingNullentropy!.event,
|
||||
card: g.discard[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
void test('paid Nullentropy suspends the advisor response and restores its exact batch once after a private JSON recovery', () => {
|
||||
const f = paidSearch();
|
||||
let g = nexusReload(f.g);
|
||||
const before = nexusReload(g);
|
||||
assert.deepEqual(normalizeAutomaticGame(g), before);
|
||||
for (const player of g.players) {
|
||||
assert.deepEqual(
|
||||
viewGame(nexusReload(g), player.id),
|
||||
viewGame(g, player.id),
|
||||
);
|
||||
if (player.id !== 'q')
|
||||
assert.equal(viewGame(g, player.id).nullentropy, null);
|
||||
}
|
||||
assert.deepEqual(
|
||||
nexusAdvisorPlayer(g).advisors,
|
||||
nexusAdvisorPlayer(f.original).advisors,
|
||||
);
|
||||
const selected = g.discard[0].id;
|
||||
g = finishSearch(g);
|
||||
assert.deepEqual(g.response, f.response);
|
||||
assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'pending');
|
||||
assert.equal(g.players[1].spice, 8);
|
||||
assert.ok(g.players[1].hand.some((card) => card.id === selected));
|
||||
assert.equal(g.discard.filter((card) => card.id === f.box).length, 1);
|
||||
g = allowNexusAdvisorFlip(nexusReload(g));
|
||||
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
||||
assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
|
||||
assert.equal(g.nexusAdvisorHistory!.length, 1);
|
||||
assert.deepEqual(
|
||||
g.players.map((player) => player.forces),
|
||||
f.original.players.map((player) => player.forces),
|
||||
);
|
||||
assert.equal(g.players[1].spice, 8);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
inventory(g);
|
||||
});
|
||||
|
||||
void test('the restored advisor response still accepts one Karama after Box recovery without undoing the paid search', () => {
|
||||
const f = paidSearch();
|
||||
let g = finishSearch(nexusReload(f.g));
|
||||
const selected = f.g.discard[0].id;
|
||||
g = applyAction(g, 'q', { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
g = allowNexusAdvisorFlip(nexusReload(g));
|
||||
assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
|
||||
assert.deepEqual(
|
||||
nexusAdvisorPlayer(g).advisors,
|
||||
nexusAdvisorPlayer(f.original).advisors,
|
||||
);
|
||||
assert.equal(g.players[1].spice, 8);
|
||||
assert.ok(g.players[1].hand.some((card) => card.id === selected));
|
||||
assert.equal(g.discard.filter((card) => card.id === f.box).length, 1);
|
||||
assert.equal(g.discard.filter((card) => card.id === f.karama).length, 1);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((card) => card === 'beneGesserit')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
inventory(g);
|
||||
});
|
||||
|
||||
void test('damaged advisor parents inside a paid search reject reads, automatic progress and card selection immutably', () => {
|
||||
const { g: paid } = paidSearch();
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
(g) => {
|
||||
g.pendingNullentropy!.resume.response = null;
|
||||
},
|
||||
(g) => {
|
||||
g.pendingNullentropy!.resume.response!.intent = 'stale';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingNullentropy!.resume.response!.owner = 'q';
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusAdvisorLast;
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusAdvisorHistory;
|
||||
},
|
||||
(g) => {
|
||||
g.nexusAdvisorHistory![0].stage = 'completed';
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].moved++;
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].forces['arrakeen:10']++;
|
||||
g.players[0].reserves--;
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const g = nexusReload(paid);
|
||||
mutate(g);
|
||||
const before = nexusReload(g);
|
||||
for (const player of g.players) assert.throws(() => viewGame(g, player.id));
|
||||
assert.throws(() => normalizeAutomaticGame(g));
|
||||
assert.throws(() => finishSearch(g));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
quoteNexusAdvisors,
|
||||
createNexusAdvisors,
|
||||
validateNexusAdvisors,
|
||||
type NexusAdvisorContext,
|
||||
type NexusAdvisorReceipt,
|
||||
} from '../game/nexus-advisors';
|
||||
|
||||
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
function context(): NexusAdvisorContext {
|
||||
return {
|
||||
advanced: true,
|
||||
turn: 3,
|
||||
storm: 18,
|
||||
order: ['b', 'g', 'r'],
|
||||
players: [
|
||||
{
|
||||
id: 'b',
|
||||
faction: 'beneGesserit',
|
||||
ally: null,
|
||||
forces: { 'hagga_basin:12': 2, 'hagga_basin:13': 1, 'arrakeen:10': 2 },
|
||||
advisors: { hagga_basin: { lockedTurn: 2 }, arrakeen: {} },
|
||||
},
|
||||
{
|
||||
id: 'g',
|
||||
faction: 'guild',
|
||||
ally: null,
|
||||
forces: { 'hagga_basin:12': 1, 'arrakeen:10': 1 },
|
||||
},
|
||||
{ id: 'r', faction: 'richese', ally: null, forces: {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
void test('one territory selection includes all physical advisor sectors and permits several territory groups as one immutable receipt', () => {
|
||||
const c = context(),
|
||||
before = reload(c),
|
||||
quote = quoteNexusAdvisors(c, 'b');
|
||||
assert.deepEqual(
|
||||
quote.territories
|
||||
.map((t) => [t.territory, t.count, t.blocked])
|
||||
.sort((one, two) => String(one[0]).localeCompare(String(two[0]))),
|
||||
[
|
||||
['arrakeen', 2, null],
|
||||
['hagga_basin', 3, null],
|
||||
],
|
||||
);
|
||||
const receipt = createNexusAdvisors(c, 'b', 'advisor-1', [
|
||||
'hagga_basin',
|
||||
'arrakeen',
|
||||
]);
|
||||
assert.deepEqual(receipt.selections[0], {
|
||||
territory: 'hagga_basin',
|
||||
count: 3,
|
||||
locations: [
|
||||
{ location: 'hagga_basin:12', count: 2 },
|
||||
{ location: 'hagga_basin:13', count: 1 },
|
||||
],
|
||||
noField: null,
|
||||
stance: { lockedTurn: 2 },
|
||||
});
|
||||
assert.deepEqual(c, before);
|
||||
validateNexusAdvisors(reload(c), reload(receipt), true);
|
||||
receipt.selections[0].locations[0].count = 0;
|
||||
assert.deepEqual(c, before);
|
||||
});
|
||||
void test('Basic and automatically released lone advisors yield no choice; Homeworld stances are not selected', () => {
|
||||
const c = context();
|
||||
assert.deepEqual(quoteNexusAdvisors({ ...c, advanced: false }, 'b'), {
|
||||
territories: [],
|
||||
});
|
||||
c.players[1].forces = {};
|
||||
c.players[0].advisors!['homeworld:beneGesserit'] = {};
|
||||
assert.deepEqual(quoteNexusAdvisors(c, 'b'), { territories: [] });
|
||||
assert.throws(() =>
|
||||
createNexusAdvisors(c, 'b', 'x', ['homeworld:beneGesserit']),
|
||||
);
|
||||
assert.throws(() => quoteNexusAdvisors(c, 'g'));
|
||||
});
|
||||
void test('fresh advisor lock and storm interference expose explicit unresolved reasons while clear sectors remain usable', () => {
|
||||
const fresh = context();
|
||||
fresh.players[0].advisors!.hagga_basin.lockedTurn = 3;
|
||||
assert.match(
|
||||
quoteNexusAdvisors(fresh, 'b').territories.find(
|
||||
(t) => t.territory === 'hagga_basin',
|
||||
)!.blocked!,
|
||||
/same-turn.*unresolved/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createNexusAdvisors(fresh, 'b', 'x', ['arrakeen', 'hagga_basin']),
|
||||
/unresolved/,
|
||||
);
|
||||
const storm = context();
|
||||
storm.storm = 10;
|
||||
assert.match(
|
||||
quoteNexusAdvisors(storm, 'b').territories.find(
|
||||
(t) => t.territory === 'arrakeen',
|
||||
)!.blocked!,
|
||||
/storm.*ruling/,
|
||||
);
|
||||
assert.equal(
|
||||
quoteNexusAdvisors(storm, 'b').territories.find(
|
||||
(t) => t.territory === 'hagga_basin',
|
||||
)!.blocked,
|
||||
null,
|
||||
);
|
||||
});
|
||||
void test('a storm separating hostile sectors blocks only the affected conversion rather than requiring battle contact everywhere', () => {
|
||||
const c = context();
|
||||
c.players[0].forces = { 'pasty_mesa:5': 2 };
|
||||
c.players[0].advisors = { pasty_mesa: {} };
|
||||
c.players[1].forces = { 'pasty_mesa:8': 1 };
|
||||
c.storm = 7;
|
||||
assert.match(quoteNexusAdvisors(c, 'b').territories[0].blocked!, /storm/);
|
||||
c.storm = 18;
|
||||
assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null);
|
||||
c.players[0].forces = { 'polar_sink:0': 2 };
|
||||
c.players[0].advisors = { polar_sink: {} };
|
||||
c.players[1].forces = { 'polar_sink:0': 1 };
|
||||
assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null);
|
||||
});
|
||||
void test('ordinary allied occupation and a third stronghold faction block conversion using public No-Field presence', () => {
|
||||
const allied = context();
|
||||
allied.players[0].ally = 'g';
|
||||
allied.players[1].ally = 'b';
|
||||
assert.ok(
|
||||
quoteNexusAdvisors(allied, 'b').territories.every((t) =>
|
||||
/ally/.test(t.blocked!),
|
||||
),
|
||||
);
|
||||
const full = context();
|
||||
full.players[2].noField = {
|
||||
deployed: { location: { territory: 'arrakeen', sector: 10 } },
|
||||
};
|
||||
assert.match(
|
||||
quoteNexusAdvisors(full, 'b').territories.find(
|
||||
(t) => t.territory === 'arrakeen',
|
||||
)!.blocked!,
|
||||
/three occupying factions/,
|
||||
);
|
||||
assert.equal(
|
||||
quoteNexusAdvisors(full, 'b').territories.find(
|
||||
(t) => t.territory === 'hagga_basin',
|
||||
)!.blocked,
|
||||
null,
|
||||
);
|
||||
});
|
||||
void test('the established Ecaz shared occupancy identity is preserved for capacity and allied entry', () => {
|
||||
const c = context();
|
||||
c.players[1].faction = 'ecaz';
|
||||
c.players[0].ally = 'g';
|
||||
c.players[1].ally = 'b';
|
||||
c.players[2].forces = { 'arrakeen:10': 1 };
|
||||
assert.equal(
|
||||
quoteNexusAdvisors(c, 'b').territories.find(
|
||||
(t) => t.territory === 'arrakeen',
|
||||
)!.blocked,
|
||||
null,
|
||||
);
|
||||
});
|
||||
void test('public marker counts and locations are recorded without reading hidden No-Field denomination or other secrets', () => {
|
||||
const c = context();
|
||||
c.players[0].noField = {
|
||||
deployed: { location: { territory: 'hagga_basin', sector: 12 } },
|
||||
};
|
||||
for (const player of c.players)
|
||||
for (const key of ['hand', 'spice', 'traitors', 'leaders'])
|
||||
Object.defineProperty(player, key, {
|
||||
get() {
|
||||
throw Error('Private field accessed');
|
||||
},
|
||||
});
|
||||
const marker = c.players[0].noField!;
|
||||
for (const key of ['tokens', 'value', 'tokenId', 'lastShipped'])
|
||||
Object.defineProperty(marker, key, {
|
||||
get() {
|
||||
throw Error('Hidden marker accessed');
|
||||
},
|
||||
});
|
||||
Object.defineProperty(marker.deployed!, 'tokenId', {
|
||||
get() {
|
||||
throw Error('Hidden identity accessed');
|
||||
},
|
||||
});
|
||||
const receipt = createNexusAdvisors(c, 'b', 'private', ['hagga_basin']);
|
||||
assert.equal(receipt.selections[0].count, 4);
|
||||
assert.equal(receipt.selections[0].noField, 'hagga_basin:12');
|
||||
assert.equal(
|
||||
receipt.selections[0].locations.reduce((sum, g) => sum + g.count, 0),
|
||||
3,
|
||||
);
|
||||
validateNexusAdvisors(c, receipt, true);
|
||||
const changed = reload(c);
|
||||
changed.players[0].noField = null;
|
||||
changed.players[0].forces = {
|
||||
'hagga_basin:12': 3,
|
||||
'hagga_basin:13': 1,
|
||||
'arrakeen:10': 2,
|
||||
};
|
||||
assert.equal(
|
||||
quoteNexusAdvisors(changed, 'b').territories.find(
|
||||
(t) => t.territory === 'hagga_basin',
|
||||
)!.count,
|
||||
4,
|
||||
);
|
||||
assert.throws(() => validateNexusAdvisors(changed, receipt, true));
|
||||
});
|
||||
void test('empty, duplicate, foreign and malformed selections reject immutably and current settlement rechecks exact counts and legality', () => {
|
||||
const c = context(),
|
||||
before = reload(c);
|
||||
for (const selected of [
|
||||
[],
|
||||
['arrakeen', 'arrakeen'],
|
||||
['unknown'],
|
||||
['arrakeen:10'],
|
||||
])
|
||||
assert.throws(() => createNexusAdvisors(c, 'b', 'x', selected));
|
||||
assert.deepEqual(c, before);
|
||||
const receipt = createNexusAdvisors(c, 'b', 'x', ['arrakeen']);
|
||||
for (const change of [
|
||||
(g: NexusAdvisorContext) => {
|
||||
g.players[0].forces = { 'arrakeen:10': 1 };
|
||||
},
|
||||
(g: NexusAdvisorContext) => {
|
||||
delete g.players[0].advisors!.arrakeen;
|
||||
},
|
||||
(g: NexusAdvisorContext) => {
|
||||
g.players[0].advisors!.arrakeen.lockedTurn = 3;
|
||||
},
|
||||
(g: NexusAdvisorContext) => {
|
||||
g.players[2].forces = { 'arrakeen:10': 1 };
|
||||
},
|
||||
(g: NexusAdvisorContext) => {
|
||||
g.storm = 10;
|
||||
},
|
||||
]) {
|
||||
const altered = reload(c);
|
||||
change(altered);
|
||||
assert.throws(() => validateNexusAdvisors(altered, receipt, true));
|
||||
}
|
||||
});
|
||||
void test('signed receipt detects altered counts, stances and ordering while completed history survives later movement and turns', () => {
|
||||
const c = context(),
|
||||
receipt = createNexusAdvisors(c, 'b', 'x', ['arrakeen', 'hagga_basin']);
|
||||
for (const change of [
|
||||
(r: NexusAdvisorReceipt) => {
|
||||
r.selections.reverse();
|
||||
},
|
||||
(r: NexusAdvisorReceipt) => {
|
||||
r.selections[0].count++;
|
||||
},
|
||||
(r: NexusAdvisorReceipt) => {
|
||||
r.selections[0].stance.lockedTurn = 1;
|
||||
},
|
||||
(r: NexusAdvisorReceipt) => {
|
||||
r.selections[1].locations.reverse();
|
||||
},
|
||||
(r: NexusAdvisorReceipt) => {
|
||||
r.owner = 'g';
|
||||
},
|
||||
(r: NexusAdvisorReceipt) => {
|
||||
r.turn++;
|
||||
},
|
||||
]) {
|
||||
const bad = reload(receipt);
|
||||
change(bad);
|
||||
assert.throws(() => validateNexusAdvisors(c, bad, false));
|
||||
}
|
||||
const later = reload(c);
|
||||
later.turn++;
|
||||
later.players[0].forces = {};
|
||||
later.players[0].advisors = {};
|
||||
validateNexusAdvisors(later, receipt, false);
|
||||
assert.throws(() => validateNexusAdvisors(later, receipt, true));
|
||||
});
|
||||
|
||||
void test('ordinary in-memory undefined lock metadata normalizes into the same saved stance without mutating its source', () => {
|
||||
const c = context();
|
||||
c.players[0].advisors!.arrakeen = { lockedTurn: undefined };
|
||||
const receipt = createNexusAdvisors(c, 'b', 'undefined-lock', ['arrakeen']);
|
||||
assert.deepEqual(receipt.selections[0].stance, {});
|
||||
assert.equal(
|
||||
Object.hasOwn(c.players[0].advisors!.arrakeen, 'lockedTurn'),
|
||||
true,
|
||||
);
|
||||
validateNexusAdvisors(c, receipt, true);
|
||||
validateNexusAdvisors(reload(c), reload(receipt), true);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { nexusAdvisorFixture, holdAdvisorKarama } from './fixture-nexus-advisors';
|
||||
import { nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
async function fixture(initial: Game) {
|
||||
const store = unitStore();
|
||||
const made = await store.rooms.createRoom(
|
||||
'Cunning SQL',
|
||||
'harkonnen',
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const code = made.view.code,
|
||||
tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const ids = ['p', 'q', 'r'];
|
||||
// Only disposable SQL seat bindings move. Existing game identities and their
|
||||
// signed battle/component histories remain untouched.
|
||||
for (const [i, token] of tokens.entries()) {
|
||||
const auth = await store.rooms.authenticate(code, token);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?',
|
||||
)
|
||||
.run(ids[i], code, auth.playerId);
|
||||
}
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
initial.code = code;
|
||||
initial.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(initial), initial.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, tokens, auths, ids, initial };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>> & { sqlite: DatabaseSync };
|
||||
const row = (f: Fixture) =>
|
||||
f.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(f.code);
|
||||
const advisorAction = (g: Game): Action => ({
|
||||
type: 'nexusAdvisors', event: viewGame(g, 'p').nexusAdvisors!.offer!.event,
|
||||
territories: ['arrakeen', 'pasty_mesa'],
|
||||
});
|
||||
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);
|
||||
const actual = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(actual, viewGame(g, f.ids[i]));
|
||||
assert.equal(Object.hasOwn(actual, 'nexusAdvisorHistory'), false);
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
async function compete(f: Fixture, action: Action) {
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const results = await Promise.allSettled(
|
||||
[f.restart(), f.restart()].map((rooms) =>
|
||||
rooms.act(f.code, f.auths[0], f.initial.version, action, clock),
|
||||
),
|
||||
);
|
||||
delete f.hooks.beforeWrite;
|
||||
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 g = await f.restart().readRoom(f.code);
|
||||
assert.equal(g.version, f.initial.version + 1);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('SQLite competing advisor declarations save exactly one pending batch and private seats restore it', async () => {
|
||||
const initial = nexusAdvisorFixture();
|
||||
holdAdvisorKarama(initial);
|
||||
const f = await fixture(initial);
|
||||
try {
|
||||
const action = advisorAction(f.initial);
|
||||
const g = await compete(f, action);
|
||||
assert.equal(g.response?.kind, 'nexusAdvisorFlip');
|
||||
assert.equal(g.nexusAdvisorHistory!.length, 1);
|
||||
assert.equal(g.nexusAdvisorLast!.stage, 'pending');
|
||||
assert.deepEqual(g.players[0].advisors, initial.players[0].advisors);
|
||||
await restored(f, g);
|
||||
const before = row(f);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], initial.version, action, clock));
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, action, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
await f.restart().act(f.code, f.auths[1], g.version, {type:'passResponse'}, clock);
|
||||
const settled = await f.restart().readRoom(f.code);
|
||||
assert.equal(settled.nexusAdvisorLast!.stage, 'completed');
|
||||
assert.deepEqual(settled.players[0].advisors, {carthag:{}});
|
||||
assert.deepEqual(settled.players[0].forces, initial.players[0].forces);
|
||||
await restored(f, settled);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('SQLite reconnect then Karama cancels the entire saved advisor batch without spending movement', async () => {
|
||||
const initial = nexusAdvisorFixture();
|
||||
const karama = holdAdvisorKarama(initial);
|
||||
const f = await fixture(initial);
|
||||
try {
|
||||
await f.rooms.act(f.code, f.auths[0], initial.version, advisorAction(initial), clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
await restored(f, pending);
|
||||
await f.restart().act(f.code, f.auths[1], pending.version, {type:'card',card:karama,mode:'cancel'}, clock);
|
||||
const g = await f.restart().readRoom(f.code);
|
||||
assert.equal(g.nexusAdvisorLast!.stage, 'canceled');
|
||||
assert.deepEqual(g.players[0].advisors, initial.players[0].advisors);
|
||||
assert.deepEqual(g.players[0].forces, initial.players[0].forces);
|
||||
assert.equal(g.players[0].moved, 0);
|
||||
assert.equal(g.players[0].shipped, false);
|
||||
assert.equal(g.discard.filter(c => c.id === karama).length, 1);
|
||||
await restored(f, g);
|
||||
const before = row(f);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[1], g.version, {type:'card',card:karama,mode:'cancel'}, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('SQLite rejects missing or corrupted advisor continuations without changing their saved rows', async () => {
|
||||
const initial = nexusAdvisorFixture(); holdAdvisorKarama(initial);
|
||||
const f = await fixture(initial);
|
||||
try {
|
||||
await f.rooms.act(f.code, f.auths[0], initial.version, advisorAction(initial), clock);
|
||||
const good = await f.rooms.readRoom(f.code);
|
||||
const changes: ((g:Game)=>void)[] = [
|
||||
g => { delete g.nexusAdvisorHistory; },
|
||||
g => { delete g.nexusAdvisorLast; },
|
||||
g => { g.nexusAdvisorHistory![0].receipt.selections.pop(); },
|
||||
g => { g.response!.intent += '-stale'; },
|
||||
g => { g.response = null; },
|
||||
g => { g.players[0].moved++; },
|
||||
g => { g.players[0].forces['pasty_mesa:5']++; g.players[0].reserves--; },
|
||||
g => { g.nexusAdvisorHistory![0].stage = 'completed'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const bad = structuredClone(good); change(bad);
|
||||
f.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(bad), f.code);
|
||||
f.writes.length = 0;
|
||||
const before = row(f);
|
||||
for (const auth of f.auths) await assert.rejects(f.restart().readSeatView(f.code, auth));
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[1], bad.version, {type:'passResponse'}, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusAdvisorFixture,
|
||||
nexusAdvisorPlayer,
|
||||
nexusAdvisorInventory,
|
||||
holdAdvisorKarama,
|
||||
beginNexusAdvisorFlip,
|
||||
allowNexusAdvisorFlip,
|
||||
} from './fixture-nexus-advisors';
|
||||
import { nexusReload, nexusReject } from './fixture-nexus-cards';
|
||||
import { drawNexusCard } from '../game/nexus-cards';
|
||||
|
||||
function action(g: Game, territories = ['arrakeen', 'pasty_mesa']) {
|
||||
const owner = nexusAdvisorPlayer(g);
|
||||
return {
|
||||
type: 'nexusAdvisors',
|
||||
event: viewGame(g, owner.id).nexusAdvisors!.offer!.event,
|
||||
territories,
|
||||
};
|
||||
}
|
||||
function resources(g: Game) {
|
||||
return g.players.map((p) => ({
|
||||
id: p.id,
|
||||
forces: p.forces,
|
||||
reserves: p.reserves,
|
||||
tanks: p.tanks,
|
||||
elites: p.elites,
|
||||
spice: p.spice,
|
||||
hand: p.hand,
|
||||
shipped: p.shipped,
|
||||
moved: p.moved,
|
||||
}));
|
||||
}
|
||||
|
||||
void test('Cunning converts every counter in selected multi-sector groups without consuming movement, shipment or spice', () => {
|
||||
let g = nexusAdvisorFixture();
|
||||
holdAdvisorKarama(g);
|
||||
const original = nexusReload(g);
|
||||
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
|
||||
assert.deepEqual(
|
||||
[...offer.territories]
|
||||
.sort((a, b) => a.territory.localeCompare(b.territory))
|
||||
.map((t) => [t.territory, t.count]),
|
||||
[
|
||||
['arrakeen', 2],
|
||||
['carthag', 1],
|
||||
['pasty_mesa', 3],
|
||||
],
|
||||
);
|
||||
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
||||
assert.equal(g.response?.kind, 'nexusAdvisorFlip');
|
||||
assert.deepEqual(
|
||||
nexusAdvisorPlayer(g).advisors,
|
||||
nexusAdvisorPlayer(original).advisors,
|
||||
);
|
||||
assert.equal(g.nexusCards!.cards!.hands.p, null);
|
||||
g = allowNexusAdvisorFlip(g);
|
||||
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
||||
assert.deepEqual(resources(g), resources(original));
|
||||
assert.equal(g.phase, 5);
|
||||
assert.equal(g.active, 'p');
|
||||
assert.equal(g.movementRemaining, original.movementRemaining);
|
||||
assert.equal(g.pendingTerrorEntry, original.pendingTerrorEntry);
|
||||
assert.equal(g.pendingAmbassador, original.pendingAmbassador);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length,
|
||||
1,
|
||||
);
|
||||
nexusAdvisorInventory(g);
|
||||
});
|
||||
|
||||
void test('one Karama cancels the entire announced advisor batch and spends both physical cards once', () => {
|
||||
let g = nexusAdvisorFixture();
|
||||
const karama = holdAdvisorKarama(g);
|
||||
const advisors = structuredClone(nexusAdvisorPlayer(g).advisors);
|
||||
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa', 'carthag']);
|
||||
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
|
||||
g = allowNexusAdvisorFlip(g);
|
||||
assert.deepEqual(nexusAdvisorPlayer(g).advisors, advisors);
|
||||
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(viewGame(g, 'p').nexusAdvisors!.pending, null);
|
||||
nexusAdvisorInventory(g);
|
||||
});
|
||||
|
||||
void test('saved live response exposes only its public selected groups and resumes exactly once', () => {
|
||||
let g = nexusAdvisorFixture({
|
||||
seatIds: ['bg-seat', 'guild-seat', 'fremen-seat'],
|
||||
});
|
||||
holdAdvisorKarama(g, 'guild-seat');
|
||||
const request = action(g);
|
||||
g = beginNexusAdvisorFlip(g, request.territories);
|
||||
const saved = nexusReload(g);
|
||||
const expected = viewGame(saved, 'bg-seat').nexusAdvisors!.pending;
|
||||
assert.ok(expected);
|
||||
for (const id of ['bg-seat', 'guild-seat', 'fremen-seat']) {
|
||||
const view = viewGame(saved, id);
|
||||
assert.deepEqual(view.nexusAdvisors!.pending, expected);
|
||||
for (const p of view.players)
|
||||
if (p.id !== id) assert.equal(p.hand, undefined);
|
||||
}
|
||||
assert.deepEqual(normalizeAutomaticGame(saved), saved);
|
||||
g = allowNexusAdvisorFlip(nexusReload(saved));
|
||||
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
||||
const done = nexusReload(g);
|
||||
assert.deepEqual(normalizeAutomaticGame(done), done);
|
||||
nexusReject(g, 'bg-seat', request);
|
||||
nexusAdvisorInventory(g);
|
||||
});
|
||||
|
||||
void test('foreign, stale, empty, duplicate, partial and non-advisor selections reject before any spend', () => {
|
||||
const g = nexusAdvisorFixture();
|
||||
const request = action(g);
|
||||
nexusReject(g, 'q', request);
|
||||
nexusReject(g, 'p', { ...request, event: 'old' });
|
||||
for (const territories of [
|
||||
[],
|
||||
['arrakeen', 'arrakeen'],
|
||||
['polar_sink'],
|
||||
['pasty_mesa:5'],
|
||||
['homeworld:beneGesserit'],
|
||||
])
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{ ...request, territories },
|
||||
/advisor|territor|Homeworld/i,
|
||||
);
|
||||
nexusReject(g, 'p', { ...request, amount: 1 });
|
||||
assert.equal(g.nexusCards!.cards!.hands.p, 'beneGesserit');
|
||||
});
|
||||
|
||||
void test('Cunning belongs to the current native Advanced shipment and movement action', () => {
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
g.active = 'q';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.phase = 6;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.phase = 8;
|
||||
},
|
||||
]) {
|
||||
const g = nexusAdvisorFixture();
|
||||
mutate(g);
|
||||
assert.ok(viewGame(g, 'p').nexusAdvisors!.offer!.blocked);
|
||||
nexusReject(g, 'p', action(g), /own Shipment and Movement/);
|
||||
}
|
||||
const basic = nexusAdvisorFixture({ advanced: false });
|
||||
const offer = viewGame(basic, 'p').nexusAdvisors!.offer;
|
||||
assert.ok(!offer || offer.blocked || !offer.territories.length);
|
||||
nexusReject(
|
||||
basic,
|
||||
'p',
|
||||
{
|
||||
type: 'nexusAdvisors',
|
||||
event: offer?.event ?? 'none',
|
||||
territories: ['arrakeen'],
|
||||
},
|
||||
/Advanced/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('fresh advisor locks and storm-locked strongholds remain guarded without partially converting an allowed group', () => {
|
||||
for (const scenario of ['lock', 'storm']) {
|
||||
const g = nexusAdvisorFixture();
|
||||
if (scenario === 'lock')
|
||||
nexusAdvisorPlayer(g).advisors!.arrakeen.lockedTurn = g.turn;
|
||||
else g.storm = 10;
|
||||
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
|
||||
assert.ok(
|
||||
offer.territories.find((t) => t.territory === 'arrakeen')!.blocked,
|
||||
);
|
||||
assert.equal(
|
||||
offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked,
|
||||
null,
|
||||
);
|
||||
nexusReject(g, 'p', action(g));
|
||||
assert.ok(nexusAdvisorPlayer(g).advisors!.pasty_mesa);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a third occupying faction blocks stronghold conversion while leaving other advisor groups available', () => {
|
||||
const g = nexusAdvisorFixture();
|
||||
const observer = g.players.find((p) => p.id === 'r')!;
|
||||
observer.forces['arrakeen:10'] = 1;
|
||||
observer.reserves--;
|
||||
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
|
||||
assert.ok(offer.territories.find((t) => t.territory === 'arrakeen')!.blocked);
|
||||
assert.equal(
|
||||
offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked,
|
||||
null,
|
||||
);
|
||||
nexusReject(g, 'p', action(g), /three occupying factions/);
|
||||
nexusAdvisorInventory(g);
|
||||
});
|
||||
|
||||
void test('already completed shipping and moving remain completed after a legal conversion before ending the action', () => {
|
||||
let g = nexusAdvisorFixture();
|
||||
nexusAdvisorPlayer(g).shipped = true;
|
||||
nexusAdvisorPlayer(g).moved = 1;
|
||||
const before = nexusReload(g);
|
||||
g = allowNexusAdvisorFlip(beginNexusAdvisorFlip(g, ['pasty_mesa']));
|
||||
assert.deepEqual(resources(g), resources(before));
|
||||
assert.equal(g.active, 'p');
|
||||
assert.equal(g.phase, 5);
|
||||
assert.deepEqual(nexusAdvisorPlayer(g).advisors, {
|
||||
arrakeen: {},
|
||||
carthag: {},
|
||||
});
|
||||
});
|
||||
|
||||
void test('missing response, edited original groups and altered movement-frame evidence reject reads and actions unchanged', () => {
|
||||
let g = nexusAdvisorFixture();
|
||||
holdAdvisorKarama(g);
|
||||
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
||||
for (const mutate of [
|
||||
(bad: Game) => {
|
||||
bad.response = null;
|
||||
},
|
||||
(bad: Game) => {
|
||||
bad.response!.owner = 'q';
|
||||
},
|
||||
(bad: Game) => {
|
||||
bad.response!.intent = 'other-event';
|
||||
},
|
||||
(bad: Game) => {
|
||||
delete bad.nexusAdvisorHistory;
|
||||
},
|
||||
(bad: Game) => {
|
||||
delete bad.nexusAdvisorLast;
|
||||
},
|
||||
(bad: Game) => {
|
||||
bad.nexusAdvisorHistory![0].receipt.selections[0].count++;
|
||||
},
|
||||
(bad: Game) => {
|
||||
bad.nexusAdvisorHistory![0].stage = 'completed';
|
||||
},
|
||||
(bad: Game) => {
|
||||
nexusAdvisorPlayer(bad).moved++;
|
||||
},
|
||||
(bad: Game) => {
|
||||
nexusAdvisorPlayer(bad).forces['arrakeen:10']++;
|
||||
nexusAdvisorPlayer(bad).reserves--;
|
||||
},
|
||||
]) {
|
||||
const bad = nexusReload(g);
|
||||
mutate(bad);
|
||||
const saved = JSON.stringify(bad);
|
||||
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
assert.throws(() => applyAction(bad, 'q', { type: 'passResponse' }));
|
||||
assert.equal(JSON.stringify(bad), saved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a real Truthtrance interruption preserves the pending advisor batch and its original cancellation response', () => {
|
||||
let g = nexusAdvisorFixture();
|
||||
holdAdvisorKarama(g);
|
||||
const index = g.deck.findIndex((card) => card.effect === 'truthtrance');
|
||||
assert.ok(index >= 0);
|
||||
const truth = g.deck.splice(index, 1)[0];
|
||||
g.players.find((p) => p.id === 'r')!.hand.push(truth);
|
||||
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
||||
const response = structuredClone(g.response);
|
||||
const record = structuredClone(g.nexusAdvisorHistory);
|
||||
g = applyAction(g, 'r', { type: 'card', card: truth.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, 'r', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: 'p',
|
||||
fact: { kind: 'hand', name: 'Shield' },
|
||||
},
|
||||
});
|
||||
g = applyAction(nexusReload(g), 'p', { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(g.truthtrance, null);
|
||||
assert.deepEqual(g.response, response);
|
||||
assert.deepEqual(g.nexusAdvisorHistory, record);
|
||||
g = allowNexusAdvisorFlip(g);
|
||||
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
||||
assert.equal(g.discard.filter((card) => card.id === truth.id).length, 1);
|
||||
nexusAdvisorInventory(g);
|
||||
});
|
||||
|
||||
void test('Harkonnen private Nexus exchange cannot consume or alter a pending advisor conversion', () => {
|
||||
let g = nexusAdvisorFixture({ opponentFaction: 'harkonnen' });
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = [
|
||||
'harkonnen',
|
||||
...cards.deck.filter((card) => card !== 'harkonnen'),
|
||||
];
|
||||
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
|
||||
holdAdvisorKarama(g, 'r');
|
||||
g = beginNexusAdvisorFlip(g, ['pasty_mesa']);
|
||||
const response = structuredClone(g.response);
|
||||
const record = structuredClone(g.nexusAdvisorHistory);
|
||||
const offer = viewGame(g, 'q').nexusTraitors!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'nexusTraitorDraw',
|
||||
event: offer.event,
|
||||
mode: offer.mode,
|
||||
});
|
||||
assert.deepEqual(g.nexusAdvisorHistory, record);
|
||||
nexusReject(g, 'r', { type: 'passResponse' }, /private Nexus card return/);
|
||||
const pending = g.nexusTraitorExchanges!.at(-1)!;
|
||||
g = applyAction(normalizeAutomaticGame(nexusReload(g)), 'q', {
|
||||
type: 'nexusTraitorReturn',
|
||||
event: pending.event,
|
||||
cards: [...pending.drawn],
|
||||
});
|
||||
assert.deepEqual(g.response, response);
|
||||
assert.deepEqual(g.nexusAdvisorHistory, record);
|
||||
g = allowNexusAdvisorFlip(g);
|
||||
assert.deepEqual(nexusAdvisorPlayer(g).advisors, {
|
||||
arrakeen: {},
|
||||
carthag: {},
|
||||
});
|
||||
assert.equal(g.nexusTraitorExchanges!.length, 1);
|
||||
assert.equal(g.nexusAdvisorHistory!.length, 1);
|
||||
nexusAdvisorInventory(g);
|
||||
});
|
||||
@@ -123,10 +123,12 @@ void test('the owned face highlights its mode from the public roster and all thr
|
||||
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
|
||||
const view = fixture();
|
||||
const html = markup(view, true);
|
||||
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning/);
|
||||
assert.match(html, /other effects remain unavailable/);
|
||||
assert.doesNotMatch(html, /other card effects are not playable yet/);
|
||||
assert.match(html, /10 in deck · 0 discarded · 2 held/);
|
||||
assert.match(html, /Waiting for Nexus choices: Harkonnen/);
|
||||
assert.match(html, /You do not hold a Nexus card/);
|
||||
assert.match(html, /effects are not playable yet/);
|
||||
assert.match(html, /<button[^>]*disabled=""[^>]*>Draw a Nexus card/);
|
||||
assert.match(html, /<button[^>]*disabled=""[^>]*>Skip this draw/);
|
||||
assert.doesNotMatch(html, />Play |Activate effect|Use Betrayal/);
|
||||
|
||||
Reference in new issue
Block a user