Prioritize Basic faction prototypes and connect three missing rule paths

This commit is contained in:
Quintine committed 2026-10-08 08:23:20 +11:00
1 parent f36632accc
commit c28e24b5fa
17 files changed
+152 -110

No files matched your search

+5 -2
View File
@@ -1913,8 +1913,11 @@ function policyActions(g: GameView): Action[] {
if (entry.stage === 'allianceReply') return [{ ...action, accept: true }];
if (entry.stage === 'revival') {
const revival = entry.revival;
if (!revival || revival.blocked || revival.maximum < 1)
return [{ ...action, decline: true }];
if (!revival || revival.blocked) return [{ ...action, decline: true }];
if (revival.maximum < 1)
return [revival.leaders[0]
? { ...action, leader: revival.leaders[0].id }
: { ...action, decline: true }];
// Minimal legal participation: return as many Tanks counters as the
// printed maximum allows, spending the single elite option first.
const elite = Math.min(
+4 -9
View File
@@ -92,9 +92,9 @@ function requireBattle(condition: unknown, message: string): asserts condition {
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
/** Advanced retains ceil(E/2); source-clear Basic admits only even E and uses
* E/2. The increment is free. The selected lead supplies the plan and pays for
* the actual ally's dial; selecting Ecaz does not transfer the ally's counters. */
/** Printed p8: ceil(E/2) free contribution and winning losses. Basic odd counts
* use this provisionally under the 8 October breadth-first instruction; the
* conflicting FAQ remains recorded, not silently promoted to a settled ruling. */
export function quoteEcazOccupyBattle(
input: EcazOccupyBattleInput,
): EcazOccupyBattleProfile {
@@ -137,14 +137,9 @@ export function quoteEcazOccupyBattle(
typeof canceled === 'boolean',
'The Occupy profile needs its pre-plan cancellation state.',
);
requireBattle(
advanced || canceled || ecaz.forces.normal % 2 === 0,
'Uncanceled Basic Occupy needs an even Ecaz force count; odd-force publisher casualty wording remains unresolved.',
);
const forceOwner = canceled ? lead : ally.id;
const forces = forceOwner === ecaz.id ? ecaz.forces : ally.forces;
const fixedEcazDial = canceled ? 0 :
advanced ? Math.ceil(ecaz.forces.normal / 2) : ecaz.forces.normal / 2;
const fixedEcazDial = canceled ? 0 : Math.ceil(ecaz.forces.normal / 2);
return {
advanced,
battleOrderActor,
+54 -23
View File
@@ -1537,6 +1537,8 @@ export type Game = {
| 'complete';
position: 'first' | 'last' | null;
normalCount: number | null;
/** Frozen before the cache deduction; cancellation never recounts live hands. */
normalCountBeforeCache?: number;
blackMarketSold: boolean;
cacheCanceled: boolean;
cacheAuthority?: string;
@@ -9445,7 +9447,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
!g.discoveryStash && !g.greatMaker &&
(g.ecazTreachery === undefined || g.ecazTreachery === true);
}
/** Fresh native Occupy profile; Basic's disputed odd-force arithmetic stays guarded. */
/** Fresh native Occupy profile; Basic odd counts use the main printed rule provisionally. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
'Occupy requires supported native factions and their distinct selected decks; Basic uses Ecaz/classic/optional Moritani without optional modules.');
@@ -10755,14 +10757,32 @@ function richeseDeclaration(g: Game) {
richeseDeclarationCache(g);
const round = g.richeseBidding!;
round.stage = 'declaration';
round.normalCountBeforeCache = Math.max(
0, auctionEligible(g).length - Number(round.blackMarketSold),
);
round.normalCount = Math.max(
0,
auctionEligible(g).length -
Number(richeseCacheRuns(g)) -
Number(round.blackMarketSold),
0, round.normalCountBeforeCache - Number(richeseCacheRuns(g)),
);
richeseDecision(g, 'richeseDeclaration');
}
function validateRicheseCacheCancellation(g: Game, response: ResponseWindow) {
const round = g.richeseBidding;
requireRule(g.status === 'playing' && g.phase === 3 && round &&
round.turn === g.turn && round.stage === 'declaration' &&
round.owner === response.owner && getPlayer(g, round.owner).faction === 'richese' &&
(round.position === 'first' || round.position === 'last') &&
Number.isSafeInteger(round.normalCount) && round.normalCount! >= 0 &&
round.normalCount! <= g.players.length && !round.cacheCanceled &&
!g.auction && !g.richeseAuction && !g.ixAuction &&
(round.normalCountBeforeCache === undefined ||
Number.isSafeInteger(round.normalCountBeforeCache) &&
round.normalCountBeforeCache >= round.normalCount! &&
round.normalCountBeforeCache <= g.players.length &&
round.normalCountBeforeCache - round.normalCount! <= 1),
'Cache cancellation needs the original undealt Richese declaration.');
richeseDeclarationCache(g);
return round;
}
function prepareRicheseNormal(g: Game) {
g.richeseBidding!.stage = 'normal';
setAuction(g);
@@ -12775,10 +12795,8 @@ function validateKaramaUse(
response: { kind: use.response.kind, owner: use.response.owner },
canceled: true,
});
requireRule(
use.response.kind !== 'richeseAuction',
'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.',
);
if (use.response.kind === 'richeseAuction')
validateRicheseCacheCancellation(g, use.response);
if (
use.response.kind === 'richeseBlackMarket' ||
(use.response.kind === 'atreidesAuction' && g.richeseAuction)
@@ -16319,15 +16337,13 @@ function resolveAmbassadorEffect(g: Game) {
return;
}
if (effect === 'tleilaxu') {
// Printed grant: "Revive one of your leaders or up to 4 of your forces for
// free." Only the physical force alternative is source-supported here; the
// leader alternative, ordinary allowance accounting and Tleilaxu income for
// this distinct event stay gated pending the recorded interpretations.
// Initial leader path: first-death own discs, independent of ordinary quota.
// Repeat-death and shared-disc interactions remain explicit refinement work.
entry.stage = 'revival';
g.decision = { kind: 'ecazAmbassador', player: p.id };
log(
g,
`${p.name} may return up to four physical forces from the Tanks for free through the Tleilaxu Ambassador. Ordinary revival allowances and income are unchanged.`,
`${p.name} may revive one first-death own leader or return up to four physical forces for free through the Tleilaxu Ambassador. Ordinary revival allowances and income are unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
);
return;
@@ -16468,9 +16484,9 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
if (entry.stage === 'revival') {
requireRule(
Object.keys(action).every((key) =>
['type', 'event', 'decline', 'forces', 'elite'].includes(key),
['type', 'event', 'decline', 'forces', 'elite', 'leader'].includes(key),
),
'Choose a physical force return or decline it.',
'Choose one leader, a physical force return, or decline.',
);
if (action.decline === true) {
log(
@@ -16481,6 +16497,20 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
finishAmbassador(g);
return;
}
if (action.leader !== undefined) {
requireRule(action.forces === undefined && action.elite === undefined,
'Choose a leader or forces, not both.');
const leader = p.leaders.find(l => l.id === action.leader &&
l.faction === p.faction && l.dead && l.deaths === 1 &&
!l.capturedBy && !l.gholaBy);
requireRule(leader, 'Choose a first-death own leader from the Tanks.');
leader.dead = false;
delete leader.usedAt;
log(g, `${p.name} revived ${leader.name} for free through the Tleilaxu Ambassador. The ordinary leader allowance is unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' });
finishAmbassador(g);
return;
}
const forces = integer(action.forces, 1, 4, 'Forces to return');
const elite =
action.elite === undefined
@@ -23946,12 +23976,13 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g);
if (pending.mentat) g.decision = { kind: 'choamMentat', player: choam.id };
} else if (response.kind === 'richeseAuction') {
const round = g.richeseBidding!;
const round = validateRicheseCacheCancellation(g, response);
if (canceled) {
// User ruling 7 October 2026: a canceled or prevented compulsory cache
// auction restores one ordinary lot, since no cache lot replaces it.
// Restore the frozen count, retaining the independent Black Market deduction.
// Older saved declarations lack the additive before-cache field.
round.normalCount = round.normalCountBeforeCache ??
Math.max(0, round.normalCount! + Number(richeseCacheRuns(g)));
round.cacheCanceled = true;
round.normalCount = Math.max(0, (round.normalCount ?? 0) + 1);
log(
g,
'The compulsory Richese cache auction was canceled; one ordinary lot is restored.',
@@ -31279,8 +31310,6 @@ function applyActionInner(
if (combined) {
requireRule(ecazOccupyCompositionSupported(g),
'Combined Occupy requires source-selected native factions; optional overlays remain gated.');
requireRule(g.advanced || fighterCount(ecaz!, choice.territory) % 2 === 0,
'Odd-force Basic Occupy awaits the preserved publisher casualty-rounding ruling.');
}
const battleEvent = crypto.randomUUID();
g.battle = {
@@ -32073,7 +32102,9 @@ export function viewGame(state: Game, id: string) {
tanks: me.tanks,
eliteTanks: me.elites?.tanks ?? 0,
eliteRevived: me.elites?.revived ?? 0,
leaderAlternative: false,
leaders: me.leaders.filter(l => l.faction === me.faction &&
l.dead && l.deaths === 1 && !l.capturedBy && !l.gholaBy)
.map(l => ({ id: l.id, name: l.name, strength: l.strength })),
blocked:
ambassadorEffectBlock(
g,
+9 -8
View File
@@ -419,7 +419,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The opt-in Richese Nexus Betrayal auction preview adds a neutral before-payment acknowledgement. A legal rival holder may veto a public self-cache purchase or send another buyer’s sale payment to the bank. See its separate guide and bounded verification evidence; normal hidden self-purchases and special-Karama acquisition are not connected triggers.',
'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.',
'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.',
'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.',
'Karama may prevent the declared cache auction before the ordinary pool is drawn. Cancellation leaves the cache intact and restores its deducted ordinary lot; first/last position does not create another cache offer that round. The independent reduction for a completed Black Market sale remains. An exhausted cache similarly causes no cache deduction. Positive Black Market self-bids and advanced Ixian Technology substitution on special lots retain their separate boundaries.',
'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies opposing revealed attacks, with independent copy-first choices for Tooth and Stone in bounded CHOAM/Richese-deck classic-faction battles. A winning physical Mirror may be kept even after copying activated Tooth or Artillery under the user-selected interpretation, not a publisher clarification. Semuta Drug has a bounded clean public ordinary-discard preview; other discard producers and Sapho timing modes remain unfinished. This does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
],
related: [
@@ -1253,14 +1253,14 @@ export const RULE_TOPICS: RuleTopic[] = [
),
{
id: 'ecaz-occupy',
title: 'Advanced Ecaz Occupy: combined-army battles',
title: 'Ecaz Occupy: Basic and Advanced combined-army battles',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'Ecaz chooses who leads its combined army. Basic uses the source-clear even-force half contribution and losses; the authorized Advanced revision supplies ceiling strength and lead-own forces after Karama. Bounded fresh local rules and human controls are Development Verified.',
developmentStage: 'Prototyped',
summary: 'Ecaz chooses who leads its combined army. Basic provisionally follows the main printed paragraph for odd as well as even counts: ceiling contribution/loss and floor survivors. Advanced retains its user-authorized revision. Usable controls and legal participation are connected; interaction refinement remains.',
steps: [
'When reciprocal Ecaz allies coexist with fighting forces, Ecaz chooses Ecaz or its ally to lead. Every participating whole-territory army must be clear of storm and connected; no component-only battle or casualties are created. The selected lead uses its own leaders, Treachery cards and spice; the original battle-order chooser is not replaced. Advisors are not fighters but retain their native voluntary fighter-conversion opportunity.',
'Basic combined battles are admitted for the exact Ecaz deck with classic or optional Moritani opponents and no optional modules. An even Ecaz force count contributes half at full strength and loses half on an ordinary win; the printed page-8 and page-15 odd-count examples conflict, so an uncanceled odd Basic count is rejected before any lead choice rather than choosing a rounding. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
'Basic combined battles use the native Ecaz deck and existing roster prerequisites. Ecaz contributes the ceiling of half its forces at full strength and loses that many on an ordinary win; survivors round down. This follows the main printed paragraph provisionally because its odd-count FAQ example conflicts. A visible control warning distinguishes the prototype from a settled correction. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
'With Advanced Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.',
'An ordinary victory leaves the floor of half the Ecaz fighters alive and separately applies the variable army’s dial losses. A sole successful Traitor-call winner preserves both armies; an ordinary defeat, mutual Traitor result or resolved explosion removes the actual combined side. Ecaz and its ally share the printed same-faction Traitor/Face Dancer relation.',
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
@@ -1270,13 +1270,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'Stone Burner compares physical undialed counters, not total dial or paid strength: active Occupy contributes floor(Ecaz forces / 2) plus the ally’s native undialed tokens. Canceled Occupy compares only the selected lead’s own pool. Physical Mirror copies use the same comparison and retain their original cleanup. Hidden opposing No-Field preflight uses public possible pools, not its actual denomination; genuinely winner-changing typed allocations remain blocked.',
'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
'Reinforcements consumes three card-user reserves normal-first and adds +2 score without extra support or on-board arrivals. Its existing provisional cost/disposal on every revealed outcome is unchanged, not a new publisher ruling. Own same-plan Harass/Reinforcements and Stone remain guarded; Basic/unprofiled co-side cards remain unsupported.',
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic keeps the preserved even-only composition above. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM/Richese two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both; Richese retains its separate ten-card cache. Basic admits Ecaz/classic/optional Moritani and the exact ecaz deck. Explicit --ecaz-treachery independently adds all three cards for36/50/38. Own mixed No-Field dialing, whole-plan disclosure, Ix Stone/Tooth timing, Harass/Stone and optional overlays remain open. Full factions/public starts stay gated.',
'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic follows the main printed paragraph provisionally. Existing faction/deck prerequisites remain:33 Ecaz alone,47 Ix,35 CHOAM,47 both; Richese retains its separate ten-card cache. Explicit --ecaz-treachery independently adds all three cards. Whole-plan disclosure, mixed No-Field and optional-overlay interactions remain for refinement; public starts remain gated.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, explicit lead choice, native Occupy response, source-band even Basic or Advanced arithmetic and owner-labelled physical dial/outcome integration are implemented. Optional/exotic compositions remain open.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, lead choice, native Occupy response and owner-labelled physical outcomes are connected. Basic odd counts now follow the main printed paragraph provisionally; Advanced retains its authorized arithmetic. Optional/exotic composition refinement remains.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Original 390px controls cover mandatory/free dials, native Karama redial, Ix substitutions and whole-side Face Dance. Richese human T78EFNZK chose Ecaz lead, sealed total5/support2 with its real leader, revealed the ally’s original No-Field5 into only two available reserves, and reached Collection with Ecaz2 and two Richese losses. Basic human FEJ6SBFB shows the correct even-force labels, a total-dial slider bounded2..6 with no support control, and real Ecaz/guild casualties after an8–6 win. Other combinations remain open.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic/cancellation, unchanged Basic and specific Duke override are separated from historical source gaps and optional-module gates.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic and provisional main-paragraph Basic arithmetic are distinguished from the conflicting FAQ, specific Duke ruling and deferred interaction refinement.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: affected363 rules cases,26 actual native programs including all four legal policies, and original390px Richese/No-Field controls. Source-band Basic adds focused even/odd/canceled quote cases and native runtime cases with all four policies and original Collection/victory. Five genuine2..6-seat Basic games: four complete2090 accepted actions/54 JSON continuations/no rejection; four-seat seed20261299 is captured at the preserved odd-count conflict. Earlier46 ordinary/variant games and source-specific card evidence remain historical; complete factions/optional combinations and comprehensive assurance follow all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
@@ -1328,6 +1328,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this overlap path.',
'The Tleilaxu Ambassador offers one first-death own-leader revival or up to four physical forces for free. These are exclusive choices; the beneficiary sees its own eligible leaders privately. The ordinary allowance remains unchanged provisionally. Repeat-death, shared-leader and revival-income interactions remain for refinement.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
],
checklist: [