Connect actual occupied Homeworld Bureaucrat split receipts
This commit is contained in:
1 parent
df9bbc5e04
commit
304849f8d9
18 files changed
+1019
-38
No files matched your search
@@ -194,16 +194,16 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basi
|
||||
}
|
||||
});
|
||||
|
||||
void test('occupied-percentage entry remains explicitly fenced from an actual trained Homeworld Bureaucrat game', () => {
|
||||
void test('played Homeworld skills rooms cannot be converted and incomplete occupation saves reject immutably', () => {
|
||||
const f = fixture({ kind: 'shipment' });
|
||||
assert.equal(f.game.homeworldOccupationPreview, undefined);
|
||||
assert.throws(() => initializeHomeworldOccupationGameForAudit(f.game),
|
||||
{ message: 'Occupation requires a fresh Homeworld lobby with original selected faction decks and no other modules.' });
|
||||
const original = structuredClone(f.game);
|
||||
assert.throws(() => initializeHomeworldOccupationGameForAudit(f.game));
|
||||
assert.deepEqual(f.game, original);
|
||||
// Negative malformed-save input only: never claimed as a reachable occupation
|
||||
// fixture. No wallet, army, occupation history or skill custody is fabricated.
|
||||
const rejectedSave = structuredClone(f.game); rejectedSave.homeworldOccupationPreview = true;
|
||||
const before = structuredClone(rejectedSave);
|
||||
assert.throws(() => applyAction(rejectedSave, f.payer, f.action),
|
||||
{ message: 'The fresh occupation profile lost its original Homeworld module or income ledger.' });
|
||||
assert.throws(() => applyAction(rejectedSave, f.payer, f.action));
|
||||
assert.deepEqual(rejectedSave, before);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
|
||||
import { homeworldFundingCards } from './fixture-bureaucrat-homeworld-funding';
|
||||
import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills';
|
||||
import {
|
||||
advanceOccupiedEmperor, allocateOccupiedEmperor as allocate,
|
||||
allowOccupiedEmperorResponses as allow, chooseOccupiedEmperor as choose,
|
||||
createOccupiedEmperorFixture as fixture, occupiedEmperorClean,
|
||||
occupiedEmperorPlayer as player, occupiedEmperorWallets as wallets,
|
||||
payOccupiedEmperor as pay, type OccupiedEmperorFixture,
|
||||
} from './fixture-bureaucrat-occupied-emperor';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Full debit evidence precedes either skill or occupation allocation. */
|
||||
function assertPaid(f: OccupiedEmperorFixture, paid: Game): void {
|
||||
const expected = wallets(f.beforePledge);
|
||||
expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare;
|
||||
assert.deepEqual(wallets(paid), expected);
|
||||
assert.equal(paid.currentAuctionSale!.amount, f.gross);
|
||||
assert.deepEqual(paid.currentAuctionSale!.occupiedContributions,
|
||||
[{ payer: f.payer, amount: f.payerShare }, ...(f.donorShare ? [{ payer: f.donor, amount: f.donorShare }] : [])]);
|
||||
assert.equal(paid.aid[f.donor]?.amount ?? 0, 0);
|
||||
}
|
||||
|
||||
function assertOutcome(f: OccupiedEmperorFixture, allocation: Game, done: Game,
|
||||
diverted: 0 | 2, canceled = false, ownAmount?: number): void {
|
||||
const pending = allocation.pendingHomeworldOccupiedPercentage!;
|
||||
const source = pending.source;
|
||||
assert.ok(source.kind === 'emperor-treachery');
|
||||
const grossAfterBank = f.gross - diverted;
|
||||
const native = canceled ? 0 : Math.ceil(grossAfterBank / 2), occupied = Math.floor(grossAfterBank / 2);
|
||||
const own = ownAmount ?? occupied;
|
||||
assert.equal(source.amount, grossAfterBank); assert.equal(source.payment.paid, f.gross);
|
||||
assert.deepEqual(source.payment.contributions,
|
||||
[{ payer: f.payer, amount: f.payerShare }, ...(f.donorShare ? [{ payer: f.donor, amount: f.donorShare }] : [])]);
|
||||
if (diverted) assert.equal(source.payment.bankDiversion, 2);
|
||||
else assert.equal(Object.hasOwn(source.payment, 'bankDiversion'), false);
|
||||
assert.equal(source.nativeIncome, canceled ? 'karama-canceled' : 'received');
|
||||
const expected = wallets(f.beforePledge);
|
||||
expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare;
|
||||
expected[f.payee] += native; expected[f.occupier] += own;
|
||||
const receipt = done.homeworldOccupiedPercentageLedger!.receipts.find(r => r.source.event === source.event)!;
|
||||
if (occupied - own) expected[receipt.entitlement!.ally!] += occupied - own;
|
||||
assert.deepEqual(wallets(done), expected);
|
||||
assert.equal(receipt.nativeRetained, native); assert.equal(receipt.occupiedAmount, occupied);
|
||||
assert.equal(receipt.status, 'settled'); assert.equal(receipt.ownAmount, own);
|
||||
assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: own },
|
||||
...(occupied - own ? [{ player: receipt.entitlement!.ally!, amount: occupied - own }] : [])]);
|
||||
assert.equal(Object.values(wallets(f.beforePledge)).reduce((a, b) => a + b, 0) -
|
||||
Object.values(wallets(done)).reduce((a, b) => a + b, 0), canceled ? Math.ceil(f.gross / 2) : diverted);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0);
|
||||
assert.deepEqual(player(done, f.payer).hand.map(c => c.id),
|
||||
[...player(f.beforeAction, f.payer).hand.map(c => c.id), f.lot, f.bonus]);
|
||||
assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1);
|
||||
assert.equal(done.currentAuctionSale, null); assert.equal(done.pendingHomeworldOccupiedPercentage, null);
|
||||
assert.deepEqual(homeworldFundingCards(done), f.physical);
|
||||
assert.deepEqual(done.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })),
|
||||
f.beforePledge.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })));
|
||||
assert.deepEqual(done.homeworlds!.custody, f.beforePledge.homeworlds!.custody);
|
||||
assertClassicHomeworldSkillsCustody(done);
|
||||
}
|
||||
|
||||
void test('Basic native paid auction empties original Kaitain with a real twenty-counter shipment and qualifies a one-counter Guild invasion', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.setup.homeworldOccupationPreview, true);
|
||||
assert.equal(f.setup.leaderSkills!.deck.length + f.setup.leaderSkills!.assignments.length, 14);
|
||||
assert.equal(f.setup.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')!.owner, f.trainer);
|
||||
assert.equal(player(f.fundedEmperor, f.payee).spice, 20);
|
||||
assert.equal(player(f.emptyKaitain, f.payee).spice, 0);
|
||||
assert.equal(player(f.emptyKaitain, f.payee).reserves, 0);
|
||||
assert.equal(Object.values(player(f.emptyKaitain, f.payee).forces).reduce((a, b) => a + b, 0), 20);
|
||||
assert.equal(f.occupied.homeworlds!.custody!.visitors['homeworld:emperor'][f.occupier].normal, 1);
|
||||
const authority = quoteStableHomeworldOccupation(f.occupied, 'kaitain');
|
||||
assert.equal(authority.blocked, null); assert.equal(authority.entitlement!.occupier, f.occupier);
|
||||
assert.equal(player(f.alliance, f.payer).ally, f.donor);
|
||||
assert.equal(player(f.alliance, f.donor).ally, f.payer);
|
||||
for (const boundary of [f.setup, f.emptyKaitain, f.occupied, f.beforeAction]) assertClassicHomeworldSkillsCustody(boundary);
|
||||
});
|
||||
|
||||
for (const redirect of [true, false]) void test(`occupied gross8 ${redirect ? 'diverts before splitting to Emperor3/Guild3/Bank2' : 'declines to Emperor4/Guild4'} and delivers the original lot/bonus once`, () => {
|
||||
const f = fixture(), paid = pay(f); assertPaid(f, paid);
|
||||
const pending = allow(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(pending.decision!.player, f.trainer);
|
||||
const allocation = choose(pending, redirect);
|
||||
assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assert.equal(allocation.decision!.player, f.occupier);
|
||||
assert.equal(player(allocation, f.payee).spice - player(f.beforePledge, f.payee).spice, redirect ? 3 : 4);
|
||||
assert.equal(player(allocation, f.occupier).spice, player(f.beforePledge, f.occupier).spice - 3);
|
||||
const done = allocate(allocation); assertOutcome(f, allocation, done, redirect ? 2 : 0);
|
||||
reject(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
reject(done, f.occupier, { type: 'decision', event: allocation.pendingHomeworldOccupiedPercentage!.source.event, ownAmount: redirect ? 3 : 4 });
|
||||
});
|
||||
|
||||
void test('original occupied sharing suffix legally credits the reciprocal ally directly after gross-minus-two', () => {
|
||||
const f = fixture(), allocation = choose(allow(pay(f)), true);
|
||||
assert.equal(viewGame(allocation, f.occupier).homeworldOccupiedPercentage!.ally, f.payer);
|
||||
const done = allocate(allocation, 1); assertOutcome(f, allocation, done, 2, false, 1);
|
||||
reject(allocation, f.payer, { type: 'decision', event: allocation.pendingHomeworldOccupiedPercentage!.source.event, ownAmount: 1 });
|
||||
reject(allocation, f.occupier, { type: 'decision', event: 'stale-occupied-source', ownAmount: 1 });
|
||||
});
|
||||
|
||||
void test('actual 3+5 qualifies on the donor; actual 4+4 cannot pool two sub-five debits', () => {
|
||||
const eligible = fixture({ payerShare: 3, donorShare: 5 }), paid = pay(eligible); assertPaid(eligible, paid);
|
||||
const pending = allow(paid); assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
const allocation = choose(pending, true); assertOutcome(eligible, allocation, allocate(allocation), 2);
|
||||
const pooled = fixture({ payerShare: 4, donorShare: 4 }), pooledPaid = pay(pooled); assertPaid(pooled, pooledPaid);
|
||||
const direct = allow(pooledPaid); assert.equal(direct.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assertOutcome(pooled, direct, allocate(direct), 0);
|
||||
});
|
||||
|
||||
for (const donor of ['atreides', 'emperor'] as const) void test(`occupied funding excludes ${donor === 'atreides' ? 'trainer' : 'native recipient'} share without masking the independent qualified buyer`, () => {
|
||||
const excluded = fixture({ donor, payerShare: 3, donorShare: 5 }), paid = pay(excluded); assertPaid(excluded, paid);
|
||||
const allocation = allow(paid); assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assertOutcome(excluded, allocation, allocate(allocation), 0);
|
||||
const eligible = fixture({ donor, payerShare: 5, donorShare: 5 }), eligiblePaid = pay(eligible); assertPaid(eligible, eligiblePaid);
|
||||
const pending = allow(eligiblePaid); assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
const redirected = choose(pending, true); assertOutcome(eligible, redirected, allocate(redirected), 2);
|
||||
});
|
||||
|
||||
void test('native Karama cancellation preserves occupied4, retains full paid8 and never offers or spends Bureaucrat', () => {
|
||||
const f = fixture(), paid = pay(f); assertPaid(f, paid);
|
||||
assert.equal(paid.response?.kind, 'emperorIncome');
|
||||
assert.ok(viewGame(paid, f.trainer).responseControls!.cancelCards.includes(f.karama));
|
||||
const allocation = allow(applyAction(paid, f.trainer, { type: 'card', mode: 'cancel', card: f.karama }));
|
||||
assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
const done = allocate(allocation); assertOutcome(f, allocation, done, 0, true);
|
||||
assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
|
||||
assert.ok(!player(done, f.trainer).hand.some(c => c.id === f.karama));
|
||||
reject(done, f.trainer, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
});
|
||||
|
||||
void test('occupied gross8 invoice keeps private payer evidence private, rejects stale/wrong actors, and all four minimal owner bots settle real money', () => {
|
||||
const f = fixture(), pending = allow(pay(f));
|
||||
for (const viewer of pending.players) {
|
||||
const invoice = viewGame(pending, viewer.id).bureaucrat.pending!;
|
||||
assert.equal(invoice.amount, 8); assert.equal(invoice.payer, null); assert.equal(invoice.payee, f.payee);
|
||||
for (const key of ['contributions', 'shares', 'qualifyingPayer', 'privateFunding', 'signature', 'source'])
|
||||
assert.equal(key in invoice, false);
|
||||
}
|
||||
reject(pending, f.payer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
reject(pending, f.trainer, { type: 'decision', event: 'stale-bureaucrat-source', redirect: true });
|
||||
reject(pending, f.donor, { type: 'pledgeAid', amount: 1 });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, f.trainer); view.players.find(p => p.id === f.trainer)!.bot = difficulty;
|
||||
const actions = botActions(view); assert.equal(actions.length, 1);
|
||||
const allocation = applyAction(pending, f.trainer, actions[0]);
|
||||
assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assertOutcome(f, allocation, allocate(allocation), 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('redirect spends only one phase use; decline leaves it for the next actual funded lot and each physical bonus occurs once', () => {
|
||||
for (const firstRedirect of [true, false]) {
|
||||
const f = fixture(), firstPending = allow(pay(f)), firstAllocation = choose(firstPending, firstRedirect);
|
||||
const first = allocate(firstAllocation); assertOutcome(f, firstAllocation, first, firstRedirect ? 2 : 0);
|
||||
let next = advanceOccupiedEmperor(first, g => !!g.auction && g.auction.active === f.payer && occupiedEmperorClean(g));
|
||||
const beforePledge = structuredClone(next);
|
||||
next = applyAction(next, f.donor, { type: 'pledgeAid', amount: 5 });
|
||||
const second: OccupiedEmperorFixture = { ...f, game: next, beforePledge, beforeAction: structuredClone(next),
|
||||
payerShare: 0, donorShare: 5, gross: 5, action: { type: 'bid', amount: 5, allyPayment: 5 },
|
||||
lot: next.auction!.cards[next.auction!.index].id, bonus: next.deck[0].id };
|
||||
const paid = pay(second); assertPaid(second, paid);
|
||||
let allocation = allow(paid);
|
||||
if (firstRedirect) assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
else {
|
||||
assert.equal(allocation.decision?.kind, 'bureaucratPayment');
|
||||
reject(allocation, f.trainer, { type: 'decision', event: firstPending.bureaucratPaymentEvent, redirect: true });
|
||||
allocation = choose(allocation, true);
|
||||
}
|
||||
const done = allocate(allocation);
|
||||
const expected = wallets(beforePledge); expected[f.donor] -= 5;
|
||||
expected[f.payee] += firstRedirect ? 3 : 2; expected[f.occupier] += firstRedirect ? 2 : 1;
|
||||
assert.deepEqual(wallets(done), expected);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.deepEqual(player(done, f.payer).hand.map(c => c.id),
|
||||
[...player(first, f.payer).hand.map(c => c.id), second.lot, second.bonus]);
|
||||
assert.equal(done.auction!.index, first.auction!.index + 1);
|
||||
assert.deepEqual(homeworldFundingCards(done), f.physical);
|
||||
assertClassicHomeworldSkillsCustody(done);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
|
||||
import type { OccupiedPercentageReceipt } from '../game/homeworld-occupied-percentage';
|
||||
import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills';
|
||||
import {
|
||||
allocateOccupiedGuild, allowOccupiedGuildIncome, cancelOccupiedGuildIncome, chooseOccupiedGuild,
|
||||
createOccupiedGuildFixture, declareOccupiedGuild, occupiedGuildArmies, occupiedGuildCards,
|
||||
occupiedGuildClean, occupiedGuildPlayer, occupiedGuildWallets, advanceOccupiedGuild,
|
||||
type OccupiedGuildFixture,
|
||||
} from './fixture-bureaucrat-occupied-guild';
|
||||
|
||||
const wallets = occupiedGuildWallets;
|
||||
const armies = occupiedGuildArmies;
|
||||
|
||||
/** The frozen receipt keeps its original producer proof; read it only after the
|
||||
* discriminant is checked. */
|
||||
function guildShippingSource(receipt: OccupiedPercentageReceipt) {
|
||||
const source = receipt.source;
|
||||
if (source.kind !== 'guild-shipping') throw new Error('Expected the original Guild shipping source.');
|
||||
return source;
|
||||
}
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Exact wallet conservation: the payer and the pledged ally pay their original
|
||||
* shares once, the native keeps its half, the occupier takes the other half and
|
||||
* only the ruled diversion (or the canceled native half) leaves for the Bank. */
|
||||
function outcome(f: OccupiedGuildFixture, done: Game, diverted: number, canceled = false): void {
|
||||
const expected = wallets(f.beforePledge);
|
||||
expected[f.payer] -= f.payerShare;
|
||||
expected[f.donor] -= f.donorShare;
|
||||
const amount = canceled ? f.gross : f.gross - diverted;
|
||||
if (!canceled) expected[f.payee] += Math.ceil(amount / 2);
|
||||
expected[f.occupier] += Math.floor(amount / 2);
|
||||
assert.deepEqual(wallets(done), expected);
|
||||
const before = Object.values(wallets(f.beforePledge)).reduce((a, b) => a + b, 0);
|
||||
const after = Object.values(wallets(done)).reduce((a, b) => a + b, 0);
|
||||
assert.equal(before - after, canceled ? Math.floor(amount / 2) : diverted,
|
||||
'Only the ruled diversion or the canceled native half leaves the seated wallets for the Bank');
|
||||
assert.equal(done.aid[f.donor]?.amount ?? 0, 0);
|
||||
assert.deepEqual(occupiedGuildCards(done), f.physical);
|
||||
assertClassicHomeworldSkillsCustody(done);
|
||||
}
|
||||
|
||||
void test('native Guild occupation redirects two of a foreign shipping payment to the Bank after the full payer and ally debits', () => {
|
||||
const f = createOccupiedGuildFixture();
|
||||
assert.equal(f.payerShare + f.donorShare, 8);
|
||||
assert.equal(quoteStableHomeworldOccupation(f.junctionEmpty, 'junction').entitlement, null);
|
||||
const authority = quoteStableHomeworldOccupation(f.occupied, 'junction');
|
||||
assert.equal(authority.blocked, null);
|
||||
assert.equal(authority.entitlement?.occupier, f.occupier);
|
||||
assert.equal(authority.entitlement?.native, f.payee);
|
||||
assert.deepEqual(f.occupied.homeworlds!.custody!.visitors['homeworld:guild'],
|
||||
{ harkonnen: { normal: 1, elite: 0 } });
|
||||
assert.equal(occupiedGuildPlayer(f.junctionEmpty, f.payee).reserves, 0);
|
||||
const declared = declareOccupiedGuild(f);
|
||||
assert.equal(declared.response?.kind, 'guildIncome');
|
||||
assert.equal(declared.response?.amount, f.gross);
|
||||
assert.equal(occupiedGuildPlayer(declared, f.payer).spice,
|
||||
occupiedGuildPlayer(f.beforePledge, f.payer).spice - f.payerShare);
|
||||
assert.equal(occupiedGuildPlayer(declared, f.donor).spice,
|
||||
occupiedGuildPlayer(f.beforePledge, f.donor).spice - f.donorShare);
|
||||
assert.equal(occupiedGuildPlayer(declared, f.payee).spice, occupiedGuildPlayer(f.beforePledge, f.payee).spice);
|
||||
assert.deepEqual(occupiedGuildPlayer(declared, f.payer).forces, {
|
||||
...occupiedGuildPlayer(f.beforePledge, f.payer).forces,
|
||||
[`${f.territory}:${f.sector}`]: f.gross / 2,
|
||||
});
|
||||
assert.equal(occupiedGuildPlayer(declared, f.payer).reserves,
|
||||
occupiedGuildPlayer(f.beforePledge, f.payer).reserves - f.gross / 2);
|
||||
assert.equal(occupiedGuildPlayer(declared, f.payer).shipped, true);
|
||||
for (const other of declared.players.filter(player => player.id !== f.payer)) {
|
||||
assert.deepEqual(other.forces, occupiedGuildPlayer(f.beforePledge, other.id).forces);
|
||||
assert.equal(other.reserves, occupiedGuildPlayer(f.beforePledge, other.id).reserves);
|
||||
}
|
||||
const pending = allowOccupiedGuildIncome(declared);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.decision?.player, f.trainer);
|
||||
for (const viewer of pending.players) {
|
||||
const view = viewGame(pending, viewer.id), invoice = view.bureaucrat.pending!;
|
||||
assert.equal(invoice.payer, null);
|
||||
assert.equal(invoice.payee, f.payee);
|
||||
assert.equal(invoice.owner, f.trainer);
|
||||
assert.equal(invoice.amount, f.gross);
|
||||
assert.equal(invoice.homeworldAllocation, true);
|
||||
assert.equal(view.bureaucrat.usedThisPhase, false);
|
||||
for (const field of ['contributions', 'shares', 'qualifyingPayer', 'signature', 'source', 'privateFunding'])
|
||||
assert.equal(field in invoice, false, `${field} is private funding evidence, not a public invoice`);
|
||||
assert.equal('homeworldOccupiedPercentageLedger' in view, false);
|
||||
}
|
||||
reject(pending, f.donor, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
reject(pending, f.trainer, { type: 'decision', event: 'expired-bureaucrat-event', redirect: true });
|
||||
reject(pending, f.payer, { type: 'endMovement' });
|
||||
const redirected = chooseOccupiedGuild(pending, true);
|
||||
assert.equal(redirected.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assert.equal(redirected.decision?.player, f.occupier);
|
||||
assert.equal(occupiedGuildPlayer(redirected, f.payee).spice,
|
||||
occupiedGuildPlayer(f.beforePledge, f.payee).spice + 3);
|
||||
const offer = viewGame(redirected, f.occupier).homeworldOccupiedPercentage!;
|
||||
assert.equal(offer.world, 'homeworld:guild');
|
||||
assert.equal(offer.name, 'Junction');
|
||||
assert.equal(offer.owner, f.occupier);
|
||||
assert.equal(offer.ally, f.donor);
|
||||
assert.equal(offer.amount, 3);
|
||||
assert.equal(offer.minOwnAmount, 0);
|
||||
assert.equal(offer.maxOwnAmount, 3);
|
||||
assert.equal(offer.blocked, null);
|
||||
const done = allocateOccupiedGuild(redirected);
|
||||
const receipt = done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!;
|
||||
const source = guildShippingSource(receipt);
|
||||
assert.equal(source.nativeIncome, 'received');
|
||||
assert.equal(source.native, f.payee);
|
||||
assert.equal(source.amount, 6);
|
||||
assert.equal(source.payment.recipient, f.payee);
|
||||
assert.ok(source.payment.id.length > 0);
|
||||
assert.ok(source.payment.binding.length > 0);
|
||||
assert.equal(source.payment.paid, 8, 'the receipt keeps the original paid amount');
|
||||
assert.equal(source.payment.bankDiversion, 2);
|
||||
assert.deepEqual(source.payment.contributions, [
|
||||
{ payer: f.payer, amount: 6 }, { payer: f.donor, amount: 2 },
|
||||
]);
|
||||
assert.equal(receipt.status, 'settled');
|
||||
assert.equal(receipt.nativeRetained, 3);
|
||||
assert.equal(receipt.occupiedAmount, 3);
|
||||
assert.equal(receipt.bankRetained, 0);
|
||||
assert.equal(receipt.ownAmount, 3);
|
||||
assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 3 }]);
|
||||
assert.equal(receipt.entitlement?.occupier, f.occupier);
|
||||
assert.equal(receipt.entitlement?.ally, f.donor);
|
||||
assert.equal(occupiedGuildPlayer(done, f.payer).forces[`${f.territory}:${f.sector}`], f.gross / 2);
|
||||
assert.equal(occupiedGuildPlayer(done, f.payer).reserves,
|
||||
occupiedGuildPlayer(f.beforePledge, f.payer).reserves - f.gross / 2);
|
||||
assert.equal(occupiedGuildPlayer(done, f.payer).shipped, true);
|
||||
assert.deepEqual(armies(done), armies(declared),
|
||||
'The ruled diversion never rewrites the committed force shipment');
|
||||
assert.equal(done.bureaucratPayments?.used.length, 1);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
outcome(f, done, 2);
|
||||
assert.notEqual(applyAction(done, done.active!, { type: 'endMovement' }).active, f.payer);
|
||||
});
|
||||
|
||||
void test('the same occupied shipping payment declined keeps four with the native Guild and four with the occupier', () => {
|
||||
const f = createOccupiedGuildFixture();
|
||||
const decline = allocateOccupiedGuild(chooseOccupiedGuild(allowOccupiedGuildIncome(declareOccupiedGuild(f)), false));
|
||||
const receipt = decline.homeworldOccupiedPercentageLedger!.receipts.at(-1)!;
|
||||
assert.equal(receipt.source.amount, 8);
|
||||
assert.equal(receipt.nativeRetained, 4);
|
||||
assert.equal(receipt.occupiedAmount, 4);
|
||||
assert.equal(receipt.bankRetained, 0);
|
||||
assert.equal(receipt.ownAmount, 4);
|
||||
assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 4 }]);
|
||||
assert.equal(decline.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(viewGame(decline, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
outcome(f, decline, 0);
|
||||
});
|
||||
|
||||
void test('an original Karama cancellation removes the offer and banks the native half while the occupied half still settles', () => {
|
||||
const f = createOccupiedGuildFixture();
|
||||
const paid = declareOccupiedGuild(f);
|
||||
const canceled = cancelOccupiedGuildIncome(f, paid);
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
assert.equal(canceled.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
|
||||
reject(canceled, f.payee, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
const done = allocateOccupiedGuild(canceled);
|
||||
const receipt = done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!;
|
||||
const source = guildShippingSource(receipt);
|
||||
assert.equal(source.nativeIncome, 'karama-canceled');
|
||||
assert.equal(source.amount, 8);
|
||||
assert.equal(source.payment.paid, 8);
|
||||
assert.equal(source.payment.bankDiversion, undefined);
|
||||
assert.deepEqual(source.payment.contributions, [
|
||||
{ payer: f.payer, amount: 6 }, { payer: f.donor, amount: 2 },
|
||||
]);
|
||||
assert.equal(receipt.nativeRetained, 0);
|
||||
assert.equal(receipt.occupiedAmount, 4);
|
||||
assert.equal(receipt.bankRetained, 4);
|
||||
assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 4 }]);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(occupiedGuildPlayer(done, f.payee).spice, occupiedGuildPlayer(f.beforePledge, f.payee).spice);
|
||||
outcome(f, done, 0, true);
|
||||
});
|
||||
|
||||
void test('occupied Junction refuses two odd contributions and sub-five splits obtain no offer', () => {
|
||||
for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5]]) {
|
||||
const rejected = createOccupiedGuildFixture({ payerShare, donorShare, donor: 'atreides' });
|
||||
assert.equal(rejected.gross, payerShare + donorShare);
|
||||
reject(rejected.game, rejected.payer, rejected.action);
|
||||
}
|
||||
const subFive = createOccupiedGuildFixture({ payerShare: 2, donorShare: 2 });
|
||||
const settled = allocateOccupiedGuild(allowOccupiedGuildIncome(declareOccupiedGuild(subFive)));
|
||||
assert.equal(settled.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(settled.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.occupiedAmount, 2);
|
||||
assert.equal(settled.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.nativeRetained, 2);
|
||||
outcome(subFive, settled, 0);
|
||||
});
|
||||
|
||||
void test('a single payer and a fully pledged ally both settle the occupied Junction payment once', () => {
|
||||
const single = createOccupiedGuildFixture({ payerShare: 8, donorShare: 0 });
|
||||
const singleDone = allocateOccupiedGuild(chooseOccupiedGuild(
|
||||
allowOccupiedGuildIncome(declareOccupiedGuild(single)), true));
|
||||
assert.equal(singleDone.bureaucratPayments?.used.length, 1);
|
||||
assert.equal(singleDone.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.source.amount, 6);
|
||||
outcome(single, singleDone, 2);
|
||||
const pledged = createOccupiedGuildFixture({
|
||||
payer: 'emperor', donor: 'harkonnen', occupier: 'harkonnen', payerShare: 0, donorShare: 8,
|
||||
});
|
||||
const declared = declareOccupiedGuild(pledged);
|
||||
assert.equal(occupiedGuildPlayer(declared, pledged.payer).spice,
|
||||
occupiedGuildPlayer(pledged.beforePledge, pledged.payer).spice,
|
||||
'The payer contributes nothing of its own spice');
|
||||
const pledgedDone = allocateOccupiedGuild(chooseOccupiedGuild(
|
||||
allowOccupiedGuildIncome(declared), true));
|
||||
assert.equal(pledgedDone.bureaucratPayments?.used.length, 1);
|
||||
outcome(pledged, pledgedDone, 2);
|
||||
});
|
||||
|
||||
void test('the Bureaucrat use is once per phase and a later genuine payment in a new phase renews it', () => {
|
||||
for (const redirect of [false, true]) {
|
||||
const f = createOccupiedGuildFixture();
|
||||
const settled = allocateOccupiedGuild(chooseOccupiedGuild(
|
||||
allowOccupiedGuildIncome(declareOccupiedGuild(f)), redirect));
|
||||
const following = applyAction(settled, f.payer, { type: 'bribe', target: f.payee, amount: 5 });
|
||||
let used: Game;
|
||||
if (redirect) {
|
||||
assert.notEqual(following.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(occupiedGuildPlayer(following, f.payee).bribes,
|
||||
occupiedGuildPlayer(settled, f.payee).bribes + 5);
|
||||
used = following;
|
||||
} else {
|
||||
assert.equal(following.decision?.kind, 'bureaucratPayment');
|
||||
used = chooseOccupiedGuild(following, true);
|
||||
assert.equal(occupiedGuildPlayer(used, f.payee).bribes,
|
||||
occupiedGuildPlayer(settled, f.payee).bribes + 3);
|
||||
}
|
||||
assert.equal(used.bureaucratPayments!.used.length, 1);
|
||||
const ended = applyAction(used, f.payer, { type: 'endMovement' });
|
||||
const next = advanceOccupiedGuild(ended, g => g.turn === 3 && g.phase === 1 && occupiedGuildClean(g));
|
||||
assert.equal(viewGame(next, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
const renewed = chooseOccupiedGuild(applyAction(next, f.payer,
|
||||
{ type: 'bribe', target: f.payee, amount: 5 }), true);
|
||||
assert.equal(renewed.bureaucratPayments!.used.length, 2);
|
||||
assert.deepEqual(renewed.bureaucratPayments!.used.map(use => [use.turn, use.phase]), [[2, 5], [3, 1]]);
|
||||
assert.equal(viewGame(renewed, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
assertClassicHomeworldSkillsCustody(renewed);
|
||||
}
|
||||
});
|
||||
|
||||
void test('every owner bot resolves the occupied Junction offer and the native payment stays in real custody', () => {
|
||||
const f = createOccupiedGuildFixture();
|
||||
const pending = allowOccupiedGuildIncome(declareOccupiedGuild(f));
|
||||
for (const bot of DIFFICULTIES) {
|
||||
const view = viewGame(pending, f.trainer);
|
||||
view.players.find(p => p.id === f.trainer)!.bot = bot;
|
||||
const actions = botActions(view);
|
||||
assert.equal(actions.length, 1, `${bot} has exactly one legal Bureaucrat choice`);
|
||||
const done = allocateOccupiedGuild(applyAction(pending, f.trainer, actions[0]));
|
||||
assert.equal(done.bureaucratPayments?.used.length, 1);
|
||||
assert.equal(done.homeworldOccupationPreview, true);
|
||||
assert.equal(done.homeworlds!.custody!.visitors['homeworld:guild'][f.occupier].normal, 1);
|
||||
outcome(f, done, 2);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
|
||||
import {
|
||||
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
|
||||
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
|
||||
type ClassicHomeworldSkillsStep,
|
||||
} from './fixture-homeworld-classic-skills';
|
||||
import { homeworldFundingCards } from './fixture-bureaucrat-homeworld-funding';
|
||||
|
||||
export const occupiedEmperorPlayer = classicHomeworldSkillsPlayer;
|
||||
export const occupiedEmperorWallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
export const occupiedEmperorClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Only conserved, original unplayed Spice order is controlled. */
|
||||
function orderSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
(kind === 'worm' ? 'worm' in card : 'territory' in card));
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/** A physical custody swap retains both cards and the original hand size. */
|
||||
function holdKarama(game: Game, actor: string): string {
|
||||
const player = occupiedEmperorPlayer(game, actor);
|
||||
const held = player.hand.find(card => card.effect === 'karama');
|
||||
if (held) return held.id;
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0 && player.hand.length);
|
||||
const card = game.deck[index];
|
||||
game.deck[index] = player.hand[0]; player.hand[0] = card;
|
||||
return card.id;
|
||||
}
|
||||
|
||||
export function nextOccupiedEmperorStep(game: Game): ClassicHomeworldSkillsStep {
|
||||
const decision = game.decision;
|
||||
if (game.biddingEnd && occupiedEmperorClean(game)) {
|
||||
const actor = game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' } };
|
||||
}
|
||||
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, allow: true } };
|
||||
if (decision?.kind === 'homeworldOccupiedIncome') {
|
||||
const offer = viewGame(game, decision.player).homeworldOccupiedIncome!;
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event,
|
||||
world: decision.world, ownAmount: offer.maxOwnAmount } };
|
||||
}
|
||||
if (decision?.kind === 'homeworldOccupiedPercentage') {
|
||||
const offer = viewGame(game, decision.player).homeworldOccupiedPercentage!;
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event,
|
||||
ownAmount: offer.maxOwnAmount } };
|
||||
}
|
||||
const step = nextClassicHomeworldSkillsStep(game);
|
||||
assert.ok(step, 'The native scenario never requires a fabricated battle plan');
|
||||
return step;
|
||||
}
|
||||
|
||||
export function advanceOccupiedEmperor(state: Game, until: (game: Game) => boolean,
|
||||
actions?: ClassicHomeworldSkillsStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1600; n++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextOccupiedEmperorStep(game);
|
||||
actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.ok(until(game), 'Native occupation programme reaches the requested boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function allowOccupiedEmperorResponses(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.response && n < 40; n++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
|
||||
export type OccupiedEmperorFixture = {
|
||||
game: Game; setup: Game; fundedEmperor: Game; emptyKaitain: Game; occupied: Game; alliance: Game;
|
||||
beforePledge: Game; beforeAction: Game;
|
||||
payer: string; donor: string; payee: string; trainer: string; occupier: string;
|
||||
payerShare: number; donorShare: number; gross: number; action: Action;
|
||||
lot: string; bonus: string; karama: string; physical: string[]; actions: ClassicHomeworldSkillsStep[];
|
||||
};
|
||||
|
||||
/** Actual Basic setup -> paid ten-spice lot -> twenty-counter paid shipment ->
|
||||
* Guild's real bribes through Mentat -> worm/alliance -> empty Kaitain invasion
|
||||
* -> later funded auction. No wallet, force, phase, alliance or authority edits. */
|
||||
export function createOccupiedEmperorFixture(options: {
|
||||
payerShare?: number; donorShare?: number; donor?: 'guild' | 'atreides' | 'emperor';
|
||||
} = {}): OccupiedEmperorFixture {
|
||||
const { payerShare = 5, donorShare = 3, donor = 'guild' } = options;
|
||||
const payer = 'harkonnen', payee = 'emperor', trainer = 'atreides', occupier = 'guild';
|
||||
const native = createClassicHomeworldSkillsSetup({ occupation: true });
|
||||
const setup = structuredClone(native.afterSetup), actions = [...native.actions];
|
||||
let game = structuredClone(setup);
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push({ actor, action: structuredClone(action) }); game = applyAction(game, actor, action);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceOccupiedEmperor(game, until, actions); };
|
||||
orderSpice(game, ['land']);
|
||||
reach(g => g.phase === 3 && !!g.auction && occupiedEmperorClean(g));
|
||||
holdKarama(game, payer);
|
||||
reach(g => g.auction!.active === payer && occupiedEmperorClean(g));
|
||||
const firstLot = game.auction!.cards[game.auction!.index].id;
|
||||
act(payer, { type: 'bid', amount: 10 });
|
||||
reach(g => g.decision?.kind === 'auctionPayment');
|
||||
act(payer, { type: 'decision', karama: false });
|
||||
reach(g => occupiedEmperorClean(g) && occupiedEmperorPlayer(g, payer).hand.some(c => c.id === firstLot));
|
||||
assert.equal(occupiedEmperorPlayer(game, payee).spice, 20);
|
||||
const fundedEmperor = structuredClone(game);
|
||||
reach(g => g.phase === 5 && g.active === payee && occupiedEmperorClean(g));
|
||||
const emperor = occupiedEmperorPlayer(game, payee), elite = emperor.elites?.reserves ?? 0;
|
||||
assert.equal(emperor.reserves, 20);
|
||||
assert.equal(emperor.spice, 20, 'The original Kaitain discard opportunity is declined, retaining shipment funding');
|
||||
const destination = 'habbanya_ridge_sietch';
|
||||
assert.ok(game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${destination}:`))));
|
||||
act(payee, { type: 'ship', territory: destination, sector: territory(destination).sectors[0], amount: 20, elite,
|
||||
homeworldSources: { 'homeworld:emperor': { normal: 20 - elite, elite } } });
|
||||
reach(g => g.phase === 5 && occupiedEmperorClean(g));
|
||||
assert.equal(occupiedEmperorPlayer(game, payee).reserves, 0);
|
||||
assert.equal(occupiedEmperorPlayer(game, payee).forces[`${destination}:${territory(destination).sectors[0]}`], 20);
|
||||
const emptyKaitain = structuredClone(game);
|
||||
// Real transport income pays these bribes; the native Mentat transfers them.
|
||||
act(occupier, { type: 'bribe', target: payer, amount: 5 });
|
||||
reach(occupiedEmperorClean);
|
||||
act(occupier, { type: 'bribe', target: payee, amount: 5 });
|
||||
reach(occupiedEmperorClean);
|
||||
reach(g => g.turn === 2 && g.phase === 1 && occupiedEmperorClean(g));
|
||||
orderSpice(game, ['worm', 'land']);
|
||||
reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && occupiedEmperorClean(g));
|
||||
act(payer, { type: 'alliance', target: donor });
|
||||
act(donor, { type: 'alliance', target: payer });
|
||||
assert.equal(occupiedEmperorPlayer(game, payer).ally, donor);
|
||||
assert.equal(occupiedEmperorPlayer(game, donor).ally, payer);
|
||||
const alliance = structuredClone(game);
|
||||
reach(g => g.turn === 2 && g.phase === 5 && g.active === occupier && occupiedEmperorClean(g));
|
||||
const invasion = homeworldShipmentChoice(viewGame(game, occupier), 'homeworld:emperor',
|
||||
{ 'homeworld:guild': { normal: 1, elite: 0 } });
|
||||
assert.ok(invasion.action, invasion.blocked ?? undefined);
|
||||
act(occupier, invasion.action);
|
||||
reach(occupiedEmperorClean);
|
||||
const authority = quoteStableHomeworldOccupation(game, 'kaitain');
|
||||
assert.equal(authority.blocked, null); assert.equal(authority.entitlement?.occupier, occupier);
|
||||
assert.ok(game.homeworldOccupationHistory!.sources.some(source =>
|
||||
source.event === authority.entitlement!.qualification && source.cause !== 'setup'));
|
||||
const occupied = structuredClone(game);
|
||||
orderSpice(game, ['land', 'land']);
|
||||
reach(g => g.turn === 3 && g.phase === 3 && !!g.auction && occupiedEmperorClean(g));
|
||||
holdKarama(game, payer);
|
||||
const karama = holdKarama(game, trainer);
|
||||
reach(g => g.auction!.active === payer && occupiedEmperorClean(g));
|
||||
const beforePledge = structuredClone(game);
|
||||
if (donorShare) act(donor, { type: 'pledgeAid', amount: donorShare });
|
||||
const beforeAction = structuredClone(game), gross = payerShare + donorShare;
|
||||
assert.ok(occupiedEmperorPlayer(game, payer).spice >= payerShare);
|
||||
assertClassicHomeworldSkillsCustody(game);
|
||||
return { game, setup, fundedEmperor, emptyKaitain, occupied, alliance, beforePledge, beforeAction,
|
||||
payer, donor, payee, trainer, occupier, payerShare, donorShare, gross,
|
||||
action: { type: 'bid', amount: gross, allyPayment: donorShare },
|
||||
lot: game.auction!.cards[game.auction!.index].id, bonus: game.deck[0].id,
|
||||
karama, physical: homeworldFundingCards(game), actions };
|
||||
}
|
||||
|
||||
export function payOccupiedEmperor(f: OccupiedEmperorFixture, state = f.game): Game {
|
||||
let game = applyAction(state, f.payer, f.action);
|
||||
game = advanceOccupiedEmperor(game, g => g.decision?.kind === 'auctionPayment');
|
||||
return applyAction(game, f.payer, { type: 'decision', karama: false });
|
||||
}
|
||||
export function chooseOccupiedEmperor(game: Game, redirect: boolean): Game {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
return applyAction(game, game.decision!.player,
|
||||
{ type: 'decision', event: game.bureaucratPaymentEvent, redirect });
|
||||
}
|
||||
export function allocateOccupiedEmperor(game: Game, ownAmount?: number): Game {
|
||||
assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
const offer = viewGame(game, game.decision!.player).homeworldOccupiedPercentage!;
|
||||
return allowOccupiedEmperorResponses(applyAction(game, game.decision!.player,
|
||||
{ type: 'decision', event: offer.event, ownAmount: ownAmount ?? offer.maxOwnAmount }));
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
|
||||
import {
|
||||
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
|
||||
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
|
||||
type ClassicHomeworldSkillsStep,
|
||||
} from './fixture-homeworld-classic-skills';
|
||||
import { homeworldFundingCards } from './fixture-bureaucrat-homeworld-funding';
|
||||
|
||||
export const occupiedGuildPlayer = classicHomeworldSkillsPlayer;
|
||||
export const occupiedGuildWallets = (game: Game) =>
|
||||
Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
export const occupiedGuildArmies = (game: Game) => game.players.map(p => ({
|
||||
id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites,
|
||||
}));
|
||||
export const occupiedGuildClean = (game: Game) =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export const occupiedGuildCards = homeworldFundingCards;
|
||||
|
||||
/** Only conserved, original unplayed Spice order is controlled. */
|
||||
function orderSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
(kind === 'worm' ? 'worm' in card : 'territory' in card));
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/** A physical custody swap retains both cards and the original hand size. */
|
||||
function holdKarama(game: Game, actor: string): string {
|
||||
const player = occupiedGuildPlayer(game, actor);
|
||||
const held = player.hand.find(card => card.effect === 'karama');
|
||||
if (held) return held.id;
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0 && player.hand.length);
|
||||
const card = game.deck[index];
|
||||
game.deck[index] = player.hand[0];
|
||||
player.hand[0] = card;
|
||||
return card.id;
|
||||
}
|
||||
|
||||
export function nextOccupiedGuildStep(game: Game): ClassicHomeworldSkillsStep {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, allow: true } };
|
||||
if (decision?.kind === 'guildShipment') return { actor: decision.player,
|
||||
action: { type: 'decision', allow: true } };
|
||||
if (decision?.kind === 'homeworldOccupiedIncome') {
|
||||
const offer = viewGame(game, decision.player).homeworldOccupiedIncome!;
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event,
|
||||
world: decision.world, ownAmount: offer.maxOwnAmount } };
|
||||
}
|
||||
if (decision?.kind === 'homeworldOccupiedPercentage') {
|
||||
const offer = viewGame(game, decision.player).homeworldOccupiedPercentage!;
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event,
|
||||
ownAmount: offer.maxOwnAmount } };
|
||||
}
|
||||
const step = nextClassicHomeworldSkillsStep(game);
|
||||
assert.ok(step, 'The native scenario never requires a fabricated battle plan');
|
||||
return step;
|
||||
}
|
||||
|
||||
export function advanceOccupiedGuild(state: Game, until: (game: Game) => boolean,
|
||||
actions?: ClassicHomeworldSkillsStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1600; n++) {
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextOccupiedGuildStep(game);
|
||||
actions?.push(structuredClone(step));
|
||||
game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
assert.ok(until(game), 'Native occupation programme reaches the requested boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function allowOccupiedGuildResponses(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.response && n < 60; n++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id));
|
||||
assert.ok(actor, 'The original response queue must keep a legal responder');
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
export type OccupiedGuildFixture = {
|
||||
game: Game; setup: Game; junctionEmpty: Game; occupied: Game; alliance: Game;
|
||||
beforePledge: Game; beforeAction: Game;
|
||||
payer: string; donor: string; payee: string; trainer: string; occupier: string;
|
||||
payerShare: number; donorShare: number; gross: number; action: Action;
|
||||
territory: string; sector: number; karama: string; physical: string[];
|
||||
staging: string[]; actions: ClassicHomeworldSkillsStep[];
|
||||
};
|
||||
|
||||
/** Actual classic occupation + Skills setup: the native Guild empties Junction
|
||||
* through its own reserve shipment, a foreign faction invades Junction through
|
||||
* a real world shipment, and the later real alliance funds the real paid
|
||||
* shipment. No wallet, force, custody, history, phase or authority was assigned;
|
||||
* only original unplayed Spice order and one conserved card swap are staged. */
|
||||
export function createOccupiedGuildFixture(options: {
|
||||
payer?: string; donor?: string; occupier?: string; prep?: string;
|
||||
payerShare?: number; donorShare?: number;
|
||||
} = {}): OccupiedGuildFixture {
|
||||
const payer = options.payer ?? 'harkonnen';
|
||||
const donor = options.donor ?? 'emperor';
|
||||
const occupier = options.occupier ?? 'harkonnen';
|
||||
const prep = options.prep ?? 'emperor';
|
||||
const { payerShare = 6, donorShare = 2 } = options;
|
||||
const payee = 'guild', trainer = 'atreides';
|
||||
const native = createClassicHomeworldSkillsSetup({ occupation: true });
|
||||
const setup = structuredClone(native.afterSetup), actions = [...native.actions];
|
||||
const staging = [...native.staging];
|
||||
let game = structuredClone(setup);
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceOccupiedGuild(game, until, actions);
|
||||
};
|
||||
orderSpice(game, ['land']);
|
||||
reach(g => g.turn === 1 && g.phase === 5 && g.active === prep && occupiedGuildClean(g));
|
||||
const opening = occupiedGuildPlayer(game, payee).spice;
|
||||
// One real four-spice shipment income funds the native Guild's later tariff.
|
||||
act(prep, { type: 'ship', territory: 'sietch_tabr', sector: territory('sietch_tabr').sectors[0],
|
||||
amount: 4, elite: 0, homeworldSources: { [`homeworld:${occupiedGuildPlayer(game, prep).faction}`]:
|
||||
{ normal: 4, elite: 0 } } });
|
||||
reach(occupiedGuildClean);
|
||||
assert.equal(occupiedGuildPlayer(game, payee).spice, opening + 4,
|
||||
'The four-spice shipment income reaches the native Guild');
|
||||
// The Guild's own reserve shipment empties Junction.
|
||||
reach(g => g.phase === 5 && g.active === payee && occupiedGuildClean(g));
|
||||
act(payee, { type: 'ship', territory: 'habbanya_ridge_sietch',
|
||||
sector: territory('habbanya_ridge_sietch').sectors[0], amount: 15, elite: 0,
|
||||
homeworldSources: { 'homeworld:guild': { normal: 15, elite: 0 } } });
|
||||
reach(occupiedGuildClean);
|
||||
assert.equal(occupiedGuildPlayer(game, payee).reserves, 0, 'Junction is physically empty');
|
||||
const junctionEmpty = structuredClone(game);
|
||||
assert.equal(quoteStableHomeworldOccupation(game, 'junction').entitlement, null);
|
||||
// The real foreign homeworld invasion, then the real reciprocal alliance.
|
||||
reach(g => g.phase === 5 && g.active === occupier && occupiedGuildClean(g));
|
||||
const invasion = homeworldShipmentChoice(viewGame(game, occupier), 'homeworld:guild',
|
||||
{ [`homeworld:${occupiedGuildPlayer(game, occupier).faction}`]: { normal: 1, elite: 0 } });
|
||||
assert.ok(invasion.action, invasion.blocked ?? undefined);
|
||||
act(occupier, invasion.action);
|
||||
reach(occupiedGuildClean);
|
||||
const authority = quoteStableHomeworldOccupation(game, 'junction');
|
||||
assert.equal(authority.blocked, null);
|
||||
assert.equal(authority.entitlement?.occupier, occupier);
|
||||
assert.ok(game.homeworldOccupationHistory!.sources.some(source =>
|
||||
source.event === authority.entitlement!.qualification && source.cause !== 'setup'));
|
||||
const occupied = structuredClone(game);
|
||||
reach(g => g.turn === 2 && g.phase === 1 && occupiedGuildClean(g));
|
||||
orderSpice(game, ['worm', 'land']);
|
||||
reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && occupiedGuildClean(g));
|
||||
act(payer, { type: 'alliance', target: donor });
|
||||
act(donor, { type: 'alliance', target: payer });
|
||||
assert.equal(occupiedGuildPlayer(game, payer).ally, donor);
|
||||
assert.equal(occupiedGuildPlayer(game, donor).ally, payer);
|
||||
const alliance = structuredClone(game);
|
||||
// The later real paid shipment to an empty Arrakis territory.
|
||||
reach(g => g.turn === 2 && g.phase === 5 && g.active === payer && occupiedGuildClean(g));
|
||||
const karama = holdKarama(game, trainer);
|
||||
const beforePledge = structuredClone(game);
|
||||
if (donorShare) act(donor, { type: 'pledgeAid', amount: donorShare });
|
||||
const beforeAction = structuredClone(game), gross = payerShare + donorShare;
|
||||
const target = 'arsunt', sector = territory(target).sectors[0];
|
||||
assert.ok(occupiedGuildPlayer(game, payer).spice >= payerShare);
|
||||
assert.ok(game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${target}:`))));
|
||||
const action: Action = { type: 'ship', territory: target, sector, amount: gross / 2,
|
||||
elite: 0, allyPayment: donorShare };
|
||||
assertClassicHomeworldSkillsCustody(game);
|
||||
assert.deepEqual(occupiedGuildCards(game), homeworldFundingCards(setup));
|
||||
return { game, setup, junctionEmpty, occupied, alliance, beforePledge, beforeAction,
|
||||
payer, donor, payee, trainer, occupier, payerShare, donorShare, gross, action,
|
||||
territory: target, sector, karama, physical: homeworldFundingCards(game), staging, actions };
|
||||
}
|
||||
|
||||
/** The original declaration debits the payer and the pledged ally, then opens
|
||||
* only the real income window. */
|
||||
export function declareOccupiedGuild(f: OccupiedGuildFixture, state = f.game): Game {
|
||||
let game = applyAction(state, f.payer, f.action);
|
||||
if (game.decision?.kind === 'guildShipment') {
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Walk only the real persisted pass queue. */
|
||||
export function allowOccupiedGuildIncome(state: Game): Game {
|
||||
return allowOccupiedGuildResponses(state);
|
||||
}
|
||||
|
||||
export function cancelOccupiedGuildIncome(f: OccupiedGuildFixture, state: Game): Game {
|
||||
assert.equal(state.response?.kind, 'guildIncome');
|
||||
assert.ok(viewGame(state, f.trainer).responseControls?.cancelCards.includes(f.karama));
|
||||
return applyAction(state, f.trainer, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
}
|
||||
|
||||
export function chooseOccupiedGuild(game: Game, redirect: boolean): Game {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
return applyAction(game, game.decision!.player,
|
||||
{ type: 'decision', event: game.bureaucratPaymentEvent, redirect });
|
||||
}
|
||||
|
||||
export function allocateOccupiedGuild(game: Game, ownAmount?: number): Game {
|
||||
assert.equal(game.decision?.kind, 'homeworldOccupiedPercentage');
|
||||
const offer = viewGame(game, game.decision!.player).homeworldOccupiedPercentage!;
|
||||
return allowOccupiedGuildResponses(applyAction(game, game.decision!.player,
|
||||
{ type: 'decision', event: offer.event, ownAmount: ownAmount ?? offer.maxOwnAmount }));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine';
|
||||
import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { territory } from '../game/board';
|
||||
@@ -93,6 +93,7 @@ export function createClassicHomeworldSkillsSetup(options: {
|
||||
roster?: readonly FactionId[];
|
||||
initial?: Game;
|
||||
skill?: LeaderSkillId;
|
||||
occupation?: boolean;
|
||||
} = {}): ClassicHomeworldSkillsSetup {
|
||||
const roster = options.roster ?? ['emperor', 'guild', 'atreides', 'harkonnen'];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
@@ -128,7 +129,8 @@ export function createClassicHomeworldSkillsSetup(options: {
|
||||
const deckOrder = [...prefix, ...cards.map(c => c.id).filter(id => !prefix.includes(id))];
|
||||
if (game.status === 'lobby') game = originalLottery(
|
||||
[...permutationDraws(canonical, desired), ...permutationDraws(cards.map(c => c.id), deckOrder)],
|
||||
() => initializeLeaderSkillsGameForAudit(game));
|
||||
() => options.occupation ? initializeHomeworldOccupationGameForAudit(game, true)
|
||||
: initializeLeaderSkillsGameForAudit(game));
|
||||
let offered: Game | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 200; count++) {
|
||||
let next: ClassicHomeworldSkillsStep;
|
||||
|
||||
Reference in new issue
Block a user