Integrate Ixian Nexus Betrayal at original native attempts

This commit is contained in:
Quintine committed 2026-10-01 21:58:07 +10:00
1 parent 49c99de3e2
commit cdfe0e5370
25 files changed
+2824 -63

No files matched your search

+92
View File
@@ -4,6 +4,9 @@ import { DatabaseSync } from 'node:sqlite';
import { adminStore, pauseAdminBatch } from './admin-access-fixture';
import { adminLogin, requireAdmin, adminLogoutAll, AdminError } from '../db/admin-access';
import { projectAdminRoom, readAdminDirectory } from '../db/admin-directory';
import { applyAction, viewGame } from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-betrayal';
const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [],
@@ -48,6 +51,95 @@ void test('admin directory allowlist reveals no private state, role power or sea
assert.deepEqual(decision.pending, { label: 'Special decision', owners: ['p1'] });
assert.equal(JSON.stringify(decision).includes('SECRET'), false);
});
const project = (value: unknown) => projectAdminRoom({ code: 'IXADMINA', version: 3, updated_at: 100, state: JSON.stringify(value) });
void test('directory ownership follows genuine shared and sole Ixian acknowledgements instead of the native bidder', () => {
for (const kind of ['technology', 'bidding'] as const) {
const f = createIxianNexusBetrayalFixture({ kind, receiverCount: 2 });
assert.equal(f.required.length, 2);
const shared = project(f.game);
assert.deepEqual(shared.pending.owners, []);
for (const player of f.game.players) assert.equal(tableActionOwner(viewGame(f.game, player.id)), null);
const partial = applyAction(f.game, f.holder, { type: 'nexusIxianBetrayalPass', event: f.event });
const remaining = f.required.find(id => id !== f.holder)!;
const sole = project(partial);
assert.deepEqual(sole.pending.owners, [remaining]);
assert.notEqual(remaining, f.provider);
for (const player of partial.players) assert.equal(tableActionOwner(viewGame(partial, player.id)), remaining);
assert.equal(sole.pending.label, shared.pending.label, 'Passing does not reveal which private card can prevent the attempt.');
const privateChanges = {
...partial,
players: partial.players.map(player => ({ ...player, hand: ['SECRET_HAND'], spice: 'SECRET_PRICE' })),
auction: partial.auction && { ...partial.auction, price: 'SECRET_PRICE' },
pendingNexusIxianBetrayal: {
...partial.pendingNexusIxianBetrayal,
source: { eligible: 'SECRET_ELIGIBILITY', nativeContext: 'SECRET_NATIVE', parent: 'SECRET_PARENT', card: 'SECRET_SOURCE_CARD' },
},
};
assert.deepEqual(project(privateChanges), sole, 'Admin metadata depends on public membership, not private source validity or custody.');
assert.equal(JSON.stringify(sole).includes('SECRET'), false);
}
});
void test('an irrelevant held Nexus face has the same admin acknowledgement metadata as an eligible face', () => {
for (const kind of ['technology', 'bidding'] as const) {
const eligible = createIxianNexusBetrayalFixture({ kind, receiverCount: 2, face: 'ixians', secondFace: 'guild' });
const irrelevant = createIxianNexusBetrayalFixture({ kind, receiverCount: 2, face: 'richese', secondFace: 'guild' });
assert.equal(viewGame(eligible.game, eligible.holder).nexusIxianBetrayalReaction!.canUse, true);
assert.equal(viewGame(irrelevant.game, irrelevant.holder).nexusIxianBetrayalReaction!.canUse, false);
assert.deepEqual(project(eligible.game), project(irrelevant.game));
const eligiblePartial = applyAction(eligible.game, eligible.holder, { type: 'nexusIxianBetrayalPass', event: eligible.event });
const irrelevantPartial = applyAction(irrelevant.game, irrelevant.holder, { type: 'nexusIxianBetrayalPass', event: irrelevant.event });
assert.deepEqual(project(eligiblePartial), project(irrelevantPartial));
}
});
void test('Ixian admin acknowledgements preserve lifecycle and higher public pending priorities', () => {
const input = {
...state('Priority QA', 'playing'), phase: 3,
pendingNexusIxianBetrayal: { required: ['p1'], passed: [], source: 'SECRET_SOURCE' },
auction: { active: 'p1', price: 'SECRET_PRICE' },
};
const higherPriority = [
{ status: 'finished' }, { status: 'lobby' }, { status: 'setup' },
{ pendingTreacheryDiscard: { card: 'SECRET_CARD' } },
{ response: { kind: 'SECRET_RESPONSE' } }, { phaseOpening: { passed: [] } },
{ truthtrance: { question: 'SECRET_QUESTION' } },
{ decision: { player: 'p1', card: 'SECRET_CARD' } },
{ battle: { attacker: 'p1', defender: 'UNSEATED_SECRET' } },
{ nexus: true },
{ phase: 0, stormDialers: ['p1'], stormDials: {} },
];
for (const priority of higherPriority) {
const value = { ...input, ...priority };
const withGate = project(value);
const withoutGate = project({ ...value, pendingNexusIxianBetrayal: null });
assert.deepEqual(withGate, withoutGate);
assert.equal(JSON.stringify(withGate).includes('SECRET'), false);
}
});
void test('malformed or orphan Ixian membership cannot disclose an unseated owner or fall through to the bidder', () => {
const input = { ...state('Membership QA', 'playing'), phase: 3, auction: { active: 'p1' } };
for (const membership of [
{ required: ['UNSEATED_SECRET'], passed: [] },
{ required: [null, 17, { id: 'SECRET_OBJECT' }], passed: [] },
{ required: [], passed: [] },
{ required: ['p1'], passed: ['p1'] },
{ required: 'SECRET_REQUIRED', passed: [] },
{ required: ['p1'], passed: 'SECRET_PASSED' },
{},
]) {
const output = project({ ...input, pendingNexusIxianBetrayal: { ...membership, source: 'SECRET_SOURCE' } });
assert.deepEqual(output.pending.owners, []);
assert.equal(output.status, 'playing', 'Malformed optional membership does not hide otherwise readable public room metadata.');
assert.equal(JSON.stringify(output).includes('SECRET'), false);
}
const duplicate = project({ ...input, pendingNexusIxianBetrayal: { required: ['p1', 'p1', 'UNSEATED_SECRET'], passed: [] } });
assert.deepEqual(duplicate.pending.owners, ['p1']);
});
void test('admin directory filters, stable pages, literal wildcard search and nested malformed saves', async () => {
const f = await fixture();
try {