Connect Bureaucrat payment redirection and saved continuation
Add an optional owner-controlled two-spice diversion from eligible single-payer ordinary auctions, Guild shipment income, Richese auction proceeds and actual bribes in the bounded base/CHOAM Leader Skills profile. Debit the payer once, credit the recipient once, retain bribes until Mentat Pause and restore the original response/auction/advisor callback. Decline preserves later phase opportunities; successful physical-card usage persists ownership changes. Keep qualifying allied funding and unfinished payment families explicitly guarded or outside the connected batch. Record source authority and the out-of-battle Karama composition and physical-card usage inferences; no public mode or publication gate opens. Connect readable owner controls and all four AI profiles using public payment/alliance information. Independent review found and fixed duplicate usage-event aliases and an overbroad guard on Bank-bound independent Karama shipments. Focused tests cover native/hidden/captured/dead eligibility, unchanged costs and card/force custody, normal and Richese auction callbacks, real Bene Gesserit advisor continuation, private views, JSON and production SQLite restore, stale/duplicate rejection and same-version competing writes. Validation: typecheck, lint, 4631 offline tests, production build and 43 HTTP tests passed with unchanged source. Four controlled-shuffle genuine-setup samples completed: base Basic 718 actions/20 restores, base Advanced 551/15, CHOAM Basic 681/19 and CHOAM Advanced 174/5. All had zero rejected candidates and preserved full physical Treachery, force and skill custody. None reached a Bureaucrat payment decision; these are regression-continuation samples, not natural full-game redirection acceptance. The separate conserved staged browser exercise allowed one five-spice bribe and redirected two from a second; the payer spent ten, the payee retained eight deferred spice, and one skill use was recorded. Pending and resolved states survived refresh with unchanged physical custody and private projection. After the reported outage, all 699 opening games and original seat/recovery records were preserved (733 after HTTP checks); database integrity and foreign keys passed. Reused the healthy server without restart, reset or recurring automation. Verified source tree: a30615deed306e06ab4c153b9a7eabdc92d646ed79474e348a3500d525bcb8c8
This commit is contained in:
1 parent
9f1f783be4
commit
d0dc7007d9
18 files changed
+1649
-49
No files matched your search
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function BureaucratPayment({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const pending = game.bureaucrat?.pending;
|
||||
const decision = game.decision;
|
||||
if (
|
||||
!pending ||
|
||||
decision?.kind !== 'bureaucratPayment' ||
|
||||
decision.player !== game.me ||
|
||||
decision.event !== pending.event ||
|
||||
pending.owner !== game.me
|
||||
)
|
||||
return null;
|
||||
const payer = game.players.find((player) => player.id === pending.payer)?.name ?? pending.payer;
|
||||
const payee = game.players.find((player) => player.id === pending.payee)?.name ?? pending.payee;
|
||||
const redirectAmount = pending.amount - pending.redirect;
|
||||
return (
|
||||
<section className="min-w-0 space-y-3" aria-label="Bureaucrat payment">
|
||||
<h3 className="font-serif text-xl">Bureaucrat payment</h3>
|
||||
<p className="text-base leading-7">
|
||||
The payment from {payer} to {payee} is {pending.amount} spice. You may redirect {pending.redirect}{' '}
|
||||
spice to the Bank: {payee} receives {redirectAmount} spice and the Bank receives{' '}
|
||||
{pending.redirect}, while {payer} still pays the full amount.
|
||||
</p>
|
||||
{pending.kind === 'bribe' && (
|
||||
<p className="text-base leading-7">
|
||||
The recipient keeps this spice in front of their shield until the next Mentat Pause.
|
||||
It cannot be spent before then.
|
||||
</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
Allowing the full payment preserves Bureaucrat for later use this phase.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
!busy && act({ type: 'decision', event: pending.event, redirect: true })
|
||||
}
|
||||
>
|
||||
Redirect {pending.redirect} spice to the Bank
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
!busy && act({ type: 'decision', event: pending.event, redirect: false })
|
||||
}
|
||||
>
|
||||
Allow full {pending.amount}-spice payment
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { LeaderSkillsPanel } from './leader-skills';
|
||||
import { LeaderSkillBattleGuide } from './leader-skill-battle-guide';
|
||||
import { RihaniChoice, RihaniHistory } from './rihani-decipherer';
|
||||
import { MentatQuestion, MentatHistory } from './mentat-question';
|
||||
import { BureaucratPayment } from './bureaucrat-payment';
|
||||
import { SukGraduatePanel } from './suk-graduate';
|
||||
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
|
||||
import { OrnithopterMovement } from './ornithopter-movement';
|
||||
@@ -2146,6 +2147,8 @@ export function GameTable({
|
||||
? 'Position your skilled leader'
|
||||
: g.decision.kind === 'mentatQuestion'
|
||||
? g.decision.stage === 'name' ? 'Mentat · name a weapon' : 'Mentat · privately show a card'
|
||||
: g.decision.kind === 'bureaucratPayment'
|
||||
? 'Bureaucrat · redirect a payment'
|
||||
: g.decision.kind === 'leaderSkillRevival'
|
||||
? 'Choose a skill for the revived leader'
|
||||
: g.decision.kind === 'rihani'
|
||||
@@ -2439,6 +2442,8 @@ export function GameTable({
|
||||
/>
|
||||
) : g.decision.kind === 'mentatQuestion' ? (
|
||||
<MentatQuestion key={`${g.decision.event}-${g.decision.stage}-${g.me}`} game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'bureaucratPayment' ? (
|
||||
<BureaucratPayment game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'leaderSkillVisibility' || g.decision.kind === 'leaderSkillRevival' ? (
|
||||
<p className="muted">Use the Leader Skills controls above to make your choice.</p>
|
||||
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
|
||||
|
||||
@@ -113,7 +113,7 @@ export function LeaderSkillsPanel({
|
||||
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
|
||||
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
|
||||
Special-card battle use, Suk Graduate force rescue, Mentat’s private
|
||||
weapon-question preview and battle bonus, Bureaucrat battle scores, Sandmaster ground-route collection and
|
||||
weapon-question preview and battle bonus, Bureaucrat payment redirection and battle scores, Sandmaster ground-route collection and
|
||||
victory spice, Rihani inspections and exchanges, optional Smuggler
|
||||
reserve shipping, Spice Banker battle spending, and Diplomat copying
|
||||
an opposing base defense with a committed Worthless card after plans
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Bureaucrat payment redirection
|
||||
|
||||
14 September 2026. This connected development prototype extends the existing
|
||||
Bureaucrat battle penalty with optional payment redirection. Leader Skills,
|
||||
expansion combinations and public starts remain partially implemented. This is
|
||||
not complete economic-rule acceptance.
|
||||
|
||||
## Source and payment identity
|
||||
|
||||
The physical card permits one use per phase when another player pays at least
|
||||
five spice to a player other than the skill owner. Exactly two spice from that
|
||||
payment go to the Bank if the owner elects to use the skill. The payer's cost
|
||||
stays the same; the recipient receives two less. Declining leaves the opportunity
|
||||
available for a later qualifying payment in the same phase.
|
||||
[Physical card](https://cdn.anyfinder.eu/assets/NQOmfvkJitFsQncGJ73kLzv7aC1w7Jp1kQAyy5CJ0pOoj8RlSEN3veAO6wBnw8wV?height=768)
|
||||
|
||||
The designer names Emperor, Guild and Tleilaxu receipts as intended targets.
|
||||
[Jack Reda, 15:01–15:28](https://www.youtube.com/watch?v=XT_azRVLq_0&t=901s)
|
||||
A payment made by the owner, made to the owner, or made to the Bank fails the
|
||||
printed third-party condition. Evaluate the actual amount paid rather than a
|
||||
nominal price or free acquisition. Use the native living face-up assignment;
|
||||
a captured card supplies only its battle band.
|
||||
[GF9 CHOAM & Richese, pp. 9 and 12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
|
||||
|
||||
Actual bribe transfers qualify: the official FAQs describe spice paid as bribes
|
||||
being placed before the recipient's shield. Spendability waits until the next
|
||||
Mentat Pause, but the payment occurs when those counters transfer. Bureaucrat
|
||||
therefore diverts two from the transfer and leaves the remainder in bribe escrow.
|
||||
Collecting that escrow later is not another payment and must not trigger again.
|
||||
A verbal promise alone transfers no spice.
|
||||
[April FAQ, p. 1](https://www.gf9games.com/dune/wp-content/uploads/2020/04/Dune-FAQ.pdf#page=1),
|
||||
[November FAQ, p. 9](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=9)
|
||||
|
||||
## Connected boundary and explicit interpretations
|
||||
|
||||
The target paths are ordinary paid auctions, Guild shipment income, direct
|
||||
Richese auction proceeds and actual bribe transfers in the existing base or
|
||||
CHOAM-only skill profile. Other optional modules retain their existing gates.
|
||||
Tleilaxu revival, other expansion payment sources and direct gifts remain outside
|
||||
this connected batch. Their absence does not establish a rule prohibition.
|
||||
|
||||
For native income, the prototype resolves the existing Karama income window
|
||||
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.
|
||||
|
||||
Qualifying single-payer payments have a public amount. Qualifying allied funding,
|
||||
including an aggregate such as three plus three and a payment funded entirely by
|
||||
the ally, is guarded before costs or commitment. It needs separate payment-unit
|
||||
and privacy work, as does split-recipient Homeworld income. Independent Karama
|
||||
shipments paid entirely to the Bank bypass this player-income guard. Do not infer
|
||||
eligibility from an aggregate that silently combines payers or reveals concealed
|
||||
funding. A successful use is retained for the physical
|
||||
card's turn and phase, including a same-phase ownership change. That physical
|
||||
card accounting is an explicit implementation inference.
|
||||
|
||||
## Saved choices and verification
|
||||
|
||||
Only the skill owner may redirect or allow the current payment. The saved event
|
||||
binds the payer, payee, amount, skill, phase and original continuation. It must
|
||||
retain any prior decision and response without showing their private contents.
|
||||
The payer is charged once; the original purchase, shipment or bribe remains
|
||||
intact. Decline restores the full recipient share without consuming the skill.
|
||||
|
||||
Controls explain both possible recipient amounts and the deferred nature of
|
||||
bribes. AI uses public payment and alliance information. Focused checks cover
|
||||
ownership, successful-use limits, custody, private projection, stale requests,
|
||||
JSON/SQLite restoration and competing writes. Final evidence and limitations
|
||||
are recorded with the verified checkpoint; narrow prototype paths do not open
|
||||
mode or publication gates.
|
||||
|
||||
Four source-stable genuine-setup samples completed with all four AI profiles,
|
||||
full physical Treachery/force/skill custody checks and periodic JSON/private-view
|
||||
restoration. The initial skill shuffle deliberately offered Bureaucrat, which the
|
||||
Atreides AI selected. No hands, forces or phases were staged in these samples.
|
||||
|
||||
| Profile | Seed | Actions | JSON restores | Finish turn |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Base Basic | 20260940 | 718 | 20 | 9 |
|
||||
| Base Advanced | 20260941 | 551 | 15 | 6 |
|
||||
| CHOAM/Richese Basic | 20260942 | 681 | 19 | 8 |
|
||||
| CHOAM/Richese Advanced | 20260943 | 174 | 5 | 2 |
|
||||
|
||||
All four had zero rejected candidates, but none reached a Bureaucrat payment
|
||||
decision. They establish regression continuation for these paths, not natural
|
||||
full-game redirection coverage. Focused tests and the targeted browser exercise
|
||||
cover the payment choices directly; broader integrated acceptance remains open.
|
||||
|
||||
The separate browser QA uses a conserved staged shipment-phase position after
|
||||
genuine skill setup. It allows one actual five-spice bribe, then redirects two
|
||||
from a second actual five-spice bribe in the same phase. The second choice and
|
||||
its resolved movement controls survive refresh. The payer spends ten total,
|
||||
the recipient retains eight in deferred bribe escrow, physical custody stays
|
||||
unchanged, and only the redirection consumes the skill. This is explicitly staged
|
||||
UI evidence. After the reported outage, all 699 opening rooms and their original
|
||||
seat/recovery records matched the private backup; database integrity and foreign
|
||||
keys passed. The healthy server was reused.
|
||||
+22
-26
@@ -1,37 +1,33 @@
|
||||
# Current development status
|
||||
|
||||
Updated 13 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 14 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable; public Advanced and unfinished expansion/module starts remain
|
||||
gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest verified checkpoint: `903d861`, deferred movement arrival safety.
|
||||
Types, lint, **4,583 offline tests**, build and **43 HTTP tests** passed.
|
||||
All **632 opening games** and original seat/recovery records were preserved
|
||||
(665 after HTTP checks). The healthy server was reused; both existing Ecaz and
|
||||
Ixian browser QA seats restored their private hands and pending decisions.
|
||||
Latest verified checkpoint: `9f1f783`, private Mentat question preview.
|
||||
Types, lint, **4,605 offline tests**, build and **43 HTTP tests** passed.
|
||||
All 665 opening rooms and original seat/recovery records were preserved
|
||||
(699 after HTTP checks). The existing server was reused after the outage;
|
||||
Ecaz, Ixian and Mentat browser seats restored their private state and decisions.
|
||||
The only post-verification edit removed an extra newline in a developer document.
|
||||
|
||||
[Deferred movement](DEFERRED_MOVEMENT_ARRIVAL.md) now rejects unsupported arrivals
|
||||
before prevention windows or new card commitment. A legacy CHOAM decline returns
|
||||
an uncommitted move for another choice while preserving reserved Ornithopter
|
||||
custody. All six genuine faction sample games finish; combined Advanced completes
|
||||
1,303 actions and 35 JSON restorations. Guarded arrival candidates still occur.
|
||||
The exact prior failed save also finishes. Simultaneous Ambassador/Terror priority
|
||||
remains unresolved, so these paths do not prove complete combined rules support.
|
||||
[Mentat](MENTAT_QUESTION.md) connects private naming/disclosure before posture
|
||||
and faction powers, all four AI profiles and saved continuation in explicitly
|
||||
opted-in previews. Two controlled-shuffle genuine-setup samples finish. Ordinary
|
||||
activation still awaits the uniform private response UX decision.
|
||||
|
||||
The [Mentat question preview](MENTAT_QUESTION.md) now connects private naming and
|
||||
card disclosure before leader posture and faction powers, with human controls,
|
||||
all four AI profiles and saved continuation. Independent review and 22 focused
|
||||
tests pass; two controlled-shuffle genuine-setup samples finish. Ordinary games
|
||||
remain unactivated while the uniform private response UX question is pending.
|
||||
Final broad results and Git delivery are recorded with the verified checkpoint.
|
||||
|
||||
After the latest power failure, all 665 opening rooms and original seat/recovery
|
||||
records matched the backup; the new Mentat QA room brings the total to 666 before
|
||||
HTTP checks. Database integrity and foreign keys passed. The existing server
|
||||
responds at localhost:3000 and is reused; its lock prevented a duplicate start.
|
||||
The Mentat browser seat restored its private history and pending leader choice.
|
||||
The current milestone connects [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md):
|
||||
ordinary auctions, shipment income, Richese sale proceeds and actual bribe
|
||||
transfers, with a once-per-phase optional redirection and preserved continuation.
|
||||
Qualifying allied funding, other payment families and combined-module acceptance
|
||||
stay explicit. Independent review and focused controls/AI/recovery checks pass.
|
||||
Four genuine-setup games finish without rejected candidates, but contain no payment
|
||||
choice; a separate staged browser exercise verifies both choices and refresh.
|
||||
All 699 opening rooms and original seat/recovery records survived the reported
|
||||
outage. The healthy server was reused. The completed checkpoint's Git record
|
||||
contains final broad verification and post-HTTP preservation totals.
|
||||
|
||||
## Authoritative navigation
|
||||
|
||||
@@ -58,7 +54,7 @@ These links define bounded working behavior, not complete module certification.
|
||||
| --- | --- |
|
||||
| Base/Advanced setup | [Genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
|
||||
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement, separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
|
||||
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md) and [No-Field](SMUGGLER_NO_FIELD.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
|
||||
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md) and [No-Field](SMUGGLER_NO_FIELD.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
|
||||
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
|
||||
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) is connected; its after-victory inspection remains pending. A source audit or helper alone is not a completed effect. |
|
||||
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 14 September 2026 — Bureaucrat payment prototype and outage preservation
|
||||
|
||||
[Bureaucrat](BUREAUCRAT_PAYMENTS.md) now connects optional two-spice redirection
|
||||
for ordinary auction, Guild shipment, Richese auction and actual bribe payments
|
||||
in the bounded base/CHOAM Leader Skills profile. Native income cancellation
|
||||
resolves before the offer; the payer is charged once and the original callback
|
||||
resumes once. Decline leaves later opportunities available, and bribe proceeds
|
||||
remain unspendable until Mentat Pause. The physical-card use ledger survives
|
||||
ownership changes. Payment-unit guards, composition inferences and missing
|
||||
families remain explicit; no public mode or publication gate is opened.
|
||||
|
||||
Owner controls and all four AI profiles use public payment information. Independent
|
||||
review covers saved receipts, private projection, costs, custody and callback
|
||||
ordering. Review found and fixed duplicate use-event corruption and an overly
|
||||
broad guard on Bank-bound independent Karama shipments. Focused regressions
|
||||
cover those defects, auction/card delivery, real Bene Gesserit advisor continuation,
|
||||
bribe escrow, private SQLite restoration and competing version writes.
|
||||
|
||||
Four controlled-shuffle genuine-setup games complete with zero rejected actions:
|
||||
base Basic 718 actions/20 JSON restores, base Advanced 551/15, CHOAM Basic 681/19,
|
||||
and CHOAM Advanced 174/5. Full physical card, force and skill custody is checked.
|
||||
None reaches a Bureaucrat payment decision, so those samples establish bounded
|
||||
regression continuation only. A separate conserved staged browser game allows
|
||||
one five-spice bribe and redirects two from a second: ten is paid, eight stays in
|
||||
bribe escrow, and only one skill use is recorded. Both pending and resolved states
|
||||
restore after refresh with unchanged physical custody and private projections.
|
||||
|
||||
After the reported power failure, all 699 opening games and original seat/recovery
|
||||
records matched the private backup. The database passed integrity and foreign-key
|
||||
checks and the server remained healthy. No restart or reset was needed. Final
|
||||
broad results, source fingerprint, Git delivery and post-HTTP preservation totals
|
||||
are recorded in the checkpoint commit and private verification report.
|
||||
|
||||
## 13 September 2026 — Private Mentat question preview and outage recovery
|
||||
|
||||
[Mentat](MENTAT_QUESTION.md) has a connected private pre-plan question preview:
|
||||
|
||||
@@ -56,7 +56,10 @@ and the lower Mentat, Bureaucrat and Sandmaster effects. A separate
|
||||
[Mentat question preview](MENTAT_QUESTION.md) connects private naming/disclosure,
|
||||
posture and saved history under an explicit private opt-in. The uniform mandatory
|
||||
response step awaits the user's UX decision; ordinary activation remains disabled.
|
||||
Bureaucrat payment redirection and Spice Banker income remain missing. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
|
||||
[Bureaucrat payment redirection](BUREAUCRAT_PAYMENTS.md) connects the ordinary
|
||||
auction, shipment, Richese sale and paid-bribe families within its recorded
|
||||
boundary. Other payments and split funding remain incomplete. Spice Banker
|
||||
income remains missing. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
|
||||
now connects its lower band with separate sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
|
||||
connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md).
|
||||
[Diplomat defense](DIPLOMAT_DEFENSE.md) connects its normal band for a physical
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rule decision index
|
||||
|
||||
Updated 13 September 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
Updated 14 September 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
|
||||
## Recorded contracts and implementation boundaries
|
||||
|
||||
@@ -12,6 +12,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
|
||||
| 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 on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.md) |
|
||||
| Diplomat defense | With no actual defense, one physical Worthless card in either Battle Plan slot may copy the opponent's canonical base Shield or Snooper. The same native trained disc may protect itself. The public after-reveal/before-traitor choice and mandatory discard after traitor preemption are documented implementation inferences. Lower retreat and modified or multirole defenses remain guarded. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
|
||||
| Bureaucrat payments | Once per phase, optionally divert two from an actual third-party payment of at least five; payer cost is unchanged. Bribes trigger at transfer to the recipient's shield, not later collection. Native income is offered after Karama allowance as an explicit outcome-based inference; split contributions, split recipients and remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) |
|
||||
| Mentat question preview | The physical card says to show the card to the questioner, privately; it stays in hand and does not bind a plan. Ask before posture/faction powers. A uniform target response protects held/absent privacy but awaits approval as an exception to skipping forced confirmations; standard activation stays disabled. Empty hands skip with no fabricated answer. | [Source and preview](MENTAT_QUESTION.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. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.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) |
|
||||
|
||||
+9
-1
@@ -1438,7 +1438,7 @@ function policyActions(g: GameView): Action[] {
|
||||
if (g.decision) {
|
||||
if (g.decision.player !== me.id) return [];
|
||||
const d = g.decision;
|
||||
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion') return [];
|
||||
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return [];
|
||||
if (d.kind === 'choamAudit')
|
||||
return [{ type: 'decision', event: d.event, audit: true }];
|
||||
if (d.kind === 'choamAuditPayment') {
|
||||
@@ -3659,6 +3659,14 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
|
||||
/** Obligations apply across policy branches, including choosing to move first. */
|
||||
export function botActions(g: GameView): Action[] {
|
||||
const bureaucrat = g.bureaucrat?.pending;
|
||||
if (bureaucrat) {
|
||||
if (bureaucrat.owner !== g.me || g.decision?.kind !== 'bureaucratPayment' ||
|
||||
g.decision.player !== g.me || g.decision.event !== bureaucrat.event ||
|
||||
g.truthtrance || g.response || g.automaticContinuationPending) return [];
|
||||
const ally = g.players.find((player) => player.id === g.me)?.ally;
|
||||
return [{ type: 'decision', event: bureaucrat.event, redirect: bureaucrat.payee !== ally }];
|
||||
}
|
||||
const mentat = g.mentat?.pending;
|
||||
if (mentat) {
|
||||
if (mentat.player !== g.me || g.decision?.kind !== 'mentatQuestion' ||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe';
|
||||
export type BureaucratPaymentSource = {
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
kind: BureaucratPaymentKind;
|
||||
payer: string;
|
||||
payee: string;
|
||||
amount: number;
|
||||
signature: string;
|
||||
};
|
||||
/** The one physical skill card's use survives death, redraw and controller changes. */
|
||||
export type BureaucratPaymentUse = {
|
||||
event: string;
|
||||
owner: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
signature: string;
|
||||
};
|
||||
export type BureaucratPaymentView = {
|
||||
pending: null | {
|
||||
event: string;
|
||||
owner: string;
|
||||
payer: string;
|
||||
payee: string;
|
||||
amount: number;
|
||||
kind: BureaucratPaymentKind;
|
||||
redirect: 2;
|
||||
};
|
||||
usedThisPhase: boolean;
|
||||
};
|
||||
export class BureaucratPaymentError extends Error {}
|
||||
export function bureaucratPaymentSignature(value: object): string {
|
||||
return JSON.stringify({ ...value, signature: undefined });
|
||||
}
|
||||
export function bureaucratPaymentModeSupported(game: {
|
||||
expansions: readonly string[];
|
||||
homeworlds?: unknown;
|
||||
nexusCards?: unknown;
|
||||
discoveries?: unknown;
|
||||
discoveryEnabled?: unknown;
|
||||
strongholdCards?: unknown;
|
||||
techTokens?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam')) &&
|
||||
!game.homeworlds &&
|
||||
!game.nexusCards &&
|
||||
!game.discoveries &&
|
||||
!game.discoveryEnabled &&
|
||||
!game.strongholdCards &&
|
||||
!game.techTokens
|
||||
);
|
||||
}
|
||||
export function bureaucratUsed(
|
||||
used: readonly BureaucratPaymentUse[],
|
||||
turn: number,
|
||||
phase: number,
|
||||
): boolean {
|
||||
return used.some((use) => use.turn === turn && use.phase === phase);
|
||||
}
|
||||
/** Caller supplies only a living face-up native trainer and actual player payees. */
|
||||
export function quoteBureaucratPayment(input: {
|
||||
owner: string | null;
|
||||
payer: string;
|
||||
payee: string;
|
||||
amount: number;
|
||||
turn: number;
|
||||
phase: number;
|
||||
used: readonly BureaucratPaymentUse[];
|
||||
}): { redirect: 2; income: number } | null {
|
||||
if (
|
||||
!Number.isSafeInteger(input.amount) ||
|
||||
input.amount < 0 ||
|
||||
!Number.isSafeInteger(input.turn) ||
|
||||
input.turn < 1 ||
|
||||
!Number.isSafeInteger(input.phase) ||
|
||||
input.phase < 0 ||
|
||||
input.phase > 8 ||
|
||||
!input.payer ||
|
||||
!input.payee
|
||||
)
|
||||
throw new BureaucratPaymentError(
|
||||
'Bureaucrat needs a valid actual player payment.',
|
||||
);
|
||||
return input.owner &&
|
||||
input.owner !== input.payer &&
|
||||
input.owner !== input.payee &&
|
||||
input.payer !== input.payee &&
|
||||
input.amount >= 5 &&
|
||||
!bureaucratUsed(input.used, input.turn, input.phase)
|
||||
? { redirect: 2, income: input.amount - 2 }
|
||||
: null;
|
||||
}
|
||||
+188
-12
@@ -1,5 +1,6 @@
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
|
||||
import { 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,
|
||||
@@ -746,6 +747,7 @@ export type Decision =
|
||||
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
| {kind:'bureaucratPayment';player:string;event:string}
|
||||
| { kind: 'mentatQuestion'; player: string; event: string; owner: string; target: string; stage: 'name' | 'reveal'; weapon: string | null }
|
||||
| { kind: 'fullPlanRead'; player: string; target: string }
|
||||
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
|
||||
@@ -823,6 +825,8 @@ export type Decision =
|
||||
}
|
||||
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
|
||||
export type ResponseWindow = {
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
bureaucratPaymentEvent?: string;
|
||||
noFieldSkillProof?: string;
|
||||
/** Original optional collection binds a pending ground movement, including saved responses. */
|
||||
sandmasterProof?: string;
|
||||
@@ -1149,6 +1153,8 @@ export type Game = {
|
||||
};
|
||||
ixRicheseTechnologyEvent?: string;
|
||||
currentAuctionSale?: {
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
bureaucratPaymentEvent?: string;
|
||||
winner: string;
|
||||
amount: number;
|
||||
free: boolean;
|
||||
@@ -1467,6 +1473,14 @@ export type Game = {
|
||||
/** Absent on legacy rooms whose starting cards were already dealt. */
|
||||
setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces';
|
||||
leaderSkills?: LeaderSkillsState;
|
||||
bureaucratPayments?: {used:BureaucratPaymentUse[];pending?:{
|
||||
source:BureaucratPaymentSource;owner:string;leader:string;
|
||||
resume:{response:ResponseWindow|null;decision:Decision|null};
|
||||
continuation:{kind:'response'}|{kind:'auction';quote:AuctionContinuationQuote}|{kind:'bribe'};
|
||||
frame:string;signature:string;
|
||||
}};
|
||||
bureaucratPaymentEvent?: string;
|
||||
bureaucratUseEvents?: string[];
|
||||
/** Private development opt-in pending the uniform Mentat response UX decision. */
|
||||
mentatQuestionPreview?: true;
|
||||
mentatHistory?: MentatObservation[];
|
||||
@@ -1985,7 +1999,7 @@ function validateNexusAdvisorResponse(g: Game, response: ResponseWindow) {
|
||||
function nexusAdvisorIntegrity(g: Game) {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,g.bureaucratPayments?.pending?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const responses = contexts.flatMap(context => {
|
||||
const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
@@ -4977,6 +4991,7 @@ function leaderSkillAssignmentUnavailable(
|
||||
: null;
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
bureaucratPaymentIntegrity(g);
|
||||
mentatQuestionIntegrity(g);
|
||||
diplomatDefenseIntegrity(g);
|
||||
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
|
||||
@@ -5069,6 +5084,138 @@ function nativeLeaderSkill(g: Game, owner: string) {
|
||||
(a) => a.owner === owner && leaderSkillController(g, a) === owner,
|
||||
);
|
||||
}
|
||||
function nativeBureaucrat(g: Game) {
|
||||
return g.leaderSkills?.assignments.find(a => a.skill === 'bureaucrat' &&
|
||||
getPlayer(g,a.owner).leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy) &&
|
||||
!g.battle?.leaderSkillHidden?.[a.owner]);
|
||||
}
|
||||
function bureaucratPaymentQuote(g: Game,source: Pick<BureaucratPaymentSource,'payer'|'payee'|'amount'>) {
|
||||
const owner = nativeBureaucrat(g)?.owner ?? null;
|
||||
return quoteBureaucratPayment({...source,owner,turn:g.turn,phase:g.phase,used:g.bureaucratPayments?.used ?? []});
|
||||
}
|
||||
function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{payer:string;amount:number}[],privateFunding=false) {
|
||||
if (!g.leaderSkills || !payee) return;
|
||||
const paid = shares.filter(share => share.amount > 0);
|
||||
const owner = nativeBureaucrat(g)?.owner;
|
||||
if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) ||
|
||||
paid.reduce((total,share) => total+share.amount,0) < 5 || !paid.some(share => share.payer !== owner && share.payer !== payee)) return;
|
||||
requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.');
|
||||
requireRule(paid.length === 1 && !privateFunding,'Bureaucrat on allied funding or multiple payer contributions is awaiting private payment-split integration.');
|
||||
}
|
||||
function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number):
|
||||
{bureaucratPayment?:BureaucratPaymentSource;bureaucratPaymentEvent?:string} {
|
||||
if (!g.leaderSkills || !bureaucratPaymentModeSupported(g) || amount < 5 || payer === payee) return {};
|
||||
const source:BureaucratPaymentSource = {event:crypto.randomUUID(),turn:g.turn,phase:g.phase,kind,payer,payee,amount,signature:''};
|
||||
source.signature = bureaucratPaymentSignature(source);
|
||||
return {bureaucratPayment:source,bureaucratPaymentEvent:source.event};
|
||||
}
|
||||
function bureaucratPaymentFrame(g: Game): string {
|
||||
return JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order,
|
||||
players:g.players.map(p => ({id:p.id,spice:p.spice,bribes:p.bribes,hand:p.hand,leaders:p.leaders,forces:p.forces,
|
||||
elites:p.elites,reserves:p.reserves,tanks:p.tanks,ally:p.ally,shipped:p.shipped,moved:p.moved})),
|
||||
aid:g.aid,auction:g.auction,sale:g.currentAuctionSale,richeseAuction:g.richeseAuction,richeseBidding:g.richeseBidding,
|
||||
richeseFunding:g.richeseFunding,richeseCache:g.richeseCache,pendingIxAlly:g.pendingIxAlly,
|
||||
battle:g.battle,leaderSkills:g.leaderSkills,phaseOpening:g.phaseOpening});
|
||||
}
|
||||
function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) {
|
||||
requireRule(source && typeof source === 'object' && typeof source.event === 'string' && source.event.length > 0 &&
|
||||
source.turn === g.turn && source.phase === g.phase && ['auction','shipment','bribe'].includes(source.kind) &&
|
||||
g.players.some(p => p.id === source.payer) && g.players.some(p => p.id === source.payee) && source.payer !== source.payee &&
|
||||
Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source),
|
||||
'The saved Bureaucrat payment changed its actual payer, payee, amount or event.');
|
||||
}
|
||||
function bureaucratPaymentIntegrity(g: Game) {
|
||||
const state = g.bureaucratPayments,events = g.bureaucratUseEvents ?? [];
|
||||
requireRule(!state || Array.isArray(state.used),'The saved Bureaucrat usage ledger is invalid.');
|
||||
requireRule(Array.isArray(events) && events.length === (state?.used.length ?? 0) && new Set(events).size === events.length &&
|
||||
new Set((state?.used ?? []).map(use => use?.event)).size === (state?.used.length ?? 0),
|
||||
'The physical Bureaucrat card lost its independent usage events.');
|
||||
const phases = new Set<string>();
|
||||
for (const use of state?.used ?? []) {
|
||||
const phase = `${use.turn}:${use.phase}`;
|
||||
requireRule(use && typeof use.event === 'string' && events.includes(use.event) && !phases.has(phase) &&
|
||||
Number.isSafeInteger(use.turn) && use.turn >= 1 && use.turn <= g.turn && Number.isSafeInteger(use.phase) && use.phase >= 0 && use.phase <= 8 &&
|
||||
(use.turn < g.turn || use.phase <= g.phase) && g.players.some(p => p.id === use.owner) && use.signature === bureaucratPaymentSignature(use),
|
||||
'The physical Bureaucrat skill cannot gain a second use in the same phase.');
|
||||
phases.add(phase);
|
||||
}
|
||||
const pending = state?.pending;
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,g.pendingRichesePurchaseIncome?.resume,
|
||||
g.summonedWorm?.resume,continuation && 'resume' in continuation ? continuation.resume : null,pending?.resume];
|
||||
const responses = contexts.flatMap(context => context?.response ? [context.response] : []);
|
||||
if (g.pendingKarama?.use.kind === 'cancel') responses.push(g.pendingKarama.use.response);
|
||||
for (const carrier of [g.currentAuctionSale,...responses].filter((value): value is NonNullable<typeof value> => !!value)) {
|
||||
const source = carrier.bureaucratPayment;
|
||||
if (!source) {requireRule(!carrier.bureaucratPaymentEvent,'The saved payment lost its original Bureaucrat source.');continue;}
|
||||
requireRule(g.leaderSkills && bureaucratPaymentModeSupported(g) && carrier.bureaucratPaymentEvent === source.event,
|
||||
'The saved Bureaucrat payment lost its module or independent event.');
|
||||
validateBureaucratSource(g,source);
|
||||
if ('winner' in carrier) requireRule(source.kind === 'auction' && !carrier.free && source.amount === carrier.amount &&
|
||||
(source.payer === carrier.winner || getPlayer(g,carrier.winner).ally === source.payer) &&
|
||||
source.payee === (carrier.origin !== 'normal' && carrier.seller !== carrier.winner ? carrier.seller : byFaction(g,'emperor')?.id),
|
||||
'The Bureaucrat source no longer matches its paid auction sale.');
|
||||
else requireRule(source.payee === carrier.owner &&
|
||||
(carrier.kind === 'guildIncome' && source.kind === 'shipment' && source.amount === carrier.amount ||
|
||||
carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event),
|
||||
'The Bureaucrat source no longer matches its original native income.');
|
||||
}
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'bureaucratPayment');
|
||||
if (!pending) {requireRule(!g.bureaucratPaymentEvent && !decisions.length,'The saved Bureaucrat decision lost its original payment.');return;}
|
||||
validateBureaucratSource(g,pending.source);
|
||||
const assignment = nativeBureaucrat(g);
|
||||
requireRule(g.status === 'playing' && g.leaderSkills && bureaucratPaymentModeSupported(g) && !g.response &&
|
||||
g.bureaucratPaymentEvent === pending.source.event && pending.signature === bureaucratPaymentSignature(pending) &&
|
||||
pending.frame === bureaucratPaymentFrame(g) && assignment?.owner === pending.owner && assignment.leader === pending.leader &&
|
||||
bureaucratPaymentQuote(g,pending.source) && decisions.length === 1 && decisions[0] === g.decision &&
|
||||
decisions[0].player === pending.owner && decisions[0].event === pending.source.event,
|
||||
'The pending Bureaucrat payment changed its trainer, resources, callback or unique decision.');
|
||||
if (pending.continuation.kind === 'response') requireRule(pending.resume.response?.bureaucratPaymentEvent === pending.source.event,
|
||||
'The Bureaucrat income lost its uncanceled response continuation.');
|
||||
else if (pending.continuation.kind === 'auction') requireRule(g.currentAuctionSale?.bureaucratPaymentEvent === pending.source.event &&
|
||||
JSON.stringify(pending.continuation.quote) === JSON.stringify(currentAuctionContinuationQuote(g,{kind:'sale',free:false})),
|
||||
'The Bureaucrat sale lost its original seller-credit continuation.');
|
||||
else requireRule(pending.continuation.kind === 'bribe' && pending.source.kind === 'bribe',
|
||||
'The Bureaucrat payment has an invalid continuation.');
|
||||
}
|
||||
function offerBureaucratPayment(g: Game,source:BureaucratPaymentSource|undefined,
|
||||
continuation:NonNullable<NonNullable<Game['bureaucratPayments']>['pending']>['continuation']): boolean {
|
||||
if (!source || !bureaucratPaymentQuote(g,source)) return false;
|
||||
requireRule(!g.bureaucratPayments?.pending,'Finish the current Bureaucrat payment first.');
|
||||
validateBureaucratSource(g,source);
|
||||
const assignment = nativeBureaucrat(g)!;
|
||||
const pending = {source,owner:assignment.owner,leader:assignment.leader,
|
||||
resume:{response:g.response,decision:g.decision},continuation,frame:bureaucratPaymentFrame(g),signature:''};
|
||||
pending.signature = bureaucratPaymentSignature(pending);
|
||||
(g.bureaucratPayments ??= {used:[]}).pending = pending;
|
||||
g.bureaucratPaymentEvent = source.event;
|
||||
g.response = null;
|
||||
g.decision = {kind:'bureaucratPayment',player:assignment.owner,event:source.event};
|
||||
return true;
|
||||
}
|
||||
function projectedBureaucrat(g: Game): BureaucratPaymentView {
|
||||
const pending = g.bureaucratPayments?.pending;
|
||||
return {pending:pending ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee,
|
||||
amount:pending.source.amount,kind:pending.source.kind,redirect:2} : null,
|
||||
usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)};
|
||||
}
|
||||
function actBureaucratPayment(g: Game,action:Action) {
|
||||
const pending = g.bureaucratPayments!.pending!;
|
||||
requireRule(action.event === pending.source.event && typeof action.redirect === 'boolean' &&
|
||||
Object.keys(action).every(key => ['type','event','redirect'].includes(key)), 'Choose whether to redirect this exact Bureaucrat payment.');
|
||||
const redirected = action.redirect ? 2 : 0;
|
||||
if (redirected) {
|
||||
const use:BureaucratPaymentUse = {event:pending.source.event,owner:pending.owner,turn:g.turn,phase:g.phase,signature:''};
|
||||
use.signature = bureaucratPaymentSignature(use);
|
||||
g.bureaucratPayments!.used.push(use);(g.bureaucratUseEvents ??= []).push(use.event);
|
||||
log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of the ${pending.source.amount}-spice payment to the Bank.`);
|
||||
}
|
||||
delete g.bureaucratPayments!.pending;delete g.bureaucratPaymentEvent;
|
||||
g.response = pending.resume.response;g.decision = pending.resume.decision;
|
||||
if (pending.continuation.kind === 'response') finishResponse(g,false,redirected);
|
||||
else if (pending.continuation.kind === 'auction') commitAuctionContinuation(g,pending.continuation.quote,redirected);
|
||||
else getPlayer(g,pending.source.payee).bribes += pending.source.amount-redirected;
|
||||
}
|
||||
function requireLeaderSkillRevival(g: Game, owner: string) {
|
||||
requireRule(
|
||||
!g.leaderSkills?.assignments.some(
|
||||
@@ -6580,6 +6727,7 @@ function settleRicheseLot(g: Game) {
|
||||
free: false,
|
||||
origin: lot.source,
|
||||
seller: owner.id,
|
||||
...stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id),
|
||||
};
|
||||
log(
|
||||
g,
|
||||
@@ -7286,6 +7434,14 @@ function payWithAlly(g: Game, p: Player, cost: number, allyPayment: number) {
|
||||
p.spice -= cost - allyPayment;
|
||||
if (credit) credit.amount -= allyPayment;
|
||||
}
|
||||
function auctionBureaucratParties(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) {
|
||||
return {payee:seller && seller !== p.id ? seller : byFaction(g,'emperor')?.id,
|
||||
shares:[{payer:p.id,amount:amount-allyPayment},...(allyPayment ? [{payer:p.ally!,amount:allyPayment}] : [])].filter(share => share.amount > 0)};
|
||||
}
|
||||
function stampAuctionBureaucrat(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) {
|
||||
const {payee,shares} = auctionBureaucratParties(g,p,amount,allyPayment,seller);
|
||||
return payee && !allyPayment && shares.length === 1 ? stampBureaucratPayment(g,'auction',shares[0].payer,payee,amount) : {};
|
||||
}
|
||||
function auctionNext(g: Game) {
|
||||
const a = g.auction!;
|
||||
const eligible = g.order.filter((id) => {
|
||||
@@ -7323,6 +7479,7 @@ function settleAuction(g: Game, free = false, automatic = false) {
|
||||
free,
|
||||
origin: 'normal',
|
||||
seller: null,
|
||||
...(!free ? stampAuctionBureaucrat(g,winner,a.bid,a.allyPayment ?? 0) : {}),
|
||||
};
|
||||
log(
|
||||
g,
|
||||
@@ -7393,21 +7550,24 @@ function validateAuctionContinuationCancellation(
|
||||
);
|
||||
return null;
|
||||
}
|
||||
function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote) {
|
||||
function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bureaucratDiversion?:number) {
|
||||
if (bureaucratDiversion === undefined && quote.steps.some(step => step.kind === 'sellerCredit') &&
|
||||
offerBureaucratPayment(g,g.currentAuctionSale?.bureaucratPayment,{kind:'auction',quote})) return;
|
||||
for (const step of quote.steps) {
|
||||
if (step.kind === 'clearIxAlly') g.pendingIxAlly = null;
|
||||
else {
|
||||
const seller = getPlayer(g, step.player);
|
||||
seller.spice = step.balance;
|
||||
seller.spice = step.balance-(bureaucratDiversion ?? 0);
|
||||
log(
|
||||
g,
|
||||
`${seller.name} collected ${step.amount} spice from the ${quote.sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`,
|
||||
`${seller.name} collected ${step.amount-(bureaucratDiversion ?? 0)} spice from the ${quote.sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`,
|
||||
{ faction: 'richese', name: 'Auction income' },
|
||||
);
|
||||
}
|
||||
}
|
||||
const next = quote.next;
|
||||
if (next.kind === 'response') g.response = next.response;
|
||||
if (next.kind === 'response') g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ?
|
||||
{bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})};
|
||||
else if (next.kind === 'richeseEnd') finishRicheseLot(g);
|
||||
else {
|
||||
g.currentAuctionSale = null;
|
||||
@@ -10378,13 +10538,16 @@ function checkShipmentIncomeRounding(g: Game, p: Player, cost: number, allyPayme
|
||||
const guild = byFaction(g, 'guild');
|
||||
if (!guild) return;
|
||||
const amounts = shipmentIncomeContributions(g, p, cost, allyPayment);
|
||||
checkBureaucratContributors(g,g.karamaShipping?.player === p.id ? undefined : guild.id,[{payer:p.id,amount:p.id === guild.id ? 0 : cost-allyPayment},
|
||||
...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0);
|
||||
validateGuildPaymentRounding(g, guild.id, amounts.reduce((sum, amount) => sum + amount, 0), amounts);
|
||||
}
|
||||
function guildPaymentResponse(g: Game, owner: string, amounts: number[]): ResponseWindow {
|
||||
function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string): ResponseWindow {
|
||||
const contributions = amounts.filter(amount => amount > 0);
|
||||
const amount = contributions.reduce((sum, value) => sum + value, 0);
|
||||
validateGuildPaymentRounding(g, owner, amount, contributions);
|
||||
return { kind: 'guildIncome', owner, amount, passed: [],
|
||||
...(contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}),
|
||||
...(g.homeworlds?.custody ? { guildContributions: contributions,
|
||||
guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) };
|
||||
}
|
||||
@@ -16271,7 +16434,7 @@ function currentPlacementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function finishResponse(g: Game, canceled: boolean) {
|
||||
function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) {
|
||||
karamaConversionIntegrity(g);
|
||||
currentFactionPayment(g);
|
||||
const response = g.response!;
|
||||
@@ -16317,6 +16480,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
const allianceCancellation = canceled
|
||||
? moritaniAllianceCancellationQuote(g, response)
|
||||
: null;
|
||||
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
|
||||
g.response = null;
|
||||
if (response.kind === 'nexusGuildCunning') { settleGuildCunning(g,response,canceled); return; }
|
||||
if (response.kind === 'nexusAdvisorFlip') {
|
||||
@@ -17200,7 +17364,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
);
|
||||
}
|
||||
} else if (response.kind === 'guildIncome') {
|
||||
const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!) : null;
|
||||
const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null;
|
||||
log(
|
||||
g,
|
||||
canceled
|
||||
@@ -17251,7 +17415,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
if (!canceled) {
|
||||
const owner = getPlayer(g, response.owner);
|
||||
const amount = g.currentAuctionSale?.amount ?? g.auction!.bid;
|
||||
const payment = creditFactionPayment(g, owner.id, 'treachery', amount);
|
||||
const payment = creditFactionPayment(g, owner.id, 'treachery', amount-(bureaucratDiversion ?? 0));
|
||||
log(
|
||||
g,
|
||||
`${owner.name} received ${payment.income} spice from another faction’s ${amount}-spice paid auction bid.${payment.bank ? ` Low-population Kaitain leaves ${payment.bank} spice in the bank.` : ''}`,
|
||||
@@ -17912,7 +18076,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
bankOnly: g.karamaShipping?.player === p.id,
|
||||
});
|
||||
if (guild && guildPayment > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment));
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
|
||||
g.karamaShipping = null;
|
||||
log(
|
||||
g,
|
||||
@@ -19792,6 +19956,7 @@ function applyActionInner(
|
||||
requireRule(g.status !== 'finished', 'This game has ended.');
|
||||
const t = action.type;
|
||||
if (t === 'advanceBots') return g;
|
||||
requireRule(!g.bureaucratPayments?.pending || t === 'decision','Resolve the pending Bureaucrat payment before another action.');
|
||||
requireRule(!(g.battle?.mentatQuestion?.stage === 'name' || g.battle?.mentatQuestion?.stage === 'reveal') || t === 'decision',
|
||||
'Resolve the current Mentat question before another action.');
|
||||
if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
|
||||
@@ -20113,6 +20278,7 @@ function applyActionInner(
|
||||
actMentatQuestion(g, decision, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'bureaucratPayment') {actBureaucratPayment(g,action);return g;}
|
||||
if (decision.kind === 'ixRicheseTechnology') {
|
||||
const pending = g.pendingIxRicheseTechnology!;
|
||||
requireRule(action.event === pending.event && action.decline === true &&
|
||||
@@ -21596,12 +21762,14 @@ function applyActionInner(
|
||||
'Bribes are between different, non-allied factions.',
|
||||
);
|
||||
const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice');
|
||||
checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]);
|
||||
p.spice -= n;
|
||||
target.bribes += n;
|
||||
log(
|
||||
g,
|
||||
`${p.name} promised ${n} spice to ${target.name}; collect it at the Mentat pause.`,
|
||||
);
|
||||
const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment;
|
||||
if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n;
|
||||
return g;
|
||||
}
|
||||
if (t === 'stormDial') {
|
||||
@@ -21735,6 +21903,10 @@ function applyActionInner(
|
||||
);
|
||||
if (amount !== null)
|
||||
validateRicheseFunding(amount, allyPayment, ownAvailable, allyAvailable);
|
||||
if (amount !== null) {
|
||||
const parties = auctionBureaucratParties(g,p,amount,allyPayment,lot.owner);
|
||||
checkBureaucratContributors(g,parties.payee,parties.shares,allyPayment > 0);
|
||||
}
|
||||
g.richeseAuction = submitRicheseBid(
|
||||
lot,
|
||||
{ event: stringField(action.event), actor: p.id, amount },
|
||||
@@ -21780,6 +21952,8 @@ function applyActionInner(
|
||||
a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
|
||||
? contribution(g, p, a.bid, action.allyPayment)
|
||||
: 0;
|
||||
const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment);
|
||||
checkBureaucratContributors(g,parties.payee,parties.shares,a.allyPayment > 0);
|
||||
a.bidder = id;
|
||||
a.passed = [];
|
||||
} else if (!a.passed.includes(id)) a.passed.push(id);
|
||||
@@ -22471,7 +22645,7 @@ function applyActionInner(
|
||||
bankOnly: g.karamaShipping?.player === id,
|
||||
});
|
||||
if (guild && guildPayment > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment));
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
|
||||
if (fromReserves) {
|
||||
p.reserves -= n;
|
||||
if (p.elites) p.elites.reserves -= elite;
|
||||
@@ -23262,6 +23436,7 @@ export function viewGame(state: Game, id: string) {
|
||||
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
|
||||
setupPending: setupPending(g),
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
bureaucrat: projectedBureaucrat(g),
|
||||
mentat: projectedMentat(g, id),
|
||||
rihani: projectedRihani(g, id),
|
||||
advanced: g.advanced,
|
||||
@@ -23715,6 +23890,7 @@ export function viewGame(state: Game, id: string) {
|
||||
response: g.response
|
||||
? {
|
||||
...g.response,
|
||||
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
|
||||
...(g.response.guildContributions ? { guildContributions: undefined } : {}),
|
||||
...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}),
|
||||
...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}),
|
||||
|
||||
+8
-7
@@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping, Sandmaster route collection sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping, Sandmaster route collection sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
@@ -3580,16 +3580,17 @@ 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 remains unfinished.',
|
||||
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. Lower retreat and modified defenses remain unfinished.',
|
||||
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
|
||||
'Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
'Bureaucrat may redirect two spice from a payment of at least five between two other players, 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 the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income and Richese sales have connected payment paths; split funding, other payment families and combined modules remain unfinished.',
|
||||
'Special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; normal income, other remaining bands, Diplomat retreat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ const DECISIONS = {
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
mentatQuestion: true,
|
||||
bureaucratPayment: true,
|
||||
sukRescue: true,
|
||||
rihani: true,
|
||||
diplomatDefense: true,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction,viewGame} from '../game/engine';
|
||||
import {botActions} from '../game/bots';
|
||||
import {bureaucratPaymentGame,bureaucratReload} from './bureaucrat-payment-fixture';
|
||||
|
||||
void test('all four profiles legally redirect opposing bribes and Guild income using only the owned current choice',()=>{
|
||||
for(const bot of ['Easy','Medium','Hard','Brutal'] as const)for(const kind of ['bribe','shipment'] as const){
|
||||
let game=bureaucratPaymentGame();game.players.find(p=>p.id==='b')!.bot=bot;
|
||||
game=applyAction(game,'p',kind==='bribe'?{type:'bribe',target:'e',amount:5}:{type:'ship',territory:'arrakeen',sector:10,amount:5});
|
||||
assert.equal(game.decision?.kind,'bureaucratPayment');
|
||||
for(const p of game.players.filter(p=>p.id!=='b'))assert.deepEqual(botActions(viewGame(game,p.id)),[]);
|
||||
const actions=botActions(viewGame(game,'b'));assert.deepEqual(actions,[{type:'decision',event:game.decision!.event,redirect:true}]);
|
||||
const after=applyAction(bureaucratReload(game),'b',actions[0]);
|
||||
assert.equal(after.bureaucratPayments!.used.length,1);assert.equal(after.players.find(p=>p.id==='p')!.spice,25);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Bureaucrat policy preserves allied income and ignores hidden rival wealth and cards',()=>{
|
||||
for(const bot of ['Easy','Medium','Hard','Brutal'] as const){
|
||||
let game=bureaucratPaymentGame();game.players.find(p=>p.id==='b')!.bot=bot;
|
||||
game.players.find(p=>p.id==='b')!.ally='e';game.players.find(p=>p.id==='e')!.ally='b';
|
||||
game=applyAction(game,'p',{type:'bribe',target:'e',amount:5});
|
||||
assert.ok(game.decision?.kind==='bureaucratPayment');
|
||||
const original=viewGame(game,'b'),expected=botActions(original);
|
||||
assert.deepEqual(expected,[{type:'decision',event:game.decision!.event,redirect:false}]);
|
||||
const changed=structuredClone(original);
|
||||
for(const p of changed.players.filter(p=>p.id!=='b'))Object.assign(p,{spice:9_999,hand:[{id:'hidden',name:'Unseen',kind:'worthless'}]});
|
||||
assert.deepEqual(botActions(changed),expected);
|
||||
const after=applyAction(bureaucratReload(game),'b',expected[0]);
|
||||
assert.equal(after.players.find(p=>p.id==='e')!.bribes,5);assert.equal(after.bureaucratPayments!.used.length,0);
|
||||
const stale=structuredClone(original);stale.decision={kind:'bureaucratPayment',player:'b',event:'old'};
|
||||
assert.deepEqual(botActions(stale),[]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { BureaucratPayment } from '../components/bureaucrat-payment';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function view(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
me: 'payer',
|
||||
players: [
|
||||
{ id: 'owner', name: 'House Owner' },
|
||||
{ id: 'payer', name: 'House Payer' },
|
||||
{ id: 'payee', name: 'House Payee' },
|
||||
],
|
||||
decision: {
|
||||
kind: 'bureaucratPayment',
|
||||
player: 'owner',
|
||||
event: 'bureaucrat-1',
|
||||
},
|
||||
bureaucrat: {
|
||||
pending: {
|
||||
event: 'bureaucrat-1',
|
||||
owner: 'owner',
|
||||
payer: 'payer',
|
||||
payee: 'payee',
|
||||
amount: 5,
|
||||
kind: 'auction',
|
||||
redirect: 2,
|
||||
},
|
||||
usedThisPhase: false,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(BureaucratPayment, { game, act() {}, busy }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('owned matching Bureaucrat decision shows both payment costs and public names', () => {
|
||||
const html = markup(view({ me: 'owner' }));
|
||||
assert.match(html, /payment from House Payer to House Payee is 5 spice/);
|
||||
assert.match(html, /House Payee receives 3 spice/);
|
||||
assert.match(html, /Bank receives 2/);
|
||||
assert.match(html, /still pays the full amount/);
|
||||
assert.match(html, /Redirect 2 spice to the Bank/);
|
||||
assert.match(html, /Allow full 5-spice payment/);
|
||||
});
|
||||
|
||||
void test('non-owner and stale decisions render no Bureaucrat controls', () => {
|
||||
assert.equal(markup(view()), '');
|
||||
assert.equal(
|
||||
markup(view({ me: 'owner', decision: { kind: 'bureaucratPayment', player: 'owner', event: 'old' } })),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
void test('busy Bureaucrat controls are disabled', () => {
|
||||
const html = markup(view({ me: 'owner' }), true);
|
||||
assert.ok((html.match(/disabled=""/g) ?? []).length >= 2);
|
||||
});
|
||||
|
||||
void test('bribe redirection keeps the remaining payment unavailable until Mentat', () => {
|
||||
const game = view({ me: 'owner' });
|
||||
game.bureaucrat.pending!.kind = 'bribe';
|
||||
const html = markup(game);
|
||||
assert.match(html, /recipient keeps this spice in front of their shield until the next Mentat Pause/);
|
||||
assert.match(html, /cannot be spent before then/);
|
||||
assert.match(html, /House Payee receives 3 spice/);
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
export const bureaucratReload = (g: Game): Game =>
|
||||
JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const bureaucratPlayer = (g: Game, id: string) =>
|
||||
g.players.find((p) => p.id === id)!;
|
||||
export function takeBureaucratCard(g: Game, id: string, name = 'Karama') {
|
||||
const index = g.deck.findIndex((card) => card.name === name);
|
||||
assert.ok(index >= 0);
|
||||
const [card] = g.deck.splice(index, 1);
|
||||
bureaucratPlayer(g, id).hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Genuine Leader Skills setup; conserved focused position with four or five human seats. */
|
||||
export function bureaucratPaymentGame({
|
||||
advanced = false,
|
||||
choam = false,
|
||||
phase = 5,
|
||||
advisor = false,
|
||||
}: {
|
||||
advanced?: boolean;
|
||||
choam?: boolean;
|
||||
phase?: number;
|
||||
advisor?: boolean;
|
||||
} = {}): Game {
|
||||
let g = createGame(
|
||||
'BUROTEST',
|
||||
newPlayer('b', 'Bureaucrat', 'atreides'),
|
||||
advanced,
|
||||
choam ? ['choam'] : [],
|
||||
);
|
||||
for (const [id, name, faction] of [
|
||||
['p', 'Payer', 'harkonnen'],
|
||||
['e', 'Emperor', 'emperor'],
|
||||
['g', 'Guild', 'guild'],
|
||||
...(choam ? [['r', 'Richese', 'richese']] : []),
|
||||
...(advisor ? [['bg', 'Bene Gesserit', 'beneGesserit']] : []),
|
||||
] as [string, string, Parameters<typeof newPlayer>[2]][])
|
||||
joinGame(g, newPlayer(id, name, faction));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const index = LEADER_SKILL_CARDS.findIndex(
|
||||
(card) => card.id === 'bureaucrat',
|
||||
);
|
||||
let cursor = LEADER_SKILL_CARDS.length - 1;
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = cursor-- === index ? 0 : 0xffffffff;
|
||||
return array;
|
||||
});
|
||||
try {
|
||||
g = initializeLeaderSkillsGameForAudit(g);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
for (let step = 0; g.status === 'setup' && step < 80; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const offer = g.leaderSkills!.offers[p.id];
|
||||
if (g.setupStage === 'leaderSkills' && p.id === 'b' && offer)
|
||||
assert.ok(offer.cards.includes('bureaucrat'));
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((player) => player.id === p.id)!.bot = 'Medium';
|
||||
const actions =
|
||||
g.setupStage === 'leaderSkills' && offer
|
||||
? [
|
||||
{
|
||||
type: 'leaderSkill',
|
||||
event: offer.event,
|
||||
skill: p.id === 'b' ? 'bureaucrat' : offer.cards[0],
|
||||
leader: `${p.faction}-0`,
|
||||
},
|
||||
]
|
||||
: g.setupStage === 'traitors' && p.traitorChoices.length
|
||||
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
|
||||
: botActions(view);
|
||||
for (const action of actions)
|
||||
try {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
} catch {}
|
||||
if (next) break;
|
||||
}
|
||||
assert.ok(next, `Setup stalled at ${g.setupStage}`);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand);
|
||||
Object.assign(p, {
|
||||
hand: [],
|
||||
spice: 30,
|
||||
bribes: 0,
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
shipped: false,
|
||||
moved: 0,
|
||||
advisors: {},
|
||||
});
|
||||
if (p.elites) p.elites = { forces: {}, reserves: 5, tanks: 0, revived: 0 };
|
||||
}
|
||||
Object.assign(g, {
|
||||
turn: 2,
|
||||
phase,
|
||||
storm: 18,
|
||||
active: 'p',
|
||||
ready: [],
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
order: g.players.map((p) => p.id),
|
||||
movementRemaining: g.players.map((p) => p.id),
|
||||
});
|
||||
return g;
|
||||
}
|
||||
export function beginBureaucratAuction(g: Game): Game {
|
||||
Object.assign(g, {
|
||||
phase: 2,
|
||||
ready: [],
|
||||
active: null,
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
});
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.phase, 3);
|
||||
if (g.phaseOpening)
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
export function allowBureaucratIncome(g: Game): Game {
|
||||
for (let n = 0; g.response && n < 12; n++) {
|
||||
const responder = g.players.find((p) => {
|
||||
const controls = viewGame(g, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length;
|
||||
});
|
||||
assert.ok(responder);
|
||||
g = applyAction(g, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
bureaucratPaymentGame,
|
||||
beginBureaucratAuction,
|
||||
takeBureaucratCard,
|
||||
} from './bureaucrat-payment-fixture';
|
||||
|
||||
const clock: Rooms.RoomsClock = { now: () => 81_000, sleep: async () => {} };
|
||||
const hash = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
const rows = (db: DatabaseSync) => ({
|
||||
rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(),
|
||||
seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
});
|
||||
function physical(game: Game) {
|
||||
return {
|
||||
players: game.players.map(
|
||||
({ spice: _spice, bribes: _bribes, ...player }) => player,
|
||||
),
|
||||
deck: game.deck,
|
||||
discard: game.discard,
|
||||
skills: game.leaderSkills,
|
||||
};
|
||||
}
|
||||
type PaymentKind = 'bribe' | 'shipment' | 'auction' | 'cache' | 'blackMarket';
|
||||
function paidAuction(
|
||||
kind: 'auction' | 'cache' | 'blackMarket',
|
||||
advanced: boolean,
|
||||
): Game {
|
||||
let game = bureaucratPaymentGame({ advanced, choam: kind !== 'auction' });
|
||||
const offered =
|
||||
kind === 'blackMarket' ? takeBureaucratCard(game, 'r', 'Baliset') : null;
|
||||
game = beginBureaucratAuction(game);
|
||||
if (kind === 'auction') {
|
||||
while (game.auction!.active !== 'p')
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
game = applyAction(game, 'p', { type: 'bid', amount: 5 });
|
||||
for (let i = 0; !game.currentAuctionSale && i < 12; i++)
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
} else {
|
||||
if (kind === 'cache')
|
||||
game = applyAction(game, 'r', {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
position: 'first',
|
||||
});
|
||||
game = applyAction(game, 'r', {
|
||||
type: 'decision',
|
||||
event: game.richeseBidding!.event,
|
||||
card: offered?.id ?? game.richeseCache![0].id,
|
||||
method: 'silent',
|
||||
});
|
||||
for (const id of game.richeseAuction!.order)
|
||||
game = applyAction(game, id, {
|
||||
type: 'richeseBid',
|
||||
event: game.richeseAuction!.event,
|
||||
amount: id === 'p' ? 5 : 0,
|
||||
});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
async function fixture(
|
||||
t: test.TestContext,
|
||||
kind: PaymentKind,
|
||||
advanced = false,
|
||||
) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
let game =
|
||||
kind === 'bribe' || kind === 'shipment'
|
||||
? applyAction(
|
||||
bureaucratPaymentGame({ advanced }),
|
||||
'p',
|
||||
kind === 'bribe'
|
||||
? { type: 'bribe', target: 'e', amount: 5 }
|
||||
: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 5 },
|
||||
)
|
||||
: paidAuction(kind, advanced);
|
||||
if (game.decision?.kind === 'guildShipment')
|
||||
game = applyAction(game, game.decision.player, {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
game.version = 41;
|
||||
const code = game.code,
|
||||
event = game.decision!.event!;
|
||||
store.sqlite
|
||||
.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run(code, JSON.stringify(game), game.version, 80_000);
|
||||
const tokens = Object.fromEntries(
|
||||
game.players.map((p) => [p.id, hash('bureaucrat-test:' + p.id)]),
|
||||
);
|
||||
const auths: Record<string, Rooms.SeatAuth> = {};
|
||||
for (const p of game.players) {
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
||||
)
|
||||
.run(hash(tokens[p.id]), code, p.id);
|
||||
auths[p.id] = await store.rooms.authenticate(code, tokens[p.id]);
|
||||
}
|
||||
return {
|
||||
...store,
|
||||
code,
|
||||
event,
|
||||
initial: game,
|
||||
tokens,
|
||||
auths,
|
||||
payee:
|
||||
kind === 'shipment'
|
||||
? 'g'
|
||||
: kind === 'cache' || kind === 'blackMarket'
|
||||
? 'r'
|
||||
: 'e',
|
||||
kind,
|
||||
};
|
||||
}
|
||||
async function views(f: Awaited<ReturnType<typeof fixture>>, game: Game) {
|
||||
const before = rows(f.sqlite),
|
||||
room = f.restart();
|
||||
for (const player of game.players) {
|
||||
const auth = await room.authenticate(f.code, f.tokens[player.id]);
|
||||
const view = await room.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(game, player.id));
|
||||
assert.equal('bureaucratPayments' in view, false);
|
||||
assert.equal('bureaucratUseEvents' in view, false);
|
||||
for (const rival of view.players.filter((p) => p.id !== player.id)) {
|
||||
assert.equal(rival.hand, undefined);
|
||||
assert.equal(rival.traitors, undefined);
|
||||
}
|
||||
if (view.bureaucrat.pending)
|
||||
assert.deepEqual(
|
||||
Object.keys(view.bureaucrat.pending).sort(),
|
||||
[
|
||||
'event',
|
||||
'owner',
|
||||
'payer',
|
||||
'payee',
|
||||
'amount',
|
||||
'kind',
|
||||
'redirect',
|
||||
].sort(),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
}
|
||||
|
||||
void test('saved Bureaucrat bribe and Guild choices resume once with unchanged payer cost and physical custody', async (t) => {
|
||||
for (const advanced of [false, true])
|
||||
for (const kind of ['bribe', 'shipment'] as const)
|
||||
for (const redirect of [false, true]) {
|
||||
const f = await fixture(t, kind, advanced),
|
||||
before = rows(f.sqlite),
|
||||
original = f.initial.players.find((p) => p.id === f.payee)!;
|
||||
const action = { type: 'decision', event: f.event, redirect };
|
||||
await views(f, f.initial);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths.p, 41, action));
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 40, action),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 41, {
|
||||
...action,
|
||||
event: 'stale-event',
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 41, { ...action, redirect: 'yes' }),
|
||||
);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
await f.restart().act(f.code, f.auths.b, 41, action, clock);
|
||||
const after = await f.restart().readRoom(f.code);
|
||||
assert.equal(after.version, 42);
|
||||
assert.deepEqual(physical(after), physical(f.initial));
|
||||
const recipient = after.players.find((p) => p.id === f.payee)!,
|
||||
amount = redirect ? 3 : 5;
|
||||
assert.equal(
|
||||
recipient.spice,
|
||||
original.spice + (kind === 'shipment' ? amount : 0),
|
||||
);
|
||||
assert.equal(
|
||||
recipient.bribes,
|
||||
original.bribes + (kind === 'bribe' ? amount : 0),
|
||||
);
|
||||
assert.equal(
|
||||
after.players.find((p) => p.id === 'p')!.spice,
|
||||
f.initial.players.find((p) => p.id === 'p')!.spice,
|
||||
);
|
||||
assert.equal(after.bureaucratPayments?.used.length, Number(redirect));
|
||||
assert.equal(
|
||||
after.decision?.kind,
|
||||
f.initial.bureaucratPayments!.pending!.resume.decision?.kind,
|
||||
);
|
||||
await views(f, after);
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(
|
||||
f.rooms.act(f.code, f.auths.b, 41, action),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths.b, 42, action));
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes),
|
||||
[1],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('competing Bureaucrat redirect and allowance commit one recipient share without duplicate debit', async (t) => {
|
||||
const f = await fixture(t, 'bribe');
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let arrived = 0;
|
||||
f.hooks.beforeWrite = async () => {
|
||||
arrived++;
|
||||
if (arrived === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const results = await Promise.allSettled(
|
||||
[true, false].map((redirect) =>
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.auths.b,
|
||||
41,
|
||||
{ type: 'decision', event: f.event, redirect },
|
||||
clock,
|
||||
),
|
||||
),
|
||||
);
|
||||
f.hooks.beforeWrite = undefined;
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
const after = await f.restart().readRoom(f.code);
|
||||
assert.equal(after.version, 42);
|
||||
const amount = after.players.find((p) => p.id === 'e')!.bribes;
|
||||
assert.ok(amount === 3 || amount === 5);
|
||||
assert.equal(after.bureaucratPayments!.used.length, amount === 3 ? 1 : 0);
|
||||
assert.deepEqual(physical(after), physical(f.initial));
|
||||
assert.equal(after.players.find((p) => p.id === 'p')!.spice, 25);
|
||||
await views(f, after);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
function auctionInventory(game: Game) {
|
||||
return [
|
||||
...game.deck,
|
||||
...game.discard,
|
||||
...game.players.flatMap((player) => player.hand),
|
||||
...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(
|
||||
game.auction.index +
|
||||
(game.currentAuctionSale?.origin === 'normal' ? 1 : 0),
|
||||
) ?? []),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
void test('saved normal, Richese cache and Black Market payments resume card custody and auction callbacks once after SQLite restart', async (t) => {
|
||||
for (const kind of ['auction', 'cache', 'blackMarket'] as const) {
|
||||
const redirect = kind !== 'blackMarket',
|
||||
f = await fixture(t, kind, kind === 'blackMarket');
|
||||
const source = f.initial.currentAuctionSale!;
|
||||
const purchased =
|
||||
kind === 'auction'
|
||||
? f.initial.auction!.cards[f.initial.auction!.index].id
|
||||
: f.initial.richeseAuction!.cardId;
|
||||
const inventory = auctionInventory(f.initial),
|
||||
payer = f.initial.players.find((player) => player.id === 'p')!;
|
||||
assert.equal(source.winner, 'p');
|
||||
assert.equal(source.amount, 5);
|
||||
assert.equal(payer.spice, 25);
|
||||
assert.equal(payer.hand.filter((card) => card.id === purchased).length, 1);
|
||||
assert.equal(payer.hand.length, 1);
|
||||
assert.equal(
|
||||
f.initial.bureaucratPayments!.pending!.continuation.kind,
|
||||
kind === 'auction' ? 'response' : 'auction',
|
||||
);
|
||||
await views(f, f.initial);
|
||||
if (kind === 'blackMarket')
|
||||
assert.ok(!JSON.stringify(viewGame(f.initial, 'g')).includes(purchased));
|
||||
const action = { type: 'decision', event: f.event, redirect },
|
||||
before = rows(f.sqlite);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths.p, 41, action));
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
const restarted = f.restart(),
|
||||
auth = await restarted.authenticate(f.code, f.tokens.b);
|
||||
await restarted.act(f.code, auth, 41, action, clock);
|
||||
const after = await f.restart().readRoom(f.code);
|
||||
assert.equal(after.version, 42);
|
||||
assert.equal(after.currentAuctionSale, null);
|
||||
assert.equal(after.bureaucratPayments!.pending, undefined);
|
||||
assert.deepEqual(auctionInventory(after), inventory);
|
||||
for (const initial of f.initial.players) {
|
||||
const actual = after.players.find((player) => player.id === initial.id)!;
|
||||
assert.equal(
|
||||
actual.spice,
|
||||
initial.spice + (actual.id === f.payee ? (redirect ? 3 : 5) : 0),
|
||||
);
|
||||
const { spice: _initialSpice, hand: _initialHand, ...original } = initial;
|
||||
const { spice: _actualSpice, hand: _actualHand, ...current } = actual;
|
||||
assert.deepEqual(current, original);
|
||||
}
|
||||
const winner = after.players.find((player) => player.id === 'p')!;
|
||||
assert.equal(winner.hand.length, 2);
|
||||
assert.equal(winner.hand.filter((card) => card.id === purchased).length, 1);
|
||||
assert.equal(
|
||||
after.log.filter((entry) =>
|
||||
entry.text.includes('drew one bonus treachery card'),
|
||||
).length,
|
||||
f.initial.log.filter((entry) =>
|
||||
entry.text.includes('drew one bonus treachery card'),
|
||||
).length + 1,
|
||||
);
|
||||
assert.equal(after.bureaucratPayments!.used.length, Number(redirect));
|
||||
if (kind === 'auction') assert.equal(after.auction!.index, 1);
|
||||
else if (kind === 'cache') {
|
||||
assert.equal(after.richeseAuction, null);
|
||||
assert.equal(after.auction!.index, 0);
|
||||
} else {
|
||||
assert.equal(after.richeseAuction, null);
|
||||
assert.equal(after.decision?.kind, 'richeseDeclaration');
|
||||
}
|
||||
await views(f, after);
|
||||
if (kind === 'blackMarket')
|
||||
assert.ok(!JSON.stringify(viewGame(after, 'g')).includes(purchased));
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths.b, 41, action),
|
||||
/table changed/,
|
||||
);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths.b, 42, action));
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
assert.deepEqual(
|
||||
f.writes.map((write) => write.changes),
|
||||
[1],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
bureaucratPaymentModeSupported,
|
||||
bureaucratPaymentSignature,
|
||||
bureaucratUsed,
|
||||
quoteBureaucratPayment,
|
||||
} from '../game/bureaucrat-payment';
|
||||
import {
|
||||
bureaucratPaymentGame,
|
||||
bureaucratPlayer,
|
||||
bureaucratReload,
|
||||
beginBureaucratAuction,
|
||||
takeBureaucratCard,
|
||||
allowBureaucratIncome,
|
||||
} from './bureaucrat-payment-fixture';
|
||||
|
||||
const ship: Action = {
|
||||
type: 'ship',
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
amount: 5,
|
||||
};
|
||||
const bribe: Action = { type: 'bribe', target: 'e', amount: 5 };
|
||||
function choose(g: Game, redirect = true): Game {
|
||||
return applyAction(g, 'b', {
|
||||
type: 'decision',
|
||||
event: g.bureaucratPaymentEvent,
|
||||
redirect,
|
||||
});
|
||||
}
|
||||
function reject(g: Game, id: string, action: Action, pattern?: RegExp) {
|
||||
const before = JSON.stringify(g);
|
||||
if (pattern) assert.throws(() => applyAction(g, id, action), pattern);
|
||||
else assert.throws(() => applyAction(g, id, action));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
function cards(g: Game) {
|
||||
return [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...(g.richeseCache ?? []),
|
||||
...(g.richeseRemoved ?? []),
|
||||
...(g.auction?.cards.slice(
|
||||
g.auction.index + (g.currentAuctionSale?.origin === 'normal' ? 1 : 0),
|
||||
) ?? []),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
}
|
||||
function normalSale(g: Game, amount = 5): Game {
|
||||
while (g.auction?.active !== 'p')
|
||||
g = applyAction(g, g.auction!.active, { type: 'passBid' });
|
||||
g = applyAction(g, 'p', { type: 'bid', amount });
|
||||
for (let i = 0; g.auction && !g.currentAuctionSale && i < 12; i++)
|
||||
g = applyAction(g, g.auction.active, { type: 'passBid' });
|
||||
return g;
|
||||
}
|
||||
function richeseSale(source: 'cache' | 'blackMarket', advanced = false) {
|
||||
let g = bureaucratPaymentGame({ choam: true, advanced });
|
||||
const saleCard =
|
||||
source === 'blackMarket'
|
||||
? takeBureaucratCard(g, 'r', 'Baliset')
|
||||
: g.richeseCache![0];
|
||||
g = beginBureaucratAuction(g);
|
||||
if (source === 'cache')
|
||||
g = applyAction(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
position: 'first',
|
||||
});
|
||||
g = applyAction(g, 'r', {
|
||||
type: 'decision',
|
||||
event: g.richeseBidding!.event,
|
||||
card: saleCard.id,
|
||||
method: 'silent',
|
||||
});
|
||||
for (const id of g.richeseAuction!.order)
|
||||
g = applyAction(g, id, {
|
||||
type: 'richeseBid',
|
||||
event: g.richeseAuction!.event,
|
||||
amount: id === 'p' ? 5 : 0,
|
||||
});
|
||||
return { g, saleCard };
|
||||
}
|
||||
|
||||
void test('Bureaucrat requires two other players and tracks the unique physical skill once per phase, independent of later owner', () => {
|
||||
const input = {
|
||||
owner: 'b',
|
||||
payer: 'p',
|
||||
payee: 'g',
|
||||
amount: 5,
|
||||
turn: 2,
|
||||
phase: 5,
|
||||
used: [],
|
||||
};
|
||||
assert.deepEqual(quoteBureaucratPayment(input), { redirect: 2, income: 3 });
|
||||
for (const patch of [
|
||||
{ owner: null },
|
||||
{ payer: 'b' },
|
||||
{ payee: 'b' },
|
||||
{ payer: 'g' },
|
||||
{ amount: 4 },
|
||||
])
|
||||
assert.equal(quoteBureaucratPayment({ ...input, ...patch }), null);
|
||||
const used = [
|
||||
{ event: 'spent', owner: 'former', turn: 2, phase: 5, signature: '' },
|
||||
];
|
||||
assert.equal(quoteBureaucratPayment({ ...input, owner: 'new', used }), null);
|
||||
assert.equal(bureaucratUsed(used, 2, 6), false);
|
||||
assert.ok(
|
||||
!bureaucratPaymentModeSupported({ expansions: ['choam'], homeworlds: {} }),
|
||||
);
|
||||
});
|
||||
for (const advanced of [false, true])
|
||||
for (const choam of [false, true])
|
||||
void test(`single-payer ${choam ? 'CHOAM' : 'base'} ${advanced ? 'Advanced' : 'Basic'} shipment diverts two after payment without replaying arrival`, () => {
|
||||
const start = bureaucratPaymentGame({ advanced, choam }),
|
||||
inventory = cards(start);
|
||||
let pending = applyAction(start, 'p', ship);
|
||||
if (pending.decision?.kind === 'guildShipment')
|
||||
pending = applyAction(pending, 'g', { type: 'decision', allow: true });
|
||||
assert.equal(bureaucratPlayer(pending, 'p').spice, 25);
|
||||
assert.equal(bureaucratPlayer(pending, 'g').spice, 30);
|
||||
assert.equal(bureaucratPlayer(pending, 'p').forces['arrakeen:10'], 5);
|
||||
assert.equal(pending.response, null);
|
||||
assert.equal(
|
||||
pending.bureaucratPayments!.pending!.resume.response!.kind,
|
||||
'guildIncome',
|
||||
);
|
||||
const original = structuredClone(bureaucratPlayer(pending, 'p'));
|
||||
const done = choose(bureaucratReload(pending));
|
||||
assert.deepEqual(bureaucratPlayer(done, 'p'), original);
|
||||
assert.equal(bureaucratPlayer(done, 'g').spice, 33);
|
||||
assert.deepEqual(cards(done), inventory);
|
||||
assert.equal(viewGame(done, 'b').bureaucrat.usedThisPhase, true);
|
||||
assert.deepEqual(normalizeAutomaticGame(bureaucratReload(done)), done);
|
||||
reject(done, 'b', {
|
||||
type: 'decision',
|
||||
event: pending.bureaucratPaymentEvent,
|
||||
redirect: true,
|
||||
});
|
||||
});
|
||||
void test('bribe diversion reduces only recipient escrow and never runs again on Mentat collection', () => {
|
||||
let g = applyAction(bureaucratPaymentGame(), 'p', bribe);
|
||||
assert.equal(bureaucratPlayer(g, 'p').spice, 25);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 0);
|
||||
g = choose(g);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 3);
|
||||
assert.equal(bureaucratPlayer(g, 'e').spice, 30);
|
||||
const uses = structuredClone(g.bureaucratPayments!.used);
|
||||
// Conserved phase staging reaches the actual collection callback.
|
||||
Object.assign(g, { phase: 7, active: null, ready: [], phaseOpening: null });
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.phase, 8);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 0);
|
||||
assert.equal(bureaucratPlayer(g, 'e').spice, 33);
|
||||
assert.deepEqual(g.bureaucratPayments!.used, uses);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending, null);
|
||||
});
|
||||
void test('decline keeps the next opportunity, while a consumed card cannot gain another use after a saved controller/assignment change', () => {
|
||||
let g = applyAction(bureaucratPaymentGame(), 'p', bribe);
|
||||
g = choose(g, false);
|
||||
assert.equal(bureaucratPlayer(g, 'e').bribes, 5);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.usedThisPhase, false);
|
||||
g = applyAction(g, 'p', bribe);
|
||||
assert.equal(g.decision?.kind, 'bureaucratPayment');
|
||||
g = choose(g);
|
||||
const assignments = g.leaderSkills!.assignments,
|
||||
old = assignments.find((a) => a.owner === 'b')!,
|
||||
next = assignments.find((a) => a.owner === 'p')!;
|
||||
// A conserved saved redraw/custody state still refers to the same physical card.
|
||||
[old.skill, next.skill] = [next.skill, old.skill];
|
||||
g = applyAction(bureaucratReload(g), 'e', {
|
||||
type: 'bribe',
|
||||
target: 'g',
|
||||
amount: 5,
|
||||
});
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(g.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(bureaucratPlayer(g, 'g').bribes, 5);
|
||||
});
|
||||
void test('native income cancellation sends the whole payment to the bank without a Bureaucrat opportunity or use', () => {
|
||||
for (const kind of ['shipment', 'auction'] as const) {
|
||||
let g = bureaucratPaymentGame();
|
||||
const karama = takeBureaucratCard(g, 'b');
|
||||
g =
|
||||
kind === 'shipment'
|
||||
? applyAction(g, 'p', ship)
|
||||
: normalSale(beginBureaucratAuction(g));
|
||||
assert.equal(
|
||||
g.response?.kind,
|
||||
kind === 'shipment' ? 'guildIncome' : 'emperorIncome',
|
||||
);
|
||||
assert.equal(g.bureaucratPayments?.pending, undefined);
|
||||
const payer = bureaucratPlayer(g, 'p').spice;
|
||||
g = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: karama.id });
|
||||
assert.equal(bureaucratPlayer(g, 'p').spice, payer);
|
||||
assert.equal(
|
||||
bureaucratPlayer(g, kind === 'shipment' ? 'g' : 'e').spice,
|
||||
30,
|
||||
);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending, null);
|
||||
assert.equal(g.bureaucratPayments?.used.length ?? 0, 0);
|
||||
}
|
||||
});
|
||||
void test('normal auction resumes Emperor credit, Harkonnen bonus and the next lot once after Bureaucrat', () => {
|
||||
const start = beginBureaucratAuction(bureaucratPaymentGame()),
|
||||
inventory = cards(start),
|
||||
card = start.auction!.cards[0].id;
|
||||
const pending = normalSale(start);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(bureaucratPlayer(pending, 'p').spice, 25);
|
||||
assert.equal(bureaucratPlayer(pending, 'p').hand.length, 1);
|
||||
const done = choose(pending);
|
||||
assert.equal(bureaucratPlayer(done, 'e').spice, 33);
|
||||
assert.equal(bureaucratPlayer(done, 'p').hand.length, 2);
|
||||
assert.ok(bureaucratPlayer(done, 'p').hand.some((c) => c.id === card));
|
||||
assert.deepEqual(cards(done), inventory);
|
||||
assert.equal(done.auction!.index, 1);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
});
|
||||
void test('Richese cache and concealed Black Market seller credits preserve their original physical sale and next callback', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const)
|
||||
for (const advanced of source === 'cache' ? [false, true] : [true]) {
|
||||
const { g, saleCard } = richeseSale(source, advanced),
|
||||
inventory = cards(g);
|
||||
assert.equal(g.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(bureaucratPlayer(g, 'r').spice, 30);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending!.kind, 'auction');
|
||||
// The native Atreides Bureaucrat has its independently earned auction peek.
|
||||
if (source === 'blackMarket')
|
||||
assert.ok(!JSON.stringify(viewGame(g, 'g')).includes(saleCard.id));
|
||||
const done = choose(bureaucratReload(g));
|
||||
assert.equal(bureaucratPlayer(done, 'r').spice, 33);
|
||||
assert.equal(bureaucratPlayer(done, 'e').spice, 30);
|
||||
assert.equal(
|
||||
bureaucratPlayer(done, 'p').hand.filter((c) => c.id === saleCard.id)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(cards(done), inventory);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
assert.equal(
|
||||
done.decision?.kind,
|
||||
source === 'blackMarket' ? 'richeseDeclaration' : undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('a real simultaneous Bene Gesserit advisor choice survives the deferred Guild payment', () => {
|
||||
const pending = applyAction(
|
||||
bureaucratPaymentGame({ advisor: true }),
|
||||
'p',
|
||||
ship,
|
||||
);
|
||||
const original = structuredClone(
|
||||
pending.bureaucratPayments!.pending!.resume.decision,
|
||||
);
|
||||
assert.equal(original!.kind, 'advisor');
|
||||
const done = choose(bureaucratReload(pending));
|
||||
assert.deepEqual(done.decision, original);
|
||||
assert.equal(bureaucratPlayer(done, 'g').spice, 33);
|
||||
});
|
||||
void test('aggregate 3+3 and fully allied-funded qualifying payments reject before cost, force/card commitment or RNG', () => {
|
||||
for (const allyPayment of [1, 3, 6]) {
|
||||
let g = bureaucratPaymentGame();
|
||||
bureaucratPlayer(g, 'p').ally = 'e';
|
||||
bureaucratPlayer(g, 'e').ally = 'p';
|
||||
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 6 });
|
||||
mock.method(globalThis.crypto, 'randomUUID', () => {
|
||||
throw new Error('Unexpected event before payment preflight');
|
||||
});
|
||||
try {
|
||||
reject(
|
||||
g,
|
||||
'p',
|
||||
{ ...ship, amount: 6, allyPayment },
|
||||
/private payment-split/,
|
||||
);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
g = bureaucratPaymentGame();
|
||||
bureaucratPlayer(g, 'p').ally = 'g';
|
||||
bureaucratPlayer(g, 'g').ally = 'p';
|
||||
g = beginBureaucratAuction(g);
|
||||
g = applyAction(g, 'g', { type: 'pledgeAid', amount: 6 });
|
||||
g = applyAction(g, 'b', { type: 'passBid' });
|
||||
reject(
|
||||
g,
|
||||
'p',
|
||||
{ type: 'bid', amount: 6, allyPayment },
|
||||
/private payment-split/,
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('saved Bureaucrat receipt, source, unique decision, continuation and phase-use mutations reject immutably before reads', () => {
|
||||
const pending = applyAction(bureaucratPaymentGame(), 'p', ship),
|
||||
done = choose(pending);
|
||||
const cases: [Game, (g: Game) => void][] = [
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.decision = null;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
delete g.bureaucratPaymentEvent;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
delete g.bureaucratPayments!.pending;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.pending!.source.amount = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.pending!.resume.response!.amount = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
bureaucratPlayer(g, 'p').spice++;
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.pending!.leader = 'wrong';
|
||||
},
|
||||
],
|
||||
[
|
||||
pending,
|
||||
(g) => {
|
||||
g.pendingExchange = { response: null, decision: g.decision };
|
||||
},
|
||||
],
|
||||
[
|
||||
done,
|
||||
(g) => {
|
||||
g.bureaucratPayments!.used[0].phase = 6;
|
||||
},
|
||||
],
|
||||
[
|
||||
done,
|
||||
(g) => {
|
||||
g.bureaucratUseEvents = [];
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [source, mutate] of cases) {
|
||||
const g = bureaucratReload(source);
|
||||
mutate(g);
|
||||
const before = JSON.stringify(g);
|
||||
for (const operation of [
|
||||
() => viewGame(g, 'b'),
|
||||
() => normalizeAutomaticGame(g),
|
||||
() => applyAction(g, 'b', { type: 'advanceBots' }),
|
||||
]) {
|
||||
assert.throws(operation);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
}
|
||||
reject(pending, 'p', {
|
||||
type: 'decision',
|
||||
event: pending.bureaucratPaymentEvent,
|
||||
redirect: true,
|
||||
});
|
||||
reject(pending, 'b', { type: 'decision', event: 'stale', redirect: true });
|
||||
});
|
||||
void test('legacy unstamped income finishes unchanged; active source evidence stays out of private seat projections', () => {
|
||||
let g = bureaucratPaymentGame();
|
||||
takeBureaucratCard(g, 'b');
|
||||
g = applyAction(g, 'p', ship);
|
||||
for (const p of g.players) {
|
||||
const response = viewGame(g, p.id).response!;
|
||||
assert.equal(response.bureaucratPayment, undefined);
|
||||
assert.equal(response.bureaucratPaymentEvent, undefined);
|
||||
}
|
||||
delete g.response!.bureaucratPayment;
|
||||
delete g.response!.bureaucratPaymentEvent;
|
||||
g = allowBureaucratIncome(bureaucratReload(g));
|
||||
assert.equal(bureaucratPlayer(g, 'g').spice, 35);
|
||||
assert.equal(g.bureaucratPayments?.pending, undefined);
|
||||
assert.equal(g.bureaucratPayments?.used.length ?? 0, 0);
|
||||
});
|
||||
|
||||
void test('independent Karama sends every allied shipment share to Bank without a Bureaucrat split guard or opportunity', () => {
|
||||
let g = bureaucratPaymentGame();
|
||||
bureaucratPlayer(g, 'p').ally = 'e';
|
||||
bureaucratPlayer(g, 'e').ally = 'p';
|
||||
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 5 });
|
||||
const karama = takeBureaucratCard(g, 'b');
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'card',
|
||||
mode: 'shipment',
|
||||
target: 'p',
|
||||
card: karama.id,
|
||||
});
|
||||
const before = cards(g);
|
||||
g = applyAction(g, 'p', { ...ship, amount: 12, allyPayment: 3 });
|
||||
assert.equal(bureaucratPlayer(g, 'p').spice, 27);
|
||||
assert.equal(g.aid.e.amount, 2);
|
||||
assert.equal(bureaucratPlayer(g, 'g').spice, 30);
|
||||
assert.equal(viewGame(g, 'b').bureaucrat.pending, null);
|
||||
assert.equal(bureaucratPlayer(g, 'p').forces['arrakeen:10'], 12);
|
||||
assert.deepEqual(cards(g), before);
|
||||
});
|
||||
|
||||
void test('captured, dead and genuinely concealed native Bureaucrats cannot redirect another payment', () => {
|
||||
for (const state of ['captured', 'dead', 'hidden', 'faceUp'] as const) {
|
||||
let g = bureaucratPaymentGame();
|
||||
if (state === 'captured')
|
||||
bureaucratPlayer(g, 'b').leaders[0].capturedBy = 'p';
|
||||
else if (state === 'dead') {
|
||||
bureaucratPlayer(g, 'b').leaders[0].dead = true;
|
||||
bureaucratPlayer(g, 'b').leaders[0].deaths = 1;
|
||||
g.leaderSkills!.assignments = g.leaderSkills!.assignments.filter(
|
||||
(a) => a.owner !== 'b',
|
||||
);
|
||||
g.leaderSkills!.deck.push('bureaucrat');
|
||||
} else {
|
||||
Object.assign(g, { phase: 6, active: 'b' });
|
||||
for (const id of ['b', 'p']) {
|
||||
bureaucratPlayer(g, id).forces = { 'arrakeen:10': 3 };
|
||||
bureaucratPlayer(g, id).reserves = 17;
|
||||
}
|
||||
g = applyAction(g, 'b', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'arrakeen',
|
||||
target: 'p',
|
||||
});
|
||||
while (g.decision?.kind === 'leaderSkillVisibility') {
|
||||
const decision = g.decision;
|
||||
g = applyAction(g, decision.player, {
|
||||
type: 'leaderSkillVisibility',
|
||||
event: decision.event,
|
||||
hide: decision.player === 'b' && state === 'hidden',
|
||||
});
|
||||
}
|
||||
while (g.battle?.preparation)
|
||||
g = applyAction(g, g.battle.preparation.owner, {
|
||||
type: 'declineBattlePower',
|
||||
});
|
||||
}
|
||||
g = applyAction(g, 'e', { type: 'bribe', target: 'g', amount: 5 });
|
||||
assert.equal(
|
||||
g.decision?.kind,
|
||||
state === 'faceUp' ? 'bureaucratPayment' : undefined,
|
||||
);
|
||||
assert.equal(bureaucratPlayer(g, 'g').bribes, state === 'faceUp' ? 0 : 5);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a later phase grants one new use, and duplicated source events cannot pad the independent physical-card history', () => {
|
||||
let g = choose(applyAction(bureaucratPaymentGame(), 'p', bribe));
|
||||
g.phase = 6;
|
||||
g = choose(applyAction(g, 'p', bribe));
|
||||
assert.equal(g.bureaucratPayments!.used.length, 2);
|
||||
const broken = bureaucratReload(g);
|
||||
broken.bureaucratPayments!.used[1].event =
|
||||
broken.bureaucratPayments!.used[0].event;
|
||||
broken.bureaucratPayments!.used[1].signature = bureaucratPaymentSignature(
|
||||
broken.bureaucratPayments!.used[1],
|
||||
);
|
||||
assert.throws(() => viewGame(broken, 'b'));
|
||||
assert.throws(() => normalizeAutomaticGame(broken));
|
||||
});
|
||||
Reference in new issue
Block a user