Connect Bureaucrat payment redirection and saved continuation
Add an optional owner-controlled two-spice diversion from eligible single-payer ordinary auctions, Guild shipment income, Richese auction proceeds and actual bribes in the bounded base/CHOAM Leader Skills profile. Debit the payer once, credit the recipient once, retain bribes until Mentat Pause and restore the original response/auction/advisor callback. Decline preserves later phase opportunities; successful physical-card usage persists ownership changes. Keep qualifying allied funding and unfinished payment families explicitly guarded or outside the connected batch. Record source authority and the out-of-battle Karama composition and physical-card usage inferences; no public mode or publication gate opens. Connect readable owner controls and all four AI profiles using public payment/alliance information. Independent review found and fixed duplicate usage-event aliases and an overbroad guard on Bank-bound independent Karama shipments. Focused tests cover native/hidden/captured/dead eligibility, unchanged costs and card/force custody, normal and Richese auction callbacks, real Bene Gesserit advisor continuation, private views, JSON and production SQLite restore, stale/duplicate rejection and same-version competing writes. Validation: typecheck, lint, 4631 offline tests, production build and 43 HTTP tests passed with unchanged source. Four controlled-shuffle genuine-setup samples completed: base Basic 718 actions/20 restores, base Advanced 551/15, CHOAM Basic 681/19 and CHOAM Advanced 174/5. All had zero rejected candidates and preserved full physical Treachery, force and skill custody. None reached a Bureaucrat payment decision; these are regression-continuation samples, not natural full-game redirection acceptance. The separate conserved staged browser exercise allowed one five-spice bribe and redirected two from a second; the payer spent ten, the payee retained eight deferred spice, and one skill use was recorded. Pending and resolved states survived refresh with unchanged physical custody and private projection. After the reported outage, all 699 opening games and original seat/recovery records were preserved (733 after HTTP checks); database integrity and foreign keys passed. Reused the healthy server without restart, reset or recurring automation. Verified source tree: a30615deed306e06ab4c153b9a7eabdc92d646ed79474e348a3500d525bcb8c8
This commit is contained in:
1 parent
9f1f783be4
commit
d0dc7007d9
18 files changed
+1649
-49
No files matched your search
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction,viewGame} from '../game/engine';
|
||||
import {botActions} from '../game/bots';
|
||||
import {bureaucratPaymentGame,bureaucratReload} from './bureaucrat-payment-fixture';
|
||||
|
||||
void test('all four profiles legally redirect opposing bribes and Guild income using only the owned current choice',()=>{
|
||||
for(const bot of ['Easy','Medium','Hard','Brutal'] as const)for(const kind of ['bribe','shipment'] as const){
|
||||
let game=bureaucratPaymentGame();game.players.find(p=>p.id==='b')!.bot=bot;
|
||||
game=applyAction(game,'p',kind==='bribe'?{type:'bribe',target:'e',amount:5}:{type:'ship',territory:'arrakeen',sector:10,amount:5});
|
||||
assert.equal(game.decision?.kind,'bureaucratPayment');
|
||||
for(const p of game.players.filter(p=>p.id!=='b'))assert.deepEqual(botActions(viewGame(game,p.id)),[]);
|
||||
const actions=botActions(viewGame(game,'b'));assert.deepEqual(actions,[{type:'decision',event:game.decision!.event,redirect:true}]);
|
||||
const after=applyAction(bureaucratReload(game),'b',actions[0]);
|
||||
assert.equal(after.bureaucratPayments!.used.length,1);assert.equal(after.players.find(p=>p.id==='p')!.spice,25);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Bureaucrat policy preserves allied income and ignores hidden rival wealth and cards',()=>{
|
||||
for(const bot of ['Easy','Medium','Hard','Brutal'] as const){
|
||||
let game=bureaucratPaymentGame();game.players.find(p=>p.id==='b')!.bot=bot;
|
||||
game.players.find(p=>p.id==='b')!.ally='e';game.players.find(p=>p.id==='e')!.ally='b';
|
||||
game=applyAction(game,'p',{type:'bribe',target:'e',amount:5});
|
||||
assert.ok(game.decision?.kind==='bureaucratPayment');
|
||||
const original=viewGame(game,'b'),expected=botActions(original);
|
||||
assert.deepEqual(expected,[{type:'decision',event:game.decision!.event,redirect:false}]);
|
||||
const changed=structuredClone(original);
|
||||
for(const p of changed.players.filter(p=>p.id!=='b'))Object.assign(p,{spice:9_999,hand:[{id:'hidden',name:'Unseen',kind:'worthless'}]});
|
||||
assert.deepEqual(botActions(changed),expected);
|
||||
const after=applyAction(bureaucratReload(game),'b',expected[0]);
|
||||
assert.equal(after.players.find(p=>p.id==='e')!.bribes,5);assert.equal(after.bureaucratPayments!.used.length,0);
|
||||
const stale=structuredClone(original);stale.decision={kind:'bureaucratPayment',player:'b',event:'old'};
|
||||
assert.deepEqual(botActions(stale),[]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { BureaucratPayment } from '../components/bureaucrat-payment';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function view(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
me: 'payer',
|
||||
players: [
|
||||
{ id: 'owner', name: 'House Owner' },
|
||||
{ id: 'payer', name: 'House Payer' },
|
||||
{ id: 'payee', name: 'House Payee' },
|
||||
],
|
||||
decision: {
|
||||
kind: 'bureaucratPayment',
|
||||
player: 'owner',
|
||||
event: 'bureaucrat-1',
|
||||
},
|
||||
bureaucrat: {
|
||||
pending: {
|
||||
event: 'bureaucrat-1',
|
||||
owner: 'owner',
|
||||
payer: 'payer',
|
||||
payee: 'payee',
|
||||
amount: 5,
|
||||
kind: 'auction',
|
||||
redirect: 2,
|
||||
},
|
||||
usedThisPhase: false,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(BureaucratPayment, { game, act() {}, busy }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('owned matching Bureaucrat decision shows both payment costs and public names', () => {
|
||||
const html = markup(view({ me: 'owner' }));
|
||||
assert.match(html, /payment from House Payer to House Payee is 5 spice/);
|
||||
assert.match(html, /House Payee receives 3 spice/);
|
||||
assert.match(html, /Bank receives 2/);
|
||||
assert.match(html, /still pays the full amount/);
|
||||
assert.match(html, /Redirect 2 spice to the Bank/);
|
||||
assert.match(html, /Allow full 5-spice payment/);
|
||||
});
|
||||
|
||||
void test('non-owner and stale decisions render no Bureaucrat controls', () => {
|
||||
assert.equal(markup(view()), '');
|
||||
assert.equal(
|
||||
markup(view({ me: 'owner', decision: { kind: 'bureaucratPayment', player: 'owner', event: 'old' } })),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
void test('busy Bureaucrat controls are disabled', () => {
|
||||
const html = markup(view({ me: 'owner' }), true);
|
||||
assert.ok((html.match(/disabled=""/g) ?? []).length >= 2);
|
||||
});
|
||||
|
||||
void test('bribe redirection keeps the remaining payment unavailable until Mentat', () => {
|
||||
const game = view({ me: 'owner' });
|
||||
game.bureaucrat.pending!.kind = 'bribe';
|
||||
const html = markup(game);
|
||||
assert.match(html, /recipient keeps this spice in front of their shield until the next Mentat Pause/);
|
||||
assert.match(html, /cannot be spent before then/);
|
||||
assert.match(html, /House Payee receives 3 spice/);
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
export const bureaucratReload = (g: Game): Game =>
|
||||
JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const bureaucratPlayer = (g: Game, id: string) =>
|
||||
g.players.find((p) => p.id === id)!;
|
||||
export function takeBureaucratCard(g: Game, id: string, name = 'Karama') {
|
||||
const index = g.deck.findIndex((card) => card.name === name);
|
||||
assert.ok(index >= 0);
|
||||
const [card] = g.deck.splice(index, 1);
|
||||
bureaucratPlayer(g, id).hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Genuine Leader Skills setup; conserved focused position with four or five human seats. */
|
||||
export function bureaucratPaymentGame({
|
||||
advanced = false,
|
||||
choam = false,
|
||||
phase = 5,
|
||||
advisor = false,
|
||||
}: {
|
||||
advanced?: boolean;
|
||||
choam?: boolean;
|
||||
phase?: number;
|
||||
advisor?: boolean;
|
||||
} = {}): Game {
|
||||
let g = createGame(
|
||||
'BUROTEST',
|
||||
newPlayer('b', 'Bureaucrat', 'atreides'),
|
||||
advanced,
|
||||
choam ? ['choam'] : [],
|
||||
);
|
||||
for (const [id, name, faction] of [
|
||||
['p', 'Payer', 'harkonnen'],
|
||||
['e', 'Emperor', 'emperor'],
|
||||
['g', 'Guild', 'guild'],
|
||||
...(choam ? [['r', 'Richese', 'richese']] : []),
|
||||
...(advisor ? [['bg', 'Bene Gesserit', 'beneGesserit']] : []),
|
||||
] as [string, string, Parameters<typeof newPlayer>[2]][])
|
||||
joinGame(g, newPlayer(id, name, faction));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const index = LEADER_SKILL_CARDS.findIndex(
|
||||
(card) => card.id === 'bureaucrat',
|
||||
);
|
||||
let cursor = LEADER_SKILL_CARDS.length - 1;
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = cursor-- === index ? 0 : 0xffffffff;
|
||||
return array;
|
||||
});
|
||||
try {
|
||||
g = initializeLeaderSkillsGameForAudit(g);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
for (let step = 0; g.status === 'setup' && step < 80; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const offer = g.leaderSkills!.offers[p.id];
|
||||
if (g.setupStage === 'leaderSkills' && p.id === 'b' && offer)
|
||||
assert.ok(offer.cards.includes('bureaucrat'));
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((player) => player.id === p.id)!.bot = 'Medium';
|
||||
const actions =
|
||||
g.setupStage === 'leaderSkills' && offer
|
||||
? [
|
||||
{
|
||||
type: 'leaderSkill',
|
||||
event: offer.event,
|
||||
skill: p.id === 'b' ? 'bureaucrat' : offer.cards[0],
|
||||
leader: `${p.faction}-0`,
|
||||
},
|
||||
]
|
||||
: g.setupStage === 'traitors' && p.traitorChoices.length
|
||||
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
|
||||
: botActions(view);
|
||||
for (const action of actions)
|
||||
try {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
} catch {}
|
||||
if (next) break;
|
||||
}
|
||||
assert.ok(next, `Setup stalled at ${g.setupStage}`);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand);
|
||||
Object.assign(p, {
|
||||
hand: [],
|
||||
spice: 30,
|
||||
bribes: 0,
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
shipped: false,
|
||||
moved: 0,
|
||||
advisors: {},
|
||||
});
|
||||
if (p.elites) p.elites = { forces: {}, reserves: 5, tanks: 0, revived: 0 };
|
||||
}
|
||||
Object.assign(g, {
|
||||
turn: 2,
|
||||
phase,
|
||||
storm: 18,
|
||||
active: 'p',
|
||||
ready: [],
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
order: g.players.map((p) => p.id),
|
||||
movementRemaining: g.players.map((p) => p.id),
|
||||
});
|
||||
return g;
|
||||
}
|
||||
export function beginBureaucratAuction(g: Game): Game {
|
||||
Object.assign(g, {
|
||||
phase: 2,
|
||||
ready: [],
|
||||
active: null,
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
});
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.phase, 3);
|
||||
if (g.phaseOpening)
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
export function allowBureaucratIncome(g: Game): Game {
|
||||
for (let n = 0; g.response && n < 12; n++) {
|
||||
const responder = g.players.find((p) => {
|
||||
const controls = viewGame(g, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length;
|
||||
});
|
||||
assert.ok(responder);
|
||||
g = applyAction(g, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
bureaucratPaymentGame,
|
||||
beginBureaucratAuction,
|
||||
takeBureaucratCard,
|
||||
} from './bureaucrat-payment-fixture';
|
||||
|
||||
const clock: Rooms.RoomsClock = { now: () => 81_000, sleep: async () => {} };
|
||||
const hash = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
const rows = (db: DatabaseSync) => ({
|
||||
rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(),
|
||||
seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
});
|
||||
function physical(game: Game) {
|
||||
return {
|
||||
players: game.players.map(
|
||||
({ spice: _spice, bribes: _bribes, ...player }) => player,
|
||||
),
|
||||
deck: game.deck,
|
||||
discard: game.discard,
|
||||
skills: game.leaderSkills,
|
||||
};
|
||||
}
|
||||
type PaymentKind = 'bribe' | 'shipment' | 'auction' | 'cache' | 'blackMarket';
|
||||
function paidAuction(
|
||||
kind: 'auction' | 'cache' | 'blackMarket',
|
||||
advanced: boolean,
|
||||
): Game {
|
||||
let game = bureaucratPaymentGame({ advanced, choam: kind !== 'auction' });
|
||||
const offered =
|
||||
kind === 'blackMarket' ? takeBureaucratCard(game, 'r', 'Baliset') : null;
|
||||
game = beginBureaucratAuction(game);
|
||||
if (kind === 'auction') {
|
||||
while (game.auction!.active !== 'p')
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
game = applyAction(game, 'p', { type: 'bid', amount: 5 });
|
||||
for (let i = 0; !game.currentAuctionSale && i < 12; i++)
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
} else {
|
||||
if (kind === 'cache')
|
||||
game = applyAction(game, 'r', {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
position: 'first',
|
||||
});
|
||||
game = applyAction(game, 'r', {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
card: offered?.id ?? game.richeseCache![0].id,
|
||||
method: 'silent',
|
||||
});
|
||||
for (const id of game.richeseAuction!.order)
|
||||
game = applyAction(game, id, {
|
||||
type: 'richeseBid',
|
||||
event: game.richeseAuction!.event,
|
||||
amount: id === 'p' ? 5 : 0,
|
||||
});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
async function fixture(
|
||||
t: test.TestContext,
|
||||
kind: PaymentKind,
|
||||
advanced = false,
|
||||
) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
let game =
|
||||
kind === 'bribe' || kind === 'shipment'
|
||||
? applyAction(
|
||||
bureaucratPaymentGame({ advanced }),
|
||||
'p',
|
||||
kind === 'bribe'
|
||||
? { type: 'bribe', target: 'e', amount: 5 }
|
||||
: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 5 },
|
||||
)
|
||||
: paidAuction(kind, advanced);
|
||||
if (game.decision?.kind === 'guildShipment')
|
||||
game = applyAction(game, game.decision.player, {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
game.version = 41;
|
||||
const code = game.code,
|
||||
event = game.decision!.event!;
|
||||
store.sqlite
|
||||
.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run(code, JSON.stringify(game), game.version, 80_000);
|
||||
const tokens = Object.fromEntries(
|
||||
game.players.map((p) => [p.id, hash('bureaucrat-test:' + p.id)]),
|
||||
);
|
||||
const auths: Record<string, Rooms.SeatAuth> = {};
|
||||
for (const p of game.players) {
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
||||
)
|
||||
.run(hash(tokens[p.id]), code, p.id);
|
||||
auths[p.id] = await store.rooms.authenticate(code, tokens[p.id]);
|
||||
}
|
||||
return {
|
||||
...store,
|
||||
code,
|
||||
event,
|
||||
initial: game,
|
||||
tokens,
|
||||
auths,
|
||||
payee:
|
||||
kind === 'shipment'
|
||||
? 'g'
|
||||
: kind === 'cache' || kind === 'blackMarket'
|
||||
? 'r'
|
||||
: 'e',
|
||||
kind,
|
||||
};
|
||||
}
|
||||
async function views(f: Awaited<ReturnType<typeof fixture>>, game: Game) {
|
||||
const before = rows(f.sqlite),
|
||||
room = f.restart();
|
||||
for (const player of game.players) {
|
||||
const auth = await room.authenticate(f.code, f.tokens[player.id]);
|
||||
const view = await room.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(game, player.id));
|
||||
assert.equal('bureaucratPayments' in view, false);
|
||||
assert.equal('bureaucratUseEvents' in view, false);
|
||||
for (const rival of view.players.filter((p) => p.id !== player.id)) {
|
||||
assert.equal(rival.hand, undefined);
|
||||
assert.equal(rival.traitors, undefined);
|
||||
}
|
||||
if (view.bureaucrat.pending)
|
||||
assert.deepEqual(
|
||||
Object.keys(view.bureaucrat.pending).sort(),
|
||||
[
|
||||
'event',
|
||||
'owner',
|
||||
'payer',
|
||||
'payee',
|
||||
'amount',
|
||||
'kind',
|
||||
'redirect',
|
||||
].sort(),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
}
|
||||
|
||||
void test('saved Bureaucrat bribe and Guild choices resume once with unchanged payer cost and physical custody', async (t) => {
|
||||
for (const advanced of [false, true])
|
||||
for (const kind of ['bribe', 'shipment'] as const)
|
||||
for (const redirect of [false, true]) {
|
||||
const f = await fixture(t, kind, advanced),
|
||||
before = rows(f.sqlite),
|
||||
original = f.initial.players.find((p) => p.id === f.payee)!;
|
||||
const action = { type: 'decision', event: f.event, redirect };
|
||||
await views(f, f.initial);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths.p, 41, action));
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 40, action),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 41, {
|
||||
...action,
|
||||
event: 'stale-event',
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 41, { ...action, redirect: 'yes' }),
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
await f.restart().act(f.code, f.auths.b, 41, action, clock);
|
||||
const after = await f.restart().readRoom(f.code);
|
||||
assert.equal(after.version, 42);
|
||||
assert.deepEqual(physical(after), physical(f.initial));
|
||||
const recipient = after.players.find((p) => p.id === f.payee)!,
|
||||
amount = redirect ? 3 : 5;
|
||||
assert.equal(
|
||||
recipient.spice,
|
||||
original.spice + (kind === 'shipment' ? amount : 0),
|
||||
);
|
||||
assert.equal(
|
||||
recipient.bribes,
|
||||
original.bribes + (kind === 'bribe' ? amount : 0),
|
||||
);
|
||||
assert.equal(
|
||||
after.players.find((p) => p.id === 'p')!.spice,
|
||||
f.initial.players.find((p) => p.id === 'p')!.spice,
|
||||
);
|
||||
assert.equal(after.bureaucratPayments?.used.length, Number(redirect));
|
||||
assert.equal(
|
||||
after.decision?.kind,
|
||||
f.initial.bureaucratPayments!.pending!.resume.decision?.kind,
|
||||
);
|
||||
await views(f, after);
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 41, action),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths.b, 42, action));
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes),
|
||||
[1],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('competing Bureaucrat redirect and allowance commit one recipient share without duplicate debit', async (t) => {
|
||||
const f = await fixture(t, 'bribe');
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let arrived = 0;
|
||||
f.hooks.beforeWrite = async () => {
|
||||
arrived++;
|
||||
if (arrived === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const results = await Promise.allSettled(
|
||||
[true, false].map((redirect) =>
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths.b,
|
||||
41,
|
||||
{ type: 'decision', event: f.event, redirect },
|
||||
clock,
|
||||
),
|
||||
),
|
||||
);
|
||||
f.hooks.beforeWrite = undefined;
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
const after = await f.restart().readRoom(f.code);
|
||||
assert.equal(after.version, 42);
|
||||
const amount = after.players.find((p) => p.id === 'e')!.bribes;
|
||||
assert.ok(amount === 3 || amount === 5);
|
||||
assert.equal(after.bureaucratPayments!.used.length, amount === 3 ? 1 : 0);
|
||||
assert.deepEqual(physical(after), physical(f.initial));
|
||||
assert.equal(after.players.find((p) => p.id === 'p')!.spice, 25);
|
||||
await views(f, after);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
function auctionInventory(game: Game) {
|
||||
return [
|
||||
...game.deck,
|
||||
...game.discard,
|
||||
...game.players.flatMap((player) => player.hand),
|
||||
...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(
|
||||
game.auction.index +
|
||||
(game.currentAuctionSale?.origin === 'normal' ? 1 : 0),
|
||||
) ?? []),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
void test('saved normal, Richese cache and Black Market payments resume card custody and auction callbacks once after SQLite restart', async (t) => {
|
||||
for (const kind of ['auction', 'cache', 'blackMarket'] as const) {
|
||||
const redirect = kind !== 'blackMarket',
|
||||
f = await fixture(t, kind, kind === 'blackMarket');
|
||||
const source = f.initial.currentAuctionSale!;
|
||||
const purchased =
|
||||
kind === 'auction'
|
||||
? f.initial.auction!.cards[f.initial.auction!.index].id
|
||||
: f.initial.richeseAuction!.cardId;
|
||||
const inventory = auctionInventory(f.initial),
|
||||
payer = f.initial.players.find((player) => player.id === 'p')!;
|
||||
assert.equal(source.winner, 'p');
|
||||
assert.equal(source.amount, 5);
|
||||
assert.equal(payer.spice, 25);
|
||||
assert.equal(payer.hand.filter((card) => card.id === purchased).length, 1);
|
||||
assert.equal(payer.hand.length, 1);
|
||||
assert.equal(
|
||||
f.initial.bureaucratPayments!.pending!.continuation.kind,
|
||||
kind === 'auction' ? 'response' : 'auction',
|
||||
);
|
||||
await views(f, f.initial);
|
||||
if (kind === 'blackMarket')
|
||||
assert.ok(!JSON.stringify(viewGame(f.initial, 'g')).includes(purchased));
|
||||
const action = { type: 'decision', event: f.event, redirect },
|
||||
before = rows(f.sqlite);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths.p, 41, action));
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
const restarted = f.restart(),
|
||||
auth = await restarted.authenticate(f.code, f.tokens.b);
|
||||
await restarted.act(f.code, auth, 41, action, clock);
|
||||
const after = await f.restart().readRoom(f.code);
|
||||
assert.equal(after.version, 42);
|
||||
assert.equal(after.currentAuctionSale, null);
|
||||
assert.equal(after.bureaucratPayments!.pending, undefined);
|
||||
assert.deepEqual(auctionInventory(after), inventory);
|
||||
for (const initial of f.initial.players) {
|
||||
const actual = after.players.find((player) => player.id === initial.id)!;
|
||||
assert.equal(
|
||||
actual.spice,
|
||||
initial.spice + (actual.id === f.payee ? (redirect ? 3 : 5) : 0),
|
||||
);
|
||||
const { spice: _initialSpice, hand: _initialHand, ...original } = initial;
|
||||
const { spice: _actualSpice, hand: _actualHand, ...current } = actual;
|
||||
assert.deepEqual(current, original);
|
||||
}
|
||||
const winner = after.players.find((player) => player.id === 'p')!;
|
||||
assert.equal(winner.hand.length, 2);
|
||||
assert.equal(winner.hand.filter((card) => card.id === purchased).length, 1);
|
||||
assert.equal(
|
||||
after.log.filter((entry) =>
|
||||
entry.text.includes('drew one bonus treachery card'),
|
||||
).length,
|
||||
f.initial.log.filter((entry) =>
|
||||
entry.text.includes('drew one bonus treachery card'),
|
||||
).length + 1,
|
||||
);
|
||||
assert.equal(after.bureaucratPayments!.used.length, Number(redirect));
|
||||
if (kind === 'auction') assert.equal(after.auction!.index, 1);
|
||||
else if (kind === 'cache') {
|
||||
assert.equal(after.richeseAuction, null);
|
||||
assert.equal(after.auction!.index, 0);
|
||||
} else {
|
||||
assert.equal(after.richeseAuction, null);
|
||||
assert.equal(after.decision?.kind, 'richeseDeclaration');
|
||||
}
|
||||
await views(f, after);
|
||||
if (kind === 'blackMarket')
|
||||
assert.ok(!JSON.stringify(viewGame(after, 'g')).includes(purchased));
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths.b, 41, action),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths.b, 42, action));
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
assert.deepEqual(
|
||||
f.writes.map((write) => write.changes),
|
||||
[1],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
bureaucratPaymentModeSupported,
|
||||
bureaucratPaymentSignature,
|
||||
bureaucratUsed,
|
||||
quoteBureaucratPayment,
|
||||
} from '../game/bureaucrat-payment';
|
||||
import {
|
||||
bureaucratPaymentGame,
|
||||
bureaucratPlayer,
|
||||
bureaucratReload,
|
||||
beginBureaucratAuction,
|
||||
takeBureaucratCard,
|
||||
allowBureaucratIncome,
|
||||
} from './bureaucrat-payment-fixture';
|
||||
|
||||
const ship: Action = {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 5,
|
||||
};
|
||||
const bribe: Action = { type: 'bribe', target: 'e', amount: 5 };
|
||||
function choose(g: Game, redirect = true): Game {
|
||||
return applyAction(g, 'b', {
|
||||
type: 'decision',
|
||||
event: g.bureaucratPaymentEvent,
|
||||
redirect,
|
||||
});
|
||||
}
|
||||
function reject(g: Game, id: string, action: Action, pattern?: RegExp) {
|
||||
const before = JSON.stringify(g);
|
||||
if (pattern) assert.throws(() => applyAction(g, id, action), pattern);
|
||||
else assert.throws(() => applyAction(g, id, action));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
function cards(g: Game) {
|
||||
return [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.richeseRemoved ?? []),
|
||||
...(g.auction?.cards.slice(
|
||||
g.auction.index + (g.currentAuctionSale?.origin === 'normal' ? 1 : 0),
|
||||
) ?? []),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
}
|
||||
function normalSale(g: Game, amount = 5): Game {
|
||||
while (g.auction?.active !== 'p')
|
||||
g = applyAction(g, g.auction!.active, { type: 'passBid' });
|
||||
g = applyAction(g, 'p', { type: 'bid', amount });
|
||||
for (let i = 0; g.auction && !g.currentAuctionSale && i < 12; i++)
|
||||
g = applyAction(g, g.auction.active, { type: 'passBid' });
|
||||
return g;
|
||||
}
|
||||
function richeseSale(source: 'cache' | 'blackMarket', advanced = false) {
|
||||
let g = bureaucratPaymentGame({ choam: true, advanced });
|
||||
const saleCard =
|
||||
source === 'blackMarket'
|
||||
? takeBureaucratCard(g, 'r', 'Baliset')
|
||||
: g.richeseCache![0];
|
||||
g = beginBureaucratAuction(g);
|
||||
if (source === 'cache')
|
||||
g = applyAction(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
position: 'first',
|
||||
});
|
||||
g = applyAction(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
card: saleCard.id,
|
||||
method: 'silent',
|
||||
});
|
||||
for (const id of g.richeseAuction!.order)
|
||||
g = applyAction(g, id, {
|
||||
type: 'richeseBid',
|
||||
event: g.richeseAuction!.event,
|
||||
amount: id === 'p' ? 5 : 0,
|
||||
});
|
||||
return { g, saleCard };
|
||||
}
|
||||
|
||||
void test('Bureaucrat requires two other players and tracks the unique physical skill once per phase, independent of later owner', () => {
|
||||
const input = {
|
||||
owner: 'b',
|
||||
payer: 'p',
|
||||
payee: 'g',
|
||||
amount: 5,
|
||||
turn: 2,
|
||||
phase: 5,
|
||||
used: [],
|
||||
};
|
||||
assert.deepEqual(quoteBureaucratPayment(input), { redirect: 2, income: 3 });
|
||||
for (const patch of [
|
||||
{ owner: null },
|
||||
{ payer: 'b' },
|
||||
{ payee: 'b' },
|
||||
{ payer: 'g' },
|
||||
{ amount: 4 },
|
||||
])
|
||||
assert.equal(quoteBureaucratPayment({ ...input, ...patch }), null);
|
||||
const used = [
|
||||
{ event: 'spent', owner: 'former', turn: 2, phase: 5, signature: '' },
|
||||
];
|
||||
assert.equal(quoteBureaucratPayment({ ...input, owner: 'new', used }), null);
|
||||
assert.equal(bureaucratUsed(used, 2, 6), false);
|
||||
assert.ok(
|
||||
!bureaucratPaymentModeSupported({ expansions: ['choam'], homeworlds: {} }),
|
||||
);
|
||||
});
|
||||
for (const advanced of [false, true])
|
||||
for (const choam of [false, true])
|
||||
void test(`single-payer ${choam ? 'CHOAM' : 'base'} ${advanced ? 'Advanced' : 'Basic'} shipment diverts two after payment without replaying arrival`, () => {
|
||||
const start = bureaucratPaymentGame({ advanced, choam }),
|
||||
inventory = cards(start);
|
||||
let pending = applyAction(start, 'p', ship);
|
||||
if (pending.decision?.kind === 'guildShipment')
|
||||
pending = applyAction(pending, 'g', { type: 'decision', allow: true });
|
||||
assert.equal(bureaucratPlayer(pending, 'p').spice, 25);
|
||||
assert.equal(bureaucratPlayer(pending, 'g').spice, 30);
|
||||
assert.equal(bureaucratPlayer(pending, 'p').forces['arrakeen:10'], 5);
|
||||
assert.equal(pending.response, null);
|
||||
assert.equal(
|
||||
pending.bureaucratPayments!.pending!.resume.response!.kind,
|
||||
'guildIncome',
|
||||
);
|
||||
const original = structuredClone(bureaucratPlayer(pending, 'p'));
|
||||
const done = choose(bureaucratReload(pending));
|
||||
assert.deepEqual(bureaucratPlayer(done, 'p'), original);
|
||||
assert.equal(bureaucratPlayer(done, 'g').spice, 33);
|
||||
assert.deepEqual(cards(done), inventory);
|
||||
assert.equal(viewGame(done, 'b').bureaucrat.usedThisPhase, true);
|
||||
assert.deepEqual(normalizeAutomaticGame(bureaucratReload(done)), done);
|
||||
reject(done, 'b', {
|
||||
type: 'decision',
|
||||
event: pending.bureaucratPaymentEvent,
|
||||
redirect: true,
|
||||
});
|
||||
});
|
||||
void test('bribe diversion reduces only recipient escrow and never runs again on Mentat collection', () => {
|
||||
let g = applyAction(bureaucratPaymentGame(), 'p', bribe);
|
||||
assert.equal(bureaucratPlayer(g, 'p').spice, 25);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 0);
|
||||
g = choose(g);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 3);
|
||||
assert.equal(bureaucratPlayer(g, 'e').spice, 30);
|
||||
const uses = structuredClone(g.bureaucratPayments!.used);
|
||||
// Conserved phase staging reaches the actual collection callback.
|
||||
Object.assign(g, { phase: 7, active: null, ready: [], phaseOpening: null });
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.phase, 8);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 0);
|
||||
assert.equal(bureaucratPlayer(g, 'e').spice, 33);
|
||||
assert.deepEqual(g.bureaucratPayments!.used, uses);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending, null);
|
||||
});
|
||||
void test('decline keeps the next opportunity, while a consumed card cannot gain another use after a saved controller/assignment change', () => {
|
||||
let g = applyAction(bureaucratPaymentGame(), 'p', bribe);
|
||||
g = choose(g, false);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 5);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.usedThisPhase, false);
|
||||
g = applyAction(g, 'p', bribe);
|
||||
assert.equal(g.decision?.kind, 'bureaucratPayment');
|
||||
g = choose(g);
|
||||
const assignments = g.leaderSkills!.assignments,
|
||||
old = assignments.find((a) => a.owner === 'b')!,
|
||||
next = assignments.find((a) => a.owner === 'p')!;
|
||||
// A conserved saved redraw/custody state still refers to the same physical card.
|
||||
[old.skill, next.skill] = [next.skill, old.skill];
|
||||
g = applyAction(bureaucratReload(g), 'e', {
|
||||
type: 'bribe',
|
||||
target: 'g',
|
||||
amount: 5,
|
||||
});
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(g.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(bureaucratPlayer(g, 'g').bribes, 5);
|
||||
});
|
||||
void test('native income cancellation sends the whole payment to the bank without a Bureaucrat opportunity or use', () => {
|
||||
for (const kind of ['shipment', 'auction'] as const) {
|
||||
let g = bureaucratPaymentGame();
|
||||
const karama = takeBureaucratCard(g, 'b');
|
||||
g =
|
||||
kind === 'shipment'
|
||||
? applyAction(g, 'p', ship)
|
||||
: normalSale(beginBureaucratAuction(g));
|
||||
assert.equal(
|
||||
g.response?.kind,
|
||||
kind === 'shipment' ? 'guildIncome' : 'emperorIncome',
|
||||
);
|
||||
assert.equal(g.bureaucratPayments?.pending, undefined);
|
||||
const payer = bureaucratPlayer(g, 'p').spice;
|
||||
g = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: karama.id });
|
||||
assert.equal(bureaucratPlayer(g, 'p').spice, payer);
|
||||
assert.equal(
|
||||
bureaucratPlayer(g, kind === 'shipment' ? 'g' : 'e').spice,
|
||||
30,
|
||||
);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending, null);
|
||||
assert.equal(g.bureaucratPayments?.used.length ?? 0, 0);
|
||||
}
|
||||
});
|
||||
void test('normal auction resumes Emperor credit, Harkonnen bonus and the next lot once after Bureaucrat', () => {
|
||||
const start = beginBureaucratAuction(bureaucratPaymentGame()),
|
||||
inventory = cards(start),
|
||||
card = start.auction!.cards[0].id;
|
||||
const pending = normalSale(start);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(bureaucratPlayer(pending, 'p').spice, 25);
|
||||
assert.equal(bureaucratPlayer(pending, 'p').hand.length, 1);
|
||||
const done = choose(pending);
|
||||
assert.equal(bureaucratPlayer(done, 'e').spice, 33);
|
||||
assert.equal(bureaucratPlayer(done, 'p').hand.length, 2);
|
||||
assert.ok(bureaucratPlayer(done, 'p').hand.some((c) => c.id === card));
|
||||
assert.deepEqual(cards(done), inventory);
|
||||
assert.equal(done.auction!.index, 1);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
});
|
||||
void test('Richese cache and concealed Black Market seller credits preserve their original physical sale and next callback', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const)
|
||||
for (const advanced of source === 'cache' ? [false, true] : [true]) {
|
||||
const { g, saleCard } = richeseSale(source, advanced),
|
||||
inventory = cards(g);
|
||||
assert.equal(g.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(bureaucratPlayer(g, 'r').spice, 30);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending!.kind, 'auction');
|
||||
// The native Atreides Bureaucrat has its independently earned auction peek.
|
||||
if (source === 'blackMarket')
|
||||
assert.ok(!JSON.stringify(viewGame(g, 'g')).includes(saleCard.id));
|
||||
const done = choose(bureaucratReload(g));
|
||||
assert.equal(bureaucratPlayer(done, 'r').spice, 33);
|
||||
assert.equal(bureaucratPlayer(done, 'e').spice, 30);
|
||||
assert.equal(
|
||||
bureaucratPlayer(done, 'p').hand.filter((c) => c.id === saleCard.id)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(cards(done), inventory);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
assert.equal(
|
||||
done.decision?.kind,
|
||||
source === 'blackMarket' ? 'richeseDeclaration' : undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('a real simultaneous Bene Gesserit advisor choice survives the deferred Guild payment', () => {
|
||||
const pending = applyAction(
|
||||
bureaucratPaymentGame({ advisor: true }),
|
||||
'p',
|
||||
ship,
|
||||
);
|
||||
const original = structuredClone(
|
||||
pending.bureaucratPayments!.pending!.resume.decision,
|
||||
);
|
||||
assert.equal(original!.kind, 'advisor');
|
||||
const done = choose(bureaucratReload(pending));
|
||||
assert.deepEqual(done.decision, original);
|
||||
assert.equal(bureaucratPlayer(done, 'g').spice, 33);
|
||||
});
|
||||
void test('aggregate 3+3 and fully allied-funded qualifying payments reject before cost, force/card commitment or RNG', () => {
|
||||
for (const allyPayment of [1, 3, 6]) {
|
||||
let g = bureaucratPaymentGame();
|
||||
bureaucratPlayer(g, 'p').ally = 'e';
|
||||
bureaucratPlayer(g, 'e').ally = 'p';
|
||||
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 6 });
|
||||
mock.method(globalThis.crypto, 'randomUUID', () => {
|
||||
throw new Error('Unexpected event before payment preflight');
|
||||
});
|
||||
try {
|
||||
reject(
|
||||
g,
|
||||
'p',
|
||||
{ ...ship, amount: 6, allyPayment },
|
||||
/private payment-split/,
|
||||
);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
g = bureaucratPaymentGame();
|
||||
bureaucratPlayer(g, 'p').ally = 'g';
|
||||
bureaucratPlayer(g, 'g').ally = 'p';
|
||||
g = beginBureaucratAuction(g);
|
||||
g = applyAction(g, 'g', { type: 'pledgeAid', amount: 6 });
|
||||
g = applyAction(g, 'b', { type: 'passBid' });
|
||||
reject(
|
||||
g,
|
||||
'p',
|
||||
{ type: 'bid', amount: 6, allyPayment },
|
||||
/private payment-split/,
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('saved Bureaucrat receipt, source, unique decision, continuation and phase-use mutations reject immutably before reads', () => {
|
||||
const pending = applyAction(bureaucratPaymentGame(), 'p', ship),
|
||||
done = choose(pending);
|
||||
const cases: [Game, (g: Game) => void][] = [
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.decision = null;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
delete g.bureaucratPaymentEvent;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
delete g.bureaucratPayments!.pending;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.pending!.source.amount = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.pending!.resume.response!.amount = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
bureaucratPlayer(g, 'p').spice++;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.pending!.leader = 'wrong';
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.pendingExchange = { response: null, decision: g.decision };
|
||||
},
|
||||
],
|
||||
[
|
||||
done,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.used[0].phase = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
done,
|
||||
(g) => {
|
||||
g.bureaucratUseEvents = [];
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [source, mutate] of cases) {
|
||||
const g = bureaucratReload(source);
|
||||
mutate(g);
|
||||
const before = JSON.stringify(g);
|
||||
for (const operation of [
|
||||
() => viewGame(g, 'b'),
|
||||
() => normalizeAutomaticGame(g),
|
||||
() => applyAction(g, 'b', { type: 'advanceBots' }),
|
||||
]) {
|
||||
assert.throws(operation);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
}
|
||||
reject(pending, 'p', {
|
||||
type: 'decision',
|
||||
event: pending.bureaucratPaymentEvent,
|
||||
redirect: true,
|
||||
});
|
||||
reject(pending, 'b', { type: 'decision', event: 'stale', redirect: true });
|
||||
});
|
||||
void test('legacy unstamped income finishes unchanged; active source evidence stays out of private seat projections', () => {
|
||||
let g = bureaucratPaymentGame();
|
||||
takeBureaucratCard(g, 'b');
|
||||
g = applyAction(g, 'p', ship);
|
||||
for (const p of g.players) {
|
||||
const response = viewGame(g, p.id).response!;
|
||||
assert.equal(response.bureaucratPayment, undefined);
|
||||
assert.equal(response.bureaucratPaymentEvent, undefined);
|
||||
}
|
||||
delete g.response!.bureaucratPayment;
|
||||
delete g.response!.bureaucratPaymentEvent;
|
||||
g = allowBureaucratIncome(bureaucratReload(g));
|
||||
assert.equal(bureaucratPlayer(g, 'g').spice, 35);
|
||||
assert.equal(g.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(g.bureaucratPayments?.used.length ?? 0, 0);
|
||||
});
|
||||
|
||||
void test('independent Karama sends every allied shipment share to Bank without a Bureaucrat split guard or opportunity', () => {
|
||||
let g = bureaucratPaymentGame();
|
||||
bureaucratPlayer(g, 'p').ally = 'e';
|
||||
bureaucratPlayer(g, 'e').ally = 'p';
|
||||
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 5 });
|
||||
const karama = takeBureaucratCard(g, 'b');
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'card',
|
||||
mode: 'shipment',
|
||||
target: 'p',
|
||||
card: karama.id,
|
||||
});
|
||||
const before = cards(g);
|
||||
g = applyAction(g, 'p', { ...ship, amount: 12, allyPayment: 3 });
|
||||
assert.equal(bureaucratPlayer(g, 'p').spice, 27);
|
||||
assert.equal(g.aid.e.amount, 2);
|
||||
assert.equal(bureaucratPlayer(g, 'g').spice, 30);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending, null);
|
||||
assert.equal(bureaucratPlayer(g, 'p').forces['arrakeen:10'], 12);
|
||||
assert.deepEqual(cards(g), before);
|
||||
});
|
||||
|
||||
void test('captured, dead and genuinely concealed native Bureaucrats cannot redirect another payment', () => {
|
||||
for (const state of ['captured', 'dead', 'hidden', 'faceUp'] as const) {
|
||||
let g = bureaucratPaymentGame();
|
||||
if (state === 'captured')
|
||||
bureaucratPlayer(g, 'b').leaders[0].capturedBy = 'p';
|
||||
else if (state === 'dead') {
|
||||
bureaucratPlayer(g, 'b').leaders[0].dead = true;
|
||||
bureaucratPlayer(g, 'b').leaders[0].deaths = 1;
|
||||
g.leaderSkills!.assignments = g.leaderSkills!.assignments.filter(
|
||||
(a) => a.owner !== 'b',
|
||||
);
|
||||
g.leaderSkills!.deck.push('bureaucrat');
|
||||
} else {
|
||||
Object.assign(g, { phase: 6, active: 'b' });
|
||||
for (const id of ['b', 'p']) {
|
||||
bureaucratPlayer(g, id).forces = { 'arrakeen:10': 3 };
|
||||
bureaucratPlayer(g, id).reserves = 17;
|
||||
}
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'arrakeen',
|
||||
target: 'p',
|
||||
});
|
||||
while (g.decision?.kind === 'leaderSkillVisibility') {
|
||||
const decision = g.decision;
|
||||
g = applyAction(g, decision.player, {
|
||||
type: 'leaderSkillVisibility',
|
||||
event: decision.event,
|
||||
hide: decision.player === 'b' && state === 'hidden',
|
||||
});
|
||||
}
|
||||
while (g.battle?.preparation)
|
||||
g = applyAction(g, g.battle.preparation.owner, {
|
||||
type: 'declineBattlePower',
|
||||
});
|
||||
}
|
||||
g = applyAction(g, 'e', { type: 'bribe', target: 'g', amount: 5 });
|
||||
assert.equal(
|
||||
g.decision?.kind,
|
||||
state === 'faceUp' ? 'bureaucratPayment' : undefined,
|
||||
);
|
||||
assert.equal(bureaucratPlayer(g, 'g').bribes, state === 'faceUp' ? 0 : 5);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a later phase grants one new use, and duplicated source events cannot pad the independent physical-card history', () => {
|
||||
let g = choose(applyAction(bureaucratPaymentGame(), 'p', bribe));
|
||||
g.phase = 6;
|
||||
g = choose(applyAction(g, 'p', bribe));
|
||||
assert.equal(g.bureaucratPayments!.used.length, 2);
|
||||
const broken = bureaucratReload(g);
|
||||
broken.bureaucratPayments!.used[1].event =
|
||||
broken.bureaucratPayments!.used[0].event;
|
||||
broken.bureaucratPayments!.used[1].signature = bureaucratPaymentSignature(
|
||||
broken.bureaucratPayments!.used[1],
|
||||
);
|
||||
assert.throws(() => viewGame(broken, 'b'));
|
||||
assert.throws(() => normalizeAutomaticGame(broken));
|
||||
});
|
||||
Reference in new issue
Block a user