Allow Emperor gifts during Basic Ix phase openings
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 06:33:41 +11:00
1 parent 538247e777
commit d4abc6bb83
9 files changed
+294 -4

No files matched your search

+144
View File
@@ -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/);
});