Support native Ix factions in Emperor opening gifts
Verify and publish checkpoint container / container (push) Successful in 10m18s
Verify and publish checkpoint container / container (push) Successful in 10m18s
This commit is contained in:
1 parent
d4abc6bb83
commit
171bc3a636
7 files changed
+194
-11
No files matched your search
@@ -0,0 +1,73 @@
|
||||
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 { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
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();
|
||||
|
||||
/** A native public Basic Ix start and physical setup. Alliance is staged
|
||||
* because the first Storm opening precedes the first Nexus. */
|
||||
function opening(ally: 'ixians' | 'tleilaxu'): Game {
|
||||
let game = createGame('IXGIFTUI', newPlayer('emperor', 'Emperor', 'emperor'), false, ['ix']);
|
||||
joinGame(game, newPlayer('ixians', 'Ixians', 'ixians'));
|
||||
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
|
||||
game = applyAction(game, 'emperor', {type:'start'});
|
||||
for (let step = 0; game.status === 'setup' && step < 100; step++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.phase, 0);
|
||||
assert.ok(game.phaseOpening);
|
||||
game.players.find(player => player.id === 'emperor')!.ally = ally;
|
||||
game.players.find(player => player.id === ally)!.ally = 'emperor';
|
||||
return game;
|
||||
}
|
||||
const table = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}}));
|
||||
|
||||
for (const ally of ['ixians', 'tleilaxu'] as const) {
|
||||
void test(`native ${ally} ally opening shows an enabled owner-only Emperor gift control`, () => {
|
||||
let game = opening(ally);
|
||||
const other = ally === 'ixians' ? 'tleilaxu' : 'ixians';
|
||||
game = applyAction(game, 'emperor', {type:'ready'});
|
||||
const ownView = viewGame(game, 'emperor');
|
||||
assert.deepEqual(ownView.emperorPhaseOpeningGift, {recipient:ally, maximum:10});
|
||||
assert.equal(viewGame(game, ally).emperorPhaseOpeningGift, null);
|
||||
assert.equal(viewGame(game, other).emperorPhaseOpeningGift, null);
|
||||
const own = table(ownView);
|
||||
assert.match(own, /Before Storm/);
|
||||
assert.match(own, new RegExp(`Share spice with ${ally === 'ixians' ? 'Ixians' : 'Tleilaxu'}`));
|
||||
assert.match(own, /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.match(own, /type="number"[^>]*min="1"[^>]*max="10"/);
|
||||
assert.match(own, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
|
||||
assert.match(table(ownView, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
assert.doesNotMatch(table(viewGame(game, ally)), /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.doesNotMatch(table(viewGame(game, other)), /Phase opening resumes after the Karama response to your gift/);
|
||||
|
||||
// SSR checks the enabled control; dispatch the advertised action through the
|
||||
// same native engine separately, without claiming a browser click occurred.
|
||||
const openingBefore = structuredClone(game.phaseOpening);
|
||||
const next = applyAction(game, 'emperor', {type:'emperorGift', amount:1});
|
||||
assert.ok(next.response || next.phaseOpening);
|
||||
if (!next.response) assert.deepEqual(next.phaseOpening, openingBefore);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
|
||||
/** Public Basic setup seats both native Ix factions. Only the reciprocal
|
||||
* alliance and the physical Amal/Karama custody are staged after setup. */
|
||||
function fixture(recipient: 'ixians' | 'tleilaxu') {
|
||||
let game = createGame('EMPIXOPEN', newPlayer('emperor', 'Emperor', 'emperor'), false, ['ix']);
|
||||
for (const id of ['ixians', 'tleilaxu', 'atreides'] as const) joinGame(game, newPlayer(id, id, id));
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'emperor', {type:'start'});
|
||||
for (let i = 0; game.status === 'setup' && i < 100; i++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(game.phaseOpening);
|
||||
const original = inventory(game);
|
||||
seat(game, 'emperor').ally = recipient; seat(game, recipient).ally = 'emperor';
|
||||
// Move existing cards, including any originally dealt copy, into the deck;
|
||||
// then give the unpassed rival one real copy of each required card.
|
||||
for (const player of game.players) {
|
||||
const held = player.hand.filter(card => card.effect === 'amal' || card.effect === 'karama');
|
||||
player.hand = player.hand.filter(card => card.effect !== 'amal' && card.effect !== 'karama');
|
||||
game.deck.push(...held);
|
||||
}
|
||||
function transfer(effect: 'amal' | 'karama') {
|
||||
const index = game.deck.findIndex(card => card.effect === effect); assert.ok(index >= 0);
|
||||
const [card] = game.deck.splice(index, 1); seat(game, 'atreides').hand.push(card); return card.id;
|
||||
}
|
||||
const amal = transfer('amal'), counter = transfer('karama');
|
||||
assert.deepEqual(inventory(game), original);
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
game = act(game, recipient, {type:'ready'});
|
||||
assert.deepEqual(game.phaseOpening?.passed, ['emperor', recipient]);
|
||||
return {game, original, amal, counter};
|
||||
}
|
||||
function passResponses(game: Game) {
|
||||
for (let i = 0; game.response && i < 16; i++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
for (const recipient of ['ixians', 'tleilaxu'] as const) {
|
||||
for (const cancel of [false, true]) {
|
||||
void test(`Basic Ix native ${recipient} opening gift ${cancel ? 'cancellation' : 'allowance'} preserves passes and later Amal`, () => {
|
||||
const f = fixture(recipient), opening = structuredClone(f.game.phaseOpening);
|
||||
const beforeOwner = seat(f.game, 'emperor').spice, beforeAlly = seat(f.game, recipient).spice;
|
||||
const phase = f.game.phase, turn = f.game.turn;
|
||||
assert.deepEqual(viewGame(f.game, 'emperor').emperorPhaseOpeningGift, {recipient, maximum:beforeOwner});
|
||||
assert.equal(viewGame(f.game, recipient).emperorPhaseOpeningGift, null);
|
||||
assert.equal(viewGame(f.game, 'atreides').emperorPhaseOpeningGift, null);
|
||||
let game = act(f.game, 'emperor', {type:'emperorGift', amount:3});
|
||||
assert.equal(game.response?.kind, 'emperorGift'); assert.equal(game.phaseOpening, null);
|
||||
assert.equal(viewGame(game, 'atreides').response?.amount, undefined);
|
||||
game = cancel ? act(game, 'atreides', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(game);
|
||||
assert.equal(game.response, null); assert.deepEqual(game.phaseOpening, opening);
|
||||
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
||||
assert.equal(seat(game, 'emperor').spice, beforeOwner - (cancel ? 0 : 3));
|
||||
assert.equal(seat(game, recipient).spice, beforeAlly + (cancel ? 0 : 3));
|
||||
assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel));
|
||||
const wallets = game.players.map(player => player.spice);
|
||||
game = act(game, 'atreides', {type:'card', card:f.amal});
|
||||
assert.deepEqual(game.players.map(player => player.spice), wallets.map(amount => Math.floor(amount / 2)));
|
||||
assert.deepEqual(game.phaseOpening?.passed, []);
|
||||
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1);
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null); assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
}
|
||||
}
|
||||
void test('all minimal bots can respond to a native Ix opening gift', () => {
|
||||
const f = fixture('ixians'), opening = structuredClone(f.game.phaseOpening);
|
||||
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = copy(pending);
|
||||
const view = viewGame(game, 'atreides'); view.players.find(player => player.id === 'atreides')!.bot = difficulty;
|
||||
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card');
|
||||
assert.ok(action); game = act(game, 'atreides', action); game = passResponses(game);
|
||||
assert.deepEqual(game.phaseOpening, opening); assert.equal(game.phase, f.game.phase);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user