Route classic Homeworld Harass returns through native custody
This commit is contained in:
1 parent
5509abf1e5
commit
643add3f85
18 files changed
+1174
-83
No files matched your search
@@ -5129,7 +5129,14 @@ export function GameTable({
|
||||
{g.players.find(p => p.id === g.battle!.smugglerCollection!.player)?.name}’s Smuggler has {g.battle.smugglerCollection.amount} spice pending from the reveal-time piles.
|
||||
If the leader survives, collection resolves before battle settlement, even after defeat. A partial collection across several piles requires its owner’s sector choice. That spice is not yet spendable.
|
||||
</p>}
|
||||
{!g.battle.traitorSubmitted.includes(me.id) && (
|
||||
{!g.battle.traitorVoters.includes(me.id) && (
|
||||
<p className="notice">
|
||||
Only the native faction may declare Traitors here.
|
||||
Wait for that decision; any legal invader late defense
|
||||
follows through its own original control.
|
||||
</p>
|
||||
)}
|
||||
{g.battle.traitorVoters.includes(me.id) && !g.battle.traitorSubmitted.includes(me.id) && (
|
||||
<>
|
||||
{actionButton(
|
||||
'Reveal traitor',
|
||||
@@ -5358,6 +5365,17 @@ export function GameTable({
|
||||
</label>
|
||||
))}
|
||||
<HarassWithdrawGuide preview={g.battle.harassWithdraw} state={harassControl} />
|
||||
{g.homeworlds && g.battle.harassWithdraw && (
|
||||
<p className="notice">
|
||||
Classic Homeworld timing: actual undialed counters
|
||||
return to your native reserve Homeworlds, never to
|
||||
the Tanks. In Advanced, Emperor normal forces return
|
||||
to Kaitain and Sardaukar to Salusa; Basic retains
|
||||
its original single primary World. Your original
|
||||
dial/support stay committed across population
|
||||
thresholds. Harass cannot be played on any own World.
|
||||
</p>
|
||||
)}
|
||||
{g.battle.reinforcements && (
|
||||
<p className="notice">
|
||||
Reinforcements may occupy either card slot but is neither a weapon nor a defense.
|
||||
|
||||
@@ -47,7 +47,9 @@ export function harassWithdrawChoiceState(
|
||||
}
|
||||
}
|
||||
|
||||
function locationLabel(key: string) {
|
||||
function locationLabel(g: GameView, key: string) {
|
||||
if (key.startsWith('homeworld:'))
|
||||
return g.combatLocations?.find(place => place.id === key)?.name ?? key;
|
||||
const location = splitLocation(key);
|
||||
return `${territory(location.territory).name} · sector ${location.sector}`;
|
||||
}
|
||||
@@ -118,6 +120,14 @@ export function HarassWithdrawChoice({
|
||||
Your Battle Plan is revealed. Choose the exact undialed counters that
|
||||
return to reserves before the Traitor decision.
|
||||
</p>
|
||||
{g.homeworlds && (
|
||||
<p className="fine">
|
||||
These counters return to native Homeworld reserves. Advanced Emperor
|
||||
ordinary counters go to Kaitain and Sardaukar to Salusa; Basic keeps
|
||||
both types on its original primary World. Original commitment/support
|
||||
does not change when that population rises.
|
||||
</p>
|
||||
)}
|
||||
<label htmlFor={`${id}-commitment`}>Dialed physical commitment</label>
|
||||
<select
|
||||
id={`${id}-commitment`}
|
||||
@@ -156,7 +166,7 @@ export function HarassWithdrawChoice({
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, available]) => (
|
||||
<fieldset key={key} className="grid gap-2 rounded-lg border p-3">
|
||||
<legend>{locationLabel(key)}</legend>
|
||||
<legend>{locationLabel(g, key)}</legend>
|
||||
{(['normal', 'elite'] as const).map((kind) => (
|
||||
<label key={kind}>
|
||||
Return {kind === 'normal' ? 'ordinary' : 'elite'} (0–
|
||||
|
||||
@@ -29,6 +29,27 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Classic Homeworld Harass — 16 October.** Fresh classic2–6 Basic/Advanced
|
||||
Homeworlds can compose the original independent36-card variant. Harass works
|
||||
on Arrakis or a foreign World, never any own World, including both Emperor
|
||||
Worlds in Advanced. Other two variant cards retain their World guards; no other
|
||||
overlays, expansion rosters, public starts or played-save conversion open.
|
||||
|
||||
Actual undialed counters return through one native custody quote: Basic
|
||||
original primary retains typed elites; Advanced normal→Kaitain/elite→Salusa.
|
||||
Original commitment/support stays fixed as returned population changes later
|
||||
observations. Native-only voters, Traitor/explosion and card disposal remain;
|
||||
the UI no longer offers invader votes. Preserve the full Harass complement in
|
||||
the World loss receipt with an optional nested `fullyCommitted` bit, not a new
|
||||
game root, fake board key or delayed rescore.
|
||||
|
||||
23 new regressions; related27-file246/246, types/lint and build pass. Four actual
|
||||
mobile journeys complete native Collection;16 native programs/four policies
|
||||
accept36 continuation actions and validate precommit proposals. CLI help and
|
||||
guide evidence import pass. Initial Vite optimization aborted four old dependency
|
||||
requests; no overflow/application exception. Partial prototype, no deployed,
|
||||
whole-mode, recovery/custody certification or original-Ghola gate change.
|
||||
|
||||
**Current concealed No-Field value facts — 16 October.** Truthtrance can ask
|
||||
whether the respondent currently has a concealed printed0/3/5. Absent or
|
||||
revealed is No even for0; concealed5 with exhausted reserves remains5. Read
|
||||
|
||||
@@ -138,15 +138,90 @@ fixed losses. Both co-side journeys finish the actual CHOAM market and clear
|
||||
battle/decision into phase7. Owned hosts/tabs/scaffolds were removed; no human
|
||||
game, D1, deployment or new recovery campaign changed. Full acceptance remains.
|
||||
|
||||
## Classic Homeworld reserve routing — 16 October 2026
|
||||
|
||||
Fresh `--profile ecaz-treachery --homeworlds` selects the independent three
|
||||
physical cards before original classic2–6 Basic/Advanced setup: base33+3.
|
||||
Harass composes on Arrakis and foreign Homeworlds. The printed own-World
|
||||
prohibition remains, including both Advanced Emperor Worlds. Expansion
|
||||
rosters, other overlays, public starts and played-game conversion stay closed.
|
||||
Recruits/Reinforcements retain their existing Homeworld module guards; this
|
||||
is not certification of the entire three-card variant.
|
||||
|
||||
Arrakis returns remove exact ordinary/elite board counters and use the
|
||||
existing native reserve deposit quote. Foreign-World returns atomically
|
||||
withdraw the selected visitor pool and deposit into the actor's native
|
||||
reserves, rather than inventing `Player.forces` World keys or counting deaths.
|
||||
Basic keeps original typed counters at its one primary World. Advanced
|
||||
Emperor ordinary counters go to Kaitain and Sardaukar to Salusa; other actors
|
||||
use their primary. Reusing that native destination rule for Harass is explicit
|
||||
composition, not a new Harass-specific FAQ or a new answer to the earlier
|
||||
separate Nexus reserve-placement question.
|
||||
|
||||
The sealed physical commitment/support and original force-role snapshot
|
||||
remain fixed. Returning population updates subsequent native observations,
|
||||
not this battle's score or support. The existing Homeworld loss commitment
|
||||
now retains Harass's `fullyCommitted` bit: no smaller strength-equivalent
|
||||
casualty group can be selected after its complement already returned. The
|
||||
remaining physical pool is still validated. This adds no Game root field;
|
||||
old non-Harass commitments remain unchanged. Other mid-battle population
|
||||
guards, including the separate Ghola threshold question, remain.
|
||||
|
||||
Original native-only Traitor/Face Dancer authority and late-defense/explosion
|
||||
controls remain. Invaders no longer receive an illegal Traitor button; their
|
||||
owned late defense still follows the native decision. Successful opposing
|
||||
Traitor cancels the return. Returned counters stay outside native casualties,
|
||||
revival ledgers and subsequent Face Dance; mandatory card disposal remains.
|
||||
Canonical World names label the after-reveal typed allocation.
|
||||
|
||||
### Scoped Homeworld proof
|
||||
|
||||
Both modes complete original independent36-card setup with native custody and
|
||||
five physical Sardaukar; the custody appears after the real setup decisions,
|
||||
not at the earlier undealt snapshot. The foreign-World regression fixture ships
|
||||
3 normal plus4 elite, leaving low Salusa and an ambiguous paid typed dial.
|
||||
This avoids pretending a unique free-Sardaukar commitment needs a manual choice.
|
||||
|
||||
Four actual390px journeys reach original Collection with battle/decision/loss
|
||||
frames clear: Basic foreign loss, Advanced foreign win, Advanced Arrakis
|
||||
Salusa1→4 and own-Salusa prohibition with a plain legal plan. The first three
|
||||
select2 normal/3 elite returns, preserve the original2-counter commitment and
|
||||
end Emperor reserves18/Tanks2, elite reserves4/Tanks1. Basic deposits both
|
||||
types on Kaitain; Advanced splits14 normal Kaitain/4 Sardaukar Salusa. Original
|
||||
Advanced support1 still costs1. Own-Salusa offers no Harass slot, keeps its card
|
||||
unplayed, and completes the native-only vote without an invader action.
|
||||
|
||||
The World winner regression exposed a lost `fullyCommitted` receipt bit:
|
||||
revalidating all smaller strength-equivalent loss groups stalled the accepted
|
||||
selected complement. Retaining/filtering the original commitment fixes that
|
||||
path without weakening physical-pool checks. Fixture wallets now distinguish
|
||||
original advanced Collection income and losing/mutual-Traitor support from a
|
||||
sole winner's waiver. Incidental receipt/default/log-text assertions and an
|
||||
older obsolete Stone/validated-Traitor ambiguity expectation are removed, not
|
||||
re-pinned.
|
||||
|
||||
Sixteen fresh programs/four policies accept36 allocation/vote/own-plan
|
||||
continuation actions and validate native precommit proposals; normal, explosion
|
||||
and own-World alternatives reach phase7. CLI help executes and the generated
|
||||
guide imports with36 evidence paths/source anchor valid; five Harass facets and
|
||||
both coverages remain Partial. No overflow/application exception; initial Vite
|
||||
optimization aborted four old dependency requests before rendered journeys.
|
||||
Owned hosts/tab/scaffolds were removed. These are native setup/shipment/control
|
||||
programs, not full unstaged modes, new recovery assurance or deployed proof.
|
||||
|
||||
Final frozen proof:23 new regressions, related27 offline files246/246,
|
||||
types/lint and app build pass. Existing build deprecation/large-chunk/Unknown
|
||||
route warnings remain; no HTTP or database boundary changed.
|
||||
|
||||
## Current boundaries
|
||||
|
||||
- Basic and Advanced now support explicit ordinary/elite and sector allocation
|
||||
after reveal. Unique returns are automatic; previously valid legacy battles
|
||||
retain unique-only derivation. See the [allocation contract](HARASS_ALLOCATION.md).
|
||||
- Additional optional modules remain gated, including Homeworlds, Nexus,
|
||||
Leader Skills, Tech Tokens, Stronghold Cards and Discoveries. This includes
|
||||
the printed own-Homeworld prohibition without claiming other Homeworld
|
||||
battles are already implemented for this card.
|
||||
- Clean classic Homeworlds use the [native reserve-routing contract](#classic-homeworld-reserve-routing--16-october-2026).
|
||||
Other optional modules remain gated, including Nexus, Leader Skills, Tech
|
||||
Tokens, Stronghold Cards and Discoveries; broader World/faction composition
|
||||
and full-mode acceptance stay open.
|
||||
- Richese card-family configurations now admit Harass under the
|
||||
[provisional post-withdrawal policy](#provisional-stone-burner-timing--16-october-2026).
|
||||
The ordinary invariant-allocation guard remains. Clean selected Ix follows
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 16 October 2026 — Native Homeworld Harass return custody
|
||||
|
||||
Before-state fresh classic Homeworlds plus independent cards rejected before
|
||||
dealing. Admit only clean classic2–6 Basic/Advanced with the original36-card
|
||||
variant. Harass's printed own-Homeworld prohibition applies to both Emperor
|
||||
Worlds; foreign Worlds are not a blanket ban. Recruits/Reinforcements retain
|
||||
their Homeworld guards. All other overlays, rosters, public starts and
|
||||
played-save conversion stay closed.
|
||||
|
||||
Add one atomic source-withdrawal/native-deposit custody quote, reusing original
|
||||
Basic primary and Advanced Emperor typed destinations. No fake World force
|
||||
keys, deaths or revival. Existing original sealed physical commitment/support
|
||||
does not rescore when returned Sardaukar restore high Salusa. World loss
|
||||
receipts retain optional nested `fullyCommitted` and filter original complete
|
||||
complements, fixing the observed winning-Harass ambiguity stall; no game root
|
||||
field. Native-only voting is now reflected in revealed UI rather than offering
|
||||
an invader a button the original engine rejects.
|
||||
|
||||
Four new fixture/test files add23 behavior cases: original setup, actual typed
|
||||
shipments, win/loss, both card slots, own primary/secondary prohibition, native
|
||||
Traitor cancellation, mutual/sole Trait support, native explosion, threshold
|
||||
changes and next-turn returned-counter shipment. Remove incidental fixture/
|
||||
receipt/log-text pins and the old Stone test tail expecting an already validated
|
||||
Traitor to reject an ambiguous winner. Keep its meaningful mode and physical
|
||||
token comparisons. The first frozen union exposed that stale expectation and
|
||||
an optional-RegExp test-helper type error; repair both without production
|
||||
special cases.
|
||||
|
||||
Final related27 offline files246/246; types/lint and build pass. Build retains
|
||||
existing register deprecation/large-chunk/Unknown-route warnings. Four actual
|
||||
390px native GameTable journeys complete Collection: Basic foreign loss,
|
||||
Advanced foreign winner, Arrakis Salusa1→4, and own-Salusa legal plain plan.
|
||||
Typed labels/mandatory card discard/original commitment are observed; no
|
||||
invader vote after the UI fix. Sixteen fresh programs/four minimal policies
|
||||
accept36 continuation actions and validate16 native precommit proposals.
|
||||
CLI help executes; guide evidence/source anchor import passes. Four startup
|
||||
Vite dependency aborts precede the successful rendered journeys, not zero
|
||||
browser diagnostics. Proof artifacts remain outside Git; owned scaffolds,
|
||||
services and tab removed. No saved games or databases touched.
|
||||
|
||||
`HARASS_WITHDRAW_RUNTIME.md`, source decisions, CLI/card/guide descriptions and
|
||||
status reflect explicit native-return composition, not a newly invented
|
||||
Harass-specific FAQ. Both coverage topics remain Partial. No deployment,
|
||||
complete mode, expansion gate or recovery/custody certification.
|
||||
|
||||
## 16 October 2026 — Current concealed No-Field Truthtrance values
|
||||
|
||||
Native before-state rejected a real current-value question during an actual
|
||||
|
||||
@@ -662,6 +662,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Mixed No-Field Occupy | Extend the existing provisional additive ordinary-plus-reserve-capped-marker pool into selected native Advanced coalition battles, as ally or opponent. Original fixed Ecaz strength, support payer/counter owners, Traitor/actual reveal and history stay; prevention restores selected-lead-only pool. Selected Ecaz plan controls the ally's private variable quote, not its own ordinary maximum; rivals get no concealed increment. The FAQ mixed-cap ambiguity remains unresolved, not newly adjudicated. Basic mixed-co-side and other profile/module gates remain. | [Exact composition](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026) |
|
||||
| No-Field / Atreides special plan | Supplied physical p27 C bars ordinary dial Prescience; distinct p27 F grants an entire-plan special Karama and p36 B names Prescience, not separately F. Under standing choose-recommended, provisionally allow F's actual complete private plan read, retaining C's dial bar. Original target-first commitment is not public No-Field revelation; both seals still materialize once, including zero/caps, then original costs/losses/history run. No token inventory or automatic allied inspection entitlement is granted. This is explicit textual/timing composition, not a new combined FAQ/user answer or mode opening. | [Exact source and scope](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026) |
|
||||
| No-Field / current Truthtrance fact | Broad game-related yes/no-fact authority supports the distinct current concealed printed-value predicate0/3/5. No marker/revealed history is No even for0; capped/exhausted5 remains printed5. Read the respondent's own model, never an ally or planned shipment. Publish only aggregate Yes/No after the private expected answer; information disclosure is not physical revelation or future-use obligation. This is explicit composition, not a dedicated No-Field FAQ. Existing physical-counter exclusion, native disposal/parent and mode gates remain. | [Source and current-fact scope](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026) |
|
||||
| Harass / classic Homeworlds | Fresh classic2–6 Basic/Advanced independent36-card/Homeworld composition admits Arrakis and foreign-World returns, never an own World. Return exact typed board/visitor counters through native custody: Basic primary; Advanced Emperor normal→Kaitain/elite→Salusa. This destination/timing composition is explicit, not a new card FAQ or separate Nexus ruling. Original sealed commitment/support remains; subsequent population observations cannot rescore it. Homeworld loss receipts retain fully-committed Harass complements; native-only Traitors, late defense/explosion and after-use discard stay. Other cards, faction/overlay and public gates remain. | [Source and native routing](HARASS_WITHDRAW_RUNTIME.md#classic-homeworld-reserve-routing--16-october-2026) |
|
||||
| Clean Ix Stone / Tooth / Mirror | Reuse the existing CHOAM prototype order: original Stone first, then uncopied Tooth. The earlier priority question is unanswered; standing choose-recommended extends that order provisionally, not as a publisher ruling or user answer. Printed Mirror copy-first and user-selected physical retention stay independent. Clean selected Ix/Richese supports native Ixians/Tleilaxu in both modes; Ecaz/Moritani stays Advanced-only. Typed winner ambiguity, new Ix Tech/Stronghold and other module bands remain guarded. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
|
||||
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
|
||||
| Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) |
|
||||
|
||||
@@ -423,8 +423,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
if (harass) {
|
||||
requireQuote(validBattleSlotPair(slots[0], slots[1]),
|
||||
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
|
||||
requireQuote(side.harassWithdraw && !input.homeworld,
|
||||
'Harass & Withdraw needs its supported physical context and cannot combine with Homeworld battles.');
|
||||
requireQuote(side.harassWithdraw,
|
||||
'Harass & Withdraw needs its supported physical context.');
|
||||
requireQuote(!homeworld || homeworld.native !== side.id,
|
||||
'Harass & Withdraw cannot be used on your own Homeworld.');
|
||||
const profile = input.ecazOccupy?.lead === side.id ? input.ecazOccupy : undefined;
|
||||
const normalization = side.harassWithdraw.occupy;
|
||||
if (profile) {
|
||||
|
||||
@@ -120,7 +120,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
availability: ecaz.card.effect === 'recruits'
|
||||
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
|
||||
: ecaz.card.effect === 'harassWithdraw'
|
||||
? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Under provisional materialization-first order, an own concealed No-Field reveals natively before actual undialed counters return; token history is not reset. Stone Burner provisionally compares forces remaining after successful withdrawal. Other profile and optional-module limits remain guarded.'
|
||||
? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Classic Homeworlds returns actual typed counters into native reserve custody: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic preserves its original primary World. The card is forbidden on any own Homeworld. Original sealed commitment/support remains. An own concealed No-Field separately follows provisional materialization first without resetting history. Stone compares post-withdrawal counters provisionally. Other profile/module limits remain guarded.'
|
||||
: 'Reinforcements has controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three own reserves and adds +2 normal score, not support or physical tokens. Own No-Field reveal must leave three original reserves before this cost; later Harass returns cannot fund it. Original/copy Stone ignores the score bonus but still charges reserves. All-outcome payment and native cost-before-return order are provisional. Other profile/module limits remain guarded.',
|
||||
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
|
||||
};
|
||||
|
||||
+48
-26
@@ -170,6 +170,7 @@ import {
|
||||
import {
|
||||
quoteNativeReserveWithdrawal,
|
||||
quoteNativeReserveDeposit,
|
||||
quoteHomeworldReserveReturn,
|
||||
NativeReserveError,
|
||||
type NativeReserveSelections,
|
||||
} from './homeworld-native-reserves';
|
||||
@@ -2199,7 +2200,7 @@ export type Game = {
|
||||
kind: 'winner' | 'explosion';
|
||||
pool: HomeworldForces;
|
||||
options: HomeworldForces[];
|
||||
commitment?: { forces: CombatForces; dial: number; support: number };
|
||||
commitment?: { forces: CombatForces; dial: number; support: number; fullyCommitted?: true };
|
||||
} | null;
|
||||
lastBattleContext?: {
|
||||
event: string;
|
||||
@@ -7984,23 +7985,23 @@ function withdrawNativeReserves(
|
||||
observeOccupation(g);
|
||||
return result.receipts;
|
||||
}
|
||||
/** Adds counters to a faction's reserves, depositing into the native Homeworld
|
||||
* custody when Homeworlds are enabled. Used by revival and by the authorized
|
||||
* Nexus Secret Ally reserve return (user ruling 7 October 2026). */
|
||||
function addNativeReserves(g: Game, p: Player, amount: number, elite: number) {
|
||||
/** Adds counters to native reserves. A Homeworld source returns its exact
|
||||
* visitor pool and deposits in the same quote; route permission is external. */
|
||||
function addNativeReserves(g: Game, p: Player, amount: number, elite: number, sourceHomeworld?: string) {
|
||||
requireRule(sourceHomeworld === undefined || g.homeworlds?.custody,
|
||||
'Returning Homeworld forces requires their original physical custody.');
|
||||
if (!g.homeworlds?.custody) {
|
||||
p.reserves += amount;
|
||||
if (p.elites) p.elites.reserves += elite;
|
||||
return;
|
||||
}
|
||||
const result = homeworldRule(() =>
|
||||
quoteNativeReserveDeposit(
|
||||
homeworldContext(g),
|
||||
g.homeworlds!.custody!,
|
||||
p.id,
|
||||
{ normal: amount - elite, elite },
|
||||
),
|
||||
);
|
||||
const result = homeworldRule(() => {
|
||||
const context = homeworldContext(g);
|
||||
const forces = { normal: amount - elite, elite };
|
||||
return sourceHomeworld === undefined
|
||||
? quoteNativeReserveDeposit(context, g.homeworlds!.custody!, p.id, forces)
|
||||
: quoteHomeworldReserveReturn(context, g.homeworlds!.custody!, p.id, sourceHomeworld, forces);
|
||||
});
|
||||
const changed = result.players.find((seat) => seat.id === p.id)!;
|
||||
p.reserves = changed.reserves;
|
||||
if (p.elites) p.elites.reserves = changed.eliteReserves;
|
||||
@@ -8191,9 +8192,13 @@ function homeworldBattleLossIntegrity(g: Game) {
|
||||
options = quoteHomeworldBattleRules(pending.territory, g.players, rules).explosion.options;
|
||||
} else {
|
||||
const commitment = pending.commitment;
|
||||
requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite,
|
||||
requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite &&
|
||||
(commitment.fullyCommitted === undefined || commitment.fullyCommitted === true),
|
||||
'The Homeworld winner needs its committed physical forces.');
|
||||
options = casualtyOptions(commitment.forces, commitment.dial, commitment.support).map(({normal, elite}) => ({normal, elite}));
|
||||
options = casualtyOptions(commitment.forces, commitment.dial, commitment.support)
|
||||
.filter(choice => !commitment.fullyCommitted ||
|
||||
choice.normal === commitment.forces.normal && choice.elite === commitment.forces.elite)
|
||||
.map(({normal, elite}) => ({normal, elite}));
|
||||
}
|
||||
requireRule(options.length > 0 && JSON.stringify(options) === JSON.stringify(pending.options),
|
||||
'The Homeworld casualty choices do not match the committed outcome.');
|
||||
@@ -10674,12 +10679,22 @@ export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game {
|
||||
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
return initialized;
|
||||
}
|
||||
function classicEcazHomeworldProfile(g: Game): boolean {
|
||||
return !!g.homeworlds && g.ecazTreachery === true && g.expansions.length === 0 &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.players.every(p => CLASSIC_FACTIONS[p.faction]) &&
|
||||
!g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout;
|
||||
}
|
||||
/** Gated development setup for the independent three-card Ecaz variant. */
|
||||
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
|
||||
const g = structuredClone(state);
|
||||
g.ecazTreachery = true;
|
||||
return initializeSetupGameForAudit(g, false, false, false, false, false, false, true, true);
|
||||
const homeworlds = !!g.homeworlds;
|
||||
requireRule(!homeworlds || classicEcazHomeworldProfile(g),
|
||||
'Independent cards with Homeworlds require a fresh classic roster without other modules.');
|
||||
return initializeSetupGameForAudit(g, homeworlds, false, false, false, false, false, true, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false, strongholdFactions = false): Game {
|
||||
nexusCardsIntegrity(state);
|
||||
@@ -20705,19 +20720,25 @@ function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candi
|
||||
function harassModuleBlock(g: Game): string | null {
|
||||
if (!g.ecazTreachery)
|
||||
return 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
|
||||
if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
|
||||
return 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
|
||||
if ((g.homeworlds && !classicEcazHomeworldProfile(g)) ||
|
||||
g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
|
||||
return 'Harass & Withdraw with these other optional modules is still being implemented.';
|
||||
return combinedCardBattleBlock(g);
|
||||
}
|
||||
function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
|
||||
const b = g.battle!;
|
||||
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
let blocked = harassModuleBlock(g);
|
||||
const home = homeworldBattleLocation(g, b.territory);
|
||||
if (!blocked && (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id)))
|
||||
blocked = 'Use Harass & Withdraw in your own battle.';
|
||||
const locations = Object.fromEntries(Object.entries(p.forces)
|
||||
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
|
||||
.map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }]));
|
||||
if (!blocked && home?.native === p.id)
|
||||
blocked = 'Harass & Withdraw cannot be used on your own Homeworld.';
|
||||
const locations = home
|
||||
? { [b.territory]: combatArmy(g, p.id, b.territory) }
|
||||
: Object.fromEntries(Object.entries(p.forces)
|
||||
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
|
||||
.map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }]));
|
||||
const materializes = prospectiveNoFieldForces(p, b.territory);
|
||||
if (materializes) {
|
||||
const marker = p.noField!.deployed!;
|
||||
@@ -23238,7 +23259,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
log(g, `${player.name}'s Harass & Withdraw was canceled by an opposing traitor. No forces returned through this card.`);
|
||||
for (const withdrawal of quote.harassWithdraw ?? []) {
|
||||
const player = getPlayer(g, withdrawal.player);
|
||||
for (const [key, group] of Object.entries(withdrawal.locations)) {
|
||||
const sourceHomeworld = g.battle!.territory.startsWith('homeworld:') ? g.battle!.territory : undefined;
|
||||
if (!sourceHomeworld) for (const [key, group] of Object.entries(withdrawal.locations)) {
|
||||
player.forces[key] -= group.normal + group.elite;
|
||||
if (!player.forces[key]) delete player.forces[key];
|
||||
if (group.elite && player.elites) {
|
||||
@@ -23246,11 +23268,10 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
if (!player.elites.forces[key]) delete player.elites.forces[key];
|
||||
}
|
||||
}
|
||||
player.reserves += withdrawal.returned.normal + withdrawal.returned.elite;
|
||||
if (player.elites) player.elites.reserves += withdrawal.returned.elite;
|
||||
addNativeReserves(g, player, withdrawal.returned.normal + withdrawal.returned.elite,
|
||||
withdrawal.returned.elite, sourceHomeworld);
|
||||
log(g, `${player.name} used Harass & Withdraw to return ${withdrawal.returned.normal + withdrawal.returned.elite} undialed physical forces (${withdrawal.returned.elite} elite) to reserves before battle losses. Their leader still resolves normally.`);
|
||||
}
|
||||
if (quote.harassWithdraw?.length) observeOccupation(g);
|
||||
const explosion = quote.explosion;
|
||||
if (ac && dc) {
|
||||
dead(al);
|
||||
@@ -23579,7 +23600,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
kind: quote.homeworldExplosion ? 'explosion' : 'winner',
|
||||
pool: combatArmy(g, owner, b.territory),
|
||||
options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})),
|
||||
...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support } } : {}),
|
||||
...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support,
|
||||
...(casualtyCommitment.fullyCommitted ? { fullyCommitted: true as const } : {}) } } : {}),
|
||||
};
|
||||
}
|
||||
if (discarded.length) {
|
||||
|
||||
@@ -139,6 +139,29 @@ export function quoteNativeReserveWithdrawal(
|
||||
});
|
||||
}
|
||||
|
||||
function nativeReserveDeposits(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
actor: string,
|
||||
forces: HomeworldForces,
|
||||
): HomeworldCustodyChange[] {
|
||||
const homes = nativeHomes(context, custody, actor);
|
||||
typed(forces);
|
||||
const primary = homes.find((home) => !home.secondary)!;
|
||||
const secondary = homes.find((home) => home.secondary);
|
||||
const placements: [string, HomeworldForces][] = secondary
|
||||
? [
|
||||
[primary.id, { normal: forces.normal, elite: 0 }],
|
||||
[secondary.id, { normal: 0, elite: forces.elite }],
|
||||
]
|
||||
: [[primary.id, { normal: forces.normal, elite: forces.elite }]];
|
||||
return placements.flatMap(([homeworld, deposit]) =>
|
||||
deposit.normal + deposit.elite > 0
|
||||
? [{ homeworld, player: actor, withdraw: empty(), deposit }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Authorized native-reserve deposits: E3 p.10 restores Advanced Emperor's
|
||||
* normal forces to Kaitain and Sardaukar to Salusa. Used by revival and by the
|
||||
* Nexus Secret Ally reserve return (user ruling 7 October 2026); it does not
|
||||
@@ -149,25 +172,22 @@ export function quoteNativeReserveDeposit(
|
||||
actor: string,
|
||||
forces: HomeworldForces,
|
||||
) {
|
||||
return checked(() => {
|
||||
const homes = nativeHomes(context, custody, actor);
|
||||
typed(forces);
|
||||
const primary = homes.find((home) => !home.secondary)!;
|
||||
const secondary = homes.find((home) => home.secondary);
|
||||
const placements: [string, HomeworldForces][] = secondary
|
||||
? [
|
||||
[primary.id, { normal: forces.normal, elite: 0 }],
|
||||
[secondary.id, { normal: 0, elite: forces.elite }],
|
||||
]
|
||||
: [[primary.id, { normal: forces.normal, elite: forces.elite }]];
|
||||
return quoteHomeworldCustody(
|
||||
context,
|
||||
custody,
|
||||
placements.flatMap(([homeworld, deposit]) =>
|
||||
deposit.normal + deposit.elite > 0
|
||||
? [{ homeworld, player: actor, withdraw: empty(), deposit }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
});
|
||||
return checked(() => quoteHomeworldCustody(
|
||||
context, custody, nativeReserveDeposits(context, custody, actor, forces),
|
||||
));
|
||||
}
|
||||
|
||||
/** A separately authorized force return: remove the exact source pool and
|
||||
* deposit into native reserves in one custody quote, never as deaths. */
|
||||
export function quoteHomeworldReserveReturn(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
actor: string,
|
||||
source: string,
|
||||
forces: HomeworldForces,
|
||||
) {
|
||||
return checked(() => quoteHomeworldCustody(context, custody, [
|
||||
{ homeworld: source, player: actor, withdraw: forces, deposit: empty() },
|
||||
...nativeReserveDeposits(context, custody, actor, forces),
|
||||
]));
|
||||
}
|
||||
+8
-6
@@ -4058,6 +4058,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
|
||||
'Fresh private Homeworld prototypes retain all fourteen Skills, Discovery7/8 or both in classic or supported E1/E2 and standalone Ecaz OR Moritani tables. Original Nexus entries additionally support single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families with union47, and standalone OR paired Ecaz/Moritani/ecaz33 with classics, with or without Skills. Basic/Advanced2–6, Tech3+ and Advanced Strongholds2+ retain original setup, native placement, phase-end industry and source-local held cards. Advanced Moritani/paired E3 excludes Harkonnen; otherwise legal standalone no-Skills Ecaz/Harkonnen does not lift its separate Duke Cunning exclusion. Winning typed native Suk saves stay at that home; external Emperor returns allocate physical normal/elite counters to original homes as the existing interpretation. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus. Mixed E3/three-family Nexus, pending effects, public starts and played-save conversion remain separate.',
|
||||
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
|
||||
'A fresh classic Basic/Advanced independent36-card/Homeworld prototype additionally connects Harass on Arrakis or a foreign World, never an own World. Exact typed counters return through native reserves: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original sealed commitment/support and fully-committed casualty complement stay fixed across population changes. Other variant cards, expansion rosters and overlays keep their separate guards.',
|
||||
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
|
||||
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
|
||||
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
|
||||
@@ -5022,14 +5023,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
|
||||
'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.',
|
||||
'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds may be selected before the independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return only actual undialed board/visitor counters into native reserve custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain and Sardaukar to Salusa. Original dial/support and all remaining committed counters stay fixed even if returned population crosses a threshold. Native-only Traitors, late defense/explosion and mandatory disposal remain; other cards and overlays retain their guards.',
|
||||
],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical slots, private category answers, typed/sector return, committed losses, Traitor cancellation and mandatory disposal remain. Own No-Field now projects reserve-limited sector counters before commitment and uses actual native revelation/history. Same-plan cost cannot be funded by future returns. Other module/profile boundaries remain.', evidence: ['game/harass-withdraw.ts', 'game/stone-burner.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Either slot and after-reveal typed/sector choices remain. Owner-only No-Field forecasts and post-reveal reserve notices distinguish virtual marker from actual counters; zero marker, mixed-sector full return, blocked-cost fallback and funded card controls are usable.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles have a legal shared-quote path and preserve inspection secrecy. Retreat strategy and combined-game strength remain uncalibrated.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed effects, provisional materialization-first and Stone/withdrawal timing are distinguished from remaining profile/module limits and final assurance.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md', 'docs/RICHESE_NO_FIELD_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original Stone/Harass/Occupy proof plus actual No-Field shipment/reveal cases cover zero, caps, mixed sectors, original costs, history and Traitor cancellation. Current mobile/legal-policy proof remains bounded, not full combination/deployed or new recovery certification.', evidence: ['tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Original slots, secrecy, physical returns, costs and No-Field/Stone composition remain. Clean classic Homeworlds now withdraw exact visitor/board counters and deposit through native typed reserves. Own-World use is forbidden; committed Homeworld losses retain the full Harass complement across later population changes.', evidence: ['game/harass-withdraw.ts', 'game/stone-burner.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts', 'game/homeworld-native-reserves.ts', 'tools/prototype-room.ts', 'tools/start-prototype.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Either slot and native typed allocation remain. World names, Basic versus Advanced reserve destinations and fixed-commitment timing are explicit. Invaders no longer receive forbidden Traitor controls. Four actual390px journeys complete Basic foreign loss, Advanced foreign victory, Salusa threshold return and own-Salusa ban/plain alternative into Collection.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx', 'components/harass-withdraw-choice.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use legal shared precommit quotes and exact after-reveal allocations without an invader vote. Sixteen native World programs accept36 continuation actions and validate original precommit proposals through normal/explosion/own-World alternatives. Retreat strategy and combined strength remain deferred.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed own-World restriction and explicit native destination/fixed-commitment composition are separated from original materialization/Stone policies and remaining faction/module/final gates.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md', 'docs/RICHESE_NO_FIELD_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Native Homeworld regressions cover real typed invasion/Arrakis shipments, both slots/modes, selected full commitment, Basic primary/Advanced split reserves, native-only Traitors, winner disposal, explosion exception and Salusa threshold follow-on. Four mobile and16 policy programs reach original Collection. Earlier No-Field/Stone/recovery evidence remains historical, not complete-mode/new assurance or deployed acceptance.', evidence: ['tests/harass-homeworld.test.ts', 'tests/fixture-harass-homeworld.ts', 'tests/harass-homeworld-arrakis.test.ts', 'tests/fixture-harass-homeworld-arrakis.ts', 'tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -215,7 +215,7 @@ void test('KH prevents an invalid stored true call and adds two only to an unstu
|
||||
assert.equal(q.scores!.defender, 2);
|
||||
assert.equal(q.bounty, null);
|
||||
});
|
||||
void test('Stone validates physical ambiguity and mode before traitors, and uses undialed tokens rather than leader scores', () => {
|
||||
void test('Stone validates revealed mode before traitors and uses undialed tokens rather than leader scores', () => {
|
||||
const g = input();
|
||||
play(g, 'attacker', 'weapon', (c) => c.effect === 'stoneBurner');
|
||||
g.attacker.stoneMode = 'ignore';
|
||||
@@ -235,19 +235,6 @@ void test('Stone validates physical ambiguity and mode before traitors, and uses
|
||||
delete g.attacker.stoneMode;
|
||||
call(g, 'attacker');
|
||||
reject(g, /revealed mode/);
|
||||
g.attacker.stoneMode = 'ignore';
|
||||
g.attacker.forces = {
|
||||
normal: 5,
|
||||
elite: 1,
|
||||
eliteStrength: 2,
|
||||
freeSupport: false,
|
||||
};
|
||||
g.attacker.plan.dial = 3;
|
||||
g.attacker.plan.support = 1;
|
||||
g.defender.forces.normal = 3;
|
||||
g.defender.plan.dial = 1;
|
||||
g.defender.plan.support = 1;
|
||||
reject(g, /unresolved physical casualty/);
|
||||
});
|
||||
void test('copied Stone needs each revealed mode and keeps physical Mirror custody on victory', () => {
|
||||
const g = input();
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody';
|
||||
import { harassWithdrawCommitments, type HarassWithdrawSelection } from '../game/harass-withdraw';
|
||||
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type HarassHomeworldArrakisStep = { actor: string; action: Action };
|
||||
export type HarassHomeworldArrakisOptions = {
|
||||
advanced?: boolean;
|
||||
outcome?: 'winner' | 'loser';
|
||||
traitor?: 'owner' | 'opponent' | 'both';
|
||||
slot?: 'weapon' | 'defense';
|
||||
seatIds?: [string, string];
|
||||
/** An original undealt classic Emperor/Guild lobby, preserving live seat IDs. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type HarassHomeworldArrakisFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeBattle: Game;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
planActions: [HarassHomeworldArrakisStep, HarassHomeworldArrakisStep];
|
||||
battleStep: HarassHomeworldArrakisStep;
|
||||
shipments: { before: Game; after: Game; step: HarassHomeworldArrakisStep }[];
|
||||
nativeSteps: HarassHomeworldArrakisStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export function harassHomeworldArrakisPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player); return player;
|
||||
}
|
||||
export const harassHomeworldArrakisClean = (game: Game) =>
|
||||
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
export function harassHomeworldArrakisPool(game: Game, actor: string, location: string): HomeworldForces {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location);
|
||||
assert.ok(home); return home.forces[actor] ?? { normal: 0, elite: 0 };
|
||||
}
|
||||
export function assertHarassHomeworldArrakisCustody(game: Game): void {
|
||||
homeworldGameIntegrity(game);
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
|
||||
for (const p of game.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(p.reserves + p.tanks + sum(p.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20, p.faction);
|
||||
assert.ok(p.hand.length <= 4);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + sum(p.elites.forces) + visitors.reduce((n, pool) => n + pool.elite, 0), 5);
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, amount] of Object.entries(p.elites.forces)) assert.ok(amount <= (p.forces[key] ?? 0));
|
||||
}
|
||||
const native = homes.filter(h => h.native === p.id).map(h => h.forces[p.id]);
|
||||
assert.equal(native.reduce((n, pool) => n + pool.normal + pool.elite, 0), p.reserves);
|
||||
assert.equal(native.reduce((n, pool) => n + pool.elite, 0), p.elites?.reserves ?? 0);
|
||||
}
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
assert.equal(cards.length, 36);
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck([]), ...ecazTreacheryCards()].map(c => c.id).sort());
|
||||
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])].sort(), traitorDeck(game.players).sort());
|
||||
}
|
||||
|
||||
/** Follow entitled original controls only; never invent a phase or a plan. */
|
||||
export function nextHarassHomeworldArrakisStep(game: Game): HarassHomeworldArrakisStep | null {
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'harassWithdraw') return null;
|
||||
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
|
||||
if (d?.kind === 'homeworldRevivalDeployment' || d?.kind === 'caladanReinforcement')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (!d && game.biddingEnd) {
|
||||
const actor = game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id));
|
||||
if (actor) return { actor, action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' } };
|
||||
}
|
||||
}
|
||||
const step = nextStrongholdFactionsNativeStep(game);
|
||||
if (step?.action.type === 'stormDial' && game.turn === 1) step.action.amount = 0;
|
||||
return step;
|
||||
}
|
||||
export function advanceHarassHomeworldArrakis(state: Game, until: (game: Game) => boolean,
|
||||
actions?: HarassHomeworldArrakisStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
const step = nextHarassHomeworldArrakisStep(game);
|
||||
assert.ok(step, 'The original native boundary requires a real plan or allocation');
|
||||
actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Original Homeworld Harass programme did not reach its boundary');
|
||||
}
|
||||
function initialize(game: Game): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
|
||||
};
|
||||
try { return initializeEcazTreacheryGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = harassHomeworldArrakisPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const old = player.traitors[0]; assert.ok(old);
|
||||
const index = game.traitorReserve!.indexOf(identity);
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(index >= 0 || donor, 'Exchange an original physical Traitor, never manufacture one');
|
||||
if (index >= 0) game.traitorReserve![index] = old;
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = old;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved original Traitor ${identity} exchanged with ${old} into ${actor}'s hand.`);
|
||||
}
|
||||
|
||||
/** Original classic two-seat setup, original board armies and wallets, native
|
||||
* turn-one shipments and battle admission. Only conserved card/Traitor custody
|
||||
* is staged. Basic retains five physical Sardaukar without Advanced strength. */
|
||||
export function createHarassHomeworldArrakisFixture(options: HarassHomeworldArrakisOptions = {}): HarassHomeworldArrakisFixture {
|
||||
const ids = options.seatIds ?? ['emperor', 'guild'];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('HARASSHWARRAKIS', newPlayer(ids[0], 'Emperor', 'emperor'), options.advanced ?? false);
|
||||
if (!options.initial) joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
game.ecazTreachery === true && game.homeworlds && game.players.every(p => p.hand.length === 0),
|
||||
'Continue an undealt lobby or its original fresh Homeworld independent-card setup');
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['emperor', 'guild']);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.equal(game.advanced, options.advanced ?? game.advanced);
|
||||
if (game.status === 'lobby') {
|
||||
assert.ok(!game.ecazTreachery, 'Select independent cards through the initializer, not a redeal');
|
||||
game.homeworlds = { custody: null };
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initialize(game);
|
||||
}
|
||||
const initial = structuredClone(game), nativeSteps: HarassHomeworldArrakisStep[] = [];
|
||||
game = advanceHarassHomeworldArrakis(game, g => g.status === 'playing', nativeSteps);
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
const afterSetup = structuredClone(game);
|
||||
const owner = game.players.find(p => p.faction === 'emperor')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const locationTerritory = 'tueks_sietch', sector = territory(locationTerritory).sectors[0];
|
||||
const location = `${locationTerritory}:${sector}`;
|
||||
const shipments: HarassHomeworldArrakisFixture['shipments'] = [];
|
||||
const act = (step: HarassHomeworldArrakisStep) => {
|
||||
nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
};
|
||||
const until = (predicate: (state: Game) => boolean) => { game = advanceHarassHomeworldArrakis(game, predicate, nativeSteps); };
|
||||
// Follow actual turn order (including the Guild's genuine timing choice).
|
||||
const waiting = new Set([owner, opponent]);
|
||||
until(g => g.phase === 5 && harassHomeworldArrakisClean(g));
|
||||
while (waiting.size) {
|
||||
const actor = game.active!;
|
||||
assert.ok(waiting.has(actor), 'Each original shipment turn is consumed once');
|
||||
const imperial = actor === owner;
|
||||
const sources = imperial ? game.advanced
|
||||
? { 'homeworld:emperor': { normal: 3, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 4 } }
|
||||
: { 'homeworld:emperor': { normal: 3, elite: 4 } }
|
||||
: { 'homeworld:guild': { normal: 2, elite: 0 } };
|
||||
const step: HarassHomeworldArrakisStep = { actor, action: { type: 'ship', territory: locationTerritory,
|
||||
sector, amount: imperial ? 7 : 2, elite: imperial ? 4 : 0, homeworldSources: sources, allyPayment: 0 } };
|
||||
const before = structuredClone(game); act(step); until(harassHomeworldArrakisClean);
|
||||
shipments.push({ before, after: structuredClone(game), step });
|
||||
waiting.delete(actor); act({ actor, action: { type: 'endMovement' } });
|
||||
until(g => (g.phase === 5 || g.phase === 6) && harassHomeworldArrakisClean(g));
|
||||
}
|
||||
until(g => g.phase === 6 && harassHomeworldArrakisClean(g));
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
takeHarassCard(game, owner, 'ecaz-harass-withdraw');
|
||||
const staging = ['After genuine setup and native shipments: return unselected original hands to the remaining deck and conserve the physical Harass & Withdraw into the Emperor hand. No forces, wallets, phases or histories are staged.'];
|
||||
const winner = options.outcome === 'winner';
|
||||
const ownLeaders = harassHomeworldArrakisPlayer(game, owner).leaders.filter(l => !l.dead)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const otherLeaders = harassHomeworldArrakisPlayer(game, opponent).leaders.filter(l => !l.dead)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const own = winner ? ownLeaders.at(-1)! : ownLeaders[0];
|
||||
const other = winner ? otherLeaders[0] : otherLeaders.at(-1)!;
|
||||
if (options.traitor === 'owner' || options.traitor === 'both') exchangeTraitor(game, owner, other.id, staging);
|
||||
if (options.traitor === 'opponent' || options.traitor === 'both') exchangeTraitor(game, opponent, own.id, staging);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === locationTerritory);
|
||||
assert.ok(choice);
|
||||
const battleStep: HarassHomeworldArrakisStep = { actor: choice.chooser, action: { type: 'chooseBattle',
|
||||
territory: locationTerritory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep); game = finishHarassPreparation(game);
|
||||
assert.ok(game.battle && !game.battle.revealed && harassHomeworldArrakisClean(game));
|
||||
assert.equal(Object.keys(game.battle.plans).length, 0);
|
||||
const slot = options.slot ?? 'defense';
|
||||
const planActions: HarassHomeworldArrakisFixture['planActions'] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: 2, support: game.advanced ? 1 : 0,
|
||||
leader: own.id, weapon: slot === 'weapon' ? 'ecaz-harass-withdraw' : null,
|
||||
defense: slot === 'defense' ? 'ecaz-harass-withdraw' : null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: winner ? 0 : 3,
|
||||
support: game.advanced && !winner ? 3 : 0, leader: other.id } },
|
||||
];
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
return { initial, afterSetup, beforeBattle, game, owner, opponent, territory: locationTerritory,
|
||||
location, planActions, battleStep, shipments, nativeSteps, staging };
|
||||
}
|
||||
export function sealHarassHomeworldArrakisPlan(game: Game, step: HarassHomeworldArrakisStep): Game {
|
||||
return finishHarassPreparation(applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function revealHarassHomeworldArrakisBattle(fixture: HarassHomeworldArrakisFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealHarassHomeworldArrakisPlan(game, step);
|
||||
assert.equal(game.battle?.revealed, true);
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
return game;
|
||||
}
|
||||
/** Enumerate actual current legal complete allocations, not guessed dials. */
|
||||
export function pendingHarassHomeworldArrakisAllocations(game: Game, actor: string): HarassWithdrawSelection[] {
|
||||
const offer = viewGame(game, actor).battle?.harassAllocation;
|
||||
assert.ok(offer && !offer.selection && game.decision?.kind === 'harassWithdraw');
|
||||
assert.equal(Object.keys(offer.context.locations).length, 1);
|
||||
const [location, pool] = Object.entries(offer.context.locations)[0];
|
||||
return harassWithdrawCommitments(offer.context, offer.dial, offer.support).map(committed => ({
|
||||
[location]: { normal: pool.normal - committed.normal, elite: pool.elite - committed.elite },
|
||||
}));
|
||||
}
|
||||
export function chooseHarassHomeworldArrakisAllocation(game: Game, actor: string, returns: HarassWithdrawSelection): Game {
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
return applyAction(game, actor, { type: 'decision', event: game.decision.event, returns });
|
||||
}
|
||||
/** Native Traitor declarations, losses, card retention and cleanup. Allocation
|
||||
* remains an explicit after-reveal human boundary unless supplied by the caller. */
|
||||
export function finishHarassHomeworldArrakisBattle(state: Game,
|
||||
options: { callers?: string[]; returns?: HarassWithdrawSelection } = {},
|
||||
actions?: HarassHomeworldArrakisStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (!game.battle && harassHomeworldArrakisClean(game)) return game;
|
||||
let step: HarassHomeworldArrakisStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
|
||||
assert.ok(options.returns, 'Choose actual revealed typed returns');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
|
||||
} else step = nextHarassHomeworldArrakisStep(game);
|
||||
assert.ok(step);
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Original Homeworld Harass aftermath did not finish');
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export const HARASS_HOMEWORLD_CARD = 'ecaz-harass-withdraw';
|
||||
export type HarassHomeworldStep = { actor: string; action: Action };
|
||||
export type HarassHomeworldOptions = {
|
||||
advanced?: boolean;
|
||||
slot?: 'weapon' | 'defense';
|
||||
role?: 'visitor' | 'native';
|
||||
/** Salusa is an Advanced own-World rejection case, not a second faction. */
|
||||
secondary?: boolean;
|
||||
outcome?: 'loss' | 'win' | 'traitor' | 'explosion';
|
||||
seatIds?: [string, string, string];
|
||||
/** Original undealt classic lobby, e.g. with authenticated smoke seat IDs. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type HarassHomeworldSetup = {
|
||||
initial: Game; initialized: Game; afterSetup: Game;
|
||||
owner: string; opponent: string; observer: string;
|
||||
nativeSteps: HarassHomeworldStep[];
|
||||
};
|
||||
export type HarassHomeworldFixture = HarassHomeworldSetup & {
|
||||
options: HarassHomeworldOptions;
|
||||
role: 'visitor' | 'native'; location: string; native: string; visitor: string;
|
||||
beforeShipment: Game; shipmentStep: HarassHomeworldStep; afterShipment: Game;
|
||||
beforeBattle: Game; battleStep: HarassHomeworldStep;
|
||||
/** Genuine original prepared battle; no plan has been sealed. */
|
||||
game: Game;
|
||||
planActions: [HarassHomeworldStep, HarassHomeworldStep];
|
||||
returns: Record<string, HomeworldForces>;
|
||||
staging: string[];
|
||||
};
|
||||
export function harassHomeworldPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
export const harassHomeworldClean = (game: Game) =>
|
||||
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export function harassHomeworldPool(game: Game, actor: string, location: string): HomeworldForces {
|
||||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location);
|
||||
assert.ok(home); return { ...(home.forces[actor] ?? { normal: 0, elite: 0 }) };
|
||||
}
|
||||
|
||||
/** Includes typed Basic identity and visitor custody outside Player.forces. */
|
||||
export function assertHarassHomeworldCustody(game: Game): void {
|
||||
homeworldGameIntegrity(game);
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
for (const p of game.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0) +
|
||||
visitors.reduce((n, f) => n + f.normal + f.elite, 0), 20, p.faction);
|
||||
for (const map of [p.forces, p.elites?.forces ?? {}]) {
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
assert.ok(!key.includes('undefined') && !key.includes('NaN') && !key.startsWith('homeworld:'), key);
|
||||
assert.ok(Number.isSafeInteger(value) && value >= 0, key);
|
||||
}
|
||||
}
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0) + visitors.reduce((n, f) => n + f.elite, 0), 5);
|
||||
assert.ok(p.hand.length <= 4, p.faction);
|
||||
}
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
assert.equal(cards.length, 36);
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck([]), ...ecazTreacheryCards()].map(c => c.id).sort());
|
||||
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])].sort(),
|
||||
traitorDeck(game.players).sort());
|
||||
}
|
||||
export function actHarassHomeworld(game: Game, step: HarassHomeworldStep, actions?: HarassHomeworldStep[]): Game {
|
||||
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
/** Never invent a plan, native vote or late-defense permission. */
|
||||
export function nextHarassHomeworldStep(game: Game): HarassHomeworldStep | null {
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'harassWithdraw') return null;
|
||||
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
|
||||
if (d?.kind === 'homeworldExplosion') return { actor: d.player, action: { type: 'decision', event: d.event, choice: 0 } };
|
||||
if (d?.kind === 'homeworldRevivalDeployment' || d?.kind === 'caladanReinforcement')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d?.kind === 'wormProtection') return { actor: d.player, action: { type: 'decision', accept: true } };
|
||||
if (d?.kind === 'wormRide') return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceHarassHomeworld(game: Game, until: (game: Game) => boolean,
|
||||
actions?: HarassHomeworldStep[]): Game {
|
||||
for (let n = 0; n < 1600; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextHarassHomeworldStep(game); assert.ok(next, 'Original Homeworld continuation needs a real human plan or allocation');
|
||||
game = actHarassHomeworld(game, next, actions);
|
||||
}
|
||||
throw Error('Original Harass Homeworld programme did not reach its boundary');
|
||||
}
|
||||
function initialize(game: Game): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
|
||||
};
|
||||
try { return initializeEcazTreacheryGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Homeworlds selected before the independent 33+3 physical setup. No expansion
|
||||
* roster, other optional module, preselected variant flag or synthetic phase. */
|
||||
export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {}): HarassHomeworldSetup {
|
||||
const ids = options.seatIds ?? ['emperor', 'guild', 'atreides'];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('HARASSHOMEWORLD', newPlayer(ids[0], 'Emperor', 'emperor'), options.advanced ?? false);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer(ids[2], 'Atreides', 'atreides'));
|
||||
}
|
||||
assert.equal(game.status, 'lobby'); assert.equal(game.advanced, options.advanced ?? game.advanced);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'guild']);
|
||||
assert.ok(!game.ecazTreachery && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.discoveries && !game.greatMaker);
|
||||
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
assert.deepEqual(game.homeworlds, { custody: null });
|
||||
const nativeSteps: HarassHomeworldStep[] = [];
|
||||
for (const p of game.players) if (!p.ready)
|
||||
game = actHarassHomeworld(game, { actor: p.id, action: { type: 'ready' } }, nativeSteps);
|
||||
const initial = structuredClone(game);
|
||||
game = initialize(game);
|
||||
const initialized = structuredClone(game);
|
||||
game = advanceHarassHomeworld(game, g => g.status === 'playing', nativeSteps);
|
||||
const owner = game.players.find(p => p.faction === 'emperor')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const observer = game.players.find(p => p.faction === 'atreides')!.id;
|
||||
assertHarassHomeworldCustody(game);
|
||||
return { initial, initialized, afterSetup: structuredClone(game), owner, opponent, observer, nativeSteps };
|
||||
}
|
||||
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const p = harassHomeworldPlayer(game, actor);
|
||||
if (p.traitors.includes(identity)) return;
|
||||
const returned = p.traitors[0]; assert.ok(returned);
|
||||
const index = game.traitorReserve!.indexOf(identity);
|
||||
const donor = game.players.find(other => other.id !== actor && other.traitors.includes(identity));
|
||||
assert.ok(index >= 0 || donor, 'Only exchange a real original Traitor identity');
|
||||
if (index >= 0) game.traitorReserve!.splice(index, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
p.traitors[0] = identity;
|
||||
staging.push(`Conserved Traitor ${identity}: original ${donor?.id ?? 'reserve'} exchanged with ${actor}'s ${returned}.`);
|
||||
}
|
||||
|
||||
/** Actual first-turn typed invasion, chooseBattle and shared preparation. Only
|
||||
* unopened Spice order and conserved physical card custody are staged. */
|
||||
export function createHarassHomeworldFixture(options: HarassHomeworldOptions = {}): HarassHomeworldFixture {
|
||||
const setup = createHarassHomeworldSetup(options), role = options.role ?? 'visitor';
|
||||
const outcome = options.outcome ?? 'loss', slot = options.slot ?? 'defense';
|
||||
assert.ok(!options.secondary || role === 'native' && setup.afterSetup.advanced);
|
||||
assert.ok(outcome !== 'explosion' || role === 'visitor');
|
||||
assert.ok(outcome !== 'explosion' || slot === 'defense', 'Lasgun uses the actual weapon slot');
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const staging: string[] = [];
|
||||
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
|
||||
const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery);
|
||||
assert.ok(at >= 0); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
staging.push('Position unopened original non-worm Spice Cards for the first native Spice Blow; no storm/phase/history assignment.');
|
||||
const native = role === 'visitor' ? setup.opponent : setup.owner;
|
||||
const visitor = role === 'visitor' ? setup.owner : setup.opponent;
|
||||
const location = role === 'visitor' ? 'homeworld:guild' : options.secondary ? 'homeworld:emperor:salusa' : 'homeworld:emperor';
|
||||
game = advanceHarassHomeworld(game, g => g.phase === 5 && g.active === visitor && harassHomeworldClean(g), setup.nativeSteps);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const amount = role === 'visitor' ? 7 : 5, elite = role === 'visitor' ? 4 : 0;
|
||||
const sources = nativeShipmentSources(viewGame(game, visitor), amount, elite); assert.ok(sources);
|
||||
const choice = homeworldShipmentChoice(viewGame(game, visitor), location,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(choice.action, choice.blocked ?? 'Original typed invasion must be available');
|
||||
const shipmentStep = { actor: visitor, action: choice.action };
|
||||
game = actHarassHomeworld(game, shipmentStep, setup.nativeSteps);
|
||||
game = advanceHarassHomeworld(game, g => !g.pendingHomeworldShipment && !g.pendingShipment && harassHomeworldClean(g), setup.nativeSteps);
|
||||
const afterShipment = structuredClone(game);
|
||||
assert.deepEqual(combatArmy(game, visitor, location), role === 'visitor' ? { normal: 3, elite: 4 } : { normal: 5, elite: 0 });
|
||||
game = advanceHarassHomeworld(game, g => g.phase === 6 && harassHomeworldClean(g) && !!g.active, setup.nativeSteps);
|
||||
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
|
||||
takeHarassCard(game, setup.owner, HARASS_HOMEWORLD_CARD);
|
||||
let laser: string | undefined, shield: string | undefined;
|
||||
if (outcome === 'explosion') {
|
||||
laser = game.deck.find(c => c.kind === 'lasgun')!.id;
|
||||
shield = game.deck.find(c => c.kind === 'shield')!.id;
|
||||
takeHarassCard(game, setup.owner, laser); takeHarassCard(game, setup.opponent, shield);
|
||||
}
|
||||
staging.push('Return original hands to the undealt deck, then conserve the physical Harass and, for explosion only, original Lasgun/Shield to their actual owners; all hands <=4.');
|
||||
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const ownLeader = outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
|
||||
const otherLeader = outcome === 'win' ? living(setup.opponent)[0] : living(setup.opponent).at(-1)!;
|
||||
if (outcome === 'traitor') {
|
||||
assert.equal(role, 'visitor', 'Only the opposing native, not an invader, calls Traitor');
|
||||
conserveTraitor(game, setup.opponent, ownLeader.id, staging);
|
||||
}
|
||||
const beforeBattle = structuredClone(game);
|
||||
const battleChoice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location); assert.ok(battleChoice);
|
||||
const battleStep: HarassHomeworldStep = { actor: battleChoice.chooser, action: { type: 'chooseBattle', territory: location,
|
||||
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
|
||||
game = actHarassHomeworld(game, battleStep, setup.nativeSteps);
|
||||
game = advanceHarassHomeworld(game, g => !!g.battle && harassHomeworldClean(g) &&
|
||||
!g.battle.preparation && g.battle.preLeader?.closed !== false, setup.nativeSteps);
|
||||
assert.equal(Object.keys(game.battle!.plans).length, 0);
|
||||
const dial = role === 'native' ? 0 : 2;
|
||||
const support = game.advanced && role === 'visitor' ? 1 : 0;
|
||||
const planActions: [HarassHomeworldStep, HarassHomeworldStep] = [
|
||||
{ actor: setup.owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
|
||||
weapon: slot === 'weapon' ? HARASS_HOMEWORLD_CARD : laser ?? null,
|
||||
defense: slot === 'defense' ? HARASS_HOMEWORLD_CARD : null } },
|
||||
{ actor: setup.opponent, action: { type: 'battlePlan', dial: role === 'native' || outcome === 'win' || outcome === 'explosion' ? 0 : 3,
|
||||
support: game.advanced && role === 'visitor' && outcome !== 'win' && outcome !== 'explosion' ? 3 : 0,
|
||||
leader: otherLeader.id, defense: shield ?? null } },
|
||||
];
|
||||
const returns = { [location]: { normal: 2, elite: 3 } };
|
||||
assertHarassHomeworldCustody(game);
|
||||
return { ...setup, options, role, location, native, visitor, beforeShipment, shipmentStep, afterShipment,
|
||||
beforeBattle, battleStep, game, planActions, returns, staging };
|
||||
}
|
||||
export function sealHarassHomeworldPlan(game: Game, step: HarassHomeworldStep): Game {
|
||||
return finishHarassPreparation(applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function revealHarassHomeworldBattle(fixture: HarassHomeworldFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealHarassHomeworldPlan(game, step);
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function chooseHarassHomeworldReturn(game: Game, returns: Record<string, HomeworldForces>): Game {
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
assert.ok(game.decision && 'event' in game.decision);
|
||||
return applyAction(game, game.decision.player, { type: 'decision', event: game.decision.event, returns });
|
||||
}
|
||||
/** Original native declarations, typed losses, mandatory discard, retention and
|
||||
* Caladan aftercare; never fabricate an invader's forbidden Traitor vote. */
|
||||
export function finishHarassHomeworldBattle(game: Game, options: {
|
||||
returns?: Record<string, HomeworldForces>; callers?: string[]; actions?: HarassHomeworldStep[];
|
||||
} = {}): Game {
|
||||
for (let n = 0; n < 240; n++) {
|
||||
if (!game.battle && !game.homeworldBattleLoss && harassHomeworldClean(game)) return game;
|
||||
let step: HarassHomeworldStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
|
||||
assert.ok(options.returns, 'Use the actual after-reveal typed allocation');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
|
||||
} else step = nextHarassHomeworldStep(game);
|
||||
assert.ok(step, 'Finish requires both genuine plans');
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
game = actHarassHomeworld(game, step, options.actions);
|
||||
}
|
||||
throw Error('Original Harass Homeworld battle did not complete');
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { casualtyOptions } from '../game/combat';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import { homeworldSardaukarFreeSupport } from '../game/homeworld-benefits';
|
||||
import {
|
||||
advanceHarassHomeworldArrakis, assertHarassHomeworldArrakisCustody,
|
||||
chooseHarassHomeworldArrakisAllocation, createHarassHomeworldArrakisFixture,
|
||||
finishHarassHomeworldArrakisBattle, harassHomeworldArrakisClean,
|
||||
harassHomeworldArrakisPlayer as player, harassHomeworldArrakisPool as pool,
|
||||
pendingHarassHomeworldArrakisAllocations, revealHarassHomeworldArrakisBattle,
|
||||
sealHarassHomeworldArrakisPlan, type HarassHomeworldArrakisStep,
|
||||
} from './fixture-harass-homeworld-arrakis';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
|
||||
for (const outcome of ['winner', 'loser'] as const) {
|
||||
for (const slot of ['weapon', 'defense'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${outcome} ${slot}: every real typed return preserves its original commitment`, () => {
|
||||
const f = createHarassHomeworldArrakisFixture({ advanced, outcome, slot });
|
||||
const onePlan = sealHarassHomeworldArrakisPlan(f.game, f.planActions[0]);
|
||||
assert.equal(onePlan.battle!.harassAllocation, undefined);
|
||||
assert.equal(viewGame(onePlan, f.opponent).battle!.plans[f.owner], undefined);
|
||||
const revealed = sealHarassHomeworldArrakisPlan(onePlan, f.planActions[1]);
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
assert.equal(revealed.decision?.kind, 'harassWithdraw');
|
||||
assert.equal(viewGame(revealed, f.opponent).battle!.harassAllocation, null);
|
||||
const offer = viewGame(revealed, f.owner).battle!.harassAllocation!;
|
||||
const allocations = pendingHarassHomeworldArrakisAllocations(revealed, f.owner);
|
||||
assert.ok(allocations.length >= 3, 'Actual physical Basic/Advanced alternatives remain after reveal');
|
||||
const sealedPlan = structuredClone(revealed.battle!.plans[f.owner]);
|
||||
const event = revealed.battle!.event;
|
||||
for (const returns of allocations) {
|
||||
const returned = returns[f.location];
|
||||
const committed = { normal: 3 - returned.normal, elite: 4 - returned.elite };
|
||||
assert.ok(casualtyOptions(offer.context.forces, offer.dial, offer.support)
|
||||
.some(c => c.normal === committed.normal && c.elite === committed.elite));
|
||||
let game = chooseHarassHomeworldArrakisAllocation(revealed, f.owner, returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
assert.equal(player(game, f.owner).reserves, 13, 'Selection alone does not return counters before Traitor calls');
|
||||
assert.deepEqual(game.homeworlds, revealed.homeworlds);
|
||||
game = advanceHarassHomeworldArrakis(game, g => g.lastBattleContext?.event === event);
|
||||
assert.equal(game.lastBattleContext!.winner, outcome === 'winner' ? f.owner : f.opponent);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(player(game, f.owner).spice, player(revealed, f.owner).spice - (advanced ? 1 : 0), 'Sealed support charged once, not from returned population');
|
||||
const opponentCollection = advanced && game.phase >= 7 && (player(game, f.opponent).forces[f.location] ?? 0) > 0 ? 1 : 0;
|
||||
assert.equal(player(game, f.opponent).spice, player(revealed, f.opponent).spice - (advanced && outcome === 'loser' ? 3 : 0) + opponentCollection);
|
||||
if (outcome === 'winner' && game.decision?.kind === 'battleLosses') {
|
||||
assert.deepEqual(game.decision.options.map(c => ({ normal: c.normal, elite: c.elite })), [committed], 'The selected Harass complement fixes winner losses');
|
||||
}
|
||||
game = finishHarassHomeworldArrakisBattle(game);
|
||||
const own = player(game, f.owner);
|
||||
assert.equal(own.reserves, 13 + returned.normal + returned.elite);
|
||||
assert.equal(own.elites!.reserves, 1 + returned.elite);
|
||||
assert.equal(own.tanks, committed.normal + committed.elite);
|
||||
assert.equal(own.elites!.tanks, committed.elite);
|
||||
assert.equal(own.battleLosses, committed.normal + committed.elite, 'Returns are not battle losses');
|
||||
assert.equal(own.forces[f.location] ?? 0, 0, 'Returned counters cannot survive as occupying board forces');
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, 0);
|
||||
const other = player(game, f.opponent);
|
||||
assert.equal(other.reserves, 13, 'Opponent never receives the Harass user’s returned forces');
|
||||
assert.equal(other.tanks, outcome === 'winner' ? 7 : 3);
|
||||
assert.equal(other.battleLosses, outcome === 'winner' ? 7 : 3);
|
||||
assert.equal(other.forces[f.location] ?? 0, outcome === 'winner' ? 0 : 4);
|
||||
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: 12 + returned.normal, elite: 0 }
|
||||
: { normal: 12 + returned.normal, elite: 1 + returned.elite });
|
||||
assert.deepEqual(game.homeworlds!.custody!.salusa, advanced ? { normal: 0, elite: 1 + returned.elite } : null);
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors, {});
|
||||
assert.equal(game.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1);
|
||||
assert.ok(!own.hand.some(c => c.id === 'ecaz-harass-withdraw'));
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(harassHomeworldArrakisClean(game));
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const caller of ['opponent', 'owner', 'both'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} actual ${caller} Traitor call keeps native return and disposal precedence`, () => {
|
||||
const f = createHarassHomeworldArrakisFixture({ advanced, traitor: caller, outcome: 'loser' });
|
||||
const revealed = revealHarassHomeworldArrakisBattle(f);
|
||||
const returns = { [f.location]: { normal: 2, elite: 3 } };
|
||||
assert.ok(pendingHarassHomeworldArrakisAllocations(revealed, f.owner)
|
||||
.some(choice => JSON.stringify(choice) === JSON.stringify(returns)));
|
||||
const callers = caller === 'both' ? [f.owner, f.opponent] : [caller === 'owner' ? f.owner : f.opponent];
|
||||
const actions: HarassHomeworldArrakisStep[] = [];
|
||||
const game = finishHarassHomeworldArrakisBattle(revealed, { returns, callers }, actions);
|
||||
assert.equal(actions.filter(s => s.action.type === 'traitorCall' && s.action.call === true).length, callers.length);
|
||||
assert.equal(game.lastBattleContext!.result, caller === 'both' ? 'mutualTraitors' : 'traitor');
|
||||
assert.equal(game.lastBattleContext!.winner, caller === 'both' ? null : caller === 'owner' ? f.owner : f.opponent);
|
||||
const own = player(game, f.owner), canceled = caller !== 'owner';
|
||||
assert.equal(own.reserves, canceled ? 13 : 18);
|
||||
assert.equal(own.elites!.reserves, canceled ? 1 : 4);
|
||||
assert.equal(own.tanks, canceled ? 7 : 0);
|
||||
assert.equal(own.elites!.tanks, canceled ? 4 : 0);
|
||||
assert.equal(own.battleLosses, canceled ? 7 : 0);
|
||||
assert.equal(own.forces[f.location] ?? 0, canceled ? 0 : 2, 'Own Traitor winner keeps its committed counters, not its returned counters');
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, canceled ? 0 : 1);
|
||||
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: canceled ? 12 : 14, elite: 0 }
|
||||
: { normal: canceled ? 12 : 14, elite: canceled ? 1 : 4 });
|
||||
assert.deepEqual(game.homeworlds!.custody!.salusa, advanced ? { normal: 0, elite: canceled ? 1 : 4 } : null);
|
||||
const otherPlan = f.planActions[1].action;
|
||||
assert.equal(otherPlan.type, 'battlePlan');
|
||||
const bounty = player(revealed, f.opponent).leaders.find(l => l.id === otherPlan.leader)!.strength;
|
||||
const ownerCollection = advanced && game.phase >= 7 && (own.forces[f.location] ?? 0) > 0 ? 1 : 0;
|
||||
const paidSupport = advanced && caller !== 'owner' ? 1 : 0;
|
||||
assert.equal(own.spice, player(revealed, f.owner).spice + (caller === 'owner' ? bounty : 0) - paidSupport + ownerCollection,
|
||||
'Only the sole Traitor winner waives support; later Collection remains separate.');
|
||||
if (canceled) {
|
||||
const ownPlan = f.planActions[0].action;
|
||||
assert.equal(ownPlan.type, 'battlePlan');
|
||||
assert.equal(own.leaders.find(l => l.id === ownPlan.leader)!.dead, true);
|
||||
}
|
||||
assert.equal(game.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1, 'After-use disposal also beats own-Traitor winner retention');
|
||||
assert.ok(!own.hand.some(c => c.id === 'ecaz-harass-withdraw'));
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('Advanced Salusa low→high return does not retroactively rescore sealed support, and later native shipment uses the real reserves', () => {
|
||||
const f = createHarassHomeworldArrakisFixture({ advanced: true, outcome: 'winner' });
|
||||
const revealed = revealHarassHomeworldArrakisBattle(f);
|
||||
const offer = viewGame(revealed, f.owner).battle!.harassAllocation!;
|
||||
assert.equal(homeworldSardaukarFreeSupport(revealed, f.owner), false);
|
||||
assert.ok(casualtyOptions(offer.context.forces, 2, 1).some(c => c.normal === 1 && c.elite === 1));
|
||||
assert.ok(!casualtyOptions({ ...offer.context.forces, eliteFreeSupport: true }, 2, 1)
|
||||
.some(c => c.normal === 1 && c.elite === 1), 'Requoting against high Salusa would erase the sealed physical commitment');
|
||||
const game = finishHarassHomeworldArrakisBattle(revealed, { returns: { [f.location]: { normal: 2, elite: 3 } } });
|
||||
assert.equal(player(game, f.owner).tanks, 2);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, 1);
|
||||
assert.equal(player(game, f.owner).spice, player(revealed, f.owner).spice - 1);
|
||||
assert.equal(homeworldSardaukarFreeSupport(game, f.owner), true);
|
||||
const salusa = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(h => h.location === 'homeworld:emperor:salusa')!;
|
||||
assert.equal(salusa.population, 4);
|
||||
assert.equal(salusa.side, 'high');
|
||||
let next = advanceHarassHomeworldArrakis(game,
|
||||
g => g.turn === 2 && g.phase === 5 && g.active === f.owner && harassHomeworldArrakisClean(g));
|
||||
assert.ok(viewGame(next, f.owner).homeworldMove, 'The native next movement control remains available');
|
||||
const before = structuredClone(next);
|
||||
next = applyAction(next, f.owner, { type: 'ship', territory: f.territory,
|
||||
sector: Number(f.location.split(':').at(-1)), amount: 1, elite: 1, allyPayment: 0,
|
||||
homeworldSources: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
|
||||
next = advanceHarassHomeworldArrakis(next, harassHomeworldArrakisClean);
|
||||
assert.equal(player(next, f.owner).reserves, player(before, f.owner).reserves - 1);
|
||||
assert.equal(player(next, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves - 1);
|
||||
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor'), pool(before, f.owner, 'homeworld:emperor'));
|
||||
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 3 });
|
||||
assert.equal(player(next, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(next, f.owner).elites!.forces[f.location], 1);
|
||||
assert.equal(player(next, f.owner).tanks, player(before, f.owner).tanks);
|
||||
assertHarassHomeworldArrakisCustody(next);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { combatArmy, homeworldBattleLocation } from '../game/combat-location';
|
||||
import {
|
||||
HARASS_HOMEWORLD_CARD, advanceHarassHomeworld,
|
||||
assertHarassHomeworldCustody, chooseHarassHomeworldReturn, createHarassHomeworldFixture,
|
||||
finishHarassHomeworldBattle, harassHomeworldClean, harassHomeworldPlayer as player,
|
||||
harassHomeworldPool, revealHarassHomeworldBattle, sealHarassHomeworldPlan,
|
||||
type HarassHomeworldStep,
|
||||
} from './fixture-harass-homeworld';
|
||||
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action, reason?: RegExp): void {
|
||||
const before = JSON.stringify(game);
|
||||
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
|
||||
else assert.throws(() => applyAction(game, actor, action));
|
||||
assert.equal(JSON.stringify(game), before, 'An illegal action must not mutate original custody or sealed plans');
|
||||
}
|
||||
function assertFinished(game: Game, owner: string, location: string): void {
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(!game.homeworldBattleLoss && !game.pendingTreacheryDiscard && harassHomeworldClean(game));
|
||||
assert.equal(game.lastBattleContext!.territory, location);
|
||||
assert.equal(game.discard.filter(c => c.id === HARASS_HOMEWORLD_CARD).length, 1);
|
||||
assert.ok(!player(game, owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD));
|
||||
assertHarassHomeworldCustody(game);
|
||||
}
|
||||
|
||||
for (const sample of [
|
||||
{ advanced: false, slot: 'weapon' as const },
|
||||
{ advanced: true, slot: 'defense' as const },
|
||||
]) void test(`${sample.advanced ? 'Advanced' : 'Basic'} native visitor ${sample.slot} returns actual typed counters, then loses only its original commitment`, () => {
|
||||
const f = createHarassHomeworldFixture(sample);
|
||||
for (const state of [f.afterSetup, f.beforeShipment, f.afterShipment, f.beforeBattle, f.game])
|
||||
assertHarassHomeworldCustody(state);
|
||||
assert.equal(f.shipmentStep.action.type, 'homeworldShip');
|
||||
assert.equal(f.battleStep.action.type, 'chooseBattle');
|
||||
assert.deepEqual(combatArmy(f.beforeShipment, f.visitor, f.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(combatArmy(f.afterShipment, f.visitor, f.location), { normal: 3, elite: 4 });
|
||||
assert.equal(player(f.afterShipment, f.visitor).reserves, player(f.beforeShipment, f.visitor).reserves - 7);
|
||||
assert.equal(player(f.afterShipment, f.visitor).elites!.reserves, player(f.beforeShipment, f.visitor).elites!.reserves - 4);
|
||||
assert.equal(f.game.battle!.harassAllocationVersion, 1);
|
||||
let game = sealHarassHomeworldPlan(f.game, f.planActions[0]);
|
||||
assert.ok(!game.battle!.revealed);
|
||||
assert.equal(game.battle!.harassAllocation, undefined);
|
||||
assert.equal(viewGame(game, f.opponent).battle!.plans[f.owner], undefined);
|
||||
const sealedPlan = structuredClone(game.battle!.plans[f.owner]);
|
||||
assert.equal(sealedPlan[sample.slot], HARASS_HOMEWORLD_CARD);
|
||||
game = sealHarassHomeworldPlan(game, f.planActions[1]);
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
const offer = viewGame(game, f.owner).battle!.harassAllocation!;
|
||||
assert.deepEqual(offer.context.locations, { [f.location]: { normal: 3, elite: 4 } });
|
||||
assert.deepEqual(offer.context.forces.normal, 3);
|
||||
assert.deepEqual(offer.context.forces.elite, 4);
|
||||
assert.equal(viewGame(game, f.opponent).battle!.harassAllocation, null);
|
||||
const beforeSelection = structuredClone(game);
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
assert.deepEqual(game.homeworlds, beforeSelection.homeworlds, 'Allocation selection is not a premature withdrawal');
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves, player(beforeSelection, p.id).reserves);
|
||||
assert.equal(p.tanks, player(beforeSelection, p.id).tanks);
|
||||
}
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: false });
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
const done = finishHarassHomeworldBattle(game, { actions });
|
||||
const returned = f.returns[f.location], amount = returned.normal + returned.elite;
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.native);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(after.reserves, before.reserves + amount);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + returned.elite);
|
||||
assert.equal(after.tanks, before.tanks + 7 - amount);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 4 - returned.elite);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 7 - amount);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.elites!.revived, before.elites!.revived);
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
const primary = 'homeworld:emperor';
|
||||
const homeBefore = harassHomeworldPool(f.game, f.owner, primary);
|
||||
assert.deepEqual(harassHomeworldPool(done, f.owner, primary), sample.advanced
|
||||
? { normal: homeBefore.normal + returned.normal, elite: homeBefore.elite }
|
||||
: { normal: homeBefore.normal + returned.normal, elite: homeBefore.elite + returned.elite });
|
||||
if (sample.advanced) {
|
||||
const salusa = harassHomeworldPool(f.game, f.owner, 'homeworld:emperor:salusa');
|
||||
assert.deepEqual(harassHomeworldPool(done, f.owner, 'homeworld:emperor:salusa'),
|
||||
{ normal: salusa.normal, elite: salusa.elite + returned.elite });
|
||||
}
|
||||
assert.equal(player(done, f.native).tanks - player(f.game, f.native).tanks, 3);
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
assert.equal(after.leaders.find(l => l.id === sealedPlan.leader)!.dead, false, 'Ordinary leader resolution is unchanged');
|
||||
assertFinished(done, f.owner, f.location);
|
||||
const continued = advanceHarassHomeworld(done, g => g.phase === 7 && harassHomeworldClean(g));
|
||||
assert.equal(continued.turn, f.game.turn);
|
||||
assertFinished(continued, f.owner, f.location);
|
||||
});
|
||||
|
||||
void test('Advanced visitor winner pays its fixed original support and discards Harass instead of retaining it', () => {
|
||||
const f = createHarassHomeworldFixture({ advanced: true, slot: 'weapon', outcome: 'win' });
|
||||
let game = revealHarassHomeworldBattle(f);
|
||||
const sealedPlan = structuredClone(game.battle!.plans[f.owner]);
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
const before = player(f.game, f.owner);
|
||||
const done = finishHarassHomeworldBattle(game);
|
||||
const after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(after.reserves, before.reserves + 5);
|
||||
assert.equal(after.tanks, before.tanks + 2);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 2);
|
||||
assert.equal(after.spice, before.spice - 1, 'No leader dies here; original one-spice support is charged exactly once');
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assertFinished(done, f.owner, f.location);
|
||||
});
|
||||
|
||||
for (const sample of [
|
||||
{ advanced: false, secondary: false, slot: 'weapon' as const },
|
||||
{ advanced: true, secondary: false, slot: 'defense' as const },
|
||||
{ advanced: true, secondary: true, slot: 'weapon' as const },
|
||||
]) void test(`${sample.advanced ? 'Advanced' : 'Basic'} own ${sample.secondary ? 'Salusa' : 'Kaitain'} forbids Harass immutably but accepts the plain original plan`, () => {
|
||||
const f = createHarassHomeworldFixture({ ...sample, role: 'native' });
|
||||
assert.equal(homeworldBattleLocation(f.game, f.location)!.native, f.owner);
|
||||
const preview = viewGame(f.game, f.owner).battle!.harassWithdraw!;
|
||||
assert.match(preview.blocked!, /own.*Homeworld/i);
|
||||
rejectsUnchanged(f.game, f.owner, f.planActions[0].action, /own.*Homeworld/i);
|
||||
assert.equal(Object.keys(f.game.battle!.plans).length, 0);
|
||||
assert.ok(player(f.game, f.owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD));
|
||||
const plain: HarassHomeworldStep = { actor: f.owner, action: { ...f.planActions[0].action, weapon: null, defense: null } };
|
||||
let game = sealHarassHomeworldPlan(f.game, plain);
|
||||
game = sealHarassHomeworldPlan(game, f.planActions[1]);
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.owner]);
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
game = finishHarassHomeworldBattle(game, { actions });
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(harassHomeworldClean(game));
|
||||
assert.equal(game.lastBattleContext!.territory, f.location);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.ok(player(game, f.owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD), 'Rejected card was never used');
|
||||
assert.equal(game.discard.filter(c => c.id === HARASS_HOMEWORLD_CARD).length, 0);
|
||||
assert.ok(actions.filter(s => s.action.type === 'traitorCall').every(s => s.actor === f.owner));
|
||||
assertHarassHomeworldCustody(game);
|
||||
});
|
||||
|
||||
void test('the real opposing native Traitor cancels a selected typed withdrawal; no invader vote is invented', () => {
|
||||
const f = createHarassHomeworldFixture({ advanced: true, slot: 'defense', outcome: 'traitor' });
|
||||
let game = revealHarassHomeworldBattle(f);
|
||||
assert.ok(player(game, f.native).traitors.includes(game.battle!.plans[f.owner].leader!));
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: true });
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
const done = finishHarassHomeworldBattle(game, { callers: [f.native], actions });
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, f.native);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves);
|
||||
assert.equal(after.tanks, before.tanks + 7);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 4);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 7);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.elites!.revived, before.elites!.revived);
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assert.equal(player(done, f.native).tanks, player(f.game, f.native).tanks);
|
||||
assert.equal(after.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
|
||||
assertFinished(done, f.owner, f.location);
|
||||
});
|
||||
|
||||
void test('Harass precedes Lasgun/Shield: returned visitors survive while native losses retain the printed Homeworld exception', () => {
|
||||
const f = createHarassHomeworldFixture({ advanced: true, slot: 'defense', outcome: 'explosion' });
|
||||
let game = revealHarassHomeworldBattle(f);
|
||||
const nativeBefore = combatArmy(f.game, f.native, f.location);
|
||||
const printed = homeworldBattleLocation(f.game, f.location)!.nativeBattleStrength;
|
||||
assert.ok(nativeBefore.normal > printed);
|
||||
const ownPlan = structuredClone(game.battle!.plans[f.owner]);
|
||||
const enemyPlan = structuredClone(game.battle!.plans[f.opponent]);
|
||||
assert.equal(player(game, f.owner).hand.find(c => c.id === ownPlan.weapon)!.kind, 'lasgun');
|
||||
assert.equal(player(game, f.opponent).hand.find(c => c.id === enemyPlan.defense)!.kind, 'shield');
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], ownPlan);
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
const done = finishHarassHomeworldBattle(game, { actions });
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(done.lastBattleContext!.winner, null);
|
||||
assert.equal(after.reserves, before.reserves + 5);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
|
||||
assert.equal(after.tanks, before.tanks + 2);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 2);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.elites!.revived, before.elites!.revived);
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(combatArmy(done, f.native, f.location), { normal: nativeBefore.normal - printed, elite: nativeBefore.elite });
|
||||
assert.equal(player(done, f.native).reserves, player(f.game, f.native).reserves - printed);
|
||||
assert.equal(player(done, f.native).tanks, player(f.game, f.native).tanks + printed);
|
||||
assert.equal(player(done, f.native).battleLosses, player(f.game, f.native).battleLosses + printed);
|
||||
assert.equal(after.leaders.find(l => l.id === ownPlan.leader)!.dead, true);
|
||||
assert.equal(player(done, f.opponent).leaders.find(l => l.id === enemyPlan.leader)!.dead, true);
|
||||
for (const id of [HARASS_HOMEWORLD_CARD, ownPlan.weapon, enemyPlan.defense])
|
||||
assert.equal(done.discard.filter(c => c.id === id).length, 1);
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
assertFinished(done, f.owner, f.location);
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export type PrototypeOptions = {
|
||||
discoveries?: boolean;
|
||||
/** Explicit original skills with Semuta, classic occupation or Guild Betrayal. */
|
||||
leaderSkills?: boolean;
|
||||
/** Original Richese/CHOAM and classic Homeworld occupation with Semuta only. */
|
||||
/** Original Semuta/Banker composition or classic independent-card Homeworlds. */
|
||||
homeworlds?: boolean;
|
||||
};
|
||||
export function isPrototypeProfile(value: string): value is PrototypeProfile {
|
||||
@@ -107,8 +107,8 @@ export function startPrototypeRoom(
|
||||
throw new Error('--discoveries requires the leader-skills, nexus or semuta profile.');
|
||||
if (options.leaderSkills && profile !== 'semuta' && profile !== 'homeworld-occupation' && profile !== 'guild-betrayal')
|
||||
throw new Error('--leader-skills requires the semuta, homeworld-occupation or guild-betrayal profile.');
|
||||
if (options.homeworlds && profile !== 'semuta' && profile !== 'banker-income')
|
||||
throw new Error('--homeworlds requires the semuta or banker-income profile.');
|
||||
if (options.homeworlds && profile !== 'semuta' && profile !== 'banker-income' && profile !== 'ecaz-treachery')
|
||||
throw new Error('--homeworlds requires the semuta, banker-income or ecaz-treachery profile.');
|
||||
if (options.ecazTreachery) initial.ecazTreachery = true;
|
||||
if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards)
|
||||
initial.nexusCards = { cards: null, phase: null };
|
||||
|
||||
@@ -36,7 +36,8 @@ async function main() {
|
||||
'\nPrinted native Homeworld capture/Auditor/force-income Karama cancellation costs also offer their card. Accepted combat, paid support, leader death and original casualty/cleanup remain committed; the canceled reward never samples, transfers, inspects, refunds or credits. Earlier allowed income stays intact before a separately canceled audit.',
|
||||
'\n--profile homeworld-occupation --leader-skills composes original all14 Leader Skills with the classic2–6-seat occupied Homeworld income preview. Native setup, reserve custody, occupation history and owner allocation remain original. Bureaucrat diverts two from gross before the occupied percentage split while preserving the full original payer shares. Nexus Cards, expansions, Tech, Strongholds, Discovery, other overlays and conversion of played games remain excluded.',
|
||||
'\n--profile guild-betrayal --leader-skills composes the bounded classic native-Guild Nexus preview with original all14 skills. Original payment/physical shipment and once-per-phase Bureaucrat receipt remain; ordinary Skills use their shared quotes. Homeworlds, expansions, Tech, Strongholds, Discovery, Semuta, other overlays and conversion of played games remain excluded.',
|
||||
'\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.' +
|
||||
'\n--profile ecaz-treachery --homeworlds selects the independent three physical cards before original classic2–6 Basic/Advanced Homeworld setup (base33+3). Harass works on Arrakis or another faction’s Homeworld, never an own World. Exact typed undialed counters return through native reserves: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic retains its original primary World. Original dial/support, native invasion/Traitor/explosion and disposal stay; other two variant cards retain Homeworld guards. No expansions, other overlays, public start or played-save conversion.',
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts a fresh ready2–6 native Ecaz/classic or selected Advanced native-family lobby. Basic uses exact ecaz33 and provisionally follows the main paragraph for odd/even counts: ceil contribution/loss and floor survivors; the conflicting FAQ is recorded, not adjudicated. Advanced retains original selected ecaz/ix/choam decks and native offers/HMS/Auditor/Face Dancers/cache. Explicit --ecaz-treachery independently adds three physical cards in Advanced. Native Richese ordinary, marker-only and mixed pools compose with selected leads under the provisional additive interpretation. Ordinary dial Prescience remains barred for the physical No-Field army; distinct Advanced special Karama uses the explicit complete private-plan interpretation without early token revelation. Other profile/module and public-start gates remain.' +
|
||||
'\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. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. 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.' +
|
||||
'\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Banker income remains a separate opt-in. Mixed E3 families, other modules and played-game conversion remain excluded.',
|
||||
@@ -73,8 +74,8 @@ async function main() {
|
||||
throw new Error('--discoveries requires --profile leader-skills, nexus or semuta.');
|
||||
if (values['leader-skills'] && values.profile !== 'semuta' && values.profile !== 'homeworld-occupation' && values.profile !== 'guild-betrayal')
|
||||
throw new Error('--leader-skills requires --profile semuta, homeworld-occupation or guild-betrayal.');
|
||||
if (values.homeworlds && values.profile !== 'semuta' && values.profile !== 'banker-income')
|
||||
throw new Error('--homeworlds requires --profile semuta or banker-income.');
|
||||
if (values.homeworlds && values.profile !== 'semuta' && values.profile !== 'banker-income' && values.profile !== 'ecaz-treachery')
|
||||
throw new Error('--homeworlds requires --profile semuta, banker-income or ecaz-treachery.');
|
||||
if (
|
||||
!isPrototypeProfile(values.profile) ||
|
||||
!values.db ||
|
||||
|
||||
Reference in new issue
Block a user