Offer Emperor gifts before allied auction payment
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
93b0146de9
commit
6cec4ed78b
11 files changed
+528
-2
No files matched your search
@@ -53,7 +53,11 @@ function bid(game: Game, action: Action): Game {
|
||||
game = act(game, 'harkonnen', action);
|
||||
for (let count = 0; !game.response && !game.decision && game.auction?.bidder === 'harkonnen' && count < 12; count++)
|
||||
game = act(game, game.auction.active, { type: 'passBid' });
|
||||
if (game.decision?.kind === 'auctionPayment') game = act(game, 'harkonnen', { type: 'decision', karama: false });
|
||||
if (game.pendingEmperorAuctionGift)
|
||||
game = act(game, game.pendingEmperorAuctionGift.owner,
|
||||
{ type: 'emperorAuctionGiftContinue', event: game.pendingEmperorAuctionGift.event });
|
||||
if (!game.response && game.decision?.kind === 'auctionPayment')
|
||||
game = act(game, 'harkonnen', { type: 'decision', karama: false });
|
||||
return game;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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, viewGame, type Game, type GameView } from '../game/engine';
|
||||
import { EmperorAuctionGift } from '../components/emperor-auction-gift';
|
||||
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
|
||||
|
||||
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();
|
||||
|
||||
/** Original public Advanced setup, natural worm and actual alliance, followed
|
||||
* by ordinary bidding actions. Wallet amounts are deliberately staged. */
|
||||
function fixture(emperorSpice: number): Game {
|
||||
const source = createTruthAllianceFixture({advanced:true, consent:'none'});
|
||||
let game = source.game;
|
||||
game = applyAction(game, 'harkonnen', {type:'alliance', target:'emperor'});
|
||||
game = applyAction(game, 'emperor', {type:'alliance', target:'harkonnen'});
|
||||
game = advanceTruthAlliance(game, state => state.phase === 3 && !!state.auction && truthAllianceClean(state));
|
||||
for (let count = 0; game.auction && game.auction.active !== 'harkonnen' && count < game.players.length; count++)
|
||||
game = applyAction(game, game.auction.active, {type:'passBid'});
|
||||
assert.equal(game.auction?.active, 'harkonnen');
|
||||
game.players.find(player => player.id === 'harkonnen')!.spice = 6;
|
||||
game.players.find(player => player.id === 'emperor')!.spice = emperorSpice;
|
||||
game = applyAction(game, 'harkonnen', {type:'bid', amount:4, allyPayment:0});
|
||||
for (let count = 0; game.auction && !game.response && !game.decision && count < 12; count++)
|
||||
game = applyAction(game, game.auction.active, {type:'passBid'});
|
||||
assert.equal(game.decision?.kind, 'auctionPayment');
|
||||
assert.deepEqual(viewGame(game, 'emperor').emperorAuctionGiftPending, {owner:'emperor'});
|
||||
return game;
|
||||
}
|
||||
|
||||
const component = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(EmperorAuctionGift, {game, busy, act() {}}));
|
||||
const table = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}}));
|
||||
const enabled = (label: string) => new RegExp(`<button\\b(?![^>]*disabled)[^>]*>${label}<\\/button>`);
|
||||
const disabled = (label: string) => new RegExp(`<button\\b[^>]*disabled=""[^>]*>${label}<\\/button>`);
|
||||
|
||||
void test('the owner sees the quoted recipient, amount and continuation on the actual table', () => {
|
||||
const game = fixture(10), own = viewGame(game, 'emperor');
|
||||
assert.equal(own.emperorAuctionGift?.recipient, 'harkonnen');
|
||||
assert.equal(own.emperorAuctionGift?.maximum, 10);
|
||||
const html = component(own);
|
||||
assert.match(html, /Share spice with Harkonnen/);
|
||||
assert.match(html, /committed ally payment split remain unchanged/);
|
||||
assert.match(html, /type="number"[^>]*min="1"[^>]*max="10"/);
|
||||
assert.match(html, enabled('Share spice'));
|
||||
assert.match(html, enabled('Continue to payment'));
|
||||
assert.match(table(own), /Share spice with Harkonnen/);
|
||||
assert.match(table(own), enabled('Continue to payment'));
|
||||
});
|
||||
|
||||
void test('zero available spice still permits the owner to continue, but not to give', () => {
|
||||
const own = viewGame(fixture(0), 'emperor');
|
||||
assert.equal(own.emperorAuctionGift?.maximum, 0);
|
||||
const html = component(own);
|
||||
assert.match(html, disabled('Share spice'));
|
||||
assert.match(html, enabled('Continue to payment'));
|
||||
assert.match(table(own), enabled('Continue to payment'));
|
||||
});
|
||||
|
||||
void test('nonowners see the public wait without an owner quote or private amount', () => {
|
||||
const game = fixture(10);
|
||||
for (const id of ['harkonnen', 'atreides']) {
|
||||
const view = viewGame(game, id);
|
||||
assert.equal(view.emperorAuctionGift, null);
|
||||
assert.deepEqual(view.emperorAuctionGiftPending, {owner:'emperor'});
|
||||
assert.equal(component(view), '');
|
||||
const html = table(view);
|
||||
assert.match(html, /Waiting for the Emperor to finish the allied gift opportunity/);
|
||||
assert.doesNotMatch(html, /Emperor auction gift/);
|
||||
assert.doesNotMatch(html, /Continue to payment/);
|
||||
assert.doesNotMatch(html, /Spice to share/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy controls disable both owner actions while preserving the waiting state', () => {
|
||||
const own = viewGame(fixture(10), 'emperor');
|
||||
const html = component(own, true);
|
||||
assert.match(html, disabled('Share spice'));
|
||||
assert.match(html, disabled('Continue to payment'));
|
||||
assert.match(table(own, true), disabled('Continue to payment'));
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
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, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
const act = (g: Game, actor: string, action: Action) => applyAction(g, actor, action);
|
||||
const seat = (g: Game, id: string) => { const p = g.players.find(row => row.id === id); assert.ok(p); return p; };
|
||||
const copy = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
|
||||
/** Genuine Advanced H/A/E setup, second-turn worm and reciprocal alliance.
|
||||
* Test wallets and conservation-preserving Karama swaps are staged; bidding,
|
||||
* passing, settlement and all responses are native actions. */
|
||||
function openAuction(buyerKarama = false, emperorSpice = 10) {
|
||||
const source = createTruthAllianceFixture({advanced:true, consent:'none'});
|
||||
let g = source.game;
|
||||
g = act(g, 'harkonnen', {type:'alliance', target:'emperor'});
|
||||
g = act(g, 'emperor', {type:'alliance', target:'harkonnen'});
|
||||
g = advanceTruthAlliance(g, s => s.phase === 3 && !!s.auction && truthAllianceClean(s));
|
||||
for (let i = 0; g.auction && g.auction.active !== 'harkonnen' && i < g.players.length; i++)
|
||||
g = act(g, g.auction.active, {type:'passBid'});
|
||||
assert.equal(g.auction?.active, 'harkonnen');
|
||||
seat(g, 'harkonnen').spice = 6; seat(g, 'emperor').spice = emperorSpice;
|
||||
// The winner's private physical Karama changes only whether the underlying
|
||||
// auction needs its own payment choice, never whether this gift opens.
|
||||
const buyer = seat(g, 'harkonnen');
|
||||
const removed = buyer.hand.filter(card => card.effect === 'karama');
|
||||
buyer.hand = buyer.hand.filter(card => card.effect !== 'karama');
|
||||
g.deck.push(...removed);
|
||||
if (buyerKarama) {
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [card] = g.deck.splice(index, 1); buyer.hand.push(card);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function finishBid(g: Game, amount = 4, allyPayment = 0) {
|
||||
const card = g.auction!.cards[g.auction!.index].id;
|
||||
g = act(g, 'harkonnen', {type:'bid', amount, allyPayment});
|
||||
for (let i = 0; g.auction && !g.response && !g.decision && i < 12; i++)
|
||||
g = act(g, g.auction.active, {type:'passBid'});
|
||||
assert.equal(g.decision?.kind, 'auctionPayment');
|
||||
return {g, card};
|
||||
}
|
||||
function opportunity(g: Game) {
|
||||
const result = viewGame(g, 'emperor').emperorAuctionGift;
|
||||
assert.ok(result); return result;
|
||||
}
|
||||
function continued(g: Game) {
|
||||
const offer = opportunity(g);
|
||||
return act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event});
|
||||
}
|
||||
function passResponses(g: Game) {
|
||||
for (let i = 0; g.response && i < 30; i++) {
|
||||
const p = g.players.find(row => !g.response!.passed.includes(row.id)); assert.ok(p);
|
||||
g = act(g, p.id, {type:'passResponse'});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function completePayment(g: Game) {
|
||||
if (g.decision?.kind === 'auctionPayment')
|
||||
g = act(g, g.decision.player, {type:'decision', karama:false});
|
||||
return passResponses(g);
|
||||
}
|
||||
function frozenAuction(g: Game) {
|
||||
const a = g.auction!;
|
||||
return {decision:structuredClone(g.decision), turn:g.turn, phase:g.phase, active:g.active,
|
||||
card:a.cards[a.index].id, index:a.index, bid:a.bid, bidder:a.bidder,
|
||||
passed:structuredClone(a.passed), allyPayment:a.allyPayment,
|
||||
aid:structuredClone(g.aid), clock:structuredClone(g.auctionClock ?? null)};
|
||||
}
|
||||
|
||||
for (const buyerKarama of [false, true]) for (const emperorSpice of [0, 10]) {
|
||||
void test(`uniform prepayment gift opportunity: buyer Karama ${buyerKarama}, Emperor spice ${emperorSpice}`, () => {
|
||||
const {g: initial, card} = finishBid(openAuction(buyerKarama, emperorSpice));
|
||||
const g = copy(initial), offer = opportunity(g), parent = frozenAuction(g);
|
||||
assert.equal(offer.recipient, 'harkonnen');
|
||||
assert.equal(offer.maximum, emperorSpice);
|
||||
assert.deepEqual(viewGame(g, 'emperor').emperorAuctionGiftPending, {owner:'emperor'});
|
||||
for (const other of ['harkonnen', 'atreides']) {
|
||||
assert.equal(viewGame(g, other).emperorAuctionGift, null);
|
||||
assert.deepEqual(viewGame(g, other).emperorAuctionGiftPending, {owner:'emperor'});
|
||||
}
|
||||
const before = copy(g);
|
||||
assert.throws(() => act(g, 'harkonnen', {type:'decision', karama:false}));
|
||||
assert.throws(() => act(g, 'harkonnen', {type:'emperorAuctionGiftContinue', event:offer.event}));
|
||||
assert.throws(() => act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:'stale'}));
|
||||
assert.deepEqual(g, before);
|
||||
let next = continued(g);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGift, null);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
||||
if (buyerKarama) {
|
||||
assert.deepEqual(frozenAuction(next), parent);
|
||||
assert.equal(next.decision?.kind, 'auctionPayment');
|
||||
next = completePayment(next);
|
||||
} else next = passResponses(next);
|
||||
assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card), 'original lot delivered once');
|
||||
assert.throws(() => act(next, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event}));
|
||||
});
|
||||
}
|
||||
|
||||
void test('a prepayment gift preserves the declared mixed payer split and Advanced payment routing', () => {
|
||||
let g = openAuction(true);
|
||||
seat(g, 'harkonnen').spice = 4;
|
||||
g = act(g, 'emperor', {type:'pledgeAid', amount:2});
|
||||
const bid = finishBid(g, 6, 2); g = bid.g;
|
||||
const parent = frozenAuction(g), event = opportunity(g).event;
|
||||
assert.equal(opportunity(g).maximum, 8);
|
||||
g = act(g, 'emperor', {type:'emperorGift', amount:1});
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
assert.equal(seat(g, 'emperor').spice, 8);
|
||||
g = passResponses(copy(g));
|
||||
assert.deepEqual(frozenAuction(g), parent);
|
||||
assert.equal(seat(g, 'harkonnen').spice, 5);
|
||||
assert.equal(seat(g, 'emperor').spice, 7);
|
||||
assert.equal(opportunity(g).event, event);
|
||||
g = continued(g);
|
||||
assert.equal(g.decision?.kind, 'auctionPayment');
|
||||
g = completePayment(g);
|
||||
assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card));
|
||||
assert.equal(seat(g, 'harkonnen').spice, 1);
|
||||
assert.equal(seat(g, 'emperor').spice, 11,
|
||||
'Own-price four returns to Emperor; its separately pledged two went to Bank.');
|
||||
});
|
||||
|
||||
void test('a real Karama cancels the gift without disturbing the auction or its payment', () => {
|
||||
let g = openAuction(true);
|
||||
const third = seat(g, 'atreides');
|
||||
let card = third.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); third.hand.push(card);
|
||||
}
|
||||
assert.ok(card);
|
||||
const bid = finishBid(g); g = bid.g;
|
||||
const parent = frozenAuction(g), emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice;
|
||||
g = act(g, 'emperor', {type:'emperorGift', amount:2});
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
g = act(g, 'atreides', {type:'card', card:card.id, mode:'cancel'});
|
||||
assert.deepEqual(frozenAuction(g), parent);
|
||||
assert.equal(seat(g, 'emperor').spice, emperor);
|
||||
assert.equal(seat(g, 'harkonnen').spice, buyer);
|
||||
assert.equal(g.discard.filter(row => row.id === card!.id).length, 1);
|
||||
g = continued(g);
|
||||
g = completePayment(g);
|
||||
assert.ok(seat(g, 'harkonnen').hand.some(row => row.id === bid.card));
|
||||
});
|
||||
|
||||
void test('four minimal Emperor policies can continue the neutral prepayment opportunity', () => {
|
||||
const {g} = finishBid(openAuction(false, 0));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(g, 'emperor');
|
||||
view.players.find(p => p.id === 'emperor')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(actions, [{type:'emperorAuctionGiftContinue', event:opportunity(g).event}]);
|
||||
const next = act(copy(g), 'emperor', actions[0]);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced Bene Gesserit may use a physical Worthless card to cancel the gift', () => {
|
||||
let g = createGame('EMPAUCTIONBG', newPlayer('emperor', 'Emperor', 'emperor'), true);
|
||||
joinGame(g, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(g, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
|
||||
for (const p of g.players) g = act(g, p.id, {type:'ready'});
|
||||
g = act(g, 'emperor', {type:'start', advancedPreview:true});
|
||||
for (let i = 0; i < 80 && g.status === 'setup'; i++) {
|
||||
const step = nextStrongholdFactionsNativeStep(g); assert.ok(step);
|
||||
g = act(g, step.actor, step.action);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
// Stage only the current alliance, test wallets and unopened ordinary lot;
|
||||
// the Worthless card is moved from the actual deck, never manufactured.
|
||||
seat(g, 'emperor').ally = 'harkonnen'; seat(g, 'harkonnen').ally = 'emperor';
|
||||
seat(g, 'emperor').spice = 10; seat(g, 'harkonnen').spice = 6;
|
||||
const bg = seat(g, 'beneGesserit');
|
||||
let worthless = bg.hand.find(card => card.kind === 'worthless');
|
||||
if (!worthless) {
|
||||
const index = g.deck.findIndex(card => card.kind === 'worthless'); assert.ok(index >= 0);
|
||||
[worthless] = g.deck.splice(index, 1);
|
||||
if (bg.hand.length >= 4) g.deck.push(bg.hand.pop()!);
|
||||
bg.hand.push(worthless);
|
||||
}
|
||||
assert.ok(worthless);
|
||||
// A genuine opposing Karama keeps the conversion response visible long
|
||||
// enough to exercise its continuation instead of automatically passing.
|
||||
const emperorSeat = seat(g, 'emperor');
|
||||
if (!emperorSeat.hand.some(card => card.effect === 'karama')) {
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [counter] = g.deck.splice(index, 1);
|
||||
if (emperorSeat.hand.length >= 4) g.deck.push(emperorSeat.hand.pop()!);
|
||||
emperorSeat.hand.push(counter);
|
||||
}
|
||||
const lot = g.deck.shift(); assert.ok(lot);
|
||||
Object.assign(g, {phase:3, active:'harkonnen', order:['harkonnen','emperor','beneGesserit'],
|
||||
response:null, decision:null, phaseOpening:null, ready:[],
|
||||
auction:{cards:[lot], index:0, bid:0, bidder:null, active:'harkonnen', passed:[], opener:0, peekKnown:false}});
|
||||
const bid = finishBid(g); g = bid.g;
|
||||
const original = frozenAuction(g), event = opportunity(g).event;
|
||||
const emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice;
|
||||
g = act(g, 'emperor', {type:'emperorGift', amount:2});
|
||||
assert.equal(g.response?.kind, 'emperorGift');
|
||||
g = act(g, 'beneGesserit', {type:'card', card:worthless.id, mode:'cancel'});
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
g = passResponses(g);
|
||||
assert.deepEqual(frozenAuction(g), original);
|
||||
assert.equal(opportunity(g).event, event);
|
||||
assert.equal(seat(g, 'emperor').spice, emperor);
|
||||
assert.equal(seat(g, 'harkonnen').spice, buyer);
|
||||
assert.equal(g.discard.filter(card => card.id === worthless!.id).length, 1);
|
||||
g = continued(g);
|
||||
g = completePayment(g);
|
||||
assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card));
|
||||
});
|
||||
|
||||
void test('the normal bot scheduler advances a bot Emperor before the buyer payment', () => {
|
||||
const {g, card} = finishBid(openAuction(false, 0));
|
||||
seat(g, 'emperor').bot = 'Medium';
|
||||
const next = runBots(g, 8);
|
||||
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
||||
assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card));
|
||||
});
|
||||
Reference in new issue
Block a user