Connect native paired E2 Bureaucrat funding
This commit is contained in:
1 parent
c3d1896673
commit
b207dd0479
13 files changed
+998
-49
No files matched your search
@@ -66,13 +66,15 @@ The earlier question is now resolved by the 11 October ruling above.
|
||||
Gift classification stays separate from the connected ordinary
|
||||
[bribe controls](BRIBE_CONTROLS.md).
|
||||
|
||||
For native income, the prototype resolves the existing Karama income window
|
||||
For native income windows, the prototype resolves the existing Karama response
|
||||
first, then offers Bureaucrat before crediting the eligible recipient and
|
||||
continuing the original auction or shipment. A canceled income payment goes to
|
||||
the Bank, so creates no player-directed trigger. This is an outcome-based
|
||||
composition inference. The rulebook's skill-before-faction sentence occurs in
|
||||
its battle paragraph and does not expressly order this economic reaction.
|
||||
The implementation does not settle Bureaucrat-before-Karama consumption.
|
||||
Richese-directed auction proceeds use their original seller-credit callback;
|
||||
no additional Richese-income Karama window is invented.
|
||||
|
||||
In the Basic Tleilaxu skill profile, a paid revival first commits its force or
|
||||
leader custody and deducts the actual price. Its native income response still
|
||||
@@ -91,13 +93,13 @@ Allied funding is evaluated per actual payer, not by adding different payers'
|
||||
shares to create the five-spice trigger. A three-plus-three payment, or
|
||||
four-plus-three, has no eligible payer and proceeds through original funding,
|
||||
payment, card/force commitment and recipient income without a Bureaucrat offer.
|
||||
In supported classic profiles without Homeworlds, individually qualifying
|
||||
In supported classic and E2 profiles without Homeworlds, individually qualifying
|
||||
allied-funded auction and shipment receipts now offer one optional 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. Qualifying expansion-faction funding and split-recipient
|
||||
Homeworld income retain their separate integration guards.
|
||||
eligible payer. Qualifying E1/E3 funding and Homeworld allied/split-recipient
|
||||
composition retain their separate integration boundaries.
|
||||
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
|
||||
@@ -172,6 +174,56 @@ The native fixtures and consumer cases are
|
||||
`tests/fixture-bureaucrat-qualifying-auction.ts` and
|
||||
`tests/bureaucrat-qualifying-auction.test.ts`.
|
||||
|
||||
## Native paired E2 receipts — 13 October 2026
|
||||
|
||||
`tests/fixture-bureaucrat-paired-funding.ts` uses original six-seat
|
||||
CHOAM/Richese/Harkonnen/Fremen/Emperor/Guild setup with fourteen skills and
|
||||
Nexus. CHOAM actually trains Bureaucrat. Real first-turn bribe3, Mentat
|
||||
Fremen3→6, next worm and mutual alliance retain every original army/wallet.
|
||||
Before pledges: C26/R5/H7 Basic or9 Advanced/F6/E10/G5 Basic or6 Advanced.
|
||||
Two before shipment programs and five original auction branches (ordinary/cache
|
||||
both modes, Advanced Black Market) rejected immutably at the earlier Bureaucrat
|
||||
module guard after actual F pledge5. The original paired E2 Nexus profile is
|
||||
now admitted without Homeworlds; allied support is centralized and preserves
|
||||
the other profile and printed-price constraints.
|
||||
|
||||
Ordinary auctions retain Emperor income cancellation; canceled gross goes to
|
||||
Bank with no offer/use. Richese cache and Advanced Black Market retain their
|
||||
actual seller-credit and bonus/closing callbacks, not an invented cancellation
|
||||
response. Basic still has no Black Market, and free Karama purchases remain
|
||||
normal-only. Owner/payee exclusion, donor-only five, sub-five nonpooling and
|
||||
sealed-bid privacy use the same private receipt policy as classic funding.
|
||||
|
||||
The actual Advanced five-plus-five Guild after program retains interception,
|
||||
original Karama allowance, H4/F1/C26/R5/E10/G6 before credit, then G14 on
|
||||
redirect with one use and ten original reserves deployed beside Carthag10.
|
||||
Its original Finish advances to Fremen. The actual Advanced five-plus-five
|
||||
cache after program credits R5→13, H4/F1, one Residual Poison purchase and
|
||||
one native bonus, then the original five-card normal pool at index0.
|
||||
Four parent actual programs also cover Basic ordinary and cache payments,
|
||||
Advanced wholly donor-funded Black Market and an actual trainer-funded
|
||||
nonqualifying cache payment. Two actual cache-last programs retain the pending
|
||||
CHOAM market, then original Done reaches Revival without another debit,
|
||||
purchase or use.
|
||||
|
||||
Three actual390px GameTable flows retain C26 and original forces: Advanced
|
||||
three-plus-five Guild redirects2, G6→12/H6/F1, Finish→Fremen; Basic five-plus-three
|
||||
cache allows8, R5→13/H2/F3, one purchase/bonus and original normal lot0; Advanced
|
||||
fully F-funded Black Market redirects2, R5→8/H9/F1, one transferred original
|
||||
card/bonus and original Richese declaration. Its actual cache-first control
|
||||
reaches original cache selection without another cost. Browser errors are empty.
|
||||
Artifact outside Git: `/tmp/dune-paired-bureaucrat-3226.json`.
|
||||
|
||||
The frozen consumer union157/157 across eight files passes. Initial feedback
|
||||
removed an incorrect direct-to-Revival pin and replaced it with actual original
|
||||
closing progression. An exact sealed-log comparison wrongly coupled independent
|
||||
public Storm bidder order; that incidental pin is removed, not re-pinned.
|
||||
Private receipt fields and actual payment/acquisition outcomes remain checked.
|
||||
One fixture event narrowing is repaired. A bare-not-throw/shared-gate echo
|
||||
case is removed from the affected CHOAM integration file. No source rule,
|
||||
strategy, Homeworld funding, public-start, save/deployed or complete-mode claim
|
||||
is changed by this bounded proof.
|
||||
|
||||
## Saved choices and verification
|
||||
|
||||
Only the skill owner may redirect or allow the current payment. The saved event
|
||||
|
||||
@@ -29,6 +29,32 @@ 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 / native paired E2 funding — 13 October.**
|
||||
Original six-seat CHOAM/Richese/Harkonnen/Fremen/Emperor/Guild Skills Nexus
|
||||
now admits qualifying allied normal/cache/Advanced Black Market and shipment
|
||||
receipts without Homeworlds. Native CHOAM trains Bureaucrat; real bribe3/Mentat,
|
||||
next worm and mutual alliance retain original armies and C26/R5/H7 Basic or9
|
||||
Advanced/F6/E10/G5 Basic or6 Advanced before pledges. Two shipment and five
|
||||
source-specific auction before programs rejected immutably at the earlier
|
||||
module guard. Existing native E2 profile and centralized allied support now
|
||||
connect original full costs, one flat2, private receipt fields and four legal bots.
|
||||
Native Guild/Emperor cancellation stays first; Richese proceeds use original
|
||||
seller credit with no invented cancellation. Basic no-Black-Market and normal-only
|
||||
free Karama purchase boundaries remain. E1/E3 and Homeworld funding stay separate.
|
||||
Three actual390px flows: Advanced3+5 Guild redirect G12/H6/F1/C26 and original
|
||||
Finish→Fremen; Basic5+3 cache allowance R13/H2/F3/C26, one purchase/bonus and
|
||||
normal lot0; Advanced wholly F-funded5 Black Market redirect R8/H9/F1/C26,
|
||||
one transfer/bonus then original declaration/cache-first selection, no extra cost.
|
||||
Artifact: `/tmp/dune-paired-bureaucrat-3226.json`; browser errors empty.
|
||||
Four parent actual source programs and two actual cache-last closings retain
|
||||
original CHOAM market→Revival, no additional debit/purchase/use. Final157/157
|
||||
across eight files pass. Initial direct-to-Revival and exact sealed-log order
|
||||
pins were removed, not re-pinned; independent public Storm order is not private
|
||||
funding. A fixture narrowing is repaired; one shared-gate/bare-not-throw echo
|
||||
case is removed. No rule, strategy, save/deployed/full-mode claim follows.
|
||||
Final types/lint and app build pass after removing the unused offer-initializer
|
||||
import left by that obsolete case. Existing build warnings are unchanged.
|
||||
|
||||
**Bureaucrat / qualifying classic allied receipts — 13 October.**
|
||||
Original Basic/Advanced five-plus-three, three-plus-five, five-plus-five and
|
||||
fully ally-funded five auction/shipment payments now offer one two-spice Bank
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 October 2026 — native paired E2 Bureaucrat funding
|
||||
|
||||
The earlier mode guard excluded genuine paired E2 Nexus skill games before
|
||||
their allied-funding guard. Existing original paired CHOAM/Richese Nexus
|
||||
profile is now admitted specifically without Homeworlds; allied funding
|
||||
support is centralized for supported classic or E2 profiles. Source receipts,
|
||||
single-payer signatures, physical card use and private projection are unchanged.
|
||||
Qualifying E1/E3 and Homeworld allied/split-recipient composition stay separate;
|
||||
no new public start, constructor prerequisite or recovery certification follows.
|
||||
|
||||
The new common fixture retains all native six-seat setup armies and wallets:
|
||||
C26/R5/H7 Basic or9 Advanced/F6/E10/G5 Basic or6 Advanced before actual pledges.
|
||||
Native C actually trains Bureaucrat; actual H→F bribe3, first Mentat F3→6,
|
||||
second worm and mutual alliance precede T2 funding. Only original unplayed
|
||||
cards/entropy/custody are controlled. Both shipment before programs and five
|
||||
auction before branches (normal/cache both modes, Advanced Black Market)
|
||||
rejected immutably after pledge5 at the original mode guard. The auction
|
||||
program initially expected the later funding guard; its expectation was wrong,
|
||||
and the final actual branches identify the earlier message rather than invent
|
||||
a different rejection.
|
||||
|
||||
One genuine Advanced5+5 Guild after program retains original interception,
|
||||
Karama allowance and payment order: C26/R5/H4/F1/E10/G6 before credit, G14
|
||||
after redirect, one use, original ten reserves at Habbanya17 beside Carthag10,
|
||||
then original Finish→Fremen. One Advanced5+5 silent cache after program uses
|
||||
the actual Richese seller-credit callback: H4/F1/R5→13/E10/C26; Residual Poison
|
||||
once plus native treachery-7 bonus, then original five-card normal pool index0.
|
||||
No Richese-income Karama window is fabricated. Ordinary Emperor cancellation
|
||||
banks the original gross; free Karama purchases stay normal-only, and Basic
|
||||
has no Black Market.
|
||||
|
||||
Four parent actual programs additionally cover Basic ordinary5+3 and cache3+5
|
||||
redirection, Advanced entirely F-funded5 Black Market allowance and a real
|
||||
H/C alliance where only excluded trainer5 funds an H3 cache payment.
|
||||
That last payment proceeds with no offer/use, C21/R13/H6/F6, one purchase/bonus.
|
||||
Two real last-cache programs preserve pending CHOAM market and actual Done:
|
||||
Revival4, R13/H2 Basic or4 Advanced/F1/C26, unchanged purchase/bonus, one prior
|
||||
use and renewed phase opportunity; no phase assignment or second debit occurs.
|
||||
|
||||
Three actual390px GameTable flows start after actual native commitment.
|
||||
Advanced Guild3+5 resolves printed allowance then redirect: G6→12/H6/F1/C26,
|
||||
eight reserves at Habbanya17, original Finish→Fremen. Basic cache5+3 allows8:
|
||||
R5→13/H2/F3/C26, original Residual Poison and one bonus, original normal lot0/R.
|
||||
Advanced wholly donor-funded5 Black Market redirects2: R5→8/H9/F1/C26,
|
||||
one original treachery-1 transfer and bonus7; original Richese declaration
|
||||
then actual cache-first control reaches cache selection without another cost.
|
||||
Browser errors are empty; artifact `/tmp/dune-paired-bureaucrat-3226.json`.
|
||||
Only owned isolated host/tab/scaffold is cleaned; existing games/services remain.
|
||||
|
||||
Initial frozen union157 cases had155 pass and two incorrect direct-to-Revival
|
||||
pins fail: original CHOAM market remains pending. The incidental pin is deleted,
|
||||
and that same consumer path now completes real closing with money/cards/use
|
||||
assertions. One fixture event narrowing is repaired; focused two-case iteration
|
||||
and types pass. The next union exposed an exact sealed-log comparison coupled
|
||||
to independent public Storm bidder order (156/157). It is removed, not re-pinned;
|
||||
private gross/payee/null-payer fields and actual acquisition/payment remain
|
||||
checked. Focused sealed privacy1/1 and final157/157 across eight files pass.
|
||||
The affected old shared-gate/bare-not-throw echo test and its dead imports are
|
||||
removed rather than replaced with another wiring assertion.
|
||||
Final types/lint and app build pass. The first lint run found the unused offer
|
||||
initializer left by that removed case; the import is deleted. Existing module
|
||||
registration deprecation, chunk-size and static route-classification warnings
|
||||
remain unchanged. The app guide and source-ruling boundary match this scope.
|
||||
|
||||
## 13 October 2026 — qualifying classic allied Bureaucrat receipts
|
||||
|
||||
Supported classic no-Homeworlds auction and shipment funding now retains each
|
||||
|
||||
@@ -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 no-Homeworlds auction/shipment funding, including a fully qualifying ally, now offers 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. Native income follows Karama allowance as an outcome-based inference; canceled gross income creates no offer/use. Qualifying expansion-faction funding, 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/E2 no-Homeworlds ordinary and Richese-source 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 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. Qualifying E1/E3 funding, Homeworld split recipients and 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) |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
|
||||
export type BureaucratPaymentSource = {
|
||||
@@ -39,12 +39,19 @@ export function bureaucratPaymentSignature(value: object): string {
|
||||
return JSON.stringify({ ...value, signature: undefined });
|
||||
}
|
||||
export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
|
||||
return (!game.homeworlds && game.expansions.length === 1 && game.expansions[0] === 'choam' &&
|
||||
pairedNexusLeaderSkillsProfile(game)) ||
|
||||
mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
|
||||
(noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
|
||||
nativeExpansionLeaderSkillsProfile(game))) || homeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
|
||||
}
|
||||
export function bureaucratAlliedFundingModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return !game.homeworlds &&
|
||||
(!game.expansions.length || game.expansions.length === 1 && game.expansions[0] === 'choam') &&
|
||||
bureaucratPaymentModeSupported(game);
|
||||
}
|
||||
export function bureaucratUsed(
|
||||
used: readonly BureaucratPaymentUse[],
|
||||
turn: number,
|
||||
|
||||
+5
-5
@@ -7,7 +7,7 @@ import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsu
|
||||
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
import { leaderSkillCard } from './leader-skill-cards';
|
||||
import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
|
||||
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
|
||||
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
|
||||
import {
|
||||
canUsePlanetologistBattleSpecial,
|
||||
@@ -8460,8 +8460,8 @@ function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{paye
|
||||
if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) ||
|
||||
!paid.some(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee)) return;
|
||||
requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.');
|
||||
requireRule(paid.length === 1 && !privateFunding || !g.homeworlds && !g.expansions.length,
|
||||
'Bureaucrat on allied funding or multiple payer contributions with Homeworlds or expansion factions is awaiting integration.');
|
||||
requireRule(paid.length === 1 && !privateFunding || bureaucratAlliedFundingModeSupported(g),
|
||||
'Bureaucrat on allied funding or multiple payer contributions in this module composition is awaiting integration.');
|
||||
}
|
||||
function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number,
|
||||
contributions?:BureaucratPaymentSource['contributions']):
|
||||
@@ -8477,7 +8477,7 @@ function stampBureaucratContributors(g:Game,kind:'auction'|'shipment',payee:stri
|
||||
const paid = shares.filter(share => share.amount > 0);
|
||||
if (!privateFunding && paid.length === 1)
|
||||
return stampBureaucratPayment(g,kind,paid[0].payer,payee,paid[0].amount);
|
||||
if (g.homeworlds || g.expansions.length) return {};
|
||||
if (!bureaucratAlliedFundingModeSupported(g)) return {};
|
||||
const owner = nativeBureaucrat(g)?.owner;
|
||||
const qualifying = paid.find(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee);
|
||||
return qualifying ? stampBureaucratPayment(g,kind,qualifying.payer,payee,
|
||||
@@ -8499,7 +8499,7 @@ function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) {
|
||||
Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source),
|
||||
'The saved Bureaucrat payment changed its actual payer, payee, amount or event.');
|
||||
if (source.contributions) {
|
||||
requireRule(!g.homeworlds && !g.expansions.length && source.privateFunding === true &&
|
||||
requireRule(bureaucratAlliedFundingModeSupported(g) && source.privateFunding === true &&
|
||||
(source.kind === 'auction' || source.kind === 'shipment') &&
|
||||
source.contributions.length >= 1 && source.contributions.length <= 2 &&
|
||||
source.contributions.every(share => g.players.some(player => player.id === share.payer)) &&
|
||||
|
||||
+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 games without Homeworlds additionally connect qualifying allied-funded auctions and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs and original Karama income cancellation first. 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. Qualifying expansion-faction funding and split-recipient Homeworld income remain guarded. 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 and E2 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. Qualifying E1/E3 funding and Homeworld allied/split-recipient composition remain 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.',
|
||||
'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,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { advanceBureaucratPairedFunding } from './fixture-bureaucrat-paired-funding';
|
||||
import {
|
||||
createBureaucratPairedAuctionFixture, payBureaucratPairedAuction,
|
||||
allowPairedAuctionResponses, choosePairedAuctionBureaucrat,
|
||||
pairedAuctionPlayer, pairedAuctionPhysicalCards,
|
||||
type BureaucratPairedAuctionFixture, type PairedAuctionSource,
|
||||
} from './fixture-bureaucrat-paired-auction';
|
||||
|
||||
function rejectUnchanged(game:Game,actor:string,action:Action) {
|
||||
const before=JSON.stringify(game);
|
||||
assert.throws(()=>applyAction(game,actor,action));
|
||||
assert.equal(JSON.stringify(game),before);
|
||||
}
|
||||
function assertCosts(f:BureaucratPairedAuctionFixture,game:Game) {
|
||||
assert.equal(pairedAuctionPlayer(game,f.payer).spice,pairedAuctionPlayer(f.beforePledge,f.payer).spice-f.payerShare);
|
||||
if(f.donor!==f.payee) assert.equal(pairedAuctionPlayer(game,f.donor).spice,
|
||||
pairedAuctionPlayer(f.beforePledge,f.donor).spice-f.donorShare);
|
||||
assert.equal(game.aid[f.donor]?.amount ?? 0,0);
|
||||
assert.equal(pairedAuctionPlayer(game,f.payer).hand.filter(c=>c.id===f.card).length,1);
|
||||
assert.deepEqual(pairedAuctionPhysicalCards(game),f.physical);
|
||||
}
|
||||
function assertOutcome(f:BureaucratPairedAuctionFixture,done:Game,diverted:number) {
|
||||
assertCosts(f,done);
|
||||
assert.equal(pairedAuctionPlayer(done,f.payee).spice,
|
||||
pairedAuctionPlayer(f.beforePledge,f.payee).spice-(f.donor===f.payee?f.donorShare:0)+f.amount-diverted);
|
||||
const buyer=pairedAuctionPlayer(done,f.payer),before=pairedAuctionPlayer(f.beforeAction,f.payer);
|
||||
assert.deepEqual(buyer.hand.map(c=>c.id),[...before.hand.map(c=>c.id),f.card,f.beforeAction.deck[0].id]);
|
||||
assert.equal(done.currentAuctionSale,null);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0,diverted?1:0);
|
||||
assert.equal(viewGame(done,f.trainer).bureaucrat.usedThisPhase,diverted===2);
|
||||
if(f.source==='normal') {
|
||||
assert.equal(done.auction!.index,f.beforeAction.auction!.index+1);
|
||||
assert.equal(done.auction!.cards[done.auction!.index].id,f.beforeAction.auction!.cards[f.beforeAction.auction!.index+1].id);
|
||||
assert.equal(done.auction!.bid,0);
|
||||
assert.equal(done.auction!.bidder,null);
|
||||
} else if(f.source==='cache'&&f.position==='first') {
|
||||
assert.equal(done.richeseAuction,null);
|
||||
assert.equal(done.auction!.index,0);
|
||||
assert.equal(done.auction!.cards.length,done.richeseBidding!.normalCount);
|
||||
assert.equal(done.richeseCache!.some(c=>c.id===f.card),false);
|
||||
} else if(f.source==='blackMarket') {
|
||||
assert.equal(done.decision?.kind,'richeseDeclaration');
|
||||
assert.equal(done.richeseBidding!.blackMarketSold,true);
|
||||
assert.equal(pairedAuctionPlayer(done,f.richese).hand.some(c=>c.id===f.card),false);
|
||||
}
|
||||
}
|
||||
|
||||
for(const advanced of [false,true]) {
|
||||
const sources:PairedAuctionSource[]=advanced?['normal','cache','blackMarket']:['normal','cache'];
|
||||
for(const source of sources) {
|
||||
for(const [payerShare,donorShare] of [[5,3],[3,5],[5,5],[0,5]]) {
|
||||
for(const redirect of [false,true]) {
|
||||
void test(`${advanced?'Advanced':'Basic'} paired ${source} ${payerShare}+${donorShare}: ${redirect?'Bank two':'full native income'}, one purchase and original H bonus`,()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced,source,payerShare,donorShare});
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
assert.equal(pending.decision?.kind,'bureaucratPayment');
|
||||
assertCosts(f,pending);
|
||||
assert.equal(pairedAuctionPlayer(pending,f.payee).spice,pairedAuctionPlayer(f.beforeAction,f.payee).spice);
|
||||
assert.equal(pairedAuctionPlayer(pending,f.payer).hand.length,pairedAuctionPlayer(f.beforeAction,f.payer).hand.length+1);
|
||||
const done=allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending,redirect));
|
||||
assertOutcome(f,done,redirect?2:0);
|
||||
rejectUnchanged(done,f.trainer,{type:'decision',event:pending.bureaucratPaymentEvent,redirect:true});
|
||||
});
|
||||
}
|
||||
}
|
||||
void test(`${advanced?'Advanced':'Basic'} paired ${source} three plus three never pools a qualifying contribution`,()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced,source,payerShare:3,donorShare:3});
|
||||
const done=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
assert.equal(viewGame(done,f.trainer).bureaucrat.pending,null);
|
||||
assertOutcome(f,done,0);
|
||||
});
|
||||
void test(`${advanced?'Advanced':'Basic'} paired ${source}: gross eight is public, allied shares and qualifying identity are not`,()=>{
|
||||
const primary=createBureaucratPairedAuctionFixture({advanced,source,payerShare:5,donorShare:3});
|
||||
const donor=createBureaucratPairedAuctionFixture({advanced,source,payerShare:3,donorShare:5});
|
||||
const a=allowPairedAuctionResponses(payBureaucratPairedAuction(primary));
|
||||
const b=allowPairedAuctionResponses(payBureaucratPairedAuction(donor));
|
||||
for(const viewer of [primary.trainer,primary.guild,primary.payee]) {
|
||||
const left=viewGame(a,viewer),right=viewGame(b,viewer);
|
||||
const {event:_left,...invoice}=left.bureaucrat.pending!;
|
||||
const {event:_right,...other}=right.bureaucrat.pending!;
|
||||
assert.equal(invoice.payer,null);
|
||||
assert.equal(invoice.payee,primary.payee);
|
||||
assert.equal(invoice.amount,8);
|
||||
assert.deepEqual(invoice,other);
|
||||
assert.equal('contributions' in invoice,false);
|
||||
assert.equal('signature' in invoice,false);
|
||||
if(source==='normal') {
|
||||
assert.equal(left.auction!.bid,8);
|
||||
assert.equal(left.auction!.allyPayment,undefined);
|
||||
} else {
|
||||
assert.equal(left.richeseAuction!.revealedBids![primary.payer],8);
|
||||
assert.deepEqual(left.richeseAuction!.revealedBids,right.richeseAuction!.revealedBids);
|
||||
assert.equal('richeseFunding' in left,false);
|
||||
assert.equal('contributions' in left.richeseAuction!,false);
|
||||
}
|
||||
}
|
||||
assertOutcome(primary,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(a)),2);
|
||||
assertOutcome(donor,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(b)),2);
|
||||
});
|
||||
void test(`${advanced?'Advanced':'Basic'} paired ${source}: cross-actor and stale choices cannot debit, refund or duplicate a native purchase`,()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced,source,payerShare:5,donorShare:5});
|
||||
const bid=applyAction(f.game,f.payer,f.action);
|
||||
rejectUnchanged(bid,f.donor,{type:'pledgeAid',amount:3});
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
rejectUnchanged(pending,f.donor,{type:'decision',event:pending.bureaucratPaymentEvent,redirect:true});
|
||||
rejectUnchanged(pending,f.trainer,{type:'decision',event:'old-richese-invoice',redirect:true});
|
||||
rejectUnchanged(pending,f.payer,f.action);
|
||||
assertOutcome(f,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
|
||||
});
|
||||
}
|
||||
void test(`${advanced?'Advanced':'Basic'} original ordinary Emperor Karama cancellation banks the full funded invoice without a Bureaucrat use`,()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced,payerShare:5,donorShare:5,counter:true});
|
||||
const paid=payBureaucratPairedAuction(f);
|
||||
assert.equal(paid.response?.kind,'emperorIncome');
|
||||
assertCosts(f,paid);
|
||||
assert.equal(viewGame(paid,f.trainer).bureaucrat.pending,null);
|
||||
const done=allowPairedAuctionResponses(applyAction(paid,f.trainer,{type:'card',card:f.counterCard,mode:'cancel'}));
|
||||
assert.equal(pairedAuctionPlayer(done,f.emperor).spice,pairedAuctionPlayer(f.beforeAction,f.emperor).spice);
|
||||
assert.equal(pairedAuctionPlayer(done,f.payer).hand.length,pairedAuctionPlayer(f.beforeAction,f.payer).hand.length+2);
|
||||
assert.equal(done.discard.filter(c=>c.id===f.counterCard).length,1);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0,0);
|
||||
assert.equal(done.auction!.index,1);
|
||||
assertCosts(f,done);
|
||||
});
|
||||
void test(`${advanced?'Advanced':'Basic'} original Karama purchases remain normal-only, not a free cache invoice`,()=>{
|
||||
const normal=createBureaucratPairedAuctionFixture({advanced,counter:true});
|
||||
const done=allowPairedAuctionResponses(applyAction(normal.game,normal.trainer,{type:'card',card:normal.counterCard,mode:'purchase'}));
|
||||
assert.equal(pairedAuctionPlayer(done,normal.trainer).spice,pairedAuctionPlayer(normal.game,normal.trainer).spice);
|
||||
assert.equal(pairedAuctionPlayer(done,normal.emperor).spice,pairedAuctionPlayer(normal.game,normal.emperor).spice);
|
||||
assert.equal(pairedAuctionPlayer(done,normal.trainer).hand.filter(c=>c.id===normal.card).length,1);
|
||||
assert.equal(done.discard.filter(c=>c.id===normal.counterCard).length,1);
|
||||
assert.equal(done.auction!.index,1);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0,0);
|
||||
const cache=createBureaucratPairedAuctionFixture({advanced,source:'cache',counter:true});
|
||||
rejectUnchanged(cache.game,cache.trainer,{type:'card',card:cache.counterCard,mode:'purchase'});
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(cache));
|
||||
assertOutcome(cache,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
|
||||
});
|
||||
void test(`${advanced?'Advanced':'Basic'} actual last-cache closing advances without another funded debit or purchase`,()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced,source:'cache',method:'onceAround',position:'last',payerShare:5,donorShare:5});
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
const paid=allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending));
|
||||
assertOutcome(f,paid,2);
|
||||
const closed=advanceBureaucratPairedFunding(paid,state=>state.phase>3&&!state.phaseOpening&&!state.response&&!state.decision);
|
||||
assert.equal(closed.turn,paid.turn);
|
||||
assert.equal(pairedAuctionPlayer(closed,'p').spice,pairedAuctionPlayer(paid,'p').spice);
|
||||
assert.equal(pairedAuctionPlayer(closed,'f').spice,pairedAuctionPlayer(paid,'f').spice);
|
||||
assert.equal(pairedAuctionPlayer(closed,'r').spice,pairedAuctionPlayer(paid,'r').spice);
|
||||
assert.deepEqual(pairedAuctionPlayer(closed,'p').hand,pairedAuctionPlayer(paid,'p').hand);
|
||||
assert.equal(closed.bureaucratPayments?.used.length,1);
|
||||
assert.equal(viewGame(closed,'b').bureaucrat.usedThisPhase,false);
|
||||
assert.equal(closed.currentAuctionSale,null);
|
||||
assert.equal(closed.auction,null);
|
||||
assert.equal(closed.richeseAuction,null);
|
||||
});
|
||||
for(const donor of ['b','e'] as const) {
|
||||
for(const [payerShare,donorShare] of [[3,5],[5,3]]) {
|
||||
void test(`${advanced?'Advanced':'Basic'} original worm alliance: ${donor==='b'?'trainer':'Emperor payee'} contribution cannot qualify or mask H five`,()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced,donor,payerShare,donorShare});
|
||||
const allowed=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
const qualifies=payerShare===5;
|
||||
assert.equal(viewGame(allowed,f.trainer).bureaucrat.pending!==null,qualifies);
|
||||
const done=qualifies?allowPairedAuctionResponses(choosePairedAuctionBureaucrat(allowed)):allowed;
|
||||
assertOutcome(f,done,qualifies?2:0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test('Basic original Richese opening cannot declare a Black Market lot',()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({source:'cache'});
|
||||
assert.equal(f.opening.decision?.kind,'richeseDeclaration');
|
||||
rejectUnchanged(f.opening,f.richese,{type:'decision',event:f.opening.richeseBidding!.event,
|
||||
card:pairedAuctionPlayer(f.opening,f.richese).hand[0].id,method:'silent'});
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
assertOutcome(f,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
|
||||
});
|
||||
|
||||
void test('sealed native Richese bid conceals the amount until original bidders finish',()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced:true,source:'cache',payerShare:3,donorShare:5});
|
||||
const submitted=applyAction(f.game,f.payer,f.action);
|
||||
for(const viewer of [f.trainer,f.richese,f.guild]) {
|
||||
const lot=viewGame(submitted,viewer).richeseAuction!;
|
||||
assert.equal(lot.revealedBids,null);
|
||||
assert.equal(lot.ownBid,null);
|
||||
assert.equal(lot.ownAllyPayment,0);
|
||||
}
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
assert.equal(viewGame(pending,f.trainer).richeseAuction!.revealedBids![f.payer],8);
|
||||
assertOutcome(f,allowPairedAuctionResponses(choosePairedAuctionBureaucrat(pending)),2);
|
||||
});
|
||||
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
void test(`${difficulty} original paired Bureaucrat bot resolves Richese seller invoice and releases native compulsory declaration`,()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced:true,source:'blackMarket',payerShare:5,donorShare:5});
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
const view=viewGame(pending,f.trainer);
|
||||
view.players.find(p=>p.id===f.trainer)!.bot=difficulty;
|
||||
const action=botActions(view)[0];
|
||||
const done=allowPairedAuctionResponses(applyAction(pending,f.trainer,action));
|
||||
assertOutcome(f,done,action.redirect?2:0);
|
||||
});
|
||||
}
|
||||
|
||||
void test('one physical use covers both five contributions; original bonus cancellation leads to a genuine last-space purchase and cache closing',()=>{
|
||||
const f=createBureaucratPairedAuctionFixture({advanced:true,payerShare:5,donorShare:5,counter:true});
|
||||
const pending=allowPairedAuctionResponses(payBureaucratPairedAuction(f));
|
||||
let game=choosePairedAuctionBureaucrat(pending);
|
||||
assert.equal(game.response?.kind,'harkonnenBonus');
|
||||
game=allowPairedAuctionResponses(applyAction(game,f.trainer,{type:'card',card:f.counterCard,mode:'cancel'}));
|
||||
assertCosts(f,game);
|
||||
assert.equal(pairedAuctionPlayer(game,f.emperor).spice,pairedAuctionPlayer(f.beforeAction,f.emperor).spice+8);
|
||||
assert.equal(pairedAuctionPlayer(game,f.payer).hand.length,3);
|
||||
assert.equal(game.discard.filter(c=>c.id===f.counterCard).length,1);
|
||||
assert.equal(game.bureaucratPayments!.used.length,1);
|
||||
const used=structuredClone(game.bureaucratPayments!.used);
|
||||
while(pairedAuctionPlayer(game,f.payer).hand.length<handLimit(pairedAuctionPlayer(game,f.payer))) {
|
||||
game=advanceBureaucratPairedFunding(game,s=>!!s.auction&&s.auction.active===f.payer&&!s.response&&!s.decision);
|
||||
const before=structuredClone(game),buyer=pairedAuctionPlayer(before,f.payer),index=before.auction!.index;
|
||||
const card=before.auction!.cards[index].id;
|
||||
game=applyAction(game,f.payer,{type:'bid',amount:1,allyPayment:0});
|
||||
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;}
|
||||
game=applyAction(game,game.auction!.active,{type:'passBid'});
|
||||
}
|
||||
game=allowPairedAuctionResponses(game);
|
||||
const bought=pairedAuctionPlayer(game,f.payer);
|
||||
assert.equal(bought.spice,buyer.spice-1);
|
||||
assert.equal(bought.hand.filter(c=>c.id===card).length,1);
|
||||
assert.equal(bought.hand.length,Math.min(handLimit(buyer),buyer.hand.length+2));
|
||||
if(buyer.hand.length===handLimit(buyer)-1) {
|
||||
assert.deepEqual(game.deck,before.deck,'Last-space purchase has no overflow bonus draw');
|
||||
assert.equal(bought.hand.length,buyer.hand.length+1);
|
||||
}
|
||||
assert.equal(pairedAuctionPlayer(game,f.emperor).spice,pairedAuctionPlayer(before,f.emperor).spice+1);
|
||||
assert.deepEqual(game.bureaucratPayments!.used,used);
|
||||
assert.equal(viewGame(game,f.trainer).bureaucrat.pending,null);
|
||||
}
|
||||
assert.equal(pairedAuctionPlayer(game,f.payer).hand.length,8);
|
||||
game=advanceBureaucratPairedFunding(game,s=>s.turn===2&&s.phase===4);
|
||||
assert.equal(game.auction,null);
|
||||
assert.equal(game.currentAuctionSale,null);
|
||||
assert.equal(game.richeseAuction,null);
|
||||
assert.deepEqual(pairedAuctionPhysicalCards(game),f.physical);
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
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 { advanceBureaucratPairedFunding } from './fixture-bureaucrat-paired-funding';
|
||||
import {
|
||||
allowBureaucratPairedShipmentIncome, createBureaucratPairedShipmentFixture,
|
||||
declareBureaucratPairedShipment,
|
||||
} from './fixture-bureaucrat-paired-shipment';
|
||||
|
||||
const seat = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
const counters = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces,
|
||||
reserves: p.reserves, tanks: p.tanks, elites: p.elites }));
|
||||
const wallets = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice, bribes: p.bribes }));
|
||||
function rejectUnchanged(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function choose(game: Game, redirect: boolean) {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
return applyAction(structuredClone(game), 'b', {
|
||||
type: 'decision', event: game.decision!.event, redirect,
|
||||
});
|
||||
}
|
||||
|
||||
void test('paired Basic and Advanced native shipments retain every original army and debit full allied costs for one flat Guild diversion', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]])
|
||||
for (const redirect of [false, true]) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, primary, donor });
|
||||
const original = f.beforePledge;
|
||||
assert.equal(seat(f.beforeAction, 'p').spice, advanced ? 9 : 7);
|
||||
assert.equal(seat(f.beforeAction, 'f').spice, 6 - donor);
|
||||
assert.equal(f.beforeAction.aid.f.amount, donor);
|
||||
assert.deepEqual(counters(f.beforeAction), counters(original));
|
||||
assert.ok(f.actions.some(step => step.action.type === 'bribe' && step.actor === 'p' && step.action.amount === 3));
|
||||
assert.equal(seat(f.afterFirstMentat, 'f').spice, 6);
|
||||
assert.ok(f.actions.some(step => step.actor === 'r' && step.action.type === 'decision' && typeof step.action.position === 'string'));
|
||||
const { declared, paid } = declareBureaucratPairedShipment(f);
|
||||
if (advanced) {
|
||||
assert.equal(declared.decision?.kind, 'guildShipment');
|
||||
assert.equal(declared.decision?.player, 'g');
|
||||
assert.deepEqual(wallets(declared), wallets(f.beforeAction));
|
||||
assert.deepEqual(counters(declared), counters(f.beforeAction));
|
||||
} else {
|
||||
assert.equal(declared.response?.kind, 'guildIncome');
|
||||
assert.deepEqual(counters(declared), counters(paid));
|
||||
}
|
||||
assert.equal(paid.response?.kind, 'guildIncome');
|
||||
assert.equal(paid.response?.amount, f.amount);
|
||||
assert.equal(seat(paid, 'p').spice, seat(original, 'p').spice - primary);
|
||||
assert.equal(seat(paid, 'f').spice, 6 - donor);
|
||||
assert.equal(paid.aid.f.amount, 0);
|
||||
assert.equal(seat(paid, 'g').spice, advanced ? 6 : 5);
|
||||
assert.equal(seat(paid, 'p').reserves, 10 - f.amount);
|
||||
assert.deepEqual(seat(paid, 'p').forces, { 'carthag:11': 10, [f.target]: f.amount });
|
||||
assert.equal(seat(paid, 'p').shipped, true);
|
||||
for (const id of ['b', 'r', 'f', 'e', 'g']) {
|
||||
assert.deepEqual(seat(paid, id).forces, seat(original, id).forces);
|
||||
assert.equal(seat(paid, id).reserves, seat(original, id).reserves);
|
||||
}
|
||||
const pending = allowBureaucratPairedShipmentIncome(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.deepEqual(counters(pending), counters(paid));
|
||||
assert.deepEqual(wallets(pending), wallets(paid));
|
||||
const after = choose(pending, redirect);
|
||||
assert.equal(seat(after, 'g').spice, seat(original, 'g').spice + f.amount - (redirect ? 2 : 0));
|
||||
for (const id of ['p', 'f', 'b', 'r', 'e']) {
|
||||
assert.equal(seat(after, id).spice, seat(paid, id).spice);
|
||||
assert.equal(seat(after, id).bribes, seat(paid, id).bribes);
|
||||
}
|
||||
assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0),
|
||||
original.players.reduce((sum, p) => sum + p.spice, 0) - (redirect ? 2 : 0));
|
||||
assert.deepEqual(counters(after), counters(paid));
|
||||
assert.equal(seat(after, 'p').shipped, true);
|
||||
assert.equal(after.bureaucratPayments?.used.length ?? 0, Number(redirect));
|
||||
assert.equal(viewGame(after, 'b').bureaucrat.usedThisPhase, redirect);
|
||||
assert.equal(after.bureaucratPayments?.pending, undefined);
|
||||
assert.deepEqual(after.leaderSkills?.assignments, pending.leaderSkills?.assignments);
|
||||
assert.deepEqual(after.deck, pending.deck);
|
||||
assert.deepEqual(after.discard, pending.discard);
|
||||
assert.deepEqual(after.players.map(p => p.hand), pending.players.map(p => p.hand));
|
||||
assert.equal(after.phase, 5);
|
||||
assert.equal(after.active, 'p');
|
||||
rejectUnchanged(after, 'b', { type: 'decision', event: pending.decision!.event, redirect });
|
||||
rejectUnchanged(after, 'p', f.action);
|
||||
const ended = applyAction(structuredClone(after), 'p', { type: 'endMovement' });
|
||||
assert.notEqual(ended.active, 'p');
|
||||
assert.deepEqual(counters(ended), counters(after));
|
||||
// Advanced can enter Collection here: its native income is not a refund.
|
||||
rejectUnchanged(ended, 'p', { type: 'endMovement' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('original Karama cancels the gross paired Guild income to Bank after full debits and army commitment, without an offer or use', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, primary, donor });
|
||||
const { paid } = declareBureaucratPairedShipment(f);
|
||||
assert.equal(paid.response?.amount, f.amount);
|
||||
assert.equal(viewGame(paid, f.responder).responseControls?.cancelCards.includes(f.karama.id), true);
|
||||
const after = applyAction(structuredClone(paid), f.responder, {
|
||||
type: 'card', mode: 'cancel', card: f.karama.id,
|
||||
});
|
||||
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice);
|
||||
assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - primary);
|
||||
assert.equal(seat(after, 'f').spice, 6 - donor);
|
||||
assert.equal(after.players.reduce((sum, p) => sum + p.spice, 0),
|
||||
f.beforePledge.players.reduce((sum, p) => sum + p.spice, 0) - f.amount);
|
||||
assert.deepEqual(counters(after), counters(paid));
|
||||
assert.equal(seat(after, 'p').shipped, true);
|
||||
assert.equal(after.discard.filter(card => card.id === f.karama.id).length, 1);
|
||||
assert.equal(seat(after, f.responder).hand.some(card => card.id === f.karama.id), false);
|
||||
assert.equal(after.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(viewGame(after, 'b').bureaucrat.pending, null);
|
||||
assert.notEqual(after.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(after.response, null);
|
||||
rejectUnchanged(after, f.responder, { type: 'card', mode: 'cancel', card: f.karama.id });
|
||||
}
|
||||
});
|
||||
|
||||
void test('paired native receipts never pool sub-five shares or qualify the CHOAM owner contribution alone', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const options of [
|
||||
{ primary: 3, donor: 3 }, { primary: 4, donor: 3 },
|
||||
{ primary: 3, donor: 5, ownerFunding: true }, { primary: 0, donor: 5, ownerFunding: true },
|
||||
]) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, ...options });
|
||||
if (options.ownerFunding) {
|
||||
assert.equal(seat(f.beforePledge, 'p').ally, 'b');
|
||||
assert.equal(seat(f.beforePledge, 'b').ally, 'p');
|
||||
assert.equal(seat(f.beforePledge, 'f').ally, null);
|
||||
}
|
||||
const { paid } = declareBureaucratPairedShipment(f);
|
||||
const after = allowBureaucratPairedShipmentIncome(paid);
|
||||
assert.notEqual(after.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(after.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + f.amount);
|
||||
assert.equal(seat(after, 'p').spice, seat(f.beforePledge, 'p').spice - options.primary);
|
||||
assert.equal(seat(after, f.donor).spice, seat(f.beforePledge, f.donor).spice - options.donor);
|
||||
assert.deepEqual(counters(after), counters(paid));
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual H/CHOAM alliance excludes its native owner funding leg without masking Harkonnen five', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const donor of [3, 5]) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor, ownerFunding: true });
|
||||
assert.equal(f.donor, 'b');
|
||||
assert.equal(seat(f.alliance, 'p').ally, 'b');
|
||||
assert.equal(seat(f.alliance, 'b').ally, 'p');
|
||||
const { paid } = declareBureaucratPairedShipment(f);
|
||||
const pending = allowBureaucratPairedShipmentIncome(paid);
|
||||
const after = choose(pending, true);
|
||||
assert.equal(seat(after, 'b').spice, 26 - donor);
|
||||
assert.equal(seat(after, 'p').spice, (advanced ? 9 : 7) - 5);
|
||||
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + f.amount - 2);
|
||||
assert.equal(seat(after, 'f').spice, 6);
|
||||
assert.equal(after.bureaucratPayments?.used.length, 1);
|
||||
assert.deepEqual(counters(after), counters(paid));
|
||||
}
|
||||
});
|
||||
|
||||
void test('paired pending views reveal only public gross and payee with null payer; cross-actor and stale choices reject unchanged', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const [primary, donor] of [[5, 3], [3, 5], [5, 5], [0, 5]]) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, primary, donor });
|
||||
const { paid } = declareBureaucratPairedShipment(f);
|
||||
for (const viewer of paid.players) {
|
||||
const response = viewGame(paid, viewer.id).response;
|
||||
assert.equal(response?.amount, f.amount);
|
||||
assert.equal(response?.owner, 'g');
|
||||
assert.equal(response?.bureaucratPayment, undefined);
|
||||
assert.equal(response?.guildContributions, undefined);
|
||||
assert.equal(response?.occupiedPayment, undefined);
|
||||
}
|
||||
const pending = allowBureaucratPairedShipmentIncome(paid);
|
||||
for (const viewer of pending.players) {
|
||||
const view = viewGame(pending, viewer.id);
|
||||
assert.deepEqual(view.bureaucrat.pending, {
|
||||
event: pending.bureaucratPaymentEvent, owner: 'b', payer: null, payee: 'g',
|
||||
amount: f.amount, kind: 'shipment', redirect: 2,
|
||||
});
|
||||
assert.equal('bureaucratPayments' in view, false);
|
||||
assert.equal('bureaucratUseEvents' in view, false);
|
||||
for (const other of view.players.filter(p => p.id !== viewer.id)) {
|
||||
assert.equal(other.spice, undefined);
|
||||
assert.equal(other.hand, undefined);
|
||||
}
|
||||
}
|
||||
for (const id of ['p', 'f', 'r', 'e', 'g'])
|
||||
rejectUnchanged(pending, id, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
rejectUnchanged(pending, 'b', { type: 'decision', event: 'stale-income', redirect: true });
|
||||
rejectUnchanged(pending, 'p', { type: 'endMovement' });
|
||||
rejectUnchanged(pending, 'f', { type: 'pledgeAid', amount: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal bot choices settle the genuine paired private-funded receipt and resume native Movement', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const bot of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor: 5 });
|
||||
const pending = allowBureaucratPairedShipmentIncome(declareBureaucratPairedShipment(f).paid);
|
||||
const view = viewGame(pending, 'b');
|
||||
view.players.find(p => p.id === 'b')!.bot = bot;
|
||||
const actions = botActions(view);
|
||||
assert.equal(actions.length, 1);
|
||||
const after = applyAction(structuredClone(pending), 'b', actions[0]);
|
||||
assert.equal(seat(after, 'g').spice, seat(f.beforePledge, 'g').spice + 8);
|
||||
assert.equal(after.bureaucratPayments?.used.length, 1);
|
||||
assert.deepEqual(counters(after), counters(pending));
|
||||
for (const id of ['b', 'r', 'p', 'f', 'e']) assert.equal(seat(after, id).spice, seat(pending, id).spice);
|
||||
assert.equal(after.phase, 5);
|
||||
assert.equal(after.active, 'p');
|
||||
const ended = applyAction(structuredClone(after), 'p', { type: 'endMovement' });
|
||||
assert.notEqual(ended.active, 'p');
|
||||
assert.deepEqual(counters(ended), counters(after));
|
||||
rejectUnchanged(ended, 'b', actions[0]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('decline preserves the physical Bureaucrat for the next real receipt; use is once per native phase and renews after real Mentat/Storm', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const redirect of [false, true]) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor: 3 });
|
||||
const pending = allowBureaucratPairedShipmentIncome(declareBureaucratPairedShipment(f).paid);
|
||||
const settled = choose(pending, redirect);
|
||||
const following = applyAction(structuredClone(settled), 'e', { type: 'bribe', target: 'f', amount: 5 });
|
||||
assert.equal(seat(following, 'e').spice, 5);
|
||||
assert.deepEqual(counters(following), counters(settled));
|
||||
let used: Game;
|
||||
if (redirect) {
|
||||
assert.notEqual(following.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(seat(following, 'f').bribes, seat(settled, 'f').bribes + 5);
|
||||
used = following;
|
||||
} else {
|
||||
assert.equal(following.decision?.kind, 'bureaucratPayment');
|
||||
rejectUnchanged(following, 'b', { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
used = choose(following, true);
|
||||
assert.equal(seat(used, 'f').bribes, seat(settled, 'f').bribes + 3);
|
||||
assert.equal(seat(used, 'g').spice, seat(settled, 'g').spice);
|
||||
}
|
||||
assert.equal(used.bureaucratPayments?.used.length, 1);
|
||||
assert.deepEqual(used.leaderSkills?.assignments, settled.leaderSkills?.assignments);
|
||||
const ended = applyAction(used, 'p', { type: 'endMovement' });
|
||||
const next = advanceBureaucratPairedFunding(ended, state => state.turn === 3 && state.phase === 1 &&
|
||||
!state.phaseOpening && !state.response && !state.decision);
|
||||
assert.equal(viewGame(next, 'b').bureaucrat.usedThisPhase, false);
|
||||
const payment = applyAction(next, 'e', { type: 'bribe', target: 'f', amount: 5 });
|
||||
assert.equal(payment.decision?.kind, 'bureaucratPayment');
|
||||
const renewed = choose(payment, true);
|
||||
assert.equal(renewed.bureaucratPayments?.used.length, 2);
|
||||
assert.deepEqual(renewed.bureaucratPayments!.used.map(use => ({ turn: use.turn, phase: use.phase })),
|
||||
[{ turn: 2, phase: 5 }, { turn: 3, phase: 1 }]);
|
||||
assert.equal(viewGame(renewed, 'b').bureaucrat.usedThisPhase, true);
|
||||
assert.deepEqual(counters(renewed), counters(next));
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual pledged aid bounds reject unsupported shipment declarations without costs or counters', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createBureaucratPairedShipmentFixture({ advanced, primary: 5, donor: 3 });
|
||||
assert.equal(viewGame(f.beforeAction, 'p').aid.available, 3);
|
||||
assert.equal(viewGame(f.beforeAction, 'f').aid.pledged, 3);
|
||||
assert.equal(viewGame(f.beforeAction, 'b').aid.available, 0);
|
||||
rejectUnchanged(f.beforeAction, 'p', { ...f.action, allyPayment: 4 });
|
||||
rejectUnchanged(f.beforeAction, 'f', f.action);
|
||||
assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge));
|
||||
assert.equal(seat(f.beforeAction, 'p').spice, advanced ? 9 : 7);
|
||||
assert.equal(seat(f.beforeAction, 'f').spice, 3);
|
||||
assert.equal(f.beforeAction.response, null);
|
||||
assert.equal(f.beforeAction.decision, null);
|
||||
}
|
||||
});
|
||||
@@ -5,14 +5,7 @@ import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import {
|
||||
basicChoamLeaderSkillsProfile,
|
||||
ordinaryLeaderSkillModeSupported,
|
||||
} from '../game/leader-skill-profile';
|
||||
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
|
||||
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
|
||||
import { sandmasterModeSupported } from '../game/sandmaster-movement';
|
||||
import { smugglerBattleModeSupported } from '../game/smuggler-battle';
|
||||
import { basicChoamLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
@@ -30,7 +23,6 @@ import {
|
||||
assertChoamSkillsCustody,
|
||||
choamSkillsPlayer as player,
|
||||
completedChoamSkillsGame,
|
||||
initializedChoamSkillsOffers,
|
||||
reloadChoamSkillsGame as reload,
|
||||
rejectChoamSkillsAction as reject,
|
||||
} from './choam-skills-fixture';
|
||||
@@ -102,33 +94,6 @@ void test('all fourteen production skill faces deal through genuine Basic CHOAM
|
||||
}
|
||||
});
|
||||
|
||||
void test('all bot profiles make a legal private skill choice and exact CHOAM profile gates are shared', () => {
|
||||
const offer = initializedChoamSkillsOffers({
|
||||
requestedSkill: 'planetologist',
|
||||
});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(reload(offer), 'c');
|
||||
view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty;
|
||||
const action = botActions(view).find(
|
||||
(candidate) => candidate.type === 'leaderSkill',
|
||||
);
|
||||
assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => applyAction(offer, 'c', action));
|
||||
}
|
||||
|
||||
const game = completedChoamSkillsGame();
|
||||
for (const gate of [
|
||||
basicChoamLeaderSkillsProfile,
|
||||
ordinaryLeaderSkillModeSupported,
|
||||
bureaucratPaymentModeSupported,
|
||||
planetologistMovementModeSupported,
|
||||
sandmasterModeSupported,
|
||||
smugglerBattleModeSupported,
|
||||
]) {
|
||||
assert.equal(gate(game), true, gate.name);
|
||||
assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced CHOAM and Basic Richese retain audit admission without entering the exact normal profile', () => {
|
||||
for (const variant of ['advanced', 'richese'] as const) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createBureaucratPairedFundingSetup, advanceBureaucratPairedFunding,
|
||||
type BureaucratPairedFundingStep,
|
||||
} from './fixture-bureaucrat-paired-funding';
|
||||
|
||||
export type PairedAuctionSource = 'normal' | 'cache' | 'blackMarket';
|
||||
export interface BureaucratPairedAuctionFixture {
|
||||
game:Game;
|
||||
opening:Game;
|
||||
sourceSetup:Game;
|
||||
beforePledge:Game;
|
||||
beforeAction:Game;
|
||||
payer:string;
|
||||
donor:string;
|
||||
trainer:string;
|
||||
richese:string;
|
||||
guild:string;
|
||||
emperor:string;
|
||||
source:PairedAuctionSource;
|
||||
method:'silent'|'onceAround';
|
||||
position:'first'|'last';
|
||||
action:Action;
|
||||
amount:number;
|
||||
payerShare:number;
|
||||
donorShare:number;
|
||||
card:string;
|
||||
counterCard?:string;
|
||||
physical:string[];
|
||||
actions:BureaucratPairedFundingStep[];
|
||||
staging:string[];
|
||||
payee:string;
|
||||
}
|
||||
export const pairedAuctionPlayer = (game:Game,id:string) => game.players.find(p=>p.id===id)!;
|
||||
export function pairedAuctionPhysicalCards(game:Game):string[] {
|
||||
return [...game.deck,...game.discard,...game.players.flatMap(p=>p.hand),
|
||||
...(game.richeseCache ?? []),...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin==='normal')) ?? [])]
|
||||
.map(card=>card.id).sort();
|
||||
}
|
||||
export function allowPairedAuctionResponses(state:Game):Game {
|
||||
let game=state;
|
||||
for(let n=0;game.response&&n<40;n++) {
|
||||
const actor=game.players.find(p=>{
|
||||
const controls=viewGame(game,p.id).responseControls;
|
||||
return controls&&!controls.hasPassed&&controls.cancelCards.length>0;
|
||||
});
|
||||
assert.ok(actor,'An original unpassed counter holder must remain');
|
||||
game=applyAction(game,actor.id,{type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response,null);
|
||||
return game;
|
||||
}
|
||||
export function choosePairedAuctionOwner(game:Game,values:Record<string,unknown>):Game {
|
||||
assert.ok(game.decision);
|
||||
return applyAction(game,game.decision.player,{type:'decision',event:game.richeseBidding!.event,...values} as Action);
|
||||
}
|
||||
export function choosePairedAuctionBureaucrat(game:Game,redirect=true):Game {
|
||||
assert.equal(game.decision?.kind,'bureaucratPayment');
|
||||
return applyAction(game,game.decision!.player,{type:'decision',event:game.bureaucratPaymentEvent,redirect});
|
||||
}
|
||||
|
||||
/** Only counter-card custody is controlled here: swap an original unplayed
|
||||
* card without adding a card, hand space, wallet, army, receipt or turn. */
|
||||
function counterKarama(game:Game,actor:string,staging:string[]):string {
|
||||
const owner=pairedAuctionPlayer(game,actor),held=owner.hand.find(c=>c.effect==='karama');
|
||||
if(held) return held.id;
|
||||
const index=game.deck.findIndex(c=>c.effect==='karama');
|
||||
assert.ok(index>=0&&owner.hand.length>0);
|
||||
const outgoing=owner.hand[0],incoming=game.deck[index];
|
||||
owner.hand[0]=incoming;game.deck[index]=outgoing;
|
||||
staging.push(`Native counter window: swap original unplayed ${incoming.id} with held ${outgoing.id} for ${actor}; retain physical cards and hand size.`);
|
||||
return incoming.id;
|
||||
}
|
||||
|
||||
export function createBureaucratPairedAuctionFixture({
|
||||
advanced=false,source='normal',payerShare=5,donorShare=3,counter=false,
|
||||
method='silent',position='first',donor='f',
|
||||
}:{advanced?:boolean;source?:PairedAuctionSource;payerShare?:number;donorShare?:number;
|
||||
counter?:boolean;method?:'silent'|'onceAround';position?:'first'|'last';donor?:'f'|'b'|'e'}={}):BureaucratPairedAuctionFixture {
|
||||
assert.ok(advanced||source!=='blackMarket','Basic has no Black Market');
|
||||
const native=createBureaucratPairedFundingSetup(advanced);
|
||||
let game=native.game;
|
||||
const actions:BureaucratPairedFundingStep[]=[...native.actions],staging=[...native.staging];
|
||||
if(donor!=='f') {
|
||||
game=structuredClone(native.worm);
|
||||
actions.splice(actions.findIndex(step=>step.action.type==='alliance'));
|
||||
for(const [actor,target] of [['p',donor],[donor,'p']]) {
|
||||
const action:Action={type:'alliance',target};
|
||||
actions.push({actor,action});game=applyAction(game,actor,action);
|
||||
}
|
||||
game=advanceBureaucratPairedFunding(game,s=>s.turn===2&&s.phase===3,actions);
|
||||
}
|
||||
const opening=structuredClone(game);
|
||||
const choose=(values:Record<string,unknown>)=>{game=choosePairedAuctionOwner(game,values);};
|
||||
if(advanced) {
|
||||
assert.equal(game.decision?.kind,'richeseBlackMarket');
|
||||
if(source==='blackMarket') choose({card:pairedAuctionPlayer(game,'r').hand[0].id,method,direction:'clockwise'});
|
||||
else choose({decline:true});
|
||||
} else assert.equal(game.decision?.kind,'richeseDeclaration');
|
||||
if(source!=='blackMarket') {
|
||||
choose({position:source==='normal'?'last':position});
|
||||
game=allowPairedAuctionResponses(game);
|
||||
if(source==='cache') {
|
||||
if(position==='last') game=advanceBureaucratPairedFunding(game,s=>s.decision?.kind==='richeseCache',actions);
|
||||
assert.equal(game.decision?.kind,'richeseCache');
|
||||
choose({card:game.richeseCache![0].id,method,direction:'clockwise'});
|
||||
}
|
||||
}
|
||||
game=allowPairedAuctionResponses(game);
|
||||
const sourceSetup=structuredClone(game),physical=pairedAuctionPhysicalCards(game);
|
||||
const counterCard=counter?counterKarama(game,'b',staging):undefined;
|
||||
if(source==='normal') {
|
||||
for(let n=0;game.auction!.active!=='p'&&n<game.players.length;n++)
|
||||
game=applyAction(game,game.auction!.active,{type:'passBid'});
|
||||
assert.equal(game.auction!.active,'p');
|
||||
} else if(method==='onceAround') {
|
||||
for(let n=0;game.richeseAuction!.active!=='p'&&n<game.players.length;n++)
|
||||
game=applyAction(game,game.richeseAuction!.active!,{type:'richeseBid',event:game.richeseAuction!.event,amount:null});
|
||||
assert.equal(game.richeseAuction!.active,'p');
|
||||
}
|
||||
const beforePledge=structuredClone(game);
|
||||
game=applyAction(game,donor,{type:'pledgeAid',amount:donorShare});
|
||||
const beforeAction=structuredClone(game),amount=payerShare+donorShare;
|
||||
const card=source==='normal'?game.auction!.cards[game.auction!.index].id:game.richeseAuction!.cardId;
|
||||
const action:Action=source==='normal'?{type:'bid',amount,allyPayment:donorShare}:
|
||||
{type:'richeseBid',event:game.richeseAuction!.event,amount,allyPayment:donorShare};
|
||||
return {...native,game,donor,opening,sourceSetup,beforePledge,beforeAction,source,method,position,
|
||||
action,amount,payerShare,donorShare,card,counterCard,physical,actions,staging,
|
||||
payee:source==='normal'?'e':'r'};
|
||||
}
|
||||
|
||||
export function payBureaucratPairedAuction(fixture:BureaucratPairedAuctionFixture,state=fixture.game):Game {
|
||||
let game=applyAction(state,fixture.payer,fixture.action);
|
||||
if(fixture.source==='normal') {
|
||||
const index=fixture.beforeAction.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,fixture.payer,{type:'decision',karama:false});break;
|
||||
}
|
||||
assert.ok(!game.decision&&!game.response);
|
||||
game=applyAction(game,game.auction!.active,{type:'passBid'});
|
||||
}
|
||||
} else {
|
||||
const event=fixture.beforeAction.richeseAuction!.event;
|
||||
for(let n=0;game.richeseAuction?.event===event&&!game.richeseAuction.outcome&&n<game.players.length;n++) {
|
||||
const lot=game.richeseAuction;
|
||||
const actor=lot.method==='silent'?lot.order.find(id=>!(id in lot.sealed))!:lot.active!;
|
||||
game=applyAction(game,actor,{type:'richeseBid',event,amount:lot.method==='silent'?0:null});
|
||||
}
|
||||
}
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
initializePairedChoamNexusSkills, completePairedChoamSkillsSetup,
|
||||
nextPairedChoamSkillsStep, type PairedChoamNexusSkillsOptions,
|
||||
} from './fixture-paired-choam-nexus-skills';
|
||||
|
||||
export type BureaucratPairedFundingStep = {actor:string;action:Action};
|
||||
const clean = (game:Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
|
||||
/** Original six-seat paired E2 setup; native CHOAM Bureaucrat, actual H/F
|
||||
* bribe and worm alliance. Never replace armies, wallets or phase state. */
|
||||
export function createBureaucratPairedFundingSetup(advanced=false) {
|
||||
const options:PairedChoamNexusSkillsOptions = {advanced,
|
||||
factions:['choam','richese','harkonnen','fremen','emperor','guild'],
|
||||
seatIds:['b','r','p','f','e','g'],choamSkill:'bureaucrat'};
|
||||
const actions:BureaucratPairedFundingStep[] = [];
|
||||
const staging:string[] = [];
|
||||
let game = completePairedChoamSkillsSetup(initializePairedChoamNexusSkills(options),options,actions);
|
||||
const setup = structuredClone(game);
|
||||
assert.ok(game.leaderSkills!.assignments.some(a=>a.owner==='b'&&a.skill==='bureaucrat'));
|
||||
const act = (actor:string,action:Action) => {
|
||||
actions.push({actor,action});
|
||||
game = applyAction(game,actor,action);
|
||||
};
|
||||
const advance = (until:(state:Game)=>boolean) => {
|
||||
game = advanceBureaucratPairedFunding(game,until,actions);
|
||||
};
|
||||
const orderSpice = (kinds:('land'|'worm')[]) => {
|
||||
for (const [position,kind] of kinds.entries()) {
|
||||
const index = game.spiceDeck.findIndex((card,i)=>i>=position&&
|
||||
(kind==='land'?'territory' in card:'worm' in card));
|
||||
assert.ok(index>=position);
|
||||
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
|
||||
}
|
||||
staging.push('Reorder only original still-unplayed Spice Cards.');
|
||||
};
|
||||
orderSpice(['land','land']);
|
||||
advance(state=>state.phase===5&&state.active==='p'&&clean(state));
|
||||
const beforeBribe = structuredClone(game);
|
||||
act('p',{type:'bribe',target:'f',amount:3});
|
||||
const bribe = structuredClone(game);
|
||||
assert.equal(game.players.find(p=>p.id==='f')!.bribes,3);
|
||||
advance(state=>state.turn===2&&state.phase===1&&clean(state));
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
assert.equal(game.players.find(p=>p.id==='f')!.spice,6);
|
||||
orderSpice(['worm','land','land']);
|
||||
advance(state=>!!state.nexus&&!state.spiceWindow&&!state.spiceResolution&&clean(state));
|
||||
const worm = structuredClone(game);
|
||||
act('p',{type:'alliance',target:'f'});
|
||||
act('f',{type:'alliance',target:'p'});
|
||||
const alliance = structuredClone(game);
|
||||
advance(state=>state.turn===2&&state.phase===3);
|
||||
return {game,setup,beforeBribe,bribe,afterFirstMentat,worm,alliance,
|
||||
payer:'p',donor:'f',trainer:'b',richese:'r',guild:'g',emperor:'e',actions,staging};
|
||||
}
|
||||
|
||||
export function advanceBureaucratPairedFunding(state:Game,until:(game:Game)=>boolean,
|
||||
actions?:BureaucratPairedFundingStep[]):Game {
|
||||
let game = state;
|
||||
for (let n=0;!until(game)&&n<1800;n++) {
|
||||
assert.notEqual(game.status,'finished');
|
||||
const next = nextPairedChoamSkillsStep(game);
|
||||
assert.ok(next,'Original paired E2 controls must reach the payment boundary');
|
||||
actions?.push(next);
|
||||
game = applyAction(game,next.actor,next.action);
|
||||
}
|
||||
assert.ok(until(game));
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
advanceBureaucratPairedFunding, createBureaucratPairedFundingSetup,
|
||||
type BureaucratPairedFundingStep,
|
||||
} from './fixture-bureaucrat-paired-funding';
|
||||
import { holdPairedSkillsCard } from './fixture-paired-choam-nexus-skills';
|
||||
|
||||
export function createBureaucratPairedShipmentFixture({
|
||||
advanced = false, primary = 5, donor = 3, ownerFunding = false,
|
||||
}: { advanced?: boolean; primary?: number; donor?: number; ownerFunding?: boolean } = {}) {
|
||||
const setup = createBureaucratPairedFundingSetup(advanced);
|
||||
const allianceIndex = setup.actions.findIndex(step => step.action.type === 'alliance');
|
||||
const actions = ownerFunding ? setup.actions.slice(0, allianceIndex) : [...setup.actions];
|
||||
const staging = [...setup.staging];
|
||||
const donorId = ownerFunding ? setup.trainer : setup.donor;
|
||||
let game = structuredClone(ownerFunding ? setup.worm : setup.game);
|
||||
if (ownerFunding) {
|
||||
for (const [actor, target] of [[setup.payer, donorId], [donorId, setup.payer]]) {
|
||||
const action: Action = { type: 'alliance', target };
|
||||
actions.push({ actor, action });
|
||||
game = applyAction(game, actor, action);
|
||||
}
|
||||
}
|
||||
const alliance = ownerFunding ? structuredClone(game) : setup.alliance;
|
||||
game = advanceBureaucratPairedFunding(game, state => state.phase === 5 &&
|
||||
state.active === setup.payer && !state.phaseOpening && !state.response && !state.decision, actions);
|
||||
assert.equal(game.turn, 2);
|
||||
// Exact original six-seat prerequisite after native R/CHOAM auctions,
|
||||
// Nexus choices and Revival; no force, wallet or phase staging.
|
||||
assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, forces: p.forces })), [
|
||||
{ id: 'b', spice: 26, reserves: 20, forces: {} },
|
||||
{ id: 'r', spice: 5, reserves: 20, forces: {} },
|
||||
{ id: 'p', spice: advanced ? 9 : 7, reserves: 10, forces: { 'carthag:11': 10 } },
|
||||
{ id: 'f', spice: 6, reserves: 10, forces: { 'sietch_tabr:14': 10 } },
|
||||
{ id: 'e', spice: 10, reserves: 20, forces: {} },
|
||||
{ id: 'g', spice: advanced ? 6 : 5, reserves: 15, forces: { 'tueks_sietch:5': 5 } },
|
||||
]);
|
||||
assert.equal(game.players.find(p => p.id === setup.payer)!.ally, donorId);
|
||||
assert.equal(game.players.find(p => p.id === donorId)!.ally, setup.payer);
|
||||
const target = 'habbanya_ridge_sietch:17';
|
||||
assert.ok(game.players.every(p => !p.forces[target]));
|
||||
const sourceSetup = structuredClone(game);
|
||||
const karama = holdPairedSkillsCard(game, setup.emperor, card => card.effect === 'karama', staging);
|
||||
const beforePledge = structuredClone(game);
|
||||
const pledge: Action = { type: 'pledgeAid', amount: donor };
|
||||
actions.push({ actor: donorId, action: pledge });
|
||||
game = applyAction(game, donorId, pledge);
|
||||
assert.equal(game.aid[donorId].amount, donor);
|
||||
const beforeAction = structuredClone(game);
|
||||
const amount = primary + donor;
|
||||
const action: Action = { type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount, allyPayment: donor };
|
||||
return { ...setup, donor: donorId, alliance, game, sourceSetup, beforePledge, pledge, beforeAction,
|
||||
action, primary, donorAmount: donor, amount, target, karama, responder: setup.emperor, actions, staging };
|
||||
}
|
||||
|
||||
/** Declare once. Only the original Guild interception commits the queued ship. */
|
||||
export function declareBureaucratPairedShipment(fixture: { beforeAction: Game; payer: string; action: Action }) {
|
||||
const actions: BureaucratPairedFundingStep[] = [];
|
||||
let game = applyAction(structuredClone(fixture.beforeAction), fixture.payer, fixture.action);
|
||||
actions.push({ actor: fixture.payer, action: fixture.action });
|
||||
const declared = structuredClone(game);
|
||||
if (game.decision?.kind === 'guildShipment') {
|
||||
const actor = game.decision.player;
|
||||
const action: Action = { type: 'decision', allow: true };
|
||||
actions.push({ actor, action });
|
||||
game = applyAction(game, actor, action);
|
||||
}
|
||||
return { game, declared, paid: structuredClone(game), actions };
|
||||
}
|
||||
|
||||
/** Allow the actual Karama window, never manufacture an income response. */
|
||||
export function allowBureaucratPairedShipmentIncome(state: Game) {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; game.response?.kind === 'guildIncome' && n < 12; n++) {
|
||||
const actor = game.players.find(p => {
|
||||
const controls = viewGame(game, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
||||
});
|
||||
assert.ok(actor, 'Original Karama custody must yield a legal income responder');
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'guildIncome');
|
||||
return game;
|
||||
}
|
||||
Reference in new issue
Block a user