Implement Tleilaxu and Ixian Nexus Cunning with durable private recovery
This commit is contained in:
1 parent
d5830d260c
commit
40912498b1
32 files changed
+2761
-24
No files matched your search
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import { NexusSuboids } from '../components/nexus-suboids';
|
||||
import { nexusSuboidsAction } from '../game/nexus-suboid-options';
|
||||
|
||||
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((m) => [m[1], m[1]]),
|
||||
);
|
||||
return {
|
||||
format: 'module',
|
||||
source: `export default ${JSON.stringify(classes)}`,
|
||||
shortCircuit: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
/** Synthetic private offer over an ordinary projected battle; source/custody
|
||||
* execution belongs to the engine regression suite. CSS aliases preserve real
|
||||
* component structure, without claiming browser or visual acceptance. */
|
||||
function fixture(): GameView {
|
||||
const g = createGame(
|
||||
'SUBOIDCONTROLS',
|
||||
newPlayer('i', 'Ixians', 'ixians'),
|
||||
true,
|
||||
);
|
||||
g.players.push(
|
||||
newPlayer('h', 'Harkonnen', 'harkonnen'),
|
||||
newPlayer('a', 'Atreides', 'atreides'),
|
||||
);
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 6,
|
||||
turn: 2,
|
||||
active: 'i',
|
||||
storm: 18,
|
||||
order: ['i', 'h', 'a'],
|
||||
});
|
||||
for (const player of g.players.slice(0, 2)) {
|
||||
player.forces = { 'arrakeen:10': 5 };
|
||||
player.reserves = 15;
|
||||
player.spice = 10;
|
||||
}
|
||||
g.players[0].elites!.forces = { 'arrakeen:10': 2 };
|
||||
g.players[0].elites!.reserves = 5;
|
||||
g.battle = {
|
||||
event: 'suboid-battle',
|
||||
territory: 'arrakeen',
|
||||
attacker: 'i',
|
||||
defender: 'h',
|
||||
prepared: true,
|
||||
plans: {},
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
const v = viewGame(g, 'i');
|
||||
v.nexusCards = {
|
||||
card: 'ixians',
|
||||
deckCount: 11,
|
||||
discardCount: 0,
|
||||
held: { i: true, h: false, a: false },
|
||||
turn: null,
|
||||
choices: [],
|
||||
waiting: [],
|
||||
};
|
||||
v.nexusSuboids = {
|
||||
offer: { event: 'suboid-event', blocked: null },
|
||||
active: false,
|
||||
};
|
||||
return v;
|
||||
}
|
||||
const html = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(NexusSuboids, { game, busy, act() {} }));
|
||||
|
||||
void test('Ixian Cunning binds an owned unsealed battle and authoritative commitment block', () => {
|
||||
const v = fixture();
|
||||
assert.deepEqual(nexusSuboidsAction(v, 'suboid-event'), {
|
||||
type: 'nexusSuboids',
|
||||
event: 'suboid-event',
|
||||
});
|
||||
assert.match(html(v), /rest of this turn/);
|
||||
assert.match(html(v), /Cyborgs keep their usual strength and support rules/);
|
||||
assert.doesNotMatch(html(v), /disabled=""/);
|
||||
assert.match(html(v, true), /disabled=""/);
|
||||
assert.equal(nexusSuboidsAction(v, 'stale'), null);
|
||||
v.nexusSuboids!.offer!.blocked =
|
||||
'The existing plan commitment must remain feasible.';
|
||||
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
|
||||
assert.match(html(v), /existing plan commitment/);
|
||||
v.nexusSuboids!.offer!.blocked = null;
|
||||
v.battle!.submitted = ['i'];
|
||||
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
|
||||
v.battle!.submitted = [];
|
||||
v.phase = 5;
|
||||
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
|
||||
});
|
||||
|
||||
void test('public active notice identifies Ixian Suboids and never reads an observer’s hidden cards', () => {
|
||||
const v = fixture();
|
||||
v.nexusSuboids = { offer: null, active: true };
|
||||
v.me = 'h';
|
||||
v.nexusCards = null;
|
||||
for (const player of v.players)
|
||||
for (const key of ['hand', 'traitors', 'faceDancers'])
|
||||
Object.defineProperty(player, key, {
|
||||
get() {
|
||||
throw new Error('Private card read');
|
||||
},
|
||||
});
|
||||
const text = html(v);
|
||||
assert.match(text, /Ixian Suboids fight at full strength/);
|
||||
assert.doesNotMatch(text, /<button|your Suboids/);
|
||||
assert.equal(nexusSuboidsAction(v, 'suboid-event'), null);
|
||||
v.nexusSuboids = null;
|
||||
assert.equal(html(v), '');
|
||||
});
|
||||
|
||||
void test('actual battle editor bounds paid support to Cyborgs when projected Suboids have free support', () => {
|
||||
const v = fixture();
|
||||
v.nexusSuboids = { offer: null, active: true };
|
||||
const forces = v.battle!.ownForces!;
|
||||
forces.normalFixedHalf = false;
|
||||
forces.normalFreeSupport = true;
|
||||
const render = () =>
|
||||
renderToStaticMarkup(
|
||||
createElement(GameTable, {
|
||||
game: v,
|
||||
send: async () => {},
|
||||
onExit() {},
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
const text = render();
|
||||
const input = text.match(/<input[^>]*id="battle-spice"[^>]*>/)?.[0];
|
||||
assert.ok(input);
|
||||
assert.match(input, /max="2"/);
|
||||
assert.match(text, /Spice support here applies only to Cyborgs/);
|
||||
forces.elite = 0;
|
||||
assert.match(
|
||||
render().match(/<input[^>]*id="battle-spice"[^>]*>/)![0],
|
||||
/max="0"/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user