Connect Suk Graduate casualty rescues and saved continuation

Implement both rescue bands with legal physical casualty routing, owner controls,
all four bot profiles and durable battle receipts. Preserve paid support, elite
identity, killed/returned leader custody and replay rejection. New Advanced
Atreides skill choices exclude Suk while the KH loss-count ruling is pending;
legacy assigned combinations retain their preflight guard. Public gates remain.

Independent review fixed stale dead trainer assignments and detached rescue
receipts. Final focused Suk/Leader Skills union: 46 passed. Typecheck, lint,
4,452 offline tests, production build and 43 HTTP tests passed on unchanged
source tree 3b76239a46d0ba874299495e64295a5eaaa8f2415c3e9a041bee944bbce50c5a.

Actual browser rescue and refresh retained counters, private hand and pending
winner card cleanup. Two four-profile Basic/Advanced smoke games completed
with JSON restoration and no rejected actions; neither exercised a Suk rescue.
After the outage report the server was healthy. All 360 pre-check rooms and
original seat/recovery records are unchanged; checks added 33 rooms. SQLite
integrity and foreign-key checks pass. No server restart or database reset.
This commit is contained in:
Quintine committed 2026-09-13 19:02:32 +10:00
1 parent 5082c81353
commit af360c836b
23 files changed
+987 -26

No files matched your search

+8
View File
@@ -28,6 +28,7 @@ import {
} from './stone-burner';
import { matchingTraitor } from './traitors';
import { auditCount } from './choam-auditor';
import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
import {
quoteHomeworldBattleRules,
type HomeworldBattleRules,
@@ -140,6 +141,7 @@ export type BattleResolutionQuote = {
homeworldExplosion?: HomeworldExplosionLosses;
/** Basic ordinary winners lose their dial immediately; Advanced/Ix defer typed losses. */
basicWinnerLosses: number | null;
sukGraduate?: SukGraduateSkill;
casualties: {
forces: CombatForces;
dial: number;
@@ -570,10 +572,15 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
);
let casualties: BattleResolutionQuote['casualties'] = null;
let basicWinnerLosses: number | null = null;
const sukGraduate = winner && result === 'normal' && winner.plan.dial > 0
? sukGraduateSkill(winner.leaderSkills ?? [], winner.leader,
!(winner === a ? deaths.attacker : deaths.defender))
: null;
if (winner && result === 'normal') {
if (
input.advanced ||
input.typedCasualties ||
sukGraduate ||
homeworld ||
winner.faction === 'ixians'
) {
@@ -744,6 +751,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
? { homeworldExplosion: homeworld.explosion }
: {}),
basicWinnerLosses,
...(sukGraduate ? { sukGraduate } : {}),
casualties,
played,
discarded,
+10 -2
View File
@@ -2394,6 +2394,13 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', accept: false },
];
}
if (d.kind === 'sukRescue') {
return d.options.map((option, choice) => ({ choice,
score: option.normal + option.elite * (level > 0 ? 2.5 : 1) +
(option.kept ? 1 + (option.kept.kind === 'elite' && level > 0 ? 0.5 : 0) : 0),
})).sort((a, b) => b.score - a.score)
.map(({ choice }) => ({ type: 'decision', event: d.event, choice }));
}
if (d.kind === 'battleLosses') {
const choices = d.options
.map((o, choice) => ({
@@ -3530,10 +3537,11 @@ export function botActions(g: GameView): Action[] {
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 available = offer.cards.filter((card) => !skill.unavailableSkills?.[card]);
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 chosen = available.find((card) => direct.includes(card)) ?? available[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 (leader && chosen) return [{type:'leaderSkill',event:offer.event,skill:chosen,leader}];
}
}
if (g.nexusTraitors?.pending)
+151 -2
View File
@@ -1,11 +1,12 @@
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
import { leaderSkillCard } from './leader-skill-cards';
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
import {
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
validLeaderSkillBattleCardPair,
type BattleLeaderSkill,
} from './leader-skill-combat';
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
@@ -709,6 +710,7 @@ export type Decision =
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
| { kind: 'poisonTooth'; player: string }
| { kind: 'stoneBurner'; player: string; event: string }
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
| { kind: 'fullPlanOffer'; player: string }
| { kind: 'fullPlanRead'; player: string; target: string }
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
@@ -1252,6 +1254,7 @@ export type Game = {
biddingEnd?: BiddingEnd | null;
ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[];
pendingWinnerDiscards?: { event: string; turn: number; territory: string; player: string; cards: string[]; optional: string[]; signature: string } | null;
pendingSukRescue?: SukRescueReceipt | null;
pendingChoamMarketGhola?: ChoamMarketGhola | null;
choamTradeTurn?: number;
inflation?: Inflation | null;
@@ -1465,6 +1468,7 @@ export type Game = {
cardRoles?: Record<string, Record<string, Omit<EcazPoisonDiscard, 'card'>>>;
cardRolesSignature?: string;
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
sukRescue?: { signature: string; completed: boolean };
caladanReinforcement?: HomeworldVictoryObligation;
nexusSardaukarCasualties?: string;
} | null;
@@ -4309,6 +4313,7 @@ function treacheryDiscardIntegrity(g: Game) {
!!c.casualties ===
(c.result === 'normal' &&
(g.advanced ||
!!g.pendingSukRescue ||
!!g.homeworlds ||
g.players.find((p) => p.id === c.winner)?.faction ===
'ixians')),
@@ -4887,8 +4892,25 @@ function skillEligibleLeaders(g: Game, owner: string): string[] {
)
.map((leader) => leader.id);
}
const ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK =
'Suk Graduate is unavailable to Advanced Atreides while the Kwisatz Haderach loss-count ruling is pending.';
function leaderSkillAssignmentUnavailable(
g: Game,
owner: string,
skill: string,
): string | null {
return g.advanced &&
getPlayer(g, owner).faction === 'atreides' &&
skill === 'suk-graduate'
? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK
: null;
}
function leaderSkillsIntegrity(g: Game) {
sukRescueIntegrity(g);
if (!g.leaderSkills) return;
requireRule(g.leaderSkills.assignments.every((assignment) =>
g.players.find((p) => p.id === assignment.owner)?.leaders.some((leader) => leader.id === assignment.leader && !leader.dead)),
'A saved Leader Skill cannot remain assigned to a dead or missing leader.');
skillRule(() => validateLeaderSkills(g.leaderSkills!, g.players));
requireRule(
!g.players.some((p) => p.leaders.some((l) => l.gholaBy)),
@@ -5013,6 +5035,12 @@ function projectedLeaderSkills(
const offer = g.leaderSkills.offers[owner] ?? null;
const eligible = skillEligibleLeaders(g, owner);
const assignment = nativeLeaderSkill(g, owner);
const unavailableSkills = Object.fromEntries(
(offer?.cards ?? []).flatMap((skill) => {
const reason = leaderSkillAssignmentUnavailable(g, owner, skill);
return reason ? [[skill, reason]] : [];
}),
) as Partial<Record<LeaderSkillId, string>>;
return {
assignments: g.leaderSkills.assignments.map((a) => {
const controller = leaderSkillController(g, a);
@@ -5025,6 +5053,7 @@ function projectedLeaderSkills(
};
}),
offer,
...(Object.keys(unavailableSkills).length ? { unavailableSkills } : {}),
eligibleLeaders: getPlayer(g, owner)
.leaders.filter((l) => eligible.includes(l.id))
.map((l) => ({ id: l.id, name: l.name })),
@@ -14059,6 +14088,13 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
pendingAuditorPresent: !!g.pendingAuditor,
pendingRetentionPresent: !!g.moritaniRetention,
});
if (quote.sukGraduate) requireRule(
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
if (quote.sukGraduate) requireRule(
!g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length &&
g.players.every((p) => faction(p.faction).expansion === 'base'),
'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.');
for (const id of quote.destroyedArmies)
if (b.territory.startsWith('homeworld:')) quoteHomeworldLoss(g, id, b.territory, combatArmy(g, id, b.territory));
else validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity);
@@ -14340,6 +14376,16 @@ function resolveBattle(g: Game) {
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
};
if (winner && quote.sukGraduate && casualtyCommitment) {
requireRule(!g.pendingSukRescue, 'Finish the preceding Suk Graduate rescue.');
g.pendingSukRescue = {
event: g.lastBattleContext.event, turn: g.turn, player: winner.id,
territory: b.territory, skill: quote.sukGraduate,
commitment: structuredClone(casualtyCommitment), pool: sukForcePool(winner, b.territory),
cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '',
};
updateSukReceipt(g);
}
if (g.homeworlds?.custody && winner?.faction === 'atreides' &&
(quote.result === 'normal' || quote.result === 'traitor')) {
requireRule(!g.homeworldVictoryReinforcement || g.homeworldVictoryReinforcement.stage === 'complete',
@@ -14435,6 +14481,20 @@ function settleWinnerCasualties(
choice: Casualties,
automatic = false,
) {
if (g.pendingSukRescue) {
const pending = g.pendingSukRescue;
requireRule(pending.player === p.id && pending.territory === to &&
JSON.stringify(cards) === JSON.stringify(pending.cards) && !pending.losses &&
pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(choice)),
'Choose the original Suk Graduate casualty allocation.');
pending.losses = { ...choice };
updateSukReceipt(g);
const options = sukRescueOptions(pending.skill, pending.pool, choice);
if (options.length === 1) settleSukRescue(g, options[0], true);
else g.decision = { kind: 'sukRescue', player: p.id, event: pending.event,
territory: to, mode: pending.skill.mode, options };
return;
}
if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
const sardaukar = g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.lastBattleContext?.event && record.receipt.owner === p.id);
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
@@ -14473,6 +14533,85 @@ function settleWinnerCasualties(
};
} else finishWinner(g, p, to, cards);
}
function sukForcePool(p: Player, territory: string): SukForceGroup[] {
return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory)
.map(([key, total]) => ({ key, normal: total - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }));
}
function sukPhysicalSignature(p: Player) {
return JSON.stringify({ forces: p.forces, elites: p.elites ?? null,
reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses });
}
function updateSukReceipt(g: Game) {
const pending = g.pendingSukRescue!;
pending.signature = sukReceiptSignature(pending);
g.lastBattleContext!.sukRescue = { signature: pending.signature, completed: false };
}
function sukRescueIntegrity(g: Game) {
const pending = g.pendingSukRescue, context = g.lastBattleContext;
const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'sukRescue');
if (!pending) {
requireRule(!decisions.length && (!context?.sukRescue || context.sukRescue.completed),
'The saved Suk Graduate rescue receipt is missing.');
return;
}
const player = g.players.find((p) => p.id === pending.player);
requireRule(player && g.leaderSkills && g.phase === 6 && !g.battle &&
g.leaderSkills.assignments.some((a) => a.skill === 'suk-graduate' && a.leader === pending.skill.leader) &&
context?.event === pending.event && context.turn === pending.turn && g.turn === pending.turn &&
context.result === 'normal' && context.winner === pending.player && context.territory === pending.territory &&
context.sukRescue?.signature === pending.signature && !context.sukRescue.completed &&
pending.signature === sukReceiptSignature(pending) &&
pending.physical === sukPhysicalSignature(player) &&
JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(player, pending.territory)) &&
pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) &&
pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) &&
JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) &&
pending.commitment.options.length > 0 && pending.cards.every((id) => player.hand.some((c) => c.id === id)),
'The saved Suk Graduate rescue no longer matches its battle, cards or physical forces.');
if (pending.losses) {
requireRule(pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(pending.losses)) &&
decisions.length === 1 && decisions.every((d) => d.player === pending.player && d.event === pending.event &&
d.territory === pending.territory && d.mode === pending.skill.mode &&
JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!))),
'The saved Suk Graduate rescue choices differ from the committed casualties.');
} else {
const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player);
const discard = g.pendingTreacheryDiscard?.continuation;
requireRule(!decisions.length && (lossDecision ? lossDecision.kind === 'battleLosses' &&
lossDecision.territory === pending.territory && JSON.stringify(lossDecision.options) === JSON.stringify(pending.commitment.options) &&
JSON.stringify(lossDecision.cards) === JSON.stringify(pending.cards) :
discard?.kind === 'battleResolved' && discard.event === pending.event &&
JSON.stringify(discard.casualties) === JSON.stringify(pending.commitment)),
'The Suk Graduate rescue lost its casualty continuation.');
}
}
function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
const pending = g.pendingSukRescue!;
const player = getPlayer(g, pending.player);
requireRule(pending.losses && pending.physical === sukPhysicalSignature(player),
'The Suk Graduate rescue no longer has its committed physical counters.');
const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option);
for (const group of quote.removed) {
player.forces[group.key] -= group.normal + group.elite;
if (!player.forces[group.key]) delete player.forces[group.key];
if (player.elites) {
player.elites.forces[group.key] = (player.elites.forces[group.key] ?? 0) - group.elite;
if (!player.elites.forces[group.key]) delete player.elites.forces[group.key];
}
}
player.reserves += quote.reserves.normal + quote.reserves.elite;
player.tanks += quote.tanks.normal + quote.tanks.elite;
player.battleLosses += quote.tanks.normal + quote.tanks.elite;
if (player.elites) {
player.elites.reserves += quote.reserves.elite;
player.elites.tanks += quote.tanks.elite;
}
g.pendingSukRescue = null;
g.lastBattleContext!.sukRescue!.completed = true;
log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${option.kept ? `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'}, ${quote.reserves.normal + quote.reserves.elite} returned to reserves, and ${quote.tanks.normal + quote.tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
observeOccupation(g);
finishWinner(g, player, pending.territory, pending.cards);
}
function winnerDiscardSignature(pending: NonNullable<Game['pendingWinnerDiscards']>) {
const { event, turn, territory, player, cards, optional } = pending;
return JSON.stringify({ event, turn, territory, player, cards, optional });
@@ -19205,7 +19344,10 @@ function applyActionInner(
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));
const skill = stringField(action.skill);
const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
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'});
}
g.decision = null;
@@ -20063,6 +20205,11 @@ function applyActionInner(
requireRule(action.event === decision.event, 'This Homeworld casualty choice belongs to a different battle.');
const choice = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Casualty choice')];
settleHomeworldExplosion(g, choice, false);
} else if (decision.kind === 'sukRescue') {
requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice'].includes(key)),
'Choose only the current Suk Graduate rescue.');
const option = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Rescue choice')];
settleSukRescue(g, option, false);
} else if (decision.kind === 'battleLosses') {
const choice =
decision.options[
@@ -20485,6 +20632,8 @@ function applyActionInner(
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);
const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
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') {
+2
View File
@@ -24,6 +24,8 @@ export type LeaderSkillsView = {
faceUp: boolean;
})[];
offer: LeaderSkillOffer | null;
/** Private reasons that this owner cannot select a card in the current offer. */
unavailableSkills?: Partial<Record<LeaderSkillId, string>>;
eligibleLeaders: { id: string; name: string }[];
/** Only this player's genuine public choice is included. */
battleChoice: { event: string; leader: string; skill: LeaderSkillId } | null;
+8 -7
View File
@@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines and Planetologist movement/battle effects in development tables. Other effects and combined-module acceptance remain unfinished.',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues 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.',
@@ -3571,16 +3571,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer, Sandmaster and Smuggler have readable cards but their effects are still missing. The complete deck remains present; this development prototype does not enable public module starts.',
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.',
'Bureaucrat, Spice Banker, Diplomat, Mentat, Rihani Decipherer, Sandmaster and Smuggler 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 and Planetologist movement/Special substitution. Other eight skills, 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, Planetologist range/gather controls and Special weapon selection are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates. 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','docs/PLANETOLOGIST_RULES.md'] },
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival and Planetologist movement/cancellation 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'] },
{ 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. Other seven skills, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.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. Other effect controls remain missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues. 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','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md'] },
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite 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','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts'] },
],
},
...[
+1
View File
@@ -6,6 +6,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
const DECISIONS = {
leaderSkillVisibility: true,
leaderSkillRevival: true,
sukRescue: true,
choamAudit: true,
choamAuditPayment: true,
strongholdCopy: true,
+101
View File
@@ -0,0 +1,101 @@
import type { Casualties, CombatForces } from './combat';
import type { BattleLeaderSkill } from './leader-skill-combat';
export type SukGraduateSkill = { leader: string; mode: 'normal' | 'skilled' };
export type SukForceGroup = { key: string; normal: number; elite: number };
export type SukRescueOption = {
normal: number;
elite: number;
/** The saved counter never leaves its original sector. */
kept: { key: string; kind: 'normal' | 'elite' } | null;
};
export type SukRescueReceipt = {
event: string;
turn: number;
player: string;
territory: string;
skill: SukGraduateSkill;
commitment: { forces: CombatForces; dial: number; support: number; options: Casualties[] };
pool: SukForceGroup[];
cards: string[];
physical: string;
losses: Casualties | null;
signature: string;
};
export function sukReceiptSignature(receipt: SukRescueReceipt): string {
const { signature: _signature, ...value } = receipt;
return JSON.stringify(value);
}
/** Freeze entitlement while the revealed plan and captured controller still exist. */
export function sukGraduateSkill(
assignments: readonly BattleLeaderSkill[],
leader: { id: string; dead: boolean } | undefined,
survives: boolean,
): SukGraduateSkill | null {
const skill = assignments.find((a) => a.skill === 'suk-graduate');
if (!skill) return null;
if (skill.leader === leader?.id)
return !leader.dead && survives && (!skill.faceUp || skill.captured)
? { leader: skill.leader, mode: 'skilled' }
: null;
return skill.faceUp && !skill.captured
? { leader: skill.leader, mode: 'normal' }
: null;
}
/** Match the engine's ordinary physical casualty allocation in location order. */
export function sukCasualtyGroups(pool: readonly SukForceGroup[], losses: Casualties): SukForceGroup[] {
let normal = losses.normal, elite = losses.elite;
if (![normal, elite, ...pool.flatMap((g) => [g.normal, g.elite])].every((n) => Number.isSafeInteger(n) && n >= 0) ||
new Set(pool.map((g) => g.key)).size !== pool.length)
throw new Error('Invalid Suk Graduate physical casualty pool.');
const groups = pool.map((g) => {
const n = Math.min(normal, g.normal), e = Math.min(elite, g.elite);
normal -= n; elite -= e;
return { key: g.key, normal: n, elite: e };
});
if (normal || elite) throw new Error('The Suk Graduate casualties are no longer available.');
return groups;
}
/** Only the actual dialed counters can be rescued; the two bands never add. */
export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties): SukRescueOption[] {
const groups = sukCasualtyGroups(pool, losses);
const total = losses.normal + losses.elite;
if (!total) return [];
const options: SukRescueOption[] = [];
if (skill.mode === 'skilled') options.push({ normal: 0, elite: 0, kept: null });
const maximum = skill.mode === 'normal' ? 1 : Math.min(3, total);
for (let saved = 1; saved <= maximum; saved++) {
for (let elite = 0; elite <= Math.min(saved, losses.elite); elite++) {
const normal = saved - elite;
if (normal > losses.normal) continue;
if (skill.mode === 'normal') options.push({ normal, elite, kept: null });
else for (const group of groups)
for (const kind of ['normal', 'elite'] as const)
if (group[kind] > 0 && (kind === 'normal' ? normal : elite) > 0)
options.push({ normal, elite, kept: { key: group.key, kind } });
}
}
return options;
}
/** Quote removals and destinations without touching tanks, reserves or battle-loss counters. */
export function quoteSukRescue(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties, option: SukRescueOption) {
if (!sukRescueOptions(skill, pool, losses).some((o) => JSON.stringify(o) === JSON.stringify(option)))
throw new Error('Choose an available Suk Graduate rescue.');
const groups = sukCasualtyGroups(pool, losses);
return {
removed: groups.map((g) => ({ ...g,
normal: g.normal - (option.kept?.key === g.key && option.kept.kind === 'normal' ? 1 : 0),
elite: g.elite - (option.kept?.key === g.key && option.kept.kind === 'elite' ? 1 : 0),
})),
reserves: {
normal: option.normal - (option.kept?.kind === 'normal' ? 1 : 0),
elite: option.elite - (option.kept?.kind === 'elite' ? 1 : 0),
},
tanks: { normal: losses.normal - option.normal, elite: losses.elite - option.elite },
};
}