Implement Bene Gesserit Nexus advisor conversion with durable responses

This commit is contained in:
Quintine committed 2026-09-10 11:13:08 +10:00
1 parent 40912498b1
commit b05125de9a
23 files changed
+2298 -16

No files matched your search

+92
View File
@@ -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;
+91
View File
@@ -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' });
}
});
+227
View File
@@ -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')));
});
+174
View File
@@ -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);
}
});
+289
View File
@@ -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);
});
+168
View File
@@ -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(); }
});
+340
View File
@@ -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);
});
+3 -1
View File
@@ -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/);