Add private Ecaz Nexus Secret Ally inquiry

This commit is contained in:
Quintine committed 2026-09-28 11:45:14 +10:00
1 parent 886a8f4478
commit 0c6d28a706
14 files changed
+496 -26

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { ecazInquiryAction } from '../game/nexus-ecaz-inquiry-options';
import { ecazInquiryAnswer } from '../game/nexus-ecaz-inquiry';
import { drawNexusCard } from '../game/nexus-cards';
import { NexusEcazInquiry } from '../components/nexus-ecaz-inquiry';
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
import { unitStore } from './fixture-nexus-room-store';
import type { RoomsClock } from '../db/rooms';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
const seat = (g: Game, id: string) => g.players.find(player => player.id === id)!;
function setAnswer(g: Game, positive: boolean) {
const [owner, target] = g.players;
const native = new Set(owner.leaders.map(leader => leader.id));
const reserve = g.traitorReserve!;
if (positive) {
if (target.traitors.some(id => native.has(id))) return;
const wanted = owner.leaders[0].id;
const other = g.players.find(player => player.id !== target.id && player.traitors.includes(wanted));
if (other) {
const index = other.traitors.indexOf(wanted);
other.traitors[index] = target.traitors[0];
} else {
const index = reserve.indexOf(wanted);
assert.ok(index >= 0);
reserve[index] = target.traitors[0];
}
target.traitors[0] = wanted;
} else {
for (let i = 0; i < target.traitors.length; i++) {
if (!native.has(target.traitors[i])) continue;
const index = reserve.findIndex(id => !native.has(id));
assert.ok(index >= 0);
[target.traitors[i], reserve[index]] = [reserve[index], target.traitors[i]];
}
}
nexusTraitorInventory(g);
}
function position(positive: boolean, advanced = false,
ids: [string,string,string] = ['p','q','r']) {
const [ownerId, targetId] = ids;
const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'guild',
advanced, phase: 2, seatIds: ids });
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('ecaz');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[ownerId]!;
cards.hands[ownerId] = 'ecaz';
setAnswer(g, positive);
nexusTraitorInventory(g);
const action = ecazInquiryAction(viewGame(g, ownerId), targetId);
assert.ok(action);
return { g, action };
}
function reject(g: Game, id: string, action: {type: string;[key: string]: unknown}) {
const before = reload(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
void test('Basic and Advanced inquiries spend one physical card and preserve only the owner’s historical yes/no', () => {
for (const advanced of [false, true]) for (const positive of [false, true]) {
const { g, action } = position(positive, advanced);
const offer = viewGame(g, 'p').nexusEcazInquiry.offer!;
assert.equal(offer.blocked, null);
assert.deepEqual(offer.targets.map(target => target.id), ['p', 'q', 'r']);
assert.equal(viewGame(g, 'q').nexusEcazInquiry.offer, null);
assert.equal(JSON.stringify(offer).includes(seat(g, 'q').traitors[0]), false);
const before = reload(g), beforeLog = g.log.length;
const done = applyAction(g, 'p', action);
assert.deepEqual(g, before);
assert.equal(done.nexusCards!.cards!.hands.p, null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
assert.equal(done.nexusEcazInquiries?.length, 1);
assert.equal(done.nexusEcazInquiries![0].answer, positive);
assert.equal(done.log.slice(beforeLog).some(row => /\b(?:Yes|No)\b/.test(row.text)), false);
assert.equal(viewGame(done, 'p').nexusEcazInquiry.history[0].answer, positive);
assert.deepEqual(viewGame(done, 'q').nexusEcazInquiry.history, []);
assert.deepEqual(viewGame(done, 'r').nexusEcazInquiry.history, []);
assert.equal(JSON.stringify(viewGame(done, 'q')).includes(done.nexusEcazInquiries![0].signature), false);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
reject(done, 'p', action);
nexusTraitorInventory(done);
const changed = reload(done);
setAnswer(changed, !positive);
assert.equal(viewGame(changed, 'p').nexusEcazInquiry.history[0].answer, positive);
}
});
void test('self-targeting and later Nexus discard recycling preserve the recorded answer', () => {
const { g } = position(false);
const expected = ecazInquiryAnswer(seat(g, 'p'), seat(g, 'p'));
const action = ecazInquiryAction(viewGame(g, 'p'), 'p');
assert.ok(action);
const done = applyAction(g, 'p', action);
assert.deepEqual(viewGame(done, 'p').nexusEcazInquiry.history[0],
{ event: action.event, target: 'p', turn: done.turn, phase: done.phase, answer: expected });
const recycled = reload(done), cards = recycled.nexusCards!.cards!;
cards.discard.push(...cards.deck.splice(0));
recycled.nexusCards!.cards = drawNexusCard(cards, 'p', recycled.players, () => 0);
assert.equal(recycled.nexusCards!.cards!.discard.includes('ecaz'), false);
assert.equal(viewGame(recycled, 'p').nexusEcazInquiry.history[0].answer, expected);
assert.deepEqual(normalizeAutomaticGame(reload(recycled)), recycled);
});
void test('inquiry counts dead or captured native leaders but not foreign discs, Duke or Face Dancers', () => {
const { g } = position(false);
const owner = seat(g, 'p'), target = seat(g, 'q');
assert.equal(ecazInquiryAnswer(owner, target), false);
const native = owner.leaders[0];
native.dead = true;
native.capturedBy = 'r';
target.traitors.push(native.id);
assert.equal(ecazInquiryAnswer(owner, target), true);
target.traitors.pop();
owner.leaders.push({ ...native, id: 'duke-vidal' });
target.traitors.push('duke-vidal');
assert.equal(ecazInquiryAnswer(owner, target), false);
target.traitors.pop();
target.faceDancers = [{ leader: native.id, revealed: false }];
assert.equal(ecazInquiryAnswer(owner, target), false);
});
void test('stale, forged, foreign, interrupted and damaged saved inquiry rejects without changing a game', () => {
const { g, action } = position(true);
reject(g, 'q', action);
reject(g, 'p', { ...action, event: 'stale' });
reject(g, 'p', { ...action, target: 'foreign' });
reject(g, 'p', { ...action, answer: true });
const busy = reload(g);
busy.decision = { kind: 'choamMarket', player: 'q' };
assert.ok(viewGame(busy, 'p').nexusEcazInquiry.offer?.blocked);
reject(busy, 'p', action);
const done = applyAction(g, 'p', action);
for (const damage of [
(state: Game) => { state.nexusEcazInquiries![0].answer = false; },
(state: Game) => { state.nexusEcazInquiries![0].target = 'r'; },
(state: Game) => { state.nexusEcazInquiryLast!.event = 'stale'; },
(state: Game) => { delete state.nexusEcazInquiries; },
]) {
const corrupt = reload(done); damage(corrupt); const before = reload(corrupt);
assert.throws(() => viewGame(corrupt, 'p'));
assert.deepEqual(corrupt, before);
}
});
void test('all four AI profiles inquire legally and the table keeps prior answers private after spending', () => {
const { g, action } = position(true);
for (const difficulty of ['Easy','Medium','Hard','Brutal'] as const) {
const view = viewGame(g, 'p');
view.players.find(player => player.id === 'p')!.bot = difficulty;
assert.deepEqual(botActions(view)[0], action);
}
const offerMarkup = renderToStaticMarkup(createElement(NexusEcazInquiry, {
game: viewGame(g, 'p'), busy: false, act() {},
}));
assert.match(offerMarkup, /Spend Ecaz Nexus · ask privately/);
assert.equal(renderToStaticMarkup(createElement(NexusEcazInquiry, {
game: viewGame(g, 'q'), busy: false, act() {},
})), '');
const done = applyAction(g, 'p', action);
const historyMarkup = renderToStaticMarkup(createElement(NexusEcazInquiry, {
game: viewGame(done, 'p'), busy: false, act() {},
}));
assert.match(historyMarkup, /held one of your native leaders: Yes/);
assert.equal(renderToStaticMarkup(createElement(NexusEcazInquiry, {
game: viewGame(done, 'q'), busy: false, act() {},
})), '');
});
const clock: RoomsClock = {now: () => 10000, sleep: async () => {}};
void test('simultaneous saved inquiries commit once and keep answer private after authenticated recovery', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('Ecaz inquiry SQL', 'atreides', false, []);
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!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string,string,string];
const { g } = position(true, false, ids);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
const action = ecazInquiryAction(viewGame(g, ids[0]), ids[1])!;
let arrivals = 0, release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
const timer = setTimeout(release, 2000);
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled([
store.restart().act(code, auths[0], g.version, action, clock),
store.restart().act(code, auths[0], g.version, action, clock),
]);
} finally { clearTimeout(timer); delete store.hooks.beforeWrite; }
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
const done = await store.restart().readRoom(code);
assert.equal(done.version, g.version + 1);
assert.equal(done.nexusEcazInquiries?.length, 1);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
assert.equal((await store.restart().readSeatView(code, auths[0])).nexusEcazInquiry.history[0].answer, true);
for (const auth of auths.slice(1))
assert.deepEqual((await store.restart().readSeatView(code, auth)).nexusEcazInquiry.history, []);
reject(done, ids[0], action);
} finally { store.sqlite.close(); }
});