200 lines
10 KiB
TypeScript
200 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { registerHooks } from 'node:module';
|
||
import { readFileSync } from 'node:fs';
|
||
import { createElement } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { baseDeck } from '../game/cards';
|
||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||
import { botActions } from '../game/bots';
|
||
import { applyAction, battles, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||
import { traitorDeck } from '../game/traitors';
|
||
|
||
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 {GameTable} = await import('../components/game-table');
|
||
aliases.deregister();
|
||
|
||
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
|
||
const physicalTraitors = (game: Game) => [
|
||
...(game.traitorReserve ?? []), ...game.players.flatMap(seat => [...seat.traitors, ...seat.traitorChoices]),
|
||
].sort();
|
||
|
||
/** Three-member groups cannot be started in the lobby. Stage a Nexus, form
|
||
* the pair and group through actual actions, then stage conserved forces in
|
||
* Arrakeen and transfer physical Traitor identities out of their reserve. */
|
||
function source(ally: 'e' | 'f' | 'h', options: { group?: boolean; mutual?: boolean } = {}) {
|
||
let game = createGame('GROUPTRAITOR', newPlayer('h', 'Harkonnen', 'harkonnen'), false);
|
||
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
|
||
newPlayer('g', 'Guild', 'guild'));
|
||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['h','e','f','g'],
|
||
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||
game.coalitionState = emptyCoalitionState();
|
||
game = send(game, 'h', {type:'alliance', target:'e'});
|
||
game = send(game, 'e', {type:'alliance', target:'h'});
|
||
if (options.group !== false) {
|
||
const pair = coalitionOf(game.players, game.coalitionState, 'h'); assert.ok(pair);
|
||
game = send(game, 'h', {type:'coalitionPropose', groupId:pair.id, entrant:'f',
|
||
expectedRevision:game.coalitionState!.revision});
|
||
for (const id of ['e','f']) {
|
||
const proposal = game.coalitionState!.proposal!;
|
||
game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
|
||
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||
}
|
||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'h')!.members, ['e','f','h']);
|
||
}
|
||
Object.assign(game, {phase:6, nexus:false, ready:[], response:null, decision:null,
|
||
phaseOpening:null, battle:null});
|
||
for (const seat of game.players) {
|
||
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
|
||
seat.hand = []; seat.traitors = []; seat.traitorChoices = [];
|
||
}
|
||
for (const id of [ally,'g']) {
|
||
player(game, id).forces = {'arrakeen:10':4};
|
||
player(game, id).reserves = 16;
|
||
}
|
||
const opponentLeader = player(game, 'g').leaders[0].id;
|
||
const allyLeader = player(game, ally).leaders[0].id;
|
||
game.traitorReserve = traitorDeck(game.players);
|
||
const inventory = physicalTraitors(game);
|
||
const transfer = (identity: string, holder: string) => {
|
||
const index = game.traitorReserve!.indexOf(identity); assert.ok(index >= 0);
|
||
player(game, holder).traitors.push(...game.traitorReserve!.splice(index, 1));
|
||
};
|
||
transfer(opponentLeader, 'h');
|
||
if (options.mutual) transfer(allyLeader, 'g');
|
||
assert.deepEqual(physicalTraitors(game), inventory);
|
||
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karamaIndex >= 0);
|
||
const [karama] = game.deck.splice(karamaIndex, 1); player(game, 'g').hand.push(karama);
|
||
const choice = battles(game).find(candidate => candidate.territory === 'arrakeen' &&
|
||
[candidate.attacker,candidate.defender].includes(ally) && [candidate.attacker,candidate.defender].includes('g'));
|
||
assert.ok(choice);
|
||
game.active = choice.chooser;
|
||
const enemy = choice.attacker === choice.chooser ? choice.defender : choice.attacker;
|
||
game = send(game, choice.chooser, {type:'chooseBattle', territory:'arrakeen', target:enemy});
|
||
assert.ok(game.battle && !game.battle.preparation);
|
||
game = send(game, ally, {type:'battlePlan', leader:allyLeader, dial:0, support:0, weapon:null, defense:null});
|
||
assert.ok(!game.battle?.revealed);
|
||
game = send(game, 'g', {type:'battlePlan', leader:opponentLeader, dial:0, support:0, weapon:null, defense:null});
|
||
assert.equal(game.battle?.revealed, true);
|
||
return {game, ally, opponentLeader, allyLeader, karama, inventory};
|
||
}
|
||
function passResponses(game: Game): Game {
|
||
for (let count = 0; game.response && count < 24; count++) {
|
||
const next = game.players.find(seat => !game.response!.passed.includes(seat.id));
|
||
assert.ok(next);
|
||
game = send(game, next.id, {type:'passResponse'});
|
||
}
|
||
assert.equal(game.response ?? null, null);
|
||
return game;
|
||
}
|
||
function conserved(game: Game) {
|
||
for (const seat of game.players)
|
||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20);
|
||
}
|
||
function reject(game: Game, id: string, action: Action) {
|
||
const before = structuredClone(game);
|
||
assert.throws(() => send(game, id, action));
|
||
assert.deepEqual(game, before);
|
||
}
|
||
|
||
for (const ally of ['e','f'] as const) {
|
||
void test(`Harkonnen may reveal its Guild traitor for group ally ${ally}`, () => {
|
||
const f = source(ally); let game = f.game;
|
||
const own = viewGame(game, 'h').battle!;
|
||
assert.ok(own.traitorVoters.includes('h'));
|
||
assert.equal(own.traitorBeneficiary, ally);
|
||
assert.ok(own.traitorCallChoices?.includes(true));
|
||
game = send(game, 'h', {type:'traitorCall', call:true});
|
||
assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||
assert.equal(game.battle?.traitorCalls.h, true);
|
||
game = passResponses(game);
|
||
game = send(game, ally, {type:'traitorCall', call:false});
|
||
game = send(game, 'g', {type:'traitorCall', call:false});
|
||
assert.equal(game.lastBattleContext?.result, 'traitor');
|
||
assert.equal(game.lastBattleContext?.winner, ally);
|
||
assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true);
|
||
assert.deepEqual(physicalTraitors(game), f.inventory);
|
||
conserved(game);
|
||
});
|
||
void test(`Karama cancels Harkonnen's group traitor call for ${ally}`, () => {
|
||
const f = source(ally); let game = send(f.game, 'h', {type:'traitorCall', call:true});
|
||
assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||
game = send(game, 'g', {type:'card', mode:'cancel', card:f.karama.id});
|
||
game = passResponses(game);
|
||
assert.equal(game.battle?.traitorCalls.h, false);
|
||
reject(game, 'h', {type:'traitorCall', call:true});
|
||
game = send(game, ally, {type:'traitorCall', call:false});
|
||
game = send(game, 'g', {type:'traitorCall', call:false});
|
||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||
assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1);
|
||
assert.deepEqual(physicalTraitors(game), f.inventory);
|
||
conserved(game);
|
||
});
|
||
}
|
||
|
||
void test('an opposing Guild call and Harkonnen group call destroy both sides', () => {
|
||
const f = source('f', {mutual:true}); let game = send(f.game, 'g', {type:'traitorCall', call:true});
|
||
game = send(game, 'h', {type:'traitorCall', call:true});
|
||
game = passResponses(game);
|
||
game = send(game, 'f', {type:'traitorCall', call:false});
|
||
assert.equal(game.lastBattleContext?.result, 'mutualTraitors');
|
||
assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true);
|
||
assert.equal(player(game, 'f').leaders.find(leader => leader.id === f.allyLeader)?.dead, true);
|
||
assert.deepEqual(physicalTraitors(game), f.inventory);
|
||
conserved(game);
|
||
});
|
||
|
||
void test('the existing two-member Harkonnen ally call keeps its implicit beneficiary', () => {
|
||
const f = source('e', {group:false});
|
||
assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'e');
|
||
const game = send(f.game, 'h', {type:'traitorCall', call:true});
|
||
assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||
});
|
||
|
||
void test('Harkonnen still calls its own traitor as a combatant in a group', () => {
|
||
const f = source('h');
|
||
assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'h');
|
||
const game = send(f.game, 'h', {type:'traitorCall', call:true});
|
||
assert.equal(game.battle?.traitorCalls.h, true);
|
||
assert.notEqual(game.response?.kind, 'harkonnenTraitor');
|
||
});
|
||
|
||
void test('a Medium Harkonnen bot offers a legal allied group traitor call', () => {
|
||
const {game} = source('f');
|
||
const view = viewGame(game, 'h');
|
||
view.players.find(seat => seat.id === 'h')!.bot = 'Medium';
|
||
const action = botActions(view).find(candidate => candidate.type === 'traitorCall');
|
||
assert.ok(action); assert.equal(action.call, true);
|
||
assert.equal(send(game, 'h', action).response?.kind, 'harkonnenTraitor');
|
||
});
|
||
|
||
void test('the actual group table gives Harkonnen an enabled allied call without revealing its hand to the other ally', () => {
|
||
const {game, opponentLeader} = source('e');
|
||
const own = viewGame(game, 'h');
|
||
const ownMarkup = renderToStaticMarkup(createElement(GameTable, {
|
||
game:own, busy:false, send:async () => {}, onExit() {},
|
||
}));
|
||
const button = ownMarkup.match(/<button\b[^>]*>(?:(?!<\/button>)[\s\S])*Reveal ally’s opponent as traitor(?:(?!<\/button>)[\s\S])*<\/button>/)?.[0];
|
||
assert.ok(button, 'The allied call is offered to Harkonnen.');
|
||
assert.doesNotMatch(button, /\bdisabled(?:=|\s|>)/);
|
||
assert.ok(own.players.find(seat => seat.id === 'h')?.traitors?.includes(opponentLeader));
|
||
const observer = viewGame(game, 'f');
|
||
assert.equal(observer.players.find(seat => seat.id === 'h')?.traitors, undefined);
|
||
const observerMarkup = renderToStaticMarkup(createElement(GameTable, {
|
||
game:observer, busy:false, send:async () => {}, onExit() {},
|
||
}));
|
||
assert.doesNotMatch(observerMarkup, /Reveal ally’s opponent as traitor/);
|
||
});
|