Connect expansion faction setup and saved Ixian auction decline

Add a local faction-only prototype profile for all seven nonempty expansion selections, genuine Basic/Advanced initialization and existing private setup, controls, AI and saved continuation. Ecaz allocates six forces across Imperial Basin sectors and retains fourteen reserves before later setup; preserve Fremen, Advanced BG and Moritani ordering with printed and inferred boundaries explicit. Keep optional Ecaz Treachery cards separate, ordinary deck counts 33/35/47, and Richese cache separate. Public start and publication gates remain closed.

Replace the Advanced Ixian/Richese pre-offer stop with an explicit per-lot Ixian decline. Bind the saved original offer to its round, turn, owner, independent event, exact card custody and terms before view/action/normalization. Decline resumes that unchanged lot once without using Technology; later normal lots still offer the exchange. Protect reserved cards across transfers, spending and random Harkonnen exchange, and preserve legitimate Gift/Truthtrance interruptions. The actual special-lot exchange still awaits replacement-custody interpretation.

Validation: focused setup, rules, UI/bot, private JSON/SQLite reload and concurrent CAS tests and independent custody/persistence review passed. Typecheck, lint, 4573 offline tests, production build and 43 HTTP tests passed. Browser Ecaz setup rejected an incomplete total, saved a 2/2/2 split, and refreshed to fourteen reserves and unchanged private cards. A separate genuine Advanced QA game restored the Ixian choice; explicit decline opened the unchanged cache lot with legal bidding and retained private hand, spice, pool count and unused Technology after refresh.

Five of six genuine four-profile samples finish: CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12. Combined Advanced passes three former auction stops and reaches turn three/action614 with 16 restores, then hits an existing CHOAM movement/Ambassador arrival guard. Ecaz samples also include guarded arrival candidates with legal alternatives. Full combined play is not certified; arrival integration is the next gap. Retain initial failed logs, including a corrected private harness sold-card receipt alias.

After the outage, all 597 opening rooms and original seat/recovery records were preserved (632 after QA/HTTP). A stale development module cache required restart after a fresh 598-room backup; all 598 matched afterward and the QA seat restored. Final integrity and foreign keys passed. No database reset, recurring automation or publication.

Verified source tree: 5e2ff244a1167f2834bb46d609b8de914a34f7049ff7b06046243068c0935131
This commit is contained in:
Quintine committed 2026-09-13 22:51:19 +10:00
1 parent f067950f5e
commit cab2f1c42d
26 files changed
+3001 -58

No files matched your search

+6
View File
@@ -9,6 +9,7 @@ import { greatMakerBotActions } from './great-maker-options';
import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
import { nexusCardBotActions } from './nexus-card-options';
import { nexusTraitorBotActions } from './nexus-traitor-options';
@@ -1190,12 +1191,15 @@ function policyActions(g: GameView): Action[] {
},
},
];
if (placingForces && me.faction === 'ecaz' && me.reserves === 20 && g.setupStage === 'forces' && g.setupPending.includes(me.id))
return [{ type: 'ecazSetup', placements: quoteEcazStartingForces({ [ECAZ_START_LOCATIONS[0]]: ECAZ_START_FORCES }) }];
if (
placingForces &&
g.advanced &&
me.faction === 'beneGesserit' &&
!me.advisorSetup &&
me.reserves === 20 &&
(!g.setupStage || g.setupPending.includes(me.id)) &&
g.players.every((p) => p.faction !== 'fremen' || p.reserves === 10)
)
return destinations(g, true)
@@ -1658,6 +1662,8 @@ function policyActions(g: GameView): Action[] {
const card = cards.find((c) => c.kind === 'worthless') ?? cards[0];
return card ? [{ type: 'decision', card: card.id }] : [];
}
if (d.kind === 'ixRicheseTechnology')
return [{ type: 'decision', event: d.event, decline: true }];
if (d.kind === 'richeseBlackMarket')
return [
{ type: 'decision', event: g.richeseBidding!.event, decline: true },
+3 -1
View File
@@ -62,8 +62,10 @@ export function choamDeck(): Card[] {
}
/** Deck assembly does not open the separate faction/module release gates. */
export function treacheryDeck(expansions: readonly string[] = []): Card[] {
if (expansions.some((id) => id !== 'ix' && id !== 'choam'))
if (expansions.some((id) => !['ix', 'choam', 'ecaz'].includes(id)))
throw new Error('This expansion treachery deck is not implemented yet.');
// Ecaz/Moritani factions are independent of the optional three-card variant.
// Selecting those factions does not activate its still-unimplemented effects.
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : expansions.includes('choam') ? choamDeck() : [])];
}
export type Leader = {
+44
View File
@@ -0,0 +1,44 @@
import { location, territory } from './board';
export const ECAZ_START_FORCES = 6;
export const ECAZ_START_LOCATIONS = Object.freeze(
territory('imperial_basin').sectors.map((sector) =>
location('imperial_basin', sector),
),
);
/** The printed territory and total constrain the allocation; return detached positive groups. */
export function quoteEcazStartingForces(
placements: unknown,
): Record<string, number> {
if (
!placements ||
typeof placements !== 'object' ||
Array.isArray(placements)
)
throw new Error(
'Choose the Imperial Basin sectors for six Ecaz starting forces.',
);
const entries = Object.entries(placements);
if (
!entries.every(
([key, amount]) =>
ECAZ_START_LOCATIONS.includes(key) &&
typeof amount === 'number' &&
Number.isSafeInteger(amount) &&
amount >= 0 &&
amount <= ECAZ_START_FORCES,
)
)
throw new Error(
'Ecaz starting forces require whole nonnegative amounts in explicit Imperial Basin sectors.',
);
if (
entries.reduce((total, [, amount]) => total + amount, 0) !==
ECAZ_START_FORCES
)
throw new Error(
'Place exactly six Ecaz starting forces in Imperial Basin.',
);
return Object.fromEntries(entries.filter(([, amount]) => amount > 0));
}
+169 -10
View File
@@ -15,6 +15,7 @@ import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipmen
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
@@ -710,6 +711,7 @@ export type Decision =
| { kind: 'ixSetup'; player: string }
| { kind: 'ixAuction'; player: string }
| { kind: 'ixTechnology'; player: string }
| { kind: 'ixRicheseTechnology'; player: string; event: string; source: 'cache' | 'blackMarket' }
| { kind: 'ixAllyCard'; player: string }
| { kind: 'mobileStronghold'; player: string; placement: boolean }
| {
@@ -1124,6 +1126,22 @@ export type Game = {
richesePeekKnown?: boolean;
richeseOfferedCard?: Card | null;
richeseClaim?: string | null;
/** Private pre-lot offer. The public Ixian choice contains no Black Market card identity. */
pendingIxRicheseTechnology?: {
event: string;
player: string;
owner: string;
round: string;
turn: number;
source: 'cache' | 'blackMarket';
card: Card;
method: RicheseAuction['method'];
direction: 'clockwise' | 'counterclockwise' | null;
claim: string | null;
frame: string;
signature: string;
};
ixRicheseTechnologyEvent?: string;
currentAuctionSale?: {
winner: string;
amount: number;
@@ -2870,6 +2888,8 @@ function savedTransferResponses(g: Game) {
];
}
function transferCardBlock(g: Game, owner: Player, card: Card) {
if (g.pendingIxRicheseTechnology?.owner === owner.id && g.pendingIxRicheseTechnology.card.id === card.id)
return 'This card is reserved for the pending Richese lot and Ixian choice.';
if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id))
return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.';
@@ -3322,6 +3342,8 @@ function draw(g: Game) {
return g.deck.shift();
}
function discard(g: Game, p: Player, id: string, role?: Omit<EcazPoisonDiscard, 'card'>) {
requireRule(g.pendingIxRicheseTechnology?.card.id !== id,
'The card reserved for the pending Richese lot cannot be discarded.');
requireRule(
!g.pendingTreacheryDiscard,
'Finish the committed discard continuation before another discard.',
@@ -5290,6 +5312,8 @@ function setupPending(g: Game): string[] {
.map((p) => p.id);
const fremen = byFaction(g, 'fremen');
if (fremen?.reserves === 20) return [fremen.id];
const ecaz = byFaction(g, 'ecaz');
if (ecaz?.reserves === 20) return [ecaz.id];
const bg = byFaction(g, 'beneGesserit');
return g.advanced && bg && !bg.advisorSetup ? [bg.id] : [];
}
@@ -5371,6 +5395,33 @@ export function initializeIxGameForAudit(state: Game): Game {
'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.');
return initializeSetupGameForAudit(state, false, false, true);
}
/** Genuine faction-only development setup. Optional variants and public starts remain gated. */
export function initializeFactionExpansionsGameForAudit(state: Game): Game {
requireRule(typeof state.advanced === 'boolean', 'Choose Basic or Advanced rules for the faction prototype.');
requireRule(Array.isArray(state.expansions) && state.expansions.length > 0 &&
state.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(state.expansions).size === state.expansions.length,
'The faction prototype needs a nonempty selection of distinct known expansions.');
requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills &&
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
!state.techTokens && !state.strongholdCards,
'The faction prototype excludes optional modules, including Leader Skills and Discoveries.');
requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined &&
state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === undefined &&
state.mobileStronghold === undefined && state.ixSetupCards === undefined &&
state.players.every(p => {
const original = leaders(p.faction);
return p.noField === undefined && p.noFieldEvent === undefined && p.faceDancers === undefined &&
p.revived === 0 && p.freeForcesRevived === 0 && !p.leaderRevived && p.revivalCycle === 0 &&
p.battleLosses === 0 && p.ally === null && p.bribes === 0 &&
p.leaders.length === original.length && original.every(expected => {
const actual = p.leaders.find(l => l.id === expected.id);
return actual && Object.keys(actual).length === Object.keys(expected).length &&
Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value);
});
}), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.');
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
}
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
export function initializeDiscoveryGameForAudit(state: Game): Game {
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
@@ -5384,7 +5435,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
g.leaderSkills = createLeaderSkills(random);
return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam');
}
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false): Game {
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false): Game {
nexusCardsIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -5406,8 +5457,9 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
'The audit initializer requires two through six distinct ready players and an existing host.',
);
requireRule(
(homeworlds || nexus || ix || choam || g.expansions.length === 0) &&
(choam || !g.expansions.includes('choam')) &&
(homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) &&
(choam || factions || !g.expansions.includes('choam')) &&
(factions || !g.expansions.includes('ecaz')) &&
(leaderSkills || !g.leaderSkills) &&
(discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) &&
@@ -5416,10 +5468,12 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam')),
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam') || (factions && g.expansions.includes(f.expansion))),
),
),
choam
factions
? 'The faction prototype supports base factions and the selected expansion factions without optional modules.'
: choam
? 'The Leader Skills prototype supports base, CHOAM and Richese factions without other expansions or optional modules.'
: ix
? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.'
@@ -5451,7 +5505,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
!p.prediction &&
!p.advisorSetup &&
(!p.elites ||
((homeworlds || nexus || ix) &&
((homeworlds || nexus || ix || factions) &&
p.faction === 'ixians' &&
p.elites.reserves === 7 &&
p.elites.tanks === 0 &&
@@ -5470,6 +5524,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
initializeSetup(g);
return normalizeAutomaticGame(g);
}
function ecazStartingForcesComplete(p: Player): boolean {
if (p.reserves !== 20 - ECAZ_START_FORCES) return false;
try { quoteEcazStartingForces(p.forces); return true; } catch { return false; }
}
function otherSetupComplete(g: Game) {
return (
!g.setupStage &&
@@ -5478,6 +5536,7 @@ function otherSetupComplete(g: Game) {
(p) =>
(p.faction === 'tleilaxu' || p.traitors.length > 0) &&
(p.faction !== 'fremen' || p.reserves === 10) &&
(p.faction !== 'ecaz' || ecazStartingForcesComplete(p)) &&
(p.faction !== 'beneGesserit' ||
(p.prediction && (!g.advanced || p.advisorSetup))),
)
@@ -6528,10 +6587,47 @@ function richeseOfferBlock(g: Game): string | null {
? 'Ixian Technology on Richese lots is awaiting a ruling on replacement-card custody.'
: null;
}
function ixRicheseTechnologyFrame(g: Game): string {
return JSON.stringify({ round: g.richeseBidding, order: g.order, positions: normalizedPlayerPositions(g) });
}
function ixRicheseTechnologySignature(pending: NonNullable<Game['pendingIxRicheseTechnology']>): string {
return JSON.stringify({ ...pending, signature: undefined });
}
function ixRicheseTechnologyIntegrity(g: Game) {
const pending = g.pendingIxRicheseTechnology;
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'ixRicheseTechnology');
if (pending === undefined && g.ixRicheseTechnologyEvent === undefined && !decisions.length) return;
const round = g.richeseBidding, ixians = byFaction(g, 'ixians'), owner = byFaction(g, 'richese');
requireRule(pending && g.status === 'playing' && g.phase === 3 && g.advanced &&
ixians && g.ixTechnologyTurn !== g.turn && owner && round &&
pending.player === ixians.id && pending.owner === owner.id && pending.round === round.event &&
pending.turn === g.turn && round.turn === g.turn && round.owner === owner.id &&
typeof pending.event === 'string' && pending.event.length > 0 && pending.event === g.ixRicheseTechnologyEvent &&
['cache', 'blackMarket'].includes(pending.source) &&
round.stage === (pending.source === 'cache' ? 'cacheOffer' : 'blackMarketOffer') &&
!g.auction && !g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && !g.richeseOfferedCard &&
pending.frame === ixRicheseTechnologyFrame(g) && pending.signature === ixRicheseTechnologySignature(pending),
'The saved Ixian Richese-lot choice lost or changed its original offer, round or event.');
requireRule(['normal', 'onceAround', 'silent'].includes(pending.method) &&
(pending.source !== 'cache' || pending.method !== 'normal') &&
(pending.direction === null || ['clockwise', 'counterclockwise'].includes(pending.direction)) &&
(pending.method !== 'onceAround' || pending.direction !== null) &&
(pending.source === 'cache' ? pending.claim === null : pending.claim === null ||
typeof pending.claim === 'string' && pending.claim === pending.claim.trim() && pending.claim.length <= 300),
'The saved Ixian Richese-lot choice has invalid auction terms.');
const held = (pending.source === 'cache' ? g.richeseCache : owner.hand)?.filter(c => c.id === pending.card?.id);
requireRule(held?.length === 1 && JSON.stringify(held[0]) === JSON.stringify(pending.card) &&
physicalTreacheryCards(g).filter(c => c.id === pending.card.id).length === 1,
'The pending Richese offer must retain its exact unique card in the original custody.');
requireRule(decisions.length === 1 && decisions[0].player === pending.player && decisions[0].event === pending.event &&
decisions[0].source === pending.source && Object.keys(decisions[0]).every(key => ['kind', 'player', 'event', 'source'].includes(key)),
'The saved Ixian Richese-lot choice lost or duplicated its original decision.');
}
function beginRicheseLot(
g: Game,
source: 'cache' | 'blackMarket',
action: Action,
technologyDeclined = false,
) {
const round = g.richeseBidding!,
owner = getPlayer(g, round.owner);
@@ -6558,8 +6654,25 @@ function beginRicheseLot(
action.method !== 'onceAround' || action.direction !== undefined,
'Choose a physical bidding direction.',
);
const blocked = richeseOfferBlock(g);
requireRule(!blocked, blocked ?? 'This Richese lot is unavailable.');
if (richeseOfferBlock(g) && !technologyDeclined) {
requireRule(!g.pendingIxRicheseTechnology && g.ixRicheseTechnologyEvent === undefined,
'Finish the existing Ixian choice before declaring another Richese offer.');
const event = crypto.randomUUID(), player = byFaction(g, 'ixians')!.id;
const pending: NonNullable<Game['pendingIxRicheseTechnology']> = {
event, player, owner: owner.id, round: round.event, turn: g.turn, source, card: structuredClone(card),
method: action.method as RicheseAuction['method'],
direction: (action.direction as 'clockwise' | 'counterclockwise' | undefined) ?? null,
claim: source === 'blackMarket' && typeof action.claim === 'string' ? action.claim.trim().slice(0, 300) : null,
frame: ixRicheseTechnologyFrame(g), signature: '',
};
pending.signature = ixRicheseTechnologySignature(pending);
g.pendingIxRicheseTechnology = pending;
g.ixRicheseTechnologyEvent = event;
g.decision = { kind: 'ixRicheseTechnology', player, event, source };
g.active = player;
log(g, `${getPlayer(g, player).name} must choose whether to continue without exchanging this Richese ${source === 'cache' ? 'cache' : 'Black Market'} lot. The exchange is not yet implemented; Technology remains available for later lots.`);
return;
}
const positions = normalizedPlayerPositions(g);
let order = [...g.order];
if (action.method === 'onceAround') {
@@ -7172,6 +7285,8 @@ function nextAuction(g: Game) {
}
/** Physical custody only: promise feasibility remains conservatively checked at commit. */
function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
if (g.pendingIxRicheseTechnology?.card.id === card.id)
return 'This card is reserved for the pending Richese lot and Ixian choice.';
if (g.battle?.lateDefense?.[p.id] === card.id)
return 'This Portable Snooper is already played and reserved for battle cleanup.';
if (giftReserved(g, p.id, card.id))
@@ -18533,6 +18648,8 @@ export function prepareSpecialKaramaIntent(
'Resolve the previous purchase income first.',
);
const acquire = stringField(action.acquire);
requireRule(g.pendingIxRicheseTechnology?.card.id !== acquire,
'The card reserved for the pending Richese lot cannot be acquired by another effect.');
const chosen = g.richeseCache?.find((c) => c.id === acquire);
requireRule(
chosen && richeseCardDefinition(chosen),
@@ -18724,6 +18841,11 @@ export function prepareSpecialKaramaIntent(
g.pendingRicheseGift?.intent.owner !== target.id,
'Resolve the reserved Richese gift before randomly exchanging that hand.',
);
requireRule(
g.pendingIxRicheseTechnology?.source !== 'blackMarket' ||
g.pendingIxRicheseTechnology.owner !== target.id,
'Resolve the pending Black Market Ixian choice before randomly exchanging that hand.',
);
const count = integer(
action.amount,
1,
@@ -19117,6 +19239,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -19283,6 +19406,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
homeworldSubstitutionIntegrity(g);
homeworldDefenseIntegrity(g);
homeworldShipmentIntegrity(g);
ixRicheseTechnologyIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
@@ -19377,6 +19501,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -19417,6 +19542,7 @@ export function normalizeAutomaticGame(state: Game): Game {
homeworldSubstitutionIntegrity(g);
homeworldDefenseIntegrity(g);
homeworldShipmentIntegrity(g);
ixRicheseTechnologyIntegrity(g);
return g;
}
@@ -19805,6 +19931,19 @@ function applyActionInner(
);
g.decision = null;
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
if (decision.kind === 'ixRicheseTechnology') {
const pending = g.pendingIxRicheseTechnology!;
requireRule(action.event === pending.event && action.decline === true &&
Object.keys(action).every(key => ['type', 'event', 'decline'].includes(key)),
'Ixian exchange custody for Richese lots is not implemented. Explicitly continue without this exchange.');
delete g.pendingIxRicheseTechnology;
delete g.ixRicheseTechnologyEvent;
log(g, `${p.name} continued without exchanging this Richese lot; Technology remains available for later lots.`);
beginRicheseLot(g, pending.source, { type: 'decision', card: pending.card.id, method: pending.method,
...(pending.direction !== null ? { direction: pending.direction } : {}),
...(pending.claim !== null ? { claim: pending.claim } : {}) }, true);
return g;
}
if (decision.kind === 'homeworldRevivalDeployment') {
decideHomeworldRevivalReturn(g, p, action);
return g;
@@ -21080,7 +21219,7 @@ function applyActionInner(
? ['traitor']
: g.setupStage === 'leaderSkills'
? ['leaderSkill']
: ['fremenSetup', 'advisorSetup'];
: ['fremenSetup', 'ecazSetup', 'advisorSetup'];
requireRule(
expected.includes(t) && setupPending(g).includes(id),
`Wait for the current setup step: ${g.setupStage}.`,
@@ -21130,6 +21269,18 @@ function applyActionInner(
);
p.traitors = [String(action.leader)];
p.traitorChoices = [];
} else if (t === 'ecazSetup') {
requireRule(g.setupStage === 'forces' && p.faction === 'ecaz' && p.reserves === 20 &&
Object.keys(p.forces).length === 0 && Object.keys(action).every(key => ['type', 'placements'].includes(key)),
'Place Ecaz starting forces once during the current force setup.');
const placements = nexusRule(() => quoteEcazStartingForces(action.placements));
for (const [key, amount] of Object.entries(placements)) {
const loc = splitLocation(key);
place(p, loc.territory, loc.sector, amount);
}
p.reserves -= ECAZ_START_FORCES;
log(g, `${p.name} placed six starting forces in Imperial Basin and retained fourteen in reserves.`,
{ faction: p.faction, name: 'Starting forces' });
} else if (t === 'fremenSetup') {
requireRule(
p.faction === 'fremen' && p.reserves === 20,
@@ -22823,6 +22974,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryFlightIntegrity(state);
discoveryIntegrity(state);
@@ -23636,6 +23788,13 @@ export function viewGame(state: Game, id: string) {
}
: null,
richeseSpecialKarama: richeseSpecialKaramaView(g, me),
ixRicheseTechnology: g.pendingIxRicheseTechnology ? {
event: g.pendingIxRicheseTechnology.event,
player: g.pendingIxRicheseTechnology.player,
owner: g.pendingIxRicheseTechnology.owner,
source: g.pendingIxRicheseTechnology.source,
exchangeBlocked: 'Ixian exchange custody for Richese lots is not implemented. You can explicitly continue without this exchange; Technology remains available for later lots.',
} : null,
richeseBidding:
g.richeseBidding?.turn === g.turn
? {
@@ -23644,7 +23803,7 @@ export function viewGame(state: Game, id: string) {
stage: g.richeseBidding.stage,
position: g.richeseBidding.position,
normalCount: g.richeseBidding.normalCount,
offerBlocked: richeseOfferBlock(g),
offerBlocked: null,
cache:
g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null,
}
+24
View File
@@ -3591,6 +3591,30 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
],
},
{
id: 'expansion-faction-games',
title: 'Expansion faction development games',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Genuine setup connects selected expansion factions to development tables. Complete faction rules and combined-game acceptance remain unfinished.',
steps: [
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: Recruits, Reinforcements and Harass & Withdraw form a separate unfinished optional variant.',
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.',
],
related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Genuine setup for seven expansion selections reuses existing faction effects and corrects Ecaz starting placement. Saved per-lot Ixian decline resumes Richese bidding without changing card custody or consuming Technology. Optional variants and unfinished interactions remain separate.', evidence: ['game/engine.ts', 'game/cards.ts', 'game/ecaz-setup.ts', 'tools/prototype-room.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Existing faction choices and private hands are reused. Ecaz has an owned sector-allocation control and setup waiting messages; Ixians have an explicit per-lot decline control. Public starts and full acceptance remain gated.', evidence: ['components/ecaz-setup.tsx', 'components/ix-richese-technology.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use genuine setup choices and the shared Ecaz allocation quote, and can decline this Richese lot without using Technology. Combined gameplay strategy, guarded interactions and full-game reliability remain unfinished.', evidence: ['game/bots.ts', 'game/ecaz-setup.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The faction profile records ordinary deck composition, independent variants, Ecaz territory allocation and deterministic setup-order interpretations, with unfinished play explicit.', evidence: ['docs/EXPANSION_FACTIONS_PROTOTYPE.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained.', evidence: ['tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] },
],
},
...[
[
'ix-modules',
+1
View File
@@ -38,6 +38,7 @@ const DECISIONS = {
ixSetup: true,
ixAuction: true,
ixTechnology: true,
ixRicheseTechnology: true,
ixAllyCard: true,
mobileStronghold: true,
ixSubstitution: true,