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:
Quintine committed 2026-09-21 12:43:37 +10:00
1 parent c2f24fc546
commit de26aaf029
18 files changed
+490 -32

No files matched your search

+38
View File
@@ -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
View File
@@ -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'
+2 -1
View File
@@ -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
View File
@@ -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'] : [])],
}),
),
{