Connect printed high Ecaz Homeworld alliance victory

This commit is contained in:
Quintine committed 2026-10-05 14:41:11 +11:00
1 parent cfa0fe3952
commit 17bc4537c3
14 files changed
+878 -8

No files matched your search

+15 -1
View File
@@ -1109,6 +1109,15 @@ export function GameTable({
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
</output>
)}
{g.homeworldEcazVictoryPreview && (
<output className="notice block">
Partial · Ecaz High Homeworld victory development preview, enabled only
for this fresh preview game. Public progress is separate from ordinary
stronghold and Ecaz three-joint-stronghold victory; it is evaluated only
at the Mentat victory check. Unresolved source cases remain guarded.
Not complete or certified Homeworld rules.
</output>
)}
{g.homeworldTupilePreview && (
<output className="notice block">
Occupied Tupile development preview · the occupier and reciprocal ally
@@ -6002,7 +6011,12 @@ export function GameTable({
</div>
)}
</section>
<VictoryProgress progress={g.victoryProgress} players={g.players} fremen={g.fremenVictory} />
<VictoryProgress
progress={g.victoryProgress}
players={g.players}
fremen={g.fremenVictory}
ecazHomeworld={g.ecazHomeworldVictory}
/>
{g.strongholdCards && (
<details className="m-4 rounded-xl border border-[#a88b60] p-4">
<summary className="cursor-pointer text-lg">
+78 -2
View File
@@ -2,23 +2,32 @@ import { faction } from '@/game/catalog';
import { territory } from '@/game/board';
import type { StrongholdProgress } from '@/game/victory-progress';
import type { FremenVictoryProgress } from '@/game/fremen-victory';
import type { GameView } from '@/game/engine';
import { homeworldCard } from '@/game/homeworld-cards';
export function VictoryProgress({
progress,
players,
fremen,
ecazHomeworld,
}: {
progress: readonly StrongholdProgress[];
players: readonly { id: string; faction: string }[];
fremen?: FremenVictoryProgress | null;
ecazHomeworld?: GameView['ecazHomeworldVictory'];
}) {
if (!progress.length) return null;
if (!progress.length && !fremen && !ecazHomeworld) return null;
const rows = progress.filter((row) => row.player === row.members[0]);
const playerFactionName = (id: string) => {
const player = players.find((candidate) => candidate.id === id);
return player ? faction(player.faction).name : id;
};
return (
<details className="m-4 rounded-xl border border-[#a88b60] p-4">
<summary className="cursor-pointer text-lg">
Stronghold victory progress
Victory progress
</summary>
<h3 className="mt-3">Stronghold victory progress</h3>
<p className="muted mt-3">
Current board, evaluated at the Mentat victory check. Battles and other
effects may change these holdings. A correct Bene Gesserit prediction
@@ -69,6 +78,73 @@ export function VictoryProgress({
</li>
))}
</ul>
{ecazHomeworld && (
<section
className="mt-4 space-y-2 break-words rounded-lg border border-[#a88b60]/40 p-3"
aria-label="Ecaz Homeworld victory"
>
<h3>Ecaz High Homeworld victory</h3>
<p>{ecazHomeworld.members.map(playerFactionName).join(' & ')}</p>
<p>
{playerFactionName(ecazHomeworld.owner)} native population:{' '}
{ecazHomeworld.population} / {ecazHomeworld.minimum} for High
{' · '}{ecazHomeworld.high ? 'High' : 'Low'}
</p>
<p className="fine">
Source: printed Ecaz High Homeworld card. High requires at least{' '}
{ecazHomeworld.minimum} native forces on Ecaz. The alliance must jointly
occupy at least one actual stronghold and occupy the Homeworlds of two
other native factions.
</p>
<p>
Joint strongholds: {ecazHomeworld.jointStrongholds.length} / 1 required
</p>
<p className="fine">
{ecazHomeworld.jointStrongholds.length
? ecazHomeworld.jointStrongholds.map((id) => territory(id).name).join(', ')
: 'No jointly occupied stronghold.'}
</p>
<p>
Other native factions:{' '}
{ecazHomeworld.distinctNativeFactions.length} / 2 required
</p>
<p className="fine">
{ecazHomeworld.distinctNativeFactions.length
? ecazHomeworld.distinctNativeFactions.map((id) => faction(id).name).join(', ')
: 'No other native faction’s Homeworld occupied.'}
</p>
{ecazHomeworld.foreignHomeworlds.length > 0 && (
<ul className="space-y-1 text-sm" aria-label="Occupied foreign Homeworlds">
{ecazHomeworld.foreignHomeworlds.map((world) => (
<li key={world.world}>
{homeworldCard(world.card)?.name ?? world.world}
{' · '}native {faction(world.faction).name}
{' · '}held by {playerFactionName(world.holder)}
</li>
))}
</ul>
)}
<p className="fine">
Two physical Emperor Homeworlds count as one other native faction.
Homeworlds add no ordinary stronghold points; a Tech Token set is not a
joint stronghold.
</p>
{ecazHomeworld.blocked && (
<output className="notice block">
Source blocked: {ecazHomeworld.blocked}
</output>
)}
<p className={ecazHomeworld.qualifies && !ecazHomeworld.blocked ? 'notice' : 'fine'}>
{ecazHomeworld.blocked
? 'This source cannot resolve the Ecaz Homeworld victory check.'
: ecazHomeworld.qualifies
? 'Ecaz Homeworld conditions are met on this board.'
: 'Ecaz Homeworld conditions are not met on this board.'}
{' '}Prospective progress only: victory is evaluated at the Mentat
victory check, not awarded here.
</p>
</section>
)}
{fremen && (
<section className="mt-4 space-y-2 rounded-lg border border-[#a88b60]/40 p-3" aria-label="Fremen final-turn victory">
<h3>Fremen final-turn victory</h3>
+28
View File
@@ -17,6 +17,34 @@ open mode gates.
## Current checkpoint and work
**Printed high Ecaz Homeworld alliance victory — bounded Development Verified:**
fresh native Ecaz/Homeworld entry only. Current native7+, reciprocal alliance
jointly holds one actual uncontested stronghold and proved physically held worlds
of two other native factions. Emperor’s two worlds countone; own/ally worlds,
Tech sets, advisors and ordinary points cannot manufacture this conjunction.
Qualifying members join original winners before BG prediction/final fallback.
The source result prevents Ecaz/Fremen’s printed win from being mislabeled as
Fremen fallback; Duke’s explicit Ecaz-only revival and exceptional custody stay
separate. Old/public profiles gain no new victory source.
Source34/affected119 including guide cases, types/lint/build and real original
Movement→Collection→Mentat pass; all4 legal policies finish readiness.
High6/nonjoint/one-native/same-Emperor cases donotwin; prediction/simultaneous
ordinary union and public-only inputs retain original semantics. A separate
original734-action clock reaches turn10 before a conserved Ecaz/Fremen snapshot
proves printed-source precedence.
Five genuine six-seat Advanced games allfinish4,339/4,358/114 on unchanged
b0ebb815 tree;19 rejected candidates remain recorded. Counts establishcontinuation,
not natural triggering ofeverynewroute. Specific source proof is native/human.
Original **S8N26ADW v18/setup** retainsdeck/actors/choices; real turn-two Nexus
earnsEcaz/Guild before labelledsurvivingpositions. Storm7 unchanged, native7/7,
jointHabbanyaSietch1/1, Emperor/BG2/2, EcazKaitain/GuildWallach. HumanlastReady
reaches **v25/Mentat/finished Ecaz+Guild** whileordinary1/4/Ecaz1/3 stayfalse.
Refresh fits390px. No reset/secret/public/save-conversion or Duke permission.
[Exact source/runtime evidence](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026).
Full Homeworlds/overlays, pending Basic/CHOAM/Duke, assurance and deployment remain.
**Occupied Tupile slots / normal-limit cleanup — bounded Development Verified:**
fresh native CHOAM/Homeworld entries alone initialize the source marker/ledger;
older occupation profiles remain unchanged. One validated slot per occupier/
+53
View File
@@ -113,6 +113,59 @@ Only the native faction may call Traitors or Face Dancers in a Homeworld battle.
Ecaz's verified high face supplies a separate actual win predicate: its alliance jointly occupies at least one stronghold and occupies Homeworlds belonging to two **other factions**. Two Emperor Homeworlds do not supply two different native factions. Implement this as a Homeworld-card condition while Ecaz's high advantage applies; do not add all foreign Homeworlds to ordinary stronghold progress. The sentence's alliance subject supports combining the two members' qualifying foreign occupations, while its explicit co-occupation requirement applies to the stronghold. Exact interaction with a transient turn-bound Occupier receipt must use the finalized occupation semantics rather than silently replacing actual occupation with an income entitlement.
### Printed high Ecaz alliance victory — 5 October 2026
The unused `homeworld-occupation` entry with native Ecaz now separately marks
`homeworldEcazVictoryPreview`. The original card’s condition is a conjunction:
current native Ecaz population7+, a reciprocal alliance jointly holding at least
one actual uncontested stronghold, and proved current alliance holdings of worlds
belonging to two other native factions. Original `strongholdProgress` owns joint
site legality; public advisors/No-Field/HMS and storm semantics are not duplicated.
Kaitain plus Salusa still counts only Emperor; own/ally-native worlds are excluded.
The focused [public quote](../game/ecaz-homeworld-victory.ts) uses actual typed
reserve/custody/history and the shared supported occupation authority, with
current physical holder presence. It never turns an income entitlement or a
departed garrison into ordinary points. Basic unresolved potential holdings remain
source-blocked. Unmarked board-only quotes read no new reserve or Homeworld data.
The native high condition is current population; occupation does not invent a
global suppression of that high advantage.
This qualifying alliance joins the original ordinary winner union before a
correct BG prediction; final-turn Fremen/Guild fallback still occurs only when
no earlier source wins. The returned qualifying source prevents an Ecaz/Fremen
alliance win from being described as a Fremen fallback. No special win button,
card/spice award, altered Tech/Stronghold ownership or Duke permission is added.
The explicit only-Ecaz Duke revival ruling and exceptional custody guards remain.
Source34/related119 cases including guidance, types/lint/build and original native programs pass.
The actual Movement→Collection→Mentat program wins with population7, onlyone
joint site and two other native factions; ordinary1/4 and Ecaz1/3 remainfalse.
All4 policies can finish original legal Collection readiness; BG prediction and
simultaneous ordinary-winner union retain precedence. A separate original clock
program reaches turn10 through734 legal actions before a labelled conserved
Ecaz/Fremen endgame quote proves printed-source precedence over fallback.
Original CLI **S8N26ADW v18/setup** preserves deck/actors/starting choices.
Real turn-two Nexus earns Ecaz/Guild alliance before a labelled surviving-counter
position; original storm7 remains unchanged. Phone390px prospective progress shows
native7/7, joint Habbanya Sietch1/1 and distinct Emperor/BG2/2, with Ecaz holding
Kaitain and Guild Wallach. Original ready actions then reach **v25/Mentat/finished**
with both alliance members as winners, despite unchanged ordinary1/4/Ecaz1/3.
Refresh preserves this win with no root overflow. One rejected driver closing
action was repaired using canonical eligibility; no rule guard was weakened.
Private proof stays outside Git. Full Homeworlds, overlays, pending Basic/CHOAM/
Duke and deployed acceptance remain open.
Five genuine six-seat Advanced continuations all finish4,339 accepted/
4,358 attempts/114 JSON on unchanged tree
`b0ebb815c973197ca3e3148c4ad447ddc11dba1489f72911de2cd341cafd2f08`.
The19 rejected candidates remain recorded. Winner faction alone does not identify
the new source; specific new-route proof is the original native program/human
Mentat win above, not a claim that every natural sample triggered this condition.
## Karama and other optional systems
Karama cannot cancel Homeworld advantages **or** penalties. This includes a BG Worthless card converted into Karama. Do not generate response windows for threshold flips, Homeworld strength or occupied-face awards. However, this immunity is not a blanket ban on playing Karama during a Homeworld shipment/battle: an independently cancelable ordinary faction advantage still has its own source and timing. Distinguish printed Homeworld transport permission from a faction ability and from the generic paid card effect. [E3 p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status
## 5 October 2026 — printed high Ecaz Homeworld alliance victory
Fresh native Ecaz/Homeworld audit entry marks the actual card conjunction:
nativehigh7+, reciprocalalliance oneactualjointstronghold and provedphysically
held worlds of twoOTHERnativefactions. Sharedboard andoccupationquotes own
legality; duplicateEmperorworlds countone and noTech/advisor/ordinarypoint shortcut.
Originalqualifyingwinnerunion precedes BGprediction/finalfallback. Returnedsource
disambiguates anEcaz/Fremen printedwin fromFremenfallback. Duke permission unchanged.
Source34/affected119 including guide, types/lint/build and originalprograms pass.
Real Movement→Collection→Mentat winneeds onlyonejointsite; all4policies havelegal
readiness. High6 and deficientconjunctions do notwin. Original734-action clock
reaches turn10 before separatelylabelledconserved Ecaz/Fremen sourceprecedence.
Controlledpositions preserve storm, actualTanks/starredfloors andphysicaldecks;
there is no syntheticphase/deck/receipt or favorableseed.
Five genuine six-seat Advanced samples finish4,339 accepted/4,358attempts/114JSON
on unchanged `b0ebb815c973197ca3e3148c4ad447ddc11dba1489f72911de2cd341cafd2f08`.
19 rejectedcandidates recorded. Specificnewsource triggering isproved bynative/
humanprograms, not inferredfromwinnerfactions or thesecontinuationcounts.
OriginalCLI S8N26ADW v18 setup, realturn-two Ecaz/GuildNexus and labelledsurviving
pose preserveactors/deck/clock. Storm7, native7, actualjointHabbanya1, separate
Emperor/BGworlds2 appearat390px withoutchangingordinary1/4/Ecaz1/3. ActuallastReady
reachesv25/Mentat/finished Ecaz+Guild; refreshfits390px. Drivereligibility andtyped
subset issues correctedwithoutweakeninggameguards. Privateproof outsideGit.
[Canonical source and actual winning evidence](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026).
No reset/public/saveconversion/Dukecutover; fullmodes, pendingrulings anddeployment
remainopen.
## 5 October 2026 — occupied Tupile capacity, original cleanup and low authority
Fresh native CHOAM/Homeworld audit setup alone marks Tupile slots and the original
+1
View File
@@ -178,6 +178,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Topic | Decision to preserve | Detailed authority and evidence |
| --- | --- | --- |
| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic keeps historical lifecycle guards. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Tupile original lease and consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026) |
| Printed high Ecaz Homeworld victory | Fresh native Ecaz/Homeworld entry only: current high7+, reciprocal alliance jointly holds one actual uncontested stronghold and proved physically held worlds of two other native factions. Separate source joins ordinary winner union before BG prediction/final fallback; no ordinary point inflation, Tech substitute or Duke permission. Basic potential ambiguity retains its guard; old/public profiles unchanged. | [Card contract and actual original win](HOMEWORLD_RULES.md#printed-high-ecaz-alliance-victory--5-october-2026) |
| Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) |
| Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) |
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
+156
View File
@@ -0,0 +1,156 @@
import type { BoardContext, BoardSeat } from './board-resolution-quote';
import type { FactionId } from './catalog';
import type { Game } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { homeworldForceGroups } from './homeworld-custody';
import { homeworldPopulations } from './homeworld-population';
import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
import {
quoteStableHomeworldOccupation,
type StableHomeworldOccupationContext,
} from './homeworld-stable-occupation';
import { strongholdProgress, VictoryProgressError } from './victory-progress';
export type EcazHomeworldVictoryContext = Omit<BoardContext, 'players'> &
Pick<Game, 'turn' | 'homeworlds' | 'homeworldOccupationHistory' | 'homeworldOccupationPreview'> & {
homeworldEcazVictoryPreview?: true;
players: readonly (BoardSeat & {
reserves: number;
elites?: { reserves: number } | null;
})[];
};
export type EcazHomeworldVictoryProgress = {
owner: string;
members: string[];
population: number;
minimum: number;
high: boolean;
jointStrongholds: string[];
foreignHomeworlds: {
world: string;
card: string;
native: string;
faction: FactionId;
holder: string;
}[];
distinctNativeFactions: FactionId[];
qualifies: boolean;
blocked: string | null;
};
/** Printed High Ecaz victory, separate from ordinary points and occupied Duke
* benefits. Foreign holdings require the existing original source authority AND
* the entitled holder's current physical presence; a departed garrison is never
* a victory holding. Advanced p.22 retained occupation is otherwise respected.
* This returns public facts only, never an income receipt or a winning action. */
export function ecazHomeworldVictoryProgress(
g: EcazHomeworldVictoryContext,
): EcazHomeworldVictoryProgress | null {
// Original BoardContext callers are not opted in and need no reserve data.
if (g.homeworldEcazVictoryPreview === undefined) return null;
try {
if (g.homeworldEcazVictoryPreview !== true || g.homeworldOccupationPreview !== true)
throw new VictoryProgressError('Ecaz Homeworld victory requires the fresh occupation preview.');
if (!Number.isSafeInteger(g.turn) || g.turn < 1)
throw new VictoryProgressError('Ecaz Homeworld victory requires the current positive turn.');
const board: BoardContext = {
advanced: g.advanced,
ecazOccupyPreview: g.ecazOccupyPreview,
storm: g.storm,
order: g.order,
players: g.players,
mobileStronghold: g.mobileStronghold,
discoveries: g.discoveries,
};
// This owns alliance validation, advisor settling, public No-Field presence,
// storm/contested stronghold and HMS semantics. Tech sets are not joint sites.
const ordinary = strongholdProgress(board).progress;
const ecaz = g.players.find(player => player.faction === 'ecaz');
if (!ecaz)
throw new VictoryProgressError('Fresh Ecaz Homeworld victory requires native Ecaz.');
const state = g.homeworlds;
if (!state || typeof state !== 'object' || Array.isArray(state) ||
Object.keys(state).some(key => !['custody', 'historyVersion'].includes(key)) ||
(state.historyVersion !== undefined && state.historyVersion !== 1) || !state.custody)
throw new VictoryProgressError('Ecaz Homeworld victory requires valid native Homeworld custody.');
const context = {
advanced: g.advanced,
players: g.players.map(player => ({
id: player.id, faction: player.faction, reserves: player.reserves,
eliteReserves: player.elites?.reserves ?? 0,
})),
};
const worlds = homeworldForceGroups(context, state.custody);
const history = g.homeworldOccupationHistory;
const originalHistory = state.historyVersion === 1 && history !== undefined;
if (originalHistory) validateHomeworldOccupationHistory(history, context, g.turn);
const population = homeworldPopulations(context, state.custody)
.find(home => home.native === ecaz.id && home.card === 'ecaz')!;
const minimum = HOMEWORLD_CARDS.find(card => card.id === 'ecaz')!.high.reserves.min;
const progress = ordinary.find(row => row.player === ecaz.id)!;
const members = progress.members;
const foreignHomeworlds: EcazHomeworldVictoryProgress['foreignHomeworlds'] = [];
const possible = new Set<FactionId>();
const blockers: string[] = [];
// The stable authority's legacy Game signature is broader than its actual
// public inputs. Project explicitly rather than passing private Player data.
const publicOccupation = {
advanced: g.advanced, turn: g.turn, homeworlds: state,
homeworldOccupationPreview: g.homeworldOccupationPreview,
homeworldOccupationHistory: g.homeworldOccupationHistory,
players: g.players.map(player => ({
id: player.id, faction: player.faction, ally: player.ally,
reserves: player.reserves,
elites: player.elites ? { reserves: player.elites.reserves } : undefined,
})),
} as StableHomeworldOccupationContext;
for (const world of worlds) {
// Ecaz and its ally's own native worlds are not "other factions".
if (members.includes(world.native)) continue;
const faction = context.players.find(player => player.id === world.native)!.faction;
const card = HOMEWORLD_CARDS.find(card => world.secondary
? card.id === 'salusa_secundus'
: card.faction === faction && card.id !== 'salusa_secundus')!;
const quote = originalHistory
? quoteStableHomeworldOccupation(publicOccupation, card.id)
: { entitlement: null, blocked: 'Ecaz Homeworld holdings require the original fresh occupation history.' };
const alliancePresent = members.some(member => {
const forces = world.forces[member];
return forces && forces.normal + forces.elite > 0;
});
if (quote.blocked) {
// Pending facts can block a possible win, but cannot manufacture a
// holding; unrelated occupiers and empty departed worlds cannot block it.
if (alliancePresent) {
possible.add(faction);
blockers.push(quote.blocked);
}
continue;
}
const holding = quote.entitlement;
if (!holding || !members.includes(holding.occupier)) continue;
const present = world.forces[holding.occupier];
if (!present || present.normal + present.elite === 0) continue;
foreignHomeworlds.push({
world: world.id, card: card.id, native: world.native,
faction, holder: holding.occupier,
});
possible.add(faction);
}
const distinctNativeFactions = [...new Set(foreignHomeworlds.map(home => home.faction))];
const high = population.side === 'high';
const jointStrongholds = progress.jointlyOccupied;
const blocked = blockers.length > 0 && possible.size >= 2 && distinctNativeFactions.length < 2
? blockers[0] : null;
return {
owner: ecaz.id, members: [...members], population: population.population,
minimum, high, jointStrongholds: [...jointStrongholds], foreignHomeworlds,
distinctNativeFactions,
qualifies: high && jointStrongholds.length > 0 && distinctNativeFactions.length >= 2,
blocked,
};
} catch (error) {
if (error instanceof VictoryProgressError) throw error;
throw new VictoryProgressError(error instanceof Error ? error.message : 'Invalid public Ecaz Homeworld victory state.');
}
}
+17 -3
View File
@@ -99,6 +99,7 @@ import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateO
import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding';
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
import { createOccupiedTupileState, syncOccupiedTupileLease, quoteOccupiedTupileAuthority, quoteOccupiedTupileCleanup, completeOccupiedTupileCleanup, validateOccupiedTupileState, type OccupiedTupileState } from './homeworld-occupied-tupile';
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory';
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
import { ambassadorPhaseAllowed } from './ambassador-phase';
@@ -1893,6 +1894,8 @@ export type Game = {
homeworldOccupationHistory?: HomeworldOccupationHistory;
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
homeworldOccupationPreview?: true;
/** Fresh printed Ecaz high-card victory, separate from ordinary stronghold points. */
homeworldEcazVictoryPreview?: true;
/** Fresh Tupile capacity entry only; older occupation games are not converted. */
homeworldTupilePreview?: true;
homeworldTupileState?: OccupiedTupileState;
@@ -8548,6 +8551,7 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game {
g.homeworldTupileState = createOccupiedTupileState();
g.pendingHomeworldTupileCleanup = null;
}
if (byFaction(g, 'ecaz')) g.homeworldEcazVictoryPreview = true;
return g;
}
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
@@ -18354,6 +18358,9 @@ function homeworldHistoryIntegrity(g: Game) {
homeworldOccupiedIncomeIntegrity(g);
occupiedPercentageIntegrity(g);
tupileSlotsIntegrity(g);
requireRule(!g.homeworldEcazVictoryPreview ||
g.homeworldOccupationPreview === true && !!g.homeworlds && !!byFaction(g, 'ecaz'),
'The printed Ecaz Homeworld victory requires its original fresh native entry.');
}
function homeworldOccupiedIncomeIntegrity(g: Game) {
const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory;
@@ -18880,7 +18887,10 @@ function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) {
row.members.length === g.winner.length &&
row.members.every((id) => g.winner.includes(id)),
);
const fremen = g.turn === 10 && !progress.some((row) => row.qualifies)
const homeworld = quote.ecazHomeworld &&
quote.ecazHomeworld.members.length === g.winner.length &&
quote.ecazHomeworld.members.every(id => g.winner.includes(id)) ? quote.ecazHomeworld : null;
const fremen = !quote.ecazHomeworld && g.turn === 10 && !progress.some((row) => row.qualifies)
? fremenSpecialVictory(g) : null;
const fremenWon = fremen?.qualifies && fremen.members.length === g.winner.length &&
fremen.members.every((id) => g.winner.includes(id));
@@ -18889,7 +18899,7 @@ function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) {
: '';
log(
g,
`${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}${specialReason}`,
`${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}${homeworld ? ' High Ecaz Homeworld victory: the alliance jointly holds a stronghold and occupies Homeworlds of two other native factions.' : ''}${specialReason}`,
);
} else if (g.status === 'finished')
log(g, 'The tenth turn ended without a winner.');
@@ -23092,7 +23102,7 @@ function currentRicheseCancellationQuote(g: Game, response: ResponseWindow) {
}
function currentVictoryQuote(g: Game) {
try {
return quoteVictory(g);
return quoteVictory(g, g.homeworldEcazVictoryPreview ? g : undefined);
} catch (error) {
if (error instanceof VictoryQuoteError) throw new RuleError(error.message);
throw error;
@@ -31456,10 +31466,12 @@ export function viewGame(state: Game, id: string) {
const me = getPlayer(g, id);
let victoryProgress: StrongholdProgress[] = [];
let fremenVictory: FremenVictoryProgress | null = null;
let ecazHomeworldVictory: EcazHomeworldVictoryProgress | null = null;
if (g.status === 'playing' || g.status === 'finished') {
try {
victoryProgress = strongholdProgress(state).progress;
fremenVictory = fremenSpecialVictory(state);
if (g.homeworldEcazVictoryPreview) ecazHomeworldVictory = ecazHomeworldVictoryProgress(state);
} catch (error) {
// A malformed restored board has no reliable preview. Authoritative
// victory still rejects it through its strict quote.
@@ -31556,6 +31568,7 @@ export function viewGame(state: Game, id: string) {
spiceBankerIncomePreview: g.spiceBankerIncomePreview === true,
homeworldOccupationPreview: g.homeworldOccupationPreview === true,
homeworldTupilePreview: g.homeworldTupilePreview === true,
homeworldEcazVictoryPreview: g.homeworldEcazVictoryPreview === true,
spiceBankerIncome: g.spiceBankerIncomePreview
? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null,
bribeOptions: projectedBribes(g, me),
@@ -31933,6 +31946,7 @@ export function viewGame(state: Game, id: string) {
winner: g.winner,
victoryProgress,
fremenVictory,
ecazHomeworldVictory,
shieldWallDestroyed: g.shieldWallDestroyed,
stormDialers: g.stormDialers,
stormSubmitted: Object.keys(g.stormDials),
+7
View File
@@ -3123,6 +3123,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Victory is checked when the Mentat pause begins. A faction needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds.',
'Ecaz and its reciprocal ally can also win by jointly occupying three strongholds: both factions need non-advisor presence in each, without opposing fighters. The ordinary four-stronghold alliance route remains available. Occupy cancellation does not remove this victory condition. A concealed No-Field supplies public presence; its secret value is not revealed by this check.',
'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.',
'Opposing forces may survive together in a stronghold covered by storm. That contested stronghold does not count for either side. A sole occupant still counts it even under storm.',
'A correct Bene Gesserit faction-and-turn prediction replaces a normal win, including an allied win, with a Bene Gesserit solo win. Guild and Fremen special wins cannot be predicted.',
'On turn ten, a normal stronghold victory comes first. Otherwise, check Fremen’s special condition, then award the game to Guild and its ally if present. If Guild is absent, Fremen and its ally win even when the usual Fremen special condition fails.',
@@ -3851,6 +3852,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.',
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.',
'The fresh native Ecaz high-card preview shows a separate prospective conjunction: native seven-plus population, at least one actual jointly held stronghold and proved current alliance holdings of two other native factions’ Homeworlds. This does not change the ordinary four-stronghold or three-joint Ecaz targets. Original Mentat decides the win before prediction/fallback; source uncertainty remains blocked. Duke authority and the Ecaz-only revival ruling are separate.',
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
],
example:
@@ -3901,6 +3903,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'game/homeworld-occupied-bidding.ts',
'game/homeworld-occupied-defenses.ts',
'game/homeworld-occupied-tupile.ts',
'game/ecaz-homeworld-victory.ts',
'game/victory-quote.ts',
'components/victory-progress.tsx',
'game/tupile-intelligence.ts',
'game/tupile-intelligence-answer.ts',
'game/tupile-intelligence-state.ts',
@@ -4053,6 +4058,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/homeworld-occupied-private-lots.test.ts',
'tests/homeworld-occupied-tupile.test.ts',
'tests/homeworld-occupied-tupile-runtime.test.ts',
'tests/ecaz-homeworld-victory.test.ts',
'tests/ecaz-homeworld-victory-runtime.test.ts',
'tests/grumman-collection.test.ts',
'tests/grumman-collection-engine.test.ts',
'tests/grumman-collection-controls.test.ts',
+24 -2
View File
@@ -21,6 +21,7 @@ import {
strongholdControllers,
type StrongholdState,
} from './stronghold-cards';
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryContext, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory';
export class VictoryQuoteError extends Error {}
function requireVictory(
@@ -46,12 +47,14 @@ export type VictoryQuote = {
status: 'playing' | 'finished';
/** Exact final ownership when settlement is due; null means no settlement. */
strongholds: StrongholdState | null;
/** Qualifying printed source before any private BG prediction replacement. */
ecazHomeworld: EcazHomeworldVictoryProgress | null;
};
/** Internal server quote. Prediction is private input, never a returned field.
* This preserves the existing victory order, including the distinct final-turn
* Fremen/Guild fallbacks. It does not advance a turn or settle other phase work. */
export function quoteVictory(g: VictoryContext): VictoryQuote {
export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVictoryContext): VictoryQuote {
requireVictory(
(g.status === 'playing' || g.status === 'finished') &&
whole(g.turn) &&
@@ -154,12 +157,31 @@ export function quoteVictory(g: VictoryContext): VictoryQuote {
ids.some((id) => fighterCount(player(id), t.id) > 0) &&
players.every((p) => ids.includes(p.id) || fighterCount(p, t.id) === 0),
).length;
let ecazHomeworld: EcazHomeworldVictoryProgress | null = null;
if (ecazContext) {
const context = ecazContext;
requireVictory(context.homeworldEcazVictoryPreview === true && context.turn === g.turn &&
context.advanced === g.advanced && context.storm === g.storm && context.players === g.players &&
context.order === g.order && context.mobileStronghold === g.mobileStronghold &&
context.discoveries === g.discoveries,
'The Ecaz Homeworld source must belong to this original victory board.');
try {
const row = ecazHomeworldVictoryProgress(context);
requireVictory(!row?.blocked || !row.high || !row.jointStrongholds.length,
row?.blocked ?? 'The Ecaz Homeworld victory source is unresolved.');
if (row?.qualifies) ecazHomeworld = row;
} catch (error) {
if (error instanceof VictoryProgressError) throw new VictoryQuoteError(error.message);
throw error;
}
}
let winner = [...g.winner];
const predicted = () =>
bg?.prediction?.turn === g.turn &&
winner.some((id) => player(id).faction === bg.prediction!.faction);
for (const row of progress)
if (row.qualifies) winner = [...new Set([...winner, ...row.members])];
if (ecazHomeworld) winner = [...new Set([...winner, ...ecazHomeworld.members])];
if (predicted()) winner = [bg!.id];
let status = g.status;
if (!winner.length && g.turn === 10) {
@@ -209,5 +231,5 @@ export function quoteVictory(g: VictoryContext): VictoryQuote {
throw new VictoryQuoteError((error as Error).message);
}
}
return { released, winner, status, strongholds };
return { released, winner, status, strongholds, ecazHomeworld };
}
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, RuleError, type Game } from '../game/engine';
import { quoteVictory, VictoryQuoteError } from '../game/victory-quote';
import { strongholdProgress } from '../game/victory-progress';
import { ecazHomeworldFixture, ecazSeat, observeEcazPosition } from './fixture-ecaz-homeworld-victory';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import { TERRITORIES } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function originalCollection(game: Game): Game {
game = normalizeAutomaticGame(game);
for (let step = 0; !(game.phase === 7 && !game.phaseOpening && !game.response && !game.decision) && step < 300; step++) {
if (game.phaseOpening) {
const player = game.players.find(player => !game.phaseOpening!.passed.includes(player.id))!;
game = applyAction(game, player.id, { type: 'ready' });
} else if (game.response) {
const player = game.players.find(player => !game.response!.passed.includes(player.id))!;
game = applyAction(game, player.id, { type: 'passResponse' });
} else if (game.decision?.kind === 'homeworldOccupiedIncome') {
const player = game.decision.player, offer = viewGame(game, player).homeworldOccupiedIncome!;
game = applyAction(game, player, { type: 'decision', event: offer.event, world: offer.world, ownAmount: offer.amount });
} else if (game.decision?.kind === 'guildTiming')
game = applyAction(game, game.decision.player, { type: 'decision', take: true });
else if (game.phase === 5 && game.active && !game.decision)
game = applyAction(game, game.active, { type: 'endMovement' });
else if (game.phase === 6 && !game.decision) {
const player = game.players.find(player => !game.ready.includes(player.id))!;
game = applyAction(game, player.id, { type: 'ready' });
} else throw new Error('The original Collection fixture requires another supported owned choice.');
}
assert.equal(game.phase, 7);
return game;
}
function originalMentat(game: Game) {
for (const player of game.players) if (!game.ready.includes(player.id))
game = applyAction(JSON.parse(JSON.stringify(game)), player.id, { type: 'ready' });
assert.equal(game.phase, 8);
return game;
}
void test('actual original Mentat awards the high Ecaz two-native-world alliance win with one joint stronghold and no ordinary point inflation', () => {
const fixture = ecazHomeworldFixture(true);
let game = originalCollection(fixture.game);
const ordinary = strongholdProgress(game).progress.find(row => row.player === 'ec')!;
assert.equal(ordinary.strongholds.length, 1); assert.equal(ordinary.jointlyOccupied.length, 1); assert.equal(ordinary.qualifies, false);
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
const row = viewGame(game, 'ec').ecazHomeworldVictory!;
assert.equal(row.population, 7); assert.equal(row.qualifies, true);
assert.deepEqual(new Set(row.distinctNativeFactions), new Set(['atreides', 'emperor']));
const cards = biddingPhysicalIds(game), forces = game.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, tanks: player.tanks }));
for (const difficulty of DIFFICULTIES) {
let result: Game = JSON.parse(JSON.stringify(game));
for (const player of result.players) {
const view = viewGame(result, player.id); view.players.find(seat => seat.id === player.id)!.bot = difficulty;
const ready = botActions(view).find(action => action.type === 'ready');
assert.ok(ready, `${difficulty} has its original legal Collection readiness`);
result = applyAction(result, player.id, ready);
}
assert.equal(result.status, 'finished');
assert.deepEqual(new Set(result.winner), new Set(['ec', 'al']));
assert.deepEqual(biddingPhysicalIds(result), cards);
}
game = originalMentat(game);
assert.equal(game.status, 'finished'); assert.deepEqual(new Set(game.winner), new Set(['ec', 'al']));
assert.deepEqual(biddingPhysicalIds(game), cards);
assert.deepEqual(game.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, tanks: player.tanks })), forces);
});
void test('native population6 does not award this win despite the same actual joint site and two occupied native factions', () => {
const fixture = ecazHomeworldFixture(true);
ecazSeat(fixture.game, 'ec').reserves--; ecazSeat(fixture.game, 'ec').tanks++;
observeEcazPosition(fixture.game, 'controlled-native-six-before-original-check');
let game = originalCollection(fixture.game);
assert.equal(viewGame(game, 'ec').ecazHomeworldVictory!.high, false);
game = originalMentat(game);
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
});
for (const predicted of ['ecaz', 'guild'] as const) void test(`actual BG prediction of ${predicted} replaces the printed Ecaz alliance win without changing public progress`, () => {
let game = originalCollection(ecazHomeworldFixture(true).game);
const progress = viewGame(game, 'ec').ecazHomeworldVictory!;
ecazSeat(game, 'observer').prediction = { faction: predicted, turn: game.turn };
assert.equal(viewGame(game, 'al').ecazHomeworldVictory!.qualifies, progress.qualifies);
game = originalMentat(game);
assert.deepEqual(game.winner, ['observer']); assert.equal(game.status, 'finished');
});
void test('the original winner quote unions a simultaneous ordinary side before applying private prediction', () => {
const fixture = ecazHomeworldFixture(true);
const game = originalCollection(fixture.game);
// Labelled surviving native counters: a different faction holds three ordinary sites.
const sites = TERRITORIES.filter(site => site.type === 'stronghold' &&
site.id !== fixture.jointStronghold && !site.sectors.includes(game.storm)).slice(0, 3)
.map(site => `${site.id}:${site.sectors[0]}`);
assert.equal(sites.length, 3);
const other = ecazSeat(game, 'observer');
assert.equal(other.faction, 'beneGesserit');
delete other.advisors; other.forces = Object.fromEntries(sites.map(site => [site, 1])); other.reserves -= 3;
observeEcazPosition(game, 'controlled-separate-ordinary-winner');
const quote = quoteVictory(game, game);
assert.deepEqual(new Set(quote.winner), new Set(['ec', 'al', 'observer']));
assert.equal(quote.ecazHomeworld?.qualifies, true);
});
void test('a wrong detached public Ecaz source cannot authorize an otherwise ordinary original victory quote', () => {
const game = originalCollection(ecazHomeworldFixture(true).game), changed = structuredClone(game);
changed.turn++;
const before = structuredClone(game);
assert.throws(() => quoteVictory(game, changed), VictoryQuoteError);
assert.deepEqual(game, before);
const markerless = structuredClone(game); delete markerless.homeworldEcazVictoryPreview;
assert.deepEqual(quoteVictory(markerless).winner, []);
assert.equal(viewGame(markerless, 'ec').ecazHomeworldVictory, null);
const malformed = structuredClone(game); delete malformed.homeworldOccupationHistory;
const frozen = structuredClone(malformed);
assert.throws(() => applyAction(malformed, 'ec', { type: 'ready' }), RuleError);
assert.deepEqual(malformed, frozen);
});
+257
View File
@@ -0,0 +1,257 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryProgress } from '../game/ecaz-homeworld-victory';
import { VictoryProgressError, strongholdProgress } from '../game/victory-progress';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { splitLocation } from '../game/board';
import { conserveEcazPosition, ecazHomeworldFixture, ecazSeat, observeEcazPosition } from './fixture-ecaz-homeworld-victory';
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} original audit setup and conserved split holdings satisfy the printed High Ecaz condition`, () => {
const { game, setup, movement, jointStronghold } = ecazHomeworldFixture(advanced);
assert.equal(setup.homeworldEcazVictoryPreview, true);
assert.equal(setup.homeworldOccupationPreview, true);
assert.equal(movement.phase, 5);
assert.equal(movement.response, null);
assert.equal(movement.decision, null);
assert.ok(!game.nexusCards);
assert.ok(!game.techTokens && !game.strongholdCards && !game.discoveryEnabled);
const before = JSON.stringify(game);
const row = ecazHomeworldVictoryProgress(game)!;
assert.deepEqual(row, {
owner: 'ec', members: game.order.filter(id => id === 'ec' || id === 'al'),
population: 7, minimum: 7, high: true, jointStrongholds: [jointStronghold],
foreignHomeworlds: [
{ world: 'homeworld:atreides', card: 'caladan', native: 'at', faction: 'atreides', holder: 'ec' },
{ world: 'homeworld:emperor', card: 'kaitain', native: 'em', faction: 'emperor', holder: 'al' },
],
distinctNativeFactions: ['atreides', 'emperor'], qualifies: true, blocked: null,
});
assert.equal(JSON.stringify(game), before);
assert.deepEqual(ecazHomeworldVictoryProgress(JSON.parse(before)), row);
assert.equal(strongholdProgress(game).progress.find(row => row.player === 'ec')!.qualifies, false);
homeworldGameIntegrity(game);
});
void test(`${advanced ? 'Advanced' : 'Basic'} six native Ecaz counters are Low; seven restores the independent printed win`, () => {
const { game } = ecazHomeworldFixture(advanced);
ecazSeat(game, 'ec').reserves = 6;
observeEcazPosition(game, 'controlled-native-ecaz-six');
const low = ecazHomeworldVictoryProgress(game)!;
assert.equal(low.population, 6);
assert.equal(low.high, false);
assert.equal(low.qualifies, false);
assert.equal(low.foreignHomeworlds.length, 2);
ecazSeat(game, 'ec').reserves = 7;
observeEcazPosition(game, 'controlled-native-ecaz-seven');
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
});
for (const holders of [['ec', 'ec'], ['al', 'al']] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} both distinct foreign worlds can be physically held by ${holders[0]}`, () => {
const { game } = ecazHomeworldFixture(advanced, 'guild', holders);
const row = ecazHomeworldVictoryProgress(game)!;
assert.deepEqual(row.foreignHomeworlds.map(world => world.holder), [...holders]);
assert.equal(row.qualifies, true);
});
void test(`${advanced ? 'Advanced' : 'Basic'} two worlds without one actual joint stronghold never qualify`, () => {
const { game } = ecazHomeworldFixture(advanced);
ecazSeat(game, 'al').forces = {};
conserveEcazPosition(game);
const row = ecazHomeworldVictoryProgress(game)!;
assert.deepEqual(row.jointStrongholds, []);
assert.equal(row.foreignHomeworlds.length, 2);
assert.equal(row.qualifies, false);
});
void test(`${advanced ? 'Advanced' : 'Basic'} a departed physical garrison is not a retained victory holding`, () => {
const { game } = ecazHomeworldFixture(advanced);
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
observeEcazPosition(game, 'controlled-caladan-departure');
const row = ecazHomeworldVictoryProgress(game)!;
assert.deepEqual(row.distinctNativeFactions, ['emperor']);
assert.equal(row.qualifies, false);
assert.equal(row.blocked, null);
});
void test(`${advanced ? 'Advanced' : 'Basic'} established source authority distinguishes native repopulation from physical foreign presence`, () => {
const { game } = ecazHomeworldFixture(advanced);
ecazSeat(game, 'at').reserves = 20;
observeEcazPosition(game, 'controlled-native-caladan-repopulation');
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.foreignHomeworlds.length, advanced ? 2 : 1);
assert.equal(row.blocked === null, advanced);
});
void test(`${advanced ? 'Advanced' : 'Basic'} third Homeworld visitors preserve Advanced retained authority but block unresolved Basic occupation`, () => {
const { game } = ecazHomeworldFixture(advanced);
game.homeworlds!.custody!.visitors['homeworld:atreides'].observer = { normal: 1, elite: 0 };
ecazSeat(game, 'observer').reserves--;
observeEcazPosition(game, 'controlled-third-homeworld-visitor');
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.blocked === null, advanced);
});
}
void test('Emperor Kaitain and Salusa are two physical worlds of only one other native faction', () => {
const { game } = ecazHomeworldFixture();
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
game.homeworlds!.custody!.visitors['homeworld:emperor:salusa'] = { ec: { normal: 1, elite: 0 } };
observeEcazPosition(game, 'controlled-two-emperor-worlds');
const row = ecazHomeworldVictoryProgress(game)!;
assert.deepEqual(row.foreignHomeworlds.map(world => world.card), ['kaitain', 'salusa_secundus']);
assert.deepEqual(row.distinctNativeFactions, ['emperor']);
assert.equal(row.qualifies, false);
assert.equal(row.blocked, null);
});
void test('own Ecaz and ally-native worlds are excluded rather than counted as other native factions', () => {
const { game } = ecazHomeworldFixture();
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
// Deliberately hypothetical public custody, not a legal alliance action:
// the real engine separately prohibits allies occupying each other's homes.
game.homeworlds!.custody!.visitors['homeworld:ecaz'] = { al: { normal: 1, elite: 0 } };
game.homeworlds!.custody!.visitors['homeworld:guild'] = { ec: { normal: 1, elite: 0 } };
const row = ecazHomeworldVictoryProgress(game)!;
assert.deepEqual(row.foreignHomeworlds.map(world => world.world), ['homeworld:emperor']);
assert.deepEqual(row.distinctNativeFactions, ['emperor']);
assert.equal(row.qualifies, false);
assert.equal(row.blocked, null);
});
void test('joint stronghold under storm still counts; third fighters contest it and unrelated advisors do not', () => {
const { game, jointStronghold, jointLocation } = ecazHomeworldFixture();
game.storm = splitLocation(jointLocation).sector; // Hypothetical public-board storm, not a clock transition.
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
ecazSeat(game, 'observer').forces = { [jointLocation]: 1 };
ecazSeat(game, 'observer').reserves--;
conserveEcazPosition(game);
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, []);
ecazSeat(game, 'observer').advisors = { [jointStronghold]: {} };
conserveEcazPosition(game);
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, [jointStronghold]);
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
});
void test('allied BG advisors are not a second joint fighter, and mandatory release cannot invent one', () => {
const { game, jointStronghold, jointLocation } = ecazHomeworldFixture(true, 'beneGesserit');
ecazSeat(game, 'al').advisors = { [jointStronghold]: {} };
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, false);
ecazSeat(game, 'ec').forces = {};
conserveEcazPosition(game);
assert.equal(strongholdProgress(game).released.length, 1);
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, []);
delete ecazSeat(game, 'al').advisors;
ecazSeat(game, 'ec').forces = { [jointLocation]: 1 };
conserveEcazPosition(game);
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
});
void test('a placed HMS is an actual joint stronghold, not an ordinary board pointer', () => {
const { game } = ecazHomeworldFixture(true, 'ixians');
game.mobileStronghold = { location: 'red_chasm:7' };
for (const id of ['ec', 'al']) ecazSeat(game, id).forces = { 'hidden_mobile_stronghold:0': 1 };
conserveEcazPosition(game);
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, ['hidden_mobile_stronghold']);
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
ecazSeat(game, 'al').forces = { 'red_chasm:7': 1 };
conserveEcazPosition(game);
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, []);
});
void test('public No-Field presence gives the same joint site for every concealed value without inventory reads', () => {
const { game, jointLocation } = ecazHomeworldFixture(true, 'richese');
ecazSeat(game, 'al').forces = {};
conserveEcazPosition(game);
let expected: EcazHomeworldVictoryProgress | null | undefined;
for (const id of ['field-zero', 'field-three', 'field-five']) {
const field = deployRicheseNoField(createRicheseNoField(['field-zero', 'field-three', 'field-five']), {
tokenId: id, controller: 'al', location: splitLocation(jointLocation),
});
ecazSeat(game, 'al').noField = field;
Object.defineProperty(field, 'tokens', { get() { throw new Error('Private inventory accessed'); } });
const row = ecazHomeworldVictoryProgress(game);
assert.equal(row!.qualifies, true);
if (expected) assert.deepEqual(row, expected);
expected = row;
}
});
void test('public projection does not read hands, leaders, spice, predictions or technology income', () => {
const { game } = ecazHomeworldFixture();
const expected = ecazHomeworldVictoryProgress(game)!;
for (const player of game.players)
for (const key of ['hand', 'leaders', 'spice', 'prediction'])
Object.defineProperty(player, key, { get() { throw new Error('Private player field accessed'); } });
Object.defineProperty(game, 'techTokens', { get() { throw new Error('Unneeded technology accessed'); } });
assert.deepEqual(ecazHomeworldVictoryProgress(game), expected);
});
void test('old contexts without the fresh marker return null without reserve, elite or Homeworld reads', () => {
const { game } = ecazHomeworldFixture();
delete game.homeworldEcazVictoryPreview;
for (const player of game.players)
for (const key of ['reserves', 'elites'])
Object.defineProperty(player, key, { get() { throw new Error('Old context reserve accessed'); } });
Object.defineProperty(game, 'homeworlds', { get() { throw new Error('Old context Homeworld accessed'); } });
assert.equal(ecazHomeworldVictoryProgress(game), null);
});
void test('missing or legacy source history blocks a potential two-native win without creating holdings', () => {
for (const legacy of [false, true]) {
const { game } = ecazHomeworldFixture();
if (legacy) delete game.homeworlds!.historyVersion;
else delete game.homeworldOccupationHistory;
const row = ecazHomeworldVictoryProgress(game)!;
assert.deepEqual(row.foreignHomeworlds, []);
assert.deepEqual(row.distinctNativeFactions, []);
assert.equal(row.qualifies, false);
assert.notEqual(row.blocked, null);
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
assert.equal(ecazHomeworldVictoryProgress(game)!.blocked, null);
}
});
void test('Basic expiry stays unresolved; Advanced retains occupation while its original holder remains', () => {
for (const advanced of [false, true]) {
const { game } = ecazHomeworldFixture(advanced);
game.turn++;
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, advanced);
assert.equal(row.blocked === null, advanced);
}
});
void test('a physical unobserved new sole garrison cannot manufacture Advanced source qualification', () => {
const { game, movement } = ecazHomeworldFixture();
game.homeworldOccupationHistory = movement.homeworldOccupationHistory;
const row = ecazHomeworldVictoryProgress(game)!;
assert.equal(row.qualifies, false);
assert.deepEqual(row.foreignHomeworlds, []);
assert.notEqual(row.blocked, null);
});
void test('nonexistent Homeworlds, malformed public data and altered JSON source provenance reject with the victory error class', () => {
const { game } = ecazHomeworldFixture();
const cases = [
(g: typeof game) => { g.homeworlds!.custody!.visitors['homeworld:nonexistent'] = { ec: { normal: 1, elite: 0 } }; },
(g: typeof game) => { g.homeworlds!.custody!.visitors['homeworld:ecaz'] = { ec: { normal: 1, elite: 0 } }; },
(g: typeof game) => { ecazSeat(g, 'al').ally = null; },
(g: typeof game) => { ecazSeat(g, 'ec').reserves = -1; },
(g: typeof game) => { g.turn = 0; },
(g: typeof game) => { ecazSeat(g, 'ec').faction = 'harkonnen'; },
(g: typeof game) => { g.homeworldOccupationHistory!.sources[1].cause = 'setup'; },
(g: typeof game) => { g.homeworldOccupationHistory!.qualifications[0].player = 'observer'; },
(g: typeof game) => { g.homeworldOccupationHistory!.snapshots[1] = '[]'; },
(g: typeof game) => { delete g.homeworldOccupationPreview; },
];
for (const mutate of cases) {
const restored: typeof game = JSON.parse(JSON.stringify(game));
mutate(restored);
assert.throws(() => ecazHomeworldVictoryProgress(restored), VictoryProgressError);
}
});
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer, type Game } from '../game/engine';
import { FACTIONS, type FactionId } from '../game/catalog';
import { TERRITORIES } from '../game/board';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
import { advanceOriginal } from './fixture-homeworld-occupied-producers';
export const ecazSeat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
/** Controlled positions are conserved test placements, NOT naturally achieved
* games. Setup and source history are original; no synthetic source facts,
* replacement deck, optional cards or fabricated occupation receipt is used. */
export function conserveEcazPosition(game: Game): void {
const groups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
for (const player of game.players) {
const abroad = groups.filter(home => home.native !== player.id)
.reduce((n, home) => n + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0);
player.tanks = 20 - player.reserves - abroad - Object.values(player.forces).reduce((a, n) => a + n, 0);
if (player.elites) {
const total = player.faction === 'fremen' ? 3 : player.faction === 'ixians' ? 7 : 5;
const abroadElite = groups.filter(home => home.native !== player.id)
.reduce((n, home) => n + (home.forces[player.id]?.elite ?? 0), 0);
player.elites.tanks = total - player.elites.reserves - abroadElite - Object.values(player.elites.forces).reduce((a, n) => a + n, 0);
}
}
homeworldGameIntegrity(game);
}
export function observeEcazPosition(game: Game, event: string, cause: 'change' | 'turnStart' | 'turnEnd' = 'change'): void {
conserveEcazPosition(game);
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
homeworldContext(game), game.homeworlds!.custody!, game.turn, cause, event);
}
export function ecazHomeworldFixture(advanced = true, ally: FactionId = 'guild', holders: readonly ['ec' | 'al', 'ec' | 'al'] = ['ec', 'al']) {
const factions = ['ecaz', ally, 'atreides', 'emperor', ...(ally === 'beneGesserit' ? ['guild'] : ['beneGesserit'])] as FactionId[];
const expansions = [...new Set(factions.map(faction => FACTIONS.find(row => row.id === faction)!.expansion)
.filter(expansion => expansion !== 'base'))];
let game = createGame('ECAZHOMEWORLDVICTORY', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, expansions);
for (const [index, faction] of factions.slice(1).entries())
joinGame(game, newPlayer(['al', 'at', 'em', 'observer'][index], faction, faction));
game = applyAction(game, 'ec', { type: 'homeworlds', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeHomeworldOccupationGameForAudit(game);
game = advanceOriginal(game, current => current.status === 'playing');
const setup = structuredClone(game);
game = advanceOriginal(game, current => current.phase === 5 && !current.response && !current.decision);
const movement = structuredClone(game);
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
const originalTanks = Object.fromEntries(game.players.map(player => [player.id, {
normal: player.tanks, elite: player.elites?.tanks ?? 0,
}]));
for (const player of game.players) {
Object.assign(player, { forces: {}, reserves: 20 - player.tanks, ally: null });
delete player.advisors;
if (player.elites) Object.assign(player.elites, {
reserves: (player.faction === 'ixians' ? 7 : player.faction === 'fremen' ? 3 : 5) - player.elites.tanks,
forces: {},
});
}
const joint = TERRITORIES.find(site => site.type === 'stronghold' && !site.sectors.includes(game.storm))!;
assert.ok(joint, 'Original Movement must have a non-storm joint stronghold.');
const jointStronghold = joint.id;
const jointLocation = `${joint.id}:${joint.sectors[0]}`;
const custody = game.homeworlds!.custody!;
custody.visitors = {
'homeworld:atreides': { [holders[0]]: { normal: 1, elite: 0 } },
'homeworld:emperor': { [holders[1]]: { normal: 1, elite: 0 } },
};
if (advanced) custody.salusa = { normal: 0, elite: 0 };
ecazSeat(game, 'ec').ally = 'al';
ecazSeat(game, 'al').ally = 'ec';
ecazSeat(game, 'ec').forces = { [jointLocation]: 1 };
ecazSeat(game, 'al').forces = { [jointLocation]: 1 };
ecazSeat(game, 'ec').reserves = 7;
ecazSeat(game, 'al').reserves = 20 - originalTanks.al.normal - 1 - holders.filter(holder => holder === 'al').length;
for (const id of ['at', 'em']) {
ecazSeat(game, id).reserves = 0;
if (ecazSeat(game, id).elites) ecazSeat(game, id).elites!.reserves = 0;
}
observeEcazPosition(game, 'controlled-conserved-ecaz-two-native-worlds');
for (const player of game.players) {
assert.ok(player.tanks >= originalTanks[player.id].normal, 'Controlled position cannot resurrect original Tanks.');
assert.ok((player.elites?.tanks ?? 0) >= originalTanks[player.id].elite, 'Controlled position cannot resurrect original special Tanks.');
}
assert.deepEqual([...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards);
return { game, setup, movement, cards, jointStronghold, jointLocation };
}
+1
View File
@@ -39,6 +39,7 @@ async function main() {
'\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.',
'\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.',
'\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.',
'\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.',
);
return;
}