Offer Emperor gifts before allied auction payment
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 06:21:49 +11:00
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'));
});
+223
View File
@@ -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));
});