Prototype Emperor Nexus battle betrayal response

This commit is contained in:
Quintine committed 2026-09-27 19:29:50 +10:00
1 parent 361a1def43
commit 22a81c6f7a
10 files changed
+329 -6

No files matched your search

+8
View File
@@ -13,6 +13,7 @@ export function nexusSardaukarFixture(
opponentFaction?: 'guild' | 'atreides' | 'fremen';
normal?: number;
starred?: number;
emperorCardHolder?: string;
} = {},
): Game {
let g = nexusTraitorFixture({
@@ -35,6 +36,13 @@ export function nexusSardaukarFixture(
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner.id]!;
cards.hands[owner.id] = 'emperor';
if (options.emperorCardHolder) {
const holder = g.players.find((p) => p.id === options.emperorCardHolder);
assert.ok(holder && holder.id !== owner.id);
if (cards.hands[holder.id]) cards.deck.push(cards.hands[holder.id]!);
cards.hands[holder.id] = cards.hands[owner.id];
cards.hands[owner.id] = null;
}
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type GameView } from '../game/engine';
import { nexusSardaukarFixture } from './fixture-nexus-sardaukar';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
},
});
// NexusCards imports next/image; register the test-only Vinext alias before loading it.
const { NexusCards } = await import('../components/nexus-cards');
function markup(view: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusCards, { game: view, act() {}, busy }));
}
void test('only the private Emperor Betrayal holder can see its active battle control', () => {
const g = nexusSardaukarFixture({ starred: 2, normal: 3, emperorCardHolder: 'r' });
const holder = markup(viewGame(g, 'r'));
assert.match(holder, /Emperor Betrayal · Sardaukar/);
assert.match(holder, /Suppress Sardaukar with Betrayal/);
assert.doesNotMatch(markup(viewGame(g, 'p')), /Suppress Sardaukar with Betrayal/);
assert.doesNotMatch(markup(viewGame(g, 'q')), /Suppress Sardaukar with Betrayal/);
assert.match(markup(viewGame(g, 'r'), true), /disabled=""[^>]*>Suppress Sardaukar with Betrayal/);
});
aliases.deregister();
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unitStore } from './fixture-nexus-room-store';
import { nexusSardaukarFixture, nexusSardaukarInventory } from './fixture-nexus-sardaukar';
import { viewGame } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restart, seat privacy and lost acknowledgment preserve exactly one Emperor Betrayal', async (t) => {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Emperor Betrayal SQL', 'emperor', true, []);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
for (const [index, 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(['p', 'q', 'r'][index], code, auth.playerId);
}
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const g = nexusSardaukarFixture({ starred: 2, normal: 3, emperorCardHolder: 'r' });
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
const action = { type: 'nexusEmperorBetrayal', event: g.battle!.event };
for (const [index, auth] of auths.entries()) {
const view = await store.restart().readSeatView(code, auth);
assert.deepEqual(view, viewGame(g, ['p', 'q', 'r'][index]));
assert.equal(view.nexusEmperorBetrayal !== null, index === 2);
assert.equal('nexusEmperorBetrayalHistory' in view, false);
}
await assert.rejects(store.rooms.act(code, auths[1], g.version, action, clock));
assert.equal(store.writes.length, 0);
await store.rooms.act(code, auths[2], g.version, action, clock);
const committed = await store.restart().readRoom(code);
assert.equal(committed.version, g.version + 1);
assert.equal(committed.nexusEmperorBetrayalHistory?.length, 1);
assert.ok(committed.battle!.eliteBlocked?.includes('p'));
assert.equal(committed.nexusCards!.cards!.hands.r, null);
assert.equal(committed.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
nexusSardaukarInventory(committed);
const row = store.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(code);
await assert.rejects(store.restart().act(code, auths[2], g.version, action, clock));
await store.restart().continueRoomAutomatic(code, clock);
assert.deepEqual(store.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(code), row);
for (const [index, auth] of auths.entries()) {
const view = await store.restart().readSeatView(code, auth);
assert.deepEqual(view, viewGame(committed, ['p', 'q', 'r'][index]));
assert.equal(view.nexusEmperorBetrayal, null);
assert.equal('nexusEmperorBetrayalHistory' in view, false);
}
});
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { nexusSardaukarFixture, nexusSardaukarInventory } from './fixture-nexus-sardaukar';
import { maxCombatDial } from '../game/combat';
import { nexusReload } from './fixture-nexus-cards';
function battle(holder: 'r' | null = 'r') {
const g = nexusSardaukarFixture({ starred: 2, normal: 3, ...(holder ? { emperorCardHolder: holder } : {}) });
nexusSardaukarInventory(g);
assert.equal(g.response?.kind, 'eliteStrength');
assert.equal(g.response.owner, 'p');
return g;
}
function rejected(g: Game, id: string, action: Action) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, id, action));
assert.equal(JSON.stringify(g), before);
}
void test('Emperor battle response is a universal opportunity without identifying the hidden Nexus holder', () => {
const g = battle();
assert.equal(viewGame(g, 'p').nexusEmperorBetrayal, null);
assert.equal(viewGame(g, 'q').nexusEmperorBetrayal, null);
assert.equal(viewGame(g, 'r').nexusEmperorBetrayal?.event, g.battle!.event);
for (const id of ['p', 'q', 'r']) {
const publicView = viewGame(g, id);
assert.equal(publicView.response?.kind, 'eliteStrength');
assert.equal('nexusEmperorBetrayalHistory' in publicView, false);
}
let noHolder = battle(null);
for (const id of ['p', 'q']) noHolder = applyAction(noHolder, id, { type: 'passResponse' });
assert.equal(noHolder.response?.kind, 'eliteStrength');
noHolder = applyAction(noHolder, 'r', { type: 'passResponse' });
assert.notEqual(noHolder.response?.kind, 'eliteStrength');
const passed = applyAction(battle(), 'r', { type: 'passResponse' });
assert.equal(passed.response?.kind, 'eliteStrength');
assert.equal(viewGame(passed, 'r').nexusEmperorBetrayal, null);
rejected(passed, 'r', { type: 'nexusEmperorBetrayal', event: passed.battle!.event });
});
void test('Truthtrance priority suspends the battle reaction until the question resolves', () => {
const initial = battle();
const card = initial.deck.find((candidate) => candidate.effect === 'truthtrance')!;
initial.deck = initial.deck.filter((candidate) => candidate.id !== card.id);
initial.players[1].hand.push(card);
let g = applyAction(initial, 'q', { type: 'card', card: card.id });
assert.equal(g.truthtrance?.stage, 'priority');
assert.equal(viewGame(g, 'r').nexusEmperorBetrayal, null);
rejected(g, 'r', { type: 'nexusEmperorBetrayal', event: g.battle!.event });
while (g.truthtrance?.stage === 'priority')
g = applyAction(g, g.players.find((player) => !g.truthtrance!.passed.includes(player.id))!.id,
{ type: 'truthPass' });
g = applyAction(g, 'q', { type: 'truthAsk', question: {
kind: 'battlePlan', target: 'p', claim: { kind: 'dial', compare: 'gte', value: 6 },
} });
assert.equal(viewGame(g, 'r').nexusEmperorBetrayal, null);
rejected(g, 'r', { type: 'nexusEmperorBetrayal', event: g.battle!.event });
g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'no' });
assert.equal(g.truthtrance, null);
assert.equal(viewGame(g, 'r').nexusEmperorBetrayal?.event, g.battle!.event);
assert.ok(applyAction(g, 'r', { type: 'nexusEmperorBetrayal', event: g.battle!.event })
.battle!.eliteBlocked?.includes('p'));
});
void test('one spent Emperor Betrayal suppresses actual Sardaukar strength but retains physical counters after restore', () => {
const original = battle();
const action = { type: 'nexusEmperorBetrayal', event: original.battle!.event };
rejected(original, 'q', action);
rejected(original, 'p', action);
rejected(original, 'r', { ...action, event: 'stale' });
rejected(original, 'r', { ...action, invented: true });
const before = original.players[0].elites;
const g = applyAction(nexusReload(original), 'r', action);
assert.equal(g.nexusCards!.cards!.hands.r, null);
assert.ok(g.nexusCards!.cards!.discard.includes('emperor'));
assert.ok(g.battle!.eliteBlocked?.includes('p'));
assert.deepEqual(g.players[0].elites, before);
assert.equal(g.nexusEmperorBetrayalHistory?.length, 1);
nexusSardaukarInventory(g);
rejected(g, 'r', action);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
const after = viewGame(g, 'q');
assert.equal(after.nexusEmperorBetrayal, null);
assert.equal('nexusEmperorBetrayalHistory' in after, false);
});
void test('Betrayal changes supported dial legality, not actual Sardaukar custody', () => {
const initial = battle();
assert.equal(maxCombatDial(viewGame(initial, 'p').battle!.ownForces!), 7);
let g = applyAction(initial, 'r', {
type: 'nexusEmperorBetrayal', event: initial.battle!.event,
});
assert.equal(maxCombatDial(viewGame(g, 'p').battle!.ownForces!), 5);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
rejected(g, 'p', { type: 'battlePlan', leader: 'emperor-0', dial: 6, support: 5 });
const legal = applyAction(g, 'p', { type: 'battlePlan', leader: 'emperor-0', dial: 5, support: 5 });
assert.equal(legal.battle!.plans.p.dial, 5);
assert.equal(legal.players[0].elites!.forces['pasty_mesa:5'], 2);
});
void test('all bot tiers either play their private Betrayal or legally pass the shared response', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const g = battle();
g.players[2].bot = difficulty;
const action = botActions(viewGame(g, 'r'))[0];
assert.equal(action?.type, 'nexusEmperorBetrayal');
const absent = battle(null);
absent.players[2].bot = difficulty;
const pass = botActions(viewGame(absent, 'r'))[0];
assert.equal(pass?.type, 'passResponse');
}
});