Prototype Leader Skills setup, battle disciplines and capture
Connect all fourteen physical skills and private setup to five card-role battle modifiers, public posture, Harkonnen skilled capture, death return and optional own-leader revival through controls, legal AI and persisted room actions. Nine skill effects and combined-mode/publication acceptance remain unfinished. Captured replacement entitlement and foreign ghola integration stay guarded. Fresh publisher/designer research supports skilled-capture publicity through its unique known physical card; this is recorded as a component inference. Independent review found and fixed Voice/must-use-leader and invalid saved assignment/offer cases. A pre-existing Discovery privacy assertion confused card ID prefixes; the corrected deterministic physical-pair scenario was independently reviewed. The first broad run was 4410/4411; corrected final verification passes types/lint, all 4411 offline tests, build and 43 HTTP tests. Final durations: check 149741ms, build 6659ms, integration 28274ms. Source fingerprint: aef655ac1ebc0eb2f64294e13d3d27337167312cf8ab17568a166ee9c39f6a85. Private final report: 20260913-leader-skills/checks-final/report.json. Three seeded prototype games completed with no rejected bot candidates. Browser setup/card inspection/refresh passed. Another user-reported power outage stopped the server; backup and necessary restart restored the same private seat. All 292 games at restart and their original application records remain unchanged; 33 isolated HTTP-test rooms bring the current total to 325. No database reset. Efficiency: one shared setup/lifecycle implementation supports five connected role effects; selective existing agents handled catalog/UI/tests/research and independent reviews. One repeated broad check was required by the reproduced ID-prefix assertion failure. Aggregate goal counter rose from 65529766 to 67313673 through this checkpoint (1783907), including inherited context and coordination; this is not a billing or savings estimate. Full goal remains active.
This commit is contained in:
1 parent
369371671f
commit
6e6fecc9d4
28 files changed
+4196
-38
No files matched your search
@@ -33,6 +33,11 @@ import {
|
||||
type HomeworldBattleRules,
|
||||
type HomeworldExplosionLosses,
|
||||
} from './homeworld-battle-rules';
|
||||
import {
|
||||
leaderSkillBattleBonus,
|
||||
type BattleLeaderSkill,
|
||||
type LeaderSkillBattleBonus,
|
||||
} from './leader-skill-combat';
|
||||
|
||||
export class BattleResolutionQuoteError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -68,6 +73,8 @@ export type ResolutionCombatant = ResolutionParticipant & {
|
||||
lateDefense?: string;
|
||||
poisonTooth?: boolean;
|
||||
stoneMode?: 'kill' | 'ignore';
|
||||
/** Current authorized assignments only; captured assignments grant no normal band. */
|
||||
leaderSkills?: readonly BattleLeaderSkill[];
|
||||
/** The current aidFor result: this escrow has already left the donor's balance. */
|
||||
aid?: { donor: string; amount: number };
|
||||
};
|
||||
@@ -110,6 +117,10 @@ export type BattleResolutionQuote = {
|
||||
defenderTraitor: boolean;
|
||||
revelations: { player: string; identity: string }[];
|
||||
leaderStrengths: { attacker: number; defender: number };
|
||||
leaderSkillBonuses: {
|
||||
attacker: LeaderSkillBattleBonus;
|
||||
defender: LeaderSkillBattleBonus;
|
||||
};
|
||||
leaderDeaths: { attacker: boolean; defender: boolean };
|
||||
scores: { attacker: number; defender: number } | null;
|
||||
stone: StoneBurnerComparison | null;
|
||||
@@ -428,6 +439,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
let scores: BattleResolutionQuote['scores'] = null;
|
||||
let deaths = { attacker: false, defender: false };
|
||||
let bounty: BattleResolutionQuote['bounty'] = null;
|
||||
let leaderSkillBonuses: BattleResolutionQuote['leaderSkillBonuses'] = {
|
||||
attacker: { bonus: 0, applied: [] },
|
||||
defender: { bonus: 0, applied: [] },
|
||||
};
|
||||
if (result === 'mutualTraitors' || result === 'explosion')
|
||||
deaths = { attacker: true, defender: true };
|
||||
else if (result === 'traitor') {
|
||||
@@ -453,19 +468,45 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
attacker: effects.attackerDead || stoneKills,
|
||||
defender: effects.defenderDead || stoneKills,
|
||||
};
|
||||
const selectedLeader = (side: ResolutionCombatant) =>
|
||||
side.leader
|
||||
? { id: side.leader.id, kind: 'disc' as const }
|
||||
: card(side, side.plan.leader)?.kind === 'hero'
|
||||
? { id: side.plan.leader!, kind: 'hero' as const }
|
||||
: undefined;
|
||||
leaderSkillBonuses = {
|
||||
attacker: leaderSkillBattleBonus({
|
||||
assignments: a.leaderSkills ?? [],
|
||||
selectedLeader: selectedLeader(a),
|
||||
weapon: aw,
|
||||
defense: ad,
|
||||
skilledLeaderSurvives: !deaths.attacker,
|
||||
}),
|
||||
defender: leaderSkillBattleBonus({
|
||||
assignments: d.leaderSkills ?? [],
|
||||
selectedLeader: selectedLeader(d),
|
||||
weapon: dw,
|
||||
defense: dd,
|
||||
skilledLeaderSurvives: !deaths.defender,
|
||||
}),
|
||||
};
|
||||
scores = {
|
||||
attacker:
|
||||
a.plan.dial +
|
||||
(a.id === homeworld?.native ? homeworld.strength : 0) +
|
||||
(deaths.attacker || effects.stunned
|
||||
? 0
|
||||
: strengths.attacker + (a.plan.kwisatz ? 2 : 0)),
|
||||
: strengths.attacker +
|
||||
leaderSkillBonuses.attacker.bonus +
|
||||
(a.plan.kwisatz ? 2 : 0)),
|
||||
defender:
|
||||
d.plan.dial +
|
||||
(d.id === homeworld?.native ? homeworld.strength : 0) +
|
||||
(deaths.defender || effects.stunned
|
||||
? 0
|
||||
: strengths.defender + (d.plan.kwisatz ? 2 : 0)),
|
||||
: strengths.defender +
|
||||
leaderSkillBonuses.defender.bonus +
|
||||
(d.plan.kwisatz ? 2 : 0)),
|
||||
};
|
||||
winner = stone
|
||||
? stone.winner === 'attacker'
|
||||
@@ -636,6 +677,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
defenderTraitor: dc,
|
||||
revelations,
|
||||
leaderStrengths: strengths,
|
||||
leaderSkillBonuses,
|
||||
leaderDeaths: deaths,
|
||||
scores,
|
||||
stone,
|
||||
|
||||
@@ -1375,6 +1375,7 @@ function policyActions(g: GameView): Action[] {
|
||||
if (g.decision) {
|
||||
if (g.decision.player !== me.id) return [];
|
||||
const d = g.decision;
|
||||
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival') return [];
|
||||
if (d.kind === 'choamAudit')
|
||||
return [{ type: 'decision', event: d.event, audit: true }];
|
||||
if (d.kind === 'choamAuditPayment') {
|
||||
@@ -3387,6 +3388,19 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
|
||||
/** Obligations apply across policy branches, including choosing to move first. */
|
||||
export function botActions(g: GameView): Action[] {
|
||||
const skill = g.leaderSkills;
|
||||
if (skill && !g.truthtrance && !g.response && !g.phaseOpening) {
|
||||
if (skill.battleChoice)
|
||||
return [{type:'leaderSkillVisibility',event:skill.battleChoice.event,hide:true}];
|
||||
if (skill.offer && ((g.status === 'setup' && g.setupStage === 'leaderSkills') || (g.decision?.kind === 'leaderSkillRevival' && g.decision.player === g.me))) {
|
||||
const offer = skill.offer;
|
||||
if (!offer.cards.length) return [{type:'leaderSkill',event:offer.event,mode:'draw'}];
|
||||
const direct = ['warmaster','master-of-assassins','swordmaster-of-ginaz','killer-medic','prana-bindu-adept'];
|
||||
const chosen = offer.cards.find((card) => direct.includes(card)) ?? offer.cards[0];
|
||||
const leader = offer.leader ?? [...skill.eligibleLeaders].sort((a,b) => (g.allLeaders.find((l) => l.id === b.id)?.strength ?? 0) - (g.allLeaders.find((l) => l.id === a.id)?.strength ?? 0))[0]?.id;
|
||||
if (leader) return [{type:'leaderSkill',event:offer.event,skill:chosen,leader}];
|
||||
}
|
||||
}
|
||||
if (g.nexusTraitors?.pending)
|
||||
return g.truthtrance
|
||||
? policyActions({ ...g, decision: null })
|
||||
|
||||
+311
-10
@@ -1,3 +1,5 @@
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
import { leaderSkillCard } from './leader-skill-cards';
|
||||
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';
|
||||
@@ -562,6 +564,8 @@ export type PlanField = Exclude<
|
||||
'support' | 'kwisatz' | 'allyPayment'
|
||||
>;
|
||||
export type Battle = {
|
||||
/** Public face-up/behind-shield choice precedes faction battle powers. */
|
||||
leaderSkillHidden?: Record<string, boolean>;
|
||||
nexusInspection?: NexusInspection;
|
||||
/** Independent presence marker: deleting a spent-card record is not a legacy save. */
|
||||
nexusInspectionUsed?: string;
|
||||
@@ -620,6 +624,8 @@ export type Auction = {
|
||||
allyPayment?: number;
|
||||
};
|
||||
export type Decision =
|
||||
| { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean }
|
||||
| { kind: 'leaderSkillRevival'; player: string; event: string }
|
||||
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
|
||||
| { kind: 'grummanCollection'; player: string; event: string }
|
||||
| { kind: 'caladanReinforcement'; player: string; event: string }
|
||||
@@ -1389,7 +1395,8 @@ export type Game = {
|
||||
host: string;
|
||||
status: 'lobby' | 'setup' | 'playing' | 'finished';
|
||||
/** Absent on legacy rooms whose starting cards were already dealt. */
|
||||
setupStage?: 'prediction' | 'traitors' | 'forces';
|
||||
setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces';
|
||||
leaderSkills?: LeaderSkillsState;
|
||||
advanced: boolean;
|
||||
expansions: string[];
|
||||
players: Player[];
|
||||
@@ -4841,6 +4848,7 @@ function start(g: Game) {
|
||||
requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.');
|
||||
requireRule(!g.nexusCards, 'Nexus card effects are still being implemented.');
|
||||
requireRule(!g.discoveryEnabled, 'Discoveries are still being implemented.');
|
||||
requireRule(!g.leaderSkills, 'Leader Skills are still being implemented.');
|
||||
requireRule(
|
||||
g.players.every((p) => faction(p.faction).expansion === 'base'),
|
||||
'Expansion factions are still being implemented.',
|
||||
@@ -4851,6 +4859,185 @@ function start(g: Game) {
|
||||
);
|
||||
initializeSetup(g);
|
||||
}
|
||||
function skillRule<T>(operation: () => T): T {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
if (error instanceof LeaderSkillError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function skillEligibleLeaders(g: Game, owner: string): string[] {
|
||||
return getPlayer(g, owner)
|
||||
.leaders.filter(
|
||||
(leader) =>
|
||||
!leader.dead &&
|
||||
!leader.capturedBy &&
|
||||
!leader.gholaBy &&
|
||||
!isAuditorLeader(leader) &&
|
||||
!g.leaderSkills?.assignments.some(
|
||||
(assignment) => assignment.leader === leader.id,
|
||||
),
|
||||
)
|
||||
.map((leader) => leader.id);
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
if (!g.leaderSkills) return;
|
||||
skillRule(() => validateLeaderSkills(g.leaderSkills!, g.players));
|
||||
requireRule(
|
||||
!g.players.some((p) => p.leaders.some((l) => l.gholaBy)),
|
||||
'Leader Skills with foreign gholas await their custody integration.',
|
||||
);
|
||||
if (g.battle?.leaderSkillHidden)
|
||||
requireRule(
|
||||
Object.entries(g.battle.leaderSkillHidden).every(
|
||||
([id, hidden]) =>
|
||||
[g.battle!.attacker, g.battle!.defender].includes(id) &&
|
||||
typeof hidden === 'boolean',
|
||||
),
|
||||
'Invalid saved skilled-leader visibility.',
|
||||
);
|
||||
if (g.decision?.kind === 'leaderSkillVisibility')
|
||||
requireRule(
|
||||
g.battle?.event === g.decision.event &&
|
||||
g.leaderSkills.assignments.some((a) => a.owner === g.decision!.player),
|
||||
'The saved skilled-leader choice lost its battle or assigned leader.',
|
||||
);
|
||||
if (g.decision?.kind === 'leaderSkillRevival')
|
||||
requireRule(
|
||||
g.leaderSkills.offers[g.decision.player]?.event === g.decision.event,
|
||||
'The saved revival choice lost its physical skill offer.',
|
||||
);
|
||||
}
|
||||
function leaderSkillController(
|
||||
g: Game,
|
||||
assignment: { owner: string; leader: string },
|
||||
): string {
|
||||
return (
|
||||
getPlayer(g, assignment.owner).leaders.find(
|
||||
(l) => l.id === assignment.leader,
|
||||
)!.capturedBy ?? assignment.owner
|
||||
);
|
||||
}
|
||||
function nativeLeaderSkill(g: Game, owner: string) {
|
||||
return g.leaderSkills?.assignments.find(
|
||||
(a) => a.owner === owner && leaderSkillController(g, a) === owner,
|
||||
);
|
||||
}
|
||||
function requireLeaderSkillRevival(g: Game, owner: string) {
|
||||
requireRule(
|
||||
!g.leaderSkills?.assignments.some(
|
||||
(a) => a.owner === owner && leaderSkillController(g, a) !== owner,
|
||||
),
|
||||
'Reviving another leader while your Leader Skill is captured awaits the replacement-skill ruling.',
|
||||
);
|
||||
}
|
||||
function finishLeaderSkillCustody(g: Game, before?: Game) {
|
||||
if (!g.leaderSkills) return;
|
||||
const result = skillRule(() =>
|
||||
returnDeadLeaderSkills(g.leaderSkills!, g.players, random),
|
||||
);
|
||||
g.leaderSkills = result.state;
|
||||
for (const assignment of result.returned) {
|
||||
if (g.battle?.leaderSkillHidden)
|
||||
delete g.battle.leaderSkillHidden[assignment.owner];
|
||||
log(
|
||||
g,
|
||||
`${leaderSkillCard(assignment.skill).name} returned to the shuffled skill deck because its assigned leader died.`,
|
||||
{
|
||||
faction: getPlayer(g, assignment.owner).faction,
|
||||
name: 'Leader Skill lost',
|
||||
},
|
||||
);
|
||||
}
|
||||
if (before?.leaderSkills)
|
||||
for (const player of g.players)
|
||||
for (const leader of player.leaders)
|
||||
if (
|
||||
!leader.dead &&
|
||||
!leader.capturedBy &&
|
||||
!leader.gholaBy &&
|
||||
!isAuditorLeader(leader) &&
|
||||
before.players
|
||||
.find((p) => p.id === player.id)
|
||||
?.leaders.find((l) => l.id === leader.id)?.dead
|
||||
) {
|
||||
requireLeaderSkillRevival(g, player.id);
|
||||
g.leaderSkills = skillRule(() =>
|
||||
offerRevivedLeaderSkill(
|
||||
g.leaderSkills!,
|
||||
player.id,
|
||||
leader.id,
|
||||
crypto.randomUUID(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
function projectedLeaderSkills(
|
||||
g: Game,
|
||||
owner: string,
|
||||
): LeaderSkillsView | null {
|
||||
if (!g.leaderSkills) return null;
|
||||
const offer = g.leaderSkills.offers[owner] ?? null;
|
||||
const eligible = skillEligibleLeaders(g, owner);
|
||||
const assignment = nativeLeaderSkill(g, owner);
|
||||
return {
|
||||
assignments: g.leaderSkills.assignments.map((a) => {
|
||||
const controller = leaderSkillController(g, a);
|
||||
return {
|
||||
...a,
|
||||
controller,
|
||||
captured: controller !== a.owner,
|
||||
faceUp:
|
||||
controller === a.owner && !g.battle?.leaderSkillHidden?.[a.owner],
|
||||
};
|
||||
}),
|
||||
offer,
|
||||
eligibleLeaders: getPlayer(g, owner)
|
||||
.leaders.filter((l) => eligible.includes(l.id))
|
||||
.map((l) => ({ id: l.id, name: l.name })),
|
||||
battleChoice:
|
||||
assignment &&
|
||||
g.decision?.kind === 'leaderSkillVisibility' &&
|
||||
g.decision.player === owner
|
||||
? {
|
||||
event: g.decision.event,
|
||||
leader: assignment.leader,
|
||||
skill: assignment.skill,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
/** Later Voice/card use can remove the last alternative to the skilled disc. */
|
||||
function finishMandatorySkillVisibility(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!g.leaderSkills || !b || b.revealed) return;
|
||||
for (const owner of [b.attacker, b.defender]) {
|
||||
if (b.plans[owner] || b.leaderSkillHidden?.[owner] !== false) continue;
|
||||
const assignment = nativeLeaderSkill(g, owner);
|
||||
if (!assignment) continue;
|
||||
const p = getPlayer(g, owner);
|
||||
const available = controlledLeaders(g, p).filter(
|
||||
(l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
|
||||
);
|
||||
const heroAvailable =
|
||||
p.hand.some((c) => c.kind === 'hero') &&
|
||||
!(b.voice?.target === owner && b.voice.kind === 'hero' && !b.voice.must);
|
||||
if (
|
||||
available.length === 1 &&
|
||||
available[0].id === assignment.leader &&
|
||||
!heroAvailable
|
||||
) {
|
||||
b.leaderSkillHidden![owner] = true;
|
||||
log(
|
||||
g,
|
||||
`${p.name} moved the skilled leader behind the shield because the remaining legal Battle Plan must use that leader.`,
|
||||
{ faction: p.faction, name: 'Leader Skill' },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeSetup(g: Game) {
|
||||
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
|
||||
if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null };
|
||||
@@ -4887,7 +5074,9 @@ function initializeSetup(g: Game) {
|
||||
g,
|
||||
byFaction(g, 'beneGesserit')
|
||||
? 'Player positions are fixed. Bene Gesserit must lock its prediction before private cards are dealt.'
|
||||
: 'Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards.',
|
||||
: g.leaderSkills
|
||||
? 'Player positions are fixed. Starting Treachery Cards and Leader Skills precede traitor selection in this variant.'
|
||||
: 'Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards.',
|
||||
);
|
||||
}
|
||||
function initializeStartingForces(g: Game) {
|
||||
@@ -4955,7 +5144,7 @@ function dealStartingTreachery(g: Game) {
|
||||
}
|
||||
log(
|
||||
g,
|
||||
`${p.name} received ${quantity} starting Treachery Card${quantity === 1 ? '' : 's'} after force placement.`,
|
||||
`${p.name} received ${quantity} starting Treachery Card${quantity === 1 ? '' : 's'} ${g.leaderSkills ? 'before Leader Skill assignment' : 'after force placement'}.`,
|
||||
{ faction: p.faction, name: 'Starting cards' },
|
||||
);
|
||||
}
|
||||
@@ -4963,7 +5152,9 @@ function dealStartingTreachery(g: Game) {
|
||||
if (ixians)
|
||||
log(
|
||||
g,
|
||||
'Starting force placement is complete. Ixians must choose their starting card before the remaining cards are dealt.',
|
||||
g.leaderSkills
|
||||
? 'Ixians must choose their starting card before the remaining cards and Leader Skills are dealt.'
|
||||
: 'Starting force placement is complete. Ixians must choose their starting card before the remaining cards are dealt.',
|
||||
);
|
||||
}
|
||||
function setupPending(g: Game): string[] {
|
||||
@@ -4972,6 +5163,8 @@ function setupPending(g: Game): string[] {
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
return bg && !bg.prediction ? [bg.id] : [];
|
||||
}
|
||||
if (g.setupStage === 'skillTreachery') return g.ixSetupCards ? [byFaction(g, 'ixians')!.id] : [];
|
||||
if (g.setupStage === 'leaderSkills') return Object.keys(g.leaderSkills!.offers);
|
||||
if (g.setupStage === 'traitors')
|
||||
return g.players
|
||||
.filter((p) => p.traitorChoices.length > 0)
|
||||
@@ -4985,6 +5178,20 @@ function advanceSetup(g: Game) {
|
||||
if (!g.setupStage || g.status !== 'setup') return;
|
||||
if (g.setupStage === 'prediction') {
|
||||
if (setupPending(g).length) return;
|
||||
if (g.leaderSkills) {
|
||||
g.setupStage = 'skillTreachery';
|
||||
dealStartingTreachery(g);
|
||||
if (g.decision) return;
|
||||
}
|
||||
}
|
||||
if (g.setupStage === 'skillTreachery') {
|
||||
if (g.ixSetupCards || g.decision) return;
|
||||
g.leaderSkills = skillRule(() => dealLeaderSkills(g.leaderSkills!, g.players.map((p) => p.id), crypto.randomUUID()));
|
||||
g.setupStage = 'leaderSkills';
|
||||
log(g, 'Each faction received two private Leader Skill cards. Keep one and assign an eligible leader before traitors are dealt.');
|
||||
}
|
||||
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')),
|
||||
);
|
||||
@@ -4999,7 +5206,9 @@ function advanceSetup(g: Game) {
|
||||
g.setupStage = 'traitors';
|
||||
log(
|
||||
g,
|
||||
'Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt.',
|
||||
g.leaderSkills
|
||||
? 'Leader Skills are assigned publicly. Traitor Cards were dealt privately; choose before starting force placement.'
|
||||
: 'Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt.',
|
||||
);
|
||||
}
|
||||
if (g.setupStage === 'traitors') {
|
||||
@@ -5008,12 +5217,14 @@ function advanceSetup(g: Game) {
|
||||
g.setupStage = 'forces';
|
||||
log(
|
||||
g,
|
||||
'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards.',
|
||||
g.leaderSkills
|
||||
? 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement to begin play.'
|
||||
: 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards.',
|
||||
);
|
||||
}
|
||||
if (g.setupStage === 'forces' && !setupPending(g).length) {
|
||||
delete g.setupStage;
|
||||
dealStartingTreachery(g);
|
||||
if (!g.leaderSkills) dealStartingTreachery(g);
|
||||
}
|
||||
}
|
||||
/** Offline-only test seam. No player action or room API dispatches this function. */
|
||||
@@ -5047,7 +5258,14 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
'Enable Discoveries in a fresh audit lobby first.');
|
||||
return initializeSetupGameForAudit(state, false, false, false, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false): Game {
|
||||
/** Gated development setup; never dispatched by a player action or room route. */
|
||||
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
||||
const g = structuredClone(state);
|
||||
g.leaderSkills = createLeaderSkills(random);
|
||||
return initializeSetupGameForAudit(g, false, false, false, false, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false): Game {
|
||||
nexusCardsIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -5070,6 +5288,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
);
|
||||
requireRule(
|
||||
(homeworlds || nexus || ix || g.expansions.length === 0) &&
|
||||
(leaderSkills || !g.leaderSkills) &&
|
||||
(discovery || !g.discoveryEnabled) &&
|
||||
(nexus || !g.nexusCards) &&
|
||||
!g.techTokens &&
|
||||
@@ -6454,6 +6673,25 @@ function strongholdCopyChoices(g: Game, player: string): StrongholdId[] {
|
||||
}
|
||||
function beginBattlePowers(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (g.leaderSkills) {
|
||||
b.leaderSkillHidden ??= {};
|
||||
const pending = [b.attacker, b.defender].find((id) =>
|
||||
nativeLeaderSkill(g,id) && b.leaderSkillHidden![id] === undefined,
|
||||
);
|
||||
if (pending) {
|
||||
const p = getPlayer(g, pending);
|
||||
const trained = nativeLeaderSkill(g,pending)!;
|
||||
const available = controlledLeaders(g, p).filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory));
|
||||
if (available.length === 1 && available[0].id === trained.leader && !p.hand.some((c) => c.kind === 'hero')) {
|
||||
b.leaderSkillHidden[pending] = true;
|
||||
log(g, `${p.name} moved the skilled leader behind the shield because it is their only available battle leader.`, {faction:p.faction,name:'Leader Skill'});
|
||||
beginBattlePowers(g);
|
||||
return;
|
||||
}
|
||||
g.decision = {kind:'leaderSkillVisibility',player:pending,event:b.event!};
|
||||
return;
|
||||
}
|
||||
}
|
||||
const choam = byFaction(g, 'choam');
|
||||
if (
|
||||
g.advanced &&
|
||||
@@ -12413,6 +12651,7 @@ function validatePlan(
|
||||
(l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
|
||||
);
|
||||
const l = available.find((l) => l.id === plan.leader);
|
||||
requireRule(!l || !g.leaderSkills?.assignments.some((assignment) => assignment.owner === p.id && assignment.leader === l.id) || !!b.leaderSkillHidden?.[p.id], 'Move your skilled leader behind the shield before using it in your battle plan.');
|
||||
const hero = cardOf(p, plan.leader)?.kind === 'hero';
|
||||
requireRule(
|
||||
!plan.leader || l || hero,
|
||||
@@ -12755,6 +12994,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
||||
`${p.name} revived Kwisatz Haderach with Ghola for use again this turn. No spice or normal leader-revival allowance was spent.`,
|
||||
);
|
||||
} else if (action.leader) {
|
||||
requireLeaderSkillRevival(g,p.id);
|
||||
requireRule(
|
||||
action.leader !== DUKE_VIDAL_ID || p.faction === 'ecaz',
|
||||
'Only Ecaz may revive Duke Vidal, including with Ghola.',
|
||||
@@ -13680,6 +13920,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
hand: p.hand,
|
||||
plan,
|
||||
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
|
||||
leaderSkills: g.leaderSkills?.assignments.filter((assignment) => leaderSkillController(g,assignment) === p.id).map((assignment) => ({skill:assignment.skill,leader:assignment.leader,faceUp:assignment.owner === p.id && !b.leaderSkillHidden?.[p.id],captured:assignment.owner !== p.id})),
|
||||
forces: combatForces(g, p, b.territory, opponent),
|
||||
stronghold: strongholdEffect(g, p.id),
|
||||
lateDefense: b.lateDefense?.[p.id],
|
||||
@@ -13871,6 +14112,10 @@ function resolveBattle(g: Game) {
|
||||
if (dk) dead(dl);
|
||||
const av = quote.scores!.attacker,
|
||||
dv = quote.scores!.defender;
|
||||
if (!stoneResult && !quote.effects?.stunned)
|
||||
for (const [player, receipt, died] of [[a, quote.leaderSkillBonuses.attacker, ak], [d, quote.leaderSkillBonuses.defender, dk]] as const)
|
||||
for (const bonus of died ? [] : receipt.applied)
|
||||
log(g, `${player.name} gained ${bonus.amount} battle strength from ${leaderSkillCard(bonus.skill).name}${bonus.mode === 'normal' ? ' while their skilled leader remained face up' : ' by using their surviving skilled leader'}.`, {faction:player.faction,name:'Leader Skill'});
|
||||
const loser = winner === a ? d : a;
|
||||
if (quote.bounty) winner!.spice += quote.bounty.amount;
|
||||
killTerritory(g, loser, b.territory, Infinity, true);
|
||||
@@ -14964,6 +15209,7 @@ function finishRevival(
|
||||
if (quote.nextResponse) g.response = quote.nextResponse;
|
||||
}
|
||||
function beginRevival(g: Game, revival: PendingRevival) {
|
||||
if (revival.kind === 'leader') requireLeaderSkillRevival(g,revival.player);
|
||||
requireRule(
|
||||
!revivalPrevented(g, revival.player),
|
||||
'Tleilaxu prevented this faction’s normal revivals for this turn.',
|
||||
@@ -15908,6 +16154,10 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
usedAt: l.usedAt,
|
||||
};
|
||||
l.capturedBy = capture.player;
|
||||
const capturedSkill = g.leaderSkills?.assignments.find((a) => a.leader === l.id);
|
||||
// This unique card and its leader were publicly assigned. The expansion
|
||||
// requires the card to accompany the captive, identifying that capture.
|
||||
if (capturedSkill) delete l.concealed;
|
||||
g.decision = {
|
||||
kind: 'capturedLeader',
|
||||
player: capture.player,
|
||||
@@ -15917,7 +16167,9 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
};
|
||||
log(
|
||||
g,
|
||||
`${getPlayer(g, capture.player).name} captured a leader from ${getPlayer(g, capture.loser).name}.`,
|
||||
capturedSkill
|
||||
? `${getPlayer(g, capture.player).name} captured ${l.name} with ${leaderSkillCard(capturedSkill.skill).name}. Its known skill travels with the leader; only its battle effect is available to the captor.`
|
||||
: `${getPlayer(g, capture.player).name} captured a leader from ${getPlayer(g, capture.loser).name}.`,
|
||||
);
|
||||
}
|
||||
} else if (response.kind === 'choamAudit') {
|
||||
@@ -17759,6 +18011,7 @@ export function prepareSpecialKaramaIntent(
|
||||
'The Emperor special Karama is used during Revival.',
|
||||
);
|
||||
if (action.leader) {
|
||||
requireLeaderSkillRevival(g,p.id);
|
||||
const l = p.leaders.find((l) => l.id === action.leader);
|
||||
requireRule(
|
||||
l?.dead && !l.capturedBy && !l.gholaBy,
|
||||
@@ -18178,6 +18431,7 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
leaderSkillsIntegrity(state);
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
@@ -18238,6 +18492,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
finishLeaderSkillCustody(g, state);
|
||||
observeOccupation(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -18327,6 +18582,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
}
|
||||
}
|
||||
finishActionContinuations(g);
|
||||
finishLeaderSkillCustody(g, state);
|
||||
leaderSkillsIntegrity(g);
|
||||
reconcileBattlePromises(g, { actor: id, action });
|
||||
reconcileShipmentPromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
@@ -18343,6 +18600,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
return g;
|
||||
}
|
||||
function finishActionContinuations(g: Game) {
|
||||
finishLeaderSkillCustody(g);
|
||||
finishMandatorySkillVisibility(g);
|
||||
if (g.pendingNullentropy) return;
|
||||
// Losing cleanup may finish casualties and produce the winner's mandatory
|
||||
// discard. Retire that next physical batch before exposing optional choices.
|
||||
@@ -18355,6 +18614,10 @@ function finishActionContinuations(g: Game) {
|
||||
resumeMarketGhola(g);
|
||||
resumeGrummanCollection(g);
|
||||
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
|
||||
if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) {
|
||||
const owner = g.order.find((id) => g.leaderSkills!.offers[id]);
|
||||
if (owner) g.decision = {kind:'leaderSkillRevival',player:owner,event:g.leaderSkills.offers[owner].event};
|
||||
}
|
||||
if (
|
||||
!g.truthtrance &&
|
||||
g.pendingTerrorEntry?.stage === 'discard' &&
|
||||
@@ -18428,6 +18691,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
leaderSkillsIntegrity(state);
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
@@ -18818,6 +19082,32 @@ function applyActionInner(
|
||||
g.decision.player === id
|
||||
)
|
||||
g.decision = null;
|
||||
if (t === 'leaderSkillVisibility' || (t === 'leaderSkill' && g.status === 'playing')) {
|
||||
const decision = g.decision;
|
||||
requireRule(decision && (decision.kind === 'leaderSkillVisibility' || decision.kind === 'leaderSkillRevival') && decision.player === id && decision.event === action.event, 'This Leader Skill decision is not waiting for you.');
|
||||
if (t === 'leaderSkillVisibility') {
|
||||
requireRule(decision.kind === 'leaderSkillVisibility' && g.battle?.event === decision.event && typeof action.hide === 'boolean', 'Choose whether your skilled leader remains face up or moves behind the shield.');
|
||||
requireRule(Object.keys(action).every((key) => ['type','event','hide'].includes(key)), 'Choose only the current skill visibility decision.');
|
||||
(g.battle.leaderSkillHidden ??= {})[id] = action.hide;
|
||||
g.decision = null;
|
||||
log(g, `${p.name} ${action.hide ? 'moved their skilled leader and card behind the shield; it may still be a bluff' : 'kept their skilled leader and card face up; that leader cannot be in their Battle Plan'}.`, {faction:p.faction,name:'Leader Skill'});
|
||||
if (decision.resumePowers !== false) beginBattlePowers(g);
|
||||
} else {
|
||||
requireRule(decision.kind === 'leaderSkillRevival' && g.leaderSkills, 'No revived leader is awaiting a skill choice.');
|
||||
requireRule(Object.keys(action).every((key) => ['type','event','mode','skill','leader'].includes(key)), 'Choose only the current revival skill action.');
|
||||
if (action.mode === 'draw') g.leaderSkills = skillRule(() => drawRevivedLeaderSkills(g.leaderSkills!, id, stringField(action.event)));
|
||||
else if (action.mode === 'decline') g.leaderSkills = skillRule(() => declineRevivedLeaderSkill(g.leaderSkills!, id, stringField(action.event)));
|
||||
else {
|
||||
requireRule(action.mode === undefined, 'Choose draw, decline or a dealt skill.');
|
||||
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), stringField(action.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'});
|
||||
}
|
||||
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};
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (g.decision) {
|
||||
const decision = g.decision;
|
||||
requireRule(
|
||||
@@ -18825,6 +19115,7 @@ function applyActionInner(
|
||||
'Waiting for the player with the pending decision.',
|
||||
);
|
||||
g.decision = null;
|
||||
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
|
||||
if (decision.kind === 'homeworldRevivalDeployment') {
|
||||
decideHomeworldRevivalReturn(g, p, action);
|
||||
return g;
|
||||
@@ -20077,13 +20368,20 @@ function applyActionInner(
|
||||
? ['predict']
|
||||
: g.setupStage === 'traitors'
|
||||
? ['traitor']
|
||||
: g.setupStage === 'leaderSkills'
|
||||
? ['leaderSkill']
|
||||
: ['fremenSetup', 'advisorSetup'];
|
||||
requireRule(
|
||||
expected.includes(t) && setupPending(g).includes(id),
|
||||
`Wait for the current setup step: ${g.setupStage}.`,
|
||||
);
|
||||
}
|
||||
if (t === 'advisorSetup') {
|
||||
if (t === 'leaderSkill') {
|
||||
requireRule(Object.keys(action).every((key) => ['type','event','skill','leader'].includes(key)), 'Choose only your dealt skill and its eligible leader.');
|
||||
const skill = stringField(action.skill), leader = stringField(action.leader);
|
||||
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, leader, skillEligibleLeaders(g, id), random));
|
||||
log(g, `${p.name} assigned ${leaderSkillCard(skill as Parameters<typeof leaderSkillCard>[0]).name} to ${p.leaders.find((l) => l.id === leader)!.name}.`, { faction: p.faction, name: 'Leader Skill' });
|
||||
} else if (t === 'advisorSetup') {
|
||||
requireRule(
|
||||
g.advanced && p.faction === 'beneGesserit' && !p.advisorSetup,
|
||||
'Advisor setup is not available.',
|
||||
@@ -21800,6 +22098,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
leaderSkillsIntegrity(state);
|
||||
discoveryFlightIntegrity(state);
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
@@ -21895,6 +22194,7 @@ export function viewGame(state: Game, id: string) {
|
||||
status: g.status,
|
||||
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
|
||||
setupPending: setupPending(g),
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
advanced: g.advanced,
|
||||
dukeVidal: g.dukeVidal
|
||||
? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) }
|
||||
@@ -22333,6 +22633,7 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
decision:
|
||||
g.decision?.kind === 'capturedLeader' &&
|
||||
!g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract<Decision,{kind:'capturedLeader'}>).leader) &&
|
||||
![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
|
||||
id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/** Canonical summaries of the fourteen physical CHOAM & Richese Leader Skill
|
||||
* cards. This reference catalog does not enable the optional module or execute
|
||||
* its effects. Source and lifecycle contract: docs/LEADER_SKILLS_RULES.md. */
|
||||
export const LEADER_SKILL_CARDS = [
|
||||
{
|
||||
id: 'bureaucrat',
|
||||
name: 'Bureaucrat',
|
||||
normal: [
|
||||
'Once per phase, when another player pays at least 5 spice to a player other than you, you may redirect 2 of that payment to the Spice Bank.',
|
||||
],
|
||||
battle: [
|
||||
'Your opponent subtracts 1 from their battle total for each stronghold they occupy; Bene Gesserit advisors do not count as occupation for this penalty.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'spice-banker',
|
||||
name: 'Spice Banker',
|
||||
normal: [
|
||||
'Once per phase, when another player pays at least 4 spice to the Spice Bank, place 1 spice in front of your shield and collect it during the Mentat Pause.',
|
||||
],
|
||||
battle: [
|
||||
'Commit 1–3 spice with your Battle Plan and add the same amount to this leader’s strength.',
|
||||
'This payment is separate from spice paid to make forces fight at full strength.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'diplomat',
|
||||
name: 'Diplomat',
|
||||
normal: [
|
||||
'When you play no defense, one Worthless card in your Battle Plan may count as the defense your opponent used in that battle; discard the Worthless card afterward.',
|
||||
],
|
||||
battle: [
|
||||
'If you lose, retreat undialed forces up to this leader’s strength to one empty adjacent territory that is not a stronghold.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mentat',
|
||||
name: 'Mentat',
|
||||
normal: [
|
||||
'Before Battle Plans, name a specific weapon and ask whether your opponent holds it. They reveal that card if held, or a different Treachery Card otherwise.',
|
||||
'Revealing the named weapon does not require your opponent to use it.',
|
||||
],
|
||||
battle: ['Add 2 to this leader’s strength.'],
|
||||
},
|
||||
{
|
||||
id: 'suk-graduate',
|
||||
name: 'Suk Graduate',
|
||||
normal: [
|
||||
'After you win a battle and lose forces, return 1 such force to reserves instead of the Tleilaxu Tanks.',
|
||||
],
|
||||
battle: [
|
||||
'After you win and lose forces, save up to 3 of them from going to the Tleilaxu Tanks: leave 1 in the battle territory and return the rest to reserves.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rihani-decipherer',
|
||||
name: 'Rihani Decipherer',
|
||||
normal: [
|
||||
'After you win a battle, secretly inspect 2 random cards from the Traitor Deck, then reshuffle them into that deck.',
|
||||
],
|
||||
battle: [
|
||||
'After you win, draw 2 Traitor Cards and keep 1 by revealing an unused Traitor Card you already hold.',
|
||||
'Shuffle the revealed card and the unkept drawn card into the Traitor Deck.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sandmaster',
|
||||
name: 'Sandmaster',
|
||||
normal: [
|
||||
'When moving forces into or through a territory containing spice, you may immediately collect 1 spice there, once per territory traversed.',
|
||||
],
|
||||
battle: [
|
||||
'After winning a battle in a territory that contains spice, add 3 spice to that territory.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smuggler',
|
||||
name: 'Smuggler',
|
||||
normal: [
|
||||
'When shipping forces from off-planet to an empty territory, one additional force may accompany that shipment for free.',
|
||||
],
|
||||
battle: [
|
||||
'When Battle Plans are revealed, collect spice from the battle territory equal to this leader’s strength, limited by the spice present and subject to the skilled leader surviving the battle.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'planetologist',
|
||||
name: 'Planetologist',
|
||||
normal: [
|
||||
'For a movement, either add 1 to movement range, capped at 3, or move forces from two different territories when both groups share one destination.',
|
||||
],
|
||||
battle: [
|
||||
'In place of a weapon, reveal a green Special card other than a Cheap Hero and add 2 to this leader’s strength.',
|
||||
'Discard that Special card after the battle.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'warmaster',
|
||||
name: 'Warmaster',
|
||||
normal: [
|
||||
'Add 1 to your other leader discs when their Battle Plan contains at least one Worthless card.',
|
||||
],
|
||||
battle: [
|
||||
'Add 3 to this leader’s strength when its Battle Plan contains at least one Worthless card.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'master-of-assassins',
|
||||
name: 'Master of Assassins',
|
||||
normal: ['Add 1 to your other leader discs when they use a Poison Weapon.'],
|
||||
battle: ['Add 3 to this leader’s strength when it uses a Poison Weapon.'],
|
||||
},
|
||||
{
|
||||
id: 'swordmaster-of-ginaz',
|
||||
name: 'Swordmaster of Ginaz',
|
||||
normal: [
|
||||
'Add 1 to your other leader discs when they use a Projectile Weapon.',
|
||||
],
|
||||
battle: [
|
||||
'Add 3 to this leader’s strength when it uses a Projectile Weapon.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'killer-medic',
|
||||
name: 'Killer Medic',
|
||||
normal: [
|
||||
'Add 1 to your other leader discs when they use a Poison Defense.',
|
||||
],
|
||||
battle: ['Add 3 to this leader’s strength when it uses a Poison Defense.'],
|
||||
},
|
||||
{
|
||||
id: 'prana-bindu-adept',
|
||||
name: 'Prana Bindu Adept',
|
||||
normal: [
|
||||
'Add 1 to your other leader discs when they use a Projectile Defense.',
|
||||
],
|
||||
battle: [
|
||||
'Add 3 to this leader’s strength when it uses a Projectile Defense.',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type LeaderSkillCard = (typeof LEADER_SKILL_CARDS)[number];
|
||||
export type LeaderSkillId = LeaderSkillCard['id'];
|
||||
|
||||
export function leaderSkillCard(id: LeaderSkillId): LeaderSkillCard {
|
||||
const card = LEADER_SKILL_CARDS.find((candidate) => candidate.id === id);
|
||||
if (!card) throw new Error('Unknown Leader Skill Card.');
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Card } from './cards';
|
||||
import { defenseTypes, weaponTypes } from './battle-cards';
|
||||
import type { LeaderSkillId } from './leader-skill-cards';
|
||||
|
||||
export type DirectLeaderSkillId =
|
||||
| 'warmaster'
|
||||
| 'master-of-assassins'
|
||||
| 'swordmaster-of-ginaz'
|
||||
| 'killer-medic'
|
||||
| 'prana-bindu-adept';
|
||||
|
||||
export type BattleLeaderSkill = {
|
||||
skill: LeaderSkillId;
|
||||
leader: string;
|
||||
/** Ordinary assignments are publicly face up until deliberately concealed. */
|
||||
faceUp: boolean;
|
||||
/** A captured skill grants only its attached leader's lower battle effect. */
|
||||
captured: boolean;
|
||||
};
|
||||
|
||||
export type AppliedLeaderSkill = {
|
||||
skill: DirectLeaderSkillId;
|
||||
amount: 1 | 3;
|
||||
mode: 'normal' | 'skilled';
|
||||
};
|
||||
|
||||
export type LeaderSkillBattleBonus = {
|
||||
bonus: number;
|
||||
applied: AppliedLeaderSkill[];
|
||||
};
|
||||
|
||||
const directSkills = new Set<LeaderSkillId>([
|
||||
'warmaster',
|
||||
'master-of-assassins',
|
||||
'swordmaster-of-ginaz',
|
||||
'killer-medic',
|
||||
'prana-bindu-adept',
|
||||
]);
|
||||
|
||||
function isDirectSkill(skill: string): skill is DirectLeaderSkillId {
|
||||
return directSkills.has(skill as LeaderSkillId);
|
||||
}
|
||||
|
||||
function qualifies(
|
||||
skill: DirectLeaderSkillId,
|
||||
weapon: Card | undefined,
|
||||
defense: Card | undefined,
|
||||
) {
|
||||
if (skill === 'warmaster')
|
||||
return [weapon, defense].some((card) => card?.kind === 'worthless');
|
||||
if (skill === 'master-of-assassins')
|
||||
return weaponTypes(weapon).includes('poison');
|
||||
if (skill === 'swordmaster-of-ginaz')
|
||||
return weaponTypes(weapon).includes('projectile');
|
||||
if (skill === 'killer-medic')
|
||||
return defenseTypes(defense).includes('snooper');
|
||||
return defenseTypes(defense).includes('shield');
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote the five card-role strength families after weapon survival is known.
|
||||
* Printed disc strength and bounty remain outside this additive score receipt.
|
||||
*/
|
||||
export function leaderSkillBattleBonus(input: {
|
||||
assignments: readonly BattleLeaderSkill[];
|
||||
/** Cheap Hero substitutes for another leader for normal +1 training only. */
|
||||
selectedLeader: { id: string; kind: 'disc' | 'hero' } | undefined;
|
||||
weapon: Card | undefined;
|
||||
defense: Card | undefined;
|
||||
skilledLeaderSurvives: boolean;
|
||||
}): LeaderSkillBattleBonus {
|
||||
if (!input.selectedLeader) return { bonus: 0, applied: [] };
|
||||
const applied: AppliedLeaderSkill[] = [];
|
||||
for (const assignment of input.assignments) {
|
||||
if (!isDirectSkill(assignment.skill)) continue;
|
||||
if (!qualifies(assignment.skill, input.weapon, input.defense)) continue;
|
||||
if (
|
||||
input.selectedLeader.kind === 'disc' &&
|
||||
assignment.leader === input.selectedLeader.id &&
|
||||
(!assignment.faceUp || assignment.captured) &&
|
||||
input.skilledLeaderSurvives
|
||||
)
|
||||
applied.push({ skill: assignment.skill, amount: 3, mode: 'skilled' });
|
||||
else if (
|
||||
assignment.leader !== input.selectedLeader.id &&
|
||||
assignment.faceUp &&
|
||||
!assignment.captured
|
||||
)
|
||||
applied.push({ skill: assignment.skill, amount: 1, mode: 'normal' });
|
||||
}
|
||||
return {
|
||||
bonus: applied.reduce((sum, receipt) => sum + receipt.amount, 0),
|
||||
applied,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from './leader-skill-cards';
|
||||
|
||||
export type LeaderSkillOffer = {
|
||||
event: string;
|
||||
/** An empty revival offer is the choice whether to draw, not a free preview. */
|
||||
cards: LeaderSkillId[];
|
||||
/** Revival fixes the eligible leader; setup lets the owner choose. */
|
||||
leader: string | null;
|
||||
};
|
||||
export type LeaderSkillAssignment = {
|
||||
skill: LeaderSkillId;
|
||||
leader: string;
|
||||
owner: string;
|
||||
};
|
||||
export type LeaderSkillsState = {
|
||||
deck: LeaderSkillId[];
|
||||
offers: Record<string, LeaderSkillOffer>;
|
||||
assignments: LeaderSkillAssignment[];
|
||||
};
|
||||
export type LeaderSkillsView = {
|
||||
assignments: (LeaderSkillAssignment & {
|
||||
controller: string;
|
||||
captured: boolean;
|
||||
faceUp: boolean;
|
||||
})[];
|
||||
offer: LeaderSkillOffer | null;
|
||||
eligibleLeaders: { id: string; name: string }[];
|
||||
/** Only this player's genuine public choice is included. */
|
||||
battleChoice: { event: string; leader: string; skill: LeaderSkillId } | null;
|
||||
};
|
||||
export type SkillLeader = {
|
||||
id: string;
|
||||
dead: boolean;
|
||||
capturedBy?: string;
|
||||
gholaBy?: string;
|
||||
};
|
||||
export type SkillRoster = readonly {
|
||||
id: string;
|
||||
leaders: readonly SkillLeader[];
|
||||
}[];
|
||||
export class LeaderSkillError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LeaderSkillError';
|
||||
}
|
||||
}
|
||||
function ensure(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new LeaderSkillError(message);
|
||||
}
|
||||
function shuffled<T>(cards: readonly T[], random: () => number): T[] {
|
||||
const result = [...cards];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const roll = random();
|
||||
ensure(
|
||||
Number.isFinite(roll) && roll >= 0 && roll < 1,
|
||||
'Invalid skill shuffle.',
|
||||
);
|
||||
const j = Math.floor(roll * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function createLeaderSkills(random: () => number): LeaderSkillsState {
|
||||
return {
|
||||
deck: shuffled(
|
||||
LEADER_SKILL_CARDS.map((card) => card.id),
|
||||
random,
|
||||
),
|
||||
offers: {},
|
||||
assignments: [],
|
||||
};
|
||||
}
|
||||
/** Validate physical custody without treating a hidden skill as a new copy. */
|
||||
export function validateLeaderSkills(
|
||||
state: LeaderSkillsState,
|
||||
roster: SkillRoster,
|
||||
): void {
|
||||
ensure(
|
||||
state &&
|
||||
Array.isArray(state.deck) &&
|
||||
Array.isArray(state.assignments) &&
|
||||
state.offers &&
|
||||
typeof state.offers === 'object' &&
|
||||
!Array.isArray(state.offers),
|
||||
'Invalid saved Leader Skills state.',
|
||||
);
|
||||
const cards: string[] = [...state.deck];
|
||||
const assignedLeaders = new Set<string>();
|
||||
const originalOwners = new Set<string>();
|
||||
for (const assignment of state.assignments) {
|
||||
const owner = roster.find((p) => p.id === assignment.owner);
|
||||
ensure(
|
||||
owner?.leaders.some((leader) => leader.id === assignment.leader),
|
||||
'A skill must remain attached to its original leader.',
|
||||
);
|
||||
ensure(
|
||||
!assignedLeaders.has(assignment.leader),
|
||||
'A leader cannot hold two skills.',
|
||||
);
|
||||
assignedLeaders.add(assignment.leader);
|
||||
ensure(
|
||||
!originalOwners.has(assignment.owner),
|
||||
'A faction cannot acquire a second original skill while the captured replacement ruling is unresolved.',
|
||||
);
|
||||
originalOwners.add(assignment.owner);
|
||||
cards.push(assignment.skill);
|
||||
}
|
||||
for (const [owner, offer] of Object.entries(state.offers)) {
|
||||
ensure(
|
||||
!originalOwners.has(owner),
|
||||
'A faction with an assigned skill cannot hold another skill offer.',
|
||||
);
|
||||
ensure(
|
||||
roster.some((p) => p.id === owner) &&
|
||||
offer &&
|
||||
typeof offer.event === 'string' &&
|
||||
offer.event.length > 0 &&
|
||||
Array.isArray(offer.cards) &&
|
||||
(offer.cards.length === 2 ||
|
||||
(offer.leader !== null && offer.cards.length === 0)),
|
||||
'Invalid saved skill choice.',
|
||||
);
|
||||
ensure(
|
||||
offer.leader === null ||
|
||||
roster
|
||||
.find((p) => p.id === owner)
|
||||
?.leaders.some((l) => l.id === offer.leader),
|
||||
'Invalid revival skill leader.',
|
||||
);
|
||||
cards.push(...offer.cards);
|
||||
}
|
||||
const canonical = LEADER_SKILL_CARDS.map((c) => c.id);
|
||||
ensure(
|
||||
cards.length === canonical.length &&
|
||||
new Set(cards).size === canonical.length &&
|
||||
canonical.every((id) => cards.includes(id)),
|
||||
'Leader Skills must preserve all fourteen physical cards exactly once.',
|
||||
);
|
||||
}
|
||||
/** Deal every initial two-card hand before any rejected card returns to the deck. */
|
||||
export function dealLeaderSkills(
|
||||
state: LeaderSkillsState,
|
||||
players: readonly string[],
|
||||
event: string,
|
||||
): LeaderSkillsState {
|
||||
ensure(
|
||||
!state.assignments.length &&
|
||||
!Object.keys(state.offers).length &&
|
||||
state.deck.length === LEADER_SKILL_CARDS.length,
|
||||
'Leader Skills have already been dealt.',
|
||||
);
|
||||
ensure(
|
||||
players.length >= 2 &&
|
||||
players.length <= 6 &&
|
||||
new Set(players).size === players.length &&
|
||||
!!event,
|
||||
'Choose two through six distinct skill recipients.',
|
||||
);
|
||||
const next = structuredClone(state);
|
||||
for (const player of players)
|
||||
next.offers[player] = {
|
||||
event,
|
||||
cards: next.deck.splice(0, 2),
|
||||
leader: null,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
/** Callers supply the living own leader choices after excluding Auditor/captives. */
|
||||
export function chooseLeaderSkill(
|
||||
state: LeaderSkillsState,
|
||||
owner: string,
|
||||
event: string,
|
||||
skill: string,
|
||||
leader: string,
|
||||
eligibleLeaders: readonly string[],
|
||||
random: () => number,
|
||||
): LeaderSkillsState {
|
||||
const offer = state.offers[owner];
|
||||
ensure(
|
||||
!state.assignments.some((assignment) => assignment.owner === owner),
|
||||
'This faction already has an assigned Leader Skill.',
|
||||
);
|
||||
ensure(
|
||||
offer &&
|
||||
offer.event === event &&
|
||||
offer.cards.includes(skill as LeaderSkillId),
|
||||
'Choose one of your current Leader Skill cards.',
|
||||
);
|
||||
ensure(
|
||||
eligibleLeaders.includes(leader) &&
|
||||
(!offer.leader || offer.leader === leader),
|
||||
'Choose an eligible living leader for this skill.',
|
||||
);
|
||||
ensure(
|
||||
!state.assignments.some((assignment) => assignment.leader === leader),
|
||||
'This leader already has a skill.',
|
||||
);
|
||||
const next = structuredClone(state);
|
||||
next.assignments.push({ owner, leader, skill: skill as LeaderSkillId });
|
||||
next.deck = shuffled(
|
||||
[...next.deck, ...offer.cards.filter((card) => card !== skill)],
|
||||
random,
|
||||
);
|
||||
delete next.offers[owner];
|
||||
return next;
|
||||
}
|
||||
/** Death returns the physical card once; capture leaves it attached to the disc. */
|
||||
export function returnDeadLeaderSkills(
|
||||
state: LeaderSkillsState,
|
||||
roster: SkillRoster,
|
||||
random: () => number,
|
||||
): { state: LeaderSkillsState; returned: LeaderSkillAssignment[] } {
|
||||
const returned = state.assignments.filter(
|
||||
(assignment) =>
|
||||
roster
|
||||
.find((p) => p.id === assignment.owner)
|
||||
?.leaders.find((l) => l.id === assignment.leader)?.dead,
|
||||
);
|
||||
if (!returned.length) return { state, returned };
|
||||
const next = structuredClone(state);
|
||||
next.assignments = next.assignments.filter(
|
||||
(assignment) => !returned.some((dead) => dead.leader === assignment.leader),
|
||||
);
|
||||
next.deck = shuffled(
|
||||
[...next.deck, ...returned.map((assignment) => assignment.skill)],
|
||||
random,
|
||||
);
|
||||
return { state: next, returned };
|
||||
}
|
||||
/** The capture/replacement question stays guarded while an original skill exists. */
|
||||
export function offerRevivedLeaderSkill(
|
||||
state: LeaderSkillsState,
|
||||
owner: string,
|
||||
leader: string,
|
||||
event: string,
|
||||
): LeaderSkillsState {
|
||||
if (
|
||||
state.assignments.some((assignment) => assignment.owner === owner) ||
|
||||
state.offers[owner]
|
||||
)
|
||||
return state;
|
||||
ensure(
|
||||
state.deck.length >= 2 && !!event,
|
||||
'Two physical cards are required for a revived leader skill choice.',
|
||||
);
|
||||
const next = structuredClone(state);
|
||||
next.offers[owner] = { event, cards: [], leader };
|
||||
return next;
|
||||
}
|
||||
export function drawRevivedLeaderSkills(
|
||||
state: LeaderSkillsState,
|
||||
owner: string,
|
||||
event: string,
|
||||
): LeaderSkillsState {
|
||||
const offer = state.offers[owner];
|
||||
ensure(
|
||||
offer?.leader &&
|
||||
offer.event === event &&
|
||||
offer.cards.length === 0 &&
|
||||
state.deck.length >= 2,
|
||||
'Only a current undrawn revival offer may draw skill cards.',
|
||||
);
|
||||
const next = structuredClone(state);
|
||||
next.offers[owner].cards = next.deck.splice(0, 2);
|
||||
return next;
|
||||
}
|
||||
export function declineRevivedLeaderSkill(
|
||||
state: LeaderSkillsState,
|
||||
owner: string,
|
||||
event: string,
|
||||
): LeaderSkillsState {
|
||||
const offer = state.offers[owner];
|
||||
ensure(
|
||||
offer?.leader && offer.event === event && offer.cards.length === 0,
|
||||
'Only an undrawn revival skill offer may be declined.',
|
||||
);
|
||||
const next = structuredClone(state);
|
||||
delete next.offers[owner];
|
||||
return next;
|
||||
}
|
||||
+27
-1
@@ -4,6 +4,7 @@ import { FACTIONS, PHASES } from './catalog';
|
||||
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { NEXUS_CARD_REFERENCE } from './nexus-card-reference';
|
||||
import { LEADER_SKILL_CARDS } from './leader-skill-cards';
|
||||
|
||||
export const RULE_CHECKLIST_AREAS = [
|
||||
'Implementation',
|
||||
@@ -3556,6 +3557,31 @@ 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: 'leader-skills',
|
||||
title: 'Leader Skills',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices and five connected battle disciplines in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
|
||||
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
|
||||
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
|
||||
'The assigned leader keeps its skill through capture and returns with it if it survives its captive battle. The known skill identifies that captured leader; Harkonnen receives only its lower battle effect. Normal effects remain unavailable to both the captor and original faction while it is captured. A killed skilled leader returns its card to the shuffled skill deck.',
|
||||
'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.',
|
||||
'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer, Sandmaster, Smuggler and Planetologist have readable cards but their effects are still missing. The complete deck remains present; 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: ['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. Other nine effects, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline controls are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers and legal visibility/revival actions. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup and revival checks cover this boundary. 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'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
[
|
||||
'ix-modules',
|
||||
@@ -3602,7 +3628,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'setup',
|
||||
'advanced-combat',
|
||||
...(id === 'ix-modules' ? ['tech-tokens'] : []),
|
||||
...(id === 'choam-modules' ? ['stronghold-cards'] : []),
|
||||
...(id === 'choam-modules' ? ['stronghold-cards', 'leader-skills'] : []),
|
||||
...(id === 'choam-modules' ? ['richese-cards'] : []),
|
||||
...(id === 'ecaz-modules'
|
||||
? [
|
||||
|
||||
@@ -4,6 +4,8 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
// Exhaustive typed discriminants for the existing engine control unions. These
|
||||
// are envelope checks, not a duplicate implementation of each future choice.
|
||||
const DECISIONS = {
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
choamAudit: true,
|
||||
choamAuditPayment: true,
|
||||
strongholdCopy: true,
|
||||
|
||||
Reference in new issue
Block a user