Allow Emperor gifts during Basic Ix phase openings
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
538247e777
commit
d4abc6bb83
9 files changed
+294
-4
No files matched your search
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTruthTurnAmalFixture, 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));
|
||||
|
||||
/** Original public Basic Ix Harkonnen/Atreides/Emperor setup opens the first
|
||||
* real phase. Stage only the pair and conserved Amal/Karama custody; passes,
|
||||
* gift, response and phase initialization use native actions. */
|
||||
function fixture() {
|
||||
const source = createTruthTurnAmalFixture();
|
||||
let game = source.game;
|
||||
const original = inventory(game);
|
||||
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
|
||||
const amalIndex = seat(game, 'emperor').hand.findIndex(card => card.effect === 'amal');
|
||||
assert.ok(amalIndex >= 0);
|
||||
seat(game, 'harkonnen').hand.push(seat(game, 'emperor').hand.splice(amalIndex, 1)[0]);
|
||||
// A real held opposing Karama keeps the gift's response open; existing
|
||||
// physical copies remain in their actual zones, never fabricated.
|
||||
for (const player of game.players) {
|
||||
const cards = player.hand.filter(card => card.effect === 'karama');
|
||||
player.hand = player.hand.filter(card => card.effect !== 'karama');
|
||||
game.deck.push(...cards);
|
||||
}
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [counter] = game.deck.splice(index, 1); seat(game, 'harkonnen').hand.push(counter);
|
||||
assert.deepEqual(inventory(game), original);
|
||||
assert.ok(game.phaseOpening);
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
game = act(game, 'atreides', {type:'ready'});
|
||||
assert.deepEqual(game.phaseOpening?.passed, ['emperor','atreides']);
|
||||
return {game, original, counter:counter.id, amal:source.amal};
|
||||
}
|
||||
|
||||
function passResponses(game: Game) {
|
||||
for (let count = 0; game.response && count < 15; count++) {
|
||||
const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
|
||||
for (const cancel of [false,true]) {
|
||||
void test(`Basic Ix opening gift ${cancel ? 'cancellation' : 'allowance'} resumes exact passes through the original opening`, () => {
|
||||
const f = fixture(), initial = f.game, opening = structuredClone(initial.phaseOpening);
|
||||
const emperor = seat(initial, 'emperor').spice, ally = seat(initial, 'atreides').spice;
|
||||
const phase = initial.phase, turn = initial.turn;
|
||||
assert.deepEqual(viewGame(initial, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:emperor});
|
||||
assert.equal(viewGame(initial, 'atreides').emperorPhaseOpeningGift, null);
|
||||
assert.equal(viewGame(initial, 'harkonnen').emperorPhaseOpeningGift, null);
|
||||
let game = act(initial, 'emperor', {type:'emperorGift', amount:3});
|
||||
assert.equal(game.response?.kind, 'emperorGift');
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(seat(game, 'emperor').spice, emperor);
|
||||
assert.equal(seat(game, 'atreides').spice, ally);
|
||||
assert.equal(viewGame(game, 'emperor').response?.amount, 3);
|
||||
assert.equal(viewGame(game, 'atreides').response?.amount, 3);
|
||||
assert.equal(viewGame(game, 'harkonnen').response?.amount, undefined);
|
||||
assert.equal(viewGame(game, 'emperor').emperorPhaseOpeningGift, null);
|
||||
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:1}));
|
||||
game = cancel ? act(copy(game), 'harkonnen', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(copy(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, emperor - (cancel ? 0 : 3));
|
||||
assert.equal(seat(game, 'atreides').spice, ally + (cancel ? 0 : 3));
|
||||
assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel));
|
||||
assert.throws(() => act(game, 'harkonnen', {type:'passResponse'}));
|
||||
|
||||
// The unpassed Harkonnen can still play the original physical Amal.
|
||||
const beforeAmal = game.players.map(player => player.spice);
|
||||
game = act(game, 'harkonnen', {type:'card', card:f.amal});
|
||||
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.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 id of ['emperor','atreides','harkonnen']) game = act(game, id, {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
||||
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
|
||||
assert.throws(() => act(game, 'harkonnen', {type:'card', card:f.amal}));
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
}
|
||||
|
||||
void test('all minimal bots can legally respond to an opening gift without losing its parent', () => {
|
||||
const f = fixture(), 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, 'harkonnen'); view.players.find(player => player.id === 'harkonnen')!.bot = difficulty;
|
||||
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card');
|
||||
assert.ok(action); game = act(game, 'harkonnen', action);
|
||||
game = passResponses(game);
|
||||
assert.deepEqual(game.phaseOpening, opening);
|
||||
assert.equal(game.phase, f.game.phase);
|
||||
}
|
||||
});
|
||||
|
||||
void test('invalid opening gifts and a mismatched original opening cannot commit', () => {
|
||||
const f = fixture(), before = copy(f.game);
|
||||
for (const [actor, action] of [
|
||||
['atreides', {type:'emperorGift', amount:1}],
|
||||
['emperor', {type:'emperorGift', amount:0}],
|
||||
['emperor', {type:'emperorGift', amount:11}],
|
||||
] as [string, Action][]) {
|
||||
assert.throws(() => act(f.game, actor, action));
|
||||
assert.deepEqual(f.game, before);
|
||||
}
|
||||
const unallied = copy(f.game);
|
||||
seat(unallied, 'emperor').ally = null; seat(unallied, 'atreides').ally = null;
|
||||
assert.equal(viewGame(unallied, 'emperor').emperorPhaseOpeningGift, null);
|
||||
assert.throws(() => act(unallied, 'emperor', {type:'emperorGift', amount:1}));
|
||||
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
|
||||
const altered = copy(pending);
|
||||
altered.response!.emperorPhaseOpeningGift!.opening.passed = [];
|
||||
assert.throws(() => viewGame(altered, 'emperor'), /original phase opening/);
|
||||
assert.throws(() => act(altered, 'harkonnen', {type:'passResponse'}), /original phase opening/);
|
||||
});
|
||||
|
||||
void test('a deferred Mentat initialization follows a gift and credits the original bribe once', () => {
|
||||
const f = createTruthTurnAmalFixture({phase:8, incoming:4});
|
||||
let game = f.game;
|
||||
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
|
||||
assert.equal(game.phaseOpening?.initialize, true);
|
||||
assert.equal(seat(game, 'emperor').spice, 10); assert.equal(seat(game, 'emperor').bribes, 4);
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
assert.deepEqual(viewGame(game, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:10});
|
||||
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:11}));
|
||||
game = act(game, 'emperor', {type:'emperorGift', amount:3});
|
||||
game = passResponses(game);
|
||||
assert.deepEqual(game.phaseOpening, {passed:['emperor'], initialize:true});
|
||||
assert.equal(seat(game, 'emperor').spice, 7); assert.equal(seat(game, 'emperor').bribes, 4);
|
||||
game = act(game, 'atreides', {type:'ready'});
|
||||
game = act(game, 'harkonnen', {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(seat(game, 'emperor').spice, 11); assert.equal(seat(game, 'emperor').bribes, 0);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
@@ -98,3 +99,31 @@ void test('an Extortion quote presents the recipient and restores its original d
|
||||
assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/);
|
||||
assert.doesNotMatch(table(observer), /Share spice with Harkonnen/);
|
||||
});
|
||||
|
||||
void test('a phase-opening quote gives only its owner the gift control', () => {
|
||||
const game = fixture();
|
||||
// Exercise the isolated projected contract; the engine owns its eligibility.
|
||||
const own: GameView = {...viewGame(game, 'e'), emperorGuildTimingGift:null,
|
||||
emperorExtortionGift:null, emperorPhaseOpeningGift:{recipient:'g', maximum:6}};
|
||||
const observer: GameView = {...viewGame(game, 'h'), emperorGuildTimingGift:null,
|
||||
emperorExtortionGift:null, emperorPhaseOpeningGift:null};
|
||||
const html = component(own);
|
||||
assert.match(html, /Share spice with Guild/);
|
||||
assert.match(html, /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.match(html, /type="number"[^>]*min="1"[^>]*max="6"/);
|
||||
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
|
||||
assert.match(component(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
assert.equal(component(observer), '');
|
||||
assert.equal(component({...own, emperorPhaseOpeningGift:null}), '');
|
||||
});
|
||||
|
||||
void test('the real Basic Ix opening table exposes the control only to the Emperor', () => {
|
||||
const {game} = createTruthTurnAmalFixture();
|
||||
game.players.find(player => player.id === 'emperor')!.ally = 'atreides';
|
||||
game.players.find(player => player.id === 'atreides')!.ally = 'emperor';
|
||||
const own = table(viewGame(game, 'emperor'));
|
||||
const observer = table(viewGame(game, 'harkonnen'));
|
||||
assert.match(own, /Before Storm/);
|
||||
assert.match(own, /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.doesNotMatch(observer, /Phase opening resumes after the Karama response to your gift/);
|
||||
});
|
||||
Reference in new issue
Block a user