Implement Tleilaxu and Ixian Nexus Cunning with durable private recovery
This commit is contained in:
1 parent
d5830d260c
commit
40912498b1
32 files changed
+2761
-24
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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), '');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user