Allow Basic bribes during selected pending decisions
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
171bc3a636
commit
72f6ec2b12
11 files changed
+435
-29
No files matched your search
@@ -132,13 +132,13 @@ void test('bribe controls render recipient choices, private escrow and disabled
|
||||
assert.match(html, /Bribe recipient/);
|
||||
assert.match(html, /Harkonnen recipient/);
|
||||
assert.match(html, /collects their share at the start of Mentat Pause/);
|
||||
assert.match(html, /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/);
|
||||
assert.match(html, /Payments made after its opening wait until the start of next turn's Mentat Pause/);
|
||||
assert.match(html, /disabled=""[^>]*>Pay bribe/);
|
||||
assert.match(html, /rules\?topic=bribes#bribes/);
|
||||
assert.ok((render(g, true).match(/disabled=""/g) ?? []).length >= 3);
|
||||
g.phase = 8;
|
||||
assert.equal(options(g).blocked, null);
|
||||
assert.match(render(g), /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/);
|
||||
assert.match(render(g), /Payments made after its opening wait until the start of next turn's Mentat Pause/);
|
||||
g.status = 'lobby';
|
||||
assert.equal(render(g), '');
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { bribeAction } from '../game/bribe-options';
|
||||
import { territory } from '../game/board';
|
||||
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
|
||||
const aliases = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
} });
|
||||
const { Bribes } = await import('../components/bribes');
|
||||
aliases.deregister();
|
||||
|
||||
const act = (game: Game, actor: string, action: Parameters<typeof applyAction>[2]) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const render = (game: Game, actor: string, busy = false) =>
|
||||
renderToStaticMarkup(createElement(Bribes, { game: viewGame(game, actor), busy, act() {} }));
|
||||
|
||||
void test('actual Basic Ix Mentat opening offers a bribe for the upcoming initialization', () => {
|
||||
const f = createTruthTurnAmalFixture({phase:8, incoming:4});
|
||||
let game = f.game;
|
||||
assert.deepEqual(game.phaseOpening, {passed:[], initialize:true});
|
||||
const quote = viewGame(game, 'harkonnen').bribeOptions;
|
||||
assert.equal(quote.receiptTiming, 'upcoming');
|
||||
assert.equal(quote.blocked, null);
|
||||
assert.ok(quote.targets.some(target => target.id === 'emperor' && target.maximum >= 1));
|
||||
const html = render(game, 'harkonnen');
|
||||
assert.match(html, /This payment will be collected at the upcoming Mentat Pause\./);
|
||||
assert.match(html, /<select(?![^>]*disabled)[^>]*>/);
|
||||
assert.match(html, /Emperor/);
|
||||
assert.match(html, /<button\b[^>]*disabled=""[^>]*>Pay bribe<\/button>/);
|
||||
assert.match(render(game, 'harkonnen', true), /<select\b[^>]*disabled=""/);
|
||||
const action = bribeAction(quote, 'emperor', 1);
|
||||
assert.deepEqual(action, {type:'bribe', target:'emperor', amount:1});
|
||||
const before = {cash:seat(game,'emperor').spice, incoming:seat(game,'emperor').bribes};
|
||||
game = act(game, 'harkonnen', action!);
|
||||
assert.equal(seat(game,'emperor').spice, before.cash);
|
||||
assert.equal(seat(game,'emperor').bribes, before.incoming + 1);
|
||||
assert.deepEqual(game.phaseOpening, {passed:[], initialize:true});
|
||||
for (const id of ['harkonnen','atreides','emperor']) game = act(game, id, {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(seat(game,'emperor').bribes, 0);
|
||||
assert.equal(seat(game,'emperor').spice, before.cash + before.incoming + 1);
|
||||
const late = viewGame(game, 'harkonnen').bribeOptions;
|
||||
assert.equal(late.receiptTiming, 'nextTurn');
|
||||
assert.equal(late.blocked, null);
|
||||
assert.match(render(game,'harkonnen'), /This payment will be collected at the start of next turn’s Mentat Pause\./);
|
||||
});
|
||||
|
||||
/** Public E3 setup and actual native controls reach Moritani's first Mentat
|
||||
* placement; no pending decision or funds are manufactured by the test. */
|
||||
function moritaniPlacement(): Game {
|
||||
let game = createGame('BRIBENATIVE', newPlayer('moritani','Moritani','moritani'), false, ['ecaz']);
|
||||
joinGame(game, newPlayer('atreides','Atreides','atreides'));
|
||||
joinGame(game, newPlayer('emperor','Emperor','emperor'));
|
||||
for (const p of game.players) game = act(game,p.id,{type:'ready'});
|
||||
game = act(game,'moritani',{type:'start'});
|
||||
for (let i = 0; i < 1800; i++) {
|
||||
if (game.phase === 8 && game.decision?.kind === 'moritaniPlacement') return game;
|
||||
assert.notEqual(game.status,'finished');
|
||||
const next = game.decision?.kind === 'moritaniSetup'
|
||||
? {actor:'moritani', action:{type:'decision' as const, territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}}
|
||||
: nextStrongholdFactionsNativeStep(game);
|
||||
assert.ok(next, 'Native setup and turns must reach the original Mentat placement');
|
||||
game = act(game,next.actor,next.action);
|
||||
}
|
||||
throw Error('Native Mentat placement did not open');
|
||||
}
|
||||
|
||||
void test('a different pending Mentat decision stays blocked with next-turn receipt timing', () => {
|
||||
const game = moritaniPlacement();
|
||||
assert.equal(game.decision?.kind, 'moritaniPlacement');
|
||||
const quote = viewGame(game, 'atreides').bribeOptions;
|
||||
assert.equal(quote.receiptTiming,'nextTurn');
|
||||
assert.ok(quote.blocked);
|
||||
assert.equal(bribeAction(quote,'emperor',1),null);
|
||||
const html = render(game,'atreides');
|
||||
assert.match(html,/This payment will be collected at the start of next turn’s Mentat Pause\./);
|
||||
assert.match(html,/<select\b[^>]*disabled=""/);
|
||||
});
|
||||
|
||||
/** Public E3 setup and real shipment/Extortion reveal. The later clean movement
|
||||
* boundary and prior physical token placement are explicitly staged. */
|
||||
function moritaniExtortion(): Game {
|
||||
let game = createGame('BRIBEEXTORTION', newPlayer('moritani','Moritani','moritani'), false, ['ecaz']);
|
||||
for (const faction of ['emperor','atreides','harkonnen'] as const)
|
||||
joinGame(game,newPlayer(faction,faction,faction));
|
||||
for (const p of game.players) game = act(game,p.id,{type:'ready'});
|
||||
game = act(game,'moritani',{type:'start'});
|
||||
for (let i = 0; i < 100 && game.status === 'setup'; i++) {
|
||||
const next = game.decision?.kind === 'moritaniSetup'
|
||||
? {actor:'moritani', action:{type:'decision' as const, territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}}
|
||||
: nextStrongholdFactionsNativeStep(game);
|
||||
assert.ok(next); game = act(game,next.actor,next.action);
|
||||
}
|
||||
assert.equal(game.status,'playing');
|
||||
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token);
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!,token.id,'habbanya_ridge_sietch',1);
|
||||
Object.assign(game,{phase:5,turn:2,storm:18,order:['emperor','atreides','harkonnen','moritani'],
|
||||
active:'emperor',movementRemaining:['emperor','atreides','harkonnen','moritani'],
|
||||
response:null,decision:null,phaseOpening:null,ready:[]});
|
||||
game = act(game,'emperor',{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1});
|
||||
assert.equal(game.decision?.kind,'moritaniTerror');
|
||||
game = act(game,'moritani',{type:'decision',reveal:true});
|
||||
game.phase = 7; game.ready = [];
|
||||
for (const p of game.players) game = act(game,p.id,{type:'ready'});
|
||||
assert.equal(game.decision?.kind,'moritaniPlacement');
|
||||
game = act(game,'moritani',{type:'decision',decline:true});
|
||||
assert.equal(game.decision?.kind,'moritaniExtortion');
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('an actual Basic Extortion decision has its own next-turn bribe quote', () => {
|
||||
const game = moritaniExtortion();
|
||||
assert.equal(game.phase,8);
|
||||
assert.equal(game.decision?.kind,'moritaniExtortion');
|
||||
const quote = viewGame(game,'emperor').bribeOptions;
|
||||
assert.equal(quote.receiptTiming,'nextTurn');
|
||||
assert.equal(quote.blocked,null);
|
||||
const target = quote.targets.find(candidate => candidate.maximum >= 1); assert.ok(target);
|
||||
assert.deepEqual(bribeAction(quote,target.id,1), {type:'bribe',target:target.id,amount:1});
|
||||
const html = render(game,'emperor');
|
||||
assert.match(html,/This payment will be collected at the start of next turn’s Mentat Pause\./);
|
||||
assert.match(html,/<select(?![^>]*disabled)[^>]*>/);
|
||||
assert.match(render(game,'emperor',true),/<select\b[^>]*disabled=""/);
|
||||
const paid = act(game,'emperor',bribeAction(quote,target.id,1)!);
|
||||
assert.deepEqual(paid.decision,game.decision);
|
||||
assert.equal(seat(paid,target.id).bribes,seat(game,target.id).bribes + 1);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
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 { territory } from '../game/board';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { createTruthTurnAmalFixture, truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
|
||||
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));
|
||||
const pay = (target: string, amount: number): Action => ({type:'bribe', target, amount});
|
||||
|
||||
/** Public Basic Ix Harkonnen/Atreides/Emperor start. The Ix fixture uses a
|
||||
* real native phase-opening chronology and selects only unopened card order. */
|
||||
void test('Basic Ix opening bribe preserves passes and the native Amal uses the post-payment wallets', () => {
|
||||
const f = createTruthTurnAmalFixture();
|
||||
let game = f.game;
|
||||
const original = inventory(game);
|
||||
// Move the physical Amal to an unpassed seat so its later real play is possible.
|
||||
const owner = seat(game, 'emperor'), index = owner.hand.findIndex(card => card.id === f.amal);
|
||||
assert.ok(index >= 0); seat(game, 'atreides').hand.push(owner.hand.splice(index, 1)[0]);
|
||||
assert.deepEqual(inventory(game), original);
|
||||
game = act(game, 'harkonnen', {type:'ready'});
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
const opening = structuredClone(game.phaseOpening), payer = seat(game, 'harkonnen').spice;
|
||||
const recipient = seat(game, 'emperor').spice, deferred = seat(game, 'emperor').bribes;
|
||||
assert.equal(viewGame(game, 'harkonnen').bribeOptions.blocked, null);
|
||||
assert.equal(viewGame(game, 'harkonnen').bribeOptions.receiptTiming, 'upcoming');
|
||||
game = act(game, 'harkonnen', pay('emperor', 2));
|
||||
assert.deepEqual(game.phaseOpening, opening);
|
||||
assert.equal(seat(game, 'harkonnen').spice, payer - 2);
|
||||
assert.equal(seat(game, 'emperor').spice, recipient);
|
||||
assert.equal(seat(game, 'emperor').bribes, deferred + 2);
|
||||
const beforeAmal = game.players.map(player => player.spice);
|
||||
game = act(game, 'atreides', {type:'card', card:f.amal});
|
||||
assert.deepEqual(game.phaseOpening?.passed, []);
|
||||
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
|
||||
assert.equal(seat(game, 'emperor').bribes, deferred + 2);
|
||||
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.deepEqual(inventory(game), original);
|
||||
});
|
||||
|
||||
void test('bribe before real Mentat initialization is collected at that opening', () => {
|
||||
const f = createTruthTurnAmalFixture({phase:8, incoming:4});
|
||||
let game = f.game;
|
||||
assert.equal(game.phaseOpening?.initialize, true);
|
||||
game = act(game, 'atreides', {type:'ready'});
|
||||
const opening = structuredClone(game.phaseOpening), before = seat(game, 'emperor').spice;
|
||||
assert.equal(seat(game, 'emperor').bribes, 4);
|
||||
assert.equal(viewGame(game, 'atreides').bribeOptions.receiptTiming, 'upcoming');
|
||||
game = act(game, 'atreides', pay('emperor', 2));
|
||||
assert.deepEqual(game.phaseOpening, opening);
|
||||
assert.equal(seat(game, 'emperor').spice, before);
|
||||
assert.equal(seat(game, 'emperor').bribes, 6);
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
game = act(game, 'harkonnen', {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(seat(game, 'emperor').spice, before + 6);
|
||||
assert.equal(seat(game, 'emperor').bribes, 0);
|
||||
});
|
||||
|
||||
/** Public Basic Ecaz-deck Moritani setup. As in the original Extortion gift
|
||||
* fixture, stage only a legal prior physical Terror placement, phase/turn and
|
||||
* two wallets; actual shipment, reveal, bank award and queue are native. */
|
||||
function extortion() {
|
||||
let game = createGame('BRIBEEXTORTION', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'm', {type:'start'});
|
||||
for (let i = 0; i < 100 && game.status === 'setup'; i++) {
|
||||
if (game.decision?.kind === 'moritaniSetup')
|
||||
game = act(game, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]});
|
||||
else {
|
||||
const step = nextStrongholdFactionsNativeStep(game); assert.ok(step);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token);
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1);
|
||||
Object.assign(game, {phase:5, turn:2, storm:18, order:['e','a','h','m'], active:'e',
|
||||
movementRemaining:['e','a','h','m'], response:null, decision:null, phaseOpening:null, ready:[]});
|
||||
seat(game, 'e').spice = 10; seat(game, 'a').spice = 2;
|
||||
game = act(game, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1});
|
||||
assert.equal(game.decision?.kind, 'moritaniTerror');
|
||||
game = act(game, 'm', {type:'decision', reveal:true});
|
||||
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token.id)?.status, 'extortion');
|
||||
game.phase = 7; game.ready = [];
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
assert.equal(game.decision?.kind, 'moritaniPlacement');
|
||||
game = act(game, 'm', {type:'decision', decline:true});
|
||||
assert.equal(game.decision?.kind, 'moritaniExtortion');
|
||||
game = act(game, 'e', {type:'decision', event:game.decision.event, pay:false});
|
||||
assert.equal(game.decision?.kind, 'moritaniExtortion'); assert.equal(game.decision.player, 'a');
|
||||
return {game, token:token.id};
|
||||
}
|
||||
|
||||
void test('Basic Extortion bribe leaves the queue intact and reaches the next real Mentat collection', () => {
|
||||
const f = extortion(); let game = f.game;
|
||||
const decision = structuredClone(game.decision), obligation = structuredClone(game.moritaniExtortion);
|
||||
const payer = seat(game, 'h').spice, current = seat(game, 'a').spice;
|
||||
assert.equal(viewGame(game, 'h').bribeOptions.blocked, null);
|
||||
assert.equal(viewGame(game, 'h').bribeOptions.receiptTiming, 'nextTurn');
|
||||
assert.equal(viewGame(game, 'a').extortion.pending?.canPay, false);
|
||||
game = act(game, 'h', pay('a', 4));
|
||||
assert.deepEqual(game.decision, decision); assert.deepEqual(game.moritaniExtortion, obligation);
|
||||
assert.equal(seat(game, 'h').spice, payer - 4);
|
||||
assert.equal(seat(game, 'a').spice, current); assert.equal(seat(game, 'a').bribes, 4);
|
||||
assert.equal(viewGame(game, 'a').extortion.pending?.canPay, false);
|
||||
assert.ok(game.decision?.kind === 'moritaniExtortion');
|
||||
game = act(game, 'a', {type:'decision', event:game.decision.event, pay:false});
|
||||
assert.ok(game.decision?.kind === 'moritaniExtortion'); assert.equal(game.decision.player, 'h');
|
||||
game = act(game, 'h', {type:'decision', event:game.decision.event, pay:true});
|
||||
assert.equal(game.moritaniExtortion, undefined);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)?.status, 'removed');
|
||||
// Continue with real native actions until the last readiness before the
|
||||
// next Mentat. Only the unopened turn-three Spice position is selected to
|
||||
// avoid an incidental worm; no future resource, phase, turn or escrow is staged.
|
||||
let ordered = false;
|
||||
for (let i = 0; i < 1900; i++) {
|
||||
if (!ordered && game.turn === 3 && beforeIxSpiceDraw(game)) {
|
||||
orderIxSpiceCards(game, ['land']); ordered = true;
|
||||
}
|
||||
if (game.turn === 3 && game.phase === 7 && !game.response && !game.decision &&
|
||||
!game.phaseOpening && game.players.filter(player => !game.ready.includes(player.id)).length === 1) break;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.turn, 3); assert.equal(game.phase, 7);
|
||||
assert.equal(seat(game, 'a').bribes, 4);
|
||||
const before = seat(game, 'a').spice;
|
||||
const last = game.players.find(player => !game.ready.includes(player.id)); assert.ok(last);
|
||||
game = act(game, last.id, {type:'ready'});
|
||||
assert.equal(game.phase, 8); assert.equal(seat(game, 'a').spice, before + 4);
|
||||
assert.equal(seat(game, 'a').bribes, 0);
|
||||
});
|
||||
|
||||
void test('invalid Basic pending bribes preserve their original parent and game', () => {
|
||||
const f = createTruthTurnAmalFixture(); const game = f.game;
|
||||
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
|
||||
const before = copy(game);
|
||||
for (const [actor, action] of [
|
||||
['emperor', pay('atreides', 1)], ['emperor', pay('emperor', 1)],
|
||||
['emperor', pay('harkonnen', 0)], ['emperor', pay('harkonnen', seat(game, 'emperor').spice + 1)],
|
||||
] as [string, Action][]) {
|
||||
assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const pending = extortion().game, saved = copy(pending);
|
||||
assert.throws(() => act(pending, 'h', pay('a', seat(pending, 'h').spice + 1)));
|
||||
assert.deepEqual(pending, saved);
|
||||
});
|
||||
|
||||
void test('an immediate deal cannot bypass its unchanged timing during an opening', () => {
|
||||
let game = createTruthTurnAmalFixture().game;
|
||||
game = act(game, 'harkonnen', {type:'dealOffer', target:'emperor', terms:'Information for spice.',
|
||||
payment:{kind:'immediateBribe', payer:'harkonnen', recipient:'emperor', amount:1}});
|
||||
const deal = game.deals?.at(-1); assert.ok(deal);
|
||||
const before = copy(game);
|
||||
assert.throws(() => act(game, 'emperor', {type:'dealRespond', deal:deal.id, accept:true}));
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(viewGame(game, 'harkonnen').bribeOptions.blocked, null);
|
||||
game = act(game, 'harkonnen', pay('emperor', 1));
|
||||
assert.deepEqual(game.phaseOpening, before.phaseOpening);
|
||||
assert.equal(game.deals?.at(-1)?.status, 'offered');
|
||||
});
|
||||
@@ -26,8 +26,12 @@ function counter(g: Game) {
|
||||
const p = seat(g, 'h');
|
||||
let card = p.hand.find(row => row.effect === 'karama');
|
||||
if (!card) {
|
||||
const index = g.deck.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0);
|
||||
[card] = g.deck.splice(index, 1); p.hand.push(card);
|
||||
// Starting cards are shuffled: the physical Karama may already be in
|
||||
// another hand rather than the unopened deck.
|
||||
const owner = g.players.find(player => player.id !== p.id && player.hand.some(row => row.effect === 'karama'));
|
||||
const source = owner?.hand ?? g.deck;
|
||||
const index = source.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0);
|
||||
[card] = source.splice(index, 1); p.hand.push(card);
|
||||
}
|
||||
assert.ok(card); return card.id;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user