Implement Tleilaxu and Ixian Nexus Cunning with durable private recovery

This commit is contained in:
Quintine committed 2026-09-10 10:49:18 +10:00
1 parent d5830d260c
commit 40912498b1
32 files changed
+2761 -24

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { nexusTraitorFixture } from './fixture-nexus-traitors';
import { validateNexusCards } from '../game/nexus-cards';
export function suboidFixture(advanced = true, atreides = false) {
let g = nexusTraitorFixture({ ownerFaction: 'ixians', opponentFaction: atreides ? 'atreides' : 'guild', ix: true, advanced });
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('ixians'); assert.ok(index >= 0);
cards.deck[index] = cards.hands.p!; cards.hands.p = 'ixians';
Object.assign(g, { phase: 6, active: 'p', order: ['p', 'q', 'r'], ready: [], response: null, decision: null, phaseOpening: null });
for (const p of g.players) {
p.forces = {}; p.reserves = 20; p.tanks = 0;
if (p.elites) { p.elites.reserves = p.faction === 'ixians' ? 7 : 3; p.elites.tanks = 0; p.elites.forces = {}; }
}
for (const p of g.players.slice(0, 2)) {
p.forces = { 'pasty_mesa:5': 5, 'hagga_basin:12': 3 }; p.reserves = 12;
}
g.players[0].elites!.forces = { 'pasty_mesa:5': 2 }; g.players[0].elites!.reserves = 5;
g = applyAction(g, 'p', { type: 'chooseBattle', territory: 'pasty_mesa', target: 'q' });
for (const id of ['p', 'q']) if (g.battle!.preLeader && !g.battle!.preLeader.closed)
g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle!.event });
validateNexusCards(g.nexusCards!.cards!, g.players);
return g;
}
export function boostSuboids(g: Game) {
const offer = viewGame(g, 'p').nexusSuboids!.offer!;
assert.equal(offer.blocked, null);
return applyAction(g, 'p', { type: 'nexusSuboids', event: offer.event });
}
+22
View File
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import {
nexusTraitorFixture,
nexusTraitorInventory,
} from './fixture-nexus-traitors';
/** The final Tleilaxu roster and physical components come from genuine setup.
* Only the conserved held Nexus card and revealed-card checkpoint are staged. */
export function nexusFaceDancerFixture(revealed = 2) {
const g = nexusTraitorFixture({ ownerFaction: 'tleilaxu' });
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('tleilaxu');
assert.ok(index >= 0);
assert.equal(cards.hands.p, 'harkonnen');
cards.deck[index] = 'harkonnen';
cards.hands.p = 'tleilaxu';
g.players[0].faceDancers!.forEach((card, i) => {
card.revealed = i < revealed;
});
nexusTraitorInventory(g);
return g;
}
+281
View File
@@ -0,0 +1,281 @@
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 { suboidFixture } from './fixture-nexus-cunning';
import { nexusFaceDancerFixture } from './fixture-nexus-face-dancers';
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 suboidAction = (g: Game): Action => ({
type: 'nexusSuboids',
event: viewGame(g, 'p').nexusSuboids!.offer!.event,
});
const dancerAction = (g: Game): Action => ({
type: 'nexusFaceDancers',
event: viewGame(g, 'p').nexusTleilaxu!.cunning!.event,
});
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, 'nexusFaceDancerHistory'), false);
assert.equal(Object.hasOwn(actual, 'nexusSuboidHistory'), 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 Ixian Cunning plays persist one turn effect and spend one card in Basic and Advanced', async () => {
for (const advanced of [false, true]) {
const f = await fixture(suboidFixture(advanced));
try {
const action = suboidAction(f.initial),
g = await compete(f, action);
assert.equal(g.nexusSuboidHistory!.length, 1);
assert.equal(g.nexusSuboidLast!.event, g.nexusSuboidHistory![0].event);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'ixians').length,
1,
);
assert.equal(g.nexusCards!.cards!.hands.p, null);
assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFreeSupport, true);
assert.equal(viewGame(g, 'p').battle!.ownForces!.freeSupport, !advanced);
await restored(f, g);
const before = row(f);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock),
);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], g.version, action, clock),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
}
});
void test('SQLite lost Ixian response restores private hands and the same boosted plan in a later request', async () => {
const initial = suboidFixture(true);
for (const [i, kind] of [
[0, 'projectile'],
[1, 'shield'],
] as const) {
const index = initial.deck.findIndex((c) => c.kind === kind);
initial.players[i].hand.push(initial.deck.splice(index, 1)[0]);
}
const f = await fixture(initial);
try {
await f.rooms.act(
f.code,
f.auths[0],
f.initial.version,
suboidAction(f.initial),
clock,
);
let g = await f.restart().readRoom(f.code);
await restored(f, g);
const own = viewGame(g, 'p'),
other = viewGame(g, 'q');
assert.equal(own.players[0].hand![0].id, g.players[0].hand[0].id);
assert.equal(other.players[0].hand, undefined);
await f
.restart()
.act(
f.code,
f.auths[0],
g.version,
{
type: 'battlePlan',
dial: 3,
support: 0,
leader: g.players[0].leaders[0].id,
},
clock,
);
g = await f.restart().readRoom(f.code);
assert.equal(g.battle!.plans.p.dial, 3);
assert.equal(g.battle!.plans.p.support, 0);
assert.equal(g.nexusSuboidHistory!.length, 1);
assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFreeSupport, true);
assert.deepEqual(viewGame(g, 'q').battle!.plans, {});
await restored(f, g);
} finally {
f.sqlite.close();
}
});
void test('SQLite competing Face Dancer replacements commit one original draw and one shuffle, and lost responses cannot replay', async () => {
const f = await fixture(nexusFaceDancerFixture());
try {
const action = dancerAction(f.initial),
original = f.initial.players[0].faceDancers!,
drawn = f.initial.traitorReserve!.slice(0, 2);
const unrelated = await f.rooms.createRoom(
'Unrelated',
'atreides',
false,
[],
);
const otherRow = f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(unrelated.view.code);
f.writes.length = 0;
const g = await compete(f, action);
assert.equal(g.nexusFaceDancerHistory!.length, 1);
assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, drawn);
assert.deepEqual(g.players[0].faceDancers, [
original[2],
...drawn.map((leader) => ({ leader, revealed: false })),
]);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'tleilaxu').length,
1,
);
assert.equal(g.nexusCards!.cards!.hands.p, null);
for (const id of ['q', 'r'])
assert.equal(viewGame(g, id).players[0].faceDancers, undefined);
await restored(f, g);
const before = row(f);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock),
);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], g.version, action, clock),
);
assert.deepEqual(row(f), before);
assert.deepEqual(
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(unrelated.view.code),
otherRow,
);
} finally {
f.sqlite.close();
}
});
void test('SQLite rejects corrupted Cunning history and missing Suboid last marker without repairing or writing saves', async () => {
for (const kind of ['suboid', 'dancer'] as const) {
const f = await fixture(
kind === 'suboid' ? suboidFixture() : nexusFaceDancerFixture(),
);
try {
const action =
kind === 'suboid' ? suboidAction(f.initial) : dancerAction(f.initial);
await f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock);
const good = await f.rooms.readRoom(f.code);
const mutations: Array<(g: Game) => void> =
kind === 'suboid'
? [
(g) => {
delete g.nexusSuboidHistory;
},
(g) => {
delete g.nexusSuboidLast;
},
(g) => {
g.nexusSuboidHistory![0].turn++;
},
]
: [
(g) => {
g.nexusFaceDancerHistory![0].drawn.reverse();
},
(g) => {
g.nexusFaceDancerHistory![0].source.players[0].faceDancers![0].revealed = false;
},
];
for (const mutate of mutations) {
const bad = structuredClone(good);
mutate(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[0], bad.version, action, clock),
);
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
}
});
+368
View File
@@ -0,0 +1,368 @@
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 { DIFFICULTIES } from '../game/bot-profiles';
import {
nexusTraitorFixture,
nexusTraitorInventory,
nexusTraitorBattle,
} from './fixture-nexus-traitors';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
function fixture(
options: {
revealed?: number;
phase?: number;
opponentFaction?: 'atreides' | 'richese';
} = {},
): Game {
const g = nexusTraitorFixture({
ownerFaction: 'tleilaxu',
opponentFaction: options.opponentFaction,
phase: options.phase,
});
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('tleilaxu');
assert.ok(index >= 0);
assert.equal(cards.hands.p, 'harkonnen');
cards.deck[index] = 'harkonnen';
cards.hands.p = 'tleilaxu';
g.players[0].faceDancers!.forEach((card, i) => {
card.revealed = i < (options.revealed ?? 2);
});
nexusTraitorInventory(g);
return g;
}
function action(g: Game): Action {
const offer = viewGame(g, 'p').nexusTleilaxu!.cunning!;
assert.equal(offer.blocked, null);
return { type: 'nexusFaceDancers', event: offer.event };
}
function refresh(g: Game): Game {
return applyAction(g, 'p', action(g));
}
function hold(g: Game, owner: string, effect: string): string {
const source = g.richeseCache?.some((card) => card.effect === effect)
? g.richeseCache
: g.deck;
const index = source.findIndex((card) => card.effect === effect);
assert.ok(index >= 0);
const card = source.splice(index, 1)[0];
g.players.find((p) => p.id === owner)!.hand.push(card);
return card.id;
}
function reject(g: Game, id: string, attempted: Action): void {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, id, attempted));
assert.equal(JSON.stringify(g), before);
}
function inventory(g: Game): string[] {
return [
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...g.players.flatMap((p) => p.hand),
]
.map((c) => c.id)
.sort();
}
/** The leader identity moves between existing physical zones before the battle;
* actual battle resolution and the ordinary reveal create the revealed dancer. */
function actuallyReveal(): Game {
let g = fixture({ revealed: 0, phase: 6 });
const owner = g.players[0],
winner = g.players[1],
loser = g.players[2];
const leader = winner.leaders.reduce((best, next) =>
next.strength > best.strength ? next : best,
);
const opposing = loser.leaders.reduce((best, next) =>
next.strength < best.strength ? next : best,
);
assert.ok(leader.strength > opposing.strength);
if (!owner.faceDancers!.some((c) => c.leader === leader.id)) {
const displaced = owner.faceDancers![0].leader;
const index = g.traitorReserve!.indexOf(leader.id);
if (index >= 0) g.traitorReserve![index] = displaced;
else {
const holder = g.players.find((p) => p.traitors.includes(leader.id));
assert.ok(holder);
holder.traitors[holder.traitors.indexOf(leader.id)] = displaced;
}
owner.faceDancers![0].leader = leader.id;
}
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
}
for (const p of [winner, loser]) {
p.forces = { 'pasty_mesa:5': 3 };
p.reserves = 17;
}
g.active = 'q';
g.order = ['q', 'r', 'p'];
g = applyAction(g, 'q', {
type: 'chooseBattle',
territory: 'pasty_mesa',
target: 'r',
});
for (const id of ['q', 'r'])
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle!.event,
});
for (const [id, disc] of [
['q', leader.id],
['r', opposing.id],
])
g = applyAction(g, id, { type: 'battlePlan', leader: disc, dial: 0 });
for (const id of ['q', 'r'])
g = applyAction(g, id, { type: 'traitorCall', call: false });
assert.equal(g.decision?.kind, 'faceDance');
g = applyAction(g, 'p', {
type: 'decision',
reveal: true,
sources: { reserves: 1 },
sector: 5,
});
assert.equal(g.players[0].faceDancers!.filter((c) => c.revealed).length, 1);
nexusTraitorInventory(g);
return g;
}
void test('a real winning-leader Face Dance supplies the revealed card for an atomic Nexus refresh', () => {
const original = actuallyReveal();
const replaced = original.players[0].faceDancers!.find(
(c) => c.revealed,
)!.leader;
const next = original.traitorReserve![0];
const g = refresh(original);
assert.deepEqual(g.nexusFaceDancerHistory![0].replaced, [replaced]);
assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, [next]);
assert.ok(g.traitorReserve!.includes(replaced));
assert.ok(
g.players[0].faceDancers!.some((c) => c.leader === next && !c.revealed),
);
assert.equal(
g.players[0].faceDancers!.some((c) => c.leader === replaced),
false,
);
assert.equal(g.nexusCards!.cards!.hands.p, null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'tleilaxu').length,
1,
);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
nexusTraitorInventory(g);
});
void test('all revealed dancers refresh together off turn in multiple phases without a choice or acknowledgement', () => {
for (const phase of [2, 4, 5, 6, 8]) {
const original = fixture({ phase });
original.active = 'q';
const before = structuredClone(original.players[0].faceDancers!);
const drawn = original.traitorReserve!.slice(0, 2);
const g = refresh(original);
assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, drawn);
assert.deepEqual(g.players[0].faceDancers, [
before[2],
...drawn.map((leader) => ({ leader, revealed: false })),
]);
assert.equal(g.decision, null);
assert.equal(g.response, null);
assert.equal(g.active, 'q');
assert.equal(g.phase, phase);
nexusTraitorInventory(g);
}
});
void test('the shared-deck refresh preserves an actual native Prescience cancellation window', () => {
let g = fixture({ opponentFaction: 'atreides' });
hold(g, 'r', 'karama');
g = nexusTraitorBattle(g, false);
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
const before = nexusReload(g);
g = refresh(g);
assert.deepEqual(g.response, before.response);
assert.deepEqual(g.battle, before.battle);
g = nexusAllow(nexusReload(g));
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0 });
assert.equal(g.battle!.prescience!.value, 0);
nexusTraitorInventory(g);
});
void test('refreshing during an actual paid Nullentropy choice preserves its fee, search and original discard pile', () => {
let g = fixture({ opponentFaction: 'richese' });
const box = hold(g, 'q', 'nullentropyBox');
for (const kind of ['shield', 'projectile']) {
const index = g.deck.findIndex((c) => c.kind === kind);
assert.ok(index >= 0);
g.discard.push(g.deck.splice(index, 1)[0]);
}
g.players[1].spice = 10;
g = applyAction(g, 'q', { type: 'card', card: box });
assert.equal(g.decision?.kind, 'nullentropy');
const before = nexusReload(g),
allCards = inventory(g);
g = refresh(nexusReload(g));
assert.deepEqual(g.pendingNullentropy, before.pendingNullentropy);
assert.deepEqual(g.decision, before.decision);
assert.deepEqual(g.discard, before.discard);
assert.equal(g.players[1].spice, 8);
const selected = g.discard[0].id;
g = applyAction(nexusReload(g), 'q', {
type: 'decision',
event: g.pendingNullentropy!.event,
card: selected,
});
assert.equal(g.players[1].spice, 8);
assert.ok(g.players[1].hand.some((c) => c.id === selected));
assert.deepEqual(inventory(g), allCards);
});
void test('active Truthtrance blocks refresh without spending the card or exposing a new dancer', () => {
let g = fixture();
const request = action(g),
truth = hold(g, 'r', 'truthtrance');
g = applyAction(g, 'r', { type: 'card', card: truth });
assert.match(
viewGame(g, 'p').nexusTleilaxu!.cunning!.blocked!,
/Truthtrance/,
);
reject(g, 'p', request);
assert.equal(g.nexusFaceDancerHistory, undefined);
assert.equal(g.nexusCards!.cards!.hands.p, 'tleilaxu');
});
void test('ordinary Mentat replacement and Nexus refresh keep their independent usage in either order', () => {
for (const normalFirst of [false, true]) {
let g = fixture({ revealed: 1 });
const normal = (state: Game) =>
nexusAllow(
applyAction(state, 'p', {
type: 'replaceFaceDancer',
leader: state.players[0].faceDancers!.find((c) => !c.revealed)!
.leader,
}),
);
if (normalFirst) g = normal(g);
const used = g.players[0].faceDancerReplacedTurn;
g = refresh(g);
assert.equal(g.players[0].faceDancerReplacedTurn, used);
if (!normalFirst) g = normal(g);
assert.equal(g.players[0].faceDancerReplacedTurn, g.turn);
reject(g, 'p', {
type: 'replaceFaceDancer',
leader: g.players[0].faceDancers![0].leader,
});
nexusTraitorInventory(g);
}
});
void test('private replacement identities and history never enter another seat projection', () => {
const initial = fixture(),
changed = nexusReload(initial);
[changed.traitorReserve![0], changed.traitorReserve![2]] = [
changed.traitorReserve![2],
changed.traitorReserve![0],
];
const one = refresh(initial),
two = refresh(changed);
assert.notDeepEqual(
viewGame(one, 'p').players[0].faceDancers,
viewGame(two, 'p').players[0].faceDancers,
);
for (const id of ['q', 'r']) {
const a = viewGame(one, id),
b = viewGame(two, id);
assert.deepEqual(a.players, b.players);
assert.deepEqual(a.nexusTleilaxu, b.nexusTleilaxu);
assert.deepEqual(
a.log.map((entry) => entry.text),
b.log.map((entry) => entry.text),
);
assert.equal(a.players[0].faceDancers, undefined);
assert.equal(Object.hasOwn(a, 'nexusFaceDancerHistory'), false);
assert.equal(Object.hasOwn(a, 'traitorReserve'), false);
}
});
void test('stale, foreign, subset, duplicate and zero-revealed requests reject without mutation', () => {
const g = fixture(),
request = action(g);
reject(g, 'p', { ...request, event: 'old-event' });
reject(g, 'q', request);
reject(g, 'p', { ...request, cards: [g.players[0].faceDancers![0].leader] });
const done = refresh(g);
reject(done, 'p', request);
const empty = fixture({ revealed: 0 });
assert.match(
viewGame(empty, 'p').nexusTleilaxu!.cunning!.blocked!,
/No revealed/,
);
reject(empty, 'p', {
type: 'nexusFaceDancers',
event: viewGame(empty, 'p').nexusTleilaxu!.cunning!.event,
});
});
void test('saved replacement receipts reject corrupted identity, source, ownership and duplicate history', () => {
const done = refresh(fixture());
for (const mutate of [
(g: Game) => {
g.nexusFaceDancerHistory![0].drawn[0] = 'invented';
},
(g: Game) => {
g.nexusFaceDancerHistory![0].source.reserve.reverse();
},
(g: Game) => {
g.nexusFaceDancerHistory![0].owner = 'q';
},
(g: Game) => {
g.nexusFaceDancerHistory![0].turn++;
},
(g: Game) => {
g.nexusFaceDancerHistory!.push(
structuredClone(g.nexusFaceDancerHistory![0]),
);
},
]) {
const changed = nexusReload(done);
mutate(changed);
const before = JSON.stringify(changed);
for (const p of changed.players)
assert.throws(() => viewGame(changed, p.id));
assert.throws(() => normalizeAutomaticGame(changed));
assert.throws(() => applyAction(changed, 'p', { type: 'advanceBots' }));
assert.equal(JSON.stringify(changed), before);
}
});
void test('all four profiles perform the real atomic refresh using only their projected offer', () => {
const initial = fixture();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(initial, 'p');
view.players[0].bot = difficulty;
const before = structuredClone(view),
actions = botActions(view);
assert.deepEqual(view, before);
assert.equal(actions[0]?.type, 'nexusFaceDancers');
const g = applyAction(initial, 'p', actions[0]);
assert.equal(g.nexusFaceDancerHistory!.length, 1);
assert.equal(
g.players[0].faceDancers!.every((c) => !c.revealed),
true,
);
nexusTraitorInventory(g);
}
});
+269
View File
@@ -0,0 +1,269 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { leaders } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import {
validateNexusTraitorSnapshot,
type NexusTraitorSnapshot,
} from '../game/nexus-traitor-exchange';
import {
replaceNexusFaceDancers,
validateNexusFaceDancerHistory,
type NexusFaceDancerReceipt,
} from '../game/nexus-face-dancers';
const universe = traitorDeck(
['tleilaxu', 'harkonnen', 'guild'].map((f) => ({
leaders: leaders(f as 'tleilaxu' | 'harkonnen' | 'guild'),
})),
true,
);
const event = { event: 'tleilaxu-nexus-1', owner: 't', turn: 3, phase: 6 };
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
function fixture(revealed = [true, false, true]): NexusTraitorSnapshot {
return {
reserve: universe.slice(5),
players: [
{
id: 't',
faction: 'tleilaxu',
traitors: [],
faceDancers: universe
.slice(0, 3)
.map((leader, i) => ({ leader, revealed: revealed[i] })),
},
{ id: 'h', faction: 'harkonnen', traitors: [universe[3]] },
{ id: 'g', faction: 'guild', traitors: [universe[4]] },
],
};
}
void test('one, two or three revealed Face Dancers are replaced as a whole batch before their cards return to the reserve', () => {
for (const count of [1, 2, 3]) {
const state = fixture([0, 1, 2].map((i) => i < count)),
before = reload(state);
const result = replaceNexusFaceDancers(state, universe, event, () => 0.999);
assert.deepEqual(state, before);
assert.deepEqual(result.receipt.replaced, universe.slice(0, count));
assert.deepEqual(result.receipt.drawn, state.reserve.slice(0, count));
assert.deepEqual(result.state.reserve, [
...state.reserve.slice(count),
...universe.slice(0, count),
]);
assert.deepEqual(result.state.players[0].faceDancers, [
...state.players[0].faceDancers!.filter((c) => !c.revealed),
...state.reserve
.slice(0, count)
.map((leader) => ({ leader, revealed: false })),
]);
assert.deepEqual(result.state.players.slice(1), state.players.slice(1));
assert.equal(
result.state.players[0].faceDancers!.some((c) =>
result.receipt.replaced.includes(c.leader),
),
false,
);
validateNexusTraitorSnapshot(result.state, universe);
}
});
void test('replacement order preserves unrevealed originals and never offers a subset or returning new cards', () => {
const state = fixture([true, false, true]);
const result = replaceNexusFaceDancers(state, universe, event, () => 0);
assert.deepEqual(
result.state.players[0].faceDancers![0],
state.players[0].faceDancers![1],
);
assert.equal(result.state.players[0].faceDancers!.length, 3);
assert.equal(
result.state.players[0].faceDancers!.every((c) => c.revealed === false),
true,
);
assert.equal(result.receipt.stage, 'complete');
assert.throws(() =>
replaceNexusFaceDancers(
state,
universe,
{ ...event, cards: [universe[0]] } as typeof event,
() => 0,
),
);
assert.throws(() =>
replaceNexusFaceDancers(
result.state,
universe,
{ ...event, event: 'new-event' },
() => {
throw Error('No revealed cards must not shuffle');
},
),
);
});
void test('zero revealed, undersized reserve and wrong faction reject before randomness or mutation', () => {
const zero = fixture([false, false, false]);
const short = fixture();
short.players[1].traitors.push(...short.reserve.splice(1));
for (const [state, input] of [
[zero, event],
[short, event],
[fixture(), { ...event, owner: 'h' }],
[fixture(), { ...event, owner: 'missing' }],
] as Array<[NexusTraitorSnapshot, typeof event]>) {
const before = reload(state);
let calls = 0;
assert.throws(() =>
replaceNexusFaceDancers(state, universe, input, () => {
calls++;
return 0;
}),
);
assert.equal(calls, 0);
assert.deepEqual(state, before);
}
});
void test('even an exactly sized reserve draws entirely before set-aside cards are shuffled', () => {
const state = fixture();
state.players[1].traitors.push(...state.reserve.splice(2));
const drawn = [...state.reserve];
const result = replaceNexusFaceDancers(state, universe, event, () => 0);
assert.deepEqual(result.receipt.drawn, drawn);
assert.deepEqual(
[...result.state.reserve].sort(),
[universe[0], universe[2]].sort(),
);
assert.deepEqual(
result.state.players[0].faceDancers!.slice(1).map((c) => c.leader),
drawn,
);
validateNexusFaceDancerHistory(universe, result.receipt);
});
void test('validated injected randomness shuffles only the remaining reserve and old revealed identities', () => {
const state = fixture(),
before = reload(state);
let calls = 0;
const result = replaceNexusFaceDancers(state, universe, event, () => {
calls++;
return 0;
});
assert.equal(calls, state.reserve.length - 1);
assert.deepEqual(
[...result.state.reserve].sort(),
[...state.reserve.slice(2), universe[0], universe[2]].sort(),
);
assert.deepEqual(
result,
replaceNexusFaceDancers(state, universe, event, () => 0),
);
for (const invalid of [-1, 1, NaN, Infinity])
assert.throws(() =>
replaceNexusFaceDancers(state, universe, event, () => invalid),
);
assert.deepEqual(state, before);
});
void test('history validates JSON independently of later legitimate live custody and output mutations do not alias the receipt', () => {
const result = replaceNexusFaceDancers(
fixture(),
universe,
event,
() => 0.999,
);
const original = reload(result.receipt);
validateNexusFaceDancerHistory(universe, reload(result.receipt));
result.state.players[0].faceDancers![0].revealed = true;
[result.state.players[1].traitors[0], result.state.reserve[0]] = [
result.state.reserve[0],
result.state.players[1].traitors[0],
];
validateNexusTraitorSnapshot(result.state, universe);
validateNexusFaceDancerHistory(universe, result.receipt);
assert.deepEqual(result.receipt, original);
});
void test('changed whole-batch history, source statuses, original draw order and settled reserve are detected', () => {
const receipt = replaceNexusFaceDancers(
fixture(),
universe,
event,
() => 0.999,
).receipt;
for (const mutate of [
(r: NexusFaceDancerReceipt) => {
r.replaced.pop();
},
(r: NexusFaceDancerReceipt) => {
r.replaced.reverse();
},
(r: NexusFaceDancerReceipt) => {
r.drawn.reverse();
},
(r: NexusFaceDancerReceipt) => {
r.source.players[0].faceDancers![0].revealed = false;
},
(r: NexusFaceDancerReceipt) => {
r.source.reserve.reverse();
},
(r: NexusFaceDancerReceipt) => {
r.settledReserve.reverse();
},
(r: NexusFaceDancerReceipt) => {
r.event = 'other';
},
(r: NexusFaceDancerReceipt) => {
r.turn++;
},
(r: NexusFaceDancerReceipt) => {
r.phase++;
},
(r: NexusFaceDancerReceipt) => {
delete (r as Partial<NexusFaceDancerReceipt>).source;
},
(r: NexusFaceDancerReceipt) => {
Object.assign(r, { selected: [] });
},
]) {
const corrupt = reload(receipt);
mutate(corrupt);
const before = reload(corrupt);
assert.throws(() => validateNexusFaceDancerHistory(universe, corrupt));
assert.deepEqual(corrupt, before);
}
});
void test('corrupt physical census and invalid event metadata reject without invoking shuffle', () => {
for (const mutate of [
(s: NexusTraitorSnapshot) => {
s.reserve[0] = s.players[0].faceDancers![0].leader;
},
(s: NexusTraitorSnapshot) => {
s.players[0].traitors = [universe[0]];
},
(s: NexusTraitorSnapshot) => {
s.players[0].faceDancers!.pop();
},
(s: NexusTraitorSnapshot) => {
s.players[1].id = '__proto__';
},
]) {
const state = fixture();
mutate(state);
assert.throws(() =>
replaceNexusFaceDancers(state, universe, event, () => {
throw Error('Corrupt census must not shuffle');
}),
);
}
for (const change of [
{ event: '' },
{ turn: 0 },
{ turn: Infinity },
{ phase: -1 },
{ phase: 9 },
{ phase: 1.5 },
])
assert.throws(() =>
replaceNexusFaceDancers(
fixture(),
universe,
{ ...event, ...change },
() => {
throw Error('Invalid event must not shuffle');
},
),
);
});
+132
View File
@@ -0,0 +1,132 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nexusSuboidBotActions } from '../game/nexus-suboid-options';
import { casualtyOptions } from '../game/combat';
/** Authoritative projections isolate bot consumers; engine tests prove actual
* card spending and changes to physical battle strength. */
function fixture(): GameView {
const g = createGame('SUBOIDBOTS', newPlayer('i', 'Ixians', 'ixians'), true);
g.players.push(
newPlayer('h', 'Harkonnen', 'harkonnen'),
newPlayer('a', 'Atreides', 'atreides'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
active: 'i',
storm: 18,
order: ['i', 'h', 'a'],
});
for (const player of g.players.slice(0, 2)) {
player.forces = { 'arrakeen:10': 5 };
player.reserves = 15;
player.spice = 10;
}
g.players[0].elites!.forces = { 'arrakeen:10': 2 };
g.players[0].elites!.reserves = 5;
g.battle = {
event: 'suboid-battle',
territory: 'arrakeen',
attacker: 'i',
defender: 'h',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
const v = viewGame(g, 'i');
v.nexusCards = {
card: 'ixians',
deckCount: 11,
discardCount: 0,
held: { i: true, h: false, a: false },
turn: null,
choices: [],
waiting: [],
};
v.nexusSuboids = {
offer: { event: 'suboid-event', blocked: null },
active: false,
};
return v;
}
void test('all profiles use only the offered native Cunning before their battle plan', () => {
for (const difficulty of DIFFICULTIES) {
const v = fixture();
v.players[0].bot = difficulty;
const before = structuredClone(v);
assert.deepEqual(botActions(v), [
{ type: 'nexusSuboids', event: 'suboid-event' },
]);
assert.deepEqual(v, before);
v.nexusSuboids!.offer!.blocked =
'An earlier commitment would become impossible.';
assert.deepEqual(nexusSuboidBotActions(v), []);
v.nexusSuboids!.offer!.blocked = null;
v.battle!.submitted = ['i'];
assert.deepEqual(nexusSuboidBotActions(v), []);
v.battle!.submitted = [];
v.truthtrance = {
stage: 'priority',
queue: [],
passed: [],
question: null,
};
assert.deepEqual(botActions(v), [{ type: 'truthPass' }]);
}
});
void test('all profiles produce feasible boosted Suboid plans using projected support, including zero spice', () => {
for (const difficulty of DIFFICULTIES)
for (const spice of [0, 2]) {
const v = fixture();
v.players[0].bot = difficulty;
v.players[0].spice = spice;
v.nexusCards!.card = null;
v.nexusSuboids = { offer: null, active: true };
const forces = v.battle!.ownForces!;
forces.normalFixedHalf = false;
forces.normalFreeSupport = true;
const before = structuredClone(v);
const actions = botActions(v);
assert.ok(actions.length);
for (const action of actions) {
assert.equal(action.type, 'battlePlan');
assert.ok(Number(action.support) <= spice);
assert.ok(Number(action.support) <= forces.elite);
assert.ok(
casualtyOptions(forces, Number(action.dial), Number(action.support))
.length,
);
}
assert.deepEqual(v, before);
}
});
void test('native Cunning policy reads no unseen hands or opposing sealed plans', () => {
const v = fixture();
v.players[0].bot = 'Brutal';
for (const player of v.players)
for (const field of ['hand', 'traitors', 'faceDancers', 'spice'])
Object.defineProperty(player, field, {
get() {
throw new Error(`Private ${field} read`);
},
});
Object.defineProperty(v.battle!, 'plans', {
get() {
throw new Error('Read sealed plans');
},
});
assert.deepEqual(botActions(v), [
{ type: 'nexusSuboids', event: 'suboid-event' },
]);
v.me = 'h';
assert.deepEqual(nexusSuboidBotActions(v), []);
});
+163
View File
@@ -0,0 +1,163 @@
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 { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { NexusSuboids } from '../components/nexus-suboids';
import { nexusSuboidsAction } from '../game/nexus-suboid-options';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image'
? 'vinext/shims/image'
: specifier === 'next/link'
? 'vinext/shims/link'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
/** Synthetic private offer over an ordinary projected battle; source/custody
* execution belongs to the engine regression suite. CSS aliases preserve real
* component structure, without claiming browser or visual acceptance. */
function fixture(): GameView {
const g = createGame(
'SUBOIDCONTROLS',
newPlayer('i', 'Ixians', 'ixians'),
true,
);
g.players.push(
newPlayer('h', 'Harkonnen', 'harkonnen'),
newPlayer('a', 'Atreides', 'atreides'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
active: 'i',
storm: 18,
order: ['i', 'h', 'a'],
});
for (const player of g.players.slice(0, 2)) {
player.forces = { 'arrakeen:10': 5 };
player.reserves = 15;
player.spice = 10;
}
g.players[0].elites!.forces = { 'arrakeen:10': 2 };
g.players[0].elites!.reserves = 5;
g.battle = {
event: 'suboid-battle',
territory: 'arrakeen',
attacker: 'i',
defender: 'h',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
const v = viewGame(g, 'i');
v.nexusCards = {
card: 'ixians',
deckCount: 11,
discardCount: 0,
held: { i: true, h: false, a: false },
turn: null,
choices: [],
waiting: [],
};
v.nexusSuboids = {
offer: { event: 'suboid-event', blocked: null },
active: false,
};
return v;
}
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(NexusSuboids, { game, busy, act() {} }));
void test('Ixian Cunning binds an owned unsealed battle and authoritative commitment block', () => {
const v = fixture();
assert.deepEqual(nexusSuboidsAction(v, 'suboid-event'), {
type: 'nexusSuboids',
event: 'suboid-event',
});
assert.match(html(v), /rest of this turn/);
assert.match(html(v), /Cyborgs keep their usual strength and support rules/);
assert.doesNotMatch(html(v), /disabled=""/);
assert.match(html(v, true), /disabled=""/);
assert.equal(nexusSuboidsAction(v, 'stale'), null);
v.nexusSuboids!.offer!.blocked =
'The existing plan commitment must remain feasible.';
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
assert.match(html(v), /existing plan commitment/);
v.nexusSuboids!.offer!.blocked = null;
v.battle!.submitted = ['i'];
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
v.battle!.submitted = [];
v.phase = 5;
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
});
void test('public active notice identifies Ixian Suboids and never reads an observer’s hidden cards', () => {
const v = fixture();
v.nexusSuboids = { offer: null, active: true };
v.me = 'h';
v.nexusCards = null;
for (const player of v.players)
for (const key of ['hand', 'traitors', 'faceDancers'])
Object.defineProperty(player, key, {
get() {
throw new Error('Private card read');
},
});
const text = html(v);
assert.match(text, /Ixian Suboids fight at full strength/);
assert.doesNotMatch(text, /<button|your Suboids/);
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
v.nexusSuboids = null;
assert.equal(html(v), '');
});
void test('actual battle editor bounds paid support to Cyborgs when projected Suboids have free support', () => {
const v = fixture();
v.nexusSuboids = { offer: null, active: true };
const forces = v.battle!.ownForces!;
forces.normalFixedHalf = false;
forces.normalFreeSupport = true;
const render = () =>
renderToStaticMarkup(
createElement(GameTable, {
game: v,
send: async () => {},
onExit() {},
busy: false,
}),
);
const text = render();
const input = text.match(/<input[^>]*id="battle-spice"[^>]*>/)?.[0];
assert.ok(input);
assert.match(input, /max="2"/);
assert.match(text, /Spice support here applies only to Cyborgs/);
forces.elite = 0;
assert.match(
render().match(/<input[^>]*id="battle-spice"[^>]*>/)![0],
/max="0"/,
);
});
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame } from '../game/engine';
import { BattlePromises } from '../components/battle-promises';
import { suboidFixture } from './fixture-nexus-cunning';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
void test('a real future-strength Prescience commitment names Ixian Cunning in its private reachable preparation', () => {
let g = suboidFixture(true, true);
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 6 });
while (g.battle!.preparation)
g = applyAction(g, g.battle!.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
g = nexusReload(g);
const before = JSON.stringify(g);
const v = viewGame(g, 'p');
const actions = v.battle!.compliantPreparation!.actions;
assert.equal(actions[0].type, 'nexusSuboids');
assert.equal(g.nexusSuboidHistory, undefined);
assert.equal(g.nexusCards!.cards!.hands.p, 'ixians');
const text = renderToStaticMarkup(
createElement(BattlePromises, {
game: v,
fill() {},
act() {},
busy: false,
}),
);
assert.match(
text,
/Use Ixian Cunning to give Suboids full strength without spice support/,
);
assert.match(text, /Complete next preparation/);
assert.doesNotMatch(text, /Ghola|NaN/);
assert.equal(JSON.stringify(g), before);
for (const id of ['q', 'r'])
assert.equal(viewGame(g, id).battle!.compliantPreparation, null);
const boosted = applyAction(g, 'p', actions[0]);
assert.equal(boosted.battle!.prescience!.value, 6);
assert.equal(viewGame(boosted, 'p').nexusSuboids!.active, true);
assert.equal(viewGame(boosted, 'p').battle!.compliantPreparation, null);
});
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { boostSuboids, suboidFixture } from './fixture-nexus-cunning';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
function finishPreparation(state: Game): Game {
let g = state;
while (g.battle!.preparation)
g = applyAction(g, g.battle!.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
function submitPromisedDial(state: Game): Game {
let g = finishPreparation(boostSuboids(nexusReload(state)));
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 6,
support: 2,
leader: g.players.find((p) => p.id === 'p')!.leaders[0].id,
});
assert.equal(g.battle!.plans.p.dial, 6);
assert.equal(g.nexusSuboidHistory!.length, 1);
return g;
}
void test('Truthtrance can promise a dial reachable through the held Ixian Cunning after the answer', () => {
let g = suboidFixture();
const index = g.deck.findIndex((card) => card.effect === 'truthtrance');
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === 'q')!.hand.push(card);
g = applyAction(g, 'q', { type: 'card', card: card.id });
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = applyAction(g, 'q', {
type: 'truthAsk',
question: {
kind: 'battlePlan',
target: 'p',
territory: 'pasty_mesa',
claim: { kind: 'dial', compare: 'gte', value: 6 },
},
});
assert.equal(g.nexusCards!.cards!.hands.p, 'ixians');
assert.equal(g.nexusSuboidHistory, undefined);
g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' });
assert.equal(
g.nexusSuboidHistory,
undefined,
'Reachability must not spend the real card',
);
const done = submitPromisedDial(g);
assert.equal(done.battle!.truthPromises![0].answer, true);
assert.ok(!done.battle!.truthPromises![0].released);
});
void test('Prescience can bind a future full-strength Suboid dial without applying the grant during its search', () => {
let g = suboidFixture(true, true);
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 6 });
assert.equal(g.battle!.prescience!.value, 6);
assert.equal(g.nexusSuboidHistory, undefined);
assert.equal(viewGame(g, 'p').nexusSuboids!.active, false);
const done = submitPromisedDial(g);
assert.equal(done.battle!.prescience!.value, 6);
});
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { casualtyOptions } from '../game/combat';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { suboidFixture, boostSuboids } from './fixture-nexus-cunning';
import { nexusAllow, nexusReload, nexusReject } from './fixture-nexus-cards';
function prepare(g: Game) {
while (g.battle!.preparation) g = applyAction(g, g.battle!.preparation.owner, { type: 'declineBattlePower' });
if (g.decision?.kind === 'fullPlanOffer') g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
void test('Ixian Cunning supplies full free Suboid strength in Basic and Advanced without altering Cyborgs or custody', () => {
for (const advanced of [false, true]) {
let g = suboidFixture(advanced);
const before = structuredClone(g.players);
const ordinary = viewGame(g, 'p').battle!.ownForces!;
assert.equal(ordinary.normalFixedHalf, true);
g = boostSuboids(g);
const boosted = viewGame(g, 'p').battle!.ownForces!;
assert.equal(boosted.normalFixedHalf, false);
assert.equal(boosted.normalFreeSupport, true);
assert.equal(boosted.eliteStrength, ordinary.eliteStrength);
assert.equal(boosted.freeSupport, ordinary.freeSupport);
assert.deepEqual(g.players, before);
assert.ok(casualtyOptions(boosted, 3, 0).some(c => c.normal === 3 && c.elite === 0));
if (advanced) {
assert.equal(casualtyOptions(boosted, 7, 0).length, 0);
assert.ok(casualtyOptions(boosted, 7, 2).length);
assert.equal(casualtyOptions(boosted, 7, 3).length, 0);
}
for (const p of g.players) assert.equal(viewGame(g, p.id).nexusSuboids!.active, true);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
}
});
void test('Ixian Cunning keeps disclosed half-dial commitments binding and rejects impossible activation without spending the card', () => {
let g = suboidFixture(true, true);
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0.5 });
const offer = viewGame(g, 'p').nexusSuboids!.offer!;
assert.match(offer.blocked!, /commitment/);
nexusReject(g, 'p', { type: 'nexusSuboids', event: offer.event }, /commitment/);
assert.equal(g.nexusCards!.cards!.hands.p, 'ixians');
});
void test('Cunning is allowed after compatible Prescience and before own plan even when the opposing plan is sealed', () => {
let g = suboidFixture(true, true);
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' }); g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 1 });
g = prepare(g);
g = applyAction(g, 'q', { type: 'battlePlan', dial: 0, leader: g.players[1].leaders[0].id });
const sealed = structuredClone(g.battle!.plans.q);
g = boostSuboids(g);
assert.deepEqual(g.battle!.plans.q, sealed);
assert.equal(g.battle!.prescience!.value, 1);
g = applyAction(g, 'p', { type: 'battlePlan', dial: 1, support: 0, leader: g.players[0].leaders[0].id });
assert.equal(g.battle!.revealed, true);
});
void test('Suboid effect survives a second battle in the same turn and expires without discarding history next turn', () => {
let g = prepare(boostSuboids(suboidFixture()));
const first = g.battle!.event;
for (const id of ['p', 'q']) g = applyAction(g, id, { type: 'battlePlan', dial: 0, leader: g.players.find(p => p.id === id)!.leaders[0].id });
for (const id of ['p', 'q']) g = applyAction(g, id, { type: 'traitorCall', call: false });
for (let i = 0; g.battle && i < 25; i++) {
let acted = false;
for (const p of g.players) {
const view = viewGame(g, p.id); view.players.find(x => x.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0]; if (action) { g = applyAction(g, p.id, action); acted = true; break; }
}
assert.ok(acted, `Battle stalled at ${g.decision?.kind}/${g.response?.kind}`);
}
assert.equal(g.battle, null);
g = applyAction(g, 'p', { type: 'chooseBattle', territory: 'hagga_basin', target: 'q' });
assert.notEqual(g.battle!.event, first);
assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFreeSupport, true);
// Advancing only the public turn tests the receipt's lifetime independently
// of this staged second battle; no result or previous casualty is recomputed.
g.turn++;
assert.equal(viewGame(nexusReload(g), 'p').nexusSuboids!.active, false);
assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFixedHalf, true);
assert.equal(g.nexusSuboidHistory!.length, 1);
});
void test('stale, foreign, repeated and post-submission Cunning attempts reject unchanged', () => {
const initial = suboidFixture(), offer = viewGame(initial, 'p').nexusSuboids!.offer!;
nexusReject(initial, 'q', { type: 'nexusSuboids', event: offer.event }, /Cunning/);
nexusReject(initial, 'p', { type: 'nexusSuboids', event: 'old' }, /Cunning/);
nexusReject(initial, 'p', { type: 'nexusSuboids', event: offer.event, support: 0 }, /Cunning/);
const played = boostSuboids(initial);
nexusReject(played, 'p', { type: 'nexusSuboids', event: offer.event }, /Cunning/);
let sealed = prepare(initial);
sealed = applyAction(sealed, 'p', { type: 'battlePlan', dial: 0, leader: sealed.players[0].leaders[0].id });
nexusReject(sealed, 'p', { type: 'nexusSuboids', event: offer.event }, /submitted/);
});
void test('edited Suboid receipts reject reads, normalization and actions without changing the saved game', () => {
const original = boostSuboids(suboidFixture());
for (const mutate of [
(g: Game) => { delete g.nexusSuboidHistory; },
(g: Game) => { delete g.nexusSuboidLast; },
(g: Game) => { g.nexusSuboidHistory![0].turn++; },
(g: Game) => { g.nexusSuboidHistory![0].owner = 'q'; },
(g: Game) => { g.nexusSuboidHistory![0].battle = 'other'; },
(g: Game) => { g.nexusSuboidHistory!.push(structuredClone(g.nexusSuboidHistory![0])); },
]) {
const bad = nexusReload(original); mutate(bad); const before = JSON.stringify(bad);
assert.throws(() => viewGame(bad, 'p')); assert.throws(() => normalizeAutomaticGame(bad));
assert.throws(() => applyAction(bad, 'p', { type: 'advanceBots' })); assert.equal(JSON.stringify(bad), before);
}
});
void test('all four bot profiles use the actual Cunning offer and submit legal boosted plans', () => {
for (const level of DIFFICULTIES) {
let g = prepare(suboidFixture());
for (let i = 0; !g.battle!.plans.p && i < 8; i++) {
const v = viewGame(g, 'p'); v.players[0].bot = level;
const action = botActions(v)[0]; assert.ok(action); g = applyAction(g, 'p', action);
}
assert.equal(g.nexusSuboidHistory!.length, 1);
assert.ok(g.battle!.plans.p);
}
});
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nexusTleilaxuBotActions } from '../game/nexus-tleilaxu-options';
/** Synthetic authorized offer: this tests shared bot policy, not acquisition
* or source-effect execution, which belong to the engine suite. */
function fixture(): GameView {
const g = createGame('DANCERBOTS', newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.players.push(
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
active: 'a',
order: ['t', 'a', 'h'],
});
const v = viewGame(g, 't');
v.nexusCards = {
card: 'tleilaxu',
deckCount: 11,
discardCount: 0,
held: { t: true, a: false, h: false },
turn: null,
choices: [],
waiting: [],
};
v.nexusTleilaxu = {
cunning: { event: 'dancer-event', count: 1, blocked: null },
};
return v;
}
void test('every bot profile takes the complete offered replacement off turn without a choice handshake', () => {
for (const difficulty of DIFFICULTIES)
for (const count of [1, 2, 3]) {
const v = fixture();
v.players[0].bot = difficulty;
v.nexusTleilaxu!.cunning!.count = count;
v.decision = { kind: 'nullentropy', player: 'a' };
const before = structuredClone(v);
assert.deepEqual(botActions(v), [
{ type: 'nexusFaceDancers', event: 'dancer-event' },
]);
assert.deepEqual(v, before);
}
});
void test('all bot profiles preserve Truth, closing draw, automatic and preceding exchange priorities', () => {
for (const difficulty of DIFFICULTIES) {
const v = fixture();
v.players[0].bot = difficulty;
v.truthtrance = {
stage: 'priority',
queue: [],
passed: [],
question: null,
};
assert.deepEqual(botActions(v), [{ type: 'truthPass' }]);
v.truthtrance = null;
v.automaticContinuationPending = true;
assert.deepEqual(botActions(v), []);
v.automaticContinuationPending = false;
v.nexusCards!.waiting = ['a'];
assert.deepEqual(botActions(v), []);
v.nexusCards!.waiting = [];
v.nexusTraitors = {
offer: null,
pending: {
event: 'exchange',
owner: 'h',
mode: 'cunning',
count: 1,
choices: [],
},
};
assert.deepEqual(botActions(v), []);
v.nexusTraitors = null;
v.nexusTleilaxu!.cunning!.blocked = 'Cunning is unavailable.';
assert.deepEqual(nexusTleilaxuBotActions(v), []);
v.nexusTleilaxu = null;
assert.deepEqual(nexusTleilaxuBotActions(v), []);
}
});
void test('replacement policy never reads any private hand, traitor, dancer or spice field', () => {
const v = fixture();
v.players[0].bot = 'Brutal';
for (const player of v.players)
for (const field of ['hand', 'traitors', 'faceDancers', 'spice'])
Object.defineProperty(player, field, {
get() {
throw new Error(`Read private ${field}`);
},
});
assert.deepEqual(botActions(v), [
{ type: 'nexusFaceDancers', event: 'dancer-event' },
]);
v.me = 'a';
assert.deepEqual(nexusTleilaxuBotActions(v), []);
});
+133
View File
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { NexusTleilaxu } from '../components/nexus-tleilaxu';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
/** Explicit projected offers isolate presentation/consumer validation. These
* tests do not claim physical acquisition or replacement engine evidence. */
function fixture(): GameView {
const g = createGame(
'DANCERCONTROLS',
newPlayer('t', 'Tleilaxu', 'tleilaxu'),
);
g.players.push(
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
active: 'a',
order: ['t', 'a', 'h'],
});
const v = viewGame(g, 't');
v.nexusCards = {
card: 'tleilaxu',
deckCount: 11,
discardCount: 0,
held: { t: true, a: false, h: false },
turn: null,
choices: [],
waiting: [],
};
v.nexusTleilaxu = {
cunning: { event: 'dancer-event', count: 2, blocked: null },
};
return v;
}
function html(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(NexusTleilaxu, { game, busy, act() {} }),
);
}
void test('Cunning explains the ordered complete replacement and submits no selection or extra confirmation', () => {
const v = fixture();
const before = structuredClone(v);
assert.deepEqual(nexusFaceDancersAction(v, 'dancer-event'), {
type: 'nexusFaceDancers',
event: 'dancer-event',
});
const text = html(v);
assert.match(text, /secretly draw their replacements/);
assert.match(text, /Then shuffle the set-aside cards/);
assert.match(text, /unrevealed Face Dancers stay in your hand/);
assert.match(text, /Replace 2 revealed Face Dancers/);
assert.doesNotMatch(text, /type="checkbox"|<select|Confirm|disabled=""/);
assert.deepEqual(v, before);
assert.match(html(v, true), /disabled=""/);
});
void test('Cunning binds owner, held card, event and server block while preserving anytime decision access', () => {
const v = fixture();
v.decision = { kind: 'nullentropy', player: 'a' };
v.automaticContinuationPending = true;
assert.ok(nexusFaceDancersAction(v, 'dancer-event'));
assert.equal(nexusFaceDancersAction(v, 'stale'), null);
v.nexusTleilaxu!.cunning!.blocked = 'Finish the current Nexus return first.';
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
assert.match(html(v), /Finish the current Nexus return first/);
assert.match(html(v), /disabled=""/);
v.nexusTleilaxu!.cunning!.blocked = null;
v.nexusCards!.card = 'harkonnen';
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
v.nexusCards!.card = 'tleilaxu';
v.me = 'a';
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
v.me = 't';
v.players[0].ally = 'a';
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
});
void test('Cunning controls honor Truth and exchange priority and never inspect hidden identities', () => {
const v = fixture();
for (const p of v.players)
for (const key of ['traitors', 'faceDancers', 'hand'])
Object.defineProperty(p, key, {
get() {
throw new Error('Read a hidden identity');
},
});
assert.ok(nexusFaceDancersAction(v, 'dancer-event'));
assert.match(html(v), /Replace 2/);
v.truthtrance = { stage: 'priority', queue: [], passed: [], question: null };
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
v.truthtrance = null;
v.nexusCards!.waiting = ['a'];
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
v.nexusCards!.waiting = [];
v.nexusTraitors = {
offer: null,
pending: {
event: 'exchange',
owner: 'h',
mode: 'cunning',
count: 1,
choices: [],
},
};
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
v.nexusTraitors = null;
for (const count of [0, -1, 1.5, NaN]) {
v.nexusTleilaxu!.cunning!.count = count;
assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null);
}
v.nexusTleilaxu = null;
assert.equal(html(v), '');
});
void test('unavailable Tleilaxu modes are explained only from the viewer’s own held Nexus card', () => {
const v = fixture();
v.nexusTleilaxu = { cunning: null };
v.me = 'a';
assert.match(html(v), /Betrayal is unavailable while its private response timing is pending/);
v.players = v.players.filter((player) => player.faction !== 'tleilaxu');
assert.match(html(v), /Secret Ally revival is unavailable while its revival-limit ruling is pending/);
assert.doesNotMatch(html(v), /<button/);
v.nexusCards!.card = null;
assert.equal(html(v), '');
});
+239
View File
@@ -0,0 +1,239 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
casualtyOptions,
validCombatForces,
type CombatForces,
} from '../game/combat';
import {
stoneBurnerComparison,
stoneBurnerPlanBlock,
} from '../game/stone-burner';
import {
quoteBattleResolution,
type BattleResolutionInput,
} from '../game/battle-resolution-quote';
import { leaders } from '../game/cards';
const ix = (patch: Partial<CombatForces> = {}): CombatForces => ({
normal: 2,
elite: 1,
eliteStrength: 2,
freeSupport: false,
normalFixedHalf: true,
normalFreeSupport: true,
...patch,
});
void test('full-strength Suboids cost no support while Advanced Cyborgs retain their separate paid and unpaid strength', () => {
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 3, 1), [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 1 },
]);
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 3, 0), []);
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 2, 0), [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(ix({ normal: 2, elite: 0 }), 2, 0), [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(ix({ normal: 2, elite: 0 }), 2, 1), []);
assert.deepEqual(casualtyOptions(ix({ normal: 1 }), 3, 2), []);
});
void test('Basic free support and explicit elite exemption remain independent from the normal exemption', () => {
const basic = ix({ normal: 1, freeSupport: true });
assert.deepEqual(casualtyOptions(basic, 3, 0), [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(
casualtyOptions({ ...basic, normalFreeSupport: false }, 2.5, 0),
[{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 }],
);
assert.deepEqual(
casualtyOptions({ ...basic, normalFreeSupport: false }, 3, 0),
[],
);
assert.deepEqual(
casualtyOptions(ix({ normal: 1, eliteFreeSupport: true }), 3, 0),
[{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 }],
);
assert.deepEqual(
casualtyOptions(ix({ normal: 1, eliteFreeSupport: true }), 3, 1),
[],
);
});
void test('normal-free-support solver matches independent per-counter assignments for every small mixed pool', () => {
for (const normal of [0, 1, 2, 3])
for (const elite of [0, 1, 2])
for (const eliteStrength of [1, 2] as const)
for (const eliteFreeSupport of [false, true]) {
const expected = new Map<string, Set<string>>();
// Each normal token is either retained or lost at full strength for no cost.
for (let mask = 0; mask < 2 ** normal; mask++)
for (let code = 0; code < 3 ** elite; code++) {
let half = 0,
paid = 0,
n = 0,
e = 0,
remainder = code,
valid = true;
for (let i = 0; i < normal; i++)
if (mask & (1 << i)) {
half += 2;
n++;
}
for (let i = 0; i < elite; i++) {
const choice = remainder % 3;
remainder = Math.floor(remainder / 3);
if (!choice) continue;
e++;
if (eliteFreeSupport) {
if (choice === 2) {
valid = false;
break;
}
half += eliteStrength * 2;
} else {
half += eliteStrength * choice;
if (choice === 2) paid++;
}
}
if (!valid) continue;
const key = `${half}:${paid}`;
if (!expected.has(key)) expected.set(key, new Set());
expected.get(key)!.add(`${n}:${e}`);
}
const pool = ix({ normal, elite, eliteStrength, eliteFreeSupport });
for (
let half = 0;
half <= normal * 2 + elite * eliteStrength * 2;
half++
)
for (let support = 0; support <= elite + 1; support++) {
const actual = casualtyOptions(pool, half / 2, support);
assert.deepEqual(
new Set(actual.map((o) => `${o.normal}:${o.elite}`)),
expected.get(`${half}:${support}`) ?? new Set(),
`${normal}/${elite}/${eliteStrength}/${eliteFreeSupport}/${half}/${support}`,
);
assert.ok(
actual.every(
(o) => o.paidNormal === 0 && o.paidElite === support,
),
);
}
}
});
void test('Stone Burner compares actual survivors with the new normal strength and caches both profiles distinctly', () => {
const defender: CombatForces = {
normal: 1,
elite: 0,
eliteStrength: 1,
freeSupport: true,
};
assert.deepEqual(stoneBurnerComparison(ix(), 2, 0, defender, 0, 0), {
winner: 'attacker',
attacker: [1],
defender: [1],
});
assert.deepEqual(
stoneBurnerComparison(
ix({ normalFreeSupport: false }),
2,
0,
defender,
0,
0,
),
{ winner: 'defender', attacker: [0], defender: [1] },
);
const own = { ...defender, normal: 2 };
for (let repeat = 0; repeat < 2; repeat++) {
assert.match(
stoneBurnerPlanBlock(
own,
1,
0,
ix({ normalFreeSupport: false }),
'attacker',
)!,
/different legal force allocations/,
);
assert.equal(stoneBurnerPlanBlock(own, 1, 0, ix(), 'attacker'), null);
}
});
function resolution(advanced = true): BattleResolutionInput {
const a = [...leaders('ixians')].sort((x, y) => y.strength - x.strength)[0];
const d = [...leaders('guild')].sort((x, y) => x.strength - y.strength)[0];
return {
advanced,
turn: 2,
territory: 'pasty_mesa',
attacker: {
id: 'i',
faction: 'ixians',
spice: 3,
hand: [],
leader: a,
forces: ix({ normal: 1, freeSupport: !advanced }),
plan: {
dial: 3,
support: advanced ? 1 : 0,
leader: a.id,
weapon: null,
defense: null,
},
},
defender: {
id: 'g',
faction: 'guild',
spice: 0,
hand: [],
leader: d,
forces: { normal: 1, elite: 0, eliteStrength: 1, freeSupport: !advanced },
plan: { dial: 0, support: 0, leader: d.id, weapon: null, defense: null },
},
voters: [
{ id: 'i', beneficiary: 'i', called: false, traitors: [] },
{ id: 'g', beneficiary: 'g', called: false, traitors: [] },
],
participants: [
{ id: 'i', faction: 'ixians' },
{ id: 'g', faction: 'guild' },
],
physicalCards: [],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
void test('battle resolution charges only the Cyborg and preserves the typed force profile in winner casualties', () => {
for (const advanced of [false, true]) {
const input = resolution(advanced),
before = structuredClone(input),
q = quoteBattleResolution(input);
assert.equal(q.winner, 'i');
assert.equal(
q.payments.find((p) => p.player === 'i')?.cost ?? 0,
advanced ? 1 : 0,
);
assert.equal(q.casualties!.forces.normalFreeSupport, true);
assert.deepEqual(q.casualties!.options, [
{ normal: 1, elite: 1, paidNormal: 0, paidElite: advanced ? 1 : 0 },
]);
assert.deepEqual(input, before);
}
});
void test('malformed normal exemption flags reject in combat, Stone Burner and resolution without mutation', () => {
for (const flag of ['yes', 1, null]) {
const malformed = { ...ix(), normalFreeSupport: flag } as unknown as CombatForces;
assert.equal(validCombatForces(malformed), false);
assert.deepEqual(casualtyOptions(malformed, 3, 1), []);
assert.match(
stoneBurnerPlanBlock(malformed, 3, 1, ix(), 'attacker')!,
/valid supported physical/,
);
const input = resolution();
input.attacker.forces = malformed;
const before = structuredClone(input);
assert.throws(() => quoteBattleResolution(input), /valid force/);
assert.deepEqual(input, before);
}
});