Connect Basic CHOAM skills and preserve market Ghola choices

Connect ordinary Leader Skill paths to Basic CHOAM with base opponents,
the real 35-card Treachery deck and all fourteen skills. Retain wider
development initializer behavior and existing mode/publication gates.

Pause a declared sale until its Ghola revival and private skill choice
finish. Bind the actual revived leader, offer event and completion to the
saved interruption. Preserve nested Truthtrance disposal and exact sale
responses; canceling a resumed sale leaves the free revival completed.
Keep force-only and retained-skill revivals free of replacement offers.

Add genuine setup, native card-sale, mandatory disposal, private custody,
corruption and concurrent authenticated recovery regressions plus a
two-through-six-player complete-game sample profile. Record the unresolved
Sandmaster/native-HMS eligibility question without inventing a bonus.

Validation: types, lint, 5101 offline tests and production build passed.
Five genuine 2-6 player CHOAM Skills games completed with zero rejected
actions. Independent review and 50 focused compatibility tests passed.
Browser Y2UJGMCS exercised real setup, Ghola draw, exact private refresh,
mobile assignment and resumed sale paying two spice once. Screenshot
capture timed out; full visual acceptance remains open.
All 1,583 prior rooms, 3,351 seats and auxiliary records remain intact;
one QA room and human seat were added. No restart, reset or automation.

Full AI implementation and tuning remain deferred until all non-AI
features are complete, then target roughly 75% for each adjacent tier.
This commit is contained in:
Quintine committed 2026-09-21 15:10:29 +10:00
1 parent 3efda2128e
commit d1bf33d0bc
20 files changed
+1479 -31

No files matched your search

