Compose native Richese skills and Advanced contribution receipts
This commit is contained in:
1 parent
10d74f8db8
commit
032b41aa87
23 files changed
+1132
-111
No files matched your search
@@ -16,6 +16,36 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Native Richese Skills and contribution recipients — bounded Development Verified:**
|
||||
Basic/Advanced fresh native Richese retains its required CHOAM deck, separate
|
||||
ten-card cache, original setup and all fourteen skills. Canonical green
|
||||
Richese Specials can supply Planetologist strength without their native effect;
|
||||
marker-only force loss feeds actual capped Suk rescue. Ordinary allied
|
||||
declarations and own Smuggler companions retain their separate physical rules.
|
||||
Advanced Richese ally contributions now go to Emperor/bank, separately from
|
||||
buyer-to-Richese credit; Basic keeps its publisher receipt. Normal Banker
|
||||
uses only final bank legs and cannot spend deferred spice before native Mentat.
|
||||
Final affected rule/CLI union151/151, types/lint/build and eleven controlled programs pass.
|
||||
The genuine29-game batch is **not green**:28 finish11,788 accepted actions,
|
||||
303 JSON continuations and130 battles; six-seat Advanced seed20261267 stops
|
||||
after1,523 actions at the retained exhausted-cache count ruling guard.
|
||||
The captured state/report remain intact; selected two/three-seat follow-ups
|
||||
finish without rejection after native marker eligibility integration.
|
||||
Original CLI **FXK6Z8BW v6** retains setup. Human390px **358WLPYA v16**
|
||||
uses original admitted identities followed by a conserved zero-marker battle:
|
||||
Ein5/Residual Poison/+2 wins7–7, without its native leader-kill effect or any
|
||||
physical marker casualty. Original cleanup/refresh reaches Collection with
|
||||
reserves20/Tanks0. Human **E2K2U5ZW v26** uses real funded cache controls:
|
||||
buyer4/Richese4 buys one Ornithopter for8, Richese earns buyer4 and donor4 goes
|
||||
to bank. Guild Banker earns one unspendable front-shield spice, retains it
|
||||
through original Revival/movement/Collection, and collects once at Mentat
|
||||
(wallet6→7); refresh keeps the empty pile. The detailed guide is readable at
|
||||
390px (351px topic). See [native scope and evidence](LEADER_SKILLS_RUNTIME.md#native-richese-composition--4-october-2026)
|
||||
and [Advanced recipient authority](RICHESE_AUCTION_RULES.md#authorized-advanced-contribution-recipients--4-october-2026).
|
||||
Mixed own No-Field, exhausted-cache arithmetic, special-lot Technology,
|
||||
positive Black Market self-bidding and other existing rulings remain gated.
|
||||
No full assurance, calibration, public activation or deployed evidence.
|
||||
|
||||
**Advanced Moritani Skills — bounded Development Verified:**
|
||||
all fourteen skills now compose with the original native/classic non-Harkonnen
|
||||
assassination preview, exact `ecaz` deck and five native assignment discs.
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 4 October 2026 — native Richese skills and Advanced contribution recipients
|
||||
|
||||
Two bounded owners supplied canonical Richese green role substitution and
|
||||
original auction-recipient quotes/tests; one integration owner composed native
|
||||
profiles, engine receipts, force paths, shared fixtures, CLI and current guidance.
|
||||
Basic/Advanced Richese retains original CHOAM-family cards, ten-cache custody,
|
||||
setup and fourteen skills. Native selected decks keep their explicit limits.
|
||||
|
||||
Canonical printed green Richese Specials add Planetologist strength without
|
||||
executing their native fee/search/leader-kill effect; weapon-special Stone/Mirror
|
||||
and Snooper defense remain excluded. Marker-only0/3/5 casualties use the actual
|
||||
reserve-capped pool, then original Suk rescue. Normal Smuggler's own companion
|
||||
is one separate physical force, not concealed value. Actual selected-deck games
|
||||
exposed the old single-deck marker admission; the path now shares the native
|
||||
profile. Ordinary allied declaration cancellation retains its original frame,
|
||||
without granting an allied free companion. Original preleader acknowledgments
|
||||
are preserved rather than letting fixture AI consume Residual Poison early.
|
||||
|
||||
Authorized Advanced Richese Bidding routes its funded ally contribution to
|
||||
Emperor/bank, even when Emperor buys. The original scalar paid receipt keeps
|
||||
buyer-to-Richese credit separate; cancellation redirects only Emperor's share,
|
||||
never donor escrow twice or physical delivery twice. Basic retains the R2
|
||||
publisher receipt. Native Banker eligibility uses actual final bank payer legs,
|
||||
not seller/allowed Emperor income or aggregated small payers. Deferred currency
|
||||
remains unavailable until the original Mentat opening.
|
||||
|
||||
Final affected rule/CLI union151/151, types/lint/build and eleven actual controlled programs
|
||||
pass: Basic Nullentropy/Advanced Residual Poison substitution, manual/four-policy
|
||||
capped Suk rescue, and original bank/Emperor/cancellation/Emperor-buyer paths.
|
||||
Stable new CLI scenario ordinals254–287 preserve earlier sample assignments.
|
||||
The genuine29-table batch is **not green**:28 complete11,788 accepted actions,
|
||||
303 JSON continuations and130 battles; Advanced six-seat seed20261267 is
|
||||
captured after1,523 accepted actions at phase3/richeseBlackMarket. Its exhausted
|
||||
cache reaches the retained ordinary-count ruling guard. Report/state/trace
|
||||
remain outside the checkout; no rerun, artificial card, reset or forced payout.
|
||||
Separate two/three-seat selected-deck follow-ups finish without rejection.
|
||||
|
||||
Original CLI **FXK6Z8BW v6** retains native setup. Human390px **358WLPYA v16**
|
||||
keeps admitted seats before a conserved controlled zero-marker battle. Actual
|
||||
preleader readiness, skill concealment and sealed plans use Ein Calimar5,
|
||||
Residual Poison as green and dial0; +2 wins the original aggressor tie7–7.
|
||||
Normal cleanup discards the card without its native leader kill. Collection
|
||||
after refresh retains reserves20/Tanks0 and no phantom marker forces.
|
||||
|
||||
Human **E2K2U5ZW v26** uses actual cache funding controls and the real silent
|
||||
lot: buyer pays4 and Richese supplies4 for one eight-spice Ornithopter. The
|
||||
buyer wallet goes5→1 and acquires exactly that card; seller Richese is5 after
|
||||
its own4 debit plus only buyer4 credit. Absent Emperor, donor4 goes to bank,
|
||||
earning Guild Banker one deferred spice while available wallet stays5.
|
||||
Original normal lots pass, native Revival completes, Guild timing is allowed
|
||||
through actual responses, original shipment turns finish, and the ally declines
|
||||
the separate No-Field opportunity. Collection wallet6 still has front-shield1;
|
||||
actual final readiness opens Mentat, collects once6→7, and refresh retains
|
||||
v26/Mentat/wallet7/empty pile. Fresh accessibility and screenshots show the
|
||||
unavailable-income notice and real controls. The detailed current guide is
|
||||
readable at390px (351px topic), with exact green disposal and recipient limits.
|
||||
|
||||
Own mixed No-Field, exhausted-cache count, special-lot Technology, positive
|
||||
Black Market self-bidding, Bureaucrat split funding and other pending rulings
|
||||
remain intact. No full-mode, assurance, calibration or deployed claim; live
|
||||
**ab5c782** remains last observed.
|
||||
|
||||
## 4 October 2026 — Advanced Moritani skills and winner-skill-first assassination
|
||||
|
||||
Two bounded test/fixture owners extended genuine native setup/Terror/physical
|
||||
|
||||
@@ -28,6 +28,58 @@ activation still awaits the uniform-response UX decision; empty hands and
|
||||
Advanced native/combined-module skills remain guarded. See
|
||||
[native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026).
|
||||
|
||||
## Native Richese composition — 4 October 2026
|
||||
|
||||
Fresh local `leader-skills` now composes original native Richese setup and all
|
||||
fourteen skills in Basic or Advanced with the required `choam` family, its
|
||||
separate ten-card cache, classic opponents and optional native CHOAM. Advanced
|
||||
Ix/Tleilaxu/CHOAM/Richese families keep distinct required decks and their current
|
||||
rule boundaries. Extra decks do not manufacture factions; native Ixian Technology
|
||||
on Richese lots remains guarded. Ordinary forces and marker-only0/3/5 battles
|
||||
retain original private pools, both-plan reveal and physically capped casualties.
|
||||
Suk rescues only those actual casualties. Original own mixed No-Field and
|
||||
whole-plan disclosure guards remain; voluntary reveal must precede Battle.
|
||||
|
||||
Canonical printed Richese `Special` and `Special - Movement` cards can substitute
|
||||
for a selected Planetologist's weapon. Exact physical id/name/kind/effect remain
|
||||
required; Stone/Mirror weapons and Portable Snooper defense do not qualify.
|
||||
The surviving trainer gains2; the green card is disposed once without its native
|
||||
effect, fee or search. Original preleader acknowledgments stay separate controls.
|
||||
Normal Smuggler's own companion remains one physical force beside the priced
|
||||
marker; ordinary allied shipments retain their separate native cancellation
|
||||
and Guild continuations without inventing an allied free companion.
|
||||
|
||||
[Advanced contribution recipients](RICHESE_AUCTION_RULES.md#authorized-advanced-contribution-recipients--4-october-2026)
|
||||
now preserve original buyer-to-Richese versus Richese-to-Emperor/bank payer
|
||||
shares. Basic keeps the publisher's earlier receipt. Separate normal Banker
|
||||
counts only actual bank legs and collects at real Mentat; allowed Emperor
|
||||
income is not bank income. Fourteen-card custody, private entitlements and
|
||||
pending Bureaucrat split, earned-custody and ordinary Mentat questions remain.
|
||||
|
||||
Final affected rule/CLI union151/151 and types/lint/build pass.
|
||||
Eleven actual controlled cases cover inert Basic Box/Advanced Residual Poison
|
||||
substitution, five capped-marker manual/policy rescues and four original
|
||||
bank/Emperor/cancellation/buyer paths. The original29-table batch completes28
|
||||
with11,788 accepted actions,303 JSON continuations and130 battles, but is
|
||||
**not green**: six-seat Advanced seed20261267 is captured after1,523 accepted
|
||||
actions at the existing exhausted-cache ruling guard. Native selected-deck
|
||||
marker eligibility now shares the profile; separate two/three-seat follow-ups
|
||||
finish without rejection. The captured game is not rerun or bypassed.
|
||||
|
||||
Human390px **358WLPYA v16** retains original admitted seats followed by a
|
||||
conserved controlled zero-marker battle. Ein Calimar5/Residual Poison/+2
|
||||
Planetologist/dial0 wins7–7 on the original aggressor tie. The green card is
|
||||
discarded through normal cleanup without killing an opposing leader or
|
||||
creating a physical force; reserves20/Tanks0 remain at Collection after refresh.
|
||||
Human **E2K2U5ZW v26** uses actual allied funding controls: original buyer4 plus
|
||||
Richese4 buys one Ornithopter for8, Richese receives only buyer4, and absent
|
||||
Emperor routes donor4 to bank. Guild Banker earns one unavailable front-shield
|
||||
spice; real native Revival, movement and Collection retain that pile. Native
|
||||
Mentat collects it once (wallet6→7), and refresh retains an empty pile.
|
||||
The detailed green/recipient guide is visually readable at390px (351px topic).
|
||||
Original CLI **FXK6Z8BW v6** retains native setup. No full assurance,
|
||||
strategic calibration, public activation or deployed evidence.
|
||||
|
||||
## Advanced Moritani assassination composition — 4 October 2026
|
||||
|
||||
The [bounded native Moritani contract](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
|
||||
|
||||
@@ -179,13 +179,15 @@ a combined advisor-flip/cancellation continuation and remain explicitly guarded;
|
||||
this is an implementation gap, not a rule that forbids every mixed move. No source
|
||||
was found granting a chosen group order that makes fighters arrive as advisors.
|
||||
|
||||
The current runtime supports only the six base factions and the nine base green
|
||||
Special copies above. Expansion rosters, other optional modules, No-Field and
|
||||
Discovery/Richese flight combinations remain gated. Before enabling Ix, calculate
|
||||
range for each selected origin's own cyborgs and extend its cancellation trigger
|
||||
for Planetologist's added range. Aggregate elite counts must not lend one group's
|
||||
cyborg speed to a separate suboid-only origin. The expansion green-Special inventory
|
||||
must also be audited before admitting those physical cards.
|
||||
The runtime now follows the [shared native skill profiles](LEADER_SKILLS_RUNTIME.md),
|
||||
including per-origin Ixian cyborg range and its independent cancellation window.
|
||||
Canonical Base and Ix green components remain exact physical matches. The
|
||||
[bounded native Richese continuation](LEADER_SKILLS_RUNTIME.md#native-richese-composition--4-october-2026)
|
||||
adds printed Richese `Special`/`Special - Movement` components, not the broadly
|
||||
encoded Stone/Mirror weapons or Portable Snooper defense. Actual Box and Residual
|
||||
Poison substitution proves native effect/fee suppression and single disposal,
|
||||
including a zero-marker battle. Other optional modules, mixed No-Field movement/
|
||||
battle combinations and Discovery/flight interactions retain their own boundaries.
|
||||
|
||||
The movement control builds ordinary authoritative actions with an explicit range
|
||||
or gather choice. Bots use the same range helper and submit physical source/elite
|
||||
|
||||
@@ -12,6 +12,31 @@ Audit: 2026-09-06. This document covers the complete core bidding pipeline: the
|
||||
|
||||
Targeted searches did not yield a newer applicable publisher/designer ruling resolving the edge cases below. This is not a claim that none exists. Tournament rewrites and community compilations are not authority for these cases. Search-result dates do not date a printing.
|
||||
|
||||
## Authorized Advanced contribution recipients — 4 October 2026
|
||||
|
||||
The [authorized supplied Advanced source](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026),
|
||||
Richese Bidding, explicitly sends Richese's contribution to an ally's payment
|
||||
to Emperor or the Spice Bank. The private layout extraction records this at
|
||||
lines1463–1476. The publisher-authored R2 p5 instead says Richese collects other
|
||||
buyers' payment and pays Emperor/bank when buying itself, without the extra
|
||||
contributor clause. **This recipient cutover is Advanced only; Basic is unchanged.**
|
||||
|
||||
An Advanced buyer paying4 and receiving4 funded Richese aid buys the original
|
||||
eight-spice cache/Black Market lot once. Richese receives only the buyer's4;
|
||||
Richese's own4 goes to Emperor, even when Emperor is the buyer, or to the Bank
|
||||
when Emperor is absent. The original scalar contribution binds the paid lot;
|
||||
later income settlement does not reconstruct current alliances or charge
|
||||
already-debited donor escrow again. Emperor cancellation sends only its4 to
|
||||
the Bank, not the unrelated buyer-to-Richese share, and does not replay credit
|
||||
or physical card transfer. The existing whole-bank recipient override remains.
|
||||
|
||||
The separate native `banker-income` opt-in counts only actual bank-routed
|
||||
original payer legs. It never treats allowed Emperor income or ordinary seller
|
||||
credit as a bank payment, aggregates separate payers to four, or spends its
|
||||
new front-shield grant before actual Mentat. Original Bureaucrat split-funding,
|
||||
positive Black Market self-bidding, exhausted-cache counts and special-lot
|
||||
Ixian Technology remain their recorded boundaries.
|
||||
|
||||
## Printed rules, by checkpoint
|
||||
|
||||
The source anchor is R2 pp.4–6,10–12: ten cache cards stay outside the hand; one revealed cache lot replaces a normal lot and is declared first or last before Ixian preparation. It uses Once Around or Silent. Other buyers pay Richese; Richese pays Emperor/bank. Discards enter normal discard. Ordinary Karama acquisition is barred. Once Around gives each other faction one ascending bid/pass in a chosen direction, then Richese's final opportunity. Silent reveals eligible bidders' simultaneous offers, including zero, with storm-order ties. A zero result permits cache keep/remove. Advanced Black Market offers one concealed hand card before Declaration, permits all three methods, allows Atreides inspection and unverified sales claims, pays Richese, and shrinks the normal pool only after a sale; no bid means retain. Direct-cache purchases cannot use Ixian allied replacement; Black Market purchases can, even for Richese-family cards. Harkonnen bonus applies to both. Juice of Sapho works in Once Around. Cancellation prevents cache auction at round start or Black Market sale. The special power buys a privately chosen cache card for three spice. [R2](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
|
||||
|
||||
@@ -169,6 +169,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
|
||||
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
|
||||
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
|
||||
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
|
||||
| Advanced Richese ally contribution | The authorized supplied Advanced Richese Bidding clause routes Richese's contribution to its ally's cache/Black Market purchase to Emperor or bank, even when Emperor buys. Preserve the original donor leg separately from buyer-to-Richese credit; cancellation redirects only the Emperor leg without paying twice. Basic retains R2's earlier receipt. Native Banker counts only final bank legs; exhausted-cache counts, positive Black Market self-bidding and Technology custody remain unresolved. | [Source distinction and recipient contract](RICHESE_AUCTION_RULES.md#authorized-advanced-contribution-recipients--4-october-2026) |
|
||||
| Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) |
|
||||
| Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) |
|
||||
| Ixian Nexus Secret Ally replacement | Fresh classic/base33 Basic/Advanced or Basic Tleilaxu/classic Ix47, Ixians absent and Nexus alone. Spend the singleton Nexus to discard exactly the normal paid/printed-Karama purchased card before drawing the actual deck top; preserve original payment, income and auction suffix once. Full-hand and exhausted-deck same-card redraw remain legal. Uniform buyer Use/Pass is the existing development privacy policy, not publisher timing. Harkonnen buyers, special origins, other overlays and unresolved cache/bonus priority stay guarded; no public start or retrofit. Optional Sandtrout is omitted only in the fresh native branch. | [Printed operation and base contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [native integration](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-basic-tleilaxu-integration--2-october-2026) |
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
one owned Richese No-Field shipment with Leader Skills in the CHOAM & Richese
|
||||
development setup. Public expansion/module starts and publication remain gated.
|
||||
|
||||
**4 October native continuation:** the
|
||||
[current Richese skill contract](LEADER_SKILLS_RUNTIME.md#native-richese-composition--4-october-2026)
|
||||
extends original own-marker eligibility to the supported Basic or Advanced
|
||||
native profiles with their distinct required CHOAM/Ix decks. A selected extra
|
||||
deck no longer rejects an otherwise legal own marker; force/price/reveal and
|
||||
companion arithmetic below are unchanged. Ordinary allied No-Field shipments
|
||||
retain their native owner/recipient/token cancellation and Guild stages, but
|
||||
do not receive an invented allied free companion. Optional overlays remain guarded.
|
||||
|
||||
## Source and physical contract
|
||||
|
||||
The [publisher CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf),
|
||||
@@ -72,8 +81,8 @@ real force; refresh restored nineteen reserves. Voluntary reveal produced six
|
||||
real forces and fourteen reserves. Four spice, Crysknife and the Soo-Soo Sook
|
||||
traitor remained unchanged through the final refresh.
|
||||
|
||||
Allied No-Field Smuggler composition, Homeworlds, Nexus, Discoveries, Stronghold
|
||||
Allied free-companion composition, Homeworlds, Nexus, Discoveries, Stronghold
|
||||
Cards, Tech Tokens and other combined modes remain outside this prototype.
|
||||
Smuggler battle collection still needs its recorded reveal/survival settlement.
|
||||
The existing [Leader Skills material questions](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources)
|
||||
Unmodified Smuggler battle collection now has its separate native reveal/
|
||||
survival contract; the existing [Leader Skills material questions](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources)
|
||||
remain pending.
|
||||
@@ -18,6 +18,16 @@ assignment; original paid bank legs and own native trainer eligibility remain.
|
||||
The [bounded Advanced Moritani composition](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
|
||||
also shares this separate opt-in with its original assassination preview.
|
||||
Native Terror placement remains free, not an invented qualifying bank payment.
|
||||
The [native Richese composition](LEADER_SKILLS_RUNTIME.md#native-richese-composition--4-october-2026)
|
||||
adds Basic/Advanced Richese and its required CHOAM deck. Advanced Richese's
|
||||
funded ally contribution follows the authorized Emperor/bank recipient rule;
|
||||
the buyer-to-Richese share does not count as a bank payment. Basic retains its
|
||||
publisher receipt. Actual human **E2K2U5ZW v26** paid the original eight-spice
|
||||
Ornithopter invoice as buyer4/Richese4, earned one unavailable Guild Banker
|
||||
spice, and collected it once at native Mentat after ordinary Collection.
|
||||
The front-shield pile stayed separate through Revival and movement; refresh
|
||||
retains Mentat/wallet7/empty pile. No public-mode or deployed acceptance.
|
||||
|
||||
|
||||
## Normal income local contract — 2 October 2026
|
||||
|
||||
|
||||
@@ -89,6 +89,16 @@ const ids = (v: unknown): v is string[] =>
|
||||
const object = (v: unknown): v is Record<string, unknown> =>
|
||||
!!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/** The original Emperor/bank-routed invoice, not the buyer-to-Richese leg. */
|
||||
export function auctionEmperorIncomeAmount(
|
||||
sale: NonNullable<Game['currentAuctionSale']>,
|
||||
): number {
|
||||
if (sale.free || sale.recipient === 'bank') return 0;
|
||||
return sale.origin === 'normal' || sale.seller === sale.winner
|
||||
? sale.amount
|
||||
: sale.richeseContribution ?? 0;
|
||||
}
|
||||
|
||||
/** Deterministic paid-sale continuation only. A returned response, Richese
|
||||
* transition or normal phase-end is a boundary, not a simulated future action.
|
||||
* Never publish this internal quote: returned lot cards remain private. */
|
||||
@@ -270,6 +280,16 @@ function calculate(
|
||||
count(sale.amount) && typeof sale.free === 'boolean',
|
||||
'The paid auction amount or free flag is invalid.',
|
||||
);
|
||||
requireAuction(
|
||||
sale.richeseContribution === undefined ||
|
||||
(input.advanced &&
|
||||
sale.origin !== 'normal' &&
|
||||
sale.seller !== sale.winner &&
|
||||
count(sale.richeseContribution) &&
|
||||
sale.richeseContribution > 0 &&
|
||||
sale.richeseContribution <= sale.amount),
|
||||
'A Richese contribution requires a positive bounded Advanced other-buyer sale.',
|
||||
);
|
||||
if (operation.kind === 'sale')
|
||||
requireAuction(
|
||||
typeof operation.free === 'boolean' && operation.free === sale.free,
|
||||
@@ -310,9 +330,8 @@ function calculate(
|
||||
} else if (r.kind === 'emperorIncome') {
|
||||
requireAuction(
|
||||
seated(r.owner)?.faction === 'emperor' &&
|
||||
r.owner !== winner.id &&
|
||||
!sale.free &&
|
||||
(sale.origin === 'normal' || sale.seller === winner.id),
|
||||
auctionEmperorIncomeAmount(sale) > 0 &&
|
||||
(r.owner !== winner.id || sale.richeseContribution !== undefined),
|
||||
'The canceled Emperor income is not payable by this sale.',
|
||||
);
|
||||
stage = 'bonus';
|
||||
@@ -347,25 +366,29 @@ function calculate(
|
||||
if (sale.origin !== 'normal' && sale.seller !== winner.id) {
|
||||
if (sale.recipient !== 'bank') {
|
||||
const seller = seated(sale.seller!)!;
|
||||
const amount = sale.amount - (sale.richeseContribution ?? 0);
|
||||
requireAuction(
|
||||
count(seller.spice) && count(seller.spice + sale.amount),
|
||||
count(seller.spice) && count(seller.spice + amount),
|
||||
'The seller credit would overflow its current balance.',
|
||||
);
|
||||
steps.push({
|
||||
kind: 'sellerCredit',
|
||||
player: seller.id,
|
||||
amount: sale.amount,
|
||||
balance: seller.spice + sale.amount,
|
||||
amount,
|
||||
balance: seller.spice + amount,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const emperor = input.players.find((p) => p.faction === 'emperor');
|
||||
if (!sale.free && emperor && emperor.id !== winner.id)
|
||||
return result({
|
||||
kind: 'response',
|
||||
response: { kind: 'emperorIncome', owner: emperor.id, passed: [] },
|
||||
});
|
||||
}
|
||||
const emperor = input.players.find((p) => p.faction === 'emperor');
|
||||
if (
|
||||
emperor &&
|
||||
auctionEmperorIncomeAmount(sale) > 0 &&
|
||||
(emperor.id !== winner.id || sale.richeseContribution !== undefined)
|
||||
)
|
||||
return result({
|
||||
kind: 'response',
|
||||
response: { kind: 'emperorIncome', owner: emperor.id, passed: [] },
|
||||
});
|
||||
stage = 'bonus';
|
||||
}
|
||||
if (
|
||||
|
||||
+45
-9
@@ -234,6 +234,7 @@ import {
|
||||
} from './choam-storm-quote';
|
||||
import {
|
||||
quoteAuctionContinuation,
|
||||
auctionEmperorIncomeAmount,
|
||||
AuctionContinuationError,
|
||||
type AuctionContinuationOperation,
|
||||
type AuctionContinuationQuote,
|
||||
@@ -1549,6 +1550,8 @@ export type Game = {
|
||||
seller: string | null;
|
||||
/** Original seller remains the source owner when the invoice is sent to bank. */
|
||||
recipient?: 'bank';
|
||||
/** Actual Richese donor share of another buyer's lot; paid to Emperor or bank. */
|
||||
richeseContribution?: number;
|
||||
} | null;
|
||||
pendingAmbassador?: {
|
||||
revivalEvent?: string;
|
||||
@@ -7852,7 +7855,8 @@ function spiceBankerIncomeIntegrity(g: Game): void {
|
||||
for (const carrier of [g.currentAuctionSale, ...responses]) {
|
||||
const payment = carrier?.spiceBankerIncomePayment;
|
||||
if (!payment) {
|
||||
requireRule(!carrier || ('winner' in carrier ? carrier.free || carrier.origin !== 'normal' :
|
||||
requireRule(!carrier || ('winner' in carrier ? carrier.free ||
|
||||
(carrier.recipient !== 'bank' && auctionEmperorIncomeAmount(carrier) === 0) :
|
||||
carrier.kind !== 'guildIncome' &&
|
||||
!(carrier.kind === 'revivalIncome' && carrier.recipient !== carrier.owner && (carrier.amount ?? 0) > 1)),
|
||||
'The original paid transaction lost its native bank invoice.');
|
||||
@@ -7864,12 +7868,17 @@ function spiceBankerIncomeIntegrity(g: Game): void {
|
||||
Number.isSafeInteger(leg.amount) && leg.amount > 0 && ['bank', 'player'].includes(leg.recipient)) &&
|
||||
new Set(payment.legs.map(leg => leg.payer)).size === payment.legs.length,
|
||||
'The original paid bank transaction changed its event or contribution legs.');
|
||||
if (carrier && 'winner' in carrier)
|
||||
requireRule(payment.kind === 'auction' && !carrier.free && carrier.origin === 'normal' &&
|
||||
payment.legs.reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount &&
|
||||
payment.legs.every(leg => leg.payer === carrier.winner || leg.payer === getPlayer(g, carrier.winner).ally),
|
||||
'The deferred bank invoice no longer matches its original normal auction.');
|
||||
else if (carrier?.kind === 'guildIncome')
|
||||
if (carrier && 'winner' in carrier) {
|
||||
const amount = carrier.recipient === 'bank' ? carrier.amount : auctionEmperorIncomeAmount(carrier);
|
||||
const contributionOnly = carrier.origin !== 'normal' && carrier.seller !== carrier.winner && carrier.recipient !== 'bank';
|
||||
requireRule(payment.kind === 'auction' && !carrier.free &&
|
||||
payment.legs.reduce((sum, leg) => sum + leg.amount, 0) === amount &&
|
||||
(contributionOnly
|
||||
? g.advanced && !!carrier.richeseContribution && payment.legs.length === 1 &&
|
||||
payment.legs[0].payer === carrier.seller && payment.legs[0].amount === carrier.richeseContribution
|
||||
: payment.legs.every(leg => leg.payer === carrier.winner || leg.payer === getPlayer(g, carrier.winner).ally)),
|
||||
'The deferred bank invoice no longer matches its original auction payer legs.');
|
||||
} else if (carrier?.kind === 'guildIncome')
|
||||
requireRule(payment.kind === 'shipment' &&
|
||||
payment.legs.filter(leg => leg.recipient === 'player').reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount,
|
||||
'The deferred bank invoice no longer matches its original Guild income.');
|
||||
@@ -10783,6 +10792,15 @@ function settleRicheseLot(g: Game) {
|
||||
function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{kind:'sold'}>, bank: boolean) {
|
||||
const lot = g.richeseAuction!, owner = getPlayer(g,lot.owner);
|
||||
const winner = getPlayer(g, quote.winner), card = quote.card;
|
||||
const richeseContribution = g.advanced && winner.id !== owner.id && winner.ally === owner.id
|
||||
? quote.allyPayment : 0;
|
||||
const bankAmount = bank || winner.id === owner.id ? quote.amount : richeseContribution;
|
||||
const emperor = byFaction(g, 'emperor');
|
||||
const incomePayment = g.spiceBankerIncomePreview && bankAmount > 0 ? bankerIncomePayment(g, 'auction',
|
||||
bank || winner.id === owner.id
|
||||
? [{payer:winner.id,amount:quote.ownPayment,recipient:bank || !emperor ? 'bank' : 'player'},
|
||||
...(quote.allyPayment ? [{payer:winner.ally!,amount:quote.allyPayment,recipient:bank || !emperor ? 'bank' as const : 'player' as const}] : [])]
|
||||
: [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}]) : undefined;
|
||||
payWithAlly(g, winner, quote.amount, quote.allyPayment);
|
||||
g.richeseFunding = {};
|
||||
if (lot.source === 'cache')
|
||||
@@ -10790,6 +10808,8 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
|
||||
else owner.hand = owner.hand.filter((c) => c.id !== card.id);
|
||||
winner.hand.push(card);
|
||||
g.currentAuctionSale = {
|
||||
...(richeseContribution ? {richeseContribution} : {}),
|
||||
...(incomePayment ? {spiceBankerIncomePayment:incomePayment} : {}),
|
||||
winner: winner.id,
|
||||
amount: quote.amount,
|
||||
free: false,
|
||||
@@ -10798,6 +10818,7 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
|
||||
...(bank ? {recipient:'bank' as const} : {}),
|
||||
...stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id),
|
||||
};
|
||||
if (bank || !emperor) awardBankerIncome(g, incomePayment);
|
||||
log(
|
||||
g,
|
||||
`${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice. The funded bid was paid automatically.`,
|
||||
@@ -15557,7 +15578,8 @@ function currentFactionPayment(g: Game) {
|
||||
'This Richese purchase income is no longer current.');
|
||||
}
|
||||
const gross = response.kind === 'emperorIncome'
|
||||
? response.source === 'ambassador' ? response.amount! : g.currentAuctionSale?.amount ?? g.auction?.bid
|
||||
? response.source === 'ambassador' ? response.amount! :
|
||||
g.currentAuctionSale ? auctionEmperorIncomeAmount(g.currentAuctionSale) : g.auction?.bid
|
||||
: response.amount;
|
||||
requireRule(gross !== undefined, 'The faction payment is missing its original amount.');
|
||||
if (kind === 'shipment' &&
|
||||
@@ -23460,7 +23482,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
} else if (response.kind === 'emperorIncome') {
|
||||
if (!canceled) {
|
||||
const owner = getPlayer(g, response.owner);
|
||||
const amount = g.currentAuctionSale?.amount ?? g.auction!.bid;
|
||||
const amount = g.currentAuctionSale ? auctionEmperorIncomeAmount(g.currentAuctionSale) : g.auction!.bid;
|
||||
const payment = creditFactionPayment(g, owner.id, 'treachery', amount-(bureaucratDiversion ?? 0));
|
||||
log(
|
||||
g,
|
||||
@@ -23862,6 +23884,20 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
|
||||
const responses = savedNoFieldResponses(g);
|
||||
const decisions = homeworldSavedDecisions(g);
|
||||
const shipment = g.pendingShipment;
|
||||
if (g.leaderSkills && shipment?.alliedNoField) {
|
||||
requireRule(ordinaryLeaderSkillModeSupported(g) && !shipment.noField &&
|
||||
shipment.smuggler === undefined && shipment.smugglerCompanion === undefined &&
|
||||
shipment.noFieldSkillProof === undefined,
|
||||
'Allied No-Field skill composition retains its original ordinary shipment only.');
|
||||
const initial = responses.filter(r => r.kind === 'richeseNoField');
|
||||
const guild = decisions.filter(d => d.kind === 'guildShipment');
|
||||
requireRule(initial.length + guild.length === 1,
|
||||
'The allied No-Field must retain its original cancellation or Guild continuation.');
|
||||
for (const response of initial) requireRule(noFieldCancellationQuote(g, response)?.kind === 'allied',
|
||||
'The allied No-Field lost its original owner, recipient or token declaration.');
|
||||
for (const decision of guild) validateGuildShipmentDecision(g, decision);
|
||||
return true;
|
||||
}
|
||||
if (!(shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
|
||||
(g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) ||
|
||||
decisions.some(d => 'noFieldSkillProof' in d))) return false;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
weaponTypes,
|
||||
} from './battle-cards';
|
||||
import type { LeaderSkillId } from './leader-skill-cards';
|
||||
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
|
||||
export type DirectLeaderSkillId =
|
||||
| 'warmaster'
|
||||
@@ -66,19 +67,26 @@ export const PLANETOLOGIST_IX_SPECIALS = [
|
||||
{ id: 'ix-amal', name: 'Amal', effect: 'amal' },
|
||||
] as const;
|
||||
|
||||
const planetologistBaseSpecialById = new Map<
|
||||
string,
|
||||
(typeof PLANETOLOGIST_BASE_SPECIALS)[number] | (typeof PLANETOLOGIST_IX_SPECIALS)[number]
|
||||
>(
|
||||
[...PLANETOLOGIST_BASE_SPECIALS, ...PLANETOLOGIST_IX_SPECIALS].map((card) => [card.id, card]),
|
||||
);
|
||||
// Build once from physical faces. Richese's cache also transports its red/blue
|
||||
// components as `special`, so only printed green types join the role lookup.
|
||||
const planetologistSpecialById: Partial<Record<string, Pick<Card, 'name' | 'effect'>>> =
|
||||
Object.create(null);
|
||||
for (const card of [...PLANETOLOGIST_BASE_SPECIALS, ...PLANETOLOGIST_IX_SPECIALS])
|
||||
planetologistSpecialById[card.id] = card;
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS) {
|
||||
if (
|
||||
definition.printedType === 'Special' ||
|
||||
definition.printedType === 'Special - Movement'
|
||||
)
|
||||
planetologistSpecialById[definition.card.id] = definition.card;
|
||||
}
|
||||
|
||||
/** Shared engine/UI/bot eligibility: exact printed green component, no native use. */
|
||||
export function isPlanetologistBattleSpecialCard(
|
||||
card: Pick<Card, 'id' | 'name' | 'kind' | 'effect'> | undefined,
|
||||
): boolean {
|
||||
if (!card || card.kind !== 'special') return false;
|
||||
const canonical = planetologistBaseSpecialById.get(card.id);
|
||||
const canonical = planetologistSpecialById[card.id];
|
||||
return (
|
||||
canonical?.name === card.name &&
|
||||
canonical.effect === card.effect
|
||||
|
||||
@@ -90,9 +90,34 @@ export function basicChoamLeaderSkillsProfile(game: LeaderSkillProfile): boolean
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
|
||||
}
|
||||
|
||||
/** Native Basic Richese/CHOAM setup, without unrelated optional overlays. */
|
||||
export function basicRicheseLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
const players = game.players;
|
||||
if (game.advanced !== false || game.expansions.length !== 1 ||
|
||||
game.expansions[0] !== 'choam' || !players || players.length < 2 || players.length > 6 ||
|
||||
!noOtherLeaderSkillModules(game) || game.semutaPreview || game.moritaniAssassinatePreview ||
|
||||
game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
game.nexusHarkonnenBetrayalPreview) return false;
|
||||
let native = false;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
const player = players[i];
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === player.faction) return false;
|
||||
if (player.faction === 'richese') native = true;
|
||||
else if (player.faction !== 'choam') {
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction && faction.expansion === 'base') { classic = true; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
}
|
||||
return native;
|
||||
}
|
||||
|
||||
export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game) ||
|
||||
basicIxLeaderSkillsProfile(game) || basicChoamLeaderSkillsProfile(game);
|
||||
basicIxLeaderSkillsProfile(game) || basicChoamLeaderSkillsProfile(game) || basicRicheseLeaderSkillsProfile(game);
|
||||
}
|
||||
|
||||
/** Source-clear Advanced native factions; optional decks never imply a seated faction. */
|
||||
@@ -114,8 +139,9 @@ export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boo
|
||||
const player = players[i];
|
||||
// Six seats at most: check prior seats without allocating per-rule quote sets.
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === player.faction) return false;
|
||||
if (player.faction === 'ixians' || player.faction === 'tleilaxu' || player.faction === 'choam') {
|
||||
if (!game.expansions.includes(player.faction === 'choam' ? 'choam' : 'ix')) return false;
|
||||
if (player.faction === 'ixians' || player.faction === 'tleilaxu' ||
|
||||
player.faction === 'choam' || player.faction === 'richese') {
|
||||
if (!game.expansions.includes(player.faction === 'choam' || player.faction === 'richese' ? 'choam' : 'ix')) return false;
|
||||
native = true;
|
||||
} else {
|
||||
let classic = false;
|
||||
|
||||
+27
-3
@@ -4476,6 +4476,30 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original native role checks remain. Advanced own/foreign revival, normal versus lower bonus, death/buyback, Face Dance and captured use/execution cases pass with all fourteen cards and current JSON. Fourteen genuine games finish6,620 accepted actions/no rejection/171 JSON continuations, including62 battles and two foreign revivals. Actual manual/four-policy revival and captive revelation/return/execution smoke pass; complete module acceptance remains open.', evidence: ['tests/advanced-tleilaxu-skills.test.ts','tests/tleilaxu-skills-integration.test.ts','tests/tleilaxu-skills-revival.test.ts','tests/rihani-face-dancers.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'richese-leader-skills',
|
||||
title: 'Richese with Leader Skills',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Native cache setup, fourteen skills, capped concealed-force aftermath and exact green-card substitution in bounded development tables.',
|
||||
steps: [
|
||||
'Basic or Advanced native Richese retains its required CHOAM deck, separate ten-card cache and all fourteen skills. Native CHOAM may share the table; Advanced selected Ix decks and Tleilaxu retain their original inventory and explicit combination limits. Starting cards precede skills, public assignment and traitors. Public starts remain gated.',
|
||||
'Marker-only zero, three and five battles use the owning private reserve-capped pool and reveal only after both plans seal. Actual force loss then feeds original Suk rescue; no token value is a second casualty or a phantom reserve. Ordinary forces are separate. Own mixed marker/physical battle stays guarded; voluntary reveal must occur before Battle.',
|
||||
'A selected surviving Planetologist may use a canonical printed green Richese Special or Special - Movement in its weapon slot for two strength. Exact physical identity is required. Stone Burner, Mirror Weapon and Portable Snooper do not qualify. The green card is discarded once without its native effect, fee, search or early leader kill.',
|
||||
'The normal Smuggler own companion is one separate force alongside the marker, not added to its hidden value. Ordinary allied No-Field cancellation and Guild stages retain their exact owner/recipient/token frame; an allied free companion is not granted.',
|
||||
'The authorized Advanced recipient cutover keeps Richese ally contributions distinct: buyer four goes to Richese, funded Richese four goes to Emperor or bank. This also applies when Emperor is the buyer. Cancellation redirects only the original Emperor share, not unrelated buyer credit, with no second donor debit or card transfer. Basic keeps its publisher receipt.',
|
||||
'Separate normal Banker income uses only actual bank-routed payer legs and remains unavailable until actual Mentat. Allowed Emperor income and ordinary seller credit are not bank payments. Native Ixian Technology on Richese lots, exhausted-cache arithmetic, positive Black Market self-bidding, Bureaucrat split funding and other module/ruling boundaries remain explicit.',
|
||||
],
|
||||
related: ['leader-skills', 'richese-cards', 'richese-no-field', 'spice-banker-income', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{area:'Implementation',status:'Partial',detail:'Original native setup, exact green role lookup, private capped marker outcomes, ordinary allied declaration and Advanced original payer/recipient continuation compose. Existing rule guards and Basic source remain.',evidence:['game/leader-skill-profile.ts','game/leader-skill-combat.ts','game/auction-continuation-quote.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Original skill offers, cache bidding, concealed shipment, original preparation and plan slots, physical rescue, partial income and deferred grant controls are reused. Actual human evidence is recorded separately.',evidence:['components/leader-skills.tsx','components/richese-no-field.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'Shared exact roles and native private pools supply legal candidates; all four policies perform actual capped Suk rescue. No new strategy or calibration. Source questions can still stop full games.',evidence:['game/bots.ts','game/leader-skill-combat.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Exact native skill, green disposal and Basic-versus-Advanced contribution source are documented without full-family certification.',evidence:['docs/LEADER_SKILLS_RUNTIME.md','docs/RICHESE_AUCTION_RULES.md','docs/SMUGGLER_NO_FIELD.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Final affected rule/CLI union151/151, types/lint/build and eleven controlled programs pass. Genuine29-game batch is not green:28 finish11,788 actions/303 JSON continuations/130 battles; one exhausted-cache guard is captured. Actual390px green battle358WLPYA v16 and funded sale/native Mentat E2K2U5ZW v26 retain original controls and refresh without repeated payment. Broader/full/deployed acceptance remains open.',evidence:['tests/native-richese-skills.test.ts','tests/native-richese-green-battle.test.ts','tests/richese-contribution-payments.test.ts','tests/faction-games-cli.test.ts']},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'moritani-leader-skills',
|
||||
title: 'Moritani with Leader Skills',
|
||||
@@ -4507,7 +4531,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'The fresh banker-income preview gains one deferred spice once per phase when another player makes one qualifying payment to the Spice Bank, then collects automatically at native Mentat.',
|
||||
searchText: 'Spice Banker normal income bank payment four spice phase deferred front shield original owner death capture Mentat',
|
||||
steps: [
|
||||
'This separate fresh local profile admits two-to-six classic factions in Basic or Advanced with the base deck, the supported Basic native skill families, bounded Advanced Ixian/Tleilaxu/CHOAM plus classic seats with distinct required decks, or the separate Advanced Moritani/non-Harkonnen classic assassination roster with the ecaz deck. All fourteen skills and native setup remain. Foreign revival never assigns a skill, and Terror placement remains free. Unrelated native families and optional overlays stay guarded; no public start or old-game retrofit.',
|
||||
'This separate fresh profile admits classic Basic/Advanced, supported Basic native families, bounded Advanced Ixian/Tleilaxu/CHOAM/Richese plus classic seats with required decks, or separate non-Harkonnen Advanced Moritani. All fourteen skills and native setup remain. Richese cache self-payments and authorized Advanced donor legs count only when actually paid to bank; allowed Emperor or seller income does not. Basic receipts and unrelated module/public gates remain.',
|
||||
'The printed normal band is mandatory once per phase: when another player makes a payment of at least four spice to the Spice Bank, gain one spice and place it in front of the shield. It is an additional bank grant, not a reduction of the payer’s payment or another player’s income.',
|
||||
'Only an actual completed positive bank payment qualifies. A payment to a player, zero/free/waived cost, self-payment, invalid declaration or unfinished possible payment supplies no income. Several smaller payments do not combine to reach four.',
|
||||
'Use the original actual bank portion after recipient and counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support keep their native continuation once. Canceled Emperor, Guild or Tleilaxu revival income can leave a real bank payment. Tleilaxu self-revival pays the Bank; other-player revival normally pays Tleilaxu. Its separate free-revival bank award is not payer debit: three paid plus that one reward never becomes four paid to the Bank. A stopped shipment supplies no payment.',
|
||||
@@ -4538,7 +4562,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
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 and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.',
|
||||
'Fresh Advanced development profiles compose native Ixians, Tleilaxu and/or CHOAM with classic opponents, two through six unique seats and distinct required Ix/CHOAM decks. All fourteen skills and original native starting choices remain. Auditor never receives a skill. Typed cyborg support flows through Suk rescue before native substitution; CHOAM keeps original payer/income and Smuggler-before-Collection order. Foreign-ghola revival gives control without a new skill. Basic entries are unchanged; captured replacement, Advanced Atreides Suk/KH and unrelated overlays remain guarded.',
|
||||
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
|
||||
'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.',
|
||||
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
|
||||
@@ -4559,7 +4583,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Other special Sandmaster relocation remains missing. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers and skill-first Moritani assassination follow their bounded contracts; Ecaz/Duke assignment and other combined modules remain unfinished. This prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'moritani-leader-skills', '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, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride 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; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic or supported Basic native banker-income profile; other remaining bands 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/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.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, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. 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/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import type { Game, GameView } from './engine';
|
||||
import { quoteSmugglerShipment } from './smuggler-shipment';
|
||||
import { nativeExpansionLeaderSkillsProfile } from './leader-skill-profile';
|
||||
|
||||
/** The No-Field remains the priced marker; this is one separate physical force. */
|
||||
export type SmugglerNoFieldCompanion = { leader: string; amount: 1 };
|
||||
|
||||
export function smugglerNoFieldModeSupported(g: Game | GameView): boolean {
|
||||
return (
|
||||
g.expansions.length === 1 &&
|
||||
g.expansions[0] === 'choam' &&
|
||||
!g.homeworlds &&
|
||||
!g.nexusCards &&
|
||||
!g.discoveries &&
|
||||
!('discoveryEnabled' in g && g.discoveryEnabled) &&
|
||||
!g.strongholdCards &&
|
||||
!g.techTokens
|
||||
);
|
||||
return nativeExpansionLeaderSkillsProfile(g) &&
|
||||
g.players.some(player => player.faction === 'richese');
|
||||
}
|
||||
|
||||
/** Public custody only: the hidden token value never affects this free companion. */
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../game/engine';
|
||||
import {
|
||||
quoteAuctionContinuation,
|
||||
auctionEmperorIncomeAmount,
|
||||
AuctionContinuationError,
|
||||
type AuctionContinuationInput,
|
||||
type AuctionContinuationOperation,
|
||||
@@ -365,6 +366,67 @@ void test('sold Richese lots credit their seller exactly once before bonus, reta
|
||||
'phase',
|
||||
);
|
||||
});
|
||||
|
||||
void test('paid Richese donor receipts route only their bound share, including Emperor buyer and full donor funding', () => {
|
||||
for (const contribution of [1, 4]) {
|
||||
const g = richese('cache', 'e');
|
||||
g.sale!.richeseContribution = contribution;
|
||||
// Payment is already committed: no live alliance/funds reconstruction.
|
||||
g.players.find(p => p.id === 'r')!.spice = 0;
|
||||
const q = quoteAuctionContinuation(g, { kind: 'sale', free: false });
|
||||
assert.deepEqual(q.steps, [
|
||||
{ kind: 'sellerCredit', player: 'r', amount: 4 - contribution, balance: 4 - contribution },
|
||||
]);
|
||||
assert.equal(auctionEmperorIncomeAmount(q.sale), contribution);
|
||||
assert.ok(q.next.kind === 'response' && q.next.response.kind === 'emperorIncome');
|
||||
const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'));
|
||||
assert.deepEqual(canceled.steps, []);
|
||||
assert.ok(canceled.next.kind === 'richeseEnd' && canceled.next.after === 'normalPool');
|
||||
g.sale!.recipient = 'bank';
|
||||
assert.equal(auctionEmperorIncomeAmount(g.sale!), 0);
|
||||
assert.deepEqual(quoteAuctionContinuation(g, { kind: 'sale', free: false }).steps, []);
|
||||
assert.throws(() => quoteAuctionContinuation(g, cancel('emperorIncome', 'e')), /not payable/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Richese donor scalars reject impossible source, rule band and amount without changing the receipt', () => {
|
||||
const invalid: ((g: AuctionContinuationInput) => void)[] = [
|
||||
g => { g.sale!.richeseContribution = 0; },
|
||||
g => { g.sale!.richeseContribution = -1; },
|
||||
g => { g.sale!.richeseContribution = 1.5; },
|
||||
g => { g.sale!.richeseContribution = 5; },
|
||||
g => { g.advanced = false; },
|
||||
];
|
||||
for (const mutate of invalid) {
|
||||
const g = richese('cache');
|
||||
g.sale!.richeseContribution = 2;
|
||||
mutate(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => quoteAuctionContinuation(g, { kind: 'sale', free: false }), /contribution/);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
for (const g of [fixture(), richese('cache', 'r')]) {
|
||||
g.sale!.richeseContribution = 2;
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => quoteAuctionContinuation(g, { kind: 'next' }), /contribution/);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('canceling a partial Richese-paid Emperor invoice advances to native Harkonnen bonus without replaying seller income', () => {
|
||||
const g = richese('blackMarket');
|
||||
g.sale!.richeseContribution = 2;
|
||||
const paid = quoteAuctionContinuation(g, { kind: 'sale', free: false });
|
||||
assert.deepEqual(paid.steps, [
|
||||
{ kind: 'sellerCredit', player: 'r', amount: 2, balance: 22 },
|
||||
]);
|
||||
assert.ok(paid.next.kind === 'response' && paid.next.response.kind === 'emperorIncome');
|
||||
const before = structuredClone(g);
|
||||
const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'));
|
||||
assert.deepEqual(canceled.steps, []);
|
||||
assert.ok(canceled.next.kind === 'response' && canceled.next.response.kind === 'harkonnenBonus');
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
void test('Black Market cache exhaustion is checked only when reaching that declaration boundary; seller balance overflow rejects before credit', () => {
|
||||
const g = richese();
|
||||
g.richeseCacheCount = 0;
|
||||
|
||||
@@ -264,6 +264,9 @@ void test('Moritani Skills samples preserve full Basic and Advanced native decks
|
||||
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
|
||||
assert.equal(new Set(cards).size, 14);
|
||||
assert.equal(cards.length, 14);
|
||||
const advancedResumed = join(area, 'advanced-resumed');
|
||||
assert.equal(run(advancedResumed, '--resume', join(advanced, 'failed-moritani-skills-2-advanced.json'), '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(advancedResumed, 'moritani-skills-2-advanced', 1249, true);
|
||||
});
|
||||
|
||||
void test('Tleilaxu Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
|
||||
@@ -289,6 +292,9 @@ void test('Tleilaxu Skills samples preserve full Basic and Advanced native decks
|
||||
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
|
||||
assert.equal(new Set(cards).size, 14);
|
||||
assert.equal(cards.length, 14);
|
||||
const continued = join(area, `advanced-resumed-${name}`);
|
||||
assert.equal(run(continued, '--resume', join(advanced, `failed-${name}.json`), '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(continued, name, seed, true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -315,6 +321,9 @@ void test('Ixian Skills samples preserve full Basic and Advanced native decks th
|
||||
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
|
||||
assert.equal(new Set(cards).size, 14);
|
||||
assert.equal(cards.length, 14);
|
||||
const continued = join(area, `advanced-resumed-${name}`);
|
||||
assert.equal(run(continued, '--resume', join(advanced, `failed-${name}.json`), '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(continued, name, seed, true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -340,6 +349,32 @@ void test('CHOAM Skills samples preserve full Basic and Advanced native decks th
|
||||
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
|
||||
assert.equal(new Set(cards).size, 14);
|
||||
assert.equal(cards.length, 14);
|
||||
const advancedResumed = join(area, 'advanced-resumed');
|
||||
assert.equal(run(advancedResumed, '--resume', join(advanced, 'failed-choam-skills-2-advanced.json'), '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(advancedResumed, 'choam-skills-2-advanced', 1225, true);
|
||||
});
|
||||
|
||||
void test('native Richese skill captures retain all fourteen skills, ten cache cards and original Basic or Advanced CLI continuation', (t) => {
|
||||
const area = temporary(t);
|
||||
for (const rules of ['basic', 'advanced'] as const) {
|
||||
const out = join(area, rules);
|
||||
assert.equal(run(out, '--profile', 'richese-skills', '--rules', rules, '--players', '2', '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
const samples = rules === 'basic'
|
||||
? [['richese-skills-2-basic', 1254], ['richese-skills-richese-choam-2-basic', 1264]] as const
|
||||
: [['richese-skills-2-advanced', 1259], ['richese-skills-richese-choam-2-advanced', 1269], ['richese-skills-ix-deck-2-advanced', 1278]] as const;
|
||||
for (const [name, seed] of samples) {
|
||||
assertFailedEvidence(out, name, seed, false, samples.length);
|
||||
const snapshot = join(out, `failed-${name}.json`);
|
||||
const state = json<{richeseCache:{id:string}[];leaderSkills:{deck:string[];offers:Record<string,{cards:string[]}>;assignments:{skill:string}[]}}>(snapshot);
|
||||
const skills = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
|
||||
assert.equal(new Set(skills).size, 14);
|
||||
assert.equal(skills.length, 14);
|
||||
assert.equal(new Set(state.richeseCache.map(c => c.id)).size, 10);
|
||||
const continued = join(area, `resumed-${name}`);
|
||||
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(continued, name, seed, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
void test('Homeworld and Nexus sample snapshots resume only their matching module and roster', (t) => {
|
||||
const area = temporary(t);
|
||||
|
||||
@@ -10,11 +10,13 @@ import { newRevivalRules } from '../game/revival';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type AdvancedNativeSkillsOptions = {
|
||||
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani';
|
||||
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani' | 'richese';
|
||||
requestedSkill?: LeaderSkillId;
|
||||
skillOwner?: FactionId;
|
||||
opponents?: readonly FactionId[];
|
||||
bankerIncome?: boolean;
|
||||
/** Richese also exercises its already-admitted Basic native rule band. */
|
||||
rules?: 'basic' | 'advanced';
|
||||
/** Continue this actual authenticated lobby/setup without replacing its seats. */
|
||||
initial?: Game;
|
||||
};
|
||||
@@ -53,16 +55,17 @@ function withSkillShuffle<T>(skill: LeaderSkillId, ownerIndex: number, initializ
|
||||
* physical skill offers. No played save is converted and no deck is cherry-picked. */
|
||||
export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game {
|
||||
const ownerFaction = options.skillOwner ?? options.family;
|
||||
const expansion = options.family === 'moritani' ? 'ecaz' : options.family === 'choam' ? 'choam' : 'ix';
|
||||
const expansion = options.family === 'moritani' ? 'ecaz' :
|
||||
options.family === 'choam' || options.family === 'richese' ? 'choam' : 'ix';
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
const factions = [ownerFaction, options.family, ...(options.opponents ?? ['atreides', 'emperor'] as FactionId[])]
|
||||
.filter((faction, index, all) => all.indexOf(faction) === index);
|
||||
game = createGame('ADVNATIVE', newPlayer(factions[0], factions[0], factions[0]), true, [expansion]);
|
||||
game = createGame('ADVNATIVE', newPlayer(factions[0], factions[0], factions[0]), options.rules !== 'basic', [expansion]);
|
||||
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
}
|
||||
assert.equal(game.advanced, true);
|
||||
assert.equal(game.advanced, options.rules !== 'basic');
|
||||
assert.ok(game.expansions.includes(expansion));
|
||||
const owner = advancedNativePlayer(game, ownerFaction);
|
||||
if (game.status === 'setup') {
|
||||
@@ -112,6 +115,13 @@ export function advancedNativeStep(game: Game): Game {
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
if (game.decision?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage))
|
||||
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
||||
const lot = game.richeseAuction;
|
||||
if (lot && !lot.outcome && !game.response && !game.decision && !game.phaseOpening) {
|
||||
const actor = lot.method === 'silent'
|
||||
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
|
||||
assert.ok(actor, 'Original native lot must retain its next bidder.');
|
||||
return applyAction(game, actor, { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null });
|
||||
}
|
||||
const next = nextSpiceBankerIncomeNativeStep(game);
|
||||
assert.ok(next);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
@@ -216,9 +226,14 @@ export function stageAdvancedTleilaxuForeignRevival(state: Game): {
|
||||
|
||||
export function openAdvancedNativeSkillBattle(state: Game, actor: string, target: string, hideSkills = true): Game {
|
||||
let game = applyAction(state, actor, { type: 'chooseBattle', territory: 'wind_pass', target });
|
||||
for (let i = 0; (game.response || game.decision || game.battle?.preparation) && i < 100; i++) {
|
||||
for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
|
||||
if (game.decision?.kind === 'leaderSkillVisibility') {
|
||||
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: hideSkills });
|
||||
} else if (!game.response && !game.decision && game.battle?.preLeader?.closed === false) {
|
||||
const stage = game.battle.preLeader;
|
||||
const next = stage.ready.includes(game.battle.attacker) ? game.battle.defender : game.battle.attacker;
|
||||
assert.ok(!stage.ready.includes(next), 'Original pre-leader preparation must close after both acknowledgments.');
|
||||
game = applyAction(game, next, { type: 'battlePreparationReady', event: stage.event });
|
||||
} else game = advancedNativeStep(game);
|
||||
}
|
||||
assert.equal(game.decision, null);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, type Game } from '../game/engine';
|
||||
import {
|
||||
advancedNativePlayer,
|
||||
advancedNativeStep,
|
||||
assertAdvancedNativeCustody,
|
||||
completedAdvancedNativeSkillsGame,
|
||||
finishAdvancedNativeSkillAftermath,
|
||||
rejectAdvancedNativeAction,
|
||||
stageAdvancedNativeSkillBattle,
|
||||
} from './fixture-advanced-native-skills';
|
||||
|
||||
/** Original fresh all14 setup and original cache auction. Only the selected lot
|
||||
* and the other seats' passes are controlled; no cache card is injected. */
|
||||
function acquireGreen(card: string, rules: 'basic' | 'advanced', voice = false): Game {
|
||||
let game = completedAdvancedNativeSkillsGame({
|
||||
family: 'richese', requestedSkill: 'planetologist', rules,
|
||||
opponents: voice ? ['beneGesserit', 'emperor'] : ['atreides', 'emperor'],
|
||||
});
|
||||
const owner = advancedNativePlayer(game, 'richese').id;
|
||||
for (let i = 0; !advancedNativePlayer(game, 'richese').hand.some(c => c.id === card) && i < 500; i++) {
|
||||
if (game.response || game.phaseOpening) game = advancedNativeStep(game);
|
||||
else if (game.decision?.kind === 'richeseBlackMarket') {
|
||||
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, decline: true });
|
||||
} else if (game.decision?.kind === 'richeseDeclaration') {
|
||||
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
|
||||
} else if (game.decision?.kind === 'richeseCache') {
|
||||
game = applyAction(game, owner, {
|
||||
type: 'decision', event: game.richeseBidding!.event, card,
|
||||
method: 'onceAround', direction: 'counterclockwise',
|
||||
});
|
||||
} else if (game.decision?.kind === 'richeseUnbid') {
|
||||
game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, keep: true });
|
||||
} else if (game.richeseAuction && !game.richeseAuction.outcome) {
|
||||
game = applyAction(game, game.richeseAuction.active!, {
|
||||
type: 'richeseBid', event: game.richeseAuction.event, amount: null,
|
||||
});
|
||||
} else game = advancedNativeStep(game);
|
||||
}
|
||||
assert.ok(advancedNativePlayer(game, 'richese').hand.some(c => c.id === card));
|
||||
assert.equal(game.richeseCache!.some(c => c.id === card), false);
|
||||
for (let i = 0; (game.phase !== 4 || game.response || game.decision || game.phaseOpening) && i < 500; i++)
|
||||
game = advancedNativeStep(game);
|
||||
assert.equal(game.phase, 4);
|
||||
assert.ok(!game.response && !game.decision && !game.phaseOpening);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** The shared controlled battle position returns hands to the deck. Restore
|
||||
* their actual held physical cards, including the acquired cache lot, rather
|
||||
* than treating that staging operation as a new acquisition. */
|
||||
function heldCardBattle(state: Game, target: string): Game {
|
||||
const owner = advancedNativePlayer(state, 'richese').id;
|
||||
const game = stageAdvancedNativeSkillBattle(state, owner, target);
|
||||
for (const original of state.players) {
|
||||
const player = game.players.find(p => p.id === original.id)!;
|
||||
for (const card of original.hand) {
|
||||
const index = game.deck.findIndex(c => c.id === card.id);
|
||||
assert.ok(index >= 0);
|
||||
player.hand.push(...game.deck.splice(index, 1));
|
||||
}
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Explicit acknowledgments prevent fixture bots from executing the green
|
||||
* component's independent preleader effect before it is used as a substitute. */
|
||||
function openBattle(state: Game, target: string, voice?: boolean): Game {
|
||||
const owner = advancedNativePlayer(state, 'richese').id;
|
||||
let game = applyAction(state, owner, { type: 'chooseBattle', territory: 'wind_pass', target });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (game.response || game.phaseOpening) game = advancedNativeStep(game);
|
||||
else if (game.decision?.kind === 'leaderSkillVisibility') {
|
||||
game = applyAction(game, game.decision.player, {
|
||||
type: 'leaderSkillVisibility', event: game.decision.event, hide: true,
|
||||
});
|
||||
} else if (game.battle?.preLeader && !game.battle.preLeader.closed) {
|
||||
const actor = [owner, target].find(id => !game.battle!.preLeader!.ready.includes(id));
|
||||
assert.ok(actor);
|
||||
game = applyAction(game, actor, { type: 'battlePreparationReady', event: game.battle.preLeader.event });
|
||||
} else if (game.battle?.preparation) {
|
||||
const preparation = game.battle.preparation;
|
||||
game = applyAction(game, preparation.owner,
|
||||
preparation.kind === 'voice' && voice !== undefined
|
||||
? { type: 'voice', kind: 'poison', must: voice }
|
||||
: { type: 'declineBattlePower' });
|
||||
} else if (game.decision) game = advancedNativeStep(game);
|
||||
else return game;
|
||||
}
|
||||
assert.fail('Original native pre-plan windows did not close.');
|
||||
}
|
||||
|
||||
function resolveGreen(state: Game, target: string, card: string): Game {
|
||||
const owner = advancedNativePlayer(state, 'richese');
|
||||
const trainer = state.leaderSkills!.assignments.find(a => a.owner === owner.id && a.skill === 'planetologist')!.leader;
|
||||
const disc = owner.leaders.find(l => l.id === trainer)!;
|
||||
const enemy = state.players.find(p => p.id === target)!;
|
||||
const enemyTrainer = state.leaderSkills!.assignments.find(a => a.owner === target)!.leader;
|
||||
const opposing = enemy.leaders.find(l => !l.dead && l.id !== enemyTrainer && l.strength <= disc.strength + 1 && l.strength >= disc.strength - 3)!;
|
||||
assert.ok(opposing);
|
||||
// The opponent scores one above the unmodified trainer. Only the surviving
|
||||
// green substitution can cross this score boundary; no paid support is used.
|
||||
const dial = (disc.strength + 1 - opposing.strength) * (state.advanced ? 2 : 1);
|
||||
let game = applyAction(state, owner.id, { type: 'battlePlan', dial: 0, support: 0, leader: trainer, weapon: card });
|
||||
game = applyAction(game, target, { type: 'battlePlan', dial, support: 0, leader: opposing.id });
|
||||
game = applyAction(game, owner.id, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, target, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.lastBattleContext?.winner, owner.id);
|
||||
return finishAdvancedNativeSkillAftermath(game);
|
||||
}
|
||||
|
||||
void test('Basic native Richese uses an acquired Nullentropy Box for winning strength without paying or searching', () => {
|
||||
const card = 'richese-nullentropy-box';
|
||||
let game = acquireGreen(card, 'basic');
|
||||
const target = advancedNativePlayer(game, 'emperor').id;
|
||||
game = heldCardBattle(game, target);
|
||||
const index = game.deck.findIndex(c => c.kind === 'worthless');
|
||||
assert.ok(index >= 0);
|
||||
const recoverable = game.deck.splice(index, 1)[0];
|
||||
game.discard.push(recoverable); // Conserved card gives the native Box a real search target.
|
||||
game = openBattle(game, target);
|
||||
const wallet = advancedNativePlayer(game, 'richese').spice;
|
||||
game = resolveGreen(game, target, card);
|
||||
const owner = advancedNativePlayer(game, 'richese');
|
||||
assert.equal(owner.spice, wallet, 'No two-spice native Box payment.');
|
||||
assert.equal(owner.hand.some(c => c.id === recoverable.id), false);
|
||||
assert.ok(game.discard.some(c => c.id === recoverable.id), 'No native discard search or recovery.');
|
||||
assert.equal(game.discard.filter(c => c.id === card).length, 1);
|
||||
assert.equal(owner.hand.some(c => c.id === card), false);
|
||||
assertAdvancedNativeCustody(game);
|
||||
});
|
||||
|
||||
void test('Advanced marker-only Richese keeps Residual Poison inert, obeys actual-Weapon Voice, and discards its winning substitute', () => {
|
||||
const card = 'richese-residual-poison';
|
||||
let game = acquireGreen(card, 'advanced', true);
|
||||
const target = advancedNativePlayer(game, 'beneGesserit').id;
|
||||
game = heldCardBattle(game, target);
|
||||
const owner = advancedNativePlayer(game, 'richese');
|
||||
owner.reserves += owner.forces['wind_pass:14'];
|
||||
owner.forces = {};
|
||||
const opponent = advancedNativePlayer(game, 'beneGesserit');
|
||||
opponent.reserves += Object.values(opponent.forces).reduce((sum, n) => sum + n, 0);
|
||||
opponent.forces = {};
|
||||
const poisonIndex = game.deck.findIndex(c => c.kind === 'poison');
|
||||
assert.ok(poisonIndex >= 0);
|
||||
owner.hand.push(...game.deck.splice(poisonIndex, 1)); // Gives mandatory Voice an actual Weapon alternative.
|
||||
Object.assign(game, { phase: 5, active: owner.id, movementRemaining: [...game.order] });
|
||||
owner.shipped = false;
|
||||
const token = owner.noField!.tokens.find(t => t.value === 0)!;
|
||||
game = applyAction(game, owner.id, {
|
||||
type: 'ship', noField: token.id, event: owner.noFieldEvent, territory: 'wind_pass', sector: 14,
|
||||
});
|
||||
for (let i = 0; (game.response || game.decision || game.phaseOpening) && i < 100; i++) game = advancedNativeStep(game);
|
||||
assert.equal(advancedNativePlayer(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
|
||||
assert.ok(advancedNativePlayer(game, 'richese').noField!.deployed);
|
||||
const fighters = advancedNativePlayer(game, 'beneGesserit');
|
||||
fighters.reserves += Object.values(fighters.forces).reduce((sum, n) => sum + n, 0);
|
||||
fighters.forces = {};
|
||||
fighters.reserves -= 8;
|
||||
fighters.forces['wind_pass:14'] = 8;
|
||||
delete fighters.advisors?.wind_pass;
|
||||
// Conserved opposing fighter position after original shipment/arrival decisions.
|
||||
Object.assign(game, { phase: 6, active: owner.id, ready: [], movementRemaining: [] });
|
||||
const required = openBattle(game, target, true);
|
||||
const trainer = required.leaderSkills!.assignments.find(a => a.owner === owner.id)!.leader;
|
||||
rejectAdvancedNativeAction(required, owner.id,
|
||||
{ type: 'battlePlan', dial: 0, support: 0, leader: trainer, weapon: card }, /comply with the Voice/);
|
||||
game = openBattle(game, target, false);
|
||||
assert.deepEqual(game.battle!.noFieldPlayers, [owner.id]);
|
||||
const untrained = advancedNativePlayer(game, 'richese').leaders.find(l => l.id !== trainer)!;
|
||||
rejectAdvancedNativeAction(game, owner.id,
|
||||
{ type: 'battlePlan', dial: 0, support: 0, leader: untrained.id, weapon: card }, /eligible Planetologist Special/);
|
||||
const before = advancedNativePlayer(game, 'beneGesserit').leaders.map(l => ({ id: l.id, dead: l.dead, deaths: l.deaths }));
|
||||
game = resolveGreen(game, target, card);
|
||||
assert.deepEqual(advancedNativePlayer(game, 'beneGesserit').leaders.map(l => ({ id: l.id, dead: l.dead, deaths: l.deaths })), before,
|
||||
'Residual Poison did not send any randomly selected enemy leader to the Tanks.');
|
||||
assert.equal(advancedNativePlayer(game, 'richese').noField!.deployed, null);
|
||||
assert.equal(advancedNativePlayer(game, 'richese').hand.some(c => c.id === card), false);
|
||||
assert.equal(game.discard.filter(c => c.id === card).length, 1);
|
||||
assertAdvancedNativeCustody(game);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody as custody,
|
||||
completedAdvancedNativeSkillsGame, finishAdvancedNativeSkillAftermath,
|
||||
initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle,
|
||||
rejectAdvancedNativeAction as reject, stageAdvancedNativeSkillBattle,
|
||||
} from './fixture-advanced-native-skills';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
const trainer = (g: Game, id: string) => g.leaderSkills!.assignments.find(a => a.owner === id)!;
|
||||
const untrained = (g: Game, id: string) => g.players.find(p => p.id === id)!.leaders
|
||||
.filter(l => !l.dead && l.id !== trainer(g, id).leader).sort((a, b) => b.strength - a.strength)[0].id;
|
||||
|
||||
/** Genuine full native setup, then explicitly conserved shipment/battle position.
|
||||
* Native ship creates the physical marker; no fabricated deployed token. */
|
||||
function markerGame(value: 0 | 3 | 5, requestedSkill: 'suk-graduate' | 'mentat' | 'smuggler',
|
||||
rules: 'basic' | 'advanced' = 'advanced', reserves = 20, companion = false) {
|
||||
const initial = completedAdvancedNativeSkillsGame({ family: 'richese', requestedSkill, rules });
|
||||
const actor = player(initial, 'richese').id, target = player(initial, 'emperor').id;
|
||||
let game = stageAdvancedNativeSkillBattle(initial, actor, target);
|
||||
const owner = player(game, 'richese');
|
||||
owner.reserves += owner.forces['wind_pass:14'];
|
||||
owner.forces = {};
|
||||
owner.tanks += owner.reserves - reserves;
|
||||
owner.reserves = reserves;
|
||||
if (companion) {
|
||||
const enemy = player(game, 'emperor');
|
||||
enemy.reserves += enemy.forces['wind_pass:14'];
|
||||
enemy.forces = {};
|
||||
}
|
||||
owner.shipped = false; owner.moved = 0;
|
||||
Object.assign(game, { phase: 5, active: actor, ready: [], phaseOpening: null, response: null, decision: null });
|
||||
const token = owner.noField!.tokens.find(t => t.value === value)!;
|
||||
const before = owner.spice;
|
||||
game = applyAction(game, actor, { type: 'ship', noField: token.id, event: owner.noFieldEvent,
|
||||
territory: 'wind_pass', sector: 14, smuggler: companion });
|
||||
for (let n = 0; (game.response || game.decision) && n < 100; n++) game = advancedNativeStep(game);
|
||||
assert.equal(player(game, 'richese').spice, before - 2);
|
||||
assert.ok(player(game, 'richese').noField!.deployed);
|
||||
if (companion) {
|
||||
const enemy = player(game, 'emperor');
|
||||
enemy.reserves -= 8;
|
||||
enemy.forces['wind_pass:14'] = 8;
|
||||
} // Explicit later opposing position; the actual companion arrived in an empty territory.
|
||||
const beforeBattle = reload(game);
|
||||
Object.assign(game, { phase: 6, active: actor, ready: [], phaseOpening: null, response: null, decision: null });
|
||||
custody(game);
|
||||
return { game, beforeBattle, actor, target };
|
||||
}
|
||||
|
||||
void test('native Basic and Advanced Richese retain source-ordered all14 and their separate original ten-card cache', () => {
|
||||
for (const rules of ['basic', 'advanced'] as const) {
|
||||
const offered = initializeAdvancedNativeSkillsSetup({ family: 'richese', requestedSkill: 'suk-graduate', rules });
|
||||
assert.equal(offered.setupStage, 'leaderSkills');
|
||||
assert.equal(offered.richeseCache!.length, 10);
|
||||
for (const seat of offered.players) assert.equal(seat.hand.length, 1);
|
||||
const physical = [...offered.leaderSkills!.deck, ...Object.values(offered.leaderSkills!.offers).flatMap(o => o.cards)];
|
||||
assert.deepEqual([...physical].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
const rival = offered.players.find(p => p.faction !== 'richese')!;
|
||||
const own = player(offered, 'richese');
|
||||
assert.deepEqual(viewGame(offered, rival.id).leaderSkills!.eligibleLeaders.map(l => l.id).sort(),
|
||||
rival.leaders.filter(l => !l.dead && !l.capturedBy && !l.gholaBy).map(l => l.id).sort());
|
||||
assert.equal(viewGame(offered, own.id).leaderSkills!.offer!.cards.includes('suk-graduate'), true);
|
||||
custody(completedAdvancedNativeSkillsGame({ family: 'richese', requestedSkill: 'suk-graduate', rules }));
|
||||
}
|
||||
});
|
||||
|
||||
void test('scarce native No-Field five materializes only three after both plans and skilled Suk saves only real paid losses', () => {
|
||||
const staged = markerGame(5, 'suk-graduate', 'advanced', 3);
|
||||
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target);
|
||||
const trained = trainer(game, staged.actor).leader;
|
||||
reject(game, staged.actor, { type: 'battlePlan', dial: 4, support: 3, leader: trained });
|
||||
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 3, support: 3, leader: trained });
|
||||
assert.ok(player(game, 'richese').noField!.deployed);
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
|
||||
assert.equal(viewGame(game, staged.target).richeseNoField!.private, null);
|
||||
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
|
||||
assert.equal(player(game, 'richese').noField!.deployed, null);
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'], 3);
|
||||
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
|
||||
assert.equal(game.decision?.kind, 'sukRescue');
|
||||
if (game.decision?.kind !== 'sukRescue') throw new Error('Native real casualties must retain skilled rescue.');
|
||||
const pending = game;
|
||||
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14');
|
||||
assert.ok(choice >= 0);
|
||||
game = applyAction(reload(game), staged.actor, { type: 'decision', event: game.decision.event, choice });
|
||||
game = finishAdvancedNativeSkillAftermath(game);
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'], 1);
|
||||
assert.equal(player(game, 'richese').reserves, 2);
|
||||
assert.equal(player(game, 'richese').tanks, 17);
|
||||
assert.equal(player(game, 'richese').spice, 0);
|
||||
custody(game);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(reload(pending), staged.actor);
|
||||
view.players.find(p => p.id === staged.actor)!.bot = difficulty;
|
||||
const rescued = applyAction(reload(pending), staged.actor, botActions(view)[0]);
|
||||
assert.equal(player(rescued, 'richese').tanks, 17);
|
||||
assert.equal(player(rescued, 'richese').spice, 0);
|
||||
custody(finishAdvancedNativeSkillAftermath(rescued));
|
||||
}
|
||||
});
|
||||
|
||||
void test('normal native Suk returns one actual No-Field casualty under both rule-band support costs', () => {
|
||||
for (const rules of ['basic', 'advanced'] as const) {
|
||||
const staged = markerGame(3, 'suk-graduate', rules);
|
||||
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target, false);
|
||||
const balance = player(game, 'richese').spice;
|
||||
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 2, support: rules === 'advanced' ? 2 : 0,
|
||||
leader: untrained(game, staged.actor) });
|
||||
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
|
||||
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
|
||||
game = finishAdvancedNativeSkillAftermath(game);
|
||||
assert.equal(game.lastBattleContext!.winner, staged.actor);
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'], 1);
|
||||
assert.equal(player(game, 'richese').reserves, 18);
|
||||
assert.equal(player(game, 'richese').tanks, 1);
|
||||
assert.equal(player(game, 'richese').spice, balance - (rules === 'advanced' ? 2 : 0));
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('zero native No-Field retains an actual skilled leader battle and no phantom forces or support', () => {
|
||||
const staged = markerGame(0, 'mentat');
|
||||
let game = openAdvancedNativeSkillBattle(staged.game, staged.actor, staged.target);
|
||||
const trained = trainer(game, staged.actor).leader;
|
||||
const before = player(game, 'richese').spice;
|
||||
reject(game, staged.actor, { type: 'battlePlan', dial: 1, support: 0, leader: trained });
|
||||
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: trained });
|
||||
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
|
||||
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
|
||||
game = finishAdvancedNativeSkillAftermath(game);
|
||||
assert.equal(game.lastBattleContext!.winner, staged.actor);
|
||||
assert.equal(player(game, 'richese').reserves, 20);
|
||||
assert.equal(player(game, 'richese').tanks, 0);
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
|
||||
assert.equal(player(game, 'richese').spice, before);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('original Smuggler companion remains physical and mixed No-Field battle stays guarded until voluntary native reveal', () => {
|
||||
const staged = markerGame(5, 'smuggler', 'advanced', 20, true);
|
||||
const owner = player(staged.game, 'richese');
|
||||
assert.equal(owner.reserves, 19);
|
||||
assert.equal(owner.forces['wind_pass:14'], 1);
|
||||
reject(staged.game, staged.actor, { type: 'chooseBattle', territory: 'wind_pass', target: staged.target });
|
||||
const token = owner.noField!.deployed!.tokenId;
|
||||
reject(staged.game, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
|
||||
let game = applyAction(staged.beforeBattle, staged.actor, { type: 'revealNoField', token, event: owner.noFieldEvent });
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'], 6);
|
||||
assert.equal(player(game, 'richese').reserves, 14);
|
||||
Object.assign(game, { phase: 6, active: staged.actor, ready: [], phaseOpening: null, response: null, decision: null });
|
||||
game = openAdvancedNativeSkillBattle(game, staged.actor, staged.target);
|
||||
game = applyAction(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.actor) });
|
||||
game = applyAction(game, staged.target, { type: 'battlePlan', dial: 0, support: 0, leader: untrained(game, staged.target) });
|
||||
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
|
||||
game = finishAdvancedNativeSkillAftermath(game);
|
||||
assert.equal(game.lastBattleContext!.winner, staged.target);
|
||||
assert.equal(player(game, 'richese').tanks, 6);
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'] ?? 0, 0);
|
||||
assert.equal(player(game, 'richese').reserves, 14);
|
||||
custody(game);
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { mock } from 'node:test';
|
||||
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
|
||||
import { baseDeck, leaders, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import {
|
||||
PLANETOLOGIST_BASE_SPECIALS,
|
||||
canUsePlanetologistBattleSpecial,
|
||||
isPlanetologistBattleSpecialCard,
|
||||
type BattleLeaderSkill,
|
||||
} from '../game/leader-skill-combat';
|
||||
import {
|
||||
@@ -44,40 +43,23 @@ const assignment = (
|
||||
captured,
|
||||
});
|
||||
|
||||
void test('the alternate battle role is bound to the nine exact base green Specials', () => {
|
||||
const eligible = baseDeck().filter(isPlanetologistBattleSpecialCard);
|
||||
assert.deepEqual(
|
||||
eligible.map((card) => card.id),
|
||||
PLANETOLOGIST_BASE_SPECIALS.map((card) => card.id),
|
||||
);
|
||||
assert.deepEqual(
|
||||
eligible.map((card) => card.name),
|
||||
[
|
||||
'Family Atomics',
|
||||
'Weather Control',
|
||||
'Hajr',
|
||||
'Tleilaxu Ghola',
|
||||
'Harvester',
|
||||
'Karama',
|
||||
'Karama',
|
||||
'Truthtrance',
|
||||
'Truthtrance',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
baseDeck()
|
||||
.filter((card) => card.kind === 'hero')
|
||||
.some(isPlanetologistBattleSpecialCard),
|
||||
false,
|
||||
);
|
||||
assert.equal(ixBattleCards().some(isPlanetologistBattleSpecialCard), false);
|
||||
assert.equal(
|
||||
isPlanetologistBattleSpecialCard({
|
||||
...special(),
|
||||
id: 'forged-green-special',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
void test('Richese green identities cannot be forged or borrowed by weapon-special and defense components', () => {
|
||||
const green = richeseCards().find(card => card.effect === 'ornithopter')!;
|
||||
const eligible = (card: Card) => canUsePlanetologistBattleSpecial({
|
||||
assignments: [assignment('leader')],
|
||||
selectedLeader: 'leader',
|
||||
card,
|
||||
});
|
||||
assert.equal(eligible(green), true);
|
||||
for (const forged of [
|
||||
{ ...green, id: 'forged-green-special' },
|
||||
{ ...green, name: 'Stone Burner' },
|
||||
{ ...green, kind: 'projectile' as const },
|
||||
{ ...green, effect: 'stoneBurner' },
|
||||
]) assert.equal(eligible(forged), false);
|
||||
for (const effect of ['stoneBurner', 'mirrorWeapon', 'portableSnooper'])
|
||||
assert.equal(eligible(richeseCards().find(card => card.effect === effect)!), false);
|
||||
assert.equal(eligible(baseDeck().find(card => card.kind === 'hero')!), false);
|
||||
});
|
||||
|
||||
void test('only the actual concealed or captured assigned leader may use the physical substitute', () => {
|
||||
@@ -195,7 +177,8 @@ void test('a surviving skilled disc gains two without activating the Special and
|
||||
|
||||
void test('leader death suppresses the two-point band but never releases the revealed Special', () => {
|
||||
const input = battle();
|
||||
const card = playPlanetologist(input, special('weather'));
|
||||
const card = playPlanetologist(input, richeseCards().find(card => card.effect === 'residualPoison')!);
|
||||
input.physicalCards = [...input.physicalCards, card];
|
||||
const poison = baseDeck().find((candidate) => candidate.kind === 'poison')!;
|
||||
input.defender.hand = [poison];
|
||||
input.defender.plan.weapon = poison.id;
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import {
|
||||
initializeAdvancedNativeSkillsSetup, advancedNativePlayer, advancedNativeStep,
|
||||
advanceAdvancedNativeSkillsToPhase, rejectAdvancedNativeAction,
|
||||
} from './fixture-advanced-native-skills';
|
||||
import { advanceSpiceBankerIncomeToMentat } from './fixture-spice-banker-income';
|
||||
|
||||
type Options = {
|
||||
rules?: 'basic' | 'advanced'; source?: 'cache' | 'blackMarket';
|
||||
emperor?: boolean; buyer?: 'atreides' | 'emperor' | 'richese';
|
||||
banker?: boolean; counter?: boolean;
|
||||
};
|
||||
type NativeRichesePaymentFixture = {
|
||||
game: Game; seller: string; buyer: string; observer: string; counter?: string; card: string;
|
||||
};
|
||||
function choose(game: Game, values: Record<string, unknown>): Game {
|
||||
return applyAction(game, game.decision!.player, {
|
||||
type: 'decision', event: game.richeseBidding!.event, ...values,
|
||||
} as Action);
|
||||
}
|
||||
function allowOriginalResponses(state: Game): Game {
|
||||
let game = state;
|
||||
for (let i = 0; game.response && i < 100; i++) {
|
||||
const actor = game.players.find(p => p.id !== game.response!.owner && !game.response!.passed.includes(p.id));
|
||||
assert.ok(actor, 'The original counter window must retain an unpassed participant.');
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
function prepare(options: Options = {}): NativeRichesePaymentFixture {
|
||||
const opponents: FactionId[] = ['atreides', 'beneGesserit'];
|
||||
if (options.emperor !== false) opponents.push('emperor');
|
||||
let game = initializeAdvancedNativeSkillsSetup({
|
||||
family: 'richese', rules: options.rules ?? 'advanced', opponents,
|
||||
requestedSkill: options.banker ? 'spice-banker' : 'suk-graduate',
|
||||
skillOwner: options.banker ? 'beneGesserit' : 'richese', bankerIncome: options.banker,
|
||||
});
|
||||
const skillOwner = advancedNativePlayer(game, options.banker ? 'beneGesserit' : 'richese').id;
|
||||
const requested = options.banker ? 'spice-banker' : 'suk-graduate';
|
||||
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor];
|
||||
const own = viewGame(game, actor).leaderSkills!;
|
||||
const skill = actor === skillOwner ? requested :
|
||||
offer.cards.find(card => card !== 'bureaucrat' && !own.unavailableSkills?.[card]);
|
||||
assert.ok(skill && offer.cards.includes(skill));
|
||||
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id });
|
||||
} else game = advancedNativeStep(game);
|
||||
} // Choose genuine alternate offers: unresolved Bureaucrat split funding is not this rule.
|
||||
game = advanceAdvancedNativeSkillsToPhase(game, 2);
|
||||
const seller = advancedNativePlayer(game, 'richese').id;
|
||||
const buyer = advancedNativePlayer(game, options.buyer ?? 'atreides').id;
|
||||
const observer = advancedNativePlayer(game, 'beneGesserit').id;
|
||||
// Explicit controlled alliance position after original all14/native setup;
|
||||
// wallets, force inventories, skills and original starting hands stay intact.
|
||||
if (buyer !== seller) {
|
||||
game.players.find(p => p.id === seller)!.ally = buyer;
|
||||
game.players.find(p => p.id === buyer)!.ally = seller;
|
||||
}
|
||||
let counter: string | undefined;
|
||||
if (options.counter) {
|
||||
const owner = game.players.find(p => p.id === observer)!;
|
||||
let card = owner.hand.find(c => c.effect === 'karama');
|
||||
if (!card) {
|
||||
const index = game.deck.findIndex(c => c.effect === 'karama');
|
||||
if (index >= 0) card = game.deck.splice(index, 1)[0];
|
||||
else {
|
||||
const holder = game.players.find(p => p.id !== observer && p.hand.some(c => c.effect === 'karama'))!;
|
||||
assert.ok(holder, 'Transfer an existing original physical Karama, never manufacture one.');
|
||||
card = holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1)[0];
|
||||
}
|
||||
owner.hand.push(card);
|
||||
}
|
||||
counter = card.id;
|
||||
}
|
||||
for (let i = 0; game.phase === 2 && i < 100; i++) game = advancedNativeStep(game);
|
||||
assert.equal(game.phase, 3);
|
||||
if (game.advanced) {
|
||||
assert.equal(game.decision?.kind, 'richeseBlackMarket');
|
||||
if (options.source === 'blackMarket') {
|
||||
const card = game.players.find(p => p.id === seller)!.hand[0];
|
||||
assert.ok(card);
|
||||
game = choose(game, { card: card.id, method: 'silent' });
|
||||
} else game = choose(game, { decline: true });
|
||||
}
|
||||
if (options.source !== 'blackMarket') {
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
game = choose(game, { position: 'first' });
|
||||
game = allowOriginalResponses(game);
|
||||
game = choose(game, { card: game.richeseCache![0].id, method: 'silent' });
|
||||
}
|
||||
// Pass only the original pre-sale counter windows. Never substitute a receipt.
|
||||
game = allowOriginalResponses(game);
|
||||
assert.ok(game.richeseAuction && !game.richeseAuction.outcome);
|
||||
return { game, seller, buyer, observer, counter, card: game.richeseAuction.cardId };
|
||||
}
|
||||
function bid(game: Game, actor: string, amount: number, allyPayment = 0): Game {
|
||||
return applyAction(game, actor, { type: 'richeseBid', event: game.richeseAuction!.event, amount, allyPayment });
|
||||
}
|
||||
function purchase(fixture: NativeRichesePaymentFixture, donation = 4, price = 8): Game {
|
||||
let game = fixture.game;
|
||||
if (donation) game = applyAction(game, fixture.seller, { type: 'pledgeAid', amount: donation });
|
||||
game = bid(game, fixture.buyer, price, donation);
|
||||
if (donation) {
|
||||
rejectAdvancedNativeAction(game, fixture.seller, { type: 'pledgeAid', amount: donation - 1 }, /Ally funding/);
|
||||
rejectAdvancedNativeAction(game, fixture.buyer, {
|
||||
type: 'richeseBid', event: game.richeseAuction!.event, amount: 1,
|
||||
}, /already submitted/);
|
||||
}
|
||||
for (const actor of game.richeseAuction!.order)
|
||||
if (actor !== fixture.buyer) game = bid(game, actor, 0);
|
||||
return game;
|
||||
}
|
||||
function finishResponses(state: Game): Game {
|
||||
let game = state;
|
||||
for (let i = 0; (game.response || (game.decision && !['richeseDeclaration', 'richeseCache'].includes(game.decision.kind))) && i < 200; i++)
|
||||
game = advancedNativeStep(game);
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
function balance(game: Game, actor: string): number { return game.players.find(p => p.id === actor)!.spice; }
|
||||
function exactCard(game: Game, buyer: string, id: string): void {
|
||||
assert.equal(game.players.find(p => p.id === buyer)!.hand.filter(c => c.id === id).length, 1);
|
||||
const physical = [
|
||||
...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
];
|
||||
assert.equal(physical.filter(c => c.id === id).length, 1);
|
||||
}
|
||||
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
for (const mode of ['allow', 'cancel', 'emperorBuyer'] as const) {
|
||||
void test(`Advanced native ${source}: Richese 4 plus buyer 4, Emperor ${mode}, settles card/escrow once`, () => {
|
||||
const f = prepare({ source, counter: true, buyer: mode === 'emperorBuyer' ? 'emperor' : 'atreides' });
|
||||
const before = f.game;
|
||||
const emperor = advancedNativePlayer(before, 'emperor').id;
|
||||
let game = purchase(f);
|
||||
// Seller receives ONLY the non-Richese leg; donation is not refunded.
|
||||
assert.equal(balance(game, f.seller), balance(before, f.seller));
|
||||
assert.equal(balance(game, f.buyer), balance(before, f.buyer) - 4);
|
||||
assert.equal(balance(game, emperor), balance(before, emperor) - (f.buyer === emperor ? 4 : 0));
|
||||
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
assert.equal(game.response?.kind, 'emperorIncome');
|
||||
assert.equal(game.currentAuctionSale?.richeseContribution, 4);
|
||||
if (mode === 'cancel') game = applyAction(game, f.observer, { type: 'card', card: f.counter!, mode: 'cancel' });
|
||||
game = finishResponses(game);
|
||||
assert.equal(balance(game, f.seller), balance(before, f.seller));
|
||||
assert.equal(balance(game, f.buyer), balance(before, f.buyer) - 4 + (mode !== 'cancel' && f.buyer === emperor ? 4 : 0));
|
||||
assert.equal(balance(game, emperor), balance(before, emperor) - (f.buyer === emperor ? 4 : 0) + (mode === 'cancel' ? 0 : 4));
|
||||
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
if (mode === 'cancel') assert.equal(game.discard.filter(c => c.id === f.counter).length, 1);
|
||||
if (source === 'blackMarket') {
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
assert.equal(game.richeseBidding?.blackMarketSold, true);
|
||||
} else assert.ok(game.auction);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('Basic native cache keeps its original publisher seller receipt, not an inferred Advanced contributor rule', () => {
|
||||
const f = prepare({ rules: 'basic', counter: true });
|
||||
let game = purchase(f);
|
||||
game = finishResponses(game);
|
||||
assert.equal(balance(game, f.seller), balance(f.game, f.seller) + 4);
|
||||
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
|
||||
const emperor = advancedNativePlayer(game, 'emperor').id;
|
||||
assert.equal(balance(game, emperor), balance(f.game, emperor));
|
||||
assert.equal(game.aid[f.seller]?.amount ?? 0, 0);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
});
|
||||
|
||||
for (const rules of ['basic', 'advanced'] as const) {
|
||||
void test(`${rules} native Richese self-cache pays bank once, with no seller refund`, () => {
|
||||
const f = prepare({ rules, emperor: false, buyer: 'richese' });
|
||||
const game = finishResponses(purchase(f, 0, 4));
|
||||
assert.equal(balance(game, f.seller), balance(f.game, f.seller) - 4);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
});
|
||||
}
|
||||
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
void test(`Advanced native ${source}: only Richese bank-routed contribution qualifies separate Banker, deferred until native Mentat`, () => {
|
||||
const f = prepare({ source, emperor: false, banker: true });
|
||||
const ownerBalance = balance(f.game, f.observer);
|
||||
let game = finishResponses(purchase(f));
|
||||
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
|
||||
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
|
||||
assert.equal(balance(game, f.observer), ownerBalance);
|
||||
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, [{ owner: f.observer, amount: 1 }]);
|
||||
const grants = game.spiceBankerIncome!.sources.filter(row => row.grant);
|
||||
assert.equal(grants.length, 1);
|
||||
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.seller, amount: 4 }]);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
// Native phase suffix, retaining cache-first when a Black Market sale ends.
|
||||
game = advanceAdvancedNativeSkillsToPhase(game, 7);
|
||||
const beforeMentat = balance(game, f.observer);
|
||||
rejectAdvancedNativeAction(game, f.observer, { type: 'bribe', target: f.buyer, amount: beforeMentat + 1 });
|
||||
game = advanceSpiceBankerIncomeToMentat(game);
|
||||
assert.equal(balance(game, f.observer), beforeMentat + 1);
|
||||
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced Emperor contribution cancellation qualifies Banker for donor leg only, not the unrelated buyer leg', () => {
|
||||
const f = prepare({ banker: true, counter: true });
|
||||
let game = purchase(f);
|
||||
assert.equal(game.response?.kind, 'emperorIncome');
|
||||
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
|
||||
game = applyAction(game, f.observer, { type: 'card', card: f.counter!, mode: 'cancel' });
|
||||
game = finishResponses(game);
|
||||
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
|
||||
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 4);
|
||||
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, [{ owner: f.observer, amount: 1 }]);
|
||||
assert.deepEqual(game.spiceBankerIncome!.sources.find(row => row.grant)!.source.bankLegs,
|
||||
[{ payer: f.seller, amount: 4 }]);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
});
|
||||
|
||||
void test('Advanced native bank contribution below four never combines unrelated buyer-to-seller payment for Banker', () => {
|
||||
const f = prepare({ banker: true, emperor: false });
|
||||
const game = finishResponses(purchase(f, 3, 8));
|
||||
assert.equal(balance(game, f.seller), balance(f.game, f.seller) + 2);
|
||||
assert.equal(balance(game, f.buyer), balance(f.game, f.buyer) - 5);
|
||||
assert.equal(balance(game, f.observer), balance(f.game, f.observer));
|
||||
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
|
||||
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), false);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
});
|
||||
|
||||
void test('Advanced native allowed Emperor contribution is faction income, never Banker bank income', () => {
|
||||
const f = prepare({ banker: true, counter: true });
|
||||
const game = finishResponses(purchase(f));
|
||||
const emperor = advancedNativePlayer(game, 'emperor').id;
|
||||
assert.equal(balance(game, emperor), balance(f.game, emperor) + 4);
|
||||
assert.equal(balance(game, f.seller), balance(f.game, f.seller));
|
||||
assert.deepEqual(viewGame(game, f.observer).spiceBankerIncome!.deferred, []);
|
||||
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), false);
|
||||
exactCard(game, f.buyer, f.card);
|
||||
});
|
||||
+36
-12
@@ -30,13 +30,13 @@ import {
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { sampleInventory, verifySampleCustody } from './sample-custody';
|
||||
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { nativeExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { privateOutputDirectory, sourceSnapshot } from './verification';
|
||||
|
||||
const DEFAULT_SEED = 20_260_926;
|
||||
const DEFAULT_MAX_ACTIONS = 3_500;
|
||||
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
|
||||
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills';
|
||||
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills';
|
||||
type Rules = 'basic' | 'advanced';
|
||||
|
||||
type Scenario = {
|
||||
@@ -275,6 +275,24 @@ const CHOAM_SKILLS_SCENARIOS: readonly Scenario[] = [
|
||||
roster: (['choam', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
];
|
||||
const RICHESE_SKILLS_SCENARIOS: readonly Scenario[] = [
|
||||
...(['basic', 'advanced'] as const).flatMap((rules, band) => [2, 3, 4, 5, 6].map((players): Scenario => ({
|
||||
ordinal: 254 + band * 5 + players - 2, profile: 'richese-skills', rules, expansions: ['choam'],
|
||||
roster: (['richese', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
||||
}))),
|
||||
...(['basic', 'advanced'] as const).flatMap((rules, band) => [2, 3, 4, 5, 6].map((players): Scenario => ({
|
||||
ordinal: 264 + band * 5 + players - 2, profile: 'richese-skills', rules, expansions: ['choam'],
|
||||
roster: (['richese', 'choam', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players),
|
||||
}))),
|
||||
...[3, 4, 5, 6].map((players): Scenario => ({
|
||||
ordinal: 274 + players - 3, profile: 'richese-skills', rules: 'advanced', expansions: ['ix', 'choam'],
|
||||
roster: (['richese', 'tleilaxu', 'choam', 'emperor', 'guild', 'harkonnen'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
...[2, 3, 4, 5, 6].map((players): Scenario => ({
|
||||
ordinal: 278 + players - 2, profile: 'richese-skills', rules: 'advanced', expansions: ['ix', 'choam'],
|
||||
roster: (['richese', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
];
|
||||
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
||||
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
||||
];
|
||||
@@ -308,7 +326,7 @@ const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [
|
||||
roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'] as FactionId[]).slice(0, players),
|
||||
})),
|
||||
];
|
||||
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills';
|
||||
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills';
|
||||
|
||||
type TraceEntry = {
|
||||
attempt: number;
|
||||
@@ -346,12 +364,12 @@ type Result = {
|
||||
function usage() {
|
||||
return (
|
||||
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
|
||||
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills] ' +
|
||||
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills] ' +
|
||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
||||
'[--resume FAILED_GAME.json]\n' +
|
||||
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
|
||||
'\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.' +
|
||||
'\nAll four native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use the documented native plus classic families; Ix-skills also selects Ixian+CHOAM, and tleilaxu-skills selects Tleilaxu+Ixian and three-to-six-seat Tleilaxu+Ixian+CHOAM. Advanced moritani-skills composes its existing non-Harkonnen classic assassination roster with all fourteen skills and the ecaz deck. Public starts and unrelated module/roster guards remain.'
|
||||
'\nThe four earlier native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use documented native plus classic families; Advanced moritani-skills composes its original non-Harkonnen classic assassination roster. Richese-skills adds Basic/Advanced Richese or paired CHOAM tables, Advanced Richese/Tleilaxu/CHOAM and optional Ix-deck tables without native Ixians. All retain fourteen skills and original cache/native inventories. Native Ixian Technology on Richese lots, own mixed No-Field, other roster/modules and public starts retain their separate guards.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,6 +414,11 @@ function scenarioName(scenario: Scenario) {
|
||||
return `${scenario.profile}-ixians-choam-${scenario.roster.length}-${scenario.rules}`;
|
||||
if (scenario.profile === 'tleilaxu-skills' && scenario.rules === 'advanced' && scenario.roster.includes('ixians'))
|
||||
return `${scenario.profile}-${scenario.roster.filter(faction => ['tleilaxu', 'ixians', 'choam'].includes(faction)).join('-')}-${scenario.roster.length}-${scenario.rules}`;
|
||||
if (scenario.profile === 'richese-skills') {
|
||||
const native = scenario.roster.filter(faction => ['richese', 'tleilaxu', 'choam'].includes(faction));
|
||||
const suffix = native.length > 1 ? '-' + native.join('-') : scenario.expansions.includes('ix') ? '-ix-deck' : '';
|
||||
return `${scenario.profile}${suffix}-${scenario.roster.length}-${scenario.rules}`;
|
||||
}
|
||||
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
|
||||
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
|
||||
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
|
||||
@@ -404,8 +427,8 @@ function scenarioName(scenario: Scenario) {
|
||||
|
||||
function parseProfile(value: string | undefined) {
|
||||
const profile = value ?? 'all';
|
||||
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills'].includes(profile))
|
||||
throw new Error('--profile must be all, base, choam, ecaz, ecaz-treachery, ecaz-occupy, moritani-assassinate, stronghold-factions, combined, combined-nexus, combined-homeworld-nexus, homeworld, nexus, homeworld-nexus, choam-roster, ecaz-roster, ix-roster, choam-nexus, ecaz-nexus, ix-nexus, moritani-skills, tleilaxu-skills, ix-skills or choam-skills.');
|
||||
if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills'].includes(profile))
|
||||
throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.');
|
||||
return profile as Profile | 'all';
|
||||
}
|
||||
|
||||
@@ -509,7 +532,7 @@ function resumedGame(path: string) {
|
||||
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
|
||||
);
|
||||
if (
|
||||
(game.leaderSkills && !basicExpansionLeaderSkillsProfile(game)) ||
|
||||
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game)) ||
|
||||
game.discoveryEnabled ||
|
||||
game.discoveries ||
|
||||
game.discoveryStash ||
|
||||
@@ -518,16 +541,16 @@ function resumedGame(path: string) {
|
||||
game.mentatQuestionPreview
|
||||
)
|
||||
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS].find(
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS].find(
|
||||
(candidate) =>
|
||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||
(candidate.profile === 'stronghold-factions') === !!game.strongholdCards &&
|
||||
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
||||
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview &&
|
||||
(candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced')) === !!game.moritaniAssassinatePreview &&
|
||||
(candidate.profile === 'ecaz-occupy') === !!game.ecazOccupyPreview &&
|
||||
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate &&
|
||||
(candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced')) === !!game.moritaniAssassinate &&
|
||||
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
||||
JSON.stringify(candidate.expansions) ===
|
||||
JSON.stringify(game.expansions) &&
|
||||
@@ -758,7 +781,8 @@ async function main() {
|
||||
: profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS
|
||||
: profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS
|
||||
: profile === 'ix-skills' ? IX_SKILLS_SCENARIOS
|
||||
: profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS : SCENARIOS;
|
||||
: profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS
|
||||
: profile === 'richese-skills' ? RICHESE_SKILLS_SCENARIOS : SCENARIOS;
|
||||
const selected = resume
|
||||
? [resume.scenario]
|
||||
: requestedSamples.filter(
|
||||
|
||||
@@ -24,7 +24,7 @@ async function main() {
|
||||
console.log(
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
|
||||
'\nLeader-skills retains existing classic and Basic native entries. Advanced native Ixians, Tleilaxu and/or CHOAM plus classic rosters use distinct required ix/choam decks and all fourteen skills; original setup, Face Dancers, Auditor exclusion and typed aftermath remain. Foreign-ghola revival never draws a new skill. A separate Advanced Moritani plus non-Harkonnen classic roster uses the ecaz deck, five native assignment discs and original assassination preview; winner skills finish before post-loss assassination. Explicit --mentat-question remains a private uniform preview; banker-income retains separate normal deferred income in these supported native subsets. Public activation, empty-hand/captured-replacement rulings, Ecaz/Duke skill assignment and other optional overlays remain pending.',
|
||||
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Explicit --mentat-question stays private; banker-income additionally supports native Richese and exact Advanced donor bank legs, never ordinary seller/allowed Emperor income. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Ecaz/Duke assignment and other pending module/rulings remain guarded.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user