Open supported optional modules through normal lobby previews
This commit is contained in:
1 parent
b8ad2f011f
commit
210aa04811
17 files changed
+275
-78
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Dune — Arrakis Table
|
||||
|
||||
An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** The six classic factions can use a clearly labeled **Advanced preview**; unfinished expansion starts remain disabled. Start with [current status](docs/CURRENT_STATUS.md) for implemented systems, remaining requirements and release gates; detailed historical evidence remains in the [implementation log](docs/IMPLEMENTATION_STATUS.md).
|
||||
An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Basic and clearly labelled Advanced-preview games can start with selected expansion factions and supported optional modules. Unsupported module combinations still reject at start. Start with [current status](docs/CURRENT_STATUS.md) for implemented systems, remaining requirements and release gates; detailed historical evidence remains in the [implementation log](docs/IMPLEMENTATION_STATUS.md).
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -15,10 +15,19 @@ npm run dev -- --host 0.0.0.0
|
||||
Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
|
||||
|
||||
For Advanced play, choose **Rules → Advanced preview** before creating a room,
|
||||
or use the same selector as host in an existing base-game lobby. Everyone must
|
||||
or use the same selector as host in an existing lobby. Everyone must
|
||||
ready again after changing rules, then the host chooses **Begin Advanced preview**.
|
||||
This mode is unfinished; see [preview scope and limits](docs/ADVANCED_PREVIEW.md).
|
||||
|
||||
Choose expansion families when creating the room. Before starting, the host can
|
||||
select **Leader Skills**, **Ecaz Treachery Cards** (the independent three-card
|
||||
variant), **Nexus Cards**, **Discoveries**, **Homeworlds**, **Tech Tokens** or
|
||||
**Stronghold Cards** in the lobby. These are unfinished previews, not complete
|
||||
combination support: setup rejects unsupported selections rather than silently
|
||||
dropping an option. Tech Tokens require three or more seats; Stronghold Cards
|
||||
require Advanced. Changing a selection clears human readiness. Existing games
|
||||
are not converted.
|
||||
|
||||
Advanced uses the user-authorized revised rules: every newly opened Storm
|
||||
after the first draws the Storm Deck, even without Fremen. Finishing your
|
||||
Shipment and Movement sends your own shared allied fighters to Tanks;
|
||||
|
||||
@@ -2046,6 +2046,31 @@ export function GameTable({
|
||||
/>
|
||||
Stronghold Cards · Advanced rules required
|
||||
</label>
|
||||
{g.lobbyModules && ([
|
||||
['leaderSkills', 'Leader Skills · all fourteen skill cards'],
|
||||
['ecazTreachery', 'Ecaz Treachery Cards · Recruits, Reinforcements, Harass & Withdraw'],
|
||||
['nexusCards', 'Nexus Cards'],
|
||||
['discoveries', 'Discoveries'],
|
||||
] as const).map(([module, label]) => (
|
||||
<label className="checkbox-row" key={module}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={g.lobbyModules![module]}
|
||||
disabled={busy || g.host !== me.id}
|
||||
onChange={(e) => act({ type: 'moduleOption', module, enabled: e.target.checked })}
|
||||
/>
|
||||
{label} · preview
|
||||
</label>
|
||||
))}
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!g.homeworlds}
|
||||
disabled={busy || g.host !== me.id}
|
||||
onChange={(e) => act({ type: 'homeworlds', enabled: e.target.checked })}
|
||||
/>
|
||||
Homeworlds · preview
|
||||
</label>
|
||||
{actionButton(
|
||||
me.ready ? 'Not ready' : g.advanced ? 'Ready for Advanced preview' : 'Ready to play',
|
||||
{ type: 'ready' },
|
||||
|
||||
+27
-21
@@ -7,18 +7,19 @@ the rules-completion or publication criteria.
|
||||
## Select and start
|
||||
|
||||
1. On the home page, choose **Rules → Advanced preview**, then **Create a room**.
|
||||
2. Invite players or add AI seats. The preview supports two through six of the
|
||||
six classic factions: Atreides, Harkonnen, Fremen, Emperor, Guild and Bene Gesserit.
|
||||
3. Optionally enable **Tech Tokens** with at least three players, or
|
||||
**Stronghold Cards** in the lobby.
|
||||
2. Choose the desired expansion families, then invite players or add AI seats.
|
||||
Games support two through six players from the selected families and classics.
|
||||
3. Optionally choose **Leader Skills**, **Ecaz Treachery Cards**, **Nexus Cards**,
|
||||
**Discoveries**, **Homeworlds**, **Tech Tokens** (three or more players) or
|
||||
**Stronghold Cards**. Supported module/roster combinations use their existing
|
||||
setup; unsupported selections reject at start with a reason.
|
||||
4. Each human chooses **Ready for Advanced preview**. The host chooses
|
||||
**Begin Advanced preview**.
|
||||
|
||||
The host can also change **Rules** in an existing base-game lobby. Changing modes
|
||||
clears human readiness; native AI seats remain ready. Switching to Basic removes
|
||||
unused Stronghold Cards and keeps Tech Tokens. Rules cannot change after setup
|
||||
starts. Selecting Advanced on the home page clears expansion selections, which
|
||||
are outside this preview. Joining a room uses that room's existing rules.
|
||||
The host can also change **Rules** in an existing lobby. Changing modes or module
|
||||
selections clears human readiness; native AI seats remain ready. Switching to
|
||||
Basic removes unused Stronghold Cards and keeps Tech Tokens. Rules cannot change
|
||||
after setup starts. Joining a room uses that room's existing rules.
|
||||
|
||||
The warning remains visible throughout Advanced play, including older Advanced
|
||||
development saves. Games use the existing room save, reconnect and private seat
|
||||
@@ -30,23 +31,28 @@ Normal room creation already stores `advanced: boolean`; the same request format
|
||||
and exact tab retry are retained. The host-only lobby action
|
||||
`{type: 'rules', advanced: boolean}` changes the mode. The ordinary start action
|
||||
must contain `advancedPreview: true` for Advanced; a bare start remains rejected.
|
||||
Only a successful explicit start writes `Game.advancedPreview: true`.
|
||||
`GameView.advancedPreview` projects the marker as a boolean.
|
||||
Core Advanced starts record `Game.advancedPreview: true`, projected as a boolean.
|
||||
Expansion/module starts retain their own setup markers instead: some module
|
||||
profiles deliberately exclude the core-only marker. The explicit Advanced
|
||||
opt-in remains required for every Advanced start.
|
||||
|
||||
Start calls the genuine shared setup pipeline, including prediction, Traitors,
|
||||
starting forces, advisors, elites and the starting Treachery deal at their normal
|
||||
stages. The live API never dispatches a test/audit initializer. Fresh-lobby and
|
||||
unused-module checks prevent accidental redealing. Restored preview markers must
|
||||
still match their supported mode and roster. Mode changes, starts and ordinary
|
||||
stages. Public starts now call the same genuine setup entries as development
|
||||
tools; this is not permission to dispatch arbitrary initializer/profile names.
|
||||
Fresh-lobby and unused-module checks still prevent accidental redealing. Restored
|
||||
core preview markers must match their mode and roster. Mode changes and ordinary
|
||||
continuation retain the existing version/CAS fence and private views.
|
||||
|
||||
Expansion factions/decks, Homeworlds, Nexus, Discoveries, Leader Skills, the Ecaz
|
||||
Treachery variant and separate audit profiles remain rejected by normal starts.
|
||||
Enabling Advanced cannot bypass those gates. For compatibility with exact older
|
||||
create retries, the create API still accepts the pre-existing expansion lobby
|
||||
format; those unsupported lobbies cannot start and have no expansion-removal
|
||||
control yet. The preview creation UI prevents that combination. No pending ruling is resolved by
|
||||
this access change; see the [decision index](RULE_DECISIONS.md).
|
||||
The 17 October lobby-access pass opens expansion factions and supported optional
|
||||
modules as previews; it does not remove their existing composition guards.
|
||||
Skills and the independent Ecaz cards use lobby-only selections consumed during
|
||||
start; the other modules retain their existing undealt lobby shape. Every
|
||||
selected module must be honoured or the start rejected. Ecaz with Homeworlds
|
||||
alone remains unavailable because its combined Occupy battle is not supported.
|
||||
Special development profiles remain separate. No pending rules ruling or
|
||||
complete-mode/publication gate is resolved by opening these controls; see the
|
||||
[decision index](RULE_DECISIONS.md).
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
|
||||
+27
-5
@@ -1,8 +1,8 @@
|
||||
# Current development status
|
||||
|
||||
Updated 17 October 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||
Expansion starts and full rules/publication acceptance remain gated.**
|
||||
is playable. Advanced, expansion-faction and supported optional-module starts
|
||||
are labelled previews; complete rules/publication acceptance remains gated.**
|
||||
|
||||
The user-supplied `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` is now
|
||||
an [authorized Advanced source of truth](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026),
|
||||
@@ -25,15 +25,37 @@ Advanced standalone core-function inventories are closed at prototype level.
|
||||
Bounded classic/expansion mapping plus the connected follow-ups below found
|
||||
no further absent standalone faction primitive in the examined paths.
|
||||
This is not complete-mode acceptance: exceptional custody, timing variants,
|
||||
optional skill bands, combinations and public expansion starts remain open.
|
||||
optional skill bands and combinations remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Optional modules from the normal lobby — 17 October (user-directed speed pass).**
|
||||
The host can now tick Leader Skills, Ecaz Treachery Cards, Nexus Cards,
|
||||
Discoveries and Homeworlds as labelled previews alongside Tech Tokens and
|
||||
Stronghold Cards. A normal start routes to the existing genuine setup for that
|
||||
selection; each module's own admission still decides what combinations are
|
||||
supported, and an unsupported choice is rejected at start with its reason. Ecaz
|
||||
with Homeworlds alone stays unavailable (no combined Occupy composition yet).
|
||||
Lobby choices for Skills and the independent cards live in `lobbyOptions` and
|
||||
are consumed at start. A 240-start sweep (Basic/Advanced × classic/Ix/CHOAM/Ecaz
|
||||
× ten selections, repeated three times) exercised 183 all-AI games to turn 3 or
|
||||
an earlier win; the other 57 starts were explicit admission rejections. It found
|
||||
no runtime crash after two fixes: Basic won Ecaz Occupy carries variable-army
|
||||
casualties through mandatory card disposal, and bots with untyped forces round
|
||||
fractional target dials to legal whole counters. Unsupported independent-card
|
||||
combinations now reject the whole start instead of dropping the card selection.
|
||||
Final proof: types/lint, **9,866/9,866 offline tests** and build pass. Seven
|
||||
post-integration gameplay samples reach turn 3 or an earlier win with selected
|
||||
modules active. Real390px lobby rejection/correction/start/skill assignment has
|
||||
no browser errors or overflow. Ecaz setup now checks its existing combined
|
||||
Occupy admission before starting, including World+Skills choices. Local QA only;
|
||||
no deployed claim or full-combination certification.
|
||||
|
||||
**Expansion-faction lobby starts open — 17 October (user-directed speed pass).**
|
||||
Hosts may now start Basic or Advanced-preview games with Ix, CHOAM and/or Ecaz
|
||||
factions from the normal lobby; the start routes through the existing native
|
||||
faction setup. Optional Tech Tokens follow the native profile; Stronghold Cards,
|
||||
Homeworlds, Nexus, Discovery, Skills and independent cards keep their dev entries.
|
||||
faction setup. Optional modules now use the labelled lobby controls described
|
||||
above, retaining each native setup's supported roster and composition limits.
|
||||
Eight all-AI 10-turn games (Basic/Advanced × Ix, CHOAM, Ecaz, all-three six-seat)
|
||||
finished with no stuck or rejected actions. The lobby note warns of unfinished
|
||||
interactions. **Regression triage (17 October):** the 133 failures that began
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 17 October 2026 — Normal-lobby module previews and legal continuation
|
||||
|
||||
Hosts can select Leader Skills, the independent three-card Ecaz variant,
|
||||
Nexus Cards, Discoveries and Homeworlds beside Tech Tokens and Stronghold
|
||||
Cards. Normal starts reuse the genuine fresh setup entries and retain their
|
||||
roster/module constraints; Advanced still requires explicit preview opt-in.
|
||||
Lobby-only Skills/card selections are consumed at start rather than saved as
|
||||
dealt components. Unsupported combined selections reject without dropping an
|
||||
option. Ecaz admission also checks that the selected setup can resolve its
|
||||
mandatory allied Occupy battle before committing setup.
|
||||
|
||||
Changed-path gameplay exposed two real defects: a Basic Occupy winner's named
|
||||
variable-army casualties were missing from mandatory card-disposal validation,
|
||||
and ordinary Basic bots could copy a fractional target dial into an illegal
|
||||
whole-counter plan. Both now continue legally. A next-turn Salusa shipment test
|
||||
now uses Polar Sink rather than a sector which the genuine next Storm may block.
|
||||
|
||||
Earlier bounded sweep: 183 played runs and 57 explicit admission rejections
|
||||
across 240 attempts. Final seven-game lobby smoke reaches turn 3 or an earlier
|
||||
win with every requested module active. A real 390px local browser journey
|
||||
rejects incompatible cards+Nexus with both checkboxes retained, clears readiness
|
||||
on correction, starts Skills+independent cards and assigns a real offered skill
|
||||
before reaching Traitor selection. No browser errors or horizontal overflow.
|
||||
|
||||
Final types/lint, all **9,866 offline tests** and production build pass. Existing
|
||||
Node deprecation, client chunk-size and Vinext route-classification warnings
|
||||
remain. The full suite is a justified integration check for the newly opened
|
||||
start paths, not a new per-prototype schedule. Only dedicated local QA rooms
|
||||
were created/advanced; no game was reset or deleted and nothing was deployed.
|
||||
Complete combinations, custody/recovery assurance and release acceptance remain
|
||||
open. See [lobby access and limits](ADVANCED_PREVIEW.md).
|
||||
|
||||
## 17 October 2026 — Mandatory normal Banker default
|
||||
|
||||
An ordinary supported all14 Skills setup previously completed a Bank-four
|
||||
|
||||
+3
-1
@@ -897,7 +897,7 @@ function plans(g: GameView): Action[] {
|
||||
battleSkills, myLeader?.id, !effects.attackerDead,
|
||||
leaderSkillStrongholdCount(g, other),
|
||||
);
|
||||
const ideal = stoneBattle
|
||||
const rawIdeal = stoneBattle
|
||||
? 0
|
||||
: level === 0
|
||||
? Math.floor(variation(g, `${leader}-${weapon}`) * (own + 1))
|
||||
@@ -913,6 +913,8 @@ function plans(g: GameView): Action[] {
|
||||
(b.tieWinner === me.id ? 0 : typedForces ? 0.5 : 1),
|
||||
),
|
||||
);
|
||||
// Untyped forces dial whole counters even against a half-strength opponent.
|
||||
const ideal = typedForces ? rawIdeal : Math.min(ownStrength, Math.ceil(rawIdeal));
|
||||
for (const dial of new Set([
|
||||
ideal,
|
||||
...bankSupportedDials,
|
||||
|
||||
+82
-21
@@ -2261,6 +2261,8 @@ export type Game = {
|
||||
freeRevival: string[];
|
||||
/** Presence enables the independently selected three-card Ecaz variant. */
|
||||
ecazTreachery?: true;
|
||||
/** Lobby-only module selections; consumed by the genuine setup at start. */
|
||||
lobbyOptions?: LobbyOptions;
|
||||
/** Public, turn-scoped receipt for the discarded Recruits card. */
|
||||
recruits?: {
|
||||
turn: number;
|
||||
@@ -7245,6 +7247,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(g.advanced ||
|
||||
!!g.pendingSukRescue ||
|
||||
!!g.homeworlds ||
|
||||
// A won Basic Ecaz Occupy assigns losses to its named variable-army owner.
|
||||
(!!c.casualties?.owner && byFaction(g, 'ecaz') !== undefined) ||
|
||||
g.players.find((p) => p.id === c.winner)?.faction ===
|
||||
'ixians')),
|
||||
'The mandatory battle discard cause is inconsistent.',
|
||||
@@ -8395,25 +8399,53 @@ function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
|
||||
);
|
||||
return lostCyborgs;
|
||||
}
|
||||
/** Lobby-only selections whose saved setup shape is created at start. */
|
||||
type LobbyOptions = { leaderSkills?: true; ecazTreachery?: true };
|
||||
function lobbyOptionsIntegrity(g: Game) {
|
||||
const options = g.lobbyOptions;
|
||||
if (options === undefined) return;
|
||||
requireRule(g.status === 'lobby' && !!options && typeof options === 'object' && !Array.isArray(options) &&
|
||||
Object.entries(options).every(([key, value]) => ['leaderSkills', 'ecazTreachery'].includes(key) && value === true),
|
||||
'Invalid lobby module options.');
|
||||
}
|
||||
function lobbyModulesSelected(g: Game): boolean {
|
||||
return !!g.lobbyOptions?.leaderSkills || !!g.lobbyOptions?.ecazTreachery ||
|
||||
!!g.nexusCards || g.discoveryEnabled === true || !!g.homeworlds;
|
||||
}
|
||||
/** Route a normal start through the existing genuine setup for the selected modules. */
|
||||
function startModules(state: Game): Game {
|
||||
const options = state.lobbyOptions ?? {};
|
||||
const g = structuredClone(state);
|
||||
delete g.lobbyOptions;
|
||||
if (options.ecazTreachery && options.leaderSkills) g.ecazTreachery = true;
|
||||
if (options.leaderSkills) return initializeLeaderSkillsGameForAudit(g);
|
||||
// Keep this selection visible to its initializer even when another module was
|
||||
// also selected; unsupported combinations must reject, not silently omit cards.
|
||||
if (options.ecazTreachery) return initializeEcazTreacheryGameForAudit(g);
|
||||
if (g.nexusCards) {
|
||||
const natives: Record<string, [string, string]> = { ix: ['ixians', 'tleilaxu'], choam: ['choam', 'richese'], ecaz: ['ecaz', 'moritani'] };
|
||||
const pair = g.expansions.length === 1 ? natives[g.expansions[0]] : undefined;
|
||||
return pair && pair.every(id => g.players.some(p => p.faction === id))
|
||||
? initializePairedNexusGameForAudit(g) : initializeNexusGameForAudit(g);
|
||||
}
|
||||
if (g.discoveryEnabled) return initializeDiscoveryGameForAudit(g);
|
||||
if (g.homeworlds)
|
||||
return g.expansions.length ? initializeHomeworldOccupationGameForAudit(g) : initializeHomeworldGameForAudit(g);
|
||||
if (g.strongholdCards) return initializeStrongholdFactionsGameForAudit(g);
|
||||
return initializeFactionExpansionsGameForAudit(g);
|
||||
}
|
||||
/** Public consistency only; each module's genuine initializer validates its own envelope. */
|
||||
function baseStartProfileBlock(g: Game): string | null {
|
||||
if (g.homeworlds) return 'Homeworld gameplay is still being implemented.';
|
||||
if (g.nexusCards) return 'Nexus card effects are still being implemented.';
|
||||
if (g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker)
|
||||
return 'Discoveries are still being implemented.';
|
||||
if (g.leaderSkills) return 'Leader Skills are still being implemented.';
|
||||
if (g.ecazTreachery) return 'Ecaz Treachery Cards are still being implemented.';
|
||||
if (g.moritaniAssassinatePreview || g.moritaniAssassinate)
|
||||
if (g.status === 'lobby' && (g.moritaniAssassinatePreview || g.moritaniAssassinate))
|
||||
return 'Separate development profiles cannot be enabled by a normal start.';
|
||||
if (g.status === 'lobby' && (g.leaderSkills || g.ecazTreachery))
|
||||
return 'Choose Leader Skills or Ecaz Treachery Cards with the lobby option; a lobby cannot carry dealt module state.';
|
||||
const expansionFactions = g.expansions.length > 0 ||
|
||||
!g.players.every(p => FACTIONS.some(entry => entry.id === p.faction && entry.expansion === 'base'));
|
||||
if (expansionFactions && !g.players.every(p => {
|
||||
const family = FACTIONS.find(entry => entry.id === p.faction)?.expansion;
|
||||
return family === 'base' || (!!family && g.expansions.includes(family));
|
||||
})) return 'Expansion factions require their expansion to be selected.';
|
||||
if (expansionFactions && g.strongholdCards)
|
||||
return 'Stronghold Cards with expansion factions still use a development profile.';
|
||||
if (expansionFactions && g.techTokens && !nativeFactionTechProfile(g))
|
||||
return 'Tech Tokens with expansion factions need a supported three-through-six-seat native roster.';
|
||||
return null;
|
||||
}
|
||||
function advancedPreviewIntegrity(g: Game) {
|
||||
@@ -8483,10 +8515,14 @@ function start(g: Game, advancedPreview = false): Game {
|
||||
requireRule(!advancedPreview || g.advanced, 'Choose Advanced preview rules before starting that preview.');
|
||||
const blocked = baseStartProfileBlock(g);
|
||||
requireRule(!blocked, blocked ?? 'This rules profile is unavailable.');
|
||||
if (g.expansions.length) {
|
||||
// Expansion factions use the genuine native faction setup (elites, decks, cache).
|
||||
const initialized = initializeFactionExpansionsGameForAudit(g);
|
||||
if (advancedPreview) initialized.advancedPreview = true;
|
||||
if (g.expansions.length || lobbyModulesSelected(g)) {
|
||||
// Expansions and optional modules use their existing genuine setup paths. The explicit
|
||||
// Advanced opt-in was checked above; their own profiles define the saved marker set.
|
||||
const initialized = startModules(g);
|
||||
// An admitted Ecaz setup must also be able to resolve its mandatory allied
|
||||
// battle. Some development setups still exclude that module composition.
|
||||
requireRule(!byFaction(initialized, 'ecaz') || ecazOccupyCompositionSupported(initialized),
|
||||
'This Ecaz module selection does not yet support combined Occupy battles. Change the selected modules before starting.');
|
||||
return initialized;
|
||||
}
|
||||
requireFreshSetup(g);
|
||||
@@ -9515,7 +9551,7 @@ function advanceSetup(g: Game) {
|
||||
export function initializeBaseGameForAudit(state: Game): Game {
|
||||
return initializeSetupGameForAudit(state, false);
|
||||
}
|
||||
/** Offline-only seam through the real setup pipeline. Public Homeworld starts remain gated. */
|
||||
/** Shared genuine Homeworld setup for lobby previews and offline development. */
|
||||
export function initializeHomeworldGameForAudit(state: Game): Game {
|
||||
requireRule(
|
||||
!!state.homeworlds,
|
||||
@@ -9555,7 +9591,7 @@ export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSki
|
||||
if (byFaction(g, 'ecaz')) g.homeworldEcazVictoryPreview = true;
|
||||
return g;
|
||||
}
|
||||
/** Original classic and supported native Nexus setup; public starts remain gated. */
|
||||
/** Shared classic/supported-native Nexus setup; existing composition guards apply. */
|
||||
export function initializeNexusGameForAudit(state: Game): Game {
|
||||
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
|
||||
'Enable Nexus cards in a fresh audit lobby first.');
|
||||
@@ -10387,7 +10423,7 @@ function decideGuildBetrayal(g: Game, p: Player, action: Action): void {
|
||||
recipient: settlement.recipient, amount: frame.invoice.price})) return;
|
||||
}
|
||||
}
|
||||
/** Offline paired-faction Nexus setup; public expansion/module starts remain gated. */
|
||||
/** Shared paired-faction Nexus setup for supported lobby and development previews. */
|
||||
export function initializePairedNexusGameForAudit(state: Game): Game {
|
||||
const expansion = state.expansions[0];
|
||||
requireRule(state.expansions.length === 1 &&
|
||||
@@ -10525,7 +10561,7 @@ function nativeStrongholdFactionRoster(g: Game): boolean {
|
||||
g.players.every(p => faction(p.faction).expansion === 'base' ||
|
||||
g.expansions.includes(faction(p.faction).expansion));
|
||||
}
|
||||
/** Fresh native Stronghold composition; other overlays and public starts stay gated. */
|
||||
/** Shared fresh native Stronghold setup; other overlays retain their own admission. */
|
||||
export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
|
||||
requireRule(e3StrongholdFactionProfile(state) || nativeStrongholdFactionRoster(state),
|
||||
'Stronghold factions require supported Advanced native factions, classic opponents and their selected distinct decks.');
|
||||
@@ -10648,7 +10684,7 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
if (g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g);
|
||||
return g;
|
||||
}
|
||||
/** Gated development setup; never dispatched by a player action or room route. */
|
||||
/** Shared fresh Leader Skills setup, including supported independent module selections. */
|
||||
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
||||
const homeworlds = homeworldLeaderSkillsProfile(state);
|
||||
@@ -10718,7 +10754,7 @@ function independentCardModulesProfile(g: Game): boolean {
|
||||
g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview) return false;
|
||||
return true;
|
||||
}
|
||||
/** Gated development setup for the independent three-card Ecaz variant. */
|
||||
/** Shared fresh setup for the independent three-card Ecaz variant. */
|
||||
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
|
||||
const g = structuredClone(state);
|
||||
@@ -29691,6 +29727,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
harkonnenExchangeIntegrity(state);
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
lobbyOptionsIntegrity(state);
|
||||
leaderSkillsIntegrity(state);
|
||||
moritaniExtortionIntegrity(state);
|
||||
guildRateIntegrity(state);
|
||||
@@ -30096,6 +30133,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
harkonnenExchangeIntegrity(state);
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
lobbyOptionsIntegrity(state);
|
||||
leaderSkillsIntegrity(state);
|
||||
moritaniExtortionIntegrity(state);
|
||||
guildRateIntegrity(state);
|
||||
@@ -32303,6 +32341,24 @@ function applyActionInner(
|
||||
g.players.forEach((player) => (player.ready = !!player.bot));
|
||||
return g;
|
||||
}
|
||||
if (t === 'moduleOption') {
|
||||
requireRule(id === g.host, 'Only the host can change optional rules.');
|
||||
const option = action.module;
|
||||
requireRule(['leaderSkills', 'ecazTreachery', 'nexusCards', 'discoveries'].includes(option as string) &&
|
||||
typeof action.enabled === 'boolean' && Object.keys(action).every(key => ['type', 'module', 'enabled'].includes(key)),
|
||||
'Choose an optional module to enable or disable.');
|
||||
if (option === 'nexusCards') g.nexusCards = action.enabled ? { cards: null, phase: null } : null;
|
||||
else if (option === 'discoveries') {
|
||||
if (action.enabled) g.discoveryEnabled = true; else delete g.discoveryEnabled;
|
||||
} else {
|
||||
const options: LobbyOptions = { ...g.lobbyOptions };
|
||||
if (action.enabled) options[option as keyof LobbyOptions] = true;
|
||||
else delete options[option as keyof LobbyOptions];
|
||||
if (Object.keys(options).length) g.lobbyOptions = options; else delete g.lobbyOptions;
|
||||
}
|
||||
g.players.forEach((player) => (player.ready = !!player.bot));
|
||||
return g;
|
||||
}
|
||||
if (t === 'addBot' || t === 'removeBot') {
|
||||
requireRule(id === g.host, 'Only the host can manage AI seats.');
|
||||
if (t === 'addBot') {
|
||||
@@ -34214,6 +34270,7 @@ export function viewGame(state: Game, id: string) {
|
||||
harkonnenExchangeIntegrity(state);
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
lobbyOptionsIntegrity(state);
|
||||
leaderSkillsIntegrity(state);
|
||||
moritaniExtortionIntegrity(state);
|
||||
guildRateIntegrity(state);
|
||||
@@ -34619,6 +34676,10 @@ export function viewGame(state: Game, id: string) {
|
||||
}
|
||||
: null,
|
||||
techTokens: g.techTokens ?? null,
|
||||
lobbyModules: g.status === 'lobby' ? {
|
||||
leaderSkills: !!g.lobbyOptions?.leaderSkills, ecazTreachery: !!g.lobbyOptions?.ecazTreachery,
|
||||
nexusCards: !!g.nexusCards, discoveries: g.discoveryEnabled === true,
|
||||
} : null,
|
||||
ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null,
|
||||
ecazOccupyPreview: !!g.ecazOccupyPreview,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
|
||||
+1
-1
@@ -2176,7 +2176,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. Expansion and optional-module setup remain unfinished.',
|
||||
'Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. The normal lobby now starts selected expansions and the Leader Skills, Ecaz Treachery Card, Nexus, Discovery, Homeworld, Tech and Stronghold modules through their existing genuine setups as labelled previews; each module still rejects unsupported combinations at start, and Ecaz with Homeworlds alone remains unavailable.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
|
||||
@@ -121,6 +121,30 @@ void test('only the host can select a boolean rules mode, changes clear human re
|
||||
completedSetup(act(ready(game), game.host, { type: 'start' }));
|
||||
});
|
||||
|
||||
void test('incompatible selected modules reject the whole start instead of silently omitting independent cards', () => {
|
||||
for (const option of ['nexusCards', 'discoveries']) {
|
||||
let game = act(lobby(['atreides', 'emperor']), 'atreides',
|
||||
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
game = act(game, game.host, { type: 'moduleOption', module: option, enabled: true });
|
||||
game = ready(game);
|
||||
reject(game, game.host, { type: 'start' });
|
||||
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
|
||||
}
|
||||
});
|
||||
|
||||
void test('an Ecaz Homeworld Skills selection cannot start without supported combined Occupy battles', () => {
|
||||
let game = createGame('ECAZLOBBYMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
game = act(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
game = act(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
|
||||
game = ready(game);
|
||||
reject(game, game.host, preview);
|
||||
assert.equal(game.status, 'lobby');
|
||||
assert.equal(game.homeworlds!.custody, null);
|
||||
assert.equal(game.leaderSkills, undefined);
|
||||
});
|
||||
|
||||
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
|
||||
const game = ready(lobby(BASE, true));
|
||||
reject(game, game.host, { type: 'start' });
|
||||
@@ -147,9 +171,6 @@ void test('the explicit preview keeps inconsistent rosters, modules and separate
|
||||
...FACTIONS.filter(entry => entry.expansion !== 'base').map(entry => (game: Game) => {
|
||||
game.players[1] = { ...newPlayer(game.players[1].id, entry.name, entry.id), ready: true };
|
||||
}),
|
||||
game => { game.homeworlds = { custody: null }; },
|
||||
game => { game.nexusCards = { cards: null, phase: null }; },
|
||||
game => { game.discoveryEnabled = true; },
|
||||
game => { game.leaderSkills = createLeaderSkills(() => 0.25); },
|
||||
game => { game.ecazTreachery = true; },
|
||||
game => { game.moritaniAssassinatePreview = true; },
|
||||
|
||||
@@ -122,6 +122,36 @@ for (const ecazForces of [2, 4]) for (const lead of ['ecaz', 'ally'] as const)
|
||||
assert.equal(game.phase, 7, 'Surviving allies coexist into original Collection without resettlement.');
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic ${lead} lead resolves a mandatory Hero discard before its variable-army casualties`, () => {
|
||||
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
|
||||
const hero = stageEcazOccupyCard(game, fixture.opponent, card => card.kind === 'hero');
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, actor, {
|
||||
type: 'battlePlan', dial: 4, leader: leader(game, actor, true),
|
||||
});
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, leader: hero });
|
||||
while (game.battle) {
|
||||
const view = viewGame(game, actor).battle!;
|
||||
const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
|
||||
assert.ok(voter, 'The real revealed battle must retain its Traitor voters.');
|
||||
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
||||
}
|
||||
game = aftermath(game);
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(game.discard.filter(card => card.id === hero).length, 1);
|
||||
assert.equal(seat(game, fixture.opponent).hand.some(card => card.id === hero), false);
|
||||
for (const id of [fixture.ecaz, fixture.ally]) {
|
||||
assert.equal(forces(game, fixture, id), 2);
|
||||
assert.equal(seat(game, id).tanks - seat(before, id).tanks, 2);
|
||||
}
|
||||
assert.equal(forces(game, fixture, fixture.opponent), 0);
|
||||
assert.equal(game.phase, 7);
|
||||
});
|
||||
|
||||
for (const allyFaction of ['emperor', 'fremen'] as const) for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Basic ${allyFaction} ${lead} lead uses native ordinary inventory without Advanced elite strength`, () => {
|
||||
const fixture = basicEcazOccupyFixture({ allyFaction, ecazForces: 4 });
|
||||
|
||||
@@ -70,9 +70,6 @@ void test('fresh E2 Discovery uses the original 35 ordinary cards, real cache, s
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ function settle(state: Game): 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 });
|
||||
const { game, initialized } = nativeTypedSetup({ advanced, tech });
|
||||
assert.equal(game.discoveryEnabled, true);
|
||||
assert.equal(game.leaderSkills, undefined);
|
||||
assert.equal(game.strongholdCards, undefined);
|
||||
@@ -63,9 +63,6 @@ void test('fresh E1 Discovery uses original 47-card setup, Sandtrout, seven card
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -205,10 +205,6 @@ for (const [advanced, seed] of [
|
||||
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Discovery seed ${seed} completes through JSON bot batches with conserved pieces`, () =>
|
||||
withEngineSeed(seed, () => {
|
||||
const lobby = discoveryLobby(advanced);
|
||||
const publicBefore = structuredClone(lobby);
|
||||
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
|
||||
assert.deepEqual(lobby, publicBefore);
|
||||
|
||||
let game = initializeDiscoveryGameForAudit(lobby);
|
||||
game.players.forEach((player, index) => {
|
||||
player.bot = DIFFICULTIES[index];
|
||||
|
||||
@@ -37,12 +37,9 @@ function custody(g: Game) {
|
||||
);
|
||||
validateDiscoveryState(g.discoveries!);
|
||||
}
|
||||
void test('Discovery real setup deals seven spice cards and eight opaque tokens while normal starts remain gated', () => {
|
||||
void test('Discovery real setup deals seven spice cards and eight opaque tokens', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const lobby = discoveryLobby(advanced),
|
||||
before = structuredClone(lobby);
|
||||
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
|
||||
assert.deepEqual(lobby, before);
|
||||
const lobby = discoveryLobby(advanced);
|
||||
const g = initializeDiscoveryGameForAudit(lobby);
|
||||
assert.equal(g.spiceDeck.length, spiceDeck().length + 7);
|
||||
assert.equal(
|
||||
|
||||
@@ -144,16 +144,19 @@ void test('Advanced Salusa low→high return does not retroactively rescore seal
|
||||
g => g.turn === 2 && g.phase === 5 && g.active === f.owner && harassHomeworldArrakisClean(g));
|
||||
assert.ok(viewGame(next, f.owner).homeworldMove, 'The native next movement control remains available');
|
||||
const before = structuredClone(next);
|
||||
next = applyAction(next, f.owner, { type: 'ship', territory: f.territory,
|
||||
sector: Number(f.location.split(':').at(-1)), amount: 1, elite: 1, allyPayment: 0,
|
||||
// The next real Storm can cover the original battle sector. Polar Sink is a
|
||||
// legal storm-independent destination; the assertion here is native reserve use.
|
||||
const destination = 'polar_sink:0';
|
||||
next = applyAction(next, f.owner, { type: 'ship', territory: 'polar_sink',
|
||||
sector: 0, amount: 1, elite: 1, allyPayment: 0,
|
||||
homeworldSources: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
|
||||
next = advanceHarassHomeworldArrakis(next, harassHomeworldArrakisClean);
|
||||
assert.equal(player(next, f.owner).reserves, player(before, f.owner).reserves - 1);
|
||||
assert.equal(player(next, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves - 1);
|
||||
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor'), pool(before, f.owner, 'homeworld:emperor'));
|
||||
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 3 });
|
||||
assert.equal(player(next, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(next, f.owner).elites!.forces[f.location], 1);
|
||||
assert.equal(player(next, f.owner).forces[destination], 1);
|
||||
assert.equal(player(next, f.owner).elites!.forces[destination], 1);
|
||||
assert.equal(player(next, f.owner).tanks, player(before, f.owner).tanks);
|
||||
assertHarassHomeworldArrakisCustody(next);
|
||||
});
|
||||
@@ -207,7 +207,7 @@ function assertCardCustody(g: Game) {
|
||||
assert.equal(g.ixSetupCards ?? null, null);
|
||||
}
|
||||
|
||||
void test('Homeworld lobby choice belongs to the host, resets readiness, and preserves both public start gates', () => {
|
||||
void test('Homeworld lobby choice belongs to the host, resets readiness, and opens the genuine Homeworld start', () => {
|
||||
let g = lobby(['atreides', 'emperor'], false, false);
|
||||
rejectUnchanged(g, 'emperor', { type: 'homeworlds', enabled: true });
|
||||
rejectUnchanged(g, g.host, { type: 'homeworlds', enabled: 1 });
|
||||
@@ -215,7 +215,7 @@ void test('Homeworld lobby choice belongs to the host, resets readiness, and pre
|
||||
assert.deepEqual(g.homeworlds, { custody: null });
|
||||
assert.ok(g.players.every((p) => !p.ready));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
rejectUnchanged(g, g.host, { type: 'start' });
|
||||
assert.ok(applyAction(g, g.host, { type: 'start' }).homeworlds, 'A normal start keeps the selected Homeworld module');
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => initializeBaseGameForAudit(g));
|
||||
assert.deepEqual(g, before);
|
||||
|
||||
@@ -170,7 +170,7 @@ void test('orphaned/corrupt source receipts fail before projection, normalizatio
|
||||
}
|
||||
});
|
||||
|
||||
void test('initializer admits only explicit fresh CHOAM/Richese Nexus composition and does not bypass public starts', () => {
|
||||
void test('initializer admits only explicit fresh CHOAM/Richese Nexus composition', () => {
|
||||
function lobby() {
|
||||
let g = createGame('RICHESEADMISSION',newPlayer('r','Richese','richese'),false,['choam']);
|
||||
joinGame(g,newPlayer('c','CHOAM','choam'));
|
||||
@@ -194,7 +194,6 @@ void test('initializer admits only explicit fresh CHOAM/Richese Nexus compositio
|
||||
const fresh = lobby(), initialized = initializeRicheseBetrayalGameForAudit(fresh);
|
||||
assert.equal(initialized.richeseBetrayalPreview,true);
|
||||
assert.deepEqual(initialized.richeseBetrayal,{sequence:0,current:null,completed:[]});
|
||||
reject(fresh,'r',{type:'start'});
|
||||
});
|
||||
|
||||
void test('diversion keeps native bonus cancellation and paid-sale ownership through JSON restore without replaying payment', () => {
|
||||
|
||||
Reference in new issue
Block a user