Connect Homeworld allied Bureaucrat income and record two rulings
This commit is contained in:
1 parent
36f911cec8
commit
9b83b61669
8 files changed
+453
-5
No files matched your search
@@ -99,8 +99,58 @@ redirection:
|
||||
five-plus-three, three-plus-five, five-plus-five and fully ally-funded five.
|
||||
The receipt loses exactly two, never two per contributing payer. A share paid
|
||||
by the trainer or recipient cannot qualify, but does not mask a different
|
||||
eligible payer. Homeworld allied/split-recipient composition retains its
|
||||
separate integration boundary.
|
||||
eligible payer. Homeworld allied and split-recipient income is connected under
|
||||
the ruled gross-first ordering below.
|
||||
|
||||
### User rulings — 13 October 2026
|
||||
|
||||
**Homeworld split-recipient ordering.** When a qualifying payment's recipient
|
||||
income is partly diverted to Homeworld occupiers, Bureaucrat's two spice comes
|
||||
off the **gross before** the occupation percentage split. Occupier shares are
|
||||
computed from gross−2; the recipient keeps the remainder of gross−2. The payer
|
||||
still pays in full. This resolves the previously silent question (the authorized
|
||||
Advanced source, physical p.22, does not address Bureaucrat); the earlier
|
||||
runtime behavior was the same ordering but had been recorded only as an
|
||||
implementation choice.
|
||||
|
||||
The ruling is recorded but not yet exercisable: the occupied-percentage preview
|
||||
is fenced from Leader Skills (`initializeHomeworldOccupationGameForAudit`
|
||||
requires no skills, and `homeworldOccupiedIncomeIntegrity` requires
|
||||
`!g.leaderSkills` whenever the preview is active). Plain Homeworlds + Leader
|
||||
Skills allied auction/shipment income is connected now, without a split
|
||||
recipient. Exercising the ruled ordering requires deliberately widening the
|
||||
occupation preview to admit Leader Skills, which is a separate composition
|
||||
change, not a silent extension.
|
||||
|
||||
**Guild Nexus Betrayal receipt.** Betrayal redirects the full original charged
|
||||
shipment fee to the Nexus holder, while Bureaucrat says the recipient receives
|
||||
two less. The user ruled that **Bureaucrat applies**: the holder receives
|
||||
price−2 and exactly two goes to the Bank. Neither printed card addresses the
|
||||
other, so this is an explicit user ruling, not a source derivation. The Betrayal
|
||||
preview still excludes Leader Skills until that composition is implemented.
|
||||
|
||||
### Payment families deliberately left unconnected — 13 October 2026
|
||||
|
||||
A bounded sweep of every player-to-player spice transfer found no other
|
||||
qualifying gap:
|
||||
|
||||
- **Below threshold, no work due.** Richese Ambassador purchase income is exactly
|
||||
three; CHOAM Auditor charges at most two; Moritani Extortion is a fixed three.
|
||||
The stamp helper already declines amounts below five, so none can qualify.
|
||||
- **Not player-to-player payments.** Bank/board income families (Moritani terror
|
||||
bounty, CHOAM battle force-income share, Smuggler collection, Spice Banker
|
||||
deferred income, Guild Secret Ally's bank tariff) have no seated third-party
|
||||
payer and correctly carry no stamp. Moritani Robbery is theft, not a payment.
|
||||
- **Emperor gifts** remain excluded by the explicit 11 October ruling.
|
||||
- **Guild Nexus Betrayal full-fee receipt** is ruled to carry Bureaucrat (holder
|
||||
receives price−2; see the 13 October ruling above). It remains unconnected
|
||||
because the recorded Betrayal preview contract expressly excludes Leader
|
||||
Skills, so implementing it requires deliberately widening that preview
|
||||
envelope; it is not a silent omission.
|
||||
- **Homeworld allied/split-recipient income** is now connected under the ruled
|
||||
gross-first ordering above. Only the ruling makes the ordering authoritative;
|
||||
it must not be attributed to the printed source, which is silent.
|
||||
|
||||
Independent Karama shipments paid entirely to the Bank bypass player-income
|
||||
guards. A successful use remains tied to the physical card's turn and phase,
|
||||
including same-phase ownership changes; that accounting is an implementation
|
||||
|
||||
@@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Bureaucrat / Homeworld allied income and two 13 October rulings — 13 October.**
|
||||
Homeworlds + Leader Skills games now connect qualifying allied-funded ordinary
|
||||
auction and Guild-shipment income (`bureaucratAlliedFundingModeSupported` returns
|
||||
`homeworldLeaderSkillsProfile` for Homeworlds). Genuine Basic/Advanced classic
|
||||
Homeworld Skills games train Bureaucrat on Atreides and seat native Guild;
|
||||
53 consumer cases cover 5+3/3+5/5+5/fully donor-funded five, decline/redirect,
|
||||
sub-five nonpooling, owner/payee exclusion without masking an eligible payer,
|
||||
private gross-only invoices with payer null, stale/cross-actor rejection, four
|
||||
bots, once-per-phase use and original Karama cancellation for both income types.
|
||||
Two actual390px flows redirect2: Basic auction5+3 (Emperor8→14, Harkonnen10→5,
|
||||
Guild5→2) and Basic shipment3+5 (Guild5→11, Harkonnen10→7 with eight reserves at
|
||||
Habbanya17, Emperor8→3). Artifact: `/tmp/dune-homeworld-bureaucrat-3229.json`.
|
||||
Two user rulings were recorded: the diversion comes off the gross before any
|
||||
Homeworld occupation split, and a Guild Nexus Betrayal receipt still carries
|
||||
Bureaucrat (holder receives the fee minus two). Neither is exercisable yet — the
|
||||
occupied-percentage preview and the Betrayal preview are both fenced from Leader
|
||||
Skills by explicit validation, so both remain exact recorded boundaries rather
|
||||
than silent omissions. Union369/369 across sixteen files, types/lint and build
|
||||
pass. No deployed, save/recovery or full-mode claim follows.
|
||||
|
||||
**Bureaucrat / native paired E1 funding — 13 October.**
|
||||
Original paired E1 (Ixians/Tleilaxu + classics) with skills now admits
|
||||
qualifying allied ordinary-auction (Emperor), Guild-shipment and paid Tleilaxu
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 October 2026 — Homeworld allied income and two Bureaucrat rulings
|
||||
|
||||
`bureaucratAlliedFundingModeSupported` now returns `homeworldLeaderSkillsProfile`
|
||||
for Homeworlds games, so qualifying allied-funded ordinary auction and
|
||||
Guild-shipment income in Homeworlds + Leader Skills compositions offer the
|
||||
one-time two-spice redirection. Nothing broader opened: multi-expansion,
|
||||
preview overlays and the occupation/Betrayal previews remain excluded.
|
||||
|
||||
The new fixture reuses the genuine classic Homeworld Skills setup (Bureaucrat
|
||||
trained on Atreides, native Guild seated, real Storm/Mentat/worm/alliance). Only
|
||||
original unplayed entropy and card custody are staged. Fifty-three consumer
|
||||
cases cover both modes and both income types: 5+3/3+5/5+5/fully donor-funded
|
||||
five, decline/redirect, sub-five nonpooling, trainer and payee exclusion without
|
||||
masking an eligible payer, private gross-only invoices with payer null and no
|
||||
shares, stale/cross-actor rejection, all four owner bots, once-per-phase use with
|
||||
a real second auction lot, and original Karama cancellation for both income
|
||||
types (canceled gross reaches the Bank, delivery and army commitment survive,
|
||||
and no Bureaucrat offer/use occurs). A negative malformed-save case pins the
|
||||
existing fence message for the occupation preview.
|
||||
|
||||
Two actual390px GameTable flows redirect two: Basic auction5+3 (Emperor8→14,
|
||||
Harkonnen10→5, Guild5→2, one use, original bonus and next lot) and Basic
|
||||
shipment3+5 (Guild5→11, Harkonnen10→7 with eight reserves committed at
|
||||
Habbanya17 beside Carthag11, Emperor8→3, one use). One click was not recorded
|
||||
after a stale accessibility node and was retried; no action was replayed twice.
|
||||
Artifact outside Git `/tmp/dune-homeworld-bureaucrat-3229.json`.
|
||||
|
||||
Two user rulings are recorded in `docs/BUREAUCRAT_PAYMENTS.md` and the source
|
||||
index: (1) Bureaucrat's two comes off the gross before any Homeworld occupation
|
||||
percentage split; (2) a Guild Nexus Betrayal receipt still carries Bureaucrat,
|
||||
so the holder receives the fee minus two. Neither is exercisable yet: the
|
||||
occupied-percentage preview requires no Leader Skills (`initializeHomeworldOccupationGameForAudit`,
|
||||
`homeworldOccupiedIncomeIntegrity`), and the Betrayal preview excludes Leader
|
||||
Skills in `guildBetrayalModeSupported` and every profile predicate. Both are
|
||||
recorded as exact boundaries with code citations, not silent omissions; opening
|
||||
either is a separate composition change.
|
||||
|
||||
The affected union369/369 across sixteen files, types/lint and the app build
|
||||
pass with unchanged build warnings. No source rule, strategy, public-start,
|
||||
save/deployed or full-mode claim is opened by this bounded proof.
|
||||
|
||||
## 13 October 2026 — native paired E1 Bureaucrat funding
|
||||
|
||||
Before this slice the no-Homeworlds paired E1 Nexus profile was absent from
|
||||
|
||||
@@ -400,7 +400,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
|
||||
| Sandmaster movement | One optional board spice per distinct entered territory; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides and physical Fremen Ambassador relocations collect optionally at the destination before arrival reactions, without an invented intermediate route. Owned one-pile-per-territory allocation now covers ground, direct destinations and native HMS relocation; sector allocation remains provisional and concealed No-Field movement stays separate. | [Ground/direct/HMS relocation](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) |
|
||||
| Diplomat defense | A committed Worthless copies the opponent's canonical printed defensive role: base/Ix Shield/Snooper, hybrid, defensive Weirding/Chemistry or sealed Portable. Named combat distinctions remain. Copying the used role, not activation or own-slot prerequisites, is a visibly qualified first version. Reveal-before-traitor timing and mandatory disposal remain implementation interpretations; existing base-only battle windows are not reopened. Late/mirror/numeric modifications remain separate. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
|
||||
| Bureaucrat payments | Once per phase, optionally divert two when one third-party payer pays at least five; payer cost is unchanged. Different below-five allied shares never pool. Supported classic/E1/E2/E3 no-Homeworlds ordinary, Richese-source and Guild auctions/shipment funding, including a fully qualifying ally, offer one gross-receipt redirection; private shares and selected qualifying payer remain withheld. Owner/payee contributions cannot qualify but do not mask a different eligible payer. Bribes trigger at transfer to the shield, not later collection. Existing Guild/Emperor/revival income follows Karama allowance as an outcome-based inference; canceled gross creates no offer/use. Richese uses its original seller-credit callback, not an invented income cancellation. Homeworld split recipients and remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) |
|
||||
| Bureaucrat payments | Once per phase, optionally divert two when one third-party payer pays at least five; payer cost is unchanged. Different below-five allied shares never pool. Supported classic/E1/E2/E3 no-Homeworlds and Homeworlds+Leader Skills ordinary, Richese-source and Guild auctions/shipment funding, including a fully qualifying ally, offer one gross-receipt redirection; private shares and selected qualifying payer remain withheld. Owner/payee contributions cannot qualify but do not mask a different eligible payer. Bribes trigger at transfer to the shield, not later collection. Existing Guild/Emperor/revival income follows Karama allowance as an outcome-based inference; canceled gross creates no offer/use. Richese uses its original seller-credit callback, not an invented income cancellation. By user ruling, the two comes off the gross before any Homeworld occupation split, and a Guild Nexus Betrayal receipt still carries Bureaucrat (holder receives the fee minus two); both compositions are fenced from Leader Skills today. Remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) |
|
||||
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Owned multi-pile Smuggler debits and Sandmaster victory placement are connected under provisional allocation policies; modifier and exceptional Face Dancer composition remain separate. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md), [Smuggler allocation](SMUGGLER_BATTLE.md) |
|
||||
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
|
||||
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
|
||||
|
||||
@@ -49,7 +49,8 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
|
||||
nativeExpansionLeaderSkillsProfile(game))) || homeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
|
||||
}
|
||||
export function bureaucratAlliedFundingModeSupported(game: LeaderSkillProfile): boolean {
|
||||
if (game.homeworlds || !bureaucratPaymentModeSupported(game)) return false;
|
||||
if (!bureaucratPaymentModeSupported(game)) return false;
|
||||
if (game.homeworlds) return homeworldLeaderSkillsProfile(game);
|
||||
if (game.expansions.length === 1 && (game.expansions[0] === 'ecaz' || game.expansions[0] === 'ix'))
|
||||
return pairedNexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game);
|
||||
return !game.expansions.length || game.expansions.length === 1 && game.expansions[0] === 'choam';
|
||||
|
||||
+1
-1
@@ -4802,7 +4802,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.',
|
||||
'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.',
|
||||
'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
|
||||
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with their remaining share unavailable until Mentat Pause. Supported auctions, Guild income, Richese sales and paid Tleilaxu revival have connected paths; a separate free-revival award does not make a smaller price qualify. Allied payer shares below five each, such as three plus three, never pool to qualify. Supported classic, E1, E2 and E3 games without Homeworlds additionally connect qualifying allied-funded ordinary auctions, Richese cache/Advanced Black Market and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs. Original Guild/Emperor income cancellation resolves first; Richese proceeds use their original direct seller-credit callback without an invented cancellation window. Basic still has no Black Market and free Karama purchases remain normal-only. A trainer or payee contribution cannot qualify, but does not hide an eligible other payer. The prompt shows public gross and recipient, not private shares or which payer qualified. Homeworld allied/split-recipient composition remains separate. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Other payment-family/module interactions remain unfinished.',
|
||||
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with their remaining share unavailable until Mentat Pause. Supported auctions, Guild income, Richese sales and paid Tleilaxu revival have connected paths; a separate free-revival award does not make a smaller price qualify. Allied payer shares below five each, such as three plus three, never pool to qualify. Supported classic, E1, E2 and E3 games without Homeworlds additionally connect qualifying allied-funded ordinary auctions, Richese cache/Advanced Black Market and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs. Supported Homeworlds plus Leader Skills games connect the same allied-funded auction and Guild-shipment income. Original Guild/Emperor income cancellation resolves first; Richese proceeds use their original direct seller-credit callback without an invented cancellation window. Basic still has no Black Market and free Karama purchases remain normal-only. A trainer or payee contribution cannot qualify, but does not hide an eligible other payer. The prompt shows public gross and recipient, not private shares or which payer qualified. By user ruling the diversion comes off the gross before any Homeworld occupation split, but that split-recipient composition remains fenced from Leader Skills. By user ruling a Guild Nexus Betrayal receipt still carries Bureaucrat (holder receives the fee minus two), pending that composition. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Other payment-family/module interactions remain unfinished.',
|
||||
'Smuggler records all existing positive battle-territory piles when plans reveal, capped in total by the selected leader’s unmodified strength. A surviving controller collects even when losing or no forces remain. A partial collection across several piles asks that owner for exact sector amounts; all/none and single-pile collection stay automatic. This allocation policy is provisional. Pending spice cannot fund the plan. Collection precedes Sandmaster addition; a drained pile receives none. Modified strength and other combined-module limits remain guarded. Older saved battles retain their recorded behavior.',
|
||||
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
|
||||
'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.',
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeHomeworldOccupationGameForAudit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { bureaucratAlliedFundingModeSupported } from '../game/bureaucrat-payment';
|
||||
import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills';
|
||||
import {
|
||||
advanceHomeworldFunding, allowHomeworldFundingResponses as allow, chooseHomeworldFunding as choose,
|
||||
createHomeworldFundingFixture as fixture, holdHomeworldFundingKarama,
|
||||
homeworldFundingCards, homeworldFundingClean, homeworldFundingPlayer as player,
|
||||
payHomeworldFunding as pay, type HomeworldFundingFixture,
|
||||
} from './fixture-bureaucrat-homeworld-funding';
|
||||
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
const armies = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves,
|
||||
tanks: p.tanks, elites: p.elites }));
|
||||
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 proves the flat Bank diversion, not just the
|
||||
* recipient's reduced receipt. Homeworld native custody remains real. */
|
||||
function outcome(f: HomeworldFundingFixture, done: Game, diverted: number, canceled = false): void {
|
||||
const expected = wallets(f.beforePledge);
|
||||
expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare;
|
||||
if (!canceled) expected[f.payee] += f.gross - diverted;
|
||||
assert.deepEqual(wallets(done), expected);
|
||||
assert.equal(Object.values(wallets(f.beforePledge)).reduce((a, b) => a + b, 0) -
|
||||
Object.values(wallets(done)).reduce((a, b) => a + b, 0), canceled ? f.gross : diverted,
|
||||
'Only the original canceled gross or exactly two diverted spice leaves the seated wallets for the Bank');
|
||||
assert.equal(done.aid[f.donor]?.amount ?? 0, 0);
|
||||
assert.deepEqual(homeworldFundingCards(done), f.physical);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, diverted === 2);
|
||||
if (f.kind === 'auction') {
|
||||
assert.deepEqual(player(done, f.payer).hand.map(c => c.id),
|
||||
[...player(f.beforeAction, f.payer).hand.map(c => c.id), f.card, f.beforeAction.deck[0].id]);
|
||||
assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
assert.deepEqual(armies(done), armies(f.beforePledge));
|
||||
assert.deepEqual(done.homeworlds!.custody, f.beforePledge.homeworlds!.custody);
|
||||
} else {
|
||||
assert.equal(player(done, f.payer).reserves, player(f.beforePledge, f.payer).reserves - f.gross);
|
||||
assert.deepEqual(player(done, f.payer).forces,
|
||||
{ ...player(f.beforePledge, f.payer).forces, [`${f.target}:${f.sector}`]: f.gross });
|
||||
assert.equal(player(done, f.payer).shipped, true);
|
||||
for (const actor of done.players.filter(p => p.id !== f.payer)) {
|
||||
assert.deepEqual(actor.forces, player(f.beforePledge, actor.id).forces);
|
||||
assert.equal(actor.reserves, player(f.beforePledge, actor.id).reserves);
|
||||
}
|
||||
assert.equal(done.phase, 5); assert.equal(done.active, f.payer);
|
||||
const moved = applyAction(done, f.payer, { type: 'endMovement' });
|
||||
assert.notEqual(moved.active, f.payer); assert.deepEqual(armies(moved), armies(done));
|
||||
}
|
||||
assertClassicHomeworldSkillsCustody(done);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const kind of ['auction', 'shipment'] as const) {
|
||||
for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]]) for (const redirect of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld ${kind} ${payerShare}+${donorShare} ${redirect ? 'redirects two' : 'declines'} with full debits and original continuation`, () => {
|
||||
const f = fixture({ kind, advanced, payerShare, donorShare });
|
||||
assert.equal(bureaucratAlliedFundingModeSupported(f.game), true);
|
||||
assert.equal(player(f.alliance, f.payer).ally, f.donor);
|
||||
assert.equal(player(f.alliance, f.donor).ally, f.payer);
|
||||
assert.equal(player(f.beforeAction, f.donor).spice, player(f.beforePledge, f.donor).spice - donorShare);
|
||||
assert.deepEqual(armies(f.beforeAction), armies(f.beforePledge));
|
||||
const paid = pay(f), pending = allow(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(pending.decision?.player, f.trainer);
|
||||
assert.equal(player(pending, f.payer).spice, player(f.beforePledge, f.payer).spice - payerShare);
|
||||
assert.equal(player(pending, f.donor).spice, player(f.beforePledge, f.donor).spice - donorShare);
|
||||
assert.equal(player(pending, f.payee).spice, player(f.beforePledge, f.payee).spice);
|
||||
assert.deepEqual(armies(pending), armies(paid));
|
||||
const done = allow(choose(pending, redirect)); outcome(f, done, redirect ? 2 : 0);
|
||||
reject(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld ${kind} cannot pool two sub-five contributions`, () => {
|
||||
const f = fixture({ kind, advanced, payerShare: 3, donorShare: 3 });
|
||||
const done = allow(pay(f)); assert.notEqual(done.decision?.kind, 'bureaucratPayment'); outcome(f, done, 0);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld ${kind} excludes trainer and payee without masking an eligible third party`, () => {
|
||||
for (const donor of kind === 'auction' ? ['atreides', 'emperor'] : ['atreides']) {
|
||||
const eligible = fixture({ kind, advanced, donor, payerShare: 5, donorShare: 5 });
|
||||
const pending = allow(pay(eligible)); assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
outcome(eligible, allow(choose(pending, true)), 2);
|
||||
const excluded = fixture({ kind, advanced, donor, payerShare: 3, donorShare: 5 });
|
||||
const done = allow(pay(excluded)); assert.notEqual(done.decision?.kind, 'bureaucratPayment'); outcome(excluded, done, 0);
|
||||
}
|
||||
if (kind === 'shipment') {
|
||||
// Guild alliance invokes the original half-price native transport. Its
|
||||
// contribution to its own income cannot qualify as a third-party payer.
|
||||
const f = fixture({ kind, advanced, donor: 'guild', payerShare: 5, donorShare: 5 });
|
||||
const done = allow(pay(f)); assert.notEqual(done.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(player(done, f.payer).spice, player(f.beforePledge, f.payer).spice);
|
||||
assert.equal(player(done, f.donor).spice, player(f.beforePledge, f.donor).spice - 5);
|
||||
assert.equal(player(done, f.payer).reserves, player(f.beforePledge, f.payer).reserves - 10);
|
||||
assert.equal(player(done, f.payer).forces[`${f.target}:${f.sector}`], 10);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.notEqual(applyAction(done, f.payer, { type: 'endMovement' }).active, f.payer);
|
||||
assertClassicHomeworldSkillsCustody(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld ${kind} publishes gross only, rejects stale/cross-actor choices, and supports all four owner bots`, () => {
|
||||
for (const [payerShare, donorShare] of [[5, 3], [3, 5]]) {
|
||||
const f = fixture({ kind, advanced, payerShare, donorShare }), pending = allow(pay(f));
|
||||
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.amount, 8);
|
||||
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('bureaucratPayments' in view, false); assert.equal('bureaucratUseEvents' in view, false);
|
||||
}
|
||||
reject(pending, f.donor, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
reject(pending, f.trainer, { type: 'decision', event: 'expired-funding-event', redirect: true });
|
||||
reject(pending, f.payer, kind === 'shipment' ? { type: 'endMovement' } : f.action);
|
||||
reject(pending, f.donor, { type: 'pledgeAid', amount: 1 });
|
||||
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);
|
||||
outcome(f, allow(applyAction(pending, f.trainer, actions[0])), 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld ${kind} original Karama cancels gross after full payment, without an offer/use`, () => {
|
||||
const f = fixture({ kind, advanced, payerShare: 5, donorShare: 5 });
|
||||
const card = holdHomeworldFundingKarama(f), paid = pay(f);
|
||||
assert.equal(paid.response?.kind, kind === 'auction' ? 'emperorIncome' : 'guildIncome');
|
||||
assert.ok(viewGame(paid, f.trainer).responseControls?.cancelCards.includes(card));
|
||||
const done = allow(applyAction(paid, f.trainer, { type: 'card', mode: 'cancel', card }));
|
||||
assert.notEqual(done.decision?.kind, 'bureaucratPayment'); outcome(f, done, 0, true);
|
||||
assert.equal(done.discard.filter(c => c.id === card).length, 1);
|
||||
assert.ok(!player(done, f.trainer).hand.some(c => c.id === card));
|
||||
reject(done, f.trainer, { type: 'card', mode: 'cancel', card });
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld auction decline retains the physical use for the next genuine lot, redirect spends it only once`, () => {
|
||||
for (const redirect of [false, true]) {
|
||||
const f = fixture({ kind: 'auction', advanced, payerShare: 3, donorShare: 5 });
|
||||
const first = allow(choose(allow(pay(f)), redirect));
|
||||
const next = advanceHomeworldFunding(first, g => !!g.auction && g.auction.active === f.payer && homeworldFundingClean(g));
|
||||
const before = structuredClone(next), card = next.auction!.cards[next.auction!.index].id;
|
||||
const second = { ...f, game: next, action: { type: 'bid', amount: 5 } as Action };
|
||||
let done = allow(pay(second));
|
||||
if (redirect) {
|
||||
assert.notEqual(done.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(player(done, f.payee).spice, player(before, f.payee).spice + 5);
|
||||
} else {
|
||||
assert.equal(done.decision?.kind, 'bureaucratPayment');
|
||||
done = allow(choose(done, true));
|
||||
assert.equal(player(done, f.payee).spice, player(before, f.payee).spice + 3);
|
||||
}
|
||||
assert.equal(player(done, f.payer).spice, player(before, f.payer).spice - 5);
|
||||
assert.deepEqual(player(done, f.payer).hand.map(c => c.id),
|
||||
[...player(before, f.payer).hand.map(c => c.id), card, before.deck[0].id]);
|
||||
assert.equal(done.auction!.index, before.auction!.index + 1);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.deepEqual(homeworldFundingCards(done), f.physical);
|
||||
assert.deepEqual(armies(done), armies(before));
|
||||
assertClassicHomeworldSkillsCustody(done);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld physical use is once per phase; decline does not spend it and next phase renews it`, () => {
|
||||
for (const redirect of [false, true]) {
|
||||
const f = fixture({ kind: 'shipment', advanced, payerShare: 3, donorShare: 5 });
|
||||
const pending = allow(pay(f)), settled = allow(choose(pending, redirect));
|
||||
const following = applyAction(settled, f.payer, { type: 'bribe', target: 'guild', amount: 5 });
|
||||
let used: Game;
|
||||
if (redirect) {
|
||||
assert.notEqual(following.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(player(following, 'guild').bribes, player(settled, 'guild').bribes + 5); used = following;
|
||||
} else {
|
||||
assert.equal(following.decision?.kind, 'bureaucratPayment');
|
||||
reject(following, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
used = allow(choose(following, true));
|
||||
assert.equal(player(used, 'guild').bribes, player(settled, 'guild').bribes + 3);
|
||||
}
|
||||
assert.equal(used.bureaucratPayments!.used.length, 1);
|
||||
const ended = applyAction(used, f.payer, { type: 'endMovement' });
|
||||
const next = advanceHomeworldFunding(ended, g => g.turn === 3 && g.phase === 1 && homeworldFundingClean(g));
|
||||
assert.equal(viewGame(next, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
// The Guild's actual shipment receipts fund a later genuine third-party bribe.
|
||||
const renewed = allow(choose(applyAction(next, 'guild', { type: 'bribe', target: 'emperor', 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('occupied-percentage entry remains explicitly fenced from an actual trained Homeworld Bureaucrat game', () => {
|
||||
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.' });
|
||||
// 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.deepEqual(rejectedSave, before);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import {
|
||||
advanceClassicHomeworldSkills, assertClassicHomeworldSkillsCustody,
|
||||
classicHomeworldSkillsPlayer, createClassicHomeworldSkillsSetup,
|
||||
type ClassicHomeworldSkillsStep,
|
||||
} from './fixture-homeworld-classic-skills';
|
||||
|
||||
export const homeworldFundingPlayer = classicHomeworldSkillsPlayer;
|
||||
export const homeworldFundingClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
export const advanceHomeworldFunding = advanceClassicHomeworldSkills;
|
||||
|
||||
export function homeworldFundingCards(game: Game): string[] {
|
||||
const remaining = game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [];
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...remaining].map(c => c.id).sort();
|
||||
}
|
||||
|
||||
/** Select only original unplayed Spice cards; retain every physical component. */
|
||||
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
|
||||
const before = [...game.spiceDeck];
|
||||
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]);
|
||||
}
|
||||
assert.deepEqual([...game.spiceDeck].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
before.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))));
|
||||
staging.push(`Selected original unplayed Spice order: ${kinds.join(', ')}. No wallets, forces, phase or alliance assigned.`);
|
||||
}
|
||||
|
||||
export type HomeworldFundingFixture = {
|
||||
kind: 'auction' | 'shipment'; advanced: boolean;
|
||||
game: Game; beforePledge: Game; beforeAction: Game; alliance: Game;
|
||||
payer: string; donor: string; payee: string; trainer: string;
|
||||
payerShare: number; donorShare: number; gross: number;
|
||||
action: Action; card: string | null; target: string; sector: number;
|
||||
physical: string[]; staging: string[]; actions: ClassicHomeworldSkillsStep[];
|
||||
};
|
||||
|
||||
/** Real classic Homeworld Skills setup, first Mentat, second-turn worm and
|
||||
* reciprocal alliance. Only original unplayed entropy/card custody is staged. */
|
||||
export function createHomeworldFundingFixture(options: {
|
||||
kind: 'auction' | 'shipment'; advanced?: boolean;
|
||||
payerShare?: number; donorShare?: number; donor?: string;
|
||||
}): HomeworldFundingFixture {
|
||||
const { kind, advanced = false, payerShare = 5, donorShare = 3 } = options;
|
||||
const payer = 'harkonnen', trainer = 'atreides';
|
||||
const donor = options.donor ?? (kind === 'auction' ? 'guild' : 'emperor');
|
||||
const payee = kind === 'auction' ? 'emperor' : 'guild';
|
||||
const setup = createClassicHomeworldSkillsSetup({ advanced });
|
||||
const actions = [...setup.actions], staging = [...setup.staging];
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')!.owner, trainer);
|
||||
assert.equal(game.homeworldOccupationPreview, undefined);
|
||||
orderSpice(game, advanced ? ['land', 'land'] : ['land'], staging);
|
||||
game = advanceHomeworldFunding(game, g => g.turn === 2 && g.phase === 1 && homeworldFundingClean(g), actions);
|
||||
orderSpice(game, advanced ? ['worm', 'land', 'land'] : ['worm', 'land'], staging);
|
||||
game = advanceHomeworldFunding(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldFundingClean(g), actions);
|
||||
for (const [actor, target] of [[payer, donor], [donor, payer]]) {
|
||||
const step: ClassicHomeworldSkillsStep = { actor, action: { type: 'alliance', target } };
|
||||
actions.push(step); game = applyAction(game, actor, step.action);
|
||||
}
|
||||
assert.equal(homeworldFundingPlayer(game, payer).ally, donor);
|
||||
assert.equal(homeworldFundingPlayer(game, donor).ally, payer);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceHomeworldFunding(game, g => g.turn === 2 && g.phase === (kind === 'auction' ? 3 : 5) &&
|
||||
homeworldFundingClean(g) && (kind === 'auction' ? g.auction!.active === payer : g.active === payer), actions);
|
||||
const beforePledge = structuredClone(game);
|
||||
const pledge: Action = { type: 'pledgeAid', amount: donorShare };
|
||||
actions.push({ actor: donor, action: pledge }); game = applyAction(game, donor, pledge);
|
||||
const beforeAction = structuredClone(game), gross = payerShare + donorShare;
|
||||
const target = 'habbanya_ridge_sietch', sector = territory(target).sectors[0];
|
||||
const card = kind === 'auction' ? game.auction!.cards[game.auction!.index].id : null;
|
||||
if (kind === 'shipment') assert.ok(game.players.every(p => !Object.keys(p.forces).some(k => k.startsWith(target))));
|
||||
const action: Action = kind === 'auction' ? { type: 'bid', amount: gross, allyPayment: donorShare }
|
||||
: { type: 'ship', territory: target, sector, amount: gross, elite: 0, allyPayment: donorShare };
|
||||
assertClassicHomeworldSkillsCustody(game);
|
||||
return { kind, advanced, game, beforePledge, beforeAction, alliance, payer, donor, payee, trainer,
|
||||
payerShare, donorShare, gross, action, card, target, sector, physical: homeworldFundingCards(game), staging, actions };
|
||||
}
|
||||
|
||||
export function payHomeworldFunding(f: HomeworldFundingFixture, state = f.game): Game {
|
||||
let game = applyAction(state, f.payer, f.action);
|
||||
if (f.kind === 'shipment') {
|
||||
if (game.decision?.kind === 'guildShipment') game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
||||
} else {
|
||||
const index = state.auction!.index;
|
||||
for (let n = 0; !game.currentAuctionSale && game.auction?.index === index && n < game.players.length + 2; n++) {
|
||||
if (game.decision?.kind === 'auctionPayment') {
|
||||
game = applyAction(game, f.payer, { type: 'decision', karama: false }); break;
|
||||
}
|
||||
assert.ok(!game.decision && !game.response);
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
}
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
export function allowHomeworldFundingResponses(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);
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
export function chooseHomeworldFunding(game: Game, redirect: boolean): Game {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect });
|
||||
}
|
||||
|
||||
/** Conserved original unplayed Karama custody, explicitly recorded. */
|
||||
export function holdHomeworldFundingKarama(f: HomeworldFundingFixture): string {
|
||||
const player = homeworldFundingPlayer(f.game, f.trainer);
|
||||
const held = player.hand.find(c => c.effect === 'karama');
|
||||
if (held) return held.id;
|
||||
const index = f.game.deck.findIndex(c => c.effect === 'karama'); assert.ok(index >= 0 && player.hand.length > 0);
|
||||
const card = f.game.deck[index], outgoing = player.hand[0];
|
||||
player.hand[0] = card; f.game.deck[index] = outgoing;
|
||||
f.staging.push(`Conserved original unplayed custody swap: ${card.id} from deck -> ${f.trainer} hand, ${outgoing.id} -> deck, solely for the original income cancellation response.`);
|
||||
f.beforeAction = structuredClone(f.game);
|
||||
assert.deepEqual(homeworldFundingCards(f.game), f.physical);
|
||||
assertClassicHomeworldSkillsCustody(f.game);
|
||||
return card.id;
|
||||
}
|
||||
Reference in new issue
Block a user