Implement Emperor Nexus Cunning with physical casualty recovery
This commit is contained in:
1 parent
b05125de9a
commit
ba0b5647e1
27 files changed
+2487
-64
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusAllow } from './fixture-nexus-cards';
|
||||
|
||||
export function nexusSardaukarFixture(
|
||||
options: {
|
||||
seatIds?: [string, string, string];
|
||||
advanced?: boolean;
|
||||
opponentFaction?: 'guild' | 'atreides' | 'fremen';
|
||||
normal?: number;
|
||||
starred?: number;
|
||||
} = {},
|
||||
): Game {
|
||||
let g = nexusTraitorFixture({
|
||||
ownerFaction: 'emperor',
|
||||
opponentFaction:
|
||||
options.opponentFaction === 'fremen'
|
||||
? undefined
|
||||
: options.opponentFaction,
|
||||
advanced: options.advanced ?? true,
|
||||
seatIds: options.seatIds,
|
||||
phase: 6,
|
||||
});
|
||||
const owner = g.players[0],
|
||||
opponent = g.players[options.opponentFaction === 'fremen' ? 2 : 1];
|
||||
const normal = options.normal ?? 7,
|
||||
starred = options.starred ?? 0;
|
||||
assert.ok(normal >= 0 && normal <= 12 && starred >= 0 && starred <= 5);
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('emperor');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck[index] = cards.hands[owner.id]!;
|
||||
cards.hands[owner.id] = 'emperor';
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.tanks = 0;
|
||||
if (p.elites) {
|
||||
p.elites.forces = {};
|
||||
p.elites.tanks = 0;
|
||||
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
|
||||
}
|
||||
}
|
||||
owner.forces = { 'pasty_mesa:5': normal + starred, 'hagga_basin:12': 3 };
|
||||
owner.reserves = 17 - normal - starred;
|
||||
if (owner.elites) {
|
||||
owner.elites.reserves = 5 - starred;
|
||||
if (starred) owner.elites.forces = { 'pasty_mesa:5': starred };
|
||||
}
|
||||
opponent.forces = { 'pasty_mesa:5': 2, 'hagga_basin:12': 2 };
|
||||
opponent.reserves = 16;
|
||||
Object.assign(g, {
|
||||
active: owner.id,
|
||||
order: g.players.map((p) => p.id),
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
g = applyAction(g, owner.id, {
|
||||
type: 'chooseBattle',
|
||||
territory: 'pasty_mesa',
|
||||
target: opponent.id,
|
||||
});
|
||||
for (const id of [owner.id, opponent.id])
|
||||
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
|
||||
g = applyAction(g, id, {
|
||||
type: 'battlePreparationReady',
|
||||
event: g.battle!.event,
|
||||
});
|
||||
nexusSardaukarInventory(g);
|
||||
return g;
|
||||
}
|
||||
export const nexusSardaukarPlayer = (g: Game) =>
|
||||
g.players.find((p) => p.faction === 'emperor')!;
|
||||
export function nexusSardaukarInventory(g: Game): void {
|
||||
nexusTraitorInventory(g);
|
||||
const emperor = nexusSardaukarPlayer(g);
|
||||
if (!emperor.elites) {
|
||||
assert.equal(g.advanced, false);
|
||||
return;
|
||||
}
|
||||
assert.equal(
|
||||
emperor.elites!.reserves +
|
||||
emperor.elites!.tanks +
|
||||
Object.values(emperor.elites!.forces).reduce((sum, n) => sum + n, 0),
|
||||
5,
|
||||
);
|
||||
}
|
||||
export function prepareSardaukarBattle(g: Game): 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;
|
||||
}
|
||||
export function beginNexusSardaukar(g: Game): Game {
|
||||
const owner = nexusSardaukarPlayer(g);
|
||||
const offer = viewGame(g, owner.id).nexusSardaukar!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
return applyAction(g, owner.id, {
|
||||
type: 'nexusSardaukar',
|
||||
event: offer.event,
|
||||
});
|
||||
}
|
||||
export const allowNexusSardaukar = nexusAllow;
|
||||
@@ -123,7 +123,7 @@ void test('the owned face highlights its mode from the public roster and all thr
|
||||
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
|
||||
const view = fixture();
|
||||
const html = markup(view, true);
|
||||
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning/);
|
||||
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning/);
|
||||
assert.match(html, /other effects remain unavailable/);
|
||||
assert.doesNotMatch(html, /other card effects are not playable yet/);
|
||||
assert.match(html, /10 in deck · 0 discarded · 2 held/);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { casualtyOptions } from '../game/combat';
|
||||
import {
|
||||
nexusSardaukarFixture,
|
||||
nexusSardaukarInventory,
|
||||
beginNexusSardaukar,
|
||||
allowNexusSardaukar,
|
||||
prepareSardaukarBattle,
|
||||
} from './fixture-nexus-sardaukar';
|
||||
|
||||
void test('all profiles declare actual Cunning then seal a physically legal boosted plan after allowance', () => {
|
||||
const initial = nexusSardaukarFixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(initial, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
const before = structuredClone(v);
|
||||
const declaration = botActions(v)[0];
|
||||
assert.equal(declaration.type, 'nexusSardaukar');
|
||||
assert.deepEqual(v, before);
|
||||
const g = prepareSardaukarBattle(
|
||||
allowNexusSardaukar(applyAction(initial, 'p', declaration)),
|
||||
);
|
||||
const boosted = viewGame(g, 'p');
|
||||
boosted.players[0].bot = difficulty;
|
||||
const plans = botActions(boosted);
|
||||
assert.ok(plans.length);
|
||||
for (const plan of plans) {
|
||||
assert.equal(plan.type, 'battlePlan');
|
||||
const losses = casualtyOptions(
|
||||
boosted.battle!.ownForces!,
|
||||
Number(plan.dial),
|
||||
Number(plan.support),
|
||||
);
|
||||
assert.ok(losses.length);
|
||||
assert.ok(losses.every((loss) => loss.elite === 0 && loss.normal <= 7));
|
||||
}
|
||||
const sealed = applyAction(g, 'p', plans[0]);
|
||||
nexusSardaukarInventory(sealed);
|
||||
assert.deepEqual(sealed.players[0].elites, initial.players[0].elites);
|
||||
assert.equal(sealed.battle!.plans.p.dial, plans[0].dial);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Emperor offer policy never reads private cards or an opposing sealed plan', () => {
|
||||
const v = viewGame(nexusSardaukarFixture(), 'p');
|
||||
v.players[0].bot = 'Brutal';
|
||||
for (const p of v.players)
|
||||
for (const field of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Object.defineProperty(p, field, {
|
||||
get() {
|
||||
throw new Error(`Private ${field} read`);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(v.battle!, 'plans', {
|
||||
get() {
|
||||
throw new Error('Read sealed plan');
|
||||
},
|
||||
});
|
||||
assert.equal(botActions(v)[0].type, 'nexusSardaukar');
|
||||
});
|
||||
|
||||
void test('native enhancement uses ordinary Karama response policy before any boosted planning', () => {
|
||||
const initial = nexusSardaukarFixture();
|
||||
const index = initial.deck.findIndex((c) => c.effect === 'karama');
|
||||
const card = initial.deck.splice(index, 1)[0];
|
||||
initial.players[1].hand.push(card);
|
||||
const g = beginNexusSardaukar(initial);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(g, 'q');
|
||||
v.players.find((p) => p.id === 'q')!.bot = difficulty;
|
||||
const action = botActions(v)[0];
|
||||
assert.deepEqual(
|
||||
action,
|
||||
difficulty === 'Easy' || difficulty === 'Medium'
|
||||
? { type: 'passResponse' }
|
||||
: { type: 'card', card: card.id, mode: 'cancel' },
|
||||
);
|
||||
assert.equal(v.nexusSardaukar!.active, false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
nexusSardaukarFixture,
|
||||
nexusSardaukarInventory,
|
||||
beginNexusSardaukar,
|
||||
allowNexusSardaukar,
|
||||
prepareSardaukarBattle,
|
||||
} from './fixture-nexus-sardaukar';
|
||||
|
||||
const clock = { now: () => 10000, sleep: async () => {} };
|
||||
const choice = (index: number) => ({ type: 'decision', choice: index });
|
||||
|
||||
function resolvedVictory(): Game {
|
||||
let g = prepareSardaukarBattle(
|
||||
allowNexusSardaukar(beginNexusSardaukar(nexusSardaukarFixture())),
|
||||
);
|
||||
const [owner, opponent] = g.players;
|
||||
const leader = [...owner.leaders].sort((a, b) => b.strength - a.strength)[0];
|
||||
const opposing = [...opponent.leaders].sort(
|
||||
(a, b) => a.strength - b.strength,
|
||||
)[0];
|
||||
g = applyAction(g, owner.id, {
|
||||
type: 'battlePlan',
|
||||
dial: 6,
|
||||
support: 3,
|
||||
leader: leader.id,
|
||||
});
|
||||
g = applyAction(g, opponent.id, {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: opposing.id,
|
||||
});
|
||||
for (const id of [owner.id, opponent.id])
|
||||
g = applyAction(g, id, { type: 'traitorCall', call: false });
|
||||
assert.equal(g.battle, null);
|
||||
assert.equal(g.decision?.kind, 'battleLosses');
|
||||
assert.deepEqual(
|
||||
g.decision.options.map((loss) => [loss.normal, loss.elite]),
|
||||
[
|
||||
[3, 0],
|
||||
[4, 0],
|
||||
[5, 0],
|
||||
],
|
||||
);
|
||||
nexusSardaukarInventory(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const store = unitStore();
|
||||
const made = await store.rooms.createRoom(
|
||||
'Sardaukar casualties',
|
||||
'emperor',
|
||||
true,
|
||||
[],
|
||||
);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
// Bind disposable authentication seats to already-final fixture identities;
|
||||
// no game identity or signed source history is rewritten after setup.
|
||||
for (const [index, token] of tokens.entries()) {
|
||||
const auth = await store.rooms.authenticate(code, token);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?',
|
||||
)
|
||||
.run(['p', 'q', 'r'][index], code, auth.playerId);
|
||||
}
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
const initial = resolvedVictory();
|
||||
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, auths, initial };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
const row = (f: Fixture) =>
|
||||
f.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(f.code);
|
||||
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const before = row(f);
|
||||
assert.deepEqual(normalizeAutomaticGame(structuredClone(g)), g);
|
||||
for (const auth of f.auths) {
|
||||
const projected = await f.restart().readSeatView(f.code, auth);
|
||||
assert.deepEqual(projected, viewGame(g, auth.playerId));
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(projected),
|
||||
/nexusSardaukarHistory|nexusSardaukarCasualties/,
|
||||
);
|
||||
}
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusSardaukarInventory(g);
|
||||
}
|
||||
|
||||
void test('SQLite restores each committed Emperor casualty choice before and after ordinary-counter settlement', async () => {
|
||||
for (const [index, losses] of [3, 4, 5].entries()) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await restored(f, f.initial);
|
||||
await f
|
||||
.restart()
|
||||
.act(f.code, f.auths[0], f.initial.version, choice(index), clock);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.players[0].tanks, losses);
|
||||
assert.equal(done.players[0].forces['pasty_mesa:5'], 7 - losses);
|
||||
assert.equal(done.players[0].elites!.tanks, 0);
|
||||
assert.equal(done.players[0].elites!.reserves, 5);
|
||||
assert.equal(
|
||||
done.nexusSardaukarHistory![0].casualties!.outcome,
|
||||
'complete',
|
||||
);
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[0], done.version, choice(index), clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite competing different legal casualty allocations settle one physical group exactly once', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
let arrived = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrived === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const attempts = await Promise.allSettled(
|
||||
[0, 2].map((index) =>
|
||||
f
|
||||
.restart()
|
||||
.act(f.code, f.auths[0], f.initial.version, choice(index), clock),
|
||||
),
|
||||
);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(
|
||||
attempts.filter((result) => result.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, f.initial.version + 1);
|
||||
assert.ok([3, 5].includes(done.players[0].tanks));
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[0], f.initial.version, choice(0), clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite edited winner costs, effective roles or missing casualty proof reject every read and action without writes', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const edits: [string, (g: Game) => void][] = [
|
||||
[
|
||||
'available but illegal normal loss',
|
||||
(g) => {
|
||||
if (g.decision?.kind === 'battleLosses')
|
||||
g.decision.options[0].normal = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
'support allocation',
|
||||
(g) => {
|
||||
if (g.decision?.kind === 'battleLosses')
|
||||
g.decision.options[0].paidNormal = 0;
|
||||
},
|
||||
],
|
||||
[
|
||||
'effective temporary role',
|
||||
(g) => {
|
||||
delete g.nexusSardaukarHistory![0].casualties!.forces.temporaryElite;
|
||||
},
|
||||
],
|
||||
[
|
||||
'forged completion',
|
||||
(g) => {
|
||||
g.nexusSardaukarHistory![0].casualties!.outcome = 'complete';
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing choice',
|
||||
(g) => {
|
||||
g.decision = null;
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing casualty receipt',
|
||||
(g) => {
|
||||
delete g.nexusSardaukarHistory![0].casualties;
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing independent battle marker',
|
||||
(g) => {
|
||||
delete g.lastBattleContext!.nexusSardaukarCasualties;
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing history',
|
||||
(g) => {
|
||||
delete g.nexusSardaukarHistory;
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing history and latest outcome',
|
||||
(g) => {
|
||||
delete g.nexusSardaukarHistory;
|
||||
delete g.nexusSardaukarLast;
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [label, edit] of edits) {
|
||||
const bad = structuredClone(f.initial);
|
||||
edit(bad);
|
||||
const snapshot = JSON.stringify(bad);
|
||||
for (const auth of f.auths)
|
||||
assert.throws(() => viewGame(bad, auth.playerId), /./, label);
|
||||
assert.throws(() => normalizeAutomaticGame(bad), /./, label);
|
||||
assert.throws(() => applyAction(bad, 'p', choice(0)), /./, label);
|
||||
assert.equal(JSON.stringify(bad), snapshot, label);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(snapshot, 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),
|
||||
/./,
|
||||
label,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[0], bad.version, choice(0), clock),
|
||||
/./,
|
||||
label,
|
||||
);
|
||||
await f
|
||||
.restart()
|
||||
.continueRoomAutomatic(f.code, clock)
|
||||
.catch(() => {});
|
||||
assert.deepEqual(row(f), before, label);
|
||||
assert.equal(f.writes.length, 0, label);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
casualtyOptions,
|
||||
maxCombatDial,
|
||||
maxCombatSupport,
|
||||
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 pool = (patch: Partial<CombatForces> = {}): CombatForces => ({
|
||||
normal: 7,
|
||||
elite: 0,
|
||||
temporaryElite: 5,
|
||||
eliteStrength: 2,
|
||||
freeSupport: false,
|
||||
...patch,
|
||||
});
|
||||
|
||||
void test('temporary Sardaukar produce alternative ordinary casualties and pay only for physical counters', () => {
|
||||
const forces = pool(),
|
||||
before = structuredClone(forces);
|
||||
assert.deepEqual(casualtyOptions(forces, 2, 0), [
|
||||
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
|
||||
{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
assert.deepEqual(casualtyOptions(forces, 2, 1), [
|
||||
{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 },
|
||||
{ normal: 2, elite: 0, paidNormal: 1, paidElite: 0 },
|
||||
]);
|
||||
assert.deepEqual(casualtyOptions(forces, 12, 7), [
|
||||
{ normal: 7, elite: 0, paidNormal: 7, paidElite: 0 },
|
||||
]);
|
||||
assert.equal(maxCombatDial(forces), 12);
|
||||
assert.equal(maxCombatSupport(forces), 7);
|
||||
assert.deepEqual(forces, before);
|
||||
});
|
||||
|
||||
void test('Salusa support, ordinary free support and canceled Sardaukar strength retain distinct role arithmetic', () => {
|
||||
const salusa = pool({ eliteFreeSupport: true });
|
||||
assert.equal(maxCombatSupport(salusa), 2);
|
||||
assert.equal(maxCombatDial(salusa), 12);
|
||||
assert.deepEqual(casualtyOptions(salusa, 12, 2), [
|
||||
{ normal: 7, elite: 0, paidNormal: 2, paidElite: 0 },
|
||||
]);
|
||||
assert.deepEqual(casualtyOptions(salusa, 12, 7), []);
|
||||
const normalFree = pool({ normalFreeSupport: true });
|
||||
assert.equal(maxCombatSupport(normalFree), 5);
|
||||
assert.deepEqual(casualtyOptions(normalFree, 12, 5), [
|
||||
{ normal: 7, elite: 0, paidNormal: 5, paidElite: 0 },
|
||||
]);
|
||||
const suppressed = pool({ eliteStrength: 1 });
|
||||
assert.equal(maxCombatDial(suppressed), 7);
|
||||
for (let half = 0; half <= 14; half++)
|
||||
for (let support = 0; support <= 7; support++) {
|
||||
assert.deepEqual(
|
||||
casualtyOptions(suppressed, half / 2, support),
|
||||
casualtyOptions(
|
||||
{ ...suppressed, temporaryElite: undefined },
|
||||
half / 2,
|
||||
support,
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.equal(maxCombatSupport(pool({ freeSupport: true })), 0);
|
||||
assert.deepEqual(casualtyOptions(pool({ freeSupport: true }), 12, 0), [
|
||||
{ normal: 7, elite: 0, paidNormal: 0, paidElite: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
/** Independent labeled-counter choices: survive, die unsupported, die supported. */
|
||||
function assignments(forces: CombatForces) {
|
||||
let states = new Set(['0:0:0']);
|
||||
for (let counter = 0; counter < forces.normal; counter++) {
|
||||
const temporary = counter < (forces.temporaryElite ?? 0);
|
||||
const strength = temporary ? forces.eliteStrength : 1;
|
||||
const free =
|
||||
forces.freeSupport ||
|
||||
(temporary ? forces.eliteFreeSupport : forces.normalFreeSupport);
|
||||
const fixedHalf =
|
||||
!temporary && forces.normalFixedHalf && !forces.normalFreeSupport;
|
||||
const choices = [
|
||||
[0, 0, 0],
|
||||
[1, strength * (free && !fixedHalf ? 2 : 1), 0],
|
||||
];
|
||||
if (!free && !fixedHalf) choices.push([1, strength * 2, 1]);
|
||||
const next = new Set<string>();
|
||||
for (const previous of states) {
|
||||
const [loss, half, paid] = previous.split(':').map(Number);
|
||||
for (const [n, h, p] of choices)
|
||||
next.add(`${loss + n}:${half + h}:${paid + p}`);
|
||||
}
|
||||
states = next;
|
||||
}
|
||||
return [...states].map((state) => state.split(':').map(Number));
|
||||
}
|
||||
|
||||
void test('mapped losses match independent counter enumeration across mixed roles and every support exemption', () => {
|
||||
for (const normal of [0, 1, 2, 4, 6])
|
||||
for (let temporaryElite = 0; temporaryElite <= normal; temporaryElite++)
|
||||
for (const eliteStrength of [1, 2] as const)
|
||||
for (const flags of [
|
||||
{},
|
||||
{ eliteFreeSupport: true },
|
||||
{ normalFreeSupport: true },
|
||||
{ normalFixedHalf: true },
|
||||
{ normalFixedHalf: true, normalFreeSupport: true },
|
||||
{ freeSupport: true },
|
||||
{ freeSupport: true, normalFixedHalf: true },
|
||||
{ eliteFreeSupport: true, normalFreeSupport: true },
|
||||
]) {
|
||||
const forces = pool({
|
||||
normal,
|
||||
temporaryElite,
|
||||
eliteStrength,
|
||||
...flags,
|
||||
});
|
||||
const expected = assignments(forces);
|
||||
assert.equal(
|
||||
maxCombatDial(forces),
|
||||
Math.max(...expected.map(([, half]) => half)) / 2,
|
||||
);
|
||||
assert.equal(
|
||||
maxCombatSupport(forces),
|
||||
Math.max(...expected.map(([, , paid]) => paid)),
|
||||
);
|
||||
for (let half = 0; half <= normal * 4; half++)
|
||||
for (let support = 0; support <= normal; support++) {
|
||||
const losses = expected
|
||||
.filter(([, h, p]) => h === half && p === support)
|
||||
.map(([n]) => n)
|
||||
.sort((a, b) => a - b);
|
||||
assert.deepEqual(
|
||||
casualtyOptions(forces, half / 2, support),
|
||||
losses.map((normal) => ({
|
||||
normal,
|
||||
elite: 0,
|
||||
paidNormal: support,
|
||||
paidElite: 0,
|
||||
})),
|
||||
JSON.stringify([forces, half, support]),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Stone Burner compares physical survivors and separates temporary-role cache entries', () => {
|
||||
const own: CombatForces = {
|
||||
normal: 4,
|
||||
elite: 0,
|
||||
eliteStrength: 1,
|
||||
freeSupport: true,
|
||||
};
|
||||
const temporary = pool({ normal: 3, temporaryElite: 2 });
|
||||
const ordinary = { ...temporary, temporaryElite: 0 };
|
||||
assert.deepEqual(stoneBurnerComparison(temporary, 2, 0, own, 2, 0), {
|
||||
winner: 'defender',
|
||||
attacker: [1],
|
||||
defender: [2],
|
||||
});
|
||||
assert.deepEqual(stoneBurnerComparison(temporary, 2, 1, own, 2, 0), {
|
||||
winner: null,
|
||||
attacker: [1, 2],
|
||||
defender: [2],
|
||||
});
|
||||
for (let repeat = 0; repeat < 3; repeat++) {
|
||||
assert.equal(stoneBurnerPlanBlock(own, 3, 0, ordinary, 'attacker'), null);
|
||||
assert.match(
|
||||
stoneBurnerPlanBlock(own, 3, 0, temporary, 'attacker')!,
|
||||
/different legal force allocations/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function resolution(): BattleResolutionInput {
|
||||
const a = leaders('emperor')[0],
|
||||
d = leaders('guild')[0];
|
||||
return {
|
||||
advanced: true,
|
||||
turn: 2,
|
||||
territory: 'pasty_mesa',
|
||||
attacker: {
|
||||
id: 'e',
|
||||
faction: 'emperor',
|
||||
spice: 7,
|
||||
hand: [],
|
||||
leader: a,
|
||||
forces: pool(),
|
||||
plan: { dial: 12, support: 7, 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: false },
|
||||
plan: { dial: 0, support: 0, leader: d.id, weapon: null, defense: null },
|
||||
},
|
||||
voters: [
|
||||
{ id: 'e', beneficiary: 'e', called: false, traitors: [] },
|
||||
{ id: 'g', beneficiary: 'g', called: false, traitors: [] },
|
||||
],
|
||||
participants: [
|
||||
{ id: 'e', faction: 'emperor' },
|
||||
{ id: 'g', faction: 'guild' },
|
||||
],
|
||||
physicalCards: [],
|
||||
pendingAuditorPresent: false,
|
||||
pendingRetentionPresent: false,
|
||||
};
|
||||
}
|
||||
|
||||
void test('battle quote retains the physical pool, ordinary casualties and exact support payment', () => {
|
||||
const input = resolution(),
|
||||
before = structuredClone(input),
|
||||
quote = quoteBattleResolution(input);
|
||||
assert.equal(quote.winner, 'e');
|
||||
assert.equal(quote.payments.find((p) => p.player === 'e')!.cost, 7);
|
||||
assert.deepEqual(quote.casualties!.forces, input.attacker.forces);
|
||||
assert.deepEqual(quote.casualties!.options, [
|
||||
{ normal: 7, elite: 0, paidNormal: 7, paidElite: 0 },
|
||||
]);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
|
||||
void test('invalid temporary populations reject before calculation without changing physical custody', () => {
|
||||
for (const temporaryElite of [-1, 0.5, 8, 21, NaN, Infinity, '5', null]) {
|
||||
const forces = { ...pool(), temporaryElite } as unknown as CombatForces;
|
||||
assert.equal(validCombatForces(forces), false);
|
||||
assert.deepEqual(casualtyOptions(forces, 2, 0), []);
|
||||
assert.equal(maxCombatDial(forces), 0);
|
||||
assert.equal(maxCombatSupport(forces), 0);
|
||||
assert.match(
|
||||
stoneBurnerPlanBlock(forces, 2, 0, pool(), 'attacker')!,
|
||||
/valid supported physical/,
|
||||
);
|
||||
const input = resolution();
|
||||
input.attacker.forces = forces;
|
||||
const before = structuredClone(input);
|
||||
assert.throws(() => quoteBattleResolution(input), /valid force/);
|
||||
assert.deepEqual(input, before);
|
||||
}
|
||||
assert.equal(validCombatForces(pool({ elite: 1 })), false);
|
||||
assert.equal(validCombatForces(pool({ normal: 21 })), false);
|
||||
assert.equal(
|
||||
validCombatForces(pool({ normal: 20, temporaryElite: 20 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(maxCombatDial(pool({ normal: 20, temporaryElite: 20 })), 40);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
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 { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import { BattlePromises } from '../components/battle-promises';
|
||||
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
||||
import { NexusSardaukar } from '../components/nexus-sardaukar';
|
||||
import { nexusSardaukarAction } from '../game/nexus-sardaukar-options';
|
||||
import {
|
||||
nexusSardaukarFixture,
|
||||
beginNexusSardaukar,
|
||||
allowNexusSardaukar,
|
||||
prepareSardaukarBattle,
|
||||
} from './fixture-nexus-sardaukar';
|
||||
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();
|
||||
const html = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(NexusSardaukar, { game, busy, act() {} }));
|
||||
|
||||
void test('Emperor Cunning offers one event-bound five-counter play with explicit physical identity', () => {
|
||||
const v = viewGame(nexusSardaukarFixture(), 'p');
|
||||
const offer = v.nexusSardaukar!.offer!;
|
||||
assert.deepEqual(nexusSardaukarAction(v, offer.event), {
|
||||
type: 'nexusSardaukar',
|
||||
event: offer.event,
|
||||
});
|
||||
assert.match(html(v), /Use five forces as Sardaukar/);
|
||||
assert.match(html(v), /ordinary physical identity, including when lost/);
|
||||
assert.doesNotMatch(html(v), /disabled=""|checkbox/);
|
||||
assert.match(html(v, true), /disabled=""/);
|
||||
assert.equal(nexusSardaukarAction(v, 'stale'), null);
|
||||
offer.blocked = 'An earlier Battle Plan commitment would become impossible.';
|
||||
assert.equal(nexusSardaukarAction(v, offer.event), null);
|
||||
assert.match(html(v), /earlier Battle Plan commitment/);
|
||||
});
|
||||
|
||||
void test('Emperor control retains actor, Advanced, unsealed plan and interruption fences', () => {
|
||||
const initial = viewGame(nexusSardaukarFixture(), 'p');
|
||||
for (const mutate of [
|
||||
(v: GameView) => {
|
||||
v.me = 'q';
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.advanced = false;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.phase = 5;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.nexusCards!.card = null;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.battle!.submitted = ['p'];
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.truthtrance = {
|
||||
stage: 'priority',
|
||||
queue: [],
|
||||
passed: [],
|
||||
question: null,
|
||||
};
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.automaticContinuationPending = true;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.nexusCards!.waiting = ['q'];
|
||||
},
|
||||
]) {
|
||||
const v = structuredClone(initial);
|
||||
mutate(v);
|
||||
assert.equal(nexusSardaukarAction(v, v.nexusSardaukar!.offer!.event), null);
|
||||
}
|
||||
for (const options of [{ normal: 4 }, { starred: 1 }]) {
|
||||
const v = viewGame(nexusSardaukarFixture(options), 'p');
|
||||
const offer = v.nexusSardaukar!.offer!;
|
||||
assert.ok(offer.blocked);
|
||||
assert.equal(nexusSardaukarAction(v, offer.event), null);
|
||||
assert.ok(html(v).includes(offer.blocked));
|
||||
}
|
||||
});
|
||||
|
||||
void test('public pending and active notices avoid private hands and identify ordinary-counter losses', () => {
|
||||
const initial = nexusSardaukarFixture();
|
||||
const index = initial.deck.findIndex((c) => c.effect === 'karama');
|
||||
initial.players[1].hand.push(initial.deck.splice(index, 1)[0]);
|
||||
const g = beginNexusSardaukar(initial);
|
||||
assert.equal(g.response?.kind, 'nexusSardaukar');
|
||||
for (const id of ['p', 'q', 'r']) {
|
||||
const v = viewGame(g, id);
|
||||
assert.equal(v.nexusSardaukar!.active, false);
|
||||
assert.equal(v.battle!.ownForces?.temporaryElite, undefined);
|
||||
assert.equal(v.battle!.opponentForces?.temporaryElite, undefined);
|
||||
Object.defineProperty(v.nexusSardaukar!, 'offer', {
|
||||
get() {
|
||||
throw new Error('Private offer read');
|
||||
},
|
||||
});
|
||||
for (const p of v.players)
|
||||
Object.defineProperty(p, 'hand', {
|
||||
get() {
|
||||
throw new Error('Private hand read');
|
||||
},
|
||||
});
|
||||
assert.match(html(v), /Karama may cancel the entire enhancement/);
|
||||
}
|
||||
const allowed = allowNexusSardaukar(g);
|
||||
const observer = viewGame(allowed, 'q');
|
||||
assert.equal(observer.battle!.opponentForces?.temporaryElite, 5);
|
||||
assert.match(html(observer), /Five ordinary Emperor counters/);
|
||||
assert.match(html(observer), /ordinary Tanks/);
|
||||
assert.doesNotMatch(html(observer), /<button/);
|
||||
});
|
||||
|
||||
void test('actual battle editor uses temporary strength twelve and physical support seven without starred losses', () => {
|
||||
const g = prepareSardaukarBattle(
|
||||
allowNexusSardaukar(beginNexusSardaukar(nexusSardaukarFixture())),
|
||||
);
|
||||
const v = viewGame(g, 'p');
|
||||
assert.equal(v.battle!.ownForces!.normal, 7);
|
||||
assert.equal(v.battle!.ownForces!.elite, 0);
|
||||
assert.equal(v.battle!.ownForces!.temporaryElite, 5);
|
||||
const text = renderToStaticMarkup(
|
||||
createElement(GameTable, {
|
||||
game: v,
|
||||
send: async () => {},
|
||||
onExit() {},
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
assert.match(text, /max="12"/);
|
||||
assert.match(text.match(/<input[^>]*id="battle-spice"[^>]*>/)![0], /max="7"/);
|
||||
assert.match(
|
||||
text,
|
||||
/Their losses are ordinary counters, not starred Sardaukar/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('real future Prescience preparation names Emperor Cunning without spending it during the search', () => {
|
||||
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
|
||||
const cardIndex = g.deck.findIndex((card) => card.effect === 'karama');
|
||||
assert.ok(cardIndex >= 0);
|
||||
g.players[1].hand.push(g.deck.splice(cardIndex, 1)[0]);
|
||||
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
|
||||
g = prepareSardaukarBattle(nexusReload(g));
|
||||
const before = JSON.stringify(g);
|
||||
const v = viewGame(g, 'p');
|
||||
const action = v.battle!.compliantPreparation!.actions[0];
|
||||
assert.equal(action.type, 'nexusSardaukar');
|
||||
assert.equal(g.nexusCards!.cards!.hands.p, 'emperor');
|
||||
const text = renderToStaticMarkup(
|
||||
createElement(BattlePromises, {
|
||||
game: v,
|
||||
fill() {},
|
||||
act() {},
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/Declare Emperor Cunning to count five ordinary forces as Sardaukar for this battle/,
|
||||
);
|
||||
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);
|
||||
g = applyAction(g, 'p', action);
|
||||
assert.equal(g.response?.kind, 'nexusSardaukar');
|
||||
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, false);
|
||||
g = allowNexusSardaukar(g);
|
||||
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, true);
|
||||
assert.equal(g.battle!.prescience!.value, 10);
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
viewGame,
|
||||
normalizeAutomaticGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { casualtyOptions, maxCombatDial } from '../game/combat';
|
||||
import {
|
||||
createNexusSardaukar,
|
||||
validateNexusSardaukar,
|
||||
} from '../game/nexus-sardaukar';
|
||||
import {
|
||||
nexusSardaukarFixture,
|
||||
nexusSardaukarPlayer,
|
||||
nexusSardaukarInventory,
|
||||
prepareSardaukarBattle,
|
||||
beginNexusSardaukar,
|
||||
allowNexusSardaukar,
|
||||
} from './fixture-nexus-sardaukar';
|
||||
import { nexusReload, nexusReject, nexusAllow } from './fixture-nexus-cards';
|
||||
import { drawNexusCard } from '../game/nexus-cards';
|
||||
|
||||
function hold(g: Game, id: string, effect: string): string {
|
||||
const index = g.deck.findIndex(
|
||||
(card) => card.effect === effect || card.kind === effect,
|
||||
);
|
||||
assert.ok(index >= 0);
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
g.players.find((p) => p.id === id)!.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
function activate(g: Game): Game {
|
||||
return allowNexusSardaukar(beginNexusSardaukar(g));
|
||||
}
|
||||
function request(g: Game) {
|
||||
const p = nexusSardaukarPlayer(g);
|
||||
return {
|
||||
type: 'nexusSardaukar',
|
||||
event: viewGame(g, p.id).nexusSardaukar!.offer!.event,
|
||||
};
|
||||
}
|
||||
function resolveOrdinary(state: Game, dial = 6, support = 3): Game {
|
||||
let g = prepareSardaukarBattle(state);
|
||||
const owner = nexusSardaukarPlayer(g),
|
||||
target = g.players.find((p) => p.id === g.battle!.defender)!;
|
||||
const leader = [...owner.leaders].sort((a, b) => b.strength - a.strength)[0];
|
||||
const opposing = [...target.leaders].sort(
|
||||
(a, b) => a.strength - b.strength,
|
||||
)[0];
|
||||
g = applyAction(g, owner.id, {
|
||||
type: 'battlePlan',
|
||||
dial,
|
||||
support,
|
||||
leader: leader.id,
|
||||
});
|
||||
g = applyAction(g, target.id, {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: opposing.id,
|
||||
});
|
||||
for (const id of [owner.id, target.id])
|
||||
g = applyAction(g, id, { type: 'traitorCall', call: false });
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('Sardaukar receipt binds the original five-counter battle role without depending on later force custody', () => {
|
||||
const g = nexusSardaukarFixture();
|
||||
const receipt = createNexusSardaukar(
|
||||
g,
|
||||
'p',
|
||||
g.battle!.event!,
|
||||
'pasty_mesa',
|
||||
7,
|
||||
);
|
||||
assert.equal(receipt.elite, 0);
|
||||
assert.equal(receipt.count, 5);
|
||||
validateNexusSardaukar({ turn: g.turn + 1, players: g.players }, receipt);
|
||||
for (const patch of [
|
||||
{ normal: 4 },
|
||||
{ normal: 8 },
|
||||
{ count: 4 },
|
||||
{ elite: 1 },
|
||||
{ owner: 'q' },
|
||||
{ territory: 'hagga_basin' },
|
||||
{ battle: 'other' },
|
||||
{ version: 0 },
|
||||
]) {
|
||||
const bad = { ...receipt, ...patch };
|
||||
assert.throws(() => validateNexusSardaukar(g, bad as typeof receipt));
|
||||
}
|
||||
assert.throws(() => createNexusSardaukar(g, 'q', 'battle', 'pasty_mesa', 7));
|
||||
assert.throws(() => createNexusSardaukar(g, 'p', 'battle', 'pasty_mesa', 4));
|
||||
const changed = g.players.map((p) => ({ id: p.id, faction: p.faction }));
|
||||
changed[1].id = 'another-seat';
|
||||
assert.throws(() =>
|
||||
validateNexusSardaukar({ turn: g.turn, players: changed }, receipt),
|
||||
);
|
||||
});
|
||||
|
||||
void test('five temporary Sardaukar enhance an owned battle while every actual force and starred counter stays in place', () => {
|
||||
let g = nexusSardaukarFixture();
|
||||
hold(g, 'q', 'karama');
|
||||
const before = structuredClone(g.players);
|
||||
g = beginNexusSardaukar(g);
|
||||
assert.equal(g.response?.kind, 'nexusSardaukar');
|
||||
g = allowNexusSardaukar(g);
|
||||
assert.deepEqual(g.players, before);
|
||||
const forces = viewGame(g, 'p').battle!.ownForces!;
|
||||
assert.equal(forces.normal, 7);
|
||||
assert.equal(forces.elite, 0);
|
||||
assert.equal(forces.temporaryElite, 5);
|
||||
assert.equal(maxCombatDial(forces), 12);
|
||||
assert.deepEqual(
|
||||
casualtyOptions(forces, 6, 3).map((x) => [x.normal, x.elite]),
|
||||
[
|
||||
[3, 0],
|
||||
[4, 0],
|
||||
[5, 0],
|
||||
],
|
||||
);
|
||||
for (const p of g.players)
|
||||
assert.equal(viewGame(g, p.id).nexusSardaukar!.active, true);
|
||||
nexusSardaukarInventory(g);
|
||||
});
|
||||
|
||||
void test('temporary Sardaukar retain the ordinary Fremen exception and need support for full strength', () => {
|
||||
const g = activate(nexusSardaukarFixture({ opponentFaction: 'fremen' }));
|
||||
const forces = viewGame(g, 'p').battle!.ownForces!;
|
||||
assert.equal(forces.temporaryElite, 5);
|
||||
assert.equal(forces.eliteStrength, 1);
|
||||
assert.equal(forces.freeSupport, false);
|
||||
assert.equal(maxCombatDial(forces), 7);
|
||||
assert.deepEqual(
|
||||
casualtyOptions(forces, 5, 5).map((x) => [x.normal, x.elite]),
|
||||
[[5, 0]],
|
||||
);
|
||||
assert.deepEqual(casualtyOptions(forces, 5, 0), []);
|
||||
});
|
||||
|
||||
void test('Karama stops the new role grant entirely and leaves all physical forces normal', () => {
|
||||
let g = nexusSardaukarFixture();
|
||||
const karama = hold(g, 'q', 'karama');
|
||||
const original = structuredClone(nexusSardaukarPlayer(g).elites);
|
||||
g = beginNexusSardaukar(g);
|
||||
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
|
||||
g = allowNexusSardaukar(g);
|
||||
const forces = viewGame(g, 'p').battle!.ownForces!;
|
||||
assert.equal(forces.temporaryElite ?? 0, 0);
|
||||
assert.equal(maxCombatDial(forces), 7);
|
||||
assert.deepEqual(nexusSardaukarPlayer(g).elites, original);
|
||||
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'emperor').length,
|
||||
1,
|
||||
);
|
||||
nexusSardaukarInventory(g);
|
||||
});
|
||||
|
||||
void test('every winning casualty choice removes ordinary counters and never invents Sardaukar in the Tanks', () => {
|
||||
const initial = activate(nexusSardaukarFixture());
|
||||
for (const [index, losses] of [3, 4, 5].entries()) {
|
||||
let g = resolveOrdinary(nexusReload(initial));
|
||||
assert.equal(g.decision?.kind, 'battleLosses');
|
||||
g = applyAction(g, 'p', { type: 'decision', choice: index });
|
||||
const p = nexusSardaukarPlayer(g);
|
||||
assert.equal(p.tanks, losses);
|
||||
assert.equal(p.forces['pasty_mesa:5'], 7 - losses);
|
||||
assert.equal(p.elites!.tanks, 0);
|
||||
assert.equal(p.elites!.reserves, 5);
|
||||
assert.deepEqual(p.elites!.forces, {});
|
||||
nexusSardaukarInventory(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the completed grant expires before a second battle in the same turn', () => {
|
||||
let g = resolveOrdinary(activate(nexusSardaukarFixture()), 0, 0);
|
||||
assert.equal(g.battle, null);
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'hagga_basin',
|
||||
target: 'q',
|
||||
});
|
||||
assert.equal(viewGame(g, 'p').battle!.ownForces!.temporaryElite ?? 0, 0);
|
||||
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, false);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
});
|
||||
|
||||
void test('Basic, short groups, actual Sardaukar, foreign actors and stale or repeated declarations reject unchanged', () => {
|
||||
for (const options of [{ advanced: false }, { normal: 4 }, { starred: 1 }]) {
|
||||
const g = nexusSardaukarFixture(options);
|
||||
assert.ok(viewGame(g, 'p').nexusSardaukar!.offer!.blocked);
|
||||
nexusReject(g, 'p', request(g), /Basic|Advanced|five|5|Sardaukar|ruling/i);
|
||||
}
|
||||
const g = nexusSardaukarFixture(),
|
||||
action = request(g);
|
||||
nexusReject(g, 'q', action, /Sardaukar|Cunning/);
|
||||
nexusReject(g, 'p', { ...action, event: 'old' }, /Sardaukar|Cunning/);
|
||||
nexusReject(g, 'p', { ...action, count: 4 }, /Sardaukar|Cunning/);
|
||||
nexusReject(activate(g), 'p', action, /Sardaukar|Cunning/);
|
||||
const ready = prepareSardaukarBattle(g);
|
||||
const sealed = applyAction(ready, 'p', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: ready.players[0].leaders[0].id,
|
||||
});
|
||||
nexusReject(sealed, 'p', action, /submitted|Sardaukar|Cunning/);
|
||||
});
|
||||
|
||||
void test('saved declaration restores its public pending effect and only settles once', () => {
|
||||
let g = nexusSardaukarFixture({
|
||||
seatIds: ['emperor-seat', 'guild-seat', 'fremen-seat'],
|
||||
});
|
||||
hold(g, 'guild-seat', 'karama');
|
||||
const action = request(g);
|
||||
g = beginNexusSardaukar(g);
|
||||
const saved = nexusReload(g);
|
||||
for (const p of saved.players) {
|
||||
const view = viewGame(saved, p.id);
|
||||
assert.equal(view.nexusSardaukar!.pending!.owner, 'emperor-seat');
|
||||
for (const other of view.players)
|
||||
if (other.id !== p.id) assert.equal(other.hand, undefined);
|
||||
}
|
||||
assert.deepEqual(normalizeAutomaticGame(saved), saved);
|
||||
g = allowNexusSardaukar(nexusReload(saved));
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
nexusReject(g, 'emperor-seat', action, /Sardaukar|Cunning/);
|
||||
nexusSardaukarInventory(g);
|
||||
});
|
||||
|
||||
void test('native Prescience accepts a future Cunning dial and keeps its answer binding after activation', () => {
|
||||
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
|
||||
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
|
||||
assert.equal(g.nexusCards!.cards!.hands.p, 'emperor');
|
||||
g = activate(prepareSardaukarBattle(g));
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'battlePlan',
|
||||
dial: 10,
|
||||
support: 5,
|
||||
leader: g.players[0].leaders[0].id,
|
||||
});
|
||||
assert.equal(g.battle!.plans.p.dial, 10);
|
||||
assert.equal(g.battle!.prescience!.value, 10);
|
||||
});
|
||||
|
||||
void test('canceling Cunning reopens an impossible native dial answer without granting a new inspection', () => {
|
||||
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
|
||||
const karama = hold(g, 'q', 'karama');
|
||||
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
|
||||
const battle = g.battle!.event;
|
||||
g = beginNexusSardaukar(prepareSardaukarBattle(g));
|
||||
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.equal(g.battle!.event, battle);
|
||||
assert.equal(g.battle!.prescience!.player, 'q');
|
||||
assert.equal(g.battle!.prescience!.field, 'dial');
|
||||
assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer');
|
||||
g = applyAction(nexusReload(g), 'p', { type: 'prescienceAnswer', value: 5 });
|
||||
assert.equal(g.battle!.prescience!.value, 5);
|
||||
g = prepareSardaukarBattle(g);
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'battlePlan',
|
||||
dial: 5,
|
||||
support: 5,
|
||||
leader: g.players[0].leaders[0].id,
|
||||
});
|
||||
assert.equal(g.battle!.plans.p.dial, 5);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((card) => card === 'emperor').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(g.discard.filter((card) => card.id === karama).length, 1);
|
||||
nexusSardaukarInventory(g);
|
||||
});
|
||||
|
||||
void test('canceled Sardaukar reopen the affected Cunning inspection while preserving the other answered element', () => {
|
||||
for (const [nativeDial, extraLeader] of [
|
||||
[true, false],
|
||||
[false, false],
|
||||
[true, true],
|
||||
]) {
|
||||
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
|
||||
const leader = g.players[0].leaders[0].id;
|
||||
const unchangedField = extraLeader
|
||||
? 'leader'
|
||||
: nativeDial
|
||||
? 'defense'
|
||||
: 'weapon';
|
||||
const unchangedValue = extraLeader ? leader : null;
|
||||
const karama = hold(g, 'q', 'karama');
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = [
|
||||
'atreides',
|
||||
...cards.deck.filter((card) => card !== 'atreides'),
|
||||
];
|
||||
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'prescience',
|
||||
field: nativeDial ? 'dial' : 'weapon',
|
||||
});
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'prescienceAnswer',
|
||||
value: nativeDial ? 10 : null,
|
||||
});
|
||||
g = prepareSardaukarBattle(g);
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'nexusAtreides',
|
||||
event: g.battle!.event,
|
||||
mode: 'cunning',
|
||||
field: extraLeader ? 'leader' : nativeDial ? 'defense' : 'dial',
|
||||
});
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'nexusPrescienceAnswer',
|
||||
event: g.battle!.event,
|
||||
value: nativeDial ? unchangedValue : 10,
|
||||
});
|
||||
g = beginNexusSardaukar(prepareSardaukarBattle(g));
|
||||
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.equal(
|
||||
g.battle!.preparation?.kind,
|
||||
nativeDial ? 'prescienceAnswer' : 'nexusPrescienceAnswer',
|
||||
);
|
||||
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
|
||||
g = applyAction(
|
||||
nexusReload(g),
|
||||
'p',
|
||||
nativeDial
|
||||
? { type: 'prescienceAnswer', value: 5 }
|
||||
: { type: 'nexusPrescienceAnswer', event: g.battle!.event, value: 5 },
|
||||
);
|
||||
const commitments = viewGame(g, 'p').battle!.ownCommitments;
|
||||
assert.ok(commitments.some((c) => c.field === 'dial' && c.value === 5));
|
||||
assert.ok(
|
||||
commitments.some(
|
||||
(c) => c.field === unchangedField && c.value === unchangedValue,
|
||||
),
|
||||
);
|
||||
g = prepareSardaukarBattle(g);
|
||||
if (extraLeader)
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{
|
||||
type: 'battlePlan',
|
||||
dial: 5,
|
||||
support: 5,
|
||||
leader: g.players[0].leaders.find(
|
||||
(candidate) => candidate.id !== leader,
|
||||
)!.id,
|
||||
},
|
||||
/Every element revealed by a battle inspection must remain unchanged/,
|
||||
);
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'battlePlan',
|
||||
dial: 5,
|
||||
support: 5,
|
||||
leader,
|
||||
});
|
||||
assert.equal(g.battle!.plans.p.dial, 5);
|
||||
nexusSardaukarInventory(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Secret Ally inspection retains private history when canceled Sardaukar require a revised dial', () => {
|
||||
let g = nexusSardaukarFixture();
|
||||
const karama = hold(g, 'q', 'karama');
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = [
|
||||
'atreides',
|
||||
...cards.deck.filter((card) => card !== 'atreides'),
|
||||
];
|
||||
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'nexusAtreides',
|
||||
event: g.battle!.event,
|
||||
mode: 'secretAlly',
|
||||
field: 'dial',
|
||||
});
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'nexusPrescienceAnswer',
|
||||
event: g.battle!.event,
|
||||
value: 10,
|
||||
});
|
||||
g = beginNexusSardaukar(prepareSardaukarBattle(g));
|
||||
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer');
|
||||
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
|
||||
assert.ok(
|
||||
viewGame(g, 'q').battle!.nexusInsights.some(
|
||||
(insight) => insight.value === 10 && !insight.active,
|
||||
),
|
||||
);
|
||||
g = applyAction(nexusReload(g), 'p', {
|
||||
type: 'nexusPrescienceAnswer',
|
||||
event: g.battle!.event,
|
||||
value: 5,
|
||||
});
|
||||
const answers = viewGame(g, 'q').battle!.nexusInsights;
|
||||
assert.ok(answers.some((insight) => insight.value === 10 && !insight.active));
|
||||
assert.ok(answers.some((insight) => insight.value === 5 && insight.active));
|
||||
g = prepareSardaukarBattle(g);
|
||||
g = applyAction(g, 'p', {
|
||||
type: 'battlePlan',
|
||||
dial: 5,
|
||||
support: 5,
|
||||
leader: g.players[0].leaders[0].id,
|
||||
});
|
||||
nexusSardaukarInventory(g);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeNexusGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { drawNexusCard } from '../game/nexus-cards';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { maxCombatDial, maxCombatSupport } from '../game/combat';
|
||||
import {
|
||||
enterNexusSpice,
|
||||
orderNexusSpice,
|
||||
finishNexusSpice,
|
||||
nexusAllow,
|
||||
nexusReload,
|
||||
} from './fixture-nexus-cards';
|
||||
import {
|
||||
beginNexusSardaukar,
|
||||
prepareSardaukarBattle,
|
||||
nexusSardaukarInventory,
|
||||
} from './fixture-nexus-sardaukar';
|
||||
|
||||
/** Both modules and the final roster precede the genuine setup initializer.
|
||||
* Later board positions conserve all counters; Salusa is an explicit subset
|
||||
* of existing reserves, including the low case's four stars at Kaitain. */
|
||||
function fixture(salusa: 1 | 2 | 5 = 5) {
|
||||
let g = createGame('SARDHOME', newPlayer('p', 'Emperor', 'emperor'), true);
|
||||
joinGame(g, newPlayer('q', 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer('r', 'Fremen', 'fremen'));
|
||||
g = applyAction(g, 'p', { type: 'homeworlds', enabled: true });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeNexusGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 80; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((player) => player.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, 'Combined setup must progress through real choices.');
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const p of g.players) g.deck.push(...p.hand.splice(0));
|
||||
g = enterNexusSpice(g);
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
g = finishNexusSpice(g);
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.tanks = 0;
|
||||
p.spice = 20;
|
||||
if (p.elites) {
|
||||
p.elites.forces = {};
|
||||
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
|
||||
p.elites.tanks = 0;
|
||||
}
|
||||
}
|
||||
g.players[0].forces = { 'pasty_mesa:5': 7 };
|
||||
g.players[0].reserves = 13;
|
||||
g.players[1].forces = { 'pasty_mesa:5': 2 };
|
||||
g.players[1].reserves = 18;
|
||||
g.homeworlds!.custody!.salusa = { normal: 0, elite: salusa };
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')];
|
||||
g.nexusCards!.cards = drawNexusCard(cards, 'p', g.players, () => 0);
|
||||
Object.assign(g, {
|
||||
phase: 6,
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
active: 'p',
|
||||
order: ['p', 'q', 'r'],
|
||||
ready: [],
|
||||
storm: 18,
|
||||
});
|
||||
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,
|
||||
});
|
||||
g = prepareSardaukarBattle(g);
|
||||
inventory(g);
|
||||
return g;
|
||||
}
|
||||
function inventory(g: Game) {
|
||||
homeworldGameIntegrity(g);
|
||||
nexusSardaukarInventory(g);
|
||||
}
|
||||
function activate(g: Game) {
|
||||
return nexusAllow(beginNexusSardaukar(g));
|
||||
}
|
||||
function plan(g: Game, support: number) {
|
||||
const leader = [...g.players[0].leaders].sort(
|
||||
(a, b) => b.strength - a.strength,
|
||||
)[0];
|
||||
return { type: 'battlePlan', dial: 12, support, leader: leader.id };
|
||||
}
|
||||
function resolve(g: Game, support: number) {
|
||||
g = applyAction(g, 'p', plan(g, support));
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: g.players[1].leaders[0].id,
|
||||
});
|
||||
for (const id of ['p', 'q'])
|
||||
g = applyAction(g, id, { type: 'traitorCall', call: false });
|
||||
if (g.decision?.kind === 'battleLosses')
|
||||
g = applyAction(g, 'p', { type: 'decision', choice: 0 });
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('real high-Salusa Cunning pays only two normal support and all seven losses remain ordinary counters', () => {
|
||||
const original = fixture();
|
||||
let g = activate(nexusReload(original));
|
||||
const forces = viewGame(g, 'p').battle!.ownForces!;
|
||||
assert.deepEqual(
|
||||
[forces.normal, forces.elite, forces.temporaryElite],
|
||||
[7, 0, 5],
|
||||
);
|
||||
assert.equal(forces.eliteFreeSupport, true);
|
||||
assert.equal(maxCombatDial(forces), 12);
|
||||
assert.equal(maxCombatSupport(forces), 2);
|
||||
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
|
||||
const before = nexusReload(g);
|
||||
assert.throws(() => applyAction(g, 'p', plan(g, 3)));
|
||||
assert.deepEqual(g, before);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(nexusReload(g), p.id), viewGame(g, p.id));
|
||||
g = resolve(g, 2);
|
||||
assert.equal(g.players[0].tanks, 7);
|
||||
assert.equal(g.players[0].spice, original.players[0].spice - 2);
|
||||
assert.equal(g.players[0].elites!.tanks, 0);
|
||||
assert.equal(g.players[0].elites!.reserves, 5);
|
||||
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
|
||||
assert.equal(g.players[0].reserves, original.players[0].reserves);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
inventory(g);
|
||||
});
|
||||
|
||||
void test('one Salusa star stays low despite five temporary Sardaukar and requires all seven support payments', () => {
|
||||
const original = fixture(1);
|
||||
let g = activate(nexusReload(original));
|
||||
const forces = viewGame(g, 'p').battle!.ownForces!;
|
||||
assert.equal(forces.temporaryElite, 5);
|
||||
assert.equal(!!forces.eliteFreeSupport, false);
|
||||
assert.equal(maxCombatDial(forces), 12);
|
||||
assert.equal(maxCombatSupport(forces), 7);
|
||||
const before = nexusReload(g);
|
||||
assert.throws(() => applyAction(g, 'p', plan(g, 2)));
|
||||
assert.deepEqual(g, before);
|
||||
g = resolve(g, 7);
|
||||
assert.equal(g.players[0].tanks, 7);
|
||||
assert.equal(g.players[0].spice, original.players[0].spice - 7);
|
||||
assert.equal(g.players[0].elites!.tanks, 0);
|
||||
assert.equal(g.players[0].elites!.reserves, 5);
|
||||
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 1 });
|
||||
inventory(g);
|
||||
});
|
||||
|
||||
void test('the actual Salusa one-to-two threshold controls the shared profile without counting stars at Kaitain', () => {
|
||||
for (const [salusa, support] of [
|
||||
[1, 7],
|
||||
[2, 2],
|
||||
] as const) {
|
||||
const g = activate(fixture(salusa));
|
||||
assert.equal(
|
||||
maxCombatSupport(viewGame(g, 'p').battle!.ownForces!),
|
||||
support,
|
||||
);
|
||||
assert.equal(g.players[0].elites!.reserves, 5);
|
||||
assert.equal(g.homeworlds!.custody!.salusa!.elite, salusa);
|
||||
inventory(g);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
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 {
|
||||
nexusSardaukarFixture,
|
||||
prepareSardaukarBattle,
|
||||
nexusSardaukarInventory,
|
||||
} from './fixture-nexus-sardaukar';
|
||||
import { applyAction, 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 sardaukarAction = (g: Game): Action => ({
|
||||
type: 'nexusSardaukar',
|
||||
event: viewGame(g, 'p').nexusSardaukar!.offer!.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, 'nexusSardaukarHistory'), false);
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusSardaukarInventory(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;
|
||||
}
|
||||
|
||||
function initialBattle(sealed = false) {
|
||||
let g = prepareSardaukarBattle(nexusSardaukarFixture());
|
||||
const index = g.deck.findIndex((card) => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const karama = g.deck.splice(index, 1)[0];
|
||||
g.players[1].hand.push(karama);
|
||||
if (sealed)
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
support: 0,
|
||||
leader: g.players[1].leaders[0].id,
|
||||
weapon: null,
|
||||
defense: null,
|
||||
});
|
||||
nexusSardaukarInventory(g);
|
||||
return { g, karama: karama.id };
|
||||
}
|
||||
function physical(g: Game) {
|
||||
return g.players.map((p) => ({
|
||||
id: p.id,
|
||||
forces: p.forces,
|
||||
reserves: p.reserves,
|
||||
tanks: p.tanks,
|
||||
elites: p.elites,
|
||||
spice: p.spice,
|
||||
}));
|
||||
}
|
||||
|
||||
void test('SQLite competing Emperor Cunning plays spend one card and restore a sealed opponent plus the pending response privately', async () => {
|
||||
const { g: initial } = initialBattle(true);
|
||||
const f = await fixture(initial);
|
||||
try {
|
||||
const action = sardaukarAction(initial);
|
||||
const g = await compete(f, action);
|
||||
assert.equal(g.response?.kind, 'nexusSardaukar');
|
||||
assert.equal(g.nexusSardaukarHistory!.length, 1);
|
||||
assert.equal(g.nexusSardaukarLast!.stage, 'pending');
|
||||
assert.deepEqual(g.battle!.plans.q, initial.battle!.plans.q);
|
||||
assert.deepEqual(physical(g), physical(initial));
|
||||
assert.equal(g.nexusCards!.cards!.hands.p, null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((card) => card === 'emperor').length,
|
||||
1,
|
||||
);
|
||||
await restored(f, g);
|
||||
for (const id of ['p', 'r'])
|
||||
assert.equal(viewGame(g, id).battle!.plans.q, undefined);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths[0], initial.version, action, clock),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths[0], g.version, action, clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
await f
|
||||
.restart()
|
||||
.act(f.code, f.auths[1], g.version, { type: 'passResponse' }, clock);
|
||||
const active = await f.restart().readRoom(f.code);
|
||||
assert.equal(active.nexusSardaukarLast!.stage, 'active');
|
||||
assert.deepEqual(physical(active), physical(initial));
|
||||
assert.deepEqual(active.battle!.plans.q, initial.battle!.plans.q);
|
||||
await restored(f, active);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite lost acknowledgment followed by Karama cancels only temporary strength and retains all real Sardaukar in reserves', async () => {
|
||||
const { g: initial, karama } = initialBattle();
|
||||
const f = await fixture(initial);
|
||||
try {
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
initial.version,
|
||||
sardaukarAction(initial),
|
||||
clock,
|
||||
);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
await restored(f, pending);
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[1],
|
||||
pending.version,
|
||||
{ type: 'card', card: karama, mode: 'cancel' },
|
||||
clock,
|
||||
);
|
||||
const canceled = await f.restart().readRoom(f.code);
|
||||
assert.equal(canceled.nexusSardaukarLast!.stage, 'canceled');
|
||||
assert.deepEqual(physical(canceled), physical(initial));
|
||||
assert.equal(canceled.players[0].elites!.reserves, 5);
|
||||
assert.equal(canceled.players[0].elites!.tanks, 0);
|
||||
assert.equal(
|
||||
canceled.discard.filter((card) => card.id === karama).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
canceled.nexusCards!.cards!.discard.filter((card) => card === 'emperor')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
await restored(f, canceled);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.auths[1],
|
||||
canceled.version,
|
||||
{ type: 'card', card: karama, mode: 'cancel' },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite damaged Emperor battle source, history, stage or response refuses every seat and writes nothing', async () => {
|
||||
const { g: initial } = initialBattle();
|
||||
const f = await fixture(initial);
|
||||
try {
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
initial.version,
|
||||
sardaukarAction(initial),
|
||||
clock,
|
||||
);
|
||||
const good = await f.rooms.readRoom(f.code);
|
||||
const changes: ((g: Game) => void)[] = [
|
||||
(g) => {
|
||||
delete g.nexusSardaukarHistory;
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusSardaukarLast;
|
||||
},
|
||||
(g) => {
|
||||
delete g.battle!.nexusSardaukarUsed;
|
||||
},
|
||||
(g) => {
|
||||
g.battle!.nexusSardaukarUsed = 'stale';
|
||||
},
|
||||
(g) => {
|
||||
g.nexusSardaukarHistory![0].stage = 'active';
|
||||
},
|
||||
(g) => {
|
||||
g.nexusSardaukarHistory![0].receipt.normal++;
|
||||
},
|
||||
(g) => {
|
||||
g.nexusSardaukarHistory![0].receipt.battle = 'stale';
|
||||
},
|
||||
(g) => {
|
||||
g.nexusSardaukarHistory![0].parent += '-stale';
|
||||
},
|
||||
(g) => {
|
||||
g.response = null;
|
||||
},
|
||||
(g) => {
|
||||
g.response!.owner = 'q';
|
||||
},
|
||||
(g) => {
|
||||
g.response!.intent += '-stale';
|
||||
},
|
||||
(g) => {
|
||||
g.players[0].forces['pasty_mesa:5']++;
|
||||
g.players[0].reserves--;
|
||||
},
|
||||
];
|
||||
for (const change of changes) {
|
||||
const bad = structuredClone(good);
|
||||
change(bad);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(bad), f.code);
|
||||
f.writes.length = 0;
|
||||
const before = row(f);
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(f.restart().readSeatView(f.code, auth));
|
||||
await assert.rejects(
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths[1],
|
||||
bad.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
),
|
||||
);
|
||||
// Automatic recovery can decline an invalid continuation or surface its
|
||||
// integrity error; neither path may rewrite the damaged saved row.
|
||||
await f
|
||||
.restart()
|
||||
.continueRoomAutomatic(f.code, clock)
|
||||
.catch(() => {});
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user