Allow Emperor gift during Guild timing choice
Verify and publish checkpoint container / container (push) Successful in 11m8s
Verify and publish checkpoint container / container (push) Successful in 11m8s
This commit is contained in:
1 parent
a9b67a89b3
commit
93b0146de9
9 files changed
+383
-2
No files matched your search
@@ -0,0 +1,161 @@
|
||||
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';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
const copy = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
|
||||
/** A real Advanced public start and native setup. The reciprocal alliance and
|
||||
* clean movement boundary are staged; no saved room or database is touched. */
|
||||
function guildTiming() {
|
||||
let g = createGame('EMPGUILDTIMING', newPlayer('e', 'Emperor', 'emperor'), true);
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
for (const p of g.players) g = applyAction(g, p.id, {type:'ready'});
|
||||
g = applyAction(g, 'e', {type:'start', advancedPreview:true});
|
||||
for (let i = 0; i < 60 && g.status === 'setup'; i++) {
|
||||
const step = nextStrongholdFactionsNativeStep(g);
|
||||
assert.ok(step); g = applyAction(g, step.actor, step.action);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
player(g, 'e').ally = 'g'; player(g, 'g').ally = 'e';
|
||||
// Select the legitimate phase boundary that movementTurn would produce.
|
||||
Object.assign(g, {
|
||||
phase:5, turn:1, order:['e','g','h'], active:null,
|
||||
movementRemaining:['e','g','h'], guildTimingGranted:false, guildTimingLocked:false,
|
||||
decision:{kind:'guildTiming', player:'g', next:'e', following:'e'},
|
||||
response:null, phaseOpening:null, ready:[],
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'guildTiming');
|
||||
return g;
|
||||
}
|
||||
function allow(g: Game) {
|
||||
for (let i = 0; g.response && i < 15; i++) {
|
||||
const actor = g.players.find(p => !g.response!.passed.includes(p.id));
|
||||
assert.ok(actor); g = applyAction(g, actor.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
return g;
|
||||
}
|
||||
function parent(g: Game) {
|
||||
return {decision:structuredClone(g.decision), phase:g.phase, turn:g.turn,
|
||||
active:g.active, order:[...g.order], movementRemaining:structuredClone(g.movementRemaining),
|
||||
guildTimingGranted:g.guildTimingGranted, guildTimingLocked:g.guildTimingLocked};
|
||||
}
|
||||
function give(g: Game, amount = 2) {
|
||||
return applyAction(g, 'e', {type:'emperorGift', amount});
|
||||
}
|
||||
function setAsideKaramas(g: Game) {
|
||||
for (const p of g.players) {
|
||||
const cards = p.hand.filter(card => card.effect === 'karama');
|
||||
p.hand = p.hand.filter(card => card.effect !== 'karama');
|
||||
g.deck.push(...cards);
|
||||
}
|
||||
}
|
||||
function holdCounter(g: Game) {
|
||||
setAsideKaramas(g);
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const [card] = g.deck.splice(index, 1);
|
||||
player(g, 'h').hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
function guildChooses(g: Game) {
|
||||
assert.equal(g.decision?.kind, 'guildTiming');
|
||||
g = applyAction(g, 'g', {type:'decision', take:true});
|
||||
if (g.response) g = allow(g);
|
||||
assert.equal(g.active, 'g');
|
||||
assert.equal(g.phase, 5);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('Advanced Guild timing shows only the Emperor a legal gift and resumes the exact choice after allowing', () => {
|
||||
let g = guildTiming();
|
||||
holdCounter(g);
|
||||
const original = parent(g), emperor = player(g, 'e').spice, guild = player(g, 'g').spice;
|
||||
assert.deepEqual(viewGame(g, 'e').emperorGuildTimingGift, {recipient:'g', maximum:emperor});
|
||||
assert.equal(viewGame(g, 'g').emperorGuildTimingGift, null);
|
||||
assert.equal(viewGame(g, 'h').emperorGuildTimingGift, null);
|
||||
g = give(g);
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
assert.equal(player(g, 'e').spice, emperor);
|
||||
assert.equal(player(g, 'g').spice, guild);
|
||||
assert.equal(viewGame(g, 'e').emperorGuildTimingGift, null);
|
||||
g = allow(copy(g));
|
||||
assert.deepEqual(parent(g), original);
|
||||
assert.equal(player(g, 'e').spice, emperor - 2);
|
||||
assert.equal(player(g, 'g').spice, guild + 2);
|
||||
guildChooses(g);
|
||||
});
|
||||
|
||||
void test('a real Karama cancels the gift and restores the same Guild timing decision', () => {
|
||||
let g = guildTiming();
|
||||
const counter = holdCounter(g), original = parent(g);
|
||||
const emperor = player(g, 'e').spice, guild = player(g, 'g').spice;
|
||||
g = give(g, 3);
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
g = applyAction(g, 'h', {type:'card', card:counter, mode:'cancel'});
|
||||
assert.equal(g.response, null);
|
||||
assert.deepEqual(parent(g), original);
|
||||
assert.equal(player(g, 'e').spice, emperor);
|
||||
assert.equal(player(g, 'g').spice, guild);
|
||||
assert.equal(g.discard.filter(card => card.id === counter).length, 1);
|
||||
guildChooses(g);
|
||||
});
|
||||
|
||||
void test('other players and unfunded or invalid gifts cannot disturb the pending Guild decision', () => {
|
||||
const g = guildTiming(), maximum = player(g, 'e').spice;
|
||||
const attempts: [string, Action][] = [
|
||||
['g', {type:'emperorGift', amount:1}], ['h', {type:'emperorGift', amount:1}],
|
||||
['e', {type:'emperorGift', amount:0}], ['e', {type:'emperorGift', amount:maximum + 1}],
|
||||
];
|
||||
for (const [actor, action] of attempts) {
|
||||
const before = copy(g);
|
||||
assert.throws(() => applyAction(g, actor, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
const unallied = copy(g);
|
||||
player(unallied, 'e').ally = null; player(unallied, 'g').ally = null;
|
||||
const before = copy(unallied);
|
||||
assert.equal(viewGame(unallied, 'e').emperorGuildTimingGift, null);
|
||||
assert.throws(() => give(unallied));
|
||||
assert.deepEqual(unallied, before);
|
||||
});
|
||||
|
||||
void test('the pending gift remains bound to its original amount and Guild timing decision', () => {
|
||||
const original = guildTiming();
|
||||
holdCounter(original);
|
||||
const pending = give(original, 2);
|
||||
const changedAmount = copy(pending);
|
||||
assert.ok(changedAmount.response);
|
||||
changedAmount.response.amount = 1;
|
||||
assert.throws(() => viewGame(changedAmount, 'g'));
|
||||
assert.throws(() => applyAction(changedAmount, 'h', {type:'passResponse'}));
|
||||
const changedDecision = copy(pending);
|
||||
assert.ok(changedDecision.decision?.kind === 'guildTiming');
|
||||
changedDecision.decision.next = 'h';
|
||||
assert.throws(() => viewGame(changedDecision, 'e'));
|
||||
assert.throws(() => applyAction(changedDecision, 'h', {type:'passResponse'}));
|
||||
assert.equal(viewGame(pending, 'h').response?.amount, undefined);
|
||||
});
|
||||
|
||||
void test('all four minimal bot profiles can resolve the gift response legally', () => {
|
||||
let g = guildTiming();
|
||||
holdCounter(g);
|
||||
const original = parent(g);
|
||||
g = give(g, 1);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let trial = copy(g);
|
||||
const view = viewGame(trial, 'h');
|
||||
view.players.find(p => p.id === 'h')!.bot = difficulty;
|
||||
const proposed = botActions(view);
|
||||
assert.deepEqual(proposed, [{type:'passResponse'}]);
|
||||
trial = applyAction(trial, 'h', proposed[0]);
|
||||
trial = allow(trial);
|
||||
assert.deepEqual(parent(trial), original);
|
||||
guildChooses(trial);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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 { EmperorTimingGift } from '../components/emperor-timing-gift';
|
||||
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 public Advanced start and native setup, followed by a staged mutual
|
||||
* alliance and clean Guild timing boundary, as in the engine journey. */
|
||||
function fixture(): Game {
|
||||
let game = createGame('EMPGIFTCONTROL', newPlayer('e','Emperor','emperor'), true);
|
||||
joinGame(game, newPlayer('g','Guild','guild'));
|
||||
joinGame(game, newPlayer('h','Harkonnen','harkonnen'));
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
|
||||
game = applyAction(game, 'e', {type:'start', advancedPreview:true});
|
||||
for (let step = 0; game.status === 'setup' && step < 60; step++) {
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
game.players.find(player => player.id === 'e')!.ally = 'g';
|
||||
game.players.find(player => player.id === 'g')!.ally = 'e';
|
||||
Object.assign(game, {phase:5, turn:1, order:['e','g','h'], active:null,
|
||||
movementRemaining:['e','g','h'], guildTimingGranted:false, guildTimingLocked:false,
|
||||
decision:{kind:'guildTiming', player:'g', next:'e', following:'e'},
|
||||
response:null, phaseOpening:null, ready:[]});
|
||||
return game;
|
||||
}
|
||||
const component = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(EmperorTimingGift, {game, busy, act() {}}));
|
||||
const table = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}}));
|
||||
|
||||
void test('quoted Guild timing gift shows the ally, amount bounds and explanation', () => {
|
||||
const game = fixture(), view = viewGame(game, 'e');
|
||||
assert.deepEqual(view.emperorGuildTimingGift, {recipient:'g', maximum:10});
|
||||
const html = component(view);
|
||||
assert.match(html, /Share spice with Guild/);
|
||||
assert.match(html, /Guild timing decision resumes after the Karama response/);
|
||||
assert.match(html, /Spice to share/);
|
||||
assert.match(html, /type="number"[^>]*min="1"[^>]*max="10"/);
|
||||
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
|
||||
});
|
||||
|
||||
void test('the gift component hides absent quotes and disables submission while busy', () => {
|
||||
const game = fixture(), own = viewGame(game, 'e'), other = viewGame(game, 'h');
|
||||
assert.equal(other.emperorGuildTimingGift, null);
|
||||
assert.equal(component(other), '');
|
||||
const busy = component(own, true);
|
||||
assert.match(busy, /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
assert.match(busy, /<input\b[^>]*disabled=""/);
|
||||
});
|
||||
|
||||
void test('the real Guild timing table exposes the gift only to the Emperor', () => {
|
||||
const game = fixture(), own = viewGame(game, 'e'), guild = viewGame(game, 'g'), observer = viewGame(game, 'h');
|
||||
assert.equal(game.decision?.kind, 'guildTiming');
|
||||
assert.match(table(own), /Share spice with Guild/);
|
||||
assert.match(table(own), /Guild timing decision resumes after the Karama response/);
|
||||
assert.doesNotMatch(table(guild), /Share spice with Guild/);
|
||||
assert.doesNotMatch(table(observer), /Share spice with Guild/);
|
||||
assert.match(table(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
});
|
||||
Reference in new issue
Block a user