Files
Dune/tests/nexus-ecaz-inquiry.test.ts

219 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
});