Connect stable occupied Homeworld income sharing and native protections
This commit is contained in:
1 parent
84d2ba1d3d
commit
5e902e384d
24 files changed
+1535
-40
No files matched your search
@@ -150,6 +150,7 @@ export function BattlePreparation({
|
||||
before Atreides chooses prescience and the combatants seal their
|
||||
plans.
|
||||
</p>
|
||||
{preparation.blocked && <p className="notice">{preparation.blocked}</p>}
|
||||
<label htmlFor="voice-kind">
|
||||
Card type
|
||||
<select
|
||||
@@ -174,7 +175,7 @@ export function BattlePreparation({
|
||||
</label>
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy}
|
||||
disabled={busy || !!preparation.blocked}
|
||||
onClick={() => act({ type: 'voice', kind, must })}
|
||||
>
|
||||
Use the Voice
|
||||
|
||||
@@ -24,6 +24,7 @@ import { HomeworldShipment } from './homeworld-shipment';
|
||||
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
|
||||
import { CaladanReinforcement } from './caladan-reinforcement';
|
||||
import { GrummanCollection } from './grumman-collection';
|
||||
import { HomeworldOccupiedIncome } from './homeworld-occupied-income';
|
||||
import { TupileIntelligence } from './tupile-intelligence';
|
||||
import { NexusCards } from './nexus-cards';
|
||||
import { NexusTraitors } from './nexus-traitors';
|
||||
@@ -1095,6 +1096,16 @@ export function GameTable({
|
||||
</span>
|
||||
</div>
|
||||
{g.advanced && g.status !== 'lobby' && <AdvancedPreviewNotice compact />}
|
||||
{g.homeworldOccupationPreview && (
|
||||
<output className="notice block">
|
||||
Homeworld occupation development preview · continuous, uncontested
|
||||
qualification only. Printed bank Collection, immediate ally sharing
|
||||
and occupied native defenses are connected. Departure, replacement,
|
||||
contested/repopulated histories, percentage income and other unfinished
|
||||
occupied powers remain guarded. Not complete or certified Homeworld rules.{' '}
|
||||
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||||
</output>
|
||||
)}
|
||||
{g.nexusKullPreview ? (
|
||||
<p className="notice" role="status">
|
||||
CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible
|
||||
@@ -2456,6 +2467,8 @@ export function GameTable({
|
||||
? 'Caladan victory reinforcement'
|
||||
: g.decision.kind === 'grummanCollection'
|
||||
? 'Grumman Collection'
|
||||
: g.decision.kind === 'homeworldOccupiedIncome'
|
||||
? 'Occupied Homeworld bank income'
|
||||
: g.decision.kind === 'leaderSkillVisibility'
|
||||
? 'Position your skilled leader'
|
||||
: g.decision.kind === 'mentatQuestion'
|
||||
@@ -2781,6 +2794,8 @@ export function GameTable({
|
||||
<CaladanReinforcement game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'grummanCollection' ? (
|
||||
<GrummanCollection game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'homeworldOccupiedIncome' ? (
|
||||
<HomeworldOccupiedIncome offer={g.homeworldOccupiedIncome} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'ecazSpice' ? (
|
||||
g.ecazSpice?.allocation ? (
|
||||
<EcazSpice
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action } from '@/game/engine';
|
||||
import { homeworldCard } from '@/game/homeworld-cards';
|
||||
import type { HomeworldOccupiedIncomeOffer } from '@/game/homeworld-occupied-income';
|
||||
import { HomeworldFace } from './homeworld-cards';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export type HomeworldOccupiedIncomeProps = {
|
||||
offer: HomeworldOccupiedIncomeOffer | null;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
export function HomeworldOccupiedIncome({ offer, act, busy }: HomeworldOccupiedIncomeProps) {
|
||||
if (!offer) return null;
|
||||
return <IncomeChoice key={`${offer.event}:${offer.world}`} offer={offer} act={act} busy={busy} />;
|
||||
}
|
||||
|
||||
function IncomeChoice({ offer, act, busy }: HomeworldOccupiedIncomeProps & { offer: HomeworldOccupiedIncomeOffer }) {
|
||||
const [ownAmount, setOwnAmount] = useState(offer.amount);
|
||||
const valid = Number.isSafeInteger(ownAmount) && ownAmount >= offer.minOwnAmount && ownAmount <= offer.maxOwnAmount;
|
||||
const card = homeworldCard(offer.card)!;
|
||||
return (
|
||||
<section aria-label="Occupied Homeworld bank income" className="space-y-3">
|
||||
<h3>{offer.name} · occupied bank income</h3>
|
||||
<p>
|
||||
Collect {offer.amount} spice from the Bank. Keep all of it
|
||||
{offer.allyName ? `, or immediately allocate any portion to ${offer.allyName}.` : '; you have no reciprocal ally to share with.'}
|
||||
</p>
|
||||
<p className="fine">
|
||||
This is the printed Homeworld bank award, not a percentage of payments or a bribe from your personal spice.
|
||||
Homeworld effects cannot be canceled by Karama.
|
||||
</p>
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">Inspect {offer.name} source card</summary>
|
||||
<HomeworldFace card={card} />
|
||||
</details>
|
||||
{offer.blocked && <output className="notice block">{offer.blocked}</output>}
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!busy && !offer.blocked && valid)
|
||||
act({ type: 'decision', event: offer.event, world: offer.world, ownAmount });
|
||||
}}
|
||||
>
|
||||
{offer.ally && (
|
||||
<>
|
||||
<label className="block space-y-1">
|
||||
<span>Spice to keep · {offer.minOwnAmount}–{offer.maxOwnAmount}</span>
|
||||
<Input
|
||||
aria-label="Occupied bank spice to keep"
|
||||
type="number"
|
||||
min={offer.minOwnAmount}
|
||||
max={offer.maxOwnAmount}
|
||||
step={1}
|
||||
value={Number.isNaN(ownAmount) ? '' : ownAmount}
|
||||
disabled={busy || !!offer.blocked}
|
||||
onChange={(event) => setOwnAmount(event.target.value === '' ? Number.NaN : Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={busy || !!offer.blocked} onClick={() => setOwnAmount(offer.amount)}>
|
||||
Keep all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={busy || !!offer.blocked} onClick={() => setOwnAmount(0)}>
|
||||
Give all to {offer.allyName}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<output className="notice block" aria-live="polite">
|
||||
{valid
|
||||
? `Bank credits: ${offer.ownerName} ${ownAmount}${offer.allyName ? ` · ${offer.allyName} ${offer.amount - ownAmount}` : ''}. Total ${offer.amount}.`
|
||||
: `Keep a whole number from ${offer.minOwnAmount} through ${offer.maxOwnAmount}.`}
|
||||
</output>
|
||||
<Button type="submit" className="game-action" disabled={busy || !!offer.blocked || !valid}>
|
||||
Settle {offer.name} bank award
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,37 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Stable Homeworld occupation — bounded Development Verified:**
|
||||
fresh original2..6 Basic/Advanced setup/selected decks and Homeworlds alone;
|
||||
no played-game conversion or inferred current controller. Continuously present
|
||||
sole qualification, without competing/departed/contested/restored-high history,
|
||||
supports actual bank Collection/immediate ally allocation and printed Wallach/
|
||||
Tleilax/Grumman/Salusa protections without physical star conversion.
|
||||
Original board/shared allocation/phase order and once-only credit receipts
|
||||
remain; actual Homeworld portions feed Giedi once. Original dispatcher capture
|
||||
and zero-icon income admission defects are repaired, not their source guards.
|
||||
Twenty-four original setups,21 new/262 affected cases, types/lint/build and
|
||||
original-phase bank/native programs/four legal keep-all policies pass.
|
||||
|
||||
Fifty genuine2..6 samples attempted on unchanged09f8b0d tree:37 complete with
|
||||
11,762 accepted actions/300 JSON continuations, including153 rejected candidates
|
||||
(not a zero-rejection certificate). All50 total19,080 accepted/19,283 attempts/
|
||||
491 JSON continuations. Two preserved zero-award captures finish7/14 more actions
|
||||
at their original seeds on correction treeeed8f319. **39/50 finish**; nine
|
||||
occupation/Salusa-combat and two low-Tupile CHOAM source-ruling captures remain
|
||||
preserved, with no rerun to hide failures, forced outcome or guard bypass.
|
||||
|
||||
Original CLI **CPBXKFGD v14/setup** retains actual offers/cards/actors. Real
|
||||
turn-two Nexus earns Guild/Harkonnen alliance. Human390px **v21/Mentat** shares
|
||||
Caladan2 as1/1, gives Wallach1 to Harkonnen and awards Giedi2 once: Guild3→4,
|
||||
Harkonnen8→12; original readiness reaches Mentat. Refreshed original source
|
||||
cards/notice and force inspector show18 Guild reserves/0 Tanks/two separate
|
||||
foreign visitors, no root390px overflow. Controlled positions are labelled;
|
||||
only dedicated QA changes, no reset. Owned temporary scripts are removed.
|
||||
Percentage receipts, other occupied powers, unresolved occupation/CHOAM
|
||||
rulings, public starts, save conversion and full/deployed assurance remain.
|
||||
[Exact source and evidence](HOMEWORLD_OCCUPATION_RULES.md#stable-continuous-occupation-prototype--5-october-2026).
|
||||
|
||||
**Standalone E3 Nexus / Skills / selected Tech/Strongholds — bounded Development Verified:**
|
||||
native Ecaz OR Moritani plus classics, exact ecaz33/all14/all12,
|
||||
Basic/Advanced2..6; original Tech3+ and/or Advanced Strongholds2+, both3+.
|
||||
|
||||
@@ -2,6 +2,84 @@
|
||||
|
||||
Runtime follow-up, 10 September 2026: [Tupile intelligence and qualification history](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupation-observation-boundaries) implement confirmed observations while retaining the unresolved entitlement and expiry questions below. The source audit remains historical evidence.
|
||||
|
||||
## Stable continuous occupation prototype — 5 October 2026
|
||||
|
||||
The fresh local `homeworld-occupation` entry uses original two-through-six-seat
|
||||
Basic/Advanced setup, selected faction decks, thirteen physical card facts and
|
||||
the original qualification history. It never converts a played Homeworld save
|
||||
or infers entitlement from a current garrison. One original qualifier must
|
||||
remain continuously present since qualification, without another foreign army,
|
||||
another qualifier, departure or restored native high threshold. All unresolved
|
||||
histories retain the existing source guards; this is a supported unambiguous
|
||||
subset, not an expiry/replacement ruling.
|
||||
|
||||
Printed occupied **bank icons** now enter one Collection queue before ordinary
|
||||
Collection mutates balances. After original board/shared allocations and phase
|
||||
opening, each occupier may keep the award or immediately allocate a portion to
|
||||
its current reciprocal ally. Zero-icon/no-ally rows settle their only legal
|
||||
allocation automatically. Actual bank credits and the cursor/receipt commit
|
||||
together; reading a view or replaying an old command cannot mint spice.
|
||||
The bank queue does not implement Kaitain/Junction/Richese percentage receipts.
|
||||
The original Giedi hook counts actual allocated Homeworld credits once, rather
|
||||
than printed capacity; applying that hook to an immediately shared bank portion
|
||||
is explicit source composition, not a newly retrieved worked FAQ.
|
||||
|
||||
The same proved entitlement supplies Wallach IX Voice immunity, Tleilax
|
||||
Face Dancer exclusions, Grumman reveal immunity and occupied Salusa suppression.
|
||||
Both occupier and current reciprocal ally receive the three targeted protections.
|
||||
Sardaukar keep their original starred identity/custody but fight at strength one;
|
||||
no Karama opportunity can restore the printed occupied penalty.
|
||||
Known Voice immunity skips that declaration; ambiguous Voice permits only a
|
||||
source-blocked choice/decline. Ambiguous Terror keeps an owned blocked Reveal/
|
||||
Decline window, rather than silently authorizing revelation or guessing an owner.
|
||||
|
||||
Twenty-four original three-seat setup programs,21 new/262 affected cases,
|
||||
types/lint and build pass. Actual original-phase runtime proves partial bank
|
||||
allocation (owner1/ally1 plus one high-Giedi2), immutable replay rejection and
|
||||
four legal keep-all policies. The original captured decision is retained through
|
||||
the generic dispatcher; the reproduced clearing error is fixed. Native programs
|
||||
exercise real protected battles, two actual strength-one starred counters,
|
||||
an original matching Face Dancer with labelled conserved unused-deck exchange,
|
||||
and real first-Mentat Robbery placement followed by paid second-Movement
|
||||
three-force occupier entry without token revelation.
|
||||
|
||||
Fifty genuine classic/paired E1/E2/standalone E3 games were attempted on
|
||||
unchanged tree `09f8b0dcdc54bf284f234c6e0f27936689cce1bb1a7b6381548ee13a149d2dae`.
|
||||
Thirty-seven complete11,762 accepted actions/300 JSON continuations; those
|
||||
complete samples include153 rejected candidates, so this is not a zero-rejection
|
||||
or complete-mode certificate. All50 contain19,080 accepted/19,283 attempted
|
||||
actions and491 JSON continuations. Thirteen captures retain the actual blockers.
|
||||
Two were a source-clear bug: zero-icon Salusa needlessly blocked unrelated bank
|
||||
Collection. Quantity-zero ambiguity now grants nothing without choosing a
|
||||
recipient, while its mandatory combat-strength guard remains. A meaningful
|
||||
regression preserves a real Kaitain2 grant and the unresolved Salusa penalty.
|
||||
The two original snapshots finish7/14 more accepted actions without rejection
|
||||
at original scenario seeds20263435/20263443 on unchanged correction tree
|
||||
`eed8f319ece875181b9412ded145189691db3a8ca56fa6ff00e11f5ca11965ee`.
|
||||
Resume restarts that seed, not the lost pre-capture random stream.
|
||||
Thirty-nine of the original50 now finish; nine occupation/departure/competition/
|
||||
repopulation or Salusa-combat captures and two low-Tupile CHOAM captures remain
|
||||
at their existing pending rulings. No duplicate question, favorable new seed,
|
||||
forced outcome or guard bypass.
|
||||
|
||||
Original backed-up CLI **CPBXKFGD v14/setup** preserves actual prediction/
|
||||
Traitor/card/actor custody. A real second-turn worm/Nexus earns Guild/Harkonnen
|
||||
alliance before labelled conserved positions. Human390px **v21/Mentat** settles
|
||||
Caladan2 as Guild1/Harkonnen1, then Wallach1 wholly to Harkonnen, with one Giedi2:
|
||||
Guild3→4, Harkonnen8→12. Queue/world/event continue once; original phase-ready
|
||||
actions reach Mentat. Refreshed source cards/notice/force inspector show Guild18
|
||||
native reserves/0 Tanks/one visitor on each world, with no root390px overflow.
|
||||
Only this QA room changed. An early driver staging error was corrected by
|
||||
settling the original foresight response first; no Caladan rule was changed.
|
||||
Private receipts/reports stay outside Git; owned throwaway scripts were removed.
|
||||
|
||||
See [runtime bank regression](../tests/homeworld-occupation-runtime.test.ts),
|
||||
[income cases](../tests/homeworld-occupied-income.test.ts) and
|
||||
[native defense cases](../tests/homeworld-occupied-defenses.test.ts).
|
||||
The historical pending questions below, percentage/borrowed occupied powers,
|
||||
full-module assurance, public starts and deployed acceptance remain open.
|
||||
|
||||
|
||||
Source audit, 9 September 2026. This supplements [Homeworld rules](HOMEWORLD_RULES.md#occupier-lifecycle-and-benefits) and the [invasion audit](HOMEWORLD_INVASION_RULES.md#occupation-evidence-and-unresolved-lifecycle). It defines the evidence that can safely be recorded; it does **not** settle every occupied benefit or authorize a complete occupation release. The [integration audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) maps the physical mutation and recovery boundaries.
|
||||
|
||||
## Confirmed publisher contract
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
Source audit, 9 September 2026. No runtime activation. The primary module source is the [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), especially printed pp.4, 9–10 and the Homeworld FAQ on pp.15–16. The local publisher-text transcription is `/tmp/dune-rules/ecaz-audit.txt`; fresh official indexed pp.9, 10 and 15 were checked against it. Direct PDF retrieval currently returns HTTP 403. Physical card faces are separately audited in [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); a rulebook statement and a card-face statement are not interchangeable evidence.
|
||||
|
||||
### Stable occupation runtime follow-up — 5 October 2026
|
||||
|
||||
Original fresh `homeworld-occupation` setup now connects continuously proved,
|
||||
uncontested bank Collection/immediate ally sharing and the printed Wallach/
|
||||
Tleilax/Grumman/Salusa occupied protections. The immutable qualification history
|
||||
remains distinct from present armies; departure, replacement, contested or
|
||||
repopulated histories retain their pending guards. Percentage receipts and
|
||||
other occupied powers remain separate. This does not open normal starts,
|
||||
convert old saves or certify Homeworlds. See the
|
||||
[exact supported source/runtime subset](HOMEWORLD_OCCUPATION_RULES.md#stable-continuous-occupation-prototype--5-october-2026).
|
||||
|
||||
## Module scope and component inventory
|
||||
|
||||
There are **13 double-sided Homeworld cards and 13 corresponding planet discs for twelve factions**, not twelve of each. Emperor uses Kaitain and, in Advanced, Salusa Secundus. Only the seated factions' components enter the game. E3 permits the module independently of the selected factions and independently of Nexus cards, Discovery tokens and new Treachery cards. It does not require either Ecaz or Moritani to be seated, or all expansion factions to be present. [E3 pp.4, 9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 5 October 2026 — stable occupied Homeworld bank income and protections
|
||||
|
||||
Fresh original `homeworld-occupation` enters Basic/Advanced2..6 and selected
|
||||
faction decks with Homeworlds alone. One original continuously present qualifier,
|
||||
no other foreign army/departure/competing qualifier/restored-high history,
|
||||
supports the unambiguous printed bank/defensive effects. Original qualification,
|
||||
physical native population and current garrisons stay separate. No expiry/
|
||||
replacement policy or old-save conversion is introduced.
|
||||
|
||||
Two bounded owners supplied bank queue/component and printed defensive cases;
|
||||
parent integrated original setup/CLI/samples, source history, typed combat/
|
||||
Voice/FaceDance/Terror, Collection/actual Giedi sources, controls and legal bots.
|
||||
Original captured decision survives the generic dispatcher. Salusa's zero bank
|
||||
icons no longer block unrelated income; its unresolved combat penalty still
|
||||
does. New21/affected262, types/lint/build,24 original setups and real source
|
||||
programs/four legal keep-all policies pass; final clean-Movement regression1/1.
|
||||
|
||||
Fifty genuine classic/paired E1/E2/standalone E3 samples ran on unchanged
|
||||
`09f8b0dcdc54bf284f234c6e0f27936689cce1bb1a7b6381548ee13a149d2dae`.
|
||||
37 complete11,762 accepted/300 JSON continuations, with153 rejected candidates.
|
||||
All50 total19,080 accepted/19,283 attempts/491 JSON continuations; not a
|
||||
zero-rejection certificate. Two exact zero-award captures finish7/14 more
|
||||
accepted actions/no rejection at original scenario seeds on correction tree
|
||||
`eed8f319ece875181b9412ded145189691db3a8ca56fa6ff00e11f5ca11965ee`.
|
||||
Resume restarts the same seed, not the lost original stream.39/50 finish;
|
||||
nine occupation/Salusa-combat and two low-Tupile CHOAM ruling captures remain
|
||||
preserved, not replayed to hide them or forced into invented outcomes.
|
||||
|
||||
Original backed-up CLI CPBXKFGD v14 retains actual setup/cards/actors. A real
|
||||
second-turn worm/Nexus earns Guild/Harkonnen alliance before labelled conserved
|
||||
positions. Human390px v21/Mentat settles Caladan2 as1/1, then Wallach1 to ally,
|
||||
with Giedi2 once: Guild3→4/Harkonnen8→12. Source cards, warning, refresh and
|
||||
force inspector show18 Guild reserves/0 Tanks/one visitor on each world with
|
||||
no root overflow. Original readiness reaches Mentat. Native runtime programs
|
||||
exercise protected Voice, two strength-one real starred counters/no Karama
|
||||
restore, an original matching Face Dancer conserved from the unused deck and
|
||||
actual first-Mentat Robbery then paid three-force protected second-Movement entry.
|
||||
|
||||
Only dedicated QA changes; no reset/credential disclosure/assurance campaign.
|
||||
Owned throwaway scripts removed; reports/private receipts remain outside Git.
|
||||
Percentage receipts, other occupied powers, unresolved rulings, public/save
|
||||
conversion, complete-mode and deployed acceptance remain open.
|
||||
[Canonical source and exact evidence](HOMEWORLD_OCCUPATION_RULES.md#stable-continuous-occupation-prototype--5-october-2026).
|
||||
|
||||
## 5 October 2026 — standalone E3 Nexus, Skills and original components
|
||||
|
||||
Original `leader-skills --nexus-cards` now admits Ecaz OR Moritani/classic
|
||||
|
||||
@@ -155,6 +155,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
|
||||
|
||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||
| --- | --- | --- |
|
||||
| Stable Homeworld occupation | Fresh original Homeworlds alone, original setup and qualification history. Admit only one continuously present qualifier without another foreign army, departure, competing qualification or restored native high threshold. Actual bank icons/immediate reciprocal-ally allocation and Wallach/Tleilax/Grumman/Salusa protections compose source-clear cases; no current-controller shortcut or expiry/replacement ruling. Zero-icon ambiguity cannot block unrelated currency awards or select combat strength. Paid percentages, remaining borrowed powers, pending rulings and public/save conversion stay separate. | [Source and bounded runtime evidence](HOMEWORLD_OCCUPATION_RULES.md#stable-continuous-occupation-prototype--5-october-2026) |
|
||||
| Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) |
|
||||
| Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) |
|
||||
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
|
||||
|
||||
@@ -31,6 +31,7 @@ const decisions = {
|
||||
leaderSkillRevival: true,
|
||||
homeworldRevivalDeployment: true,
|
||||
grummanCollection: true,
|
||||
homeworldOccupiedIncome: true,
|
||||
caladanReinforcement: true,
|
||||
choamAudit: true,
|
||||
nexusChoamInspection: true,
|
||||
|
||||
+6
-1
@@ -2685,6 +2685,11 @@ function policyActions(g: GameView): Action[] {
|
||||
return caladanReinforcementActions(g);
|
||||
if (d.kind === 'grummanCollection')
|
||||
return grummanCollectionActions(g);
|
||||
if (d.kind === 'homeworldOccupiedIncome') {
|
||||
const offer = g.homeworldOccupiedIncome;
|
||||
return offer && !offer.blocked ? [{ type: 'decision', event: offer.event,
|
||||
world: offer.world, ownAmount: offer.amount }] : [];
|
||||
}
|
||||
if (d.kind === 'battleCards')
|
||||
return [
|
||||
{
|
||||
@@ -3625,7 +3630,7 @@ function policyActions(g: GameView): Action[] {
|
||||
if (b.preparation) {
|
||||
if (b.preparation.owner !== me.id) return [];
|
||||
if (b.preparation.kind === 'voice')
|
||||
return level === 0
|
||||
return level === 0 || b.preparation.blocked
|
||||
? [{ type: 'declineBattlePower' }]
|
||||
: [
|
||||
{
|
||||
|
||||
+185
-29
@@ -92,7 +92,9 @@ import { quoteHomeworldBattleRules } from './homeworld-battle-rules';
|
||||
import type { HomeworldForces } from './homeworld-custody';
|
||||
import { nativeReserveSources } from './homeworld-options';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { lowGrummanRevealBlock } from './homeworld-collection';
|
||||
import { lowGrummanRevealBlock, type HomeworldCollectionSource } from './homeworld-collection';
|
||||
import { quoteOccupiedHomeworldVoice, quoteOccupiedHomeworldFaceDancer, quoteOccupiedHomeworldTerror, occupiedHomeworldSardaukarStatus } from './homeworld-occupied-defenses';
|
||||
import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homeworldOccupiedIncomeOffer, validateHomeworldOccupiedIncomeState, type HomeworldOccupiedIncomeState } from './homeworld-occupied-income';
|
||||
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
|
||||
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
@@ -744,6 +746,7 @@ export type Battle = {
|
||||
kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
|
||||
owner: string;
|
||||
beneficiary: string;
|
||||
blocked?: string;
|
||||
};
|
||||
prescience?: {
|
||||
player: string;
|
||||
@@ -772,6 +775,7 @@ export type Decision =
|
||||
| { kind: 'leaderSkillRevival'; player: string; event: string }
|
||||
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
|
||||
| { kind: 'grummanCollection'; player: string; event: string }
|
||||
| { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string }
|
||||
| { kind: 'caladanReinforcement'; player: string; event: string }
|
||||
| { kind: 'choamAudit'; player: string; event: string }
|
||||
| { kind: 'nexusChoamInspection'; player: string; event: string }
|
||||
@@ -1869,6 +1873,10 @@ export type Game = {
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
|
||||
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
||||
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
|
||||
homeworldOccupationPreview?: true;
|
||||
pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null;
|
||||
homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[];
|
||||
tupileIntelligence?: TupileIntelligenceState;
|
||||
pendingTech?: { player: string; loser: string; choices: TechId[] } | null;
|
||||
summonedBeforeBlow?: boolean;
|
||||
@@ -7135,6 +7143,14 @@ function homeworldDefenseIntegrity(g: Game) {
|
||||
}
|
||||
}
|
||||
function faceDanceReturnBlock(g: Game, winner: string): string | null {
|
||||
const protectedWinner = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, winner));
|
||||
if (protectedWinner.blocked) return protectedWinner.blocked;
|
||||
const coside = g.pendingFaceDance?.winner === winner ? g.pendingFaceDance.winners : undefined;
|
||||
if (coside) for (const target of coside) {
|
||||
if (target === winner) continue;
|
||||
const quote = homeworldRule(() => quoteOccupiedHomeworldFaceDancer(g, target));
|
||||
if (quote.blocked) return quote.blocked;
|
||||
}
|
||||
return g.advanced && g.homeworlds && getPlayer(g, winner).faction === 'emperor'
|
||||
? 'Face Dance returning the Emperor’s army awaits the Kaitain/Salusa reserve-placement ruling.' : null;
|
||||
}
|
||||
@@ -7299,10 +7315,13 @@ function combatForces(
|
||||
const army = combatArmy(g, p.id, t);
|
||||
const physical = army.normal + army.elite;
|
||||
const elite = army.elite;
|
||||
const occupation = g.homeworldOccupationPreview && p.faction === 'emperor'
|
||||
? homeworldRule(() => occupiedHomeworldSardaukarStatus(g)) : undefined;
|
||||
requireRule(!occupation?.blocked, occupation?.blocked ?? 'Occupied Salusa combat is unresolved.');
|
||||
return {
|
||||
normal: physical + prospective - elite,
|
||||
...(nexusSardaukarEffective(g,p.id,t) ? {temporaryElite:nexusSardaukarEffective(g,p.id,t)} : {}),
|
||||
...(homeworldSardaukarFreeSupport(g, p.id)
|
||||
...(!occupation?.suppressed && homeworldSardaukarFreeSupport(g, p.id)
|
||||
? { eliteFreeSupport: true }
|
||||
: {}),
|
||||
normalFixedHalf: p.faction === 'ixians' && !nexusSuboidsActive(g, g.nexusSuboidHistory, p.id),
|
||||
@@ -7312,6 +7331,7 @@ function combatForces(
|
||||
(!g.advanced && p.faction !== 'ixians') ||
|
||||
(g.battle?.eliteBlocked?.includes(p.id) &&
|
||||
!(g.advanced && p.faction === 'ixians')) ||
|
||||
occupation?.suppressed ||
|
||||
(p.faction === 'emperor' && opponent.faction === 'fremen')
|
||||
? 1
|
||||
: 2,
|
||||
@@ -8455,6 +8475,26 @@ export function initializeHomeworldGameForAudit(state: Game): Game {
|
||||
);
|
||||
return initializeSetupGameForAudit(state, true);
|
||||
}
|
||||
/** Fresh occupation prototype; ambiguity remains source-gated rather than assigned a controller. */
|
||||
export function initializeHomeworldOccupationGameForAudit(state: Game): Game {
|
||||
requireRule(state.homeworlds?.custody === null && !state.homeworldOccupationPreview &&
|
||||
!state.homeworldOccupationHistory && !state.nexusCards && !state.leaderSkills &&
|
||||
!state.techTokens && !state.strongholdCards && !state.discoveryEnabled &&
|
||||
!state.discoveries && !state.discoveryStash && !state.greatMaker && !state.ecazTreachery &&
|
||||
typeof state.advanced === 'boolean' && state.expansions.length <= 3 &&
|
||||
state.expansions.every((id, index) => (id === 'ix' || id === 'choam' || id === 'ecaz') &&
|
||||
state.expansions.indexOf(id) === index) &&
|
||||
state.players.every(p => FACTIONS.some(f => f.id === p.faction &&
|
||||
(f.expansion === 'base' || state.expansions.includes(f.expansion)))),
|
||||
'Occupation requires a fresh Homeworld lobby with original selected faction decks and no other modules.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
const g = initializeSetupGameForAudit(state, true, false, false, false, false, false, true);
|
||||
g.homeworldOccupationPreview = true;
|
||||
g.pendingHomeworldOccupiedIncome = null;
|
||||
g.homeworldOccupiedIncomeHistory = [];
|
||||
return g;
|
||||
}
|
||||
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
|
||||
export function initializeNexusGameForAudit(state: Game): Game {
|
||||
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
|
||||
@@ -14057,7 +14097,7 @@ function quoteMovementArrival(
|
||||
players: g.players,
|
||||
order: move,
|
||||
ambassadors: g.ecazAmbassadors?.tokens ?? [],
|
||||
terror: homeworldTerrorEntryBlock(g, move.total)
|
||||
terror: homeworldTerrorEntryBlock(g, move.player, move.total, 'trigger')
|
||||
? [] : (g.moritaniTerror?.tokens ?? []),
|
||||
flight: g.ornithopter,
|
||||
controls: {
|
||||
@@ -14130,7 +14170,7 @@ function quoteShipmentArrival(
|
||||
wantsFighters: false,
|
||||
},
|
||||
ambassadors: g.ecazAmbassadors?.tokens ?? [],
|
||||
terror: homeworldTerrorEntryBlock(g, amount)
|
||||
terror: homeworldTerrorEntryBlock(g, p.id, amount, 'trigger')
|
||||
? []
|
||||
: (g.moritaniTerror?.tokens ?? []),
|
||||
controls: {
|
||||
@@ -14583,7 +14623,7 @@ function openTerritoryEntry(
|
||||
const terrorTokens = moritani &&
|
||||
entrant.id !== moritani.id &&
|
||||
entrant.id !== moritani.ally &&
|
||||
!homeworldTerrorEntryBlock(g, amount)
|
||||
!homeworldTerrorEntryBlock(g, entrant.id, amount, 'trigger')
|
||||
? g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to) ?? []
|
||||
: [];
|
||||
requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador,
|
||||
@@ -15212,7 +15252,7 @@ function validateGuildAdvisorEntry(
|
||||
moritani &&
|
||||
moritani.id !== bg.id &&
|
||||
moritani.ally !== bg.id &&
|
||||
!homeworldTerrorEntryBlock(g, quote.amount) &&
|
||||
!homeworldTerrorEntryBlock(g, bg.id, quote.amount, 'trigger') &&
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(t) => t.status === 'placed' && t.location === quote.territory,
|
||||
);
|
||||
@@ -15420,7 +15460,7 @@ function ambassadorRelocationArrivalBlock(
|
||||
moritani &&
|
||||
mover.id !== moritani.id &&
|
||||
mover.id !== moritani.ally &&
|
||||
!homeworldTerrorEntryBlock(g, entering) &&
|
||||
!homeworldTerrorEntryBlock(g, mover.id, entering, 'trigger') &&
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(token) => token.status === 'placed' && token.location === to,
|
||||
);
|
||||
@@ -16157,12 +16197,14 @@ function terrorEntryIntegrity(g: Game) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Public original batch size; concealed No-Field markers count as one. */
|
||||
function homeworldTerrorEntryBlock(g: Game, entering: number): string | null {
|
||||
/** Unknown occupation retains an owned Reveal/Decline window; it never authorizes revelation. */
|
||||
function homeworldTerrorEntryBlock(g: Game, entrant: string, entering: number, mode: 'trigger' | 'reveal'): string | null {
|
||||
const owner = byFaction(g, 'moritani');
|
||||
return owner
|
||||
? homeworldRule(() => lowGrummanRevealBlock(g, owner.id, entering))
|
||||
: null;
|
||||
if (!owner) return null;
|
||||
const occupation = homeworldRule(() => quoteOccupiedHomeworldTerror(g, entrant));
|
||||
if (occupation.status === 'prohibited' || mode === 'reveal' && occupation.blocked)
|
||||
return occupation.blocked;
|
||||
return homeworldRule(() => lowGrummanRevealBlock(g, owner.id, entering));
|
||||
}
|
||||
/** Entry is already paid and committed; a reaction must never replay its original action. */
|
||||
function openTerrorEntry(
|
||||
@@ -16189,7 +16231,7 @@ function openTerrorEntry(
|
||||
const token = tokens[0];
|
||||
if (!moritani || !token || entrant.id === moritani.id ||
|
||||
(entrant.id === moritani.ally && !originallyTriggered) ||
|
||||
homeworldTerrorEntryBlock(g, amount))
|
||||
homeworldTerrorEntryBlock(g, entrant.id, amount, 'trigger'))
|
||||
return false;
|
||||
requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.');
|
||||
requireRule(
|
||||
@@ -16287,7 +16329,7 @@ function terrorRevealBlocked(
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
kind: TerrorKind,
|
||||
): string | null {
|
||||
const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount);
|
||||
const homeworldBlock = homeworldTerrorEntryBlock(g, entry.entrant, entry.amount, 'reveal');
|
||||
if (homeworldBlock) return homeworldBlock;
|
||||
if (kind === 'atomics') {
|
||||
try { currentAtomicsQuote(g, entry); return null; }
|
||||
@@ -17920,10 +17962,10 @@ function currentEcazCollectionQuote(
|
||||
);
|
||||
return quote;
|
||||
}
|
||||
function creditGiediCollection(g: Game, player: string, desert: number) {
|
||||
function creditGiediCollection(g: Game, player: string, source: HomeworldCollectionSource) {
|
||||
if (!g.homeworlds?.custody || getPlayer(g, player).faction !== 'harkonnen') return;
|
||||
const quote = homeworldRule(() => quoteGiediCollectionReceipt(
|
||||
g, g.turn, player, [{ kind: 'desert', amount: desert }], g.giediCollection,
|
||||
g, g.turn, player, [source], g.giediCollection,
|
||||
));
|
||||
const p = getPlayer(g, player);
|
||||
requireRule(Number.isSafeInteger(p.spice + quote.amount),
|
||||
@@ -17931,7 +17973,7 @@ function creditGiediCollection(g: Game, player: string, desert: number) {
|
||||
p.spice += quote.amount;
|
||||
g.giediCollection = quote.receipt;
|
||||
if (quote.amount)
|
||||
log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert collection once this phase.',
|
||||
log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert or Homeworld Collection once this phase.',
|
||||
{ faction: 'harkonnen', name: 'Giedi Prime collection' });
|
||||
}
|
||||
/** Qualification history records facts only; disputed expiry and benefits remain separate. */
|
||||
@@ -17953,6 +17995,94 @@ function homeworldHistoryIntegrity(g: Game) {
|
||||
requireRule(!!choam === (g.tupileIntelligence !== undefined), 'The original Tupile intelligence ledger is missing.');
|
||||
if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn));
|
||||
} else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.');
|
||||
homeworldOccupiedIncomeIntegrity(g);
|
||||
}
|
||||
function homeworldOccupiedIncomeIntegrity(g: Game) {
|
||||
const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory;
|
||||
if (!g.homeworldOccupationPreview) {
|
||||
requireRule(pending === undefined && history === undefined,
|
||||
'Occupied income requires its original fresh occupation profile.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.homeworldOccupationPreview === true && g.homeworlds && !g.nexusCards &&
|
||||
!g.leaderSkills && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled &&
|
||||
Array.isArray(history) && pending !== undefined,
|
||||
'The fresh occupation profile lost its original Homeworld module or income ledger.');
|
||||
const turns = new Set<number>();
|
||||
for (const receipt of history) {
|
||||
requireRule(receipt.turn <= g.turn && !turns.has(receipt.turn) &&
|
||||
receipt.cursor === receipt.queue.length, 'Occupied Collection was incomplete or repeated.');
|
||||
homeworldRule(() => validateHomeworldOccupiedIncomeState(receipt, g, receipt.turn));
|
||||
turns.add(receipt.turn);
|
||||
}
|
||||
const decision = g.decision?.kind === 'homeworldOccupiedIncome' ? g.decision : null;
|
||||
if (!pending) {
|
||||
requireRule(!decision, 'The occupied bank choice lost its original Collection receipt.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.status === 'playing' && g.phase === 7 && pending.turn === g.turn &&
|
||||
!turns.has(pending.turn), 'Occupied bank income belongs to one original Collection phase.');
|
||||
homeworldRule(() => validateHomeworldOccupiedIncomeState(pending, g, g.turn));
|
||||
if (decision) {
|
||||
const row = pending.queue[pending.cursor];
|
||||
requireRule(row && decision.event === pending.event && decision.world === row.world &&
|
||||
decision.player === row.occupier, 'The bank choice lost its original world or beneficiary.');
|
||||
}
|
||||
}
|
||||
function homeworldOccupiedIncomeAutomatic(g: Game): boolean {
|
||||
return !!(g.homeworldOccupationPreview && g.pendingHomeworldOccupiedIncome &&
|
||||
g.status === 'playing' && g.phase === 7 && !g.phaseOpening && !g.decision &&
|
||||
!g.response && !g.truthtrance && !g.pendingTreacheryDiscard && !g.pendingNullentropy &&
|
||||
!g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama &&
|
||||
(!g.ecazCollection || g.ecazCollection.stage === 'complete'));
|
||||
}
|
||||
function commitHomeworldOccupiedIncome(g: Game, actor: string, event: string, world: string, own: number) {
|
||||
const pending = g.pendingHomeworldOccupiedIncome!;
|
||||
const result = homeworldRule(() => quoteHomeworldOccupiedIncomeChoice(pending, g, actor, event, world, own));
|
||||
for (const credit of result.credits)
|
||||
requireRule(Number.isSafeInteger(getPlayer(g, credit.player).spice + credit.amount),
|
||||
'Occupied bank Collection would overflow a spice balance.');
|
||||
g.pendingHomeworldOccupiedIncome = result.state;
|
||||
for (const credit of result.credits) {
|
||||
getPlayer(g, credit.player).spice += credit.amount;
|
||||
creditGiediCollection(g, credit.player, { kind: 'homeworld', amount: credit.amount });
|
||||
log(g, `${getPlayer(g, credit.player).name} received ${credit.amount} spice from ${world}'s occupied bank award.`,
|
||||
{ faction: getPlayer(g, credit.player).faction, name: 'Occupied Homeworld Collection' });
|
||||
}
|
||||
}
|
||||
function resumeHomeworldOccupiedIncome(g: Game) {
|
||||
if (!homeworldOccupiedIncomeAutomatic(g)) return;
|
||||
homeworldOccupiedIncomeIntegrity(g);
|
||||
while (g.pendingHomeworldOccupiedIncome) {
|
||||
const pending = g.pendingHomeworldOccupiedIncome;
|
||||
const row = pending.queue[pending.cursor];
|
||||
if (!row) {
|
||||
g.homeworldOccupiedIncomeHistory!.push(pending);
|
||||
g.pendingHomeworldOccupiedIncome = null;
|
||||
return;
|
||||
}
|
||||
if (row.ally && row.spice > 0) {
|
||||
g.decision = { kind: 'homeworldOccupiedIncome', player: row.occupier,
|
||||
event: pending.event, world: row.world };
|
||||
return;
|
||||
}
|
||||
commitHomeworldOccupiedIncome(g, row.occupier, pending.event, row.world, row.spice);
|
||||
}
|
||||
}
|
||||
function decideHomeworldOccupiedIncome(g: Game, p: Player,
|
||||
decision: Extract<Decision, { kind: 'homeworldOccupiedIncome' }>, action: Action) {
|
||||
homeworldOccupiedIncomeIntegrity(g);
|
||||
requireRule(decision.player === p.id &&
|
||||
action.type === 'decision' && action.event === decision.event && action.world === decision.world &&
|
||||
Object.keys(action).sort().join(',') === 'event,ownAmount,type,world',
|
||||
'Choose only the original occupied bank event, world and own allocation.');
|
||||
commitHomeworldOccupiedIncome(g, p.id, decision.event, decision.world, action.ownAmount as number);
|
||||
g.decision = null;
|
||||
resumeHomeworldOccupiedIncome(g);
|
||||
}
|
||||
function projectedHomeworldOccupiedIncome(g: Game, player: string) {
|
||||
if (g.decision?.kind !== 'homeworldOccupiedIncome' || !g.pendingHomeworldOccupiedIncome) return null;
|
||||
return homeworldRule(() => homeworldOccupiedIncomeOffer(g.pendingHomeworldOccupiedIncome!, g, player));
|
||||
}
|
||||
function tupileIntelligenceBlock(g: Game): string | null {
|
||||
if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory)
|
||||
@@ -18016,7 +18146,7 @@ function stageGrummanCollection(g: Game) {
|
||||
function grummanCollectionAutomatic(g: Game): boolean {
|
||||
const frame = g.grummanCollection;
|
||||
return !!(frame?.stage === 'waiting' && frame.turn === g.turn && g.phase === 7 &&
|
||||
!g.phaseOpening && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard &&
|
||||
!g.phaseOpening && !g.pendingHomeworldOccupiedIncome && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard &&
|
||||
!g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama &&
|
||||
(!g.ecazCollection || g.ecazCollection.stage === 'complete') &&
|
||||
homeworldRule(() => quoteGrummanCollection(g, frame.player)).high);
|
||||
@@ -18146,10 +18276,13 @@ function commitCollection(
|
||||
);
|
||||
}
|
||||
for (const receipt of discovery.receipts)
|
||||
creditGiediCollection(g, receipt.player, receipt.desert);
|
||||
creditGiediCollection(g, receipt.player, { kind: 'desert', amount: receipt.desert });
|
||||
stageGrummanCollection(g);
|
||||
}
|
||||
function collect(g: Game) {
|
||||
if (g.homeworldOccupationPreview && !g.pendingHomeworldOccupiedIncome &&
|
||||
!g.homeworldOccupiedIncomeHistory!.some(receipt => receipt.turn === g.turn))
|
||||
g.pendingHomeworldOccupiedIncome = homeworldRule(() => beginHomeworldOccupiedIncome(g, crypto.randomUUID()));
|
||||
if (g.grummanCollection?.turn === g.turn) {
|
||||
grummanCollectionIntegrity(g);
|
||||
return;
|
||||
@@ -18218,8 +18351,8 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
|
||||
pending.settled.push(receipt);
|
||||
getPlayer(g, receipt.ecaz).spice += receipt.ecazAmount;
|
||||
getPlayer(g, receipt.ally).spice += receipt.allyAmount;
|
||||
creditGiediCollection(g, receipt.ecaz, receipt.ecazAmount);
|
||||
creditGiediCollection(g, receipt.ally, receipt.allyAmount);
|
||||
creditGiediCollection(g, receipt.ecaz, { kind: 'desert', amount: receipt.ecazAmount });
|
||||
creditGiediCollection(g, receipt.ally, { kind: 'desert', amount: receipt.allyAmount });
|
||||
log(
|
||||
g,
|
||||
`${territory(receipt.territory).name} shared collection: Ecaz received ${receipt.ecazAmount} spice and ${faction(getPlayer(g, receipt.ally).faction).name} received ${receipt.allyAmount}, ${receipt.method === 'agreement' ? 'by agreement' : 'using the equal split with any odd spice going to the ally'}.`,
|
||||
@@ -18913,10 +19046,12 @@ function combatResponses(g: Game) {
|
||||
if (
|
||||
((g.advanced && ['emperor', 'fremen'].includes(p.faction)) ||
|
||||
(!g.advanced && p.faction === 'ixians')) &&
|
||||
!(p.faction === 'emperor' && other.faction === 'fremen') &&
|
||||
combatForces(g, p, b.territory, other).elite > 0
|
||||
)
|
||||
!(p.faction === 'emperor' && other.faction === 'fremen')
|
||||
) {
|
||||
const forces = combatForces(g, p, b.territory, other);
|
||||
if (forces.elite > 0 && forces.eliteStrength > 1)
|
||||
b.powerChecks.push({ kind: 'eliteStrength', owner: forceOwner });
|
||||
}
|
||||
if (g.advanced && p.faction === 'fremen')
|
||||
b.powerChecks.push({ kind: 'fremenSupport', owner: forceOwner });
|
||||
}
|
||||
@@ -18947,9 +19082,17 @@ function battlePreparation(g: Game, kind: 'voice' | 'prescience') {
|
||||
: owner.ally && combatants.includes(owner.ally)
|
||||
? owner.ally
|
||||
: null);
|
||||
if (owner && beneficiary)
|
||||
b.preparation = { kind, owner: owner.id, beneficiary };
|
||||
else if (kind === 'voice') battlePreparation(g, 'prescience');
|
||||
if (owner && beneficiary) {
|
||||
const target = b.attacker === beneficiary ? b.defender : b.attacker;
|
||||
const occupation = kind === 'voice'
|
||||
? homeworldRule(() => quoteOccupiedHomeworldVoice(g, target)) : null;
|
||||
if (occupation?.status === 'prohibited') {
|
||||
battlePreparation(g, 'prescience');
|
||||
return;
|
||||
}
|
||||
b.preparation = { kind, owner: owner.id, beneficiary,
|
||||
...(occupation?.blocked ? { blocked: occupation.blocked } : {}) };
|
||||
} else if (kind === 'voice') battlePreparation(g, 'prescience');
|
||||
else finishBattlePreparation(g);
|
||||
}
|
||||
function normalizeBattle(g: Game) {
|
||||
@@ -26567,6 +26710,7 @@ function finishActionContinuations(g: Game) {
|
||||
resumeHomeworldRevivalReturn(g);
|
||||
resumeHomeworldVictoryReturn(g);
|
||||
resumeMarketGhola(g);
|
||||
resumeHomeworldOccupiedIncome(g);
|
||||
resumeGrummanCollection(g);
|
||||
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
|
||||
if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) {
|
||||
@@ -27424,6 +27568,10 @@ function applyActionInner(
|
||||
decideGrummanCollection(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'homeworldOccupiedIncome') {
|
||||
decideHomeworldOccupiedIncome(g, p, decision, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'moritaniTerror') {
|
||||
decideTerror(g, p, action);
|
||||
return g;
|
||||
@@ -30385,6 +30533,9 @@ function applyActionInner(
|
||||
b?.preparation?.kind === 'voice' && b.preparation.owner === id,
|
||||
'Voice must be chosen by Bene Gesserit before prescience and battle plans.',
|
||||
);
|
||||
const target = b.attacker === b.preparation.beneficiary ? b.defender : b.attacker;
|
||||
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
|
||||
requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
|
||||
requireRule(
|
||||
VOICE_KINDS.includes(String(action.kind)),
|
||||
'Choose a card type.',
|
||||
@@ -30798,7 +30949,7 @@ export function viewGame(state: Game, id: string) {
|
||||
schema: g.schema,
|
||||
botsPending: g.botsPending ?? false,
|
||||
semutaReaction: projectedSemutaReaction(g, me),
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g),
|
||||
botNextActionAt: g.botNextActionAt ?? null,
|
||||
code: g.code,
|
||||
version: g.version,
|
||||
@@ -30809,6 +30960,7 @@ export function viewGame(state: Game, id: string) {
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
bureaucrat: projectedBureaucrat(g, id),
|
||||
spiceBankerIncomePreview: g.spiceBankerIncomePreview === true,
|
||||
homeworldOccupationPreview: g.homeworldOccupationPreview === true,
|
||||
spiceBankerIncome: g.spiceBankerIncomePreview
|
||||
? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null,
|
||||
bribeOptions: projectedBribes(g, me),
|
||||
@@ -30853,6 +31005,7 @@ export function viewGame(state: Game, id: string) {
|
||||
moritaniPendingPlacement:
|
||||
me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
|
||||
grummanCollection: projectedGrummanCollection(g, id),
|
||||
homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id),
|
||||
tupileIntelligence: projectedTupileIntelligence(g, id),
|
||||
terrorEntry: g.pendingTerrorEntry
|
||||
? (() => {
|
||||
@@ -31818,7 +31971,10 @@ export function viewGame(state: Game, id: string) {
|
||||
: {},
|
||||
traitorSubmitted: Object.keys(b.traitorCalls),
|
||||
traitorVoters: traitorVoters(g, b),
|
||||
preparation: b.preparation ?? null,
|
||||
preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice'
|
||||
? { ...b.preparation, blocked: homeworldRule(() => quoteOccupiedHomeworldVoice(g,
|
||||
b.attacker === b.preparation!.beneficiary ? b.defender : b.attacker)).blocked ?? undefined }
|
||||
: b.preparation ?? null,
|
||||
nexusInspection: b.nexusInspection ? {
|
||||
event: b.nexusInspection.event, mode: b.nexusInspection.mode,
|
||||
owner: b.nexusInspection.owner, target: b.nexusInspection.target,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Game } from './engine';
|
||||
import type { HomeworldId } from './homeworld-cards';
|
||||
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
|
||||
|
||||
export type OccupiedHomeworldDefenseQuote = {
|
||||
status: 'allowed' | 'prohibited' | 'unresolved';
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
function occupiedTargetQuote(
|
||||
game: Game,
|
||||
card: HomeworldId,
|
||||
target: string,
|
||||
reason: string,
|
||||
): OccupiedHomeworldDefenseQuote {
|
||||
const { entitlement, blocked } = quoteStableHomeworldOccupation(game, card);
|
||||
if (blocked) return { status: 'unresolved', blocked };
|
||||
return entitlement && (target === entitlement.occupier || target === entitlement.ally)
|
||||
? { status: 'prohibited', blocked: reason } : { status: 'allowed', blocked: null };
|
||||
}
|
||||
|
||||
/** Wallach IX's original occupied face protects the occupier and their current
|
||||
* reciprocal ally from native Bene Gesserit Voice, wherever the battle occurs. */
|
||||
export function quoteOccupiedHomeworldVoice(game: Game, target: string): OccupiedHomeworldDefenseQuote {
|
||||
return occupiedTargetQuote(game, 'wallach_ix', target,
|
||||
'The Wallach IX occupier and their ally are immune to Bene Gesserit Voice.');
|
||||
}
|
||||
|
||||
/** Tleilax's original occupied face restricts native Face Dancer reveals. */
|
||||
export function quoteOccupiedHomeworldFaceDancer(game: Game, target: string): OccupiedHomeworldDefenseQuote {
|
||||
return occupiedTargetQuote(game, 'tleilax', target,
|
||||
'Tleilaxu cannot reveal Face Dancers against the Tleilax occupier or their ally.');
|
||||
}
|
||||
|
||||
/** Grumman's original occupied face applies to the entrant, not the token's
|
||||
* territory or Moritani's own alliance. The separate low entry-size rule remains. */
|
||||
export function quoteOccupiedHomeworldTerror(game: Game, entrant: string): OccupiedHomeworldDefenseQuote {
|
||||
return occupiedTargetQuote(game, 'grumman', entrant,
|
||||
'Moritani cannot reveal Terror when the Grumman occupier or their ally enters.');
|
||||
}
|
||||
|
||||
/** Salusa's original occupied face removes native Sardaukar's advantage only.
|
||||
* This quote never converts, relocates or removes actual starred counters.
|
||||
* A blocked quote is not permission to select either possible combat strength. */
|
||||
export function occupiedHomeworldSardaukarStatus(
|
||||
game: Game,
|
||||
): { suppressed: boolean; blocked: string | null } {
|
||||
const { entitlement, blocked } = quoteStableHomeworldOccupation(game, 'salusa_secundus');
|
||||
return { suppressed: entitlement !== null, blocked };
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { HOMEWORLD_CARDS, homeworldCard, type HomeworldId } from './homeworld-cards';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import {
|
||||
quoteStableHomeworldOccupation,
|
||||
type StableHomeworldOccupationContext,
|
||||
type StableHomeworldOccupier,
|
||||
} from './homeworld-stable-occupation';
|
||||
|
||||
export type HomeworldOccupiedIncomeCredit = { player: string; amount: number };
|
||||
export type HomeworldOccupiedIncomeReceipt =
|
||||
| { world: string; status: 'pending' }
|
||||
| { world: string; status: 'settled'; ownAmount: number; credits: HomeworldOccupiedIncomeCredit[] };
|
||||
export type HomeworldOccupiedIncomeState = {
|
||||
version: 1;
|
||||
event: string;
|
||||
turn: number;
|
||||
/** Frozen at Collection entry: neither current armies nor a new alliance may replace a recipient. */
|
||||
queue: readonly Readonly<StableHomeworldOccupier>[];
|
||||
cursor: number;
|
||||
receipts: HomeworldOccupiedIncomeReceipt[];
|
||||
signature: string;
|
||||
};
|
||||
export type HomeworldOccupiedIncomeOffer = {
|
||||
event: string;
|
||||
turn: number;
|
||||
world: string;
|
||||
card: HomeworldId;
|
||||
name: string;
|
||||
owner: string;
|
||||
ownerName: string;
|
||||
ally: string | null;
|
||||
allyName: string | null;
|
||||
amount: number;
|
||||
minOwnAmount: number;
|
||||
maxOwnAmount: number;
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
const whole = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||
function requireIncome(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(`Occupied Homeworld Collection: ${message}`);
|
||||
}
|
||||
function keys(value: unknown, expected: string[]): boolean {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
|
||||
}
|
||||
function identity(row: StableHomeworldOccupier) {
|
||||
return [row.world, row.card, row.native, row.occupier, row.ally, row.qualification, row.turn, row.spice];
|
||||
}
|
||||
function allocation(row: StableHomeworldOccupier, ownAmount: number): HomeworldOccupiedIncomeCredit[] {
|
||||
const credits: HomeworldOccupiedIncomeCredit[] = [];
|
||||
if (ownAmount > 0) credits.push({ player: row.occupier, amount: ownAmount });
|
||||
if (row.ally && ownAmount < row.spice) credits.push({ player: row.ally, amount: row.spice - ownAmount });
|
||||
return credits;
|
||||
}
|
||||
function signature(state: HomeworldOccupiedIncomeState): string {
|
||||
return JSON.stringify([state.version, state.event, state.turn, state.queue.map(identity), state.cursor,
|
||||
state.receipts.map(receipt => receipt.status === 'pending'
|
||||
? [receipt.world, receipt.status]
|
||||
: [receipt.world, receipt.status, receipt.ownAmount, receipt.credits.map(credit => [credit.player, credit.amount])])]);
|
||||
}
|
||||
/** Historical receipt validation does not re-award or reinterpret its old entitlement. */
|
||||
export function validateHomeworldOccupiedIncomeState(
|
||||
state: HomeworldOccupiedIncomeState, game: StableHomeworldOccupationContext, turn: number,
|
||||
): void {
|
||||
requireIncome(keys(state, ['version', 'event', 'turn', 'queue', 'cursor', 'receipts', 'signature']) &&
|
||||
state.version === 1 && text(state.event) && whole(state.turn) && state.turn > 0 && state.turn === turn &&
|
||||
Array.isArray(state.queue) && whole(state.cursor) && state.cursor <= state.queue.length &&
|
||||
Array.isArray(state.receipts) && state.receipts.length === state.queue.length && typeof state.signature === 'string',
|
||||
'invalid or stale saved event, turn, queue or receipts.');
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < state.queue.length; index++) {
|
||||
const row = state.queue[index], receipt = state.receipts[index];
|
||||
const card = row && homeworldCard(row.card);
|
||||
requireIncome(keys(row, ['world', 'card', 'native', 'occupier', 'ally', 'qualification', 'turn', 'spice']) &&
|
||||
card && text(row.world) && text(row.qualification) && row.turn === state.turn &&
|
||||
row.spice === card.occupied.spiceIcons && !seen.has(row.world) &&
|
||||
game.players.some(player => player.id === row.native && player.faction === card.faction) &&
|
||||
game.players.some(player => player.id === row.occupier && player.id !== row.native) &&
|
||||
row.world === (card.id === 'salusa_secundus' ? 'homeworld:emperor:salusa' : `homeworld:${card.faction}`) &&
|
||||
(card.id !== 'salusa_secundus' || game.advanced) &&
|
||||
(row.ally === null || game.players.some(player => player.id === row.ally && player.id !== row.occupier)),
|
||||
'invalid frozen world entitlement.');
|
||||
seen.add(row.world);
|
||||
requireIncome(receipt && receipt.world === row.world, 'receipt lost its original world.');
|
||||
if (index >= state.cursor) {
|
||||
requireIncome(keys(receipt, ['world', 'status']) && receipt.status === 'pending', 'pending receipt/cursor mismatch.');
|
||||
} else {
|
||||
requireIncome(keys(receipt, ['world', 'status', 'ownAmount', 'credits']) && receipt.status === 'settled',
|
||||
'settled receipt/cursor mismatch.');
|
||||
requireIncome(whole(receipt.ownAmount) && receipt.ownAmount <= row.spice &&
|
||||
(row.ally !== null || receipt.ownAmount === row.spice) && Array.isArray(receipt.credits), 'invalid settled split.');
|
||||
const expected = allocation(row, receipt.ownAmount);
|
||||
requireIncome(receipt.credits.length === expected.length && receipt.credits.every((credit, i) =>
|
||||
keys(credit, ['player', 'amount']) && credit.player === expected[i].player && credit.amount === expected[i].amount),
|
||||
'settled credits do not conserve the printed bank award.');
|
||||
}
|
||||
}
|
||||
requireIncome(state.signature === signature(state), 'saved income signature changed.');
|
||||
}
|
||||
function currentBlock(row: StableHomeworldOccupier, game: StableHomeworldOccupationContext): string | null {
|
||||
const current = quoteStableHomeworldOccupation(game, row.card);
|
||||
if (current.blocked) return current.blocked;
|
||||
if (!current.entitlement || JSON.stringify(identity(current.entitlement)) !== JSON.stringify(identity(row)))
|
||||
return 'The original occupied Homeworld entitlement or reciprocal ally changed before settlement.';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Printed BANK icons only, including Basic and zero-icon Salusa. Percentage
|
||||
* payment/Collection receipts are separate sources. No read, begin or quote
|
||||
* changes wallets. The engine persists one queue per turn and atomically applies
|
||||
* the returned credits with its next state. Sources: HOMEWORLD_OCCUPATION_RULES
|
||||
* 7–33,35–56; HOMEWORLD_RULES83–105; the original thirteen occupied card faces. */
|
||||
export function beginHomeworldOccupiedIncome(
|
||||
game: StableHomeworldOccupationContext,
|
||||
event: string,
|
||||
): HomeworldOccupiedIncomeState {
|
||||
requireIncome(text(event) && whole(game.turn) && game.turn > 0, 'a Collection event and turn are required.');
|
||||
const queue: StableHomeworldOccupier[] = [];
|
||||
for (const card of HOMEWORLD_CARDS) {
|
||||
const quote = quoteStableHomeworldOccupation(game, card.id);
|
||||
if (card.occupied.spiceIcons > 0)
|
||||
requireIncome(!quote.blocked, quote.blocked ?? 'ambiguous occupied world.');
|
||||
if (quote.entitlement) queue.push(Object.freeze({ ...quote.entitlement }));
|
||||
}
|
||||
const state: HomeworldOccupiedIncomeState = { version: 1, event, turn: game.turn, queue: Object.freeze(queue), cursor: 0,
|
||||
receipts: queue.map(row => ({ world: row.world, status: 'pending' })), signature: '' };
|
||||
state.signature = signature(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** A public offer is a read-only projection, never a bank award or Karama window. */
|
||||
export function homeworldOccupiedIncomeOffer(
|
||||
state: HomeworldOccupiedIncomeState,
|
||||
game: StableHomeworldOccupationContext,
|
||||
actor: string,
|
||||
): HomeworldOccupiedIncomeOffer | null {
|
||||
validateHomeworldOccupiedIncomeState(state, game, game.turn);
|
||||
const row = state.queue[state.cursor];
|
||||
if (!row || row.occupier !== actor) return null;
|
||||
return { event: state.event, turn: state.turn, world: row.world, card: row.card,
|
||||
name: homeworldCard(row.card)!.name, owner: row.occupier,
|
||||
ownerName: game.players.find(player => player.id === row.occupier)!.name,
|
||||
ally: row.ally, allyName: row.ally ? game.players.find(player => player.id === row.ally)!.name : null,
|
||||
amount: row.spice, minOwnAmount: row.ally ? 0 : row.spice, maxOwnAmount: row.spice,
|
||||
blocked: currentBlock(row, game) };
|
||||
}
|
||||
|
||||
export function quoteHomeworldOccupiedIncomeChoice(
|
||||
state: HomeworldOccupiedIncomeState,
|
||||
game: StableHomeworldOccupationContext,
|
||||
actor: string,
|
||||
event: string,
|
||||
world: string,
|
||||
ownAmount: number,
|
||||
): { state: HomeworldOccupiedIncomeState; credits: HomeworldOccupiedIncomeCredit[] } {
|
||||
validateHomeworldOccupiedIncomeState(state, game, game.turn);
|
||||
const row = state.queue[state.cursor];
|
||||
requireIncome(row && event === state.event && world === row.world && actor === row.occupier,
|
||||
'wrong owner, stale event/world, or already settled receipt.');
|
||||
const blocked = currentBlock(row, game);
|
||||
requireIncome(!blocked, blocked ?? 'occupied entitlement changed.');
|
||||
requireIncome(whole(ownAmount) && ownAmount <= row.spice && (row.ally !== null || ownAmount === row.spice),
|
||||
'keep an integer from zero through the printed award; without an ally keep the full award.');
|
||||
const credits = allocation(row, ownAmount);
|
||||
const next: HomeworldOccupiedIncomeState = { ...state, cursor: state.cursor + 1,
|
||||
receipts: state.receipts.map((receipt, index) => index === state.cursor
|
||||
? { world: row.world, status: 'settled', ownAmount, credits: credits.map(credit => ({ ...credit })) }
|
||||
: receipt.status === 'settled' ? { ...receipt, credits: receipt.credits.map(credit => ({ ...credit })) } : { ...receipt }),
|
||||
signature: '' };
|
||||
next.signature = signature(next);
|
||||
return { state: next, credits };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Game } from './engine';
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldForceGroups, HomeworldCustodyError } from './homeworld-custody';
|
||||
import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||
|
||||
export type StableHomeworldOccupationContext = Pick<Game,
|
||||
'advanced' | 'turn' | 'players' | 'homeworlds' | 'homeworldOccupationHistory' | 'homeworldOccupationPreview'>;
|
||||
export type StableHomeworldOccupier = {
|
||||
world: string;
|
||||
card: HomeworldId;
|
||||
native: string;
|
||||
occupier: string;
|
||||
ally: string | null;
|
||||
qualification: string;
|
||||
turn: number;
|
||||
spice: number;
|
||||
};
|
||||
export type StableHomeworldOccupationQuote = {
|
||||
entitlement: StableHomeworldOccupier | null;
|
||||
blocked: string | null;
|
||||
};
|
||||
type SourceWorld = [string, [string, number, number][]];
|
||||
|
||||
/** Printed qualification is not a current-controller shortcut. This fresh
|
||||
* profile admits only one continuously present qualifier, with no competing
|
||||
* foreign garrison or restored native high threshold since qualification.
|
||||
* Departure/turnover/contested/repopulation questions remain unanswered.
|
||||
* Sources: HOMEWORLD_OCCUPATION_RULES7–33,35–56; original occupied card faces. */
|
||||
export function quoteStableHomeworldOccupation(
|
||||
game: StableHomeworldOccupationContext,
|
||||
cardId: HomeworldId,
|
||||
): StableHomeworldOccupationQuote {
|
||||
const none: StableHomeworldOccupationQuote = { entitlement: null, blocked: null };
|
||||
if (!game.homeworldOccupationPreview) return none;
|
||||
const custody = game.homeworlds?.custody, history = game.homeworldOccupationHistory;
|
||||
if (!custody || !history || game.homeworlds?.historyVersion !== 1)
|
||||
return { entitlement: null, blocked: 'Occupation benefits require the original fresh Homeworld qualification history.' };
|
||||
const context = homeworldContext(game);
|
||||
validateHomeworldOccupationHistory(history, context, game.turn);
|
||||
const card = HOMEWORLD_CARDS.find(c => c.id === cardId);
|
||||
if (!card) throw new HomeworldCustodyError('Unknown occupied Homeworld card.');
|
||||
const native = game.players.find(p => p.faction === card.faction);
|
||||
if (!native || cardId === 'salusa_secundus' && !game.advanced) return none;
|
||||
const world = homeworldForceGroups(context, custody).find(home => home.native === native.id &&
|
||||
home.secondary === (cardId === 'salusa_secundus'));
|
||||
if (!world) throw new HomeworldCustodyError('The occupied card lost its original native Homeworld.');
|
||||
const first = history.qualifications.find(f => f.world === world.id);
|
||||
if (!first) return none;
|
||||
const blocked = (detail: string): StableHomeworldOccupationQuote => ({ entitlement: null,
|
||||
blocked: `${card.name} occupied benefits await the pending ${detail} ruling.` });
|
||||
let qualifiedThisTurn = false;
|
||||
for (const fact of history.qualifications) {
|
||||
if (fact.world !== world.id) continue;
|
||||
if (fact.player !== first.player) return blocked('competing-occupier');
|
||||
if (fact.turn === game.turn) qualifiedThisTurn = true;
|
||||
}
|
||||
if (!qualifiedThisTurn) return blocked('entitlement/expiry');
|
||||
const start = history.sources.findIndex(source => source.event === first.event);
|
||||
if (start < 0) throw new HomeworldCustodyError('The occupier lost its original qualifying source.');
|
||||
const check = (groups: readonly [string, number, number][]): string | null => {
|
||||
let present = false;
|
||||
for (const [id, normal, elite] of groups) {
|
||||
const amount = normal + elite;
|
||||
if (id === native.id) {
|
||||
const population = card.reserveType === 'sardaukar' ? elite : amount;
|
||||
if (population >= card.high.reserves.min) return 'native-repopulation';
|
||||
} else if (amount > 0) {
|
||||
if (id !== first.player) return 'contested-occupation';
|
||||
present = true;
|
||||
}
|
||||
}
|
||||
return present ? null : 'departure/expiry';
|
||||
};
|
||||
let previous = -1;
|
||||
for (let i = start; i < history.sources.length; i++) {
|
||||
const index = history.sources[i].snapshot;
|
||||
if (index === previous) continue;
|
||||
previous = index;
|
||||
const snapshot = JSON.parse(history.snapshots[index]) as SourceWorld[];
|
||||
const row = snapshot.find(source => source[0] === world.id)!;
|
||||
const reason = check(row[1]);
|
||||
if (reason) return blocked(reason);
|
||||
}
|
||||
// Actions can query an effect after a physical change but before its semantic
|
||||
// observation. Never let the last recorded snapshot substitute for now.
|
||||
const current = Object.entries(world.forces).map(([id, forces]): [string, number, number] =>
|
||||
[id, forces.normal, forces.elite]);
|
||||
const reason = check(current);
|
||||
if (reason) return blocked(reason);
|
||||
const occupier = game.players.find(p => p.id === first.player)!;
|
||||
const ally = occupier.ally && game.players.some(p => p.id === occupier.ally && p.ally === occupier.id)
|
||||
? occupier.ally : null;
|
||||
return { entitlement: { world: world.id, card: cardId, native: native.id, occupier: occupier.id,
|
||||
ally, qualification: first.event, turn: game.turn, spice: card.occupied.spiceIcons }, blocked: null };
|
||||
}
|
||||
+4
-1
@@ -3846,6 +3846,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.',
|
||||
'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.',
|
||||
'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
|
||||
'The fresh local homeworld-occupation profile now connects continuously proved, unambiguous occupation with original selected decks and no other modules. Qualification history must prove one still-present occupier without departure, competing foreigners or restored native high population; a current garrison alone grants nothing. Ambiguous histories retain the source guards, not a new expiry or replacement policy.',
|
||||
'In that profile, printed occupied bank income is collected once and may immediately be allocated to a reciprocal ally through the original Collection choice. Actual credit receipts survive refresh; views and replay cannot pay again. Actual shared Homeworld credits feed the existing once-per-phase Giedi bonus. Percentage receipts remain a separate unfinished source.',
|
||||
'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.',
|
||||
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
|
||||
],
|
||||
example:
|
||||
@@ -3863,7 +3866,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Concealed transport, occupation and remaining card effects are unfinished; game starts stay disabled.',
|
||||
'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh stable occupation additionally connects printed bank Collection/immediate ally allocation, actual Giedi Homeworld sources and Wallach/Tleilax/Grumman/Salusa protections. Departure/replacement/contested/repopulation, percentage receipts, other occupied powers and concealed transport remain unfinished; public starts stay disabled.',
|
||||
evidence: [
|
||||
'game/homeworld-cards.ts',
|
||||
'game/homeworld-game.ts',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
// are envelope checks, not a duplicate implementation of each future choice.
|
||||
const DECISIONS = {
|
||||
ecazBattleLead: true,
|
||||
homeworldOccupiedIncome: true,
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
moritaniAssassinate: true,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeHomeworldOccupationGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { botActions } from '../game/bots';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
|
||||
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
export const defensePlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
|
||||
/** Original lobby and fresh Homeworld setup, completed through original legal
|
||||
* bot choices. Controlled positions below are fixtures, not natural play. */
|
||||
export function freshDefenseGame(native: FactionId, advanced = true): Game {
|
||||
const expansion = native === 'tleilaxu' ? ['ix'] : native === 'moritani' ? ['ecaz'] : [];
|
||||
let game = createGame('OCCUPIEDDEFENSES', newPlayer('native', native, native), advanced, expansion);
|
||||
for (const [id, faction] of [
|
||||
['occupier', 'guild'], ['ally', 'atreides'], ['opponent', 'harkonnen'], ['fremen', 'fremen'],
|
||||
] as const) joinGame(game, newPlayer(id, id, faction));
|
||||
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
|
||||
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = initializeHomeworldOccupationGameForAudit(game);
|
||||
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
||||
let progressed = false;
|
||||
for (const p of game.players) {
|
||||
const view = viewGame(game, p.id);
|
||||
view.players.find(player => player.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (!action) continue;
|
||||
game = applyAction(game, p.id, action);
|
||||
progressed = true;
|
||||
break;
|
||||
}
|
||||
assert.ok(progressed, 'original fresh setup must have a legal next choice');
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.homeworldOccupationPreview, true);
|
||||
assert.ok(game.homeworldOccupationHistory);
|
||||
assert.deepEqual(game.homeworldOccupationHistory.qualifications, []);
|
||||
assertDefenseInventory(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
export function recordDefensePosition(game: Game, label: string, cause: 'change' | 'turnEnd' = 'change') {
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(
|
||||
game.homeworldOccupationHistory!, homeworldContext(game), game.homeworlds!.custody!,
|
||||
game.turn, cause, `controlled-defense-${label}-${game.homeworldOccupationHistory!.sources.length}`,
|
||||
);
|
||||
assertDefenseInventory(game);
|
||||
}
|
||||
|
||||
/** Conserved native casualties: each physical normal/starred reserve counter
|
||||
* moves to its own Tanks pile; other worlds and Arrakis counters are untouched. */
|
||||
export function clearDefenseNative(game: Game, world: string) {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(home => home.id === world)!;
|
||||
const forces = home.forces[home.native];
|
||||
const native = defensePlayer(game, home.native);
|
||||
native.reserves -= forces.normal + forces.elite;
|
||||
native.tanks += forces.normal + forces.elite;
|
||||
if (forces.elite) {
|
||||
native.elites!.reserves -= forces.elite;
|
||||
native.elites!.tanks += forces.elite;
|
||||
}
|
||||
if (home.secondary) game.homeworlds!.custody!.salusa = { normal: 0, elite: 0 };
|
||||
}
|
||||
|
||||
/** Conserved normal reserve-to-visitor transfer; no qualification is fabricated. */
|
||||
export function addDefenseVisitor(game: Game, world: string, id = 'occupier') {
|
||||
defensePlayer(game, id).reserves--;
|
||||
(game.homeworlds!.custody!.visitors[world] ??= {})[id] = { normal: 1, elite: 0 };
|
||||
}
|
||||
|
||||
export function removeDefenseVisitor(game: Game, world: string, id = 'occupier') {
|
||||
const visitors = game.homeworlds!.custody!.visitors[world];
|
||||
const forces = visitors[id];
|
||||
assert.equal(forces.elite, 0);
|
||||
defensePlayer(game, id).reserves += forces.normal;
|
||||
delete visitors[id];
|
||||
if (!Object.keys(visitors).length) delete game.homeworlds!.custody!.visitors[world];
|
||||
}
|
||||
|
||||
export function qualifyDefensePosition(game: Game, world: string) {
|
||||
clearDefenseNative(game, world);
|
||||
addDefenseVisitor(game, world);
|
||||
recordDefensePosition(game, 'conserved-native-casualties-and-sole-arrival');
|
||||
const fact = game.homeworldOccupationHistory!.qualifications.find(fact => fact.world === world)!;
|
||||
assert.equal(fact.player, 'occupier');
|
||||
assert.equal(fact.cause, 'sole');
|
||||
assert.equal(fact.turn, game.turn);
|
||||
}
|
||||
|
||||
export function assertDefenseInventory(game: Game) {
|
||||
const groups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
for (const p of game.players) {
|
||||
const visitors = groups.filter(home => home.native !== p.id).map(home => home.forces[p.id]);
|
||||
const away = visitors.reduce((n, group) => n + (group ? group.normal + group.elite : 0), 0);
|
||||
const starredAway = visitors.reduce((n, group) => n + (group?.elite ?? 0), 0);
|
||||
assert.equal(p.reserves + p.tanks + sum(p.forces) + away, 20, `${p.id} physical counters`);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + sum(p.elites.forces) + starredAway,
|
||||
p.faction === 'emperor' ? 5 : p.faction === 'fremen' ? 3 : 7, `${p.id} starred identity`);
|
||||
assert.ok(p.elites.reserves <= p.reserves);
|
||||
assert.ok(p.elites.tanks <= p.tanks);
|
||||
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= p.forces[key]);
|
||||
}
|
||||
}
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)];
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length, 'original Treachery IDs are unique');
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from '../game/homeworld-cards';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
|
||||
/** Original selected decks, fresh Homeworld occupation audit setup and real
|
||||
* setup actions. Only the later Collection positions/alliance are controlled
|
||||
* test positions; the original history observes them, not fabricated facts. */
|
||||
export function occupiedIncomeFixture(cardId: HomeworldId = 'caladan', advanced = false, allied = true) {
|
||||
const card = HOMEWORLD_CARDS.find(card => card.id === cardId)!;
|
||||
const nativeFaction = card.faction;
|
||||
const used = [nativeFaction];
|
||||
const choose = (choices: FactionId[]) => {
|
||||
const faction = choices.find(faction => !used.includes(faction))!;
|
||||
used.push(faction);
|
||||
return faction;
|
||||
};
|
||||
const factions = [nativeFaction, choose(['guild', 'atreides']),
|
||||
choose(['harkonnen', 'emperor']), choose(['fremen', 'beneGesserit'])];
|
||||
const expansion = FACTIONS.find(faction => faction.id === nativeFaction)!.expansion;
|
||||
let game = createGame('OCCUPIEDINCOME', newPlayer('native', 'Native', nativeFaction), advanced,
|
||||
expansion === 'base' ? [] : [expansion]);
|
||||
for (const [index, faction] of factions.slice(1).entries())
|
||||
joinGame(game, newPlayer(['owner', 'ally', 'competitor'][index], faction, faction));
|
||||
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeHomeworldOccupationGameForAudit(game);
|
||||
for (let step = 0; game.status === 'setup' && step < 120; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(game, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, 'Original setup must make progress.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const setup = structuredClone(game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
// Controlled, conserved placement. Do not discard/deal or reset card IDs.
|
||||
for (const player of game.players) {
|
||||
Object.assign(player, { forces: {}, reserves: 20, tanks: 0, ally: null });
|
||||
if (player.elites) Object.assign(player.elites, {
|
||||
reserves: player.faction === 'fremen' ? 3 : player.faction === 'ixians' ? 7 : 5,
|
||||
tanks: 0, forces: {},
|
||||
});
|
||||
}
|
||||
const custody = game.homeworlds!.custody!;
|
||||
custody.visitors = {};
|
||||
if (advanced && game.players.some(player => player.faction === 'emperor'))
|
||||
custody.salusa = { normal: 0, elite: 5 };
|
||||
const native = game.players.find(player => player.id === 'native')!;
|
||||
native.reserves = 0;
|
||||
native.tanks = 20;
|
||||
if (native.elites) {
|
||||
native.elites.tanks = native.elites.reserves;
|
||||
native.elites.reserves = 0;
|
||||
}
|
||||
if (nativeFaction === 'emperor' && advanced) custody.salusa = { normal: 0, elite: 0 };
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
if (allied) {
|
||||
owner.ally = 'ally';
|
||||
game.players.find(player => player.id === 'ally')!.ally = owner.id;
|
||||
}
|
||||
const world = `homeworld:${nativeFaction}${cardId === 'salusa_secundus' ? ':salusa' : ''}`;
|
||||
custody.visitors[world] = { owner: { normal: 1, elite: 0 } };
|
||||
owner.reserves--;
|
||||
observeControlledIncomePosition(game, `controlled-${cardId}-sole`);
|
||||
homeworldGameIntegrity(game);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards);
|
||||
return { game, world, cards, setup };
|
||||
}
|
||||
|
||||
export function observeControlledIncomePosition(game: Game, event: string): void {
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
|
||||
homeworldContext(game), game.homeworlds!.custody!, game.turn, 'change', event);
|
||||
homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
/** Real original phases/actions into Movement; only the conserved position is
|
||||
* controlled. Never jump the phase clock to obtain a Collection decision. */
|
||||
export function occupiedIncomeMovementFixture(cardId: HomeworldId = 'caladan', advanced = false, allied = true) {
|
||||
const fixture = occupiedIncomeFixture(cardId, advanced, allied);
|
||||
let game = fixture.setup;
|
||||
for (let step = 0; !(game.phase === 5 && !game.response && !game.decision && !game.phaseOpening) && step < 500; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (!action) continue;
|
||||
if (action.type === 'stormDial') action.amount = 0;
|
||||
next = applyAction(game, player.id, action);
|
||||
break;
|
||||
}
|
||||
assert.ok(next, 'Original phases must reach the controlled Movement boundary.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.phase, 5);
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.tanks, 0, 'The position cannot resurrect previously lost counters.');
|
||||
const position = fixture.game.players.find(seat => seat.id === player.id)!;
|
||||
player.forces = structuredClone(position.forces);
|
||||
player.reserves = position.reserves;
|
||||
player.tanks = position.tanks;
|
||||
player.ally = position.ally;
|
||||
if (player.elites) player.elites = structuredClone(position.elites!);
|
||||
}
|
||||
game.homeworlds!.custody = structuredClone(fixture.game.homeworlds!.custody!);
|
||||
observeControlledIncomePosition(game, `controlled-${cardId}-real-movement-boundary`);
|
||||
return { ...fixture, game };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { occupiedIncomeMovementFixture } from './fixture-homeworld-occupied-income';
|
||||
|
||||
function collectionDecision(initial: Game): Game {
|
||||
let game = initial;
|
||||
for (let n = 0; game.decision?.kind !== 'homeworldOccupiedIncome' && n < 100; n++) {
|
||||
if (game.phase === 5 && !game.decision && !game.response && !game.phaseOpening) {
|
||||
assert.ok(game.active);
|
||||
game = applyAction(game, game.active, { type: 'endMovement' });
|
||||
continue;
|
||||
}
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { next = applyAction(game, player.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'Original phase/response actions must reach the bank choice.');
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(game.decision?.kind, 'homeworldOccupiedIncome');
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('original Movement-to-Collection dispatch preserves a JSON partial bank split and one real Giedi bonus', () => {
|
||||
const fixture = occupiedIncomeMovementFixture('caladan', false, true);
|
||||
const before = Object.fromEntries(fixture.game.players.map(p => [p.id, p.spice]));
|
||||
let game = collectionDecision(fixture.game);
|
||||
const offer = viewGame(game, 'owner').homeworldOccupiedIncome!;
|
||||
const action = { type: 'decision', event: offer.event, world: offer.world, ownAmount: 1 };
|
||||
const waiting = structuredClone(game);
|
||||
for (const rejected of [
|
||||
{ actor: 'ally', action },
|
||||
{ actor: 'owner', action: { ...action, event: `${offer.event}-stale` } },
|
||||
{ actor: 'owner', action: { ...action, ownAmount: 3 } },
|
||||
]) {
|
||||
assert.throws(() => applyAction(game, rejected.actor, rejected.action));
|
||||
assert.deepEqual(game, waiting);
|
||||
}
|
||||
game = applyAction(JSON.parse(JSON.stringify(game)) as Game, 'owner', action);
|
||||
assert.equal(game.players.find(p => p.id === 'owner')!.spice - before.owner, 1);
|
||||
assert.equal(game.players.find(p => p.id === 'ally')!.spice - before.ally, 3);
|
||||
assert.equal(game.giediCollection!.qualifying, 1);
|
||||
assert.equal(game.giediCollection!.awarded, true);
|
||||
assert.equal(game.pendingHomeworldOccupiedIncome, null);
|
||||
assert.equal(game.decision, null);
|
||||
const settled = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'owner', action));
|
||||
assert.deepEqual(game, settled, 'A replay cannot credit either wallet or Giedi again.');
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { HomeworldId } from '../game/homeworld-cards';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import {
|
||||
quoteOccupiedHomeworldVoice,
|
||||
quoteOccupiedHomeworldFaceDancer,
|
||||
quoteOccupiedHomeworldTerror,
|
||||
type OccupiedHomeworldDefenseQuote,
|
||||
occupiedHomeworldSardaukarStatus,
|
||||
} from '../game/homeworld-occupied-defenses';
|
||||
import {
|
||||
addDefenseVisitor,
|
||||
assertDefenseInventory,
|
||||
clearDefenseNative,
|
||||
defensePlayer,
|
||||
freshDefenseGame,
|
||||
qualifyDefensePosition,
|
||||
recordDefensePosition,
|
||||
removeDefenseVisitor,
|
||||
} from './fixture-homeworld-occupied-defenses';
|
||||
|
||||
const targeted: Record<'voice' | 'faceDancer' | 'terror', {
|
||||
native: FactionId;
|
||||
card: HomeworldId;
|
||||
world: string;
|
||||
quote: (game: Game, target: string) => OccupiedHomeworldDefenseQuote;
|
||||
}> = {
|
||||
voice: { native: 'beneGesserit', card: 'wallach_ix', world: 'homeworld:beneGesserit',
|
||||
quote: quoteOccupiedHomeworldVoice },
|
||||
faceDancer: { native: 'tleilaxu', card: 'tleilax', world: 'homeworld:tleilaxu',
|
||||
quote: quoteOccupiedHomeworldFaceDancer },
|
||||
terror: { native: 'moritani', card: 'grumman', world: 'homeworld:moritani',
|
||||
quote: quoteOccupiedHomeworldTerror },
|
||||
};
|
||||
|
||||
for (const [effect, rule] of Object.entries(targeted)) {
|
||||
void test(`${effect}: original fresh setup, conserved sole qualification, exact occupier and reciprocal-ally scope`, () => {
|
||||
const game = freshDefenseGame(rule.native);
|
||||
assert.equal(rule.quote(game, 'occupier').status, 'allowed', 'fresh unoccupied card grants no immunity');
|
||||
qualifyDefensePosition(game, rule.world);
|
||||
const qualified = structuredClone(game);
|
||||
assert.equal(rule.quote(game, 'occupier').status, 'prohibited');
|
||||
assert.equal(rule.quote(game, 'ally').status, 'allowed', 'an unallied player is not protected');
|
||||
assert.equal(rule.quote(game, 'opponent').status, 'allowed');
|
||||
assert.equal(rule.quote(game, 'native').status, 'allowed', 'the benefit does not protect the native');
|
||||
assert.deepEqual(game, qualified, 'quoting changes no physical counters, cards or history');
|
||||
|
||||
defensePlayer(game, 'occupier').ally = 'ally';
|
||||
assert.equal(rule.quote(game, 'ally').status, 'allowed', 'one-way alliance gives no entitlement');
|
||||
defensePlayer(game, 'ally').ally = 'occupier';
|
||||
assert.equal(rule.quote(game, 'ally').status, 'prohibited');
|
||||
assert.equal(rule.quote(game, 'opponent').status, 'allowed');
|
||||
defensePlayer(game, 'occupier').ally = 'opponent';
|
||||
assert.equal(rule.quote(game, 'ally').status, 'allowed', 'former reciprocal ally loses protection immediately');
|
||||
assert.equal(rule.quote(game, 'opponent').status, 'allowed', 'new alliance must also be reciprocal');
|
||||
defensePlayer(game, 'opponent').ally = 'occupier';
|
||||
assert.equal(rule.quote(game, 'opponent').status, 'prohibited');
|
||||
assert.equal(rule.quote(game, 'occupier').status, 'prohibited');
|
||||
|
||||
const saved: Game = JSON.parse(JSON.stringify(game));
|
||||
const before = structuredClone(saved);
|
||||
assert.equal(rule.quote(saved, 'opponent').status, 'prohibited');
|
||||
assert.equal(rule.quote(saved, 'ally').status, 'allowed');
|
||||
assert.deepEqual(saved, before, 'JSON history supplies the same pure quote');
|
||||
assertDefenseInventory(saved);
|
||||
delete saved.homeworldOccupationPreview;
|
||||
assert.equal(rule.quote(saved, 'occupier').status, 'allowed', 'disabled preview preserves original behavior');
|
||||
assert.equal(rule.quote(saved, 'opponent').status, 'allowed');
|
||||
});
|
||||
}
|
||||
|
||||
void test('Advanced occupied Salusa suppresses advantage while every original starred counter retains its typed custody', () => {
|
||||
const game = freshDefenseGame('emperor');
|
||||
const emperor = defensePlayer(game, 'native');
|
||||
// Controlled conserved deployment: one actual Sardaukar leaves Salusa for
|
||||
// Arrakis, where losing its advantage must not change its physical identity.
|
||||
emperor.reserves--;
|
||||
emperor.elites!.reserves--;
|
||||
game.homeworlds!.custody!.salusa!.elite--;
|
||||
emperor.forces['arrakeen:10'] = (emperor.forces['arrakeen:10'] ?? 0) + 1;
|
||||
emperor.elites!.forces['arrakeen:10'] = (emperor.elites!.forces['arrakeen:10'] ?? 0) + 1;
|
||||
recordDefensePosition(game, 'one-starred-counter-deployed-to-arrakis');
|
||||
qualifyDefensePosition(game, 'homeworld:emperor:salusa');
|
||||
const before = structuredClone(game);
|
||||
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: true, blocked: null });
|
||||
assert.equal(emperor.elites!.forces['arrakeen:10'], 1);
|
||||
assert.equal(emperor.elites!.tanks, 4);
|
||||
assert.equal(emperor.elites!.reserves, 0);
|
||||
assert.deepEqual(game, before, 'suppression is a rule quote, not counter conversion');
|
||||
assertDefenseInventory(game);
|
||||
const saved: Game = JSON.parse(JSON.stringify(game));
|
||||
assert.deepEqual(occupiedHomeworldSardaukarStatus(saved), { suppressed: true, blocked: null });
|
||||
delete saved.homeworldOccupationPreview;
|
||||
assert.deepEqual(occupiedHomeworldSardaukarStatus(saved), { suppressed: false, blocked: null });
|
||||
});
|
||||
|
||||
void test('low but unoccupied Advanced Salusa and occupied primary Kaitain do not suppress Sardaukar', () => {
|
||||
const game = freshDefenseGame('emperor');
|
||||
clearDefenseNative(game, 'homeworld:emperor:salusa');
|
||||
recordDefensePosition(game, 'conserved-empty-unoccupied-salusa');
|
||||
const before = structuredClone(game);
|
||||
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: false, blocked: null });
|
||||
assert.deepEqual(game, before);
|
||||
qualifyDefensePosition(game, 'homeworld:emperor');
|
||||
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: false, blocked: null },
|
||||
'Kaitain is not Salusa even though both worlds belong to Emperor');
|
||||
assertDefenseInventory(game);
|
||||
});
|
||||
|
||||
void test('Basic has no Salusa: occupied Kaitain does not remove original Basic starred identities', () => {
|
||||
const game = freshDefenseGame('emperor', false);
|
||||
const emperor = defensePlayer(game, 'native');
|
||||
assert.equal(game.homeworlds!.custody!.salusa, null);
|
||||
assert.equal(homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.some(home => home.secondary), false);
|
||||
assert.equal(emperor.elites!.reserves, 5);
|
||||
assert.equal(defensePlayer(game, 'fremen').elites!.reserves +
|
||||
Object.values(defensePlayer(game, 'fremen').elites!.forces).reduce((a, b) => a + b, 0), 3);
|
||||
qualifyDefensePosition(game, 'homeworld:emperor');
|
||||
const before = structuredClone(game);
|
||||
assert.deepEqual(occupiedHomeworldSardaukarStatus(game), { suppressed: false, blocked: null });
|
||||
assert.equal(emperor.elites!.tanks, 5, 'Basic Homeworld Sardaukar retain physical starred identity');
|
||||
assert.deepEqual(game, before);
|
||||
assertDefenseInventory(game);
|
||||
});
|
||||
|
||||
const ambiguityRules: Record<string, {
|
||||
native: FactionId;
|
||||
card: HomeworldId;
|
||||
world: string;
|
||||
high: number;
|
||||
quote: (game: Game) => { blocked: string | null; suppressed?: boolean; status?: OccupiedHomeworldDefenseQuote['status'] };
|
||||
}> = {
|
||||
voice: { native: 'beneGesserit', card: 'wallach_ix', world: 'homeworld:beneGesserit', high: 11,
|
||||
quote: game => quoteOccupiedHomeworldVoice(game, 'opponent') },
|
||||
faceDancer: { native: 'tleilaxu', card: 'tleilax', world: 'homeworld:tleilaxu', high: 9,
|
||||
quote: game => quoteOccupiedHomeworldFaceDancer(game, 'opponent') },
|
||||
terror: { native: 'moritani', card: 'grumman', world: 'homeworld:moritani', high: 8,
|
||||
quote: game => quoteOccupiedHomeworldTerror(game, 'opponent') },
|
||||
sardaukar: { native: 'emperor', card: 'salusa_secundus', world: 'homeworld:emperor:salusa', high: 2,
|
||||
quote: occupiedHomeworldSardaukarStatus },
|
||||
};
|
||||
|
||||
for (const [effect, rule] of Object.entries(ambiguityRules)) {
|
||||
void test(`${effect}: departed, contested, competing, restored-high and expired evidence propagate the shared blocker without guessing`, () => {
|
||||
const qualified = freshDefenseGame(rule.native);
|
||||
qualifyDefensePosition(qualified, rule.world);
|
||||
for (const mode of ['departed', 'contested', 'competing', 'restored-high', 'expired'] as const) {
|
||||
const game = structuredClone(qualified);
|
||||
if (mode === 'departed' || mode === 'competing') {
|
||||
removeDefenseVisitor(game, rule.world);
|
||||
recordDefensePosition(game, 'qualifier-departed');
|
||||
if (mode === 'competing') {
|
||||
addDefenseVisitor(game, rule.world, 'opponent');
|
||||
recordDefensePosition(game, 'different-conserved-sole-arrival');
|
||||
} else {
|
||||
addDefenseVisitor(game, rule.world);
|
||||
recordDefensePosition(game, 'qualifier-returned-after-departure');
|
||||
}
|
||||
} else if (mode === 'contested') {
|
||||
addDefenseVisitor(game, rule.world, 'opponent');
|
||||
recordDefensePosition(game, 'second-foreign-army-contests');
|
||||
removeDefenseVisitor(game, rule.world, 'opponent');
|
||||
recordDefensePosition(game, 'contest-ended-but-ruling-still-pending');
|
||||
} else if (mode === 'restored-high') {
|
||||
const native = defensePlayer(game, 'native');
|
||||
native.tanks -= rule.high;
|
||||
native.reserves += rule.high;
|
||||
if (rule.card === 'salusa_secundus') {
|
||||
native.elites!.tanks -= rule.high;
|
||||
native.elites!.reserves += rule.high;
|
||||
game.homeworlds!.custody!.salusa!.elite += rule.high;
|
||||
}
|
||||
recordDefensePosition(game, 'conserved-native-high-threshold-restored');
|
||||
clearDefenseNative(game, rule.world);
|
||||
recordDefensePosition(game, 'native-high-later-lost-without-invented-expiry-policy');
|
||||
} else {
|
||||
game.turn++;
|
||||
// Controlled incomplete boundary: no new-turn source may be fabricated.
|
||||
}
|
||||
const before = structuredClone(game);
|
||||
const actual = rule.quote(game);
|
||||
assert.notEqual(actual.blocked, null, 'an ambiguous beneficiary cannot authorize the native effect');
|
||||
if (effect !== 'sardaukar') assert.equal(actual.status, 'unresolved');
|
||||
if (effect === 'sardaukar') assert.equal(actual.suppressed, false, 'blocked is not selected strength');
|
||||
assert.deepEqual(game, before);
|
||||
const saved: Game = JSON.parse(JSON.stringify(game));
|
||||
assert.deepEqual(rule.quote(saved), actual, 'original JSON history retains ambiguity');
|
||||
assertDefenseInventory(saved);
|
||||
delete saved.homeworldOccupationPreview;
|
||||
assert.deepEqual(rule.quote(saved), effect === 'sardaukar'
|
||||
? { suppressed: false, blocked: null } : { status: 'allowed', blocked: null }, 'preview-off does not block old rules');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Game } from '../game/engine';
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from '../game/homeworld-cards';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { HomeworldCustodyError } from '../game/homeworld-custody';
|
||||
import { occupiedHomeworldSardaukarStatus } from '../game/homeworld-occupied-defenses';
|
||||
import {
|
||||
beginHomeworldOccupiedIncome, homeworldOccupiedIncomeOffer, quoteHomeworldOccupiedIncomeChoice,
|
||||
type HomeworldOccupiedIncomeState,
|
||||
} from '../game/homeworld-occupied-income';
|
||||
import { occupiedIncomeFixture, observeControlledIncomePosition } from './fixture-homeworld-occupied-income';
|
||||
|
||||
const printed: Record<HomeworldId, number> = {
|
||||
caladan: 2, giedi_prime: 2, southern_hemisphere: 2, junction: 2, wallach_ix: 1,
|
||||
kaitain: 2, salusa_secundus: 0, ix: 2, tleilax: 2, tupile: 2, richese: 1, ecaz: 2, grumman: 2,
|
||||
};
|
||||
|
||||
/** Exercise the returned BANK credit legs against real setup wallets. This is
|
||||
* the engine consumer contract, not personal-wallet forwarding or bribery. */
|
||||
function settle(game: Game, state: HomeworldOccupiedIncomeState, ownAmount: number) {
|
||||
const row = state.queue[state.cursor];
|
||||
const before = structuredClone({ game, state });
|
||||
const result = quoteHomeworldOccupiedIncomeChoice(state, game, row.occupier, state.event, row.world, ownAmount);
|
||||
assert.deepEqual({ game, state }, before, 'quoting never mutates wallets or the pending receipt');
|
||||
for (const credit of result.credits) game.players.find(player => player.id === credit.player)!.spice += credit.amount;
|
||||
return result;
|
||||
}
|
||||
|
||||
void test('printed BANK grants preserve all thirteen faces and Basic/Advanced sums, independent of percentage text', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
let total = 0;
|
||||
for (const card of HOMEWORLD_CARDS) {
|
||||
if (!advanced && card.id === 'salusa_secundus') continue;
|
||||
const { game, world, cards } = occupiedIncomeFixture(card.id, advanced, false);
|
||||
const before = structuredClone(game);
|
||||
const state = beginHomeworldOccupiedIncome(game, `collection-${card.id}`);
|
||||
assert.deepEqual(game, before, 'Collection entry does not mint spice');
|
||||
assert.equal(state.queue.length, 1);
|
||||
assert.equal(state.queue[0].world, world);
|
||||
assert.equal(state.queue[0].card, card.id);
|
||||
assert.equal(state.queue[0].spice, printed[card.id]);
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
const balance = owner.spice;
|
||||
const result = settle(game, state, printed[card.id]);
|
||||
assert.deepEqual(result.credits, printed[card.id] ? [{ player: 'owner', amount: printed[card.id] }] : []);
|
||||
assert.equal(owner.spice - balance, printed[card.id]);
|
||||
assert.equal(result.state.receipts[0].status, 'settled');
|
||||
assert.equal(homeworldOccupiedIncomeOffer(result.state, game, 'owner'), null);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards);
|
||||
homeworldGameIntegrity(game);
|
||||
total += owner.spice - balance;
|
||||
}
|
||||
assert.equal(total, 22);
|
||||
}
|
||||
});
|
||||
|
||||
void test('full, partial and zero own splits credit the named reciprocal ally directly from the Bank and conserve total', () => {
|
||||
for (const ownAmount of [2, 1, 0]) {
|
||||
const { game } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-split');
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
const ally = game.players.find(player => player.id === 'ally')!;
|
||||
owner.spice = 0;
|
||||
const ownerBefore = owner.spice, allyBefore = ally.spice;
|
||||
const offer = homeworldOccupiedIncomeOffer(state, game, owner.id)!;
|
||||
assert.equal(offer.ally, ally.id);
|
||||
assert.equal(offer.minOwnAmount, 0);
|
||||
assert.equal(offer.maxOwnAmount, 2);
|
||||
const result = settle(game, state, ownAmount);
|
||||
assert.equal(owner.spice - ownerBefore, ownAmount);
|
||||
assert.equal(ally.spice - allyBefore, 2 - ownAmount);
|
||||
assert.equal(result.credits.reduce((sum, credit) => sum + credit.amount, 0), 2);
|
||||
assert.deepEqual(result.state.receipts[0], { world: state.queue[0].world, status: 'settled', ownAmount, credits: result.credits });
|
||||
}
|
||||
});
|
||||
|
||||
void test('no ally permits only the full printed award; malformed amounts and actions reject without changing anything', () => {
|
||||
const { game, world } = occupiedIncomeFixture('caladan', false, false);
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-bounds');
|
||||
const offer = homeworldOccupiedIncomeOffer(state, game, 'owner')!;
|
||||
assert.equal(offer.ally, null);
|
||||
assert.equal(offer.minOwnAmount, 2);
|
||||
assert.equal(homeworldOccupiedIncomeOffer(state, game, 'native'), null);
|
||||
const before = structuredClone({ game, state });
|
||||
for (const amount of [0, 1, -1, 3, 0.5, NaN, Infinity])
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, amount), HomeworldCustodyError);
|
||||
for (const [actor, event, target] of [
|
||||
['ally', state.event, world], ['native', state.event, world], ['owner', 'stale-event', world],
|
||||
['owner', state.event, 'homeworld:guild'],
|
||||
]) assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, actor, event, target, 2), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state }, before);
|
||||
assert.deepEqual(settle(game, state, 2).credits, [{ player: 'owner', amount: 2 }]);
|
||||
});
|
||||
|
||||
void test('allied integer bounds, nonreciprocal alliance and alliance changes cannot reroute a frozen receipt', () => {
|
||||
const { game, world } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-reciprocal');
|
||||
for (const amount of [-1, 3, 1.5, NaN])
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, amount), HomeworldCustodyError);
|
||||
game.players.find(player => player.id === 'ally')!.ally = null;
|
||||
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
|
||||
const unreciprocated = beginHomeworldOccupiedIncome(game, 'collection-nonreciprocal');
|
||||
assert.equal(unreciprocated.queue[0].ally, null);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(unreciprocated, game, 'owner', unreciprocated.event, world, 0), HomeworldCustodyError);
|
||||
});
|
||||
|
||||
void test('Emperor worlds stay distinct; JSON continuation retains exact settled and pending receipts including zero Salusa', () => {
|
||||
const { game, world } = occupiedIncomeFixture('kaitain', true);
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
const secondary = 'homeworld:emperor:salusa';
|
||||
game.homeworlds!.custody!.visitors[secondary] = { owner: { normal: 1, elite: 0 } };
|
||||
owner.reserves--;
|
||||
observeControlledIncomePosition(game, 'controlled-separate-salusa');
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-emperor');
|
||||
assert.deepEqual(state.queue.map(row => [row.world, row.card, row.spice]), [[world, 'kaitain', 2], [secondary, 'salusa_secundus', 0]]);
|
||||
const first = settle(game, state, 1);
|
||||
const resumedGame: Game = JSON.parse(JSON.stringify(game));
|
||||
const resumed: HomeworldOccupiedIncomeState = JSON.parse(JSON.stringify(first.state));
|
||||
assert.deepEqual(resumed.receipts, [
|
||||
{ world, status: 'settled', ownAmount: 1, credits: [{ player: 'owner', amount: 1 }, { player: 'ally', amount: 1 }] },
|
||||
{ world: secondary, status: 'pending' },
|
||||
]);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(resumed, resumedGame, 'owner', resumed.event, world, 1), HomeworldCustodyError);
|
||||
assert.equal(homeworldOccupiedIncomeOffer(resumed, resumedGame, 'owner')!.amount, 0);
|
||||
const done = settle(resumedGame, resumed, 0);
|
||||
assert.deepEqual(done.credits, []);
|
||||
assert.deepEqual(done.state.receipts[0], first.state.receipts[0]);
|
||||
assert.deepEqual(done.state.receipts[1], { world: secondary, status: 'settled', ownAmount: 0, credits: [] });
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(done.state, resumedGame, 'owner', resumed.event, secondary, 0), HomeworldCustodyError);
|
||||
const corrupted: HomeworldOccupiedIncomeState = JSON.parse(JSON.stringify(first.state));
|
||||
corrupted.receipts[0] = { world, status: 'settled', ownAmount: 2, credits: [{ player: 'owner', amount: 2 }] };
|
||||
assert.throws(() => homeworldOccupiedIncomeOffer(corrupted, game, 'owner'), HomeworldCustodyError);
|
||||
resumedGame.turn++;
|
||||
assert.throws(() => homeworldOccupiedIncomeOffer(resumed, resumedGame, 'owner'), HomeworldCustodyError);
|
||||
});
|
||||
|
||||
void test('preview disabled never provides bank entitlement; repeated offer reads cannot pay or advance a receipt', () => {
|
||||
const { game } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-read');
|
||||
const before = structuredClone({ game, state });
|
||||
for (let i = 0; i < 5; i++) assert.equal(homeworldOccupiedIncomeOffer(state, game, 'owner')!.amount, 2);
|
||||
assert.deepEqual({ game, state }, before);
|
||||
delete game.homeworldOccupationPreview;
|
||||
assert.equal(beginHomeworldOccupiedIncome(game, 'collection-disabled').queue.length, 0);
|
||||
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
|
||||
});
|
||||
|
||||
void test('departed then returned qualifier is blocked by original history, not paid to its current garrison', () => {
|
||||
const { game, world } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-departure');
|
||||
const owner = game.players.find(player => player.id === 'owner')!;
|
||||
delete game.homeworlds!.custody!.visitors[world];
|
||||
owner.reserves++;
|
||||
observeControlledIncomePosition(game, 'controlled-departure');
|
||||
game.homeworlds!.custody!.visitors[world] = { owner: { normal: 1, elite: 0 } };
|
||||
owner.reserves--;
|
||||
observeControlledIncomePosition(game, 'controlled-return');
|
||||
const before = structuredClone({ game, state });
|
||||
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
|
||||
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-after-return'), HomeworldCustodyError);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state }, before);
|
||||
});
|
||||
|
||||
void test('competing qualification or a competing foreign army blocks all bank settlement, including an earlier queued entitlement', () => {
|
||||
for (const departed of [false, true]) {
|
||||
const { game, world } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-contested');
|
||||
const custody = game.homeworlds!.custody!;
|
||||
if (departed) {
|
||||
delete custody.visitors[world];
|
||||
game.players.find(player => player.id === 'owner')!.reserves++;
|
||||
observeControlledIncomePosition(game, 'controlled-original-departure');
|
||||
}
|
||||
custody.visitors[world] ??= {};
|
||||
custody.visitors[world].competitor = { normal: 1, elite: 0 };
|
||||
game.players.find(player => player.id === 'competitor')!.reserves--;
|
||||
observeControlledIncomePosition(game, 'controlled-competing-arrival');
|
||||
const before = structuredClone({ game, state });
|
||||
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
|
||||
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-ambiguous'), HomeworldCustodyError);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 1), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('restoring the native high threshold after qualification blocks the pending bank grant without erasing history', () => {
|
||||
const { game, world } = occupiedIncomeFixture();
|
||||
const state = beginHomeworldOccupiedIncome(game, 'collection-native-restored');
|
||||
const qualifications = structuredClone(game.homeworldOccupationHistory!.qualifications);
|
||||
const native = game.players.find(player => player.id === 'native')!;
|
||||
native.reserves = 6;
|
||||
native.tanks -= 6;
|
||||
observeControlledIncomePosition(game, 'controlled-native-repopulation');
|
||||
assert.deepEqual(game.homeworldOccupationHistory!.qualifications, qualifications);
|
||||
const before = structuredClone({ game, state });
|
||||
assert.notEqual(homeworldOccupiedIncomeOffer(state, game, 'owner')!.blocked, null);
|
||||
assert.throws(() => beginHomeworldOccupiedIncome(game, 'collection-restored-native'), HomeworldCustodyError);
|
||||
assert.throws(() => quoteHomeworldOccupiedIncomeChoice(state, game, 'owner', state.event, world, 2), HomeworldCustodyError);
|
||||
assert.deepEqual({ game, state }, before);
|
||||
});
|
||||
|
||||
void test('zero-icon contested Salusa cannot block a real Kaitain bank award or choose a Sardaukar strength', () => {
|
||||
const { game } = occupiedIncomeFixture('kaitain', true, false);
|
||||
const secondary = 'homeworld:emperor:salusa';
|
||||
const owner = game.players.find(p => p.id === 'owner')!;
|
||||
owner.reserves--;
|
||||
game.homeworlds!.custody!.visitors[secondary] = { owner: { normal: 1, elite: 0 } };
|
||||
observeControlledIncomePosition(game, 'controlled-secondary-sole-arrival');
|
||||
game.players.find(p => p.id === 'competitor')!.reserves--;
|
||||
game.homeworlds!.custody!.visitors[secondary].competitor = { normal: 1, elite: 0 };
|
||||
observeControlledIncomePosition(game, 'controlled-secondary-contested');
|
||||
assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null);
|
||||
const before = owner.spice;
|
||||
const pending = beginHomeworldOccupiedIncome(game, 'collection-positive-primary-zero-secondary');
|
||||
const result = settle(game, pending, 2);
|
||||
assert.equal(owner.spice - before, 2);
|
||||
assert.equal(result.state.cursor, result.state.queue.length);
|
||||
assert.equal(occupiedHomeworldSardaukarStatus(game).suppressed, false);
|
||||
assert.notEqual(occupiedHomeworldSardaukarStatus(game).blocked, null,
|
||||
'Collection does not guess the still-unresolved mandatory combat penalty.');
|
||||
});
|
||||
+22
-7
@@ -16,6 +16,7 @@ import {
|
||||
initializeBaseGameForAudit,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
initializeHomeworldGameForAudit,
|
||||
initializeHomeworldOccupationGameForAudit,
|
||||
initializeStrongholdFactionsGameForAudit,
|
||||
initializeNexusGameForAudit,
|
||||
initializePairedNexusGameForAudit,
|
||||
@@ -45,7 +46,7 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
|
||||
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills'
|
||||
| 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech'
|
||||
| 'paired-nexus-skills-tech' | 'paired-nexus-skills-stronghold' | 'paired-nexus-skills-stronghold-tech'
|
||||
| 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech';
|
||||
| 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation';
|
||||
type Rules = 'basic' | 'advanced';
|
||||
|
||||
type Scenario = {
|
||||
@@ -376,6 +377,12 @@ const E3_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] =
|
||||
.filter(scenario => (profile === 'e3-nexus-skills' || profile === 'e3-nexus-skills-tech' || scenario.rules === 'advanced') &&
|
||||
(profile === 'e3-nexus-skills' || profile === 'e3-nexus-skills-stronghold' || scenario.roster.length >= 3))
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 2100 + band * 100 + index })));
|
||||
const HOMEWORLD_OCCUPATION_SCENARIOS: readonly Scenario[] = [
|
||||
...MODULE_SCENARIOS.filter(scenario => scenario.profile === 'homeworld'),
|
||||
...PAIRED_NEXUS_SCENARIOS.filter(scenario => scenario.profile !== 'ecaz-nexus'),
|
||||
...ECAZ_SKILLS_SCENARIOS,
|
||||
...MORITANI_SKILLS_SCENARIOS,
|
||||
].map((scenario, index): Scenario => ({ ...scenario, profile: 'homeworld-occupation', ordinal: 2500 + index }));
|
||||
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
||||
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
||||
];
|
||||
@@ -493,7 +500,7 @@ 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|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
|
||||
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
|
||||
'[--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.' +
|
||||
@@ -507,6 +514,7 @@ function usage() {
|
||||
+ '\nFactions-tech uses native E1/E2 families or standalone Ecaz OR Moritani plus classics, three-through-six seats, Basic/Advanced, original selected decks and three Tech Tokens without Skills. Stronghold-factions-tech is its Advanced six-card Stronghold composition. Native Tech setup, real payments, typed battle rewards and selected Ecaz lead attribution remain; Basic odd Occupy, Richese cache/No-Field rulings, other modules and public starts stay guarded.'
|
||||
+ '\nNexus-tech preserves classic/base33/all12 Nexus and original Tech3..6 in Basic/Advanced. Nexus-stronghold adds six original Stronghold Cards in Advanced2..6; nexus-stronghold-tech requires3..6. Original closing Nexus deals, actual free/paid source identity, phase-end Tech, held support and postbattle card/reward windows remain. Skills, Homeworlds, expansions, other overlays, unresolved effects, public starts and played-game conversion stay separate.'
|
||||
+ '\nPaired-nexus-tech adds original Tech to both Ixians+Tleilaxu OR CHOAM+Richese and classic opponents, one selected deck, three-through-six seats, Basic/Advanced. Paired-nexus-stronghold is Advanced2..6; paired-nexus-stronghold-tech is Advanced3..6. Native Cunning, real closing deals, typed forces/physical Face Dancers/No-Fields, original industry/rewards and held effects remain. No mixed families, E3, Skills, other overlays, new rulings, public starts or save conversion.'
|
||||
+ '\nHomeworld-occupation uses original classic, paired E1/E2 or standalone E3 two-through-six-seat Basic/Advanced rosters and selected decks, with original Homeworlds alone. Continuously proved, uncontested occupation enables printed bank Collection, immediate ally sharing and source-specific occupied native defenses. Departed/replaced/contested/repopulated histories retain source guards. Paid percentage receipts, exceptional borrowed powers, unrelated modules, public starts and played-game conversion remain separate.'
|
||||
+ '\nNexus-skills uses classic/base33/all14 Skills/all12 Nexus, Basic/Advanced two through six seats, with original setup/posture, actual closing draws and physical effects. Native factions, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler shipment arithmetic and unresolved effects remain guarded; no public start or played-game conversion.'
|
||||
+ '\nPaired-nexus-skills adds all14 Skills to both original Ixians+Tleilaxu OR CHOAM+Richese, classic opponents and one family deck/all12 Nexus, Basic/Advanced2..6. Native setup, actual closing draws, typed skills/Cunning, Face Dancers and one-invoice physical marker pairs reuse original rules. Tech/Strongholds/HW, mixed families, E3, Banker/Mentat previews, borrowed Smuggler/pair companions, public starts and pending rulings remain excluded.'
|
||||
+ '\nNexus-skills-tech preserves original Tech3..6 Basic/Advanced with classic/base33/all14/all12; nexus-skills-stronghold preserves original Advanced Strongholds2..6; selecting both requires3..6 Advanced. Real first-Storm/end-Mentat ownership, phase-end industry, held subsidy, typed rescue and mandatory original-winner token transfer remain. Native rosters, HW, Banker/Mentat previews, other overlays, public starts and pending rulings stay separate.'
|
||||
@@ -542,6 +550,8 @@ function positive(value: string | undefined) {
|
||||
}
|
||||
|
||||
function scenarioName(scenario: Scenario) {
|
||||
if (scenario.profile === 'homeworld-occupation')
|
||||
return `${scenario.profile}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
|
||||
if ((scenario.profile === 'skills-tech' || strongholdSkillProfile(scenario.profile)) && scenario.expansions.length) {
|
||||
const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(faction));
|
||||
const extraIx = scenario.expansions.includes('ix') && !native.some(faction => faction === 'ixians' || faction === 'tleilaxu');
|
||||
@@ -580,6 +590,7 @@ function scenarioName(scenario: Scenario) {
|
||||
|
||||
function parseProfile(value: string | undefined) {
|
||||
const profile = value ?? 'all';
|
||||
if (profile === 'homeworld-occupation') return profile;
|
||||
if (!skillProfile(profile) && !nativeTechProfile(profile) && !nexusModuleProfile(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', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile))
|
||||
throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.');
|
||||
return profile as Profile | 'all';
|
||||
@@ -628,10 +639,12 @@ function freshGame(scenario: Scenario) {
|
||||
if (scenario.ecazTreachery) game.ecazTreachery = true;
|
||||
if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
|
||||
if (strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
|
||||
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
game.homeworlds = { custody: null };
|
||||
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
if (scenario.profile === 'homeworld-occupation')
|
||||
return initializeHomeworldOccupationGameForAudit(game);
|
||||
if (scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus');
|
||||
if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || pairedNexusModuleProfile(scenario.profile))
|
||||
@@ -697,10 +710,11 @@ 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, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_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, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS].find(
|
||||
(candidate) =>
|
||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || 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 &&
|
||||
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
|
||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||
(candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||
(nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
||||
@@ -913,7 +927,7 @@ async function main() {
|
||||
const profile = parseProfile(values.profile);
|
||||
const rules = parseRules(values.rules);
|
||||
const players = parsePlayers(values.players);
|
||||
if (supplied('players') && !nexusModuleProfile(profile) && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
|
||||
if (supplied('players') && profile !== 'homeworld-occupation' && !nexusModuleProfile(profile) && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
|
||||
throw new Error('--players requires a roster or optional-module profile.');
|
||||
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
|
||||
throw new Error(`--profile ${profile} supports only five or six players.`);
|
||||
@@ -924,7 +938,8 @@ async function main() {
|
||||
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
|
||||
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
|
||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
||||
const requestedSamples = profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS
|
||||
: profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
||||
? MODULE_SCENARIOS
|
||||
: pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS
|
||||
: profile === 'nexus-skills' ? NEXUS_SKILLS_SCENARIOS
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
initializeIxGameForAudit,
|
||||
initializeDiscoveryGameForAudit,
|
||||
initializeHomeworldOccupationGameForAudit,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
initializeSpiceBankerIncomeGameForAudit,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
export const PROTOTYPE_PROFILES = [
|
||||
'ix',
|
||||
'discovery',
|
||||
'homeworld-occupation',
|
||||
'leader-skills',
|
||||
'banker-income',
|
||||
'factions',
|
||||
@@ -105,6 +107,11 @@ export function startPrototypeRoom(
|
||||
const game =
|
||||
profile === 'ix'
|
||||
? initializeIxGameForAudit(initial)
|
||||
: profile === 'homeworld-occupation'
|
||||
? initializeHomeworldOccupationGameForAudit({
|
||||
...initial,
|
||||
homeworlds: initial.homeworlds ?? { custody: null },
|
||||
})
|
||||
: profile === 'stronghold-factions'
|
||||
? initializeStrongholdFactionsGameForAudit(initial)
|
||||
: profile === 'ecaz-treachery'
|
||||
|
||||
@@ -23,7 +23,7 @@ async function main() {
|
||||
});
|
||||
if (values.help) {
|
||||
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, optionally preserving unused Tech Tokens at 3–6 seats. 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 except for each profile’s documented composition. 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.',
|
||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|homeworld-occupation|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, optionally preserving unused Tech Tokens at 3–6 seats. 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 except for each profile’s documented composition. 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 lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. 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 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. 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 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. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Explicit --mentat-question stays private; banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
|
||||
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +
|
||||
@@ -37,6 +37,7 @@ async function main() {
|
||||
'\nThese classic or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.',
|
||||
'\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.',
|
||||
'\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.',
|
||||
'\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into continuous unambiguous occupation, original occupied bank Collection and immediate ally sharing, plus native occupied defensive effects. One continuously present original qualifier and low native population are required. Departed, replaced, contested or repopulated histories retain their source guards; no current-garrison shortcut or expiry ruling is inferred. Paid occupied percentage receipts, borrowed/exceptional powers, other modules, public starts and old-game conversion remain separate.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user