Files
Dune/tests/nexus-moritani-betrayal.test.ts
T

100 lines
5.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type Game } from '../game/engine';
import { moritaniBetrayalAction, moritaniBetrayalBotActions } from '../game/nexus-moritani-betrayal-options';
import { NexusMoritaniBetrayal } from '../components/nexus-moritani-betrayal';
import { grummanCollectionSignature } from '../game/grumman-collection-return';
import { nexusMoritaniFixture, nexusMoritaniInventory, nexusMoritaniReload } from './fixture-nexus-moritani';
function position() {
const f = nexusMoritaniFixture({ stack: true, karama: false, cardOwner: 'q' });
const g = applyAction(f.g, f.owner, { type: 'decision', decline: true });
return { ...f, g };
}
function reject(g: Game, owner: string, action: { type: string; [key: string]: unknown }) {
const before = nexusMoritaniReload(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
void test('Moritani Betrayal returns one actual placed token without disclosing its face or replaying Mentat placement', () => {
const { g: initial, owner, target, observer } = position();
const offer = viewGame(initial, target).nexusMoritaniBetrayal!;
assert.equal(offer.blocked, null);
assert.equal(offer.tokens.length, 1);
const token = offer.tokens[0];
const before = nexusMoritaniReload(initial);
const action = moritaniBetrayalAction(viewGame(initial, target), token.id)!;
assert.equal(moritaniBetrayalBotActions(viewGame(initial, target))[0].token, token.id);
const g = applyAction(initial, target, action);
nexusMoritaniInventory(g);
assert.equal(g.nexusCards!.cards!.hands[target], null);
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
assert.equal(g.moritaniTerror!.tokens.some(t => t.id === token.id), false);
assert.equal(g.moritaniTerror!.tokens.filter(t => t.status === 'available').length,
before.moritaniTerror!.tokens.filter(t => t.status === 'available').length + 1);
assert.equal(g.moritaniTerror!.placementTurn, before.moritaniTerror!.placementTurn);
assert.deepEqual(g.players.map(p => [p.forces, p.reserves, p.spice]), before.players.map(p => [p.forces, p.reserves, p.spice]));
assert.deepEqual(g.nexusMoritaniLocations, before.nexusMoritaniLocations);
assert.equal(viewGame(g, observer).nexusMoritaniBetrayal, null);
assert.equal(viewGame(g, owner).nexusMoritaniBetrayal, null);
assert.ok(g.log.at(-1)?.text.includes('without revealing'));
reject(g, target, action);
assert.deepEqual(viewGame(nexusMoritaniReload(g), target), viewGame(g, target));
});
void test('Moritani Betrayal rejects foreign, stale, pending and nonexistent token choices before spending either physical component', () => {
const { g, owner, target, observer } = position();
const offer = viewGame(g, target).nexusMoritaniBetrayal!;
const action = moritaniBetrayalAction(viewGame(g, target), offer.tokens[0].id)!;
reject(g, observer, action);
reject(g, owner, action);
reject(g, target, { ...action, event: 'old' });
reject(g, target, { ...action, token: 'unknown' });
const pending = nexusMoritaniReload(g);
pending.decision = { kind: 'moritaniPlacement', player: owner };
reject(pending, target, action);
assert.equal(g.nexusCards!.cards!.hands[target], 'moritani');
});
void test('Betrayal offer and result depend on public token positions, not Moritani hidden faces', () => {
const { g, target, observer } = position();
const shuffled = nexusMoritaniReload(g);
const placed = shuffled.moritaniTerror!.tokens.find(t => t.status === 'placed')!;
const available = shuffled.moritaniTerror!.tokens.find(t => t.status === 'available')!;
[placed.kind, available.kind] = [available.kind, placed.kind];
assert.deepEqual(viewGame(g, target), viewGame(shuffled, target));
assert.deepEqual(viewGame(g, observer), viewGame(shuffled, observer));
});
void test('Betrayal controls name the chosen public token and disable during a request', () => {
const { g, target, observer } = position();
const view = viewGame(g, target);
const html = renderToStaticMarkup(createElement(NexusMoritaniBetrayal, { game: view, act() {}, busy: false }));
assert.match(html, /Moritani Nexus · Betrayal/);
assert.match(html, /Arrakeen/);
assert.match(html, /Return hidden Terror token/);
assert.doesNotMatch(html, /Sabotage/);
assert.match(renderToStaticMarkup(createElement(NexusMoritaniBetrayal, { game: view, act() {}, busy: true })), /disabled=""/);
assert.equal(renderToStaticMarkup(createElement(NexusMoritaniBetrayal, { game: viewGame(g, observer), act() {}, busy: false })), '');
});
void test('queued high-Grumman Collection resolves before a rival spends Moritani Betrayal', () => {
const f = nexusMoritaniFixture({ stack: true, karama: false, cardOwner: 'q', homeworlds: true, native: 8 });
const g = nexusMoritaniReload(f.g);
// Reconstruct the saved automatic boundary just before opening the Grumman
// choice; opening that choice only changes this frame and its decision.
g.phase = 7;
g.decision = null;
g.grummanCollection!.stage = 'waiting';
delete g.grummanCollection!.outcome;
g.grummanCollection!.signature = grummanCollectionSignature(g.grummanCollection!);
const view = viewGame(g, f.target);
assert.equal(view.automaticContinuationPending, true);
assert.match(view.nexusMoritaniBetrayal!.blocked!, /automatic phase continuation/);
reject(g, f.target, { type: 'nexusMoritaniBetrayal',
event: view.nexusMoritaniBetrayal!.event, token: view.nexusMoritaniBetrayal!.tokens[0].id });
});