Connect native Discovery setup and nested force paths
This commit is contained in:
1 parent
8d17fab3de
commit
67ce690064
17 files changed
+1379
-39
No files matched your search
@@ -946,7 +946,9 @@ export function GameTable({
|
||||
{s === 0
|
||||
? selected === MOBILE_STRONGHOLD
|
||||
? 'Inside stronghold'
|
||||
: 'Polar sink'
|
||||
: selected === 'polar_sink'
|
||||
? 'Polar sink'
|
||||
: 'Inside location · sector 0'
|
||||
: s}
|
||||
{s === g.storm ? ' · Storm' : ''}
|
||||
</option>
|
||||
|
||||
@@ -17,6 +17,39 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Native Discovery / optional original Tech — bounded Development Verified:**
|
||||
fresh classic, selected E1/E2 or standalone Ecaz OR Moritani Basic/Advanced
|
||||
profiles retain original setup, decks/cache, seven Discovery Spice Cards and
|
||||
eight tokens. Tech requires three through six seats. Native Cyborg/starred entry,
|
||||
paid nested No-Fields, CHOAM stash capacity and Face Dance rewards reuse original
|
||||
controls and minimal legal policies. Moritani's live profile and assassination
|
||||
history now retain actual revealed nested sites. Public starts, other overlays,
|
||||
paired/mixed E3 and contested/shared/mixed rulings remain separate.
|
||||
|
||||
The frozen affected union passes **71 cases**, types/lint and build. Real
|
||||
programs prove Ixian three-counter/one-Cyborg free entry, one-spice Richese marker
|
||||
and reserve materialization, and original Ecaz/Moritani setup. A reproduced
|
||||
nested Moritani battle now reaches its actual different-Leader Traitor choice
|
||||
and printed three-spice bounty.
|
||||
|
||||
Four original six-seat Advanced Tech games finish; the preserved Moritani
|
||||
setup then finishes after the profile correction at original seed20263765.
|
||||
Combined completed paths: **1,940 accepted / 1,940 attempts / 49 JSON**.
|
||||
The initial and corrected trees differ; this is not a five-game frozen-tree
|
||||
certification or evidence that every new effect triggered naturally.
|
||||
|
||||
Actual **Q3AWYZWC** phone Collection inspection/reveal reaches v8/v9. Original
|
||||
continuation reaches turn-two Richese movement v10. Human Cistern marker5
|
||||
declaration and Guild Allow shipment reach v12: spice5→4, reserves20, effective1.
|
||||
Human reveal reaches **v13**, reserves15/five actual Cistern counters and no
|
||||
second price; refresh retains v13 and fits390px. The isolated QA worker was
|
||||
reloaded after an old-validator rejection; nested sector zero now says inside
|
||||
the location, not Polar Sink. Tab released; other games were not written.
|
||||
[Source, programs and limits](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026).
|
||||
No reset, public activation, save conversion or deployed claim. Complete
|
||||
Discovery combinations, assurance and protected NAS deployment remain open.
|
||||
|
||||
|
||||
**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
|
||||
|
||||
@@ -13,14 +13,20 @@ scan used for all six placements. Its verified inventory is seven Spice Cards
|
||||
(six Discovery territory cards and Great Maker) and eight tokens. This prototype
|
||||
uses those identities and effects; it does not treat test outcomes as rulings.
|
||||
|
||||
`initializeDiscoveryGameForAudit` enters the real staged setup with two through
|
||||
six ready base factions, in Basic or Advanced, with Discoveries enabled before
|
||||
components are dealt. It adds the seven cards to the ordinary Spice Deck and
|
||||
creates one of each token behind shuffled, opaque physical identities. It rejects
|
||||
redealing a started or nonempty game. Other expansion decks and optional modules
|
||||
are outside this initializer. No ordinary player action or room endpoint can
|
||||
bypass the normal start gate through it. The existing backed-up local starter
|
||||
accepts `--profile discovery` for a fresh ready base-faction lobby:
|
||||
`initializeDiscoveryGameForAudit` enters real staged setup with two through six
|
||||
ready seats in Basic or Advanced. Fresh classic, selected E1/E2 native families,
|
||||
and standalone Ecaz **or** Moritani with classic opponents use their original
|
||||
decks and native setup. Canonical unused Tech Tokens may be preserved at three
|
||||
through six seats. Advanced Moritani retains its non-Harkonnen assassination
|
||||
profile; paired/mixed E3, Skills, Strongholds, Homeworlds, Nexus and unrelated
|
||||
previews stay outside this entry.
|
||||
|
||||
Discoveries are enabled before components are dealt. The initializer adds the
|
||||
seven cards to the selected original Spice Deck, retaining Ix Sandtrout, and
|
||||
creates each token behind shuffled opaque physical identities. Started games,
|
||||
nonempty native inventories and already-used Tech Tokens cannot be redealt.
|
||||
No ordinary player action or room endpoint bypasses the normal start gate.
|
||||
The existing local starter accepts `--profile discovery` for these fresh lobbies:
|
||||
|
||||
```sh
|
||||
node --import tsx tools/start-prototype.ts --profile discovery --db PATH --room CODE --version NUMBER --out /private/new-directory
|
||||
@@ -29,6 +35,89 @@ node --import tsx tools/start-prototype.ts --profile discovery --db PATH --room
|
||||
It preserves all seats and other rooms, requires the current version and refuses
|
||||
to redeal a started game. The default `ix` profile remains available.
|
||||
|
||||
## Native setup and original consumers — 5 October 2026
|
||||
|
||||
Original Ixian setup keeps the six-counter HMS garrison and seven physical
|
||||
Cyborgs; Tleilaxu keeps its actual private Face Dancers. Next-turn free entry
|
||||
quotes physical normal/elite groups, including Cyborgs and starred Fremen,
|
||||
separately from the HMS and concealed marker presence. Paid shipment, movement,
|
||||
stash and Face Dance retain original force, payment and winner-reward handlers.
|
||||
CHOAM's real five-card hand may draw a sixth from Card Stash before its owned
|
||||
choice discards any held card back to five.
|
||||
|
||||
No-Field custody now recognizes the five known nested Discovery IDs at sector
|
||||
zero. This does not open an unrevealed location: declaration/commitment and
|
||||
materialization require the current revealed board. A concealed token is still
|
||||
one effective presence, not physical reserves or a source for free entry.
|
||||
Its original value-zero/three/five reveal materializes only current reserve
|
||||
forces, once. The shipment controls label nested sector zero as inside the
|
||||
location rather than Polar Sink.
|
||||
|
||||
Standalone Advanced Moritani retains its original assassination state in the
|
||||
Discovery profile. An actual nested-site battle retains that revealed location
|
||||
in the original opportunity/history, with the same different-opposing-leader
|
||||
restriction, physical Traitor cost and printed bounty. Basic is not converted
|
||||
to the Advanced ability. Ecaz retains its existing Occupy preview and Basic
|
||||
odd-force boundary.
|
||||
|
||||
The existing genuine game runner now selects `--profile discovery` or
|
||||
`--profile discovery-tech`, with `--rules both|basic|advanced` and
|
||||
`--players all|2|3|4|5|6`; Tech requires at least three seats. These profiles use
|
||||
classic, paired E1/E2 and standalone E3 original sample rosters with new
|
||||
2700/2800 ordinal bands. Earlier profiles, defaults and seeds are unchanged.
|
||||
|
||||
```sh
|
||||
node --import tsx tools/faction-games.ts --profile discovery-tech --rules advanced --players 6 --out /private/new-directory
|
||||
```
|
||||
|
||||
Focused native cases are in `tests/discovery-module-profile.test.ts`,
|
||||
`tests/discovery-native-typed-runtime.test.ts`,
|
||||
`tests/discovery-native-marker-runtime.test.ts` and
|
||||
`tests/discovery-native-e3-runtime.test.ts`. Fixtures explicitly label controlled
|
||||
unplayed Spice order, placement lottery and conserved counter/card/identity
|
||||
positions; clocks, choices and outcomes use original producers. This does not
|
||||
resolve contested Cistern/Testing Station/Orgiz, shared-payer theft, mixed-dial
|
||||
Jacurutu, complete module strategy or public-start/deployed acceptance.
|
||||
|
||||
### Bounded runtime evidence
|
||||
|
||||
The frozen affected rule union passes **71 tests**; final reference checks add
|
||||
**10 passing cases**. TypeScript/lint and the actual rule build pass. Original
|
||||
programs prove Ixian free entry of three physical
|
||||
counters, including one Cyborg, without a wallet/reserve debit; Richese pays one
|
||||
spice for a Cistern marker and later materializes three actual reserves. Fresh
|
||||
Ecaz and Moritani original setup both complete with eight tokens and all four
|
||||
minimal legal policies. The actual nested Moritani battle exposed and repaired
|
||||
the static-territory history rejection; its different Guild Traitor pays the
|
||||
printed three-spice bounty.
|
||||
|
||||
Four genuine six-seat Advanced Tech samples finish on initial tree
|
||||
`6d6c1849d6da9b1e5c2af04e07303f9bec49ec9dac2d8089ae58c77795f48e57`.
|
||||
The original Moritani setup capture then finishes at effective seed **20263765**
|
||||
on corrected tree
|
||||
`fe22922e7acdea8aea64b75af8ba29247e37b63a63f461efa930f1a8cd2c049c`.
|
||||
Across those completed paths: **1,940 accepted / 1,940 attempted actions and 49
|
||||
JSON continuations**. This is a repaired/resumed study, not five games on one
|
||||
unchanged tree or natural activation of every new effect.
|
||||
|
||||
Dedicated actual QA room **Q3AWYZWC** retains its original ready lobby, actor
|
||||
identities and private setup. Original 45-action program stops at Collection;
|
||||
only four unplayed Spice Card ordering and the token lottery are controlled.
|
||||
Guild's phone inspection/reveal reaches **v8/v9**. Original 44-action
|
||||
continuation reaches turn-two Richese movement at **v10**. Its real human
|
||||
five-value marker declaration reaches **v11**; original Guild **Allow shipment**
|
||||
settles **v12**, spice **5→4**, reserves **20** and one concealed Cistern presence.
|
||||
Human reveal reaches **v13**, reserves **20→15**, five Cistern counters and no
|
||||
second price. Refresh retains v13 and the phone fits **390px**.
|
||||
|
||||
The first UI attempt reached the old running QA worker's static No-Field
|
||||
validator and rejected without mutation. Reloading only that isolated worker
|
||||
loaded the corrected build. The fresh sector selector now says **Inside location
|
||||
· sector 0**, not Polar Sink. The managed tab was released. Other games were
|
||||
not written; no reset, public activation, save conversion or deployed claim.
|
||||
|
||||
|
||||
|
||||
## Connected behavior
|
||||
|
||||
- **Great Maker:** ordinary worm consequences resolve first. Every faction then
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 5 October 2026 — native Discovery with original Tech
|
||||
|
||||
Fresh classic, selected E1/E2 and standalone Ecaz OR Moritani Discovery entry
|
||||
preserves original decks/native setup and optional untouched Tech at3–6 seats.
|
||||
Typed next-turn entry, paid nested marker shipment/movement/reveal, CHOAM stash
|
||||
5→6→5 and native Face Dance rewards remain original consumers. Pure No-Field
|
||||
custody recognizes the five known nested IDs at0; live commitment/reveal rejects
|
||||
unknown or unrevealed destinations. No effective-marker reserve allocation.
|
||||
|
||||
Actual runtime exposed two Moritani integration defects: the live assassination
|
||||
profile rejected Discovery, and its history accepted only static territories.
|
||||
Both are repaired without disabling the printed different-opposing-Leader
|
||||
restriction or normal-call forfeiture. The nested-source regression pays the
|
||||
actual three-spice Guild Traitor bounty.
|
||||
|
||||
Frozen source/consumer union **71/71**, TypeScript/lint and build pass. Original
|
||||
programs prove three actual Ixian counters/one Cyborg with no entry fee, one-spice
|
||||
Richese nested marker and one physical reveal, and both original E3 setups with
|
||||
four minimal legal policies.
|
||||
|
||||
Four genuine six-seat Advanced Tech games complete on initial tree
|
||||
`6d6c1849d6da9b1e5c2af04e07303f9bec49ec9dac2d8089ae58c77795f48e57`;
|
||||
preserved Moritani setup completes at seed20263765 on corrected tree
|
||||
`fe22922e7acdea8aea64b75af8ba29247e37b63a63f461efa930f1a8cd2c049c`.
|
||||
Completed totals **1,940 accepted / 1,940 attempts / 49 JSON**; no single-tree
|
||||
five-game or natural-every-effect claim.
|
||||
|
||||
Actual Q3AWYZWC v6 lobby keeps authenticated actors/decks. Original45 controls
|
||||
plus labelled unplayed Spice ordering/placement reach Collection. Human Guild
|
||||
inspection/reveal v8/v9 and original44-action continuation reach Richese v10.
|
||||
Human marker5 + original Guild Allow shipment settle v12 for1spice, reserves20
|
||||
and effective1; human reveal v13 produces five Cistern counters/reserves15 with
|
||||
no second price. Refresh fits390px. Reload only isolated QA after observed old
|
||||
No-Field validator rejection; actual selector now labels nested sector0 inside
|
||||
the location instead of Polar Sink. Managed tab released; no unrelated room write.
|
||||
|
||||
[Canonical source/runtime evidence](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026).
|
||||
Contested/shared/mixed rulings, unrelated overlays, normal public starts, complete
|
||||
module strategy, assurance and deployed acceptance remain open. No reset or
|
||||
played-game conversion.
|
||||
|
||||
## 5 October 2026 — printed high Ecaz Homeworld alliance victory
|
||||
|
||||
Fresh native Ecaz/Homeworld audit entry marks the actual card conjunction:
|
||||
|
||||
@@ -236,7 +236,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Paired Nexus / Skills / Tech / Strongholds | Both Ixians+Tleilaxu OR CHOAM+Richese plus classics and one original family deck/all14/all12 preserve selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced through the original fresh skill/Nexus entry. Typed rescue precedes equal actual Cyborg Tank substitution, original card cleanup/mandatory winner Tech precedes Face Dance; held subsidy changes debit, not committed support, and reward/custody stay original winner. Signed marker pair/one invoice/native industry retains original timing. Bounded runtime/program/phone/genuine-game evidence does not certify full modules; mixed families/E3/HW/previews/companions/native Nexus free-return rulings/public start/save conversion stay guarded. | [Paired combined contract and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026) |
|
||||
| Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-composition--4-october-2026) |
|
||||
| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) |
|
||||
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
|
||||
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Fresh classic, selected E1/E2 or standalone Ecaz OR Moritani profiles may retain original Tech3..6. Native setup, physical typed entry, revealed-only nested No-Field custody/materialization, CHOAM stash capacity and original Moritani assassination use existing source handlers. Revealed locations are separate; only Jacurutu counts toward stronghold victory. Existing bounded Cistern/Orgiz/Ornithopter/Jacurutu/Testing Station/Shrine paths remain prototypes; contested/shared/mixed cases and public starts are not resolved. | [Component authority](DISCOVERY_COMPONENTS.md), [native source composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026) |
|
||||
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
|
||||
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
|
||||
| Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import type { FactionModuleProfile } from './faction-module-profile';
|
||||
|
||||
/** Public unused-lobby admission only. The original initializer owns physical
|
||||
* decks, tokens, leader/force inventories and all mandatory setup choices. */
|
||||
export function discoveryModuleProfile(game: FactionModuleProfile): boolean {
|
||||
return !game.discoveries && !game.discoveryStash && !game.greatMaker &&
|
||||
discoveryModeSupported(game);
|
||||
}
|
||||
|
||||
/** The same roster/module contract during play; live Discovery frames are expected. */
|
||||
export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
game.players.length < 2 || game.players.length > 6 ||
|
||||
(game.techTokens && game.players.length < 3) || game.expansions.length > 2 ||
|
||||
game.strongholdCards || game.leaderSkills || game.homeworlds || game.nexusCards ||
|
||||
game.ecazTreachery ||
|
||||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview)
|
||||
return false;
|
||||
const e3 = game.expansions.length === 1 && game.expansions[0] === 'ecaz';
|
||||
for (let i = 0; i < game.expansions.length; i++) {
|
||||
const expansion = game.expansions[i];
|
||||
if (e3 ? expansion !== 'ecaz' : expansion !== 'ix' && expansion !== 'choam') return false;
|
||||
for (let j = 0; j < i; j++) if (game.expansions[j] === expansion) return false;
|
||||
}
|
||||
let native = 0, moritani = false, harkonnen = false;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (game.players[j].faction === player.faction) return false;
|
||||
let expansion: string | undefined;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction) { expansion = faction.expansion; break; }
|
||||
if (expansion === undefined) return false;
|
||||
if (expansion !== 'base') {
|
||||
if (e3 ? player.faction !== 'ecaz' && player.faction !== 'moritani'
|
||||
: !game.expansions.includes(expansion)) return false;
|
||||
native++;
|
||||
}
|
||||
if (player.faction === 'moritani') moritani = true;
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return (!e3 || native === 1) && !(game.advanced && moritani && harkonnen);
|
||||
}
|
||||
+27
-8
@@ -1,5 +1,6 @@
|
||||
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
|
||||
import { nativeFactionTechProfile } from './faction-module-profile';
|
||||
import { discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
||||
@@ -3827,6 +3828,8 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
|
||||
p.faction === 'richese' && p.noField?.deployed,
|
||||
'No concealed No-Field is deployed.',
|
||||
);
|
||||
requireRule(validGameLocation(g, p.noField!.deployed!.location.territory, p.noField!.deployed!.location.sector),
|
||||
'A No-Field can reveal only in a current board location.');
|
||||
const result = noFieldRule(() =>
|
||||
revealRicheseNoField(p.noField!, {
|
||||
tokenId: p.noField!.deployed!.tokenId,
|
||||
@@ -7607,6 +7610,9 @@ function e3StrongholdFactionProfile(g: Game): boolean {
|
||||
return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen'));
|
||||
}
|
||||
function moritaniAssassinateModeSupported(g: Game) {
|
||||
if (g.discoveryEnabled || g.discoveries)
|
||||
return g.advanced && !!g.discoveries && discoveryModeSupported(g) &&
|
||||
g.players.some(player => player.faction === 'moritani');
|
||||
return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
|
||||
g.players.some(p => p.faction === 'moritani') &&
|
||||
g.players.every(p => p.faction === 'moritani' || (p.faction !== 'harkonnen' && CLASSIC_FACTIONS[p.faction] === true)) &&
|
||||
@@ -7642,7 +7648,8 @@ function moritaniAssassinateIntegrity(g: Game) {
|
||||
for (const receipt of state.opportunities) {
|
||||
const opponent = getPlayer(g,receipt.opponent);
|
||||
requireRule(receipt.turn <= g.turn && opponent.faction === receipt.faction &&
|
||||
receipt.owner === state.owner && TERRITORIES.some(t => t.id === receipt.territory),
|
||||
receipt.owner === state.owner && (TERRITORIES.some(t => t.id === receipt.territory) ||
|
||||
validGameLocation(g, receipt.territory, 0)),
|
||||
'The assassination history no longer matches its original battle participants.');
|
||||
}
|
||||
const pending = state.opportunities.filter(r => r.stage === 'skills' || r.stage === 'choice');
|
||||
@@ -9496,9 +9503,13 @@ export function initializeNexusKullGameForAudit(state: Game): Game {
|
||||
}
|
||||
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
|
||||
export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
|
||||
'Enable Discoveries in a fresh audit lobby first.');
|
||||
return initializeSetupGameForAudit(state, false, false, false, true);
|
||||
requireRule(discoveryModuleProfile(state), 'Discovery requires a fresh supported classic, E1/E2 or standalone E3 lobby without unrelated overlays.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
const g = initializeSetupGameForAudit(state, false, false, false, true, false, false, state.expansions.length > 0);
|
||||
if (byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
|
||||
if (g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g);
|
||||
return g;
|
||||
}
|
||||
/** Gated development setup; never dispatched by a player action or room route. */
|
||||
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
@@ -9544,9 +9555,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g);
|
||||
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold);
|
||||
const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g);
|
||||
const discoveryComposition = discovery && discoveryModuleProfile(g);
|
||||
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
|
||||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
|
||||
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules) &&
|
||||
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
|
||||
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
|
||||
'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.');
|
||||
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || nexusModules) &&
|
||||
@@ -9560,7 +9572,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
(discovery || !g.discoveryEnabled) &&
|
||||
(nexus || !g.nexusCards) &&
|
||||
(ecazTreachery || !g.ecazTreachery) &&
|
||||
(!g.techTokens || skillTech || nativeTech || nexusModules) &&
|
||||
(!g.techTokens || skillTech || nativeTech || nexusModules || discoveryComposition) &&
|
||||
(strongholdFactions || skillStronghold || nexusModules || !g.strongholdCards) &&
|
||||
(homeworlds || !g.homeworlds) &&
|
||||
g.players.every((p) =>
|
||||
@@ -9580,6 +9592,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.'
|
||||
: homeworlds
|
||||
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
|
||||
: discoveryComposition
|
||||
? 'Discovery supports fresh classic, selected E1/E2 or standalone E3 factions with optional original Tech and no other overlays.'
|
||||
: 'The audit initializer supports base factions without expansions or optional modules.',
|
||||
);
|
||||
initializeSetup(g, strongholdFactions || !!g.strongholdCards);
|
||||
@@ -24719,8 +24733,13 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
||||
validateAmbassadorShipmentOrder(g, shipment);
|
||||
return;
|
||||
}
|
||||
// Concealed and allied No-Fields retain their separate custody/quote contracts.
|
||||
if (shipment.noField || shipment.alliedNoField) return;
|
||||
// A marker's effective one-force declaration is not a reserve withdrawal.
|
||||
// Its separate custody contract still requires a currently revealed destination.
|
||||
if (shipment.noField || shipment.alliedNoField) {
|
||||
requireRule(validGameLocation(g, shipment.territory, shipment.sector),
|
||||
'A No-Field shipment requires a current board destination.');
|
||||
return;
|
||||
}
|
||||
const p = getPlayer(g, shipment.player);
|
||||
requireRule(
|
||||
g.status === 'playing' &&
|
||||
|
||||
+6
-5
@@ -4079,13 +4079,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, bounded Orgiz transfers, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Remaining interpretations and normal public starts stay gated.',
|
||||
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
|
||||
steps: [
|
||||
'The Discovery prototype starts through genuine base-faction setup in Basic or Advanced, with seven extra Spice Cards and eight physical tokens. Its normal public start gate stays closed while the remaining effects are connected and verified.',
|
||||
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
|
||||
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
|
||||
'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.',
|
||||
'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.',
|
||||
'Spice Stash grants seven bank spice and leaves play. Treachery Card Stash draws one private Treachery Card and leaves play; if the new hand is too large, choose any held card to discard, including the newly drawn card. The owned discard panel includes a card inspector.',
|
||||
'Revealing Ornithopter records who carries it and the turn it was gained. On a later turn, its owner may spend it on one ordinary movement action to give that selected group a fixed range of three. It grants no extra action, adds no shipment and is removed only after the movement commits; saved arrival interactions retain the exact selected group.',
|
||||
'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.',
|
||||
'Native typed forces enter as their actual ordinary and elite groups, never as passengers from the mobile stronghold or the concealed value of a No-Field. Richese may ship or move a concealed marker into an actually revealed nested location at sector zero; it remains one effective presence until its original reveal materializes available reserves. CHOAM Card Stash uses its five-card limit, drawing first and choosing any held discard afterward.',
|
||||
'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.',
|
||||
'A sole occupant of Cistern receives two bank spice during Collection; contested occupancy withholds that bonus without blocking the phase. A sole Orgiz occupant takes one spice from each rival who collected a positive board deposit, using each collected pile as one observable blow. This is a provisional interpretation of the printed blow wording. Contested Orgiz occupancy and unresolved Ecaz shared lots withhold only the uncertain transfer while ordinary collection continues.',
|
||||
'Jacurutu automatically pays one bank spice for each opposing undialed physical force sent to the Tanks. Where Advanced normal/elite allocations would produce different rewards, this prototype records the unpaid gap pending a ruling; it never guesses from the numeric wheel alone.',
|
||||
@@ -4097,8 +4098,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
checklist: [
|
||||
{
|
||||
area: 'Implementation', status: 'Partial',
|
||||
detail: 'Working prototypes cover genuine setup, physical inventory, destructive blows, Great Maker vote/ride, private inspection/reveal, stash rewards, public nested locations, signed next-turn entry, sole-occupant Cistern income, bounded Orgiz transfers and later-turn Ornithopter spending, unambiguous Jacurutu income, sole-occupant Testing Station and Shrine card roles. Contested and mixed interpretations remain pending.',
|
||||
evidence: ['game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
|
||||
detail: 'Working prototypes cover original classic/E1/E2/standalone E3 setup with optional original Tech, native typed free entry, paid nested No-Field shipment/movement/reveal, CHOAM stash capacity, native Face Dance rewards and Moritani nested-site assassination. Existing destructive blows, Great Maker, Cistern, bounded Orgiz, Ornithopter, unambiguous Jacurutu, sole Testing Station and Shrine remain. Contested and mixed interpretations are pending.',
|
||||
evidence: ['game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Player controls', status: 'Partial',
|
||||
@@ -4117,8 +4118,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
area: 'Verification', status: 'Partial',
|
||||
detail: 'Focused offline checks cover physical custody, private views, legal human/AI actions, immutable rejection, board admission, signed free entry, Cistern collection, carried flight, station movement, Jacurutu income, Shrine card custody and saved continuation. Broad checkpoint results and complete module/browser/multiplayer acceptance remain separate gates.',
|
||||
evidence: ['tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
|
||||
detail: 'Focused native runtime cases and actual phone controls cover original setup, typed entry, No-Field nested admission/materialization, stash capacity, Face Dance reward order and original Moritani assassination. Five genuine six-seat Advanced Tech samples finish after repairing and resuming the preserved Moritani profile. Specific changed effects use controlled source programs, not a claim that every sample naturally triggers them. Full Discovery, assurance and deployed acceptance remain open.',
|
||||
evidence: ['tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MOBILE_STRONGHOLD } from './board';
|
||||
import { isDiscoveryLocationId } from './discoveries';
|
||||
|
||||
export type NoFieldValue = 0 | 3 | 5;
|
||||
export type NoFieldLocation = { territory: string; sector: number };
|
||||
@@ -61,7 +62,8 @@ function validateLocation(location: NoFieldLocation) {
|
||||
(location.sector >= 1 ||
|
||||
(location.sector === 0 &&
|
||||
(location.territory === 'polar_sink' ||
|
||||
location.territory === MOBILE_STRONGHOLD))) &&
|
||||
location.territory === MOBILE_STRONGHOLD ||
|
||||
isDiscoveryLocationId(location.territory)))) &&
|
||||
location.sector <= 18,
|
||||
'Choose a valid planet territory and sector for the No-Field.',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
|
||||
function lobby(roster: FactionId[], expansions: string[], advanced = true): Game {
|
||||
const game = createGame('DISCINIT', newPlayer(roster[0], roster[0], roster[0]), advanced, expansions);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (const player of game.players) player.ready = true;
|
||||
game.discoveryEnabled = true;
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Discovery requires the selected native family and does not admit paired or mixed E3', () => {
|
||||
const unsupported = [
|
||||
lobby(['ixians', 'guild'], []),
|
||||
lobby(['richese', 'guild'], ['ix']),
|
||||
lobby(['ecaz', 'moritani', 'guild'], ['ecaz']),
|
||||
lobby(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix']),
|
||||
lobby(['moritani', 'harkonnen', 'guild'], ['ecaz']),
|
||||
];
|
||||
for (const game of unsupported) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
|
||||
// The Advanced Moritani/Harkonnen restriction is not a new Basic restriction.
|
||||
const basic = initializeDiscoveryGameForAudit(lobby(['moritani', 'harkonnen', 'guild'], ['ecaz'], false));
|
||||
assert.equal(basic.moritaniAssassinatePreview, undefined);
|
||||
assert.equal(basic.discoveries?.tokens.length, 8);
|
||||
});
|
||||
|
||||
void test('Discovery Tech requires three seats and untouched physical tokens', () => {
|
||||
const two = lobby(['ixians', 'tleilaxu'], ['ix']); two.techTokens = createTechTokens();
|
||||
assert.throws(() => initializeDiscoveryGameForAudit(two), RuleError);
|
||||
const used = lobby(['choam', 'richese', 'guild'], ['choam']); used.techTokens = createTechTokens();
|
||||
used.techTokens.production.owner = 'choam';
|
||||
assert.throws(() => initializeDiscoveryGameForAudit(used), RuleError);
|
||||
const fresh = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']); fresh.techTokens = createTechTokens();
|
||||
const initialized = initializeDiscoveryGameForAudit(fresh);
|
||||
assert.equal(initialized.techTokens?.heighliners.owner, 'ixians');
|
||||
assert.equal(initialized.techTokens?.axlotl.owner, 'tleilaxu');
|
||||
assert.equal(initialized.discoveryEnabled, true);
|
||||
});
|
||||
|
||||
void test('Discovery cannot redeal started components or silently remove another module', () => {
|
||||
const initial = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
|
||||
const started = initializeDiscoveryGameForAudit(initial);
|
||||
assert.throws(() => initializeDiscoveryGameForAudit(started), RuleError);
|
||||
const overlays: Game[] = [structuredClone(initial), structuredClone(initial), structuredClone(initial)];
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].strongholdCards = createStrongholdCards();
|
||||
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload, nativeTypedStep } from './fixture-discovery-native-typed';
|
||||
import { putDiscovery } from './fixture-discovery';
|
||||
import { giveAssassinationTraitor } from './moritani-assassinate-fixture';
|
||||
import { openSkillsTechBattle, nextSkillsTechBattleStep } from './fixture-skills-tech-battle';
|
||||
|
||||
void test('native Moritani assassination retains the actual revealed Discovery battle source and physical traitor cost', () => {
|
||||
let game = createGame('DISCASSA', newPlayer('m', 'Moritani', 'moritani'), true, ['ecaz']);
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild')); joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
|
||||
for (const player of game.players) player.ready = true;
|
||||
game.discoveryEnabled = true; game.techTokens = createTechTokens();
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
for (let n = 0; game.status === 'setup' && n < 100; n++) game = nativeTypedStep(game);
|
||||
game = nativeTypedPhase(game, 5);
|
||||
nativeTypedClearBoard(game);
|
||||
// Conserved original supply token and reserve positions; inspect/reveal,
|
||||
// clocks, battle, assassination and reward are produced by original controls.
|
||||
const token = putDiscovery(game, 'cistern');
|
||||
nativeTypedPlace(game, 'm', `${token.territory}:${token.sector}`, 2);
|
||||
game = nativeTypedPhase(game, 7);
|
||||
game = applyAction(game, 'm', { type: 'discovery', token: token.id, reveal: false });
|
||||
game = applyAction(game, 'm', { type: 'discovery', token: token.id, reveal: true });
|
||||
game = nativeTypedPhase(game, 8);
|
||||
game = nativeTypedPhase(game, 5, 2);
|
||||
nativeTypedClearBoard(game);
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
for (const id of ['m', 'g']) nativeTypedPlace(game, id, 'cistern:0', 3);
|
||||
giveAssassinationTraitor(game, 'guild-1');
|
||||
game = nativeTypedPhase(game, 6);
|
||||
game = openSkillsTechBattle(game, 'm', 'g', 'cistern');
|
||||
for (const id of [game.battle!.attacker, game.battle!.defender]) {
|
||||
const leader = id === 'g' ? 'guild-0' : nativeTypedPlayer(game, 'm').leaders[4].id;
|
||||
game = applyAction(game, id, { type: 'battlePlan', leader, dial: 0, support: 0 });
|
||||
}
|
||||
for (const id of ['m', 'g']) game = applyAction(game, id, { type: 'traitorCall', call: false });
|
||||
for (let n = 0; game.decision?.kind !== 'moritaniAssassinate' && n < 100; n++) {
|
||||
const next = nextSkillsTechBattleStep(game); game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(game.decision?.kind === 'moritaniAssassinate');
|
||||
assert.equal(viewGame(game, 'm').moritaniAssassinate!.pending!.territory, 'cistern');
|
||||
const spice = nativeTypedPlayer(game, 'm').spice;
|
||||
const event = game.decision.event;
|
||||
game = applyAction(nativeTypedReload(game), 'm', { type: 'decision', event, card: 'guild-1' });
|
||||
assert.equal(nativeTypedPlayer(game, 'g').leaders.find(leader => leader.id === 'guild-1')!.dead, true);
|
||||
assert.equal(nativeTypedPlayer(game, 'm').spice, spice + 3);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities[0].territory, 'cistern');
|
||||
assert.equal(game.moritaniAssassinate!.opportunities[0].card, 'guild-1');
|
||||
assert.equal(game.moritaniAssassinate!.opportunities[0].stage, 'revealed');
|
||||
assert.equal(viewGame(game, 'm').moritaniAssassinate!.history[0].territory, 'cistern');
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { gameTerritories, validGameLocation } from '../game/board';
|
||||
import { spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { DISCOVERY_LOCATION_IDS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { deployRicheseNoField, moveRicheseNoField, projectRicheseNoField, validateRicheseNoField } from '../game/richese-no-field';
|
||||
import {
|
||||
createDiscoveryNativeMarkerFixture, discoveryNativeMarkerShipmentWindow,
|
||||
nextDiscoveryNativeMarkerStep, revealDiscoveryNativeMarker, settleDiscoveryNativeMarkerShipment,
|
||||
} from './fixture-discovery-native-marker';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
|
||||
function custody(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
assert.deepEqual(cards.map(card => card.id).sort(),
|
||||
[...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
if (player.noField) validateRicheseNoField(player.noField);
|
||||
}
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
assert.equal(game.discoveries!.tokens.length, 8);
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function legalPolicies(game: Game, actor: string) {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||
const choices = botActions(view);
|
||||
assert.ok(choices.length, `${difficulty} must offer an actual legal continuation.`);
|
||||
for (const action of choices) {
|
||||
const before = structuredClone(game);
|
||||
const next = applyAction(reload(game), actor, action);
|
||||
assert.deepEqual(game, before);
|
||||
custody(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test('fresh E2 Discovery uses the original 35 ordinary cards, real cache, seven Spice Cards and eight tokens', () => {
|
||||
for (const advanced of [false, true]) for (const tech of [false, true]) {
|
||||
const fixture = createDiscoveryNativeMarkerFixture({ advanced, tech });
|
||||
assert.equal(treacheryDeck(['choam']).length, 35);
|
||||
const setup = fixture.setup;
|
||||
const ordinary = [...setup.deck, ...setup.players.flatMap(player => player.hand)];
|
||||
assert.deepEqual(ordinary.map(card => card.id).sort(), treacheryDeck(['choam']).map(card => card.id).sort());
|
||||
assert.deepEqual(setup.richeseCache!.map(card => card.id).sort(), richeseCards().map(card => card.id).sort());
|
||||
assert.equal(setup.spiceDeck.length, spiceDeck().length + 7);
|
||||
assert.deepEqual(setup.spiceDeck.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(),
|
||||
DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort());
|
||||
assert.equal(setup.spiceDeck.filter(card => 'worm' in card && card.greatMaker).length, 1);
|
||||
assert.equal(setup.discoveries!.tokens.length, 8);
|
||||
assert.deepEqual(viewGame(setup, fixture.owner).discoveries!.tokens, []);
|
||||
assert.equal(fixture.game.phase, 7);
|
||||
assert.equal(fixture.game.turn, 1);
|
||||
assert.ok(fixture.actions.some(step => step.action.type === 'stormDial'));
|
||||
assert.ok(fixture.actions.some(step => step.action.type === 'ship'));
|
||||
assert.ok(fixture.actions.some(step => step.action.type === 'richeseBid'));
|
||||
assert.equal(Boolean(fixture.game.techTokens), tech);
|
||||
custody(fixture.game);
|
||||
const before = structuredClone(fixture.initial);
|
||||
assert.throws(() => applyAction(fixture.initial, fixture.initial.host, { type: 'start' }));
|
||||
assert.deepEqual(fixture.initial, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('every known Discovery sector-zero location preserves private No-Field custody without authorizing arbitrary locations', () => {
|
||||
const fixture = createDiscoveryNativeMarkerFixture();
|
||||
const inventory = fixture.game.players.find(player => player.id === fixture.owner)!.noField!;
|
||||
const before = structuredClone(inventory);
|
||||
for (const territory of DISCOVERY_LOCATION_IDS) {
|
||||
const deployed = deployRicheseNoField(inventory, {
|
||||
tokenId: inventory.tokens[0].id, controller: fixture.owner, location: { territory, sector: 0 },
|
||||
});
|
||||
const moved = moveRicheseNoField(JSON.parse(JSON.stringify(deployed)), inventory.tokens[0].id, { territory, sector: 0 });
|
||||
assert.deepEqual(moved.tokens, inventory.tokens);
|
||||
assert.deepEqual(projectRicheseNoField(moved, false).deployed, {
|
||||
controller: fixture.owner, location: { territory, sector: 0 }, effectiveForces: 1,
|
||||
});
|
||||
assert.equal(validGameLocation(fixture.game, territory, 0), false);
|
||||
}
|
||||
for (const territory of ['invented-discovery', 'treachery-card-stash', 'arrakeen']) {
|
||||
assert.throws(() => deployRicheseNoField(inventory, {
|
||||
tokenId: inventory.tokens[0].id, controller: fixture.owner, location: { territory, sector: 0 },
|
||||
}));
|
||||
}
|
||||
assert.deepEqual(inventory, before);
|
||||
});
|
||||
|
||||
void test('native paid markers ship into actual revealed nested sites, move out, and reveal 0/3/5 exactly once through JSON', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createDiscoveryNativeMarkerFixture({ advanced, tech: true, face: 'shrine' });
|
||||
const window = discoveryNativeMarkerShipmentWindow(fixture);
|
||||
const owner = window.players.find(player => player.id === fixture.owner)!;
|
||||
const inventory = structuredClone(owner.noField!.tokens);
|
||||
assert.equal(validGameLocation(window, fixture.face, 0), true);
|
||||
assert.ok(gameTerritories(viewGame(window, fixture.owner)).some(territory => territory.id === fixture.face && territory.sectors[0] === 0));
|
||||
assert.equal(viewGame(window, fixture.owner).richeseNoField!.canShip, true);
|
||||
legalPolicies(window, fixture.owner);
|
||||
for (const token of inventory) {
|
||||
const action: Action = { type: 'ship', territory: fixture.face, sector: 0,
|
||||
noField: token.id, event: owner.noFieldEvent, allyPayment: 0 };
|
||||
const quoted = reserveShipmentCost({ faction: owner.faction, halfRate: false },
|
||||
gameTerritories(window).find(territory => territory.id === fixture.face)!.type, 1);
|
||||
assert.equal(quoted, 1);
|
||||
reject(window, fixture.collector, action);
|
||||
reject(window, fixture.owner, { ...action, event: 'stale-marker' });
|
||||
reject(window, fixture.owner, { ...action, territory: 'cistern' });
|
||||
reject(window, fixture.owner, { ...action, territory: 'arbitrary-sector-zero' });
|
||||
const corrupt = reload(window);
|
||||
corrupt.players.find(player => player.id === fixture.owner)!.noField!.tokens[1].id = token.id;
|
||||
if (token.id === inventory[1].id) corrupt.players.find(player => player.id === fixture.owner)!.noField!.tokens[0].id = token.id;
|
||||
reject(corrupt, fixture.owner, action);
|
||||
let game = applyAction(reload(window), fixture.owner, action);
|
||||
assert.equal(game.players.find(player => player.id === fixture.owner)!.reserves, owner.reserves);
|
||||
game = settleDiscoveryNativeMarkerShipment(reload(game));
|
||||
const shipped = game.players.find(player => player.id === fixture.owner)!;
|
||||
assert.equal(shipped.spice, owner.spice - quoted);
|
||||
assert.equal(shipped.reserves, owner.reserves);
|
||||
assert.deepEqual(shipped.forces, owner.forces);
|
||||
assert.deepEqual(shipped.noField!.tokens, inventory);
|
||||
const other = viewGame(game, fixture.collector);
|
||||
assert.equal(other.richeseNoField!.private, null);
|
||||
assert.equal(other.richeseNoField!.public.deployed!.effectiveForces, 1);
|
||||
for (const physical of inventory) assert.equal(JSON.stringify(other).includes(physical.id), false);
|
||||
for (const territory of ['cistern', 'unknown-nested-site']) {
|
||||
const corruptDeployment = reload(game);
|
||||
corruptDeployment.players.find(player => player.id === fixture.owner)!.noField!.deployed!.location = { territory, sector: 0 };
|
||||
reject(corruptDeployment, fixture.owner, { type: 'revealNoField', token: token.id, event: shipped.noFieldEvent });
|
||||
}
|
||||
const move: Action = { type: 'move', forces: {}, territory: fixture.parent, sector: fixture.parentSector,
|
||||
noField: token.id, event: shipped.noFieldEvent };
|
||||
reject(game, fixture.collector, move);
|
||||
reject(game, fixture.owner, { ...move, event: owner.noFieldEvent });
|
||||
game = applyAction(reload(game), fixture.owner, move);
|
||||
game = settleDiscoveryNativeMarkerShipment(game);
|
||||
const moved = game.players.find(player => player.id === fixture.owner)!;
|
||||
assert.deepEqual(moved.noField!.deployed!.location, { territory: fixture.parent, sector: fixture.parentSector });
|
||||
assert.deepEqual(moved.forces, owner.forces);
|
||||
assert.equal(moved.reserves, owner.reserves);
|
||||
assert.equal(moved.spice, owner.spice - quoted);
|
||||
assert.equal(moved.moved, owner.moved + 1);
|
||||
reject(game, fixture.owner, move);
|
||||
const reveal: Action = { type: 'revealNoField', token: token.id, event: moved.noFieldEvent };
|
||||
reject(game, fixture.collector, reveal);
|
||||
reject(game, fixture.owner, { ...reveal, event: shipped.noFieldEvent });
|
||||
const materialized = Math.min(token.value, moved.reserves);
|
||||
const key = `${fixture.parent}:${fixture.parentSector}`;
|
||||
game = applyAction(reload(game), fixture.owner, reveal);
|
||||
const revealed = game.players.find(player => player.id === fixture.owner)!;
|
||||
assert.equal(revealed.reserves, owner.reserves - materialized);
|
||||
assert.equal(revealed.forces[key] ?? 0, (owner.forces[key] ?? 0) + materialized);
|
||||
assert.equal(revealed.spice, owner.spice - quoted);
|
||||
assert.equal(revealed.noField!.deployed, null);
|
||||
assert.equal(revealed.noField!.lastShipped, token.id);
|
||||
assert.deepEqual(revealed.noField!.tokens, inventory);
|
||||
reject(game, fixture.owner, reveal);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
custody(game);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored pending marker declaration cannot retarget its final real response into an unknown or unrevealed site', () => {
|
||||
const fixture = createDiscoveryNativeMarkerFixture({ advanced: true, face: 'shrine' });
|
||||
const window = discoveryNativeMarkerShipmentWindow(fixture);
|
||||
const owner = window.players.find(player => player.id === fixture.owner)!;
|
||||
const token = owner.noField!.tokens.find(candidate => candidate.value === 5)!;
|
||||
// A real physical Karama keeps the native cancellation producer open.
|
||||
const karamaIndex = window.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
window.players.find(player => player.id === fixture.collector)!.hand.push(window.deck.splice(karamaIndex, 1)[0]);
|
||||
let game = applyAction(window, fixture.owner, { type: 'ship', noField: token.id,
|
||||
event: owner.noFieldEvent, territory: fixture.face, sector: 0, allyPayment: 0 });
|
||||
assert.ok(game.response?.kind === 'richeseNoField' && game.pendingShipment);
|
||||
let last = nextDiscoveryNativeMarkerStep(game);
|
||||
// Stop before the original pass that closes this response, not at an
|
||||
// incidental count of auto-passed or privately eligible seats.
|
||||
for (let n = 0; n < game.players.length; n++) {
|
||||
assert.equal(last.action.type, 'passResponse');
|
||||
const next = applyAction(game, last.actor, last.action);
|
||||
if (next.response?.kind !== 'richeseNoField') break;
|
||||
game = next;
|
||||
last = nextDiscoveryNativeMarkerStep(game);
|
||||
}
|
||||
for (const territory of ['cistern', 'unknown-nested-site']) {
|
||||
const corrupt = reload(game);
|
||||
corrupt.pendingShipment!.territory = territory;
|
||||
reject(corrupt, last.actor, last.action);
|
||||
assert.equal(corrupt.players.find(player => player.id === fixture.owner)!.spice, owner.spice);
|
||||
assert.equal(corrupt.players.find(player => player.id === fixture.owner)!.reserves, owner.reserves);
|
||||
assert.equal(corrupt.players.find(player => player.id === fixture.owner)!.noField!.deployed, null);
|
||||
}
|
||||
const done = settleDiscoveryNativeMarkerShipment(applyAction(reload(game), last.actor, last.action));
|
||||
const shipped = done.players.find(player => player.id === fixture.owner)!;
|
||||
assert.equal(shipped.spice, owner.spice - 1);
|
||||
assert.equal(shipped.reserves, owner.reserves);
|
||||
assert.deepEqual(shipped.noField!.deployed!.location, { territory: fixture.face, sector: 0 });
|
||||
custody(done);
|
||||
});
|
||||
|
||||
void test('one paid marker can enter a revealed parent-nested site by real movement and reveal there without merging physical force custody', () => {
|
||||
const fixture = createDiscoveryNativeMarkerFixture({ face: 'cistern' });
|
||||
let game = discoveryNativeMarkerShipmentWindow(fixture);
|
||||
const owner = game.players.find(player => player.id === fixture.owner)!;
|
||||
const before = structuredClone(owner);
|
||||
const token = owner.noField!.tokens.find(candidate => candidate.value === 5)!;
|
||||
game = applyAction(game, fixture.owner, { type: 'ship', noField: token.id, event: owner.noFieldEvent,
|
||||
territory: fixture.parent, sector: fixture.parentSector, allyPayment: 0 });
|
||||
game = settleDiscoveryNativeMarkerShipment(game);
|
||||
const shipped = game.players.find(player => player.id === fixture.owner)!;
|
||||
const move: Action = { type: 'move', forces: {}, noField: token.id, event: shipped.noFieldEvent,
|
||||
territory: fixture.face, sector: 0 };
|
||||
reject(game, fixture.owner, { ...move, territory: 'shrine' });
|
||||
game = applyAction(reload(game), fixture.owner, move);
|
||||
game = settleDiscoveryNativeMarkerShipment(game);
|
||||
const moved = game.players.find(player => player.id === fixture.owner)!;
|
||||
assert.deepEqual(moved.forces, before.forces);
|
||||
assert.equal(moved.reserves, before.reserves);
|
||||
assert.deepEqual(moved.noField!.deployed!.location, { territory: fixture.face, sector: 0 });
|
||||
game = applyAction(reload(game), fixture.owner, { type: 'revealNoField', token: token.id, event: moved.noFieldEvent });
|
||||
const revealed = game.players.find(player => player.id === fixture.owner)!;
|
||||
assert.equal(revealed.forces[`${fixture.face}:0`], 5);
|
||||
assert.equal(revealed.reserves, before.reserves - 5);
|
||||
assert.equal(revealed.spice, before.spice - 2);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('CHOAM opens the actual Treachery Card Stash at five, draws six, owns every discard and returns to five without payment/refund', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const fixture = createDiscoveryNativeMarkerFixture({ advanced, face: 'treachery-card-stash', collector: 'choam' });
|
||||
const before = fixture.game;
|
||||
const choam = before.players.find(player => player.id === fixture.collector)!;
|
||||
assert.equal(handLimit(choam), 5);
|
||||
assert.equal(viewGame(before, fixture.collector).players.find(player => player.id === fixture.collector)!.handLimit, 5);
|
||||
// Explicit conserved custody staging, not an invented card or auction receipt.
|
||||
while (choam.hand.length < 5) {
|
||||
const physical = before.deck.shift();
|
||||
assert.ok(physical);
|
||||
choam.hand.push(physical);
|
||||
}
|
||||
fixture.staging.push('Moved undealt original ordinary cards into CHOAM hand to exercise its real five-card boundary.');
|
||||
const wallet = choam.spice, original = choam.hand.map(card => card.id), drawn = before.deck[0];
|
||||
assert.ok(drawn);
|
||||
custody(before);
|
||||
const game = revealDiscoveryNativeMarker(fixture);
|
||||
assert.equal(game.decision?.kind, 'discoveryDiscard');
|
||||
assert.equal(game.decision?.player, fixture.collector);
|
||||
assert.equal(game.players.find(player => player.id === fixture.collector)!.hand.length, 6);
|
||||
assert.deepEqual(game.players.find(player => player.id === fixture.collector)!.hand.map(card => card.id), [...original, drawn.id]);
|
||||
assert.equal(game.players.find(player => player.id === fixture.collector)!.spice, wallet);
|
||||
assert.equal(game.deck.length, before.deck.length - 1);
|
||||
const other = JSON.stringify(viewGame(game, fixture.owner));
|
||||
assert.equal(other.includes(JSON.stringify(drawn.id)), false);
|
||||
assert.equal(viewGame(game, fixture.collector).players.find(player => player.id === fixture.collector)!.handCount, 6);
|
||||
legalPolicies(game, fixture.collector);
|
||||
for (const card of game.players.find(player => player.id === fixture.collector)!.hand) {
|
||||
const action: Action = { type: 'decision', event: game.discoveryStash!.event, card: card.id };
|
||||
reject(game, fixture.owner, action);
|
||||
reject(game, fixture.collector, { ...action, event: 'stale-stash' });
|
||||
reject(game, fixture.collector, { ...action, card: 'invented-card' });
|
||||
let done = applyAction(reload(game), fixture.collector, action);
|
||||
done = settleDiscoveryNativeMarkerShipment(done);
|
||||
assert.equal(done.discoveryStash!.stage, 'complete');
|
||||
assert.equal(done.players.find(player => player.id === fixture.collector)!.hand.length, 5);
|
||||
assert.equal(done.players.find(player => player.id === fixture.collector)!.spice, wallet);
|
||||
assert.equal(done.deck.length, before.deck.length - 1);
|
||||
assert.equal(done.discard.filter(physical => physical.id === card.id).length, 1);
|
||||
assert.equal(done.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'removed');
|
||||
reject(done, fixture.collector, action);
|
||||
reject(done, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
|
||||
custody(done);
|
||||
}
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { gameDistance, gameTerritories } from '../game/board';
|
||||
import { DISCOVERY_SPICE_CARDS } from '../game/discoveries';
|
||||
import {
|
||||
MOBILE_LOCATION, MOBILE_STRONGHOLD, nativeTypedCard, nativeTypedCollection,
|
||||
nativeTypedCustody, nativeTypedEntry, nativeTypedFaceDance, nativeTypedMovementTurn,
|
||||
nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload,
|
||||
nativeTypedReveal, nativeTypedSetup, nativeTypedStep,
|
||||
} from './fixture-discovery-native-typed';
|
||||
|
||||
function rejectUnchanged(game: Game, owner: string, action: Action) {
|
||||
const before = nativeTypedReload(game);
|
||||
assert.throws(() => applyAction(game, owner, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function legalPolicies(game: Game, owner: string) {
|
||||
assert.equal(DIFFICULTIES.length, 4);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, owner);
|
||||
view.players.find(p => p.id === owner)!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length > 0);
|
||||
for (const action of actions) {
|
||||
const result = applyAction(nativeTypedReload(game), owner, action);
|
||||
nativeTypedCustody(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
function entryAction(game: Game, groups?: unknown): Action {
|
||||
const owner = game.decision!.player;
|
||||
const offer = viewGame(game, owner).discoveryEntry!;
|
||||
return { type: 'decision', accept: true, event: offer.event, groups: groups ?? offer.sources };
|
||||
}
|
||||
function settle(state: Game): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 100 && (game.decision || game.response || game.pendingShipment); step++)
|
||||
game = nativeTypedStep(game);
|
||||
assert.ok(!game.decision && !game.response && !game.pendingShipment);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('fresh E1 Discovery uses original 47-card setup, Sandtrout, seven cards/eight tokens, HMS and native private deals', () => {
|
||||
for (const advanced of [false, true]) for (const tech of [false, true]) {
|
||||
const { game, initialized, lobby } = nativeTypedSetup({ advanced, tech });
|
||||
assert.equal(game.discoveryEnabled, true);
|
||||
assert.equal(game.leaderSkills, undefined);
|
||||
assert.equal(game.strongholdCards, undefined);
|
||||
assert.equal(game.homeworlds, undefined);
|
||||
assert.equal(game.nexusCards, undefined);
|
||||
assert.deepEqual(initialized.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
|
||||
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
|
||||
assert.equal(viewGame(initialized, 'i').discoveries!.tokens.length, 0);
|
||||
assert.equal(viewGame(game, 't').players.find(p => p.id === 't')!.faceDancers!.length, 3);
|
||||
assert.equal(viewGame(game, 'g').players.find(p => p.id === 't')!.faceDancers, undefined);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').elites!.reserves, 4);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces[MOBILE_LOCATION], 3);
|
||||
if (tech) {
|
||||
assert.equal(game.techTokens!.heighliners.owner, 'i');
|
||||
assert.equal(game.techTokens!.axlotl.owner, 't');
|
||||
assert.equal(game.techTokens!.production.owner, 'f');
|
||||
} else assert.equal(game.techTokens, undefined);
|
||||
const before = nativeTypedReload(lobby);
|
||||
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
|
||||
assert.deepEqual(lobby, before);
|
||||
nativeTypedCustody(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('original native Cyborg/Suboid and ordinary/starred Fremen free entry transfers only actual typed counters once', () => {
|
||||
for (const advanced of [false, true]) for (const owner of ['i', 'f']) {
|
||||
const { game, source } = nativeTypedEntry({ advanced, tech: true }, owner);
|
||||
const player = nativeTypedPlayer(game, owner);
|
||||
const expectedElite = owner === 'i' || advanced ? 1 : 0;
|
||||
const offer = viewGame(game, owner).discoveryEntry!;
|
||||
assert.deepEqual(offer.sources, [{ source, normal: 2, elite: expectedElite }]);
|
||||
assert.ok(offer.sources.every(group => group.source !== MOBILE_LOCATION));
|
||||
const before = nativeTypedReload(game);
|
||||
const action = entryAction(game, [{ source, normal: 1, elite: expectedElite }]);
|
||||
legalPolicies(game, owner);
|
||||
const done = applyAction(nativeTypedReload(game), owner, action);
|
||||
const arrived = nativeTypedPlayer(done, owner);
|
||||
assert.equal(arrived.forces['shrine:0'], 1 + expectedElite);
|
||||
assert.equal(arrived.elites?.forces['shrine:0'] ?? 0, expectedElite);
|
||||
assert.equal(arrived.forces[source], 1);
|
||||
assert.equal(arrived.elites?.forces[source] ?? 0, 0);
|
||||
assert.equal(arrived.reserves, player.reserves);
|
||||
assert.equal(arrived.elites?.reserves, player.elites?.reserves);
|
||||
assert.equal(arrived.spice, player.spice);
|
||||
assert.equal(arrived.moved, player.moved);
|
||||
assert.equal(arrived.shipped, player.shipped);
|
||||
assert.deepEqual(done.techTokens, game.techTokens);
|
||||
const progress = viewGame(done, owner).victoryProgress.find(row => row.player === owner)!;
|
||||
assert.deepEqual(progress.strongholds, owner === 'i' ? [MOBILE_STRONGHOLD] : []);
|
||||
assert.equal(progress.techStronghold, false);
|
||||
assert.equal(progress.qualifies, false);
|
||||
assert.equal(done.discoveryEntry, undefined);
|
||||
assert.deepEqual(done.discoveries!.newlyRevealed, []);
|
||||
assert.deepEqual(game, before);
|
||||
assert.deepEqual(normalizeAutomaticGame(nativeTypedReload(done)), done);
|
||||
rejectUnchanged(done, owner, action);
|
||||
nativeTypedCustody(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('duplicate, stale, foreign and overdrawn native typed free entry selections reject atomically across JSON', () => {
|
||||
const { game, source } = nativeTypedEntry({ advanced: true });
|
||||
const group = { source, normal: 1, elite: 1 };
|
||||
const action = entryAction(game, [group]);
|
||||
rejectUnchanged(game, 't', action);
|
||||
rejectUnchanged(game, 'i', { ...action, event: 'old-native-entry' });
|
||||
rejectUnchanged(game, 'i', entryAction(game, [group, group]));
|
||||
rejectUnchanged(game, 'i', entryAction(game, [{ source, normal: 3, elite: 1 }]));
|
||||
rejectUnchanged(game, 'i', entryAction(game, [{ source, normal: 0, elite: 2 }]));
|
||||
rejectUnchanged(game, 'i', entryAction(game, [{ source: MOBILE_LOCATION, normal: 1, elite: 1 }]));
|
||||
const reloaded = nativeTypedReload(game);
|
||||
const publicOffer = viewGame(reloaded, 'i').discoveryEntry!;
|
||||
publicOffer.sources[0].elite = 7;
|
||||
assert.deepEqual(reloaded, game);
|
||||
assert.ok(game.decision?.kind === 'discoveryEntry');
|
||||
const declined = applyAction(reloaded, 'i', { type: 'decision', accept: false, event: game.decision!.event });
|
||||
assert.equal(nativeTypedPlayer(declined, 'i').forces['shrine:0'], undefined);
|
||||
assert.equal(nativeTypedPlayer(declined, 'i').forces[source], 3);
|
||||
nativeTypedCustody(declined);
|
||||
});
|
||||
|
||||
void test('typed native free entry keeps a signed pending child and resumes exactly once after original advisor controls', () => {
|
||||
let game = nativeTypedEntry({ advanced: true, advisors: true }).game;
|
||||
nativeTypedPlace(game, 'b', 'shrine:0', 1);
|
||||
const action = entryAction(game);
|
||||
game = applyAction(game, 'i', action);
|
||||
assert.equal(game.discoveryEntry?.stage, 'arrival');
|
||||
assert.equal(game.decision?.kind, 'intrusion');
|
||||
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 3);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 1);
|
||||
const malformed = nativeTypedReload(game);
|
||||
if (malformed.decision?.kind === 'intrusion') malformed.decision.discoveryEntry!.elite = 0;
|
||||
assert.throws(() => viewGame(malformed, 'b'));
|
||||
rejectUnchanged(malformed, 'b', { type: 'decision', accept: false });
|
||||
const arrived = nativeTypedReload(game);
|
||||
nativeTypedPlayer(arrived, 'i').elites!.forces['shrine:0'] = 0;
|
||||
assert.throws(() => normalizeAutomaticGame(arrived));
|
||||
rejectUnchanged(arrived, 'b', { type: 'decision', accept: false });
|
||||
game = applyAction(nativeTypedReload(game), 'b', { type: 'decision', accept: true });
|
||||
game = settle(nativeTypedReload(game));
|
||||
assert.equal(game.discoveryEntry, undefined);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 3);
|
||||
rejectUnchanged(game, 'i', action);
|
||||
nativeTypedCustody(game);
|
||||
});
|
||||
|
||||
void test('ordinary paid Cyborg shipment and nested movement preserve physical HMS identity and optional native industry', () => {
|
||||
for (const advanced of [false, true]) for (const tech of [false, true]) {
|
||||
const entry = nativeTypedEntry({ advanced, tech });
|
||||
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
|
||||
let game = applyAction(entry.game, 'i', { type: 'decision', accept: false, event: entry.game.decision!.event });
|
||||
game = nativeTypedMovementTurn(nativeTypedPhase(game, 5), 'i');
|
||||
const before = nativeTypedReload(game);
|
||||
const hms = nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION];
|
||||
game = settle(applyAction(nativeTypedReload(game), 'i', { type: 'ship', territory: 'shrine', sector: 0, amount: 2, elite: 1 }));
|
||||
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 2);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 1);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').reserves, nativeTypedPlayer(before, 'i').reserves - 2);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').elites!.reserves, nativeTypedPlayer(before, 'i').elites!.reserves - 1);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').spice, nativeTypedPlayer(before, 'i').spice - 2);
|
||||
assert.equal(nativeTypedPlayer(game, 'g').spice, nativeTypedPlayer(before, 'g').spice + 2);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').shipped, true);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION], hms);
|
||||
assert.notEqual(MOBILE_STRONGHOLD, 'shrine');
|
||||
assert.equal(gameDistance(game, entry.source, 'shrine:0'), 1);
|
||||
const movement: Action = { type: 'move', forces: { [entry.source]: 2 }, eliteForces: { [entry.source]: 1 }, territory: 'shrine', sector: 0 };
|
||||
const moving = nativeTypedReload(game);
|
||||
game = settle(applyAction(game, 'i', movement));
|
||||
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 4);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 2);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').forces[entry.source], 1);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').moved, 1);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').spice, nativeTypedPlayer(moving, 'i').spice);
|
||||
rejectUnchanged(game, 'i', movement);
|
||||
if (tech) {
|
||||
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
|
||||
assert.equal(game.techTokens!.heighliners.spice, 1);
|
||||
const wallet = nativeTypedPlayer(game, 'i').spice;
|
||||
for (let step = 0; step < 100 && game.phase === 5; step++) game = nativeTypedStep(game);
|
||||
assert.notEqual(game.phase, 5);
|
||||
assert.equal(game.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').spice, wallet + 1);
|
||||
}
|
||||
nativeTypedCustody(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Tleilaxu Collection stash draws an actual E1 card privately and all four policies recover a full hand', () => {
|
||||
const position = nativeTypedCollection('treachery-card-stash', 't', { advanced: true });
|
||||
const player = nativeTypedPlayer(position.game, 't');
|
||||
while (player.hand.length < 4) nativeTypedCard(position.game, 't', () => true);
|
||||
const drawn = position.game.deck[0];
|
||||
const before = player.hand.map(c => c.id);
|
||||
const game = nativeTypedReveal(position);
|
||||
assert.equal(game.decision?.kind, 'discoveryDiscard');
|
||||
assert.deepEqual(nativeTypedPlayer(game, 't').hand.map(c => c.id), [...before, drawn.id]);
|
||||
assert.equal(viewGame(game, 'g').players.find(p => p.id === 't')!.hand, undefined);
|
||||
assert.equal(JSON.stringify(viewGame(game, 'g')).includes(JSON.stringify(drawn.id)), false);
|
||||
legalPolicies(game, 't');
|
||||
const action = { type: 'decision', event: game.discoveryStash!.event, card: drawn.id };
|
||||
rejectUnchanged(game, 'g', action);
|
||||
rejectUnchanged(game, 't', { ...action, event: 'old-native-stash' });
|
||||
const done = applyAction(nativeTypedReload(game), 't', action);
|
||||
assert.equal(nativeTypedPlayer(done, 't').hand.length, 4);
|
||||
assert.ok(done.discard.some(c => c.id === drawn.id));
|
||||
assert.equal(done.discoveries!.tokens.find(t => t.id === position.token)!.status, 'removed');
|
||||
rejectUnchanged(done, 't', action);
|
||||
nativeTypedCustody(done);
|
||||
});
|
||||
|
||||
void test('native nested battle retains original winner cards/Tech rewards before valid Face Dance replaces from actual sources', () => {
|
||||
for (const advanced of [false, true]) for (const tech of [false, true]) {
|
||||
const fixture = nativeTypedFaceDance(advanced, tech);
|
||||
const { game, action } = fixture;
|
||||
assert.ok(nativeTypedPlayer(game, 'g').hand.some(c => c.id === fixture.weapon.id));
|
||||
assert.ok(game.discard.some(c => c.id === fixture.defense.id));
|
||||
assert.ok(game.discard.some(c => c.id === fixture.loserCard.id));
|
||||
assert.equal(nativeTypedPlayer(game, 't').tanks, 8);
|
||||
assert.equal(game.lastBattleContext!.winner, 'g');
|
||||
if (tech) {
|
||||
assert.equal(fixture.cleanup.techTokens!.axlotl.owner, 't');
|
||||
assert.equal(game.techTokens!.axlotl.owner, 'g');
|
||||
assert.equal(game.techTokens!.heighliners.owner, 'i');
|
||||
assert.equal(game.techTokens!.production.owner, 'f');
|
||||
}
|
||||
legalPolicies(game, 't');
|
||||
rejectUnchanged(game, 'i', action);
|
||||
rejectUnchanged(game, 't', { ...action, sector: 1 });
|
||||
rejectUnchanged(game, 't', { ...action, sources: { reserves: 9 } });
|
||||
rejectUnchanged(game, 't', { ...action, sources: { [MOBILE_LOCATION]: 1 } });
|
||||
const done = applyAction(nativeTypedReload(game), 't', action);
|
||||
assert.equal(nativeTypedPlayer(done, 'g').forces['shrine:0'], undefined);
|
||||
assert.equal(nativeTypedPlayer(done, 'g').reserves, nativeTypedPlayer(game, 'g').reserves + 8);
|
||||
assert.equal(nativeTypedPlayer(done, 't').forces['shrine:0'], 3);
|
||||
assert.equal(nativeTypedPlayer(done, 't').forces[fixture.source], 1);
|
||||
assert.equal(nativeTypedPlayer(done, 't').reserves, nativeTypedPlayer(game, 't').reserves - 1);
|
||||
assert.equal(nativeTypedPlayer(done, 't').spice, nativeTypedPlayer(game, 't').spice);
|
||||
assert.equal(nativeTypedPlayer(done, 'g').leaders.find(l => l.id === fixture.winnerLeader)!.dead, true);
|
||||
assert.deepEqual(done.techTokens, game.techTokens);
|
||||
assert.equal(done.phase, 6);
|
||||
assert.deepEqual(viewGame(done, 't').victoryProgress.find(row => row.player === 't')!.strongholds, []);
|
||||
rejectUnchanged(done, 't', action);
|
||||
nativeTypedCustody(done);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native third Face Dance refills only genuine private identities with conserved Traitor/Face Dancer inventory', () => {
|
||||
const fixture = nativeTypedFaceDance(true);
|
||||
// Labelled original revealed-card history, not a fabricated card or new identity.
|
||||
for (const dancer of nativeTypedPlayer(fixture.game, 't').faceDancers!)
|
||||
dancer.revealed = dancer.leader !== fixture.winnerLeader;
|
||||
const identities = [...fixture.game.traitorReserve!,
|
||||
...nativeTypedPlayer(fixture.game, 't').faceDancers!.map(c => c.leader)].sort();
|
||||
const done = applyAction(nativeTypedReload(fixture.game), 't', fixture.action);
|
||||
assert.equal(nativeTypedPlayer(done, 't').faceDancers!.length, 3);
|
||||
assert.ok(nativeTypedPlayer(done, 't').faceDancers!.every(c => !c.revealed));
|
||||
assert.deepEqual([...done.traitorReserve!, ...nativeTypedPlayer(done, 't').faceDancers!.map(c => c.leader)].sort(), identities);
|
||||
assert.equal(viewGame(done, 'i').players.find(p => p.id === 't')!.faceDancers, undefined);
|
||||
nativeTypedCustody(done);
|
||||
});
|
||||
|
||||
void test('original E1 Discovery Spice Blow producer destroys actual ordinary/starred groups before physical placement', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
let game = nativeTypedSetup({ advanced }).game;
|
||||
game = nativeTypedPhase(game, 1);
|
||||
const printed = DISCOVERY_SPICE_CARDS.find(card => card.sector !== game.storm)!;
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery);
|
||||
assert.ok(index >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
||||
const key = `${printed.territory}:${printed.sector}`;
|
||||
nativeTypedPlace(game, 'i', key, 1, 1);
|
||||
nativeTypedPlace(game, 'f', key, 1, advanced ? 1 : 0);
|
||||
const before = nativeTypedReload(game);
|
||||
for (let step = 0; step < 100 && game.phase === 1; step++) game = nativeTypedStep(game);
|
||||
assert.ok(game.spiceDiscard.flat().some(card => 'territory' in card && card.discovery === printed.discovery));
|
||||
assert.equal(game.spice[key], 6);
|
||||
for (const id of ['i', 'f']) {
|
||||
const old = nativeTypedPlayer(before, id), player = nativeTypedPlayer(game, id);
|
||||
assert.equal(player.forces[key], undefined);
|
||||
assert.equal(player.tanks, old.tanks + old.forces[key]);
|
||||
assert.equal(player.elites?.forces[key], undefined);
|
||||
if (player.elites) assert.equal(player.elites.tanks, old.elites!.tanks + (old.elites!.forces[key] ?? 0));
|
||||
}
|
||||
assert.equal(game.discoveries!.tokens.filter(token => token.status === 'placed').length, 1);
|
||||
assert.ok(gameTerritories(game).every(t => t.id !== 'shrine'));
|
||||
nativeTypedCustody(game);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type DiscoveryNativeMarkerStep = { actor: string; action: Action };
|
||||
export type DiscoveryNativeMarkerFixture = {
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
game: Game;
|
||||
actions: DiscoveryNativeMarkerStep[];
|
||||
staging: string[];
|
||||
collector: string;
|
||||
owner: string;
|
||||
token: string;
|
||||
parent: string;
|
||||
parentSector: number;
|
||||
face: DiscoveryTokenFace;
|
||||
};
|
||||
|
||||
/** Use the existing native no-spend policy, with explicit physical cache choices. */
|
||||
export function nextDiscoveryNativeMarkerStep(game: Game): DiscoveryNativeMarkerStep {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const decision = game.decision, actor = decision.player, event = game.richeseBidding?.event;
|
||||
if (decision.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
|
||||
if (decision.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
|
||||
if (decision.kind === 'richeseCache') return { actor, action: {
|
||||
type: 'decision', event, card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise',
|
||||
} };
|
||||
if (decision.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: false } };
|
||||
if (decision.kind === 'discoveryEntry') return { actor, action: { type: 'decision', event: decision.event, accept: false } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && !game.decision && game.richeseAuction && !game.richeseAuction.outcome) {
|
||||
const lot = game.richeseAuction;
|
||||
const actor = lot.method === 'silent'
|
||||
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id))
|
||||
: lot.active;
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
const next = nextSpiceBankerIncomeNativeStep(game);
|
||||
assert.ok(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function advanceDiscoveryNativeMarker(
|
||||
state: Game,
|
||||
until: (game: Game) => boolean,
|
||||
actions?: DiscoveryNativeMarkerStep[],
|
||||
): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1600; n++) {
|
||||
assert.equal(game.status === 'finished', false);
|
||||
const next = nextDiscoveryNativeMarkerStep(game);
|
||||
actions?.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'Native Discovery marker policy did not reach its requested original window.');
|
||||
return game;
|
||||
}
|
||||
export const discoveryNativeMarkerClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
|
||||
|
||||
/** Control only the actual placement lottery, never token identities or custody. */
|
||||
function withPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
|
||||
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
|
||||
const selected = eligible.findIndex(token => token.face === face);
|
||||
assert.ok(selected >= 0);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = Math.floor((selected + 0.5) / eligible.length * 0x100000000);
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Fresh E2 admission, original setup/Storm and phase producers. The only staging
|
||||
* is the order of four original unplayed Spice Cards and the placement lottery.
|
||||
* No clock, wallet, force, marker, Discovery receipt or card is manufactured.
|
||||
* Stops at Collection BEFORE inspection/revelation for an authenticated recipe. */
|
||||
export function createDiscoveryNativeMarkerFixture(options: {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
face?: DiscoveryTokenFace;
|
||||
collector?: 'choam' | 'guild';
|
||||
} = {}): DiscoveryNativeMarkerFixture {
|
||||
const face = options.face ?? 'cistern';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('DISCOVERYE2MARKER', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
|
||||
if (!options.initial) {
|
||||
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
}
|
||||
assert.equal(game.status, 'lobby');
|
||||
assert.deepEqual(game.expansions, ['choam']);
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.nexusCards && !game.strongholdCards);
|
||||
const owner = game.players.find(player => player.faction === 'richese')!.id;
|
||||
const collector = game.players.find(player => player.faction === (options.collector ?? 'guild'))!.id;
|
||||
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
game.discoveryEnabled = true;
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
const initial = structuredClone(game), actions: DiscoveryNativeMarkerStep[] = [];
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
const setup = structuredClone(game);
|
||||
game = advanceDiscoveryNativeMarker(game, g => g.status === 'playing', actions);
|
||||
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
|
||||
assert.ok(index >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
||||
// Keep both real turns' remaining draws ordinary; never mint or replay a card.
|
||||
for (let position = 1; position < 4; position++) {
|
||||
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(ordinary >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
|
||||
}
|
||||
const staging = ['Reordered four original unplayed Spice Cards; selected the actual Discovery placement lottery.'];
|
||||
game = advanceDiscoveryNativeMarker(game, g => g.phase === 1 && discoveryNativeMarkerClean(g), actions);
|
||||
game = withPlacement(game, face, () => advanceDiscoveryNativeMarker(game,
|
||||
g => g.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
|
||||
game = advanceDiscoveryNativeMarker(game, g => g.phase === 5 && discoveryNativeMarkerClean(g), actions);
|
||||
const token = game.discoveries!.tokens.find(candidate => candidate.face === face)!;
|
||||
assert.equal(token.status, 'placed');
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
|
||||
assert.equal(token.territory, placement.territory);
|
||||
game = advanceDiscoveryNativeMarker(game, g => g.phase === 5 && g.active === collector && discoveryNativeMarkerClean(g), actions);
|
||||
const shipment: DiscoveryNativeMarkerStep = { actor: collector, action: {
|
||||
type: 'ship', territory: placement.territory, sector: placement.sector, amount: 1, elite: 0, allyPayment: 0,
|
||||
} };
|
||||
actions.push(shipment);
|
||||
game = applyAction(game, shipment.actor, shipment.action);
|
||||
game = advanceDiscoveryNativeMarker(game, g => g.phase === 7 && discoveryNativeMarkerClean(g), actions);
|
||||
assert.equal(game.turn, 1);
|
||||
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
|
||||
return { initial, setup, game, actions, staging, owner, collector, token: token.id, parent: placement.territory, parentSector: placement.sector, face };
|
||||
}
|
||||
|
||||
export function revealDiscoveryNativeMarker(fixture: DiscoveryNativeMarkerFixture): Game {
|
||||
const game = applyAction(fixture.game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
|
||||
return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
|
||||
}
|
||||
|
||||
export function discoveryNativeMarkerShipmentWindow(fixture: DiscoveryNativeMarkerFixture): Game {
|
||||
return advanceDiscoveryNativeMarker(revealDiscoveryNativeMarker(fixture), game =>
|
||||
game.turn === 2 && game.phase === 5 && game.active === fixture.owner && discoveryNativeMarkerClean(game));
|
||||
}
|
||||
|
||||
export function settleDiscoveryNativeMarkerShipment(state: Game): Game {
|
||||
return advanceDiscoveryNativeMarker(state, game => !game.pendingShipment && discoveryNativeMarkerClean(game));
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameTerritories, splitLocation } from '../game/board';
|
||||
import { spiceDeck, treacheryDeck, type Card } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
|
||||
import { putDiscovery } from './fixture-discovery';
|
||||
|
||||
export type NativeTypedOptions = { advanced?: boolean; tech?: boolean; advisors?: boolean };
|
||||
export type NativeTypedRevealPosition = { game: Game; owner: string; token: string };
|
||||
export const nativeTypedReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
export function nativeTypedPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
}
|
||||
export function nativeTypedStep(game: Game): Game {
|
||||
const next = game.decision?.kind === 'discoveryEntry'
|
||||
? { actor: game.decision.player, action: { type: 'decision', accept: false, event: game.decision.event } }
|
||||
: game.decision?.kind === 'ixSubstitution'
|
||||
? { actor: game.decision.player, action: { type: 'decision', decline: true } }
|
||||
: nextSkillsTechBattleStep(game);
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
/** Original controls only: do not assign phases, turns, wallets or pending frames. */
|
||||
export function nativeTypedPhase(state: Game, phase: number, turn = state.turn): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 1200 && (game.turn !== turn || game.phase !== phase ||
|
||||
game.phaseOpening || game.response || game.decision); step++) {
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.ok(game.turn <= turn);
|
||||
game = nativeTypedStep(game);
|
||||
}
|
||||
assert.equal(game.turn, turn);
|
||||
assert.equal(game.phase, phase);
|
||||
assert.ok(!game.phaseOpening && !game.response && !game.decision);
|
||||
return game;
|
||||
}
|
||||
export function nativeTypedCustody(game: Game): void {
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
|
||||
...game.players.flatMap(p => p.hand)].map(c => c.id).sort(),
|
||||
treacheryDeck(['ix']).map(c => c.id).sort());
|
||||
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
|
||||
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
|
||||
])].sort(), traitorDeck(game.players, true).sort());
|
||||
for (const p of game.players) {
|
||||
const board = Object.values(p.forces).reduce((sum, n) => sum + n, 0);
|
||||
assert.equal(p.reserves + p.tanks + board, 20);
|
||||
assert.ok(p.reserves >= (p.elites?.reserves ?? 0));
|
||||
assert.ok(p.tanks >= (p.elites?.tanks ?? 0));
|
||||
for (const [key, total] of Object.entries(p.forces)) {
|
||||
assert.ok(Number.isSafeInteger(total) && total >= 0);
|
||||
assert.ok(total >= (p.elites?.forces[key] ?? 0));
|
||||
}
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
||||
for (const [key, elite] of Object.entries(p.elites.forces))
|
||||
assert.ok(Number.isSafeInteger(elite) && elite >= 0 && elite <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
}
|
||||
/** Labelled conserved position: physical reserves -> board, including typed subsets. */
|
||||
export function nativeTypedPlace(game: Game, id: string, key: string, normal: number, elite = 0): void {
|
||||
const player = nativeTypedPlayer(game, id);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= normal);
|
||||
assert.ok((player.elites?.reserves ?? 0) >= elite);
|
||||
player.reserves -= normal + elite;
|
||||
player.forces[key] = (player.forces[key] ?? 0) + normal + elite;
|
||||
if (elite) {
|
||||
player.elites!.reserves -= elite;
|
||||
player.elites!.forces[key] = (player.elites!.forces[key] ?? 0) + elite;
|
||||
}
|
||||
}
|
||||
/** Keep the actual native HMS garrison; return other original board pieces to reserves. */
|
||||
export function nativeTypedClearBoard(game: Game): void {
|
||||
for (const p of game.players) {
|
||||
for (const [key, total] of Object.entries(p.forces)) {
|
||||
if (key === MOBILE_LOCATION) continue;
|
||||
p.reserves += total;
|
||||
delete p.forces[key];
|
||||
if (p.elites) {
|
||||
p.elites.reserves += p.elites.forces[key] ?? 0;
|
||||
delete p.elites.forces[key];
|
||||
}
|
||||
}
|
||||
if (p.advisors) p.advisors = {};
|
||||
}
|
||||
}
|
||||
export function nativeTypedSetup(options: NativeTypedOptions = {}) {
|
||||
let lobby = createGame('DISCOVERYNATIVETYPED', newPlayer('i', 'Ixians', 'ixians'),
|
||||
options.advanced ?? false, ['ix']);
|
||||
for (const [id, faction] of [['t', 'tleilaxu'], ['f', 'fremen'], ['g', 'guild']] as const)
|
||||
joinGame(lobby, newPlayer(id, faction, faction));
|
||||
if (options.advisors) joinGame(lobby, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
if (options.tech) lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
|
||||
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
|
||||
lobby.discoveryEnabled = true;
|
||||
let game = initializeDiscoveryGameForAudit(lobby);
|
||||
const initialized = nativeTypedReload(game);
|
||||
assert.equal(game.deck.length, 47);
|
||||
assert.equal(game.spiceDeck.length, spiceDeck(true).length + DISCOVERY_SPICE_CARDS.length + 1);
|
||||
assert.equal(game.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, 1);
|
||||
assert.equal(game.discoveries!.tokens.length, 8);
|
||||
for (let step = 0; step < 250 && game.status === 'setup'; step++) game = nativeTypedStep(game);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION], 6);
|
||||
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces[MOBILE_LOCATION], 3);
|
||||
assert.equal(nativeTypedPlayer(game, 't').faceDancers!.length, 3);
|
||||
// Conserved unplayed deck order keeps the first two turns free of unrelated worms/Discoveries.
|
||||
const blows = game.spiceDeck.filter(c => 'territory' in c && !c.discovery);
|
||||
assert.ok(blows.length >= 4);
|
||||
for (const [position, card] of blows.slice(0, 4).entries()) {
|
||||
const index = game.spiceDeck.indexOf(card);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
nativeTypedCustody(game);
|
||||
return { lobby, initialized, game };
|
||||
}
|
||||
/** Original setup/phase controls, then explicitly labelled supply-token placement
|
||||
* and reserve-counter position. Collection inspect/reveal is always the real engine producer. */
|
||||
export function nativeTypedCollection(face: DiscoveryTokenFace = 'shrine', owner = 'i',
|
||||
options: NativeTypedOptions = {}) {
|
||||
const setup = nativeTypedSetup(options);
|
||||
let game = nativeTypedPhase(setup.game, 5);
|
||||
nativeTypedClearBoard(game);
|
||||
const token = putDiscovery(game, face);
|
||||
const source = `${token.territory}:${token.sector}`;
|
||||
assert.notEqual(token.sector, game.storm);
|
||||
nativeTypedPlace(game, owner, source, 2, owner === 'i' || (owner === 'f' && game.advanced) ? 1 : 0);
|
||||
game = nativeTypedPhase(game, 7);
|
||||
if (viewGame(game, owner).discoveries!.canInspect.includes(token.id))
|
||||
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: false });
|
||||
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(token.id));
|
||||
nativeTypedCustody(game);
|
||||
return { ...setup, game, token: token.id, source, parent: token.territory!, owner };
|
||||
}
|
||||
export function nativeTypedReveal(state: NativeTypedRevealPosition): Game {
|
||||
return applyAction(state.game, state.owner, { type: 'discovery', token: state.token, reveal: true });
|
||||
}
|
||||
export function nativeTypedEntry(options: NativeTypedOptions = {}, owner = 'i') {
|
||||
const position = nativeTypedCollection('shrine', owner, options);
|
||||
let game = nativeTypedReveal(position);
|
||||
game = nativeTypedPhase(game, 8);
|
||||
for (let step = 0; step < 100 && game.decision?.kind !== 'discoveryEntry'; step++) game = nativeTypedStep(game);
|
||||
assert.equal(game.turn, 2);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(game.decision?.kind, 'discoveryEntry');
|
||||
assert.equal(game.decision?.player, owner);
|
||||
nativeTypedCustody(game);
|
||||
return { ...position, game };
|
||||
}
|
||||
export function nativeTypedCard(game: Game, id: string, predicate: (card: Card) => boolean): Card {
|
||||
const index = game.deck.findIndex(predicate);
|
||||
assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
nativeTypedPlayer(game, id).hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Exchange a genuine private Traitor identity with one genuine native Face Dancer. */
|
||||
export function nativeTypedMatchDancer(game: Game, leader: string): void {
|
||||
const dancers = nativeTypedPlayer(game, 't').faceDancers!;
|
||||
if (dancers.some(c => c.leader === leader && !c.revealed)) return;
|
||||
const dancer = dancers.find(c => !c.revealed)!;
|
||||
assert.ok(dancer);
|
||||
const reserve = game.traitorReserve!.indexOf(leader);
|
||||
if (reserve >= 0) game.traitorReserve![reserve] = dancer.leader;
|
||||
else {
|
||||
const holder = game.players.find(p => p.traitors.includes(leader));
|
||||
assert.ok(holder);
|
||||
holder.traitors[holder.traitors.indexOf(leader)] = dancer.leader;
|
||||
}
|
||||
dancer.leader = leader;
|
||||
}
|
||||
/** Native nested-location battle. Winner cleanup and original Tech rewards are
|
||||
* executed before the original Face Dance choice; no reward/receipt is staged. */
|
||||
export function nativeTypedFaceDance(advanced = false, tech = false) {
|
||||
const entry = nativeTypedEntry({ advanced, tech });
|
||||
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
|
||||
let game = applyAction(entry.game, 'i', { type: 'decision', accept: false, event: entry.game.decision!.event });
|
||||
game = nativeTypedPhase(game, 5);
|
||||
nativeTypedClearBoard(game);
|
||||
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
|
||||
nativeTypedPlace(game, 'g', 'shrine:0', 8);
|
||||
nativeTypedPlace(game, 't', 'shrine:0', 8);
|
||||
nativeTypedPlace(game, 't', entry.source, 3);
|
||||
// A second conserved conflict keeps Battle open after this reward/Face Dance.
|
||||
const other = gameTerritories(game).find(t => t.type === 'sand' &&
|
||||
t.id !== splitLocation(entry.source).territory && !t.sectors.includes(game.storm));
|
||||
assert.ok(other);
|
||||
for (const id of ['g', 't']) nativeTypedPlace(game, id, `${other.id}:${other.sectors[0]}`, 1);
|
||||
const winnerLeader = nativeTypedPlayer(game, 'g').leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
||||
const loserLeader = nativeTypedPlayer(game, 't').leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
nativeTypedMatchDancer(game, winnerLeader.id);
|
||||
const weapon = nativeTypedCard(game, 'g', c => c.kind === 'projectile');
|
||||
const defense = nativeTypedCard(game, 'g', c => c.kind === 'shield');
|
||||
const loserCard = nativeTypedCard(game, 't', c => c.kind === 'worthless');
|
||||
game = nativeTypedPhase(game, 6);
|
||||
game = openSkillsTechBattle(game, 'g', 't', 'shrine');
|
||||
const beforePlans = nativeTypedReload(game);
|
||||
game = applyAction(game, 'g', { type: 'battlePlan', leader: winnerLeader.id, dial: 0, support: 0,
|
||||
weapon: weapon.id, defense: defense.id });
|
||||
game = applyAction(game, 't', { type: 'battlePlan', leader: loserLeader.id, dial: 0, support: 0, weapon: loserCard.id });
|
||||
let cleanup: Game | undefined;
|
||||
for (let step = 0; step < 200 && game.decision?.kind !== 'faceDance'; step++) {
|
||||
if (game.decision?.kind === 'battleCards') {
|
||||
cleanup = nativeTypedReload(game);
|
||||
game = applyAction(game, 'g', { type: 'decision', discard: [defense.id] });
|
||||
} else game = nativeTypedStep(game);
|
||||
}
|
||||
assert.ok(cleanup);
|
||||
assert.equal(game.decision?.kind, 'faceDance');
|
||||
assert.equal(game.decision?.territory, 'shrine');
|
||||
assert.equal(game.decision?.player, 't');
|
||||
nativeTypedCustody(game);
|
||||
return { game, beforePlans, cleanup, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id,
|
||||
weapon, defense, loserCard, source: entry.source, action: {
|
||||
type: 'decision', reveal: true, sources: { reserves: 1, [entry.source]: 2 }, sector: 0,
|
||||
} as Action };
|
||||
}
|
||||
export function nativeTypedMovementTurn(state: Game, owner: string): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 100 && (game.active !== owner || game.decision || game.response || game.phaseOpening); step++) {
|
||||
assert.equal(game.phase, 5);
|
||||
game = nativeTypedStep(game);
|
||||
}
|
||||
assert.equal(game.active, owner);
|
||||
assert.equal(game.phase, 5);
|
||||
return game;
|
||||
}
|
||||
export { MOBILE_LOCATION, MOBILE_STRONGHOLD };
|
||||
+35
-15
@@ -17,6 +17,7 @@ import {
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
initializeHomeworldGameForAudit,
|
||||
initializeHomeworldOccupationGameForAudit,
|
||||
initializeDiscoveryGameForAudit,
|
||||
initializeStrongholdFactionsGameForAudit,
|
||||
initializeNexusGameForAudit,
|
||||
initializePairedNexusGameForAudit,
|
||||
@@ -46,7 +47,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
|
||||
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills'
|
||||
| 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech'
|
||||
| 'paired-nexus-skills-tech' | 'paired-nexus-skills-stronghold' | 'paired-nexus-skills-stronghold-tech'
|
||||
| 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation';
|
||||
| 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation'
|
||||
| 'discovery' | 'discovery-tech';
|
||||
type Rules = 'basic' | 'advanced';
|
||||
|
||||
type Scenario = {
|
||||
@@ -383,6 +385,14 @@ const HOMEWORLD_OCCUPATION_SCENARIOS: readonly Scenario[] = [
|
||||
...ECAZ_SKILLS_SCENARIOS,
|
||||
...MORITANI_SKILLS_SCENARIOS,
|
||||
].map((scenario, index): Scenario => ({ ...scenario, profile: 'homeworld-occupation', ordinal: 2500 + index }));
|
||||
const DISCOVERY_SCENARIOS: readonly Scenario[] =
|
||||
(['discovery', 'discovery-tech'] as const).flatMap((profile, band) =>
|
||||
[...BASE_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS.filter(scenario => scenario.profile !== 'ecaz-nexus'),
|
||||
...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS]
|
||||
.filter(scenario => profile === 'discovery' || scenario.roster.length >= 3)
|
||||
.map((scenario, index): Scenario => ({
|
||||
...scenario, profile, ordinal: 2700 + band * 100 + index,
|
||||
})));
|
||||
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
||||
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
||||
];
|
||||
@@ -460,9 +470,10 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
|
||||
.map((scenario, index): Scenario => ({ ...scenario, ordinal: 376 + band * 100 + index, profile })));
|
||||
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
|
||||
const skillProfile = (profile: string) => e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
|
||||
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech';
|
||||
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
|
||||
scenario.roster.includes('moritani') &&
|
||||
(scenario.profile === 'moritani-assassinate' || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
|
||||
(scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
|
||||
|
||||
type TraceEntry = {
|
||||
attempt: number;
|
||||
@@ -500,7 +511,7 @@ type Result = {
|
||||
function usage() {
|
||||
return (
|
||||
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
|
||||
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
|
||||
'[--seed UINT32] [--profile all|base|discovery|discovery-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
|
||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
||||
'[--resume FAILED_GAME.json]\n' +
|
||||
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
|
||||
@@ -520,6 +531,7 @@ function usage() {
|
||||
+ '\nNexus-skills-tech preserves original Tech3..6 Basic/Advanced with classic/base33/all14/all12; nexus-skills-stronghold preserves original Advanced Strongholds2..6; selecting both requires3..6 Advanced. Real first-Storm/end-Mentat ownership, phase-end industry, held subsidy, typed rescue and mandatory original-winner token transfer remain. Native rosters, HW, Banker/Mentat previews, other overlays, public starts and pending rulings stay separate.'
|
||||
+ '\nPaired-nexus-skills-tech preserves original Tech3..6 Basic/Advanced with both Ixians+Tleilaxu OR CHOAM+Richese and classics, one original family deck/all14/all12. Paired-nexus-skills-stronghold uses Advanced2..6; both requires3..6 Advanced. Native setup, actual closing deals, typed rescue/equal substitution, original signed marker pairs, phase-end industry, held support and original winner reward before Face Dance remain. No mixed families/E3/HW/previews/companions/public starts or new rulings.'
|
||||
+ '\nE3-nexus-skills composes standalone Ecaz OR Moritani and classics, ecaz33/all14/all12, Basic/Advanced2..6. Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Original Duke/assignment and Moritani assassination/normal-traitor-forfeit guards remain, including Advanced Harkonnen exclusion. Real closing draws, first-Storm/end-Mentat custody, source skill effects and original payments/rewards remain. No E3 pair/mixed families/HW/independent cards/previews/public starts or new rulings.'
|
||||
+ '\nDiscovery uses fresh classic, paired E1/E2 or standalone Ecaz OR Moritani setups with original selected decks, six Discovery territory cards, Great Maker and eight physical tokens in Basic/Advanced2..6. Discovery-tech preserves unused original Tech3..6. Native setup, actual token entry, typed force paths, stash and nested location controls reuse original handlers. No Skills/HW/Nexus/Strongholds, mixed E3 families, new contested/shared-payer rulings, public starts or played-game conversion.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -550,6 +562,8 @@ function positive(value: string | undefined) {
|
||||
}
|
||||
|
||||
function scenarioName(scenario: Scenario) {
|
||||
if (discoveryProfile(scenario.profile))
|
||||
return `${scenario.profile}-${scenario.expansions[0] ?? 'classic'}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
|
||||
if (scenario.profile === 'homeworld-occupation')
|
||||
return `${scenario.profile}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
|
||||
if ((scenario.profile === 'skills-tech' || strongholdSkillProfile(scenario.profile)) && scenario.expansions.length) {
|
||||
@@ -590,7 +604,7 @@ function scenarioName(scenario: Scenario) {
|
||||
|
||||
function parseProfile(value: string | undefined) {
|
||||
const profile = value ?? 'all';
|
||||
if (profile === 'homeworld-occupation') return profile;
|
||||
if (profile === 'homeworld-occupation' || discoveryProfile(profile)) return profile;
|
||||
if (!skillProfile(profile) && !nativeTechProfile(profile) && !nexusModuleProfile(profile) && !['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile))
|
||||
throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.');
|
||||
return profile as Profile | 'all';
|
||||
@@ -637,12 +651,16 @@ function freshGame(scenario: Scenario) {
|
||||
player.ready = true;
|
||||
}
|
||||
if (scenario.ecazTreachery) game.ecazTreachery = true;
|
||||
if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
|
||||
if (scenario.profile === 'discovery-tech' || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
|
||||
if (strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
|
||||
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
game.homeworlds = { custody: null };
|
||||
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
if (discoveryProfile(scenario.profile)) {
|
||||
game.discoveryEnabled = true;
|
||||
return initializeDiscoveryGameForAudit(game);
|
||||
}
|
||||
if (scenario.profile === 'homeworld-occupation')
|
||||
return initializeHomeworldOccupationGameForAudit(game);
|
||||
if (scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
@@ -700,27 +718,26 @@ function resumedGame(path: string) {
|
||||
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
|
||||
);
|
||||
const composedSkills = (standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
|
||||
const discovery = game.discoveryEnabled === true && !!game.discoveries;
|
||||
if (
|
||||
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) ||
|
||||
game.discoveryEnabled ||
|
||||
game.discoveries ||
|
||||
game.discoveryStash ||
|
||||
game.greatMaker ||
|
||||
(game.techTokens && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game)) ||
|
||||
((game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker) && !discovery) ||
|
||||
(game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game)) ||
|
||||
game.mentatQuestionPreview
|
||||
)
|
||||
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS].find(
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS].find(
|
||||
(candidate) =>
|
||||
discoveryProfile(candidate.profile) === discovery &&
|
||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
||||
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
|
||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||
(candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||
(candidate.profile === 'discovery-tech' || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||
(nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
||||
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
||||
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
|
||||
(candidate.profile === 'ecaz-occupy' || (nativeTechProfile(candidate.profile) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
|
||||
(candidate.profile === 'ecaz-occupy' || ((nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
|
||||
assassinationScenario(candidate) === !!game.moritaniAssassinate &&
|
||||
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
||||
JSON.stringify(candidate.expansions) ===
|
||||
@@ -927,7 +944,7 @@ async function main() {
|
||||
const profile = parseProfile(values.profile);
|
||||
const rules = parseRules(values.rules);
|
||||
const players = parsePlayers(values.players);
|
||||
if (supplied('players') && profile !== 'homeworld-occupation' && !nexusModuleProfile(profile) && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
|
||||
if (supplied('players') && !discoveryProfile(profile) && profile !== 'homeworld-occupation' && !nexusModuleProfile(profile) && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
|
||||
throw new Error('--players requires a roster or optional-module profile.');
|
||||
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
|
||||
throw new Error(`--profile ${profile} supports only five or six players.`);
|
||||
@@ -937,8 +954,11 @@ async function main() {
|
||||
throw new Error('Stronghold samples require Advanced rules.');
|
||||
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
|
||||
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
|
||||
if (profile === 'discovery-tech' && players === 2)
|
||||
throw new Error('Discovery with Tech Tokens requires three through six players.');
|
||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||
const requestedSamples = profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS
|
||||
const requestedSamples = discoveryProfile(profile) ? DISCOVERY_SCENARIOS
|
||||
: profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS
|
||||
: profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
|
||||
? MODULE_SCENARIOS
|
||||
: pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS
|
||||
|
||||
@@ -40,6 +40,7 @@ async function main() {
|
||||
'\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.',
|
||||
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus/Strongholds, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user