+67
View File
@@ -1,6 +1,7 @@
import type { Game, ResponseWindow } from './engine';
import type { ChoamMarket } from './choam-market';
import { quoteChoamSaleCancellation } from './choam-sale-cancellation';
import { CHOAM_AUDITOR_ID } from './cards';
/** The sale is suspended, never replayed. Its physical card stays in its hand. */
export type ChoamMarketGhola = {
@@ -13,6 +14,13 @@ export type ChoamMarketGhola = {
stage: 'discard' | 'income' | 'complete';
response: ResponseWindow;
market: ChoamMarket;
/** New Skills interruptions bind their committed revival through the optional choice. */
leaderSkill?: {
leader: string | null;
offer: string | null;
completed: boolean;
signature: string;
};
};
export function choamGholaEvent(pending: Pick<ChoamMarketGhola,
@@ -21,6 +29,58 @@ export function choamGholaEvent(pending: Pick<ChoamMarketGhola,
pending.player, pending.card, pending.discardSequence]);
}
export function choamGholaSkillSignature(pending: ChoamMarketGhola): string {
const skill = pending.leaderSkill;
return JSON.stringify(['choamGholaSkill', pending.event, skill?.leader,
skill?.offer, skill?.completed]);
}
export function choamGholaSkillOfferEvent(pending: ChoamMarketGhola, leader: string): string {
return JSON.stringify(['choamGholaSkillOffer', pending.event, leader]);
}
function skillRevivalError(
g: Game,
pending: ChoamMarketGhola,
decisions: readonly (Game['decision'] | undefined)[],
): string | null {
const record = pending.leaderSkill;
if (!g.leaderSkills && !record) return null;
if (!g.leaderSkills || !record ||
!(record.leader === null || (typeof record.leader === 'string' && record.leader)) ||
!(record.offer === null || (typeof record.offer === 'string' && record.offer)) ||
typeof record.completed !== 'boolean' || record.signature !== choamGholaSkillSignature(pending))
return 'The saved market Ghola lost its committed Leader Skill revival.';
const owner = g.players.find(player => player.id === pending.player)!;
const leader = owner.leaders.find(candidate => candidate.id === record.leader);
if (record.leader !== null && (!leader || leader.dead || leader.capturedBy || leader.gholaBy))
return 'The market Ghola no longer has its own revived leader.';
const offer = g.leaderSkills.offers[pending.player];
if (record.offer === null) {
if (!record.completed || offer || (record.leader !== null && record.leader !== CHOAM_AUDITOR_ID &&
!g.leaderSkills.assignments.some(assignment => assignment.owner === pending.player)))
return 'This market Ghola cannot omit its optional Leader Skill offer.';
return null;
}
if (record.leader === null)
return 'A market Ghola skill offer must name its revived leader.';
if (record.offer !== choamGholaSkillOfferEvent(pending, record.leader))
return 'The market Ghola skill event no longer matches its committed revival.';
if (record.completed) {
if (offer) return 'The completed market Ghola still has an unresolved skill offer.';
} else {
if (!offer || offer.event !== record.offer || offer.leader !== record.leader)
return 'The market Ghola lost its original leader or optional skill offer.';
// An ordinary-card/Truthtrance disposal may temporarily hold this owned
// decision in its continuation. Its exact event remains required there.
if (pending.stage === 'complete' &&
!decisions.some(decision => decision?.kind === 'leaderSkillRevival' &&
decision.player === pending.player && decision.event === record.offer))
return 'The completed Ghola is waiting for its owned Leader Skill decision.';
}
return null;
}
/** Public timing shared by the server and the hand's ordinary-card controls. */
export function choamSaleGholaTiming(state: {
status: string;
@@ -38,6 +98,10 @@ export function choamSaleGholaTiming(state: {
* move the sale card or change Tupile before the original declaration resumes. */
export function choamMarketGholaError(g: Game): string | null {
const pending = g.pendingChoamMarketGhola;
const marketOffers = Object.entries(g.leaderSkills?.offers ?? {})
.filter(([, offer]) => typeof offer?.event === 'string' && offer.event.startsWith('["choamGholaSkillOffer",'));
if (marketOffers.length && (!pending || marketOffers.length !== 1 || marketOffers[0][0] !== pending.player))
return 'The Ghola Leader Skill offer lost its suspended market.';
if (!pending) return null;
if (
g.status !== 'playing' || pending.turn !== g.turn || pending.phase !== g.phase ||
@@ -87,6 +151,9 @@ export function choamMarketGholaError(g: Game): string | null {
if (incomes.some((income) => income!.recipient !== pending.player || income!.amount !== 1 ||
!g.players.some((p) => p.id === income!.owner && p.faction === 'tleilaxu')))
return 'The saved Ghola income no longer matches the revived faction.';
const skillError = skillRevivalError(g, pending,
contexts.map(context => context && 'decision' in context ? context.decision : undefined));
if (skillError) return skillError;
try {
quoteChoamSaleCancellation(g, pending.response);
} catch (error) {
+23 -1
View File
@@ -449,7 +449,7 @@ import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type Bidding
import { quoteHomeworldPaymentIncome, quoteGuildPaymentRounding } from './homeworld-payment-income';
import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income';
import { resolveBattleWeapons } from './effective-weapons';
import { choamGholaEvent, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
import {
charityAmount,
charityQuote,
@@ -14384,6 +14384,7 @@ function resumeMarketGhola(g: Game) {
if (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') return;
const pending = g.pendingChoamMarketGhola;
if (!pending || pending.stage !== 'complete' || g.pendingTreacheryDiscard || g.pendingNullentropy ||
g.leaderSkills?.offers[pending.player] ||
g.response || g.decision || g.pendingKarama || g.truthtrance ||
g.phaseOpening || g.pendingRicheseGift || g.pendingExchange) return;
nexusChoamTradeIntegrity(g);
@@ -14412,6 +14413,17 @@ function playMarketGhola(g: Game, p: Player, action: Action) {
g.pendingChoamMarketGhola.event = choamGholaEvent(g.pendingChoamMarketGhola);
g.response = null;
applyGholaEffect(g, p, action, card.id);
if (g.leaderSkills) {
const pending = g.pendingChoamMarketGhola;
const offer = g.leaderSkills.offers[p.id];
pending.leaderSkill = {
leader: p.leaders.find(leader => leader.id === action.leader)?.id ?? null,
offer: offer?.event ?? null,
completed: !offer,
signature: '',
};
pending.leaderSkill.signature = choamGholaSkillSignature(pending);
}
if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event;
const used = discard(g, p, card.id);
log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`,
@@ -14445,6 +14457,10 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
// GF9 November 2020 FAQ p.9 permits a Ghola return in another battle
// this turn. Death history remains; the earlier battle location does not.
delete l.usedAt;
if (g.pendingChoamMarketGhola && g.leaderSkills && !isAuditorLeader(l) &&
!l.gholaBy && p.leaders.some(leader => leader.id === l.id))
g.leaderSkills = skillRule(() => offerRevivedLeaderSkill(
g.leaderSkills!, p.id, l.id, choamGholaSkillOfferEvent(g.pendingChoamMarketGhola!, l.id)));
log(
g,
`${p.name} revived ${l.name} with Ghola. The leader may fight again this turn; no spice or normal leader-revival allowance was spent.`,
@@ -21275,6 +21291,12 @@ function applyActionInner(
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, stringField(action.leader), skillEligibleLeaders(g,id), random));
log(g, `${p.name} assigned ${leaderSkillCard(action.skill as Parameters<typeof leaderSkillCard>[0]).name} to a newly revived leader.`, {faction:p.faction,name:'Leader Skill'});
}
const marketGhola = g.pendingChoamMarketGhola;
if (marketGhola?.player === id && marketGhola.leaderSkill?.offer === decision.event &&
!g.leaderSkills.offers[id]) {
marketGhola.leaderSkill.completed = true;
marketGhola.leaderSkill.signature = choamGholaSkillSignature(marketGhola);
}
g.decision = null;
if (g.battle && !g.battle.revealed && !g.battle.plans[id] && [g.battle.attacker,g.battle.defender].includes(id) && g.leaderSkills.assignments.some((a) => a.owner === id) && g.battle.leaderSkillHidden?.[id] === undefined)
g.decision = {kind:'leaderSkillVisibility',player:id,event:g.battle.event!,resumePowers:false};
+10 -1
View File
@@ -46,9 +46,18 @@ export function basicIxLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
/** Basic CHOAM's ordinary leaders and economy; Auditor belongs to Advanced. */
export function basicChoamLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return game.advanced === false && game.expansions.length === 1 &&
game.expansions[0] === 'choam' && noOtherLeaderSkillModules(game) &&
!!game.players?.some(player => player.faction === 'choam') &&
game.players.every(player => player.faction === 'choam' ||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game) ||
basicIxLeaderSkillsProfile(game);
basicIxLeaderSkillsProfile(game) || basicChoamLeaderSkillsProfile(game);
}
/** Shared by rule quotes, controls and minimal legal bot participation. */
+25 -2
View File
@@ -4000,6 +4000,29 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
{
id: 'choam-leader-skills',
title: 'CHOAM with Leader Skills',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Basic CHOAM connects ordinary skill actions and private revival choices during a suspended sale.',
steps: [
'This development combination uses Basic CHOAM and base opponents, the full fourteen-skill deck and no other optional modules. Starting cards precede skills and Traitors. Its 35-card Treachery deck includes Poison Tooth and Artillery; neither is a green Special. Auditor belongs to Advanced and cannot receive a skill.',
'Connected movement, shipping, payment and battle skills share their normal controls and legal choices. CHOAM cannot spend a card already committed to a sale or sell a Special already discarded after Planetologist battle use.',
'Ghola may revive an eligible own leader while a declared sale waits. Finish the spent Ghola, optional private replacement draw or decline, and any required assignment before the original sale resumes. The replacement belongs to the actual revived leader.',
'The sale resumes with its original card, price and response passes. Canceling that sale preserves the completed free revival and does not return the spent Ghola.',
'Advanced Auditor, Richese and further combinations retain separate implementation boundaries. Remaining skill effects are unfinished; this prototype does not certify complete expansion rules or AI strength.',
],
related: ['leader-skills', 'choam-modules', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Existing genuine setup now connects ordinary skill actions, native card sales, mandatory disposal and exact interrupted-sale revival continuation.', evidence: ['game/leader-skill-profile.ts', 'game/choam-market-ghola.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Existing private draw, decline and assignment controls complete the revival before returning to the sale response.', evidence: ['components/leader-skills.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles follow current private offers and legal action lists. Full strategy and calibration wait for feature completion.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Exact configuration, inventory, source composition and remaining boundaries are recorded.', evidence: ['docs/CHOAM_LEADER_SKILLS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Full-deck setup, native powers, skill controls, card custody, saved-sale corruption, private concurrent recovery and bounded complete games have focused checks. Final acceptance remains open.', evidence: ['tests/choam-skills-integration.test.ts', 'tests/choam-skills-ghola.test.ts', 'tests/choam-skills-recovery.test.ts', 'tools/faction-games.ts'] },
],
},
{
id: 'ixian-leader-skills',
title: 'Ixians with Leader Skills',
@@ -4012,7 +4035,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Planetologist calculates each gathered origin separately using its selected cyborgs. Canceling native cyborg movement leaves the independent skill range available. Sandmaster routes can enter or leave the HMS through its current pointer.',
'Suk Graduate rescues finish before substitution. Select the original sectors of rescued cyborgs where needed; only cyborgs actually sent to the Tanks can return through substitution. Rihani also finishes before native substitution.',
'Planetologist may substitute the green Thumper, Harvester or Amal in its weapon role. Their normal effects do not execute, and mandatory disposal still follows allowed, declined or canceled substitution.',
'Missing skill bands, extra Sandmaster HMS relocation and other combined configurations remain unfinished. This prototype does not certify complete rules or AI strength.',
'Missing skill bands, Sandmaster eligibility during native HMS relocation and other combined configurations remain unresolved or unfinished. This prototype does not certify complete rules or AI strength.',
],
related: ['leader-skills', 'tleilaxu-leader-skills', 'implementation-checklist'],
checklist: [
@@ -4096,7 +4119,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
related: ['choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
checklist: [
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },