Implement Advanced Ecaz Loyalty with public card inspection
Before genuine Advanced Ecaz Traitor dealing, randomly set aside one of the five native Ecaz cards face up for the game. Store its owner and exact identity once; exclude it from the initial deck, Tleilaxu Face Dancer setup and Nexus replacement universes. Do not change leader custody or add choices, payment or Karama windows. Absent markers preserve older already-dealt saves without taking held cards away. Validate owner, timing and distinct physical custody before actions, normalization and projection. Expose a detached public card identity, never private deck order. Show the named card, portrait and enlarged Traitor inspection above the table. A dedicated Loyalty context explains public set-aside custody and cannot-call rules; browser review caught and fixed the generic held-Traitor guidance. Add internal help, checklist, shared Advanced faction text and source documentation. The official page8 requirement and page16 noKarama entry support the effect; relative pre-Traitor prediction/skill sequencing is an explicit composition. Gated Skills/Ecaz and Moritani assassination/Ecaz are not certified. Twelve new cases plus related regressions cover candidate selection, immutable invalid-state rejection,2..6 genuine deals, Basic/noEcaz, completed Tleilaxu setup, Harkonnen/Tleilaxu Nexus draws, full physical census, detached public views, all4 legal AI setups, JSON continuation, legacy custody and public rendering. Independent review found no material issue after genuine setup/legacy test fixes and the focused inspector correction.50 related focused cases pass. A mistyped filename filter failed closed before the corrected union ran successfully. Types, lint, all 5012 offline tests, production build and 49 HTTP tests pass on stable source. Earlier type/lint findings were corrected before this run. Six genuine faction-game samples finish with no rejected actions and repeated JSON restoration: CHOAM Basic1054/Advanced225 actions, Ecaz Basic91/Advanced245, combined Basic476/Advanced704. Existing physical Treachery/force custody checks pass; focused Loyalty tests cover expansion Traitor census. These bounded legal samples are not strength calibration or complete expansion acceptance. Browser QA created a fresh Ecaz/Easy Harkonnen ready lobby and initialized its Advanced setup before dealing using backup and CAS. Bindikk Narvi was public before choices and remained excluded through actual human Traitor/force setup, Storm and refresh. The saved human Traitor is Beast Rabban; a pointer action just after viewport reset differed from its intended Feyd target, so exact-target pointer acceptance across viewport changes is not claimed. Ordinary source bindings remain name/ID-specific. One hand card,12spice,14reserves and the pending Storm dial restore; both public projections agree and saved seat records match. The Loyalty inspector shows correct card-specific guidance; Escape restores focus. At390px its panel has no horizontal overflow,16px text and44/48px controls; the expanded scroll body fits342px. Internal help opens its complete checklist topic. Normal viewport restored. Screenshot capture timed out; full visual, long-inspector keyboard and broader module acceptance remain open. All 1539 pre-existing rooms, 3259 human seat records and auxiliary rows remain unchanged. SQLite integrity and foreign keys pass. Only isolated QA/HTTP fixtures add rooms. No server restart, database reset, migration, recurring automation, AI strategy change or mode/publication gate change. Private evidence: 20260921-gameplay-review. Verified source tree: 881bf1912665350086d80be664d479a1b2724146da6ccc9576790c07e0449ee6
This commit is contained in:
1 parent
c2f24fc546
commit
de26aaf029
18 files changed
+490
-32
No files matched your search
@@ -73,6 +73,10 @@ Use **Inspect faction** under any player for public powers and alliance guidance
|
||||
The sheet starts in the table’s rules mode and offers an Advanced preview without
|
||||
changing the game. Browse all twelve in the [faction-sheet gallery](docs/FACTION_SHEETS.md).
|
||||
|
||||
In new Advanced Ecaz development games, **Loyalty** shows the publicly set-aside
|
||||
Traitor Card before dealing. Inspect it without making a game choice; its identity
|
||||
persists through refresh. See [Ecaz Loyalty](docs/ECAZ_LOYALTY.md).
|
||||
|
||||
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
||||
counts separately from hands, auction cards and discards. See
|
||||
[draw-pile counts](docs/DRAW_PILES.md).
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from 'next/link';
|
||||
import { leaders } from '@/game/cards';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { LeaderInspector } from './leader-inspector';
|
||||
import { LeaderPortrait } from './leader-portrait';
|
||||
|
||||
/** The server supplies only the permanently public, set-aside card identity. */
|
||||
export function EcazLoyaltyCard({ loyalty }: { loyalty: GameView['ecazLoyalty'] }) {
|
||||
if (!loyalty) return null;
|
||||
const leader = leaders('ecaz').find(candidate => candidate.id === loyalty.card);
|
||||
if (!leader) return null;
|
||||
return <section aria-label="Ecaz Loyalty card" className="m-4 flex min-w-0 flex-wrap items-center gap-4 rounded-xl border border-[#90aa73] bg-[#20271f] p-4 text-[#f2e8d3]">
|
||||
<LeaderPortrait identityId={leader.id} name={leader.name} className="size-24 shrink-0" fallback={<span>Ecaz</span>} />
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<h3 className="m-0 font-serif text-xl">Loyalty · {leader.name}</h3>
|
||||
<p className="text-base! leading-7!">This Ecaz Traitor Card was randomly set aside face up before dealing. It stays outside the deck for this game and cannot be drawn as a Traitor or Face Dancer. Its leader remains in play under the normal leader rules.</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<LeaderInspector kind="loyalty" identity={{ id: leader.id, name: leader.name, factionName: 'Ecaz', strength: leader.strength }} />
|
||||
<Link className="py-3 text-base underline" href="/rules?topic=ecaz-loyalty#ecaz-loyalty">Loyalty rules</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
||||
import { KwisatzInspector } from './kwisatz-inspector';
|
||||
import { ForceInspector } from './force-inspector';
|
||||
import { FactionInspector } from './faction-inspector';
|
||||
import { EcazLoyaltyCard } from './ecaz-loyalty';
|
||||
import { DrawPiles } from './draw-piles';
|
||||
import { HandBrowser } from './hand-browser';
|
||||
import { LobbyBotControls } from './lobby-bot-controls';
|
||||
@@ -1020,6 +1021,7 @@ export function GameTable({
|
||||
)}
|
||||
<SeatAutopilot game={g} act={act} busy={transportBusy} />
|
||||
<DukeVidal game={g} />
|
||||
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
|
||||
<MoritaniTerrorSupply game={g} />
|
||||
<MoritaniAssassinateHistory game={g} />
|
||||
<AmbassadorSupply game={g} />
|
||||
|
||||
@@ -25,7 +25,7 @@ export type LeaderDisplayIdentity = Readonly<
|
||||
>;
|
||||
export type LeaderInspectorProps = {
|
||||
identity: LeaderDisplayIdentity;
|
||||
kind?: 'leader' | 'traitor' | 'faceDancer';
|
||||
kind?: 'leader' | 'traitor' | 'faceDancer' | 'loyalty';
|
||||
assassination?: 'choice' | 'revealed' | 'retired';
|
||||
};
|
||||
|
||||
@@ -107,11 +107,14 @@ export function LeaderInspector({
|
||||
kind = 'leader',
|
||||
assassination,
|
||||
}: LeaderInspectorProps) {
|
||||
const traitor = kind === 'traitor';
|
||||
const loyalty = kind === 'loyalty';
|
||||
const traitor = kind === 'traitor' || loyalty;
|
||||
const faceDancer = kind === 'faceDancer';
|
||||
const label = faceDancer ? 'Face Dancer' : kind;
|
||||
const label = faceDancer ? 'Face Dancer' : loyalty ? 'traitor' : kind;
|
||||
const cheapHero = identity.cheapHero === true;
|
||||
const description = traitor && assassination === 'retired'
|
||||
const description = loyalty
|
||||
? 'This Ecaz Traitor Card was randomly set aside face up before initial dealing. Everyone may inspect it; nobody holds it as a Traitor or Face Dancer.'
|
||||
: traitor && assassination === 'retired'
|
||||
? 'This face-up card marks the faction against which Moritani has already used Assassinate Leaders. It is set aside, separate from held Traitor Cards.'
|
||||
: faceDancer
|
||||
? cheapHero
|
||||
@@ -259,6 +262,19 @@ export function LeaderInspector({
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : loyalty ? (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
The card remains outside the deck for the rest of this game.
|
||||
It cannot be drawn, held or called as a traitor. Inspection
|
||||
does not move the card or change its public identity.
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
Loyalty is automatic and Karama cannot prevent it. The
|
||||
corresponding leader disc keeps its normal ownership,
|
||||
battle, death and revival rules.
|
||||
</p>
|
||||
</>
|
||||
) : traitor && assassination ? (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Component inventory and verification ledger
|
||||
|
||||
**Ecaz Loyalty, 21 September 2026:** one existing native Traitor Card now has a
|
||||
separate face-up public location in new Advanced Ecaz setup. Its canonical
|
||||
identity and portrait use the enlarged Traitor inspector; it is excluded from
|
||||
all subsequent draw inventories without changing the corresponding leader disc.
|
||||
Old dealt games remain unchanged. See [source and evidence](ECAZ_LOYALTY.md).
|
||||
|
||||
**Faction sheets, 21 September 2026:** every table player has an enlarged public
|
||||
faction guide, with Basic/Advanced preview and an internal twelve-faction gallery.
|
||||
Shared text and bundled Homeworld artwork preserve public identity; no private
|
||||
|
||||
+18
-18
@@ -6,29 +6,29 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest delivered checkpoint: `e3cfd82`, [private-hand browsing](HAND_BROWSING.md).
|
||||
Types, lint, **4,996 offline tests** and production build passed. Search, category
|
||||
filters, all sort orders, an auction acquisition under an active filter, keyboard
|
||||
scrolling, internal help, tab reset and refresh passed browser checks. All 1,538
|
||||
opening rooms, 3,258 seats and auxiliary rows stayed unchanged; the isolated QA
|
||||
game brought the baseline to 1,539 rooms and 3,259 human seat records. Screenshot
|
||||
capture timed out; full visual acceptance remains open. The healthy server was
|
||||
reused. Prior checkpoints include `14a7800` for draw counts, `537da4a` for force
|
||||
inspection and `2b071a4` for Nexus offers.
|
||||
Latest delivered checkpoint: `c2f24fc`, [public faction sheets](FACTION_SHEETS.md).
|
||||
Types, lint, **5,000 offline tests** and production build passed. Public table
|
||||
inspection, all twelve gallery entries, Basic/Advanced preview, keyboard and
|
||||
mobile layouts, internal help and refresh passed bounded browser checks. All
|
||||
1,539 opening rooms, 3,259 human seats and auxiliary rows stayed unchanged.
|
||||
Screenshot capture timed out; full visual acceptance remains open. The healthy
|
||||
server was reused. Prior checkpoints include `e3cfd82` for hand browsing,
|
||||
`14a7800` for draw counts and `537da4a` for force inspection.
|
||||
|
||||
Current work: [public faction sheets](FACTION_SHEETS.md) adds a table inspector
|
||||
and twelve-faction internal gallery with Basic/Advanced preview. Shared guidance
|
||||
fills Richese’s missing overview, centralizes Ecaz/Moritani and corrects stale
|
||||
CHOAM/Ecaz summaries from existing contracts. Public ID and mode are the only
|
||||
inspector inputs; no private state or game action is involved. Focused rendering
|
||||
checks accompany independent review, browser verification and saved preservation.
|
||||
Full printed-sheet text and all remaining rules still require completion.
|
||||
Current work: [Ecaz Loyalty](ECAZ_LOYALTY.md) selects one native Traitor Card
|
||||
before genuine Advanced dealing, keeps it publicly set aside through setup and
|
||||
Nexus replacement draws, and adds enlarged inspection and internal guidance.
|
||||
Focused custody, public-view, all-profile legal setup and JSON checks accompany
|
||||
independent review, browser checks, final check/build/HTTP and saved preservation.
|
||||
Legacy dealt games remain unchanged; combined Skills/assassination rosters and
|
||||
complete faction acceptance remain gated.
|
||||
The separate Bureaucrat/Emperor gift question is pending.
|
||||
The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI
|
||||
refinement until feature completion, followed by three adjacent 75% targets.
|
||||
|
||||
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
|
||||
still cross recorded pending rulings. Continue independent features rather than
|
||||
cross recorded pending rulings, with the independent Loyalty setup omission now
|
||||
being connected. Continue independent features rather than
|
||||
inventing answers. The [platform audit](AUTOPILOT.md#platform-audit-20-september-2026)
|
||||
found no supported Sites scheduler or Durable Object provisioning contract.
|
||||
Durable unattended bot resumption remains blocked on that capability; current
|
||||
@@ -89,7 +89,7 @@ These links define bounded working behavior, not complete module certification.
|
||||
| Area | Current connected work and evidence |
|
||||
| --- | --- |
|
||||
| Base/Advanced setup | [Genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
|
||||
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement, separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
|
||||
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
|
||||
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
|
||||
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
|
||||
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Ecaz Loyalty
|
||||
|
||||
Connected Advanced development prototype, 21 September 2026. This does not
|
||||
certify the complete faction, open public Advanced starts or change AI strategy.
|
||||
|
||||
## Authority and interpretation
|
||||
|
||||
The official [Ecaz & Moritani rulebook, printed page 8](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=8)
|
||||
requires a random native Ecaz Traitor Card to be set aside publicly before the
|
||||
initial Traitor draw and excluded for the rest of the game. The
|
||||
[page 16 Karama table](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=16)
|
||||
lists no Karama effect for Loyalty. Five ordinary Ecaz cards are candidates;
|
||||
Duke Vidal has no Traitor Card. This is a card movement, not a change to leader
|
||||
ownership, availability, death or revival. Independent source review confirmed
|
||||
the indexed official rulebook text; direct PDF retrieval failed in this audit.
|
||||
|
||||
The implementation resolves this automatic step immediately before the existing
|
||||
initial shuffle and deal. Bene Gesserit prediction and any supported pre-Traitor
|
||||
skill setup therefore precede it. That relative sequencing is an implementation
|
||||
composition of the printed before-draw requirement, not a separately printed
|
||||
ordering ruling. No optional choice, payment, Karama window or acknowledgment
|
||||
is introduced.
|
||||
|
||||
## State and physical custody
|
||||
|
||||
`game/ecaz-loyalty.ts` owns the selector, deck exclusion and saved invariant.
|
||||
New genuine Advanced setup with native Ecaz records the player and a pending
|
||||
card, then chooses exactly once before initial dealing. The selected physical
|
||||
card stays in this separate public location. Its identity is removed from initial
|
||||
Traitor dealing, the Tleilaxu setup reconstruction and Nexus replacement
|
||||
universes. Later Face Dancer cycling uses the remaining physical cards.
|
||||
|
||||
Validation rejects invalid owners, Basic/lobby markers, late pending draws and
|
||||
duplicated cards across the reserve, held Traitors, setup choices, Face Dancers
|
||||
or recorded Moritani assassination cards. Action, normalization and view
|
||||
boundaries validate before mutating or exposing state. JSON continuation keeps
|
||||
the selection and does not replay the notice. A save without the marker is
|
||||
legacy state: no already-dealt card is retroactively taken away.
|
||||
|
||||
The player view returns only a detached public player/card pair once chosen.
|
||||
Every viewer sees the same identity. The table renders the existing enlarged
|
||||
Traitor inspector and an internal help link; no private hand or deck order is
|
||||
added to this public component. The automatic chronicle entry uses the existing
|
||||
Ecaz action notice.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- `tests/ecaz-loyalty-engine.test.ts`: genuine two-through-six-player deals,
|
||||
prediction ordering, Basic/no-Ecaz absence, completed Harkonnen/Tleilaxu setup,
|
||||
all four legal AI profiles, JSON continuation and completed legacy saves.
|
||||
- `tests/ecaz-loyalty.test.tsx`: random candidate bins, physical inventory,
|
||||
immutable invalid-state rejection, public rendering and internal inspection.
|
||||
- `tests/nexus-traitor-engine.test.ts`: actual Harkonnen and Tleilaxu Nexus
|
||||
replacement draws keep the same excluded card and full physical census.
|
||||
- Existing expansion setup and Nexus fixtures count the separately held card
|
||||
instead of treating it as a loss or reconstructing it into the deck.
|
||||
|
||||
Leader Skills plus Ecaz and the Moritani assassination preview plus Ecaz remain
|
||||
outside their currently supported combined rosters. Compatible code does not
|
||||
establish combined-game acceptance. The no-Ecaz Leader Skills regression checks
|
||||
only that existing setup is unchanged. Broader card/leader effects retain their
|
||||
own guards and pending rulings.
|
||||
|
||||
Private source-bound check/build/HTTP logs, six faction-game samples, browser
|
||||
inspection and saved-game preservation are recorded outside the checkout under
|
||||
`20260921-gameplay-review`. The commit message records actual final results.
|
||||
The browser uses a newly created ready Ecaz/Harkonnen lobby initialized through
|
||||
the genuine Advanced audit seam before any draw; it does not retrofit a game
|
||||
that has already started. Existing rooms and saved seats are preserved.
|
||||
@@ -1,5 +1,16 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 21 September 2026 — Advanced Ecaz Loyalty
|
||||
|
||||
Genuine Advanced Ecaz setup now randomly sets one native Traitor Card aside face
|
||||
up before dealing. A persisted public identity, enlarged inspector and automatic
|
||||
notice connect to initial setup, Tleilaxu Face Dancers and Nexus replacements.
|
||||
Physical census and invalid-state checks preserve its separate custody; older
|
||||
already-dealt games stay unchanged. [Ecaz Loyalty](ECAZ_LOYALTY.md) records official
|
||||
sources, sequencing interpretation, focused tests and combined-module limits.
|
||||
Independent review accompanies private check/build/HTTP, genuine faction samples,
|
||||
browser and saved-preservation evidence. AI strategy and release gates remain unchanged.
|
||||
|
||||
## 21 September 2026 — public faction-sheet inspection
|
||||
|
||||
Every table player now has a public faction inspector and the internal guide
|
||||
|
||||
@@ -6,6 +6,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
|
||||
|
||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||
| --- | --- | --- |
|
||||
| Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition. Older dealt saves are not retrofitted; combined Skills/assassination rosters remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md) |
|
||||
| Sapho battle order | First/last reorders remaining Battle-phase choosers at clean boundaries. Phase priority persists through aftermath; actual legal pairs determine eligibility. Separating the chooser from physical attacker/tie advantage is an explicit implementation interpretation of the card's distinct alternatives, not a dedicated FAQ ruling. Last does not prevent others from choosing the holder as an opponent. | [Runtime scope and source interpretation](SAPHO_BATTLE_ORDER.md) |
|
||||
| Harass & Withdraw | Either slot but neither category; private Prescience answers null. Return uniquely determined own undialed physical forces before weapon losses, canceled by an opposing successful Traitor. Mandatory after-use discard overrides winner retention. Explosion ordering and own-Traitor disposal are explicit timing/specificity inferences, consistent with the recorded Planetologist precedent. | [Runtime and sources](HARASS_WITHDRAW_RUNTIME.md) |
|
||||
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { leaders } from './cards';
|
||||
import type { Game } from './engine';
|
||||
|
||||
/** A separate physical Traitor Card, face up for the rest of this game. */
|
||||
export type EcazLoyalty = { player: string; card: string | null };
|
||||
|
||||
export function chooseEcazLoyalty(deck: readonly string[], roll: number): string {
|
||||
const candidates = leaders('ecaz').map(leader => leader.id);
|
||||
if (!Number.isFinite(roll) || roll < 0 || roll >= 1 ||
|
||||
new Set(deck).size !== deck.length || candidates.some(card => !deck.includes(card)))
|
||||
throw new Error('Ecaz Loyalty requires the intact native Traitor inventory and a valid random draw.');
|
||||
return candidates[Math.floor(roll * candidates.length)];
|
||||
}
|
||||
|
||||
export function withoutEcazLoyalty(deck: readonly string[], loyalty?: EcazLoyalty): string[] {
|
||||
return deck.filter(card => card !== loyalty?.card);
|
||||
}
|
||||
|
||||
/** Missing markers belong to older games and never remove an already-dealt card. */
|
||||
export function validateEcazLoyalty(game: Pick<Game, 'ecazLoyalty' | 'advanced' | 'players' | 'status' | 'setupStage' | 'traitorReserve' | 'moritaniAssassinate'>): void {
|
||||
const loyalty = game.ecazLoyalty;
|
||||
if (loyalty === undefined) return;
|
||||
if (!loyalty || !game.advanced || game.status === 'lobby' ||
|
||||
!game.players.some(player => player.id === loyalty.player && player.faction === 'ecaz'))
|
||||
throw new Error('Ecaz Loyalty must belong to the native Advanced Ecaz player.');
|
||||
const circulating = [
|
||||
...(game.traitorReserve ?? []),
|
||||
...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader)]),
|
||||
];
|
||||
if (loyalty.card === null) {
|
||||
if (game.status !== 'setup' || !['prediction', 'skillTreachery', 'leaderSkills'].includes(game.setupStage ?? '') || circulating.length)
|
||||
throw new Error('Ecaz Loyalty must be revealed before initial Traitor dealing.');
|
||||
return;
|
||||
}
|
||||
if (!leaders('ecaz').some(leader => leader.id === loyalty.card) || circulating.includes(loyalty.card) ||
|
||||
game.moritaniAssassinate?.opportunities.some(receipt => receipt.card === loyalty.card))
|
||||
throw new Error('The public Ecaz Loyalty card must remain separate from all circulating or retired Traitors.');
|
||||
}
|
||||
+19
-6
@@ -491,6 +491,7 @@ import {
|
||||
type PendingRevival,
|
||||
} from './revival';
|
||||
import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import { chooseEcazLoyalty, withoutEcazLoyalty, validateEcazLoyalty, type EcazLoyalty } from './ecaz-loyalty';
|
||||
import {
|
||||
isStoneBurner,
|
||||
isPortableSnooper,
|
||||
@@ -1427,6 +1428,7 @@ export type Game = {
|
||||
revivalPrevention?: { player: string; turn: number };
|
||||
|
||||
traitorReserve?: string[];
|
||||
ecazLoyalty?: EcazLoyalty;
|
||||
pendingFaceDance?: {
|
||||
player: string;
|
||||
winner: string;
|
||||
@@ -1852,9 +1854,9 @@ function projectedNexusCards(g: Game, id: string) {
|
||||
function nexusTraitorUniverse(g: Game) {
|
||||
// This describes the printed setup inventory; it never regenerates the deck
|
||||
// from current hands, captured leaders or Face Dancer custody.
|
||||
return traitorDeck(g.players.map(p => ({ leaders: [
|
||||
return withoutEcazLoyalty(traitorDeck(g.players.map(p => ({ leaders: [
|
||||
...leaders(p.faction), ...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []),
|
||||
] })), g.expansions.includes('ix'));
|
||||
] })), g.expansions.includes('ix')), g.ecazLoyalty);
|
||||
}
|
||||
function nexusTraitorSnapshot(g: Game): NexusTraitorSnapshot {
|
||||
return { reserve: [...(g.traitorReserve ?? [])], players: g.players.map(p => ({
|
||||
@@ -5627,6 +5629,8 @@ function finishMandatorySkillVisibility(g: Game) {
|
||||
}
|
||||
|
||||
function initializeSetup(g: Game) {
|
||||
const ecaz = byFaction(g, 'ecaz');
|
||||
if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null };
|
||||
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
|
||||
if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null };
|
||||
const choam = byFaction(g, 'choam');
|
||||
@@ -5785,9 +5789,13 @@ function advanceSetup(g: Game) {
|
||||
}
|
||||
if (g.setupStage === 'leaderSkills' && setupPending(g).length) return;
|
||||
if (g.setupStage === 'prediction' || g.setupStage === 'leaderSkills') {
|
||||
const traitors = shuffle(
|
||||
traitorDeck(g.players, g.expansions.includes('ix')),
|
||||
);
|
||||
const inventory = traitorDeck(g.players, g.expansions.includes('ix'));
|
||||
if (g.ecazLoyalty && g.ecazLoyalty.card === null) {
|
||||
g.ecazLoyalty.card = chooseEcazLoyalty(inventory, random());
|
||||
const loyal = leaders('ecaz').find(leader => leader.id === g.ecazLoyalty!.card)!;
|
||||
log(g, `Ecaz Loyalty set ${loyal.name}’s Traitor Card face up outside the deck before initial dealing. This card stays out of the game; its leader is unchanged.`, { faction: 'ecaz', name: 'Loyalty' });
|
||||
}
|
||||
const traitors = shuffle(withoutEcazLoyalty(inventory, g.ecazLoyalty));
|
||||
for (const p of g.players) {
|
||||
p.traitorChoices = p.faction === 'tleilaxu' ? [] : traitors.splice(0, 4);
|
||||
if (p.faction === 'harkonnen') {
|
||||
@@ -5956,6 +5964,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
!g.spiceDeck.length &&
|
||||
!g.spiceDiscard.some((pile) => pile.length) &&
|
||||
!g.traitorReserve?.length &&
|
||||
g.ecazLoyalty === undefined &&
|
||||
!g.decision &&
|
||||
!g.response &&
|
||||
!g.battle &&
|
||||
@@ -6025,7 +6034,7 @@ function finishSetup(g: Game) {
|
||||
if (tleilaxu) {
|
||||
const held = new Set(g.players.flatMap((player) => player.traitors));
|
||||
g.traitorReserve = shuffle(
|
||||
traitorDeck(g.players, g.expansions.includes('ix')).filter(
|
||||
withoutEcazLoyalty(traitorDeck(g.players, g.expansions.includes('ix')), g.ecazLoyalty).filter(
|
||||
(card) => !held.has(card),
|
||||
),
|
||||
);
|
||||
@@ -20485,6 +20494,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
ixRicheseTechnologyIntegrity(state);
|
||||
leaderSkillsIntegrity(state);
|
||||
@@ -20749,6 +20759,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
ixRicheseTechnologyIntegrity(state);
|
||||
leaderSkillsIntegrity(state);
|
||||
@@ -24396,6 +24407,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
validateEcazLoyalty(state);
|
||||
ecazTreacheryIntegrity(state);
|
||||
ixRicheseTechnologyIntegrity(state);
|
||||
leaderSkillsIntegrity(state);
|
||||
@@ -24700,6 +24712,7 @@ export function viewGame(state: Game, id: string) {
|
||||
}
|
||||
: null,
|
||||
techTokens: g.techTokens ?? null,
|
||||
ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)),
|
||||
ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose'
|
||||
|
||||
@@ -171,7 +171,8 @@ export const FACTION_RULES: Record<
|
||||
],
|
||||
advanced: [
|
||||
'At Spice Collection, both allies receive the normal bank income from a jointly occupied Arrakeen, Carthag or Tuek’s Sietch. Canceling Ecaz’s Collection benefit leaves the ally’s income intact.',
|
||||
'This is the currently documented Advanced Collection power. The remaining Advanced powers and combined interactions are still being completed; this sheet is not a complete faction certification.',
|
||||
'Loyalty: before initial Traitor dealing, randomly set aside one of your five native Traitor Cards face up for everyone. It stays outside the deck for the whole game, including later Traitor and Face Dancer draws. Karama cannot prevent this automatic power; the corresponding leader disc is unchanged.',
|
||||
'Loyalty and Collection have connected development implementations. Remaining Advanced powers and combined interactions are still being completed; this sheet is not a complete faction certification.',
|
||||
],
|
||||
},
|
||||
moritani: {
|
||||
|
||||
+24
-2
@@ -1230,6 +1230,27 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
}),
|
||||
),
|
||||
{
|
||||
id: 'ecaz-loyalty',
|
||||
title: 'Ecaz Loyalty: public Traitor Card',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary: 'Advanced Ecaz sets one randomly chosen native Traitor Card aside face up before the initial deal.',
|
||||
steps: [
|
||||
'Before any initial Traitor Cards are drawn, randomly select one of the five ordinary Ecaz Traitor Cards and set it aside face up. Everyone may inspect it. It remains outside the deck for the rest of the game, including later Traitor and Face Dancer draws.',
|
||||
'Loyalty is automatic and cannot be stopped with Karama. It creates no player choice, payment or acknowledgment. The card names a leader, but does not remove, kill, transfer or otherwise change that leader disc. Duke Vidal has no Traitor Card and is never a candidate.',
|
||||
'In new Advanced development games with Ecaz, the table displays the selected card before the Traitor choices. Inspect traitor enlarges its identity; refreshing or resuming the saved game keeps the same card. Basic games and games without native Ecaz do not use this power.',
|
||||
'Older saved games that have already dealt their Traitor Cards retain that inventory; they do not gain a retroactive Loyalty draw. Full Advanced and expansion starts remain gated, including unfinished combinations with Leader Skills and Moritani assassination.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'One persisted native Ecaz card is selected before genuine initial dealing, excluded from setup and Nexus deck construction, and validated against all known circulating or retired Traitor locations. Older saves stay unchanged. Broader combinations remain gated.', evidence: ['game/ecaz-loyalty.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'A public named card and existing enlarged Traitor inspector appear before setup choices and during play, with internal help and no acknowledgment.', evidence: ['components/ecaz-loyalty.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles complete genuine setup through existing legal actions without choosing, rerolling or acknowledging Loyalty. Strategy remains deferred until game features are complete.', evidence: ['tests/ecaz-loyalty-engine.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Public setup, permanent card exclusion, normal leader custody and old-save limits have internal guidance and sourced developer notes.', evidence: ['game/reference.ts', 'game/faction-reference.ts', 'docs/ECAZ_LOYALTY.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Two-through-six-player deals, full Tleilaxu setup, Harkonnen and Tleilaxu Nexus draws, public projection, invalid-state rejection, all-profile setup, rendering and JSON continuity have focused checks. Final browser and checkpoint evidence is recorded separately; combined-module acceptance remains open.', evidence: ['tests/ecaz-loyalty.test.tsx', 'tests/ecaz-loyalty-engine.test.ts', 'tests/nexus-traitor-engine.test.ts'] },
|
||||
],
|
||||
related: ['setup', 'faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'duke-vidal', 'implementation-checklist'],
|
||||
},
|
||||
{
|
||||
id: 'ecaz-ambassadors',
|
||||
title: 'Ecaz Ambassadors: placement and remaining effects',
|
||||
@@ -2660,7 +2681,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
...(f.id === 'richese' ? ['richese-cards', 'richese-no-field', 'richese-gift', 'richese-acquisition'] : []),
|
||||
...(f.id === 'choam' ? ['choam-auditor', 'choam-worthless', 'choam-combat'] : []),
|
||||
...(f.id === 'ecaz'
|
||||
? ['ecaz-ambassadors', 'duke-vidal', 'implementation-checklist']
|
||||
? ['ecaz-loyalty', 'ecaz-ambassadors', 'duke-vidal', 'implementation-checklist']
|
||||
: []),
|
||||
...(f.id === 'moritani'
|
||||
? [
|
||||
@@ -2688,13 +2709,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'emperor',
|
||||
'tleilaxu',
|
||||
'ixians',
|
||||
'ecaz',
|
||||
].includes(id)
|
||||
? 'Partial'
|
||||
: 'Planned',
|
||||
summary:
|
||||
'Advanced faction rules. Some powers have tested engine support; advanced table starts remain disabled until the full rules are ready.',
|
||||
steps: rules.advanced,
|
||||
related: [`faction-${id}`, 'advanced-combat'],
|
||||
related: [`faction-${id}`, 'advanced-combat', ...(id === 'ecaz' ? ['ecaz-loyalty'] : [])],
|
||||
}),
|
||||
),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { runBots } from '../game/bots';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
function readyTable(factions: FactionId[], advanced = true, expansions = ['ecaz']) {
|
||||
const g = createGame(
|
||||
`ECAZLOYALTY${factions.length}${advanced ? 'A' : 'B'}`,
|
||||
newPlayer('p0', 'Player 0', factions[0]),
|
||||
advanced,
|
||||
expansions,
|
||||
);
|
||||
for (let i = 1; i < factions.length; i++)
|
||||
joinGame(g, newPlayer(`p${i}`, `Player ${i}`, factions[i]));
|
||||
g.players.forEach((p) => {
|
||||
p.ready = true;
|
||||
p.bot = 'Easy';
|
||||
});
|
||||
return g;
|
||||
}
|
||||
|
||||
function setupToTraitors(g: Game) {
|
||||
let state = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let i = 0; i < 12 && state.setupStage !== 'traitors'; i++) {
|
||||
state = runBots(state, 1);
|
||||
if (state.status === 'playing') break;
|
||||
}
|
||||
assert.ok(state.setupStage === 'traitors' || state.status === 'playing');
|
||||
return state;
|
||||
}
|
||||
|
||||
function completeSetup(g: Game) {
|
||||
let state = g;
|
||||
for (let i = 0; i < 240 && state.status === 'setup'; i++)
|
||||
state = runBots(state, 1);
|
||||
assert.equal(state.status, 'playing', `setup did not complete at ${state.setupStage}`);
|
||||
return state;
|
||||
}
|
||||
|
||||
function traitorCustody(g: Game) {
|
||||
return [
|
||||
...g.players.flatMap((p) => p.traitorChoices),
|
||||
...g.players.flatMap((p) => p.traitors),
|
||||
...g.players.flatMap((p) => (p.faceDancers ?? []).map((card) => card.leader)),
|
||||
...(g.traitorReserve ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
void test('Advanced Ecaz creates a private pending marker and selects loyalty after BG prediction', () => {
|
||||
let g = readyTable(['beneGesserit', 'ecaz', 'atreides']);
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
assert.deepEqual(g.ecazLoyalty, { player: 'p1', card: null });
|
||||
assert.equal(viewGame(g, 'p0').ecazLoyalty, null);
|
||||
assert.equal(g.setupStage, 'prediction');
|
||||
|
||||
for (let i = 0; i < 24 && g.setupStage !== 'traitors' && g.status === 'setup'; i++)
|
||||
g = runBots(g, 1);
|
||||
assert.equal(g.ecazLoyalty!.player, 'p1');
|
||||
assert.ok(g.ecazLoyalty!.card);
|
||||
assert.deepEqual(viewGame(g, 'p0').ecazLoyalty, g.ecazLoyalty);
|
||||
assert.ok(g.log.some((entry) => entry.text.includes('before initial dealing')));
|
||||
});
|
||||
|
||||
void test('Basic and Advanced games without Ecaz have no loyalty marker', () => {
|
||||
let basic = readyTable(['ecaz', 'atreides'], false, ['ecaz']);
|
||||
basic = initializeFactionExpansionsGameForAudit(basic);
|
||||
assert.equal(basic.ecazLoyalty, undefined);
|
||||
assert.equal(viewGame(basic, 'p0').ecazLoyalty, null);
|
||||
|
||||
let advanced = readyTable(['atreides', 'emperor'], true, ['ix']);
|
||||
advanced = initializeFactionExpansionsGameForAudit(advanced);
|
||||
assert.equal(advanced.ecazLoyalty, undefined);
|
||||
assert.equal(viewGame(advanced, 'p0').ecazLoyalty, null);
|
||||
});
|
||||
|
||||
void test('Every legal two-to-six player Ecaz setup removes exactly one native leader from traitor custody', () => {
|
||||
const profiles: FactionId[][] = [
|
||||
['ecaz', 'atreides'],
|
||||
['ecaz', 'atreides', 'emperor'],
|
||||
['ecaz', 'atreides', 'emperor', 'fremen'],
|
||||
['ecaz', 'atreides', 'emperor', 'fremen', 'beneGesserit'],
|
||||
['ecaz', 'atreides', 'emperor', 'fremen', 'beneGesserit', 'harkonnen'],
|
||||
];
|
||||
for (const factions of profiles) {
|
||||
const g = setupToTraitors(readyTable(factions));
|
||||
const selected = g.ecazLoyalty!.card;
|
||||
assert.ok(selected);
|
||||
assert.ok(g.players.find((p) => p.faction === 'ecaz')!.leaders.some((l) => l.id === selected));
|
||||
const universe = traitorDeck(g.players).filter((id) => id !== selected);
|
||||
const custody = traitorCustody(g);
|
||||
assert.equal(custody.includes(selected), false);
|
||||
assert.equal(custody.length, universe.length);
|
||||
assert.deepEqual([...custody].sort(), [...universe].sort());
|
||||
}
|
||||
});
|
||||
|
||||
void test('Harkonnen keeps four dealt traitors and Tleilaxu face-dancer rebuilding excludes loyalty', () => {
|
||||
const g = completeSetup(setupToTraitors(readyTable(['ecaz', 'harkonnen', 'tleilaxu', 'atreides'], true, ['ecaz', 'ix'])));
|
||||
const harkonnen = g.players.find((p) => p.faction === 'harkonnen')!;
|
||||
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu')!;
|
||||
assert.equal(harkonnen.traitors.length, 4);
|
||||
assert.deepEqual(harkonnen.traitorChoices, []);
|
||||
assert.deepEqual(tleilaxu.traitorChoices, []);
|
||||
assert.equal(tleilaxu.faceDancers?.length, 3);
|
||||
assert.equal(traitorCustody(g).includes(g.ecazLoyalty!.card!), false);
|
||||
assert.deepEqual(
|
||||
[...traitorCustody(g)].sort(),
|
||||
traitorDeck(g.players, true).filter((id) => id !== g.ecazLoyalty!.card).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
void test('all four bot profiles can legally perform Ecaz setup', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let g = readyTable(['beneGesserit', 'ecaz', 'atreides', 'harkonnen']);
|
||||
g.players.forEach((p) => (p.bot = difficulty));
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let i = 0; i < 20 && g.status === 'setup'; i++) {
|
||||
g = runBots(g);
|
||||
if (g.setupStage === undefined) break;
|
||||
}
|
||||
assert.equal(g.status, 'playing', `${difficulty} did not complete setup`);
|
||||
assert.ok(g.ecazLoyalty?.card);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Leader Skills audit setup remains available for the no-Ecaz profile', () => {
|
||||
let g = readyTable(['beneGesserit', 'atreides'], true, ['choam']);
|
||||
g = initializeLeaderSkillsGameForAudit(g);
|
||||
assert.equal(g.ecazLoyalty, undefined);
|
||||
assert.equal(g.setupStage, 'prediction');
|
||||
g = runBots(g, 1);
|
||||
assert.ok(g.setupStage === 'skillTreachery' || g.setupStage === 'leaderSkills' || g.status === 'playing');
|
||||
});
|
||||
|
||||
void test('JSON save and normalize preserve the selected marker without duplicate loyalty logs', () => {
|
||||
const started = setupToTraitors(readyTable(['ecaz', 'atreides', 'emperor']));
|
||||
const before = started.ecazLoyalty;
|
||||
const loyaltyLogs = started.log.filter((entry) => entry.text.includes('Ecaz Loyalty')).length;
|
||||
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(started)) as Game);
|
||||
assert.deepEqual(restored.ecazLoyalty, before);
|
||||
assert.equal(restored.log.filter((entry) => entry.text.includes('Ecaz Loyalty')).length, loyaltyLogs);
|
||||
assert.deepEqual(viewGame(restored, 'p0').ecazLoyalty, before);
|
||||
});
|
||||
|
||||
void test('a legacy started state without the marker is not assigned loyalty retroactively', () => {
|
||||
const setup = completeSetup(setupToTraitors(readyTable(['ecaz', 'atreides'])));
|
||||
const selected = setup.ecazLoyalty!.card!;
|
||||
setup.traitorReserve!.push(selected);
|
||||
const beforeCustody = traitorCustody(setup).sort();
|
||||
const legacy = JSON.parse(JSON.stringify(setup)) as Game;
|
||||
delete legacy.ecazLoyalty;
|
||||
const restored = normalizeAutomaticGame(legacy);
|
||||
assert.equal(restored.ecazLoyalty, undefined);
|
||||
assert.equal(viewGame(restored, 'p0').ecazLoyalty, null);
|
||||
assert.deepEqual(traitorCustody(restored).sort(), beforeCustody);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { leaders } from '../game/cards';
|
||||
import { chooseEcazLoyalty, withoutEcazLoyalty } from '../game/ecaz-loyalty';
|
||||
import { createGame, newPlayer, applyAction, viewGame, normalizeAutomaticGame } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({ resolve(specifier, context, next) { return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier === 'next/image' ? 'vinext/shims/image' : specifier, context); } });
|
||||
const { EcazLoyaltyCard } = await import('../components/ecaz-loyalty');
|
||||
aliases.deregister();
|
||||
|
||||
void test('Loyalty selects each native Ecaz physical card and excludes it without changing the supplied inventory', () => {
|
||||
const native = leaders('ecaz').map(leader => leader.id);
|
||||
const deck = Object.freeze([...native, ...leaders('harkonnen').map(leader => leader.id)]);
|
||||
const selected = native.map((_, index) => chooseEcazLoyalty(deck, (index + .5) / native.length));
|
||||
assert.deepEqual(selected, native);
|
||||
for (const card of selected) {
|
||||
const remaining = withoutEcazLoyalty(deck, { player: 'e', card });
|
||||
assert.equal(remaining.length, deck.length - 1);
|
||||
assert.ok(!remaining.includes(card));
|
||||
assert.deepEqual([...remaining, card].sort(), [...deck].sort());
|
||||
}
|
||||
assert.throws(() => chooseEcazLoyalty(deck.slice(1), .5), /intact/);
|
||||
assert.throws(() => chooseEcazLoyalty([...deck, native[0]], .5), /intact/);
|
||||
for (const roll of [-.01, 1, NaN, Infinity]) assert.throws(() => chooseEcazLoyalty(deck, roll), /valid random/);
|
||||
});
|
||||
|
||||
void test('a copied or invalid set-aside card fails before actions, normalization or projection mutate the state', () => {
|
||||
const game = createGame('LOYALBAD', newPlayer('e', 'Ecaz', 'ecaz'), true);
|
||||
game.status = 'playing';
|
||||
game.ecazLoyalty = { player: 'e', card: leaders('ecaz')[0].id };
|
||||
for (const corrupt of [
|
||||
() => { game.players[0].traitors.push(game.ecazLoyalty!.card!); },
|
||||
() => { game.ecazLoyalty!.card = 'duke-vidal'; },
|
||||
() => { game.ecazLoyalty!.card = null; },
|
||||
]) {
|
||||
const saved = structuredClone(game);
|
||||
corrupt();
|
||||
const before = JSON.stringify(game);
|
||||
for (const operation of [() => applyAction(game, 'e', { type: 'ready' }), () => normalizeAutomaticGame(game), () => viewGame(game, 'e')]) {
|
||||
assert.throws(operation, /Loyalty/);
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
}
|
||||
Object.assign(game, saved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the public Loyalty panel shows only the supplied identity with an inspector and no acknowledgement', () => {
|
||||
assert.equal(renderToStaticMarkup(createElement(EcazLoyaltyCard, { loyalty: null })), '');
|
||||
for (const leader of leaders('ecaz')) {
|
||||
const html = renderToStaticMarkup(createElement(EcazLoyaltyCard, { loyalty: { player: 'e', card: leader.id } }));
|
||||
assert.match(html, /Ecaz Loyalty card/);
|
||||
assert.ok(html.includes(`Inspect traitor: ${leader.name}`));
|
||||
assert.match(html, /cannot be drawn as a Traitor or Face Dancer/);
|
||||
assert.match(html, /href="\/rules\?topic=ecaz-loyalty#ecaz-loyalty"/);
|
||||
assert.doesNotMatch(html, /Acknowledge|Confirm|Accept|Pass this/);
|
||||
for (const other of leaders('ecaz').filter(candidate => candidate.id !== leader.id)) assert.ok(!html.includes(other.name));
|
||||
}
|
||||
});
|
||||
@@ -127,6 +127,7 @@ function inventory(g: Game) {
|
||||
assert.equal(p.reserves + p.tanks + sum(p.forces), 20, p.id);
|
||||
if (g.setupStage !== 'prediction') {
|
||||
const traitors = [
|
||||
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : []),
|
||||
...(g.traitorReserve ?? []),
|
||||
...g.players.flatMap((p) => [
|
||||
...p.traitors,
|
||||
|
||||
@@ -146,7 +146,7 @@ export function nexusTraitorInventory(g: Game): void {
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
universe,
|
||||
universe.filter(card => card !== g.ecazLoyalty?.card),
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
@@ -19,6 +20,27 @@ import {
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusReload, nexusReject, nexusAllow } from './fixture-nexus-cards';
|
||||
|
||||
void test('Advanced Ecaz Loyalty stays set aside through Harkonnen and Tleilaxu Nexus draws and returns', () => {
|
||||
for (const ownerFaction of ['harkonnen', 'tleilaxu'] as const) {
|
||||
let g = nexusTraitorFixture({ ownerFaction, opponentFaction: 'ecaz', advanced: true });
|
||||
const loyalty = structuredClone(g.ecazLoyalty)!;
|
||||
assert.ok(loyalty.card);
|
||||
const inventory = traitorDeck(g.players).sort();
|
||||
const census = () => [loyalty.card!, ...(g.traitorReserve ?? []), ...g.players.flatMap(player => [
|
||||
...player.traitors, ...(player.faceDancers ?? []).map(card => card.leader),
|
||||
])].sort();
|
||||
assert.deepEqual(census(), inventory);
|
||||
g = nexusTraitorDraw(g);
|
||||
const pending = viewGame(g, 'p').nexusTraitors!.pending!;
|
||||
assert.ok(pending.choices.every(card => card.id !== loyalty.card));
|
||||
g = nexusTraitorReturn(nexusReload(g), pending.choices.filter(card => card.drawn).map(card => card.id));
|
||||
nexusTraitorInventory(g);
|
||||
assert.deepEqual(g.ecazLoyalty, loyalty);
|
||||
assert.deepEqual(census(), inventory);
|
||||
for (const player of g.players) assert.deepEqual(viewGame(g, player.id).ecazLoyalty, loyalty);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real Cunning draws before return and resumes from JSON with one physical Nexus card spent', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const before = [...g.players[0].traitors],
|
||||
|
||||
Reference in new issue
Block a user