153 lines
7.9 KiB
TypeScript
153 lines
7.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { registerHooks } from 'node:module';
|
||
import { readFileSync } from 'node:fs';
|
||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { viewGame, type Action, type GameView } from '../game/engine';
|
||
import { botNexusRicheseBetrayalActions } from '../game/bot-nexus-richese-betrayal';
|
||
import { botActions } from '../game/bots';
|
||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||
import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
|
||
|
||
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(match => [match[1], match[1]]));
|
||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||
},
|
||
});
|
||
const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal');
|
||
|
||
/** A normal-source view override isolates the connected controls and policy
|
||
* consumers. The engine suite supplies the actual ordinary purchase producer. */
|
||
function projected(): GameView {
|
||
const fixture = createRicheseBetrayalFixture('purchase');
|
||
const game = viewGame(fixture.game, fixture.holder);
|
||
assert.ok(game.richeseBetrayalReaction);
|
||
game.richeseBetrayalReaction.source = 'normal';
|
||
return game;
|
||
}
|
||
|
||
function markup(game: GameView, busy = false) {
|
||
return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, busy, act() {} }));
|
||
}
|
||
|
||
function click(node: ReactNode, prefix: string): boolean {
|
||
for (const child of Children.toArray(node)) {
|
||
if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue;
|
||
if (typeof child.props.children === 'string' && child.props.children.startsWith(prefix) && child.props.onClick) {
|
||
child.props.onClick();
|
||
return true;
|
||
}
|
||
if (click(child.props.children, prefix)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function choose(game: GameView, prefix: string, busy = false): Action[] {
|
||
const actions: Action[] = [];
|
||
click(NexusRicheseBetrayal({ game, busy, act(action) { actions.push(action); } }), prefix);
|
||
return actions;
|
||
}
|
||
|
||
void test('ordinary concealed purchases explain both outcomes and the provisional spend-first cost persistently', () => {
|
||
const game = projected();
|
||
for (let render = 0; render < 2; render++) {
|
||
const html = markup(game);
|
||
assert.match(html, /concealed ordinary auction lot/);
|
||
assert.match(html, /does not disclose the card’s family or grant inspection before you spend/);
|
||
assert.match(html, /Provisional online rule: spend the Richese Nexus before learning whether this purchase can be stopped/);
|
||
assert.match(html, /If it is a Richese card, that card is discarded without payment or delivery/);
|
||
assert.match(html, /Otherwise your Nexus remains spent and the original purchase continues/);
|
||
assert.match(html, /Spend Richese Nexus · attempt to stop this purchase/);
|
||
assert.match(html, /Pass · allow the original purchase/);
|
||
assert.doesNotMatch(html, /public cache lot|buying its own cache card|concealed Black Market lot/);
|
||
}
|
||
});
|
||
|
||
void test('actual normal-source Spend and Pass callbacks send one unchanged event-bound action', () => {
|
||
const game = projected(), event = game.richeseBetrayalReaction!.event;
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), [{ type: 'richeseBetrayalUse', event }]);
|
||
assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]);
|
||
});
|
||
|
||
void test('busy, blocked, passed and unentitled normal controls cannot commit the blind cost', () => {
|
||
const game = projected(), event = game.richeseBetrayalReaction!.event;
|
||
assert.match(markup(game, true), /disabled=""[^>]*>Spend Richese Nexus/);
|
||
assert.match(markup(game, true), /disabled=""[^>]*>Pass/);
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus', true), []);
|
||
assert.deepEqual(choose(game, 'Pass', true), []);
|
||
game.richeseBetrayalReaction!.blocked = 'Your accepted promise reserves this Nexus card.';
|
||
assert.match(markup(game), /accepted promise reserves this Nexus card/);
|
||
assert.match(markup(game), /Provisional online rule/);
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||
assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]);
|
||
game.richeseBetrayalReaction!.blocked = null;
|
||
game.richeseBetrayalReaction!.canUse = false;
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||
game.richeseBetrayalReaction!.canUse = true;
|
||
game.richeseBetrayalReaction!.hasPassed = true;
|
||
assert.match(markup(game), /You have passed/);
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||
assert.deepEqual(choose(game, 'Pass'), []);
|
||
game.richeseBetrayalReaction!.hasPassed = false;
|
||
game.richeseBetrayalReaction!.canPass = false;
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||
game.richeseBetrayalReaction!.canPass = true;
|
||
game.status = 'finished';
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), []);
|
||
assert.deepEqual(choose(game, 'Pass'), []);
|
||
});
|
||
|
||
void test('the ordinary response never reads a face or rival resources to infer the hidden card family', () => {
|
||
const game = projected();
|
||
Object.defineProperty(game, 'richeseAuction', { get() { throw new Error('public cache is unrelated to this ordinary purchase'); } });
|
||
Object.defineProperty(game, 'auction', { get() { throw new Error('ordinary face is not response entitlement'); } });
|
||
for (const player of game.players)
|
||
for (const field of ['hand', 'spice'])
|
||
Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } });
|
||
assert.match(markup(game), /concealed ordinary auction lot/);
|
||
assert.deepEqual(choose(game, 'Spend Richese Nexus'), [{ type: 'richeseBetrayalUse', event: game.richeseBetrayalReaction!.event }]);
|
||
});
|
||
|
||
void test('every bot profile and autopilot passes a blind ordinary purchase even when Use is offered', () => {
|
||
for (const profile of DIFFICULTIES) {
|
||
const game = projected(), own = game.players.find(player => player.id === game.me)!;
|
||
own.bot = profile;
|
||
assert.equal(game.richeseBetrayalReaction!.canUse, true);
|
||
const expected = [{ type: 'richeseBetrayalPass', event: game.richeseBetrayalReaction!.event }];
|
||
assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
|
||
assert.deepEqual(botActions(game), expected);
|
||
delete own.bot;
|
||
own.autopilot = profile;
|
||
assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
|
||
}
|
||
});
|
||
|
||
void test('minimal bot responses still obey current acknowledgement eligibility and preserve old-source Use', () => {
|
||
const game = projected(), own = game.players.find(player => player.id === game.me)!;
|
||
assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
|
||
own.bot = 'Easy';
|
||
game.richeseBetrayalReaction!.hasPassed = true;
|
||
assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
|
||
game.richeseBetrayalReaction!.hasPassed = false;
|
||
game.richeseBetrayalReaction!.canPass = false;
|
||
assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
|
||
for (const kind of ['purchase', 'sale'] as const) {
|
||
const fixture = createRicheseBetrayalFixture(kind);
|
||
const old = viewGame(fixture.game, fixture.holder);
|
||
old.players.find(player => player.id === old.me)!.bot = 'Easy';
|
||
assert.deepEqual(botNexusRicheseBetrayalActions(old), [{ type: 'richeseBetrayalUse', event: old.richeseBetrayalReaction!.event }]);
|
||
assert.match(markup(old), /Use Richese Nexus/);
|
||
assert.doesNotMatch(markup(old), /Provisional online rule: spend the Richese Nexus/);
|
||
assert.match(markup(old), kind === 'purchase' ? /public cache lot/ : /concealed Black Market lot/);
|
||
}
|
||
});
|
||
|
||
aliases.deregister();
|