Respect individual Bureaucrat payment thresholds and correct Homeworld evidence
This commit is contained in:
1 parent
38feaf17ba
commit
68f7a101a3
11 files changed
+318
-104
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { mock } from 'node:test';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
@@ -294,37 +294,20 @@ void test('a real simultaneous Bene Gesserit advisor choice survives the deferre
|
||||
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]) {
|
||||
void test('individually qualifying mixed or fully allied-funded payments retain the integration guard before commitment', () => {
|
||||
for (const allyPayment of [1, 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();
|
||||
}
|
||||
reject(g, 'p', { ...ship, amount: 6, allyPayment });
|
||||
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/,
|
||||
);
|
||||
reject(g, 'p', { type: 'bid', amount: 6, allyPayment });
|
||||
}
|
||||
});
|
||||
void test('saved Bureaucrat receipt, source, unique decision, continuation and phase-use mutations reject immutably before reads', () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments';
|
||||
import { createBureaucratSplitThresholdFixture, advanceBureaucratThresholdToMovement } from './fixture-bureaucrat-split-threshold';
|
||||
|
||||
function settle(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; (game.response || game.decision) && n < 40; n++) {
|
||||
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
|
||||
assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(!game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const personal of [3, 4]) void test(`${advanced ? 'Advanced' : 'Basic'} shipment preserves ${personal}+3 allied payments below Bureaucrat’s individual threshold`, () => {
|
||||
const f = createBureaucratSplitThresholdFixture(advanced);
|
||||
let game = advanceBureaucratThresholdToMovement(f.game, f.payer);
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 });
|
||||
const amount = personal + 3, key = 'habbanya_ridge_sietch:17';
|
||||
const paid = settle(applyAction(game, f.payer, {
|
||||
type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: 3,
|
||||
}));
|
||||
assert.equal(player(paid, f.payer).spice, player(before, f.payer).spice - personal);
|
||||
assert.equal(player(paid, f.donor).spice, player(before, f.donor).spice - 3);
|
||||
assert.equal(player(paid, f.guild).spice, player(before, f.guild).spice + amount);
|
||||
assert.equal(player(paid, f.trainer).spice, player(before, f.trainer).spice);
|
||||
assert.equal(player(paid, f.payer).forces[key], amount);
|
||||
assert.equal(player(paid, f.payer).reserves, player(before, f.payer).reserves - amount);
|
||||
assert.equal(paid.aid[f.donor]?.amount ?? 0, 0);
|
||||
assert.equal(viewGame(paid, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
assert.throws(() => applyAction(paid, f.payer, {
|
||||
type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: 3,
|
||||
}));
|
||||
const finished = applyAction(paid, f.payer, { type: 'endMovement' });
|
||||
assert.ok(!finished.movementRemaining?.includes(f.payer));
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} auction preserves separate three-spice payer costs and full Emperor income`, () => {
|
||||
const f = createBureaucratSplitThresholdFixture(advanced);
|
||||
let game = f.game;
|
||||
while (game.auction!.active !== f.payer) {
|
||||
const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
const card = game.auction!.cards[game.auction!.index].id;
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 });
|
||||
game = applyAction(game, f.payer, { type: 'bid', amount: 6, allyPayment: 3 });
|
||||
for (let n = 0; !player(game, f.payer).hand.some(c => c.id === card) && n < 40; n++) {
|
||||
const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
const paid = settle(game);
|
||||
assert.ok(player(paid, f.payer).hand.some(c => c.id === card));
|
||||
assert.equal(player(paid, f.payer).spice, player(before, f.payer).spice - 3);
|
||||
assert.equal(player(paid, f.donor).spice, player(before, f.donor).spice - 3);
|
||||
assert.equal(player(paid, f.emperor).spice, player(before, f.emperor).spice + 6);
|
||||
assert.equal(player(paid, f.trainer).spice, player(before, f.trainer).spice);
|
||||
assert.equal(viewGame(paid, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
assert.equal(paid.auction!.index, before.auction!.index + 1);
|
||||
assert.ok(!paid.auction!.cards.slice(paid.auction!.index).some(c => c.id === card));
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} an individual five-spice contribution retains the unresolved mixed-payment guard`, () => {
|
||||
const f = createBureaucratSplitThresholdFixture(advanced);
|
||||
let game = advanceBureaucratThresholdToMovement(f.game, f.payer);
|
||||
game = applyAction(game, f.donor, { type: 'pledgeAid', amount: 3 });
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.payer, {
|
||||
type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount: 8, allyPayment: 3,
|
||||
}));
|
||||
assert.deepEqual(game, before, 'No payer, escrow or physical force changes on the unsupported qualifying split');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments';
|
||||
|
||||
/** Original skill offers/setup and actual turn-two worm alliance. Conserved
|
||||
* starting counters are staged into reserves to isolate payment, not combat.
|
||||
* Wallets, alliances, clocks and payment records are never assigned. */
|
||||
export function createBureaucratSplitThresholdFixture(advanced = false) {
|
||||
const lobby = createGame('BUREAUCRATTHRESHOLD', newPlayer('b', 'Bureaucrat', 'atreides'), advanced);
|
||||
for (const [id, faction] of [['p', 'harkonnen'], ['f', 'fremen'], ['e', 'emperor'], ['g', 'guild']] as const)
|
||||
joinGame(lobby, newPlayer(id, faction, faction));
|
||||
let game = initializeClassicNexusSkillsPaymentsSetup({ initial: lobby, skill: 'bureaucrat' });
|
||||
const actions: { actor: string; action: Action }[] = [];
|
||||
const step = () => {
|
||||
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
|
||||
assert.ok(next, `Native payment setup/progression stalled at ${game.phase}/${game.setupStage}`);
|
||||
actions.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
};
|
||||
for (let n = 0; game.status === 'setup' && n < 240; n++) step();
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.ok(game.leaderSkills!.assignments.some(a => a.owner === 'b' && a.skill === 'bureaucrat'));
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
}
|
||||
const orderSpice = (kinds: ('land' | 'worm')[]) => {
|
||||
const front = kinds.map(kind => {
|
||||
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
|
||||
assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
});
|
||||
game.spiceDeck.unshift(...front);
|
||||
};
|
||||
orderSpice(['land', 'land']);
|
||||
for (let n = 0; !(game.turn === 2 && game.phase === 1) && n < 1800; n++) step();
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 1);
|
||||
orderSpice(['worm', 'land', 'land']);
|
||||
let allied = false;
|
||||
for (let n = 0; !(allied && game.phase === 3 && game.auction && !game.phaseOpening && !game.response && !game.decision) && n < 600; n++) {
|
||||
if (game.nexus && !game.phaseOpening && !game.response && !game.decision && !game.spiceWindow && !allied) {
|
||||
for (const [actor, target] of [['p', 'f'], ['f', 'p']]) {
|
||||
const action: Action = { type: 'alliance', target };
|
||||
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
||||
}
|
||||
allied = true;
|
||||
} else step();
|
||||
}
|
||||
assert.ok(allied && game.auction);
|
||||
assert.equal(game.players.find(p => p.id === 'p')!.ally, 'f');
|
||||
assert.equal(game.players.find(p => p.id === 'f')!.ally, 'p');
|
||||
return { game, actions, payer: 'p', donor: 'f', trainer: 'b', guild: 'g', emperor: 'e' };
|
||||
}
|
||||
|
||||
export function advanceBureaucratThresholdToMovement(state: Game, actor: string): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !(game.phase === 5 && game.active === actor && !game.phaseOpening && !game.response && !game.decision) && n < 600; n++) {
|
||||
const next = nextClassicNexusSkillsPaymentsNativeStep(game);
|
||||
assert.ok(next, 'Native payment continuation stalled');
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, actor);
|
||||
return game;
|
||||
}
|
||||
@@ -15,20 +15,11 @@ import {
|
||||
for (const claim of [false, true]) void test(
|
||||
`Homeworld ordinary special play Semuta ${claim ? 'claims' : 'passes'} the used card without disturbing occupation`, () => {
|
||||
const f = createSemutaHomeworldOrdinaryFixture();
|
||||
assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.game.active, 'a');
|
||||
assert.ok(semutaEmperorPlayer(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
const played = applyAction(f.game, 'a', f.action);
|
||||
const pending = played.pendingTreacheryDiscard;
|
||||
assert.ok(pending, 'The used ordinary special must offer its printed Semuta cost');
|
||||
assert.equal(pending.batch.cause, 'ordinary:hajr');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({
|
||||
id: entry.card.id, by: entry.discardedBy, face: entry.publicFace,
|
||||
})), [{ id: f.hajr, by: 'a', face: true }]);
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
assert.deepEqual(played.hajr, ['a'], 'The Hajr movement grant already committed');
|
||||
assert.equal(played.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
for (const player of played.players) viewGame(played, player.id);
|
||||
const before = semutaHomeworldOrdinaryState(played);
|
||||
const event = pending.batch.event;
|
||||
assert.throws(() => applyAction(played, f.owner, { type: 'semutaCommit', event }));
|
||||
@@ -59,14 +50,14 @@ for (const claim of [false, true]) void test(
|
||||
const searched = applyAction(f.game, f.owner, f.action);
|
||||
const pending = searched.pendingTreacheryDiscard;
|
||||
assert.ok(pending, 'The completed paid Box search must offer its printed Semuta cost');
|
||||
assert.equal(pending.batch.cause, 'nullentropyBox');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({
|
||||
id: entry.card.id, by: entry.discardedBy, face: entry.publicFace,
|
||||
})), [{ id: f.box, by: f.owner, face: true }]);
|
||||
assert.equal(searched.discard.at(-1)?.id, f.box, 'The used Box stays on top of the shuffled pile');
|
||||
const continuation = pending.continuation;
|
||||
assert.equal(continuation.kind, 'nullentropyDiscard');
|
||||
if (continuation.kind !== 'nullentropyDiscard') throw Error('Missing completed Box search');
|
||||
const selected = continuation.selected;
|
||||
assert.ok(semutaEmperorPlayer(searched, f.owner).hand.some(card => card.id === selected.id));
|
||||
assert.equal(semutaEmperorPlayer(searched, f.owner).spice,
|
||||
semutaEmperorPlayer(f.game, f.owner).spice - f.cost);
|
||||
for (const player of searched.players) viewGame(searched, player.id);
|
||||
const before = semutaHomeworldOrdinaryState(searched);
|
||||
const event = pending.batch.event;
|
||||
assert.throws(() => applyAction(searched, f.holder, { type: 'semutaSelect', event, card: f.box }));
|
||||
@@ -77,6 +68,9 @@ for (const claim of [false, true]) void test(
|
||||
assert.equal(holder.hand.some(card => card.id === f.box), claim);
|
||||
assert.equal(holder.hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(done.discard.some(card => card.id === f.box), !claim);
|
||||
assert.deepEqual(semutaEmperorPlayer(done, f.owner).hand,
|
||||
semutaEmperorPlayer(searched, f.owner).hand,
|
||||
'The selected card remains in the searching player’s hand without a second search');
|
||||
assert.deepEqual(semutaHomeworldOrdinaryState(done), before,
|
||||
'The two-spice payment, searched card and occupation authority are unchanged');
|
||||
assert.throws(() => applyAction(done, f.owner, f.action));
|
||||
@@ -172,7 +166,6 @@ for (const claim of [false, true]) void test(
|
||||
assert.equal(answered.truthHistory!.length, 1);
|
||||
assert.equal(answered.truthHistory![0].answer, 'yes');
|
||||
assert.equal(answered.truthtrance, null);
|
||||
for (const player of answered.players) viewGame(answered, player.id);
|
||||
const before = semutaHomeworldOrdinaryState(answered);
|
||||
const event = pending.batch.event;
|
||||
assert.throws(() => applyAction(answered, f.holder, { type: 'semutaSelect', event, card: f.truthtrance }));
|
||||
|
||||
Reference in new issue
Block a user