Connect Leader Skill battle effects and private Rihani exchanges

Prototype Mentat/Bureaucrat score effects, automatic Sandmaster board spice, and Rihani inspection plus committed exchange. Preserve private views, physical custody, saved winner cleanup and captive/Suk sequencing. Remaining normal bands, missing skill families and combined modules remain gated.

Validation: 45 focused tests; typecheck, lint, 4465 offline tests, production build and 43 HTTP tests passed. Independent rules/privacy/persistence review completed. New isolated browser setup exercised inspection, draw, pending refresh, exchange and completed refresh. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not trigger Rihani.

Preserved all 393 opening rooms and original seat/recovery rows (427 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported outage; no restart or reset.

Verified source tree: c64ce9537269de94c0d785bc08b17346e826c5797352a636771e1dc6ce5e4859
This commit is contained in:
Quintine committed 2026-09-13 19:45:29 +10:00
1 parent af360c836b
commit 06f46c6f79
26 files changed
+1230 -122

No files matched your search

+24 -2
View File
@@ -29,6 +29,7 @@ import {
import { matchingTraitor } from './traitors';
import { auditCount } from './choam-auditor';
import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
import { rihaniVictorySkill, type RihaniSkill } from './rihani-decipherer';
import {
quoteHomeworldBattleRules,
type HomeworldBattleRules,
@@ -39,6 +40,8 @@ import {
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
leaderSkillBattleBonus,
bureaucratBattlePenalty,
usesSurvivingSkilledLeader,
type BattleLeaderSkill,
type LeaderSkillBattleBonus,
} from './leader-skill-combat';
@@ -79,6 +82,8 @@ export type ResolutionCombatant = ResolutionParticipant & {
stoneMode?: 'kill' | 'ignore';
/** Current authorized assignments only; captured assignments grant no normal band. */
leaderSkills?: readonly BattleLeaderSkill[];
/** Public distinct occupation, including contested strongholds but excluding advisors. */
occupiedStrongholds?: number;
/** The current aidFor result: this escrow has already left the donor's balance. */
aid?: { donor: string; amount: number };
};
@@ -141,7 +146,10 @@ export type BattleResolutionQuote = {
homeworldExplosion?: HomeworldExplosionLosses;
/** Basic ordinary winners lose their dial immediately; Advanced/Ix defer typed losses. */
basicWinnerLosses: number | null;
leaderSkillPenalties?: { attacker: number; defender: number };
sukGraduate?: SukGraduateSkill;
rihani?: RihaniSkill;
sandmaster?: string;
casualties: {
forces: CombatForces;
dial: number;
@@ -473,6 +481,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
let scores: BattleResolutionQuote['scores'] = null;
let deaths = { attacker: false, defender: false };
let bounty: BattleResolutionQuote['bounty'] = null;
let leaderSkillPenalties: BattleResolutionQuote['leaderSkillPenalties'];
let leaderSkillBonuses: BattleResolutionQuote['leaderSkillBonuses'] = {
attacker: { bonus: 0, applied: [] },
defender: { bonus: 0, applied: [] },
@@ -524,9 +533,15 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
skilledLeaderSurvives: !deaths.defender,
}),
};
const attackerPenalty = bureaucratBattlePenalty(d.leaderSkills ?? [], d.leader?.id,
!deaths.defender, a.occupiedStrongholds);
const defenderPenalty = bureaucratBattlePenalty(a.leaderSkills ?? [], a.leader?.id,
!deaths.attacker, d.occupiedStrongholds);
if (attackerPenalty || defenderPenalty)
leaderSkillPenalties = { attacker: attackerPenalty, defender: defenderPenalty };
scores = {
attacker:
a.plan.dial +
a.plan.dial - attackerPenalty +
(a.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.attacker || effects.stunned
? 0
@@ -534,7 +549,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
leaderSkillBonuses.attacker.bonus +
(a.plan.kwisatz ? 2 : 0)),
defender:
d.plan.dial +
d.plan.dial - defenderPenalty +
(d.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.defender || effects.stunned
? 0
@@ -576,6 +591,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
? sukGraduateSkill(winner.leaderSkills ?? [], winner.leader,
!(winner === a ? deaths.attacker : deaths.defender))
: null;
const rihani = winner ? rihaniVictorySkill(winner.leaderSkills ?? [], winner.leader?.id,
!(winner === a ? deaths.attacker : deaths.defender)) : null;
const sandmaster = winner && usesSurvivingSkilledLeader(winner.leaderSkills ?? [], 'sandmaster', winner.leader?.id,
!(winner === a ? deaths.attacker : deaths.defender)) ? winner.leader!.id : null;
if (winner && result === 'normal') {
if (
input.advanced ||
@@ -751,7 +770,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
? { homeworldExplosion: homeworld.explosion }
: {}),
basicWinnerLosses,
...(leaderSkillPenalties ? { leaderSkillPenalties } : {}),
...(sukGraduate ? { sukGraduate } : {}),
...(rihani ? { rihani } : {}),
...(sandmaster ? { sandmaster } : {}),
casualties,
played,
discarded,
+20 -3
View File
@@ -1,6 +1,7 @@
import { discoveryBotActions } from './discovery-options';
import type { Card } from './cards';
import { canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat';
import { leaderSkillStrongholdCount } from './leader-skill-battle-board';
import { discoveryFlightBotActions } from './discovery-flight-options';
import { discoveryEntryBotActions } from './discovery-entry-options';
import { discoveryStormActions } from './discovery-storm-options';
@@ -817,6 +818,10 @@ function plans(g: GameView): Action[] {
effects.stunned || (inspected && effects.defenderDead)
? 0
: expectedEnemyStrength;
const opponentPenalty = stoneBattle ? 0 : bureaucratBattlePenalty(
battleSkills, myLeader?.id, !effects.attackerDead,
leaderSkillStrongholdCount(g, other),
);
const ideal = stoneBattle
? 0
: level === 0
@@ -825,7 +830,7 @@ function plans(g: GameView): Action[] {
ownStrength,
Math.max(
0,
enemySurvivingStrength +
enemySurvivingStrength - opponentPenalty +
expectedDial -
ownSurvivingStrength +
enemyNativeBonus -
@@ -932,7 +937,7 @@ function plans(g: GameView): Action[] {
(effectiveDefense ? 2 : 0)
: level === 0
? variation(g, `${leader}-${weapon}-${defense}-${dial}`) * 100
: ownSurvivingStrength * 2 +
: ownSurvivingStrength * 2 + opponentPenalty * 2 +
(inspected && enemySurvivingStrength === 0 ? 10 : 0) +
effectiveWeapon +
effectiveDefense +
@@ -2401,6 +2406,18 @@ function policyActions(g: GameView): Action[] {
})).sort((a, b) => b.score - a.score)
.map(({ choice }) => ({ type: 'decision', event: d.event, choice }));
}
if (d.kind === 'rihani') {
if (d.stage === 'offer') return [{ type: 'decision', event: d.event, draw: true }];
const pending = g.rihani?.pending;
if (!pending || pending.owner !== me.id || pending.event !== d.event || !pending.drawn?.length || !pending.eligible?.length) return [];
const value = (id: string) => {
const leader = g.allLeaders.find((l) => l.id === id);
return (leader?.strength ?? 0) + (leader?.faction === me.faction ? -10 : 10);
};
const drawn = level === 0 ? pending.drawn : [...pending.drawn].sort((a,b) => value(b) - value(a));
const old = level === 0 ? pending.eligible : [...pending.eligible].sort((a,b) => value(a) - value(b));
return [{ type: 'decision', event: d.event, cards: [drawn[0], old[0]] }];
}
if (d.kind === 'battleLosses') {
const choices = d.options
.map((o, choice) => ({
+147
View File
@@ -7,6 +7,8 @@ import {
type BattleLeaderSkill,
} from './leader-skill-combat';
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
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';
@@ -711,6 +713,7 @@ export type Decision =
| { kind: 'poisonTooth'; player: string }
| { kind: 'stoneBurner'; player: string; event: string }
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
| { kind: 'fullPlanOffer'; player: string }
| { kind: 'fullPlanRead'; player: string; target: string }
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
@@ -1255,6 +1258,7 @@ export type Game = {
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;
rihaniHistory?: RihaniReceipt[];
pendingChoamMarketGhola?: ChoamMarketGhola | null;
choamTradeTurn?: number;
inflation?: Inflation | null;
@@ -1469,6 +1473,8 @@ export type Game = {
cardRolesSignature?: string;
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
sukRescue?: { signature: string; completed: boolean };
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean };
sandmaster?: { leader: string; key: string; before: number; after: number };
caladanReinforcement?: HomeworldVictoryObligation;
nexusSardaukarCasualties?: string;
} | null;
@@ -4907,6 +4913,7 @@ function leaderSkillAssignmentUnavailable(
}
function leaderSkillsIntegrity(g: Game) {
sukRescueIntegrity(g);
rihaniIntegrity(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)),
@@ -14054,6 +14061,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
plan,
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
leaderSkills: battleLeaderSkills(g, p),
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
forces: combatForces(g, p, b.territory, opponent),
stronghold: strongholdEffect(g, p.id),
lateDefense: b.lateDefense?.[p.id],
@@ -14095,6 +14103,17 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
!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.');
if (quote.rihani || quote.sandmaster) requireRule(
!g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length &&
g.players.every((p) => faction(p.faction).expansion === 'base'),
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
if (quote.sandmaster) nexusRule(() => sandmasterVictorySpice(b.territory, g.spice));
if (quote.rihani) {
const winner = getPlayer(g, quote.winner!);
nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
{ event: b.event!, turn: g.turn, owner: winner.id, skill: quote.rihani! },
[...new Set([...(winner.revealedTraitors ?? []), ...quote.revelations.filter((r) => r.player === winner.id).map((r) => r.identity)])], () => 0));
}
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);
@@ -14252,6 +14271,9 @@ function resolveBattle(g: Game) {
if (dk) dead(dl);
const av = quote.scores!.attacker,
dv = quote.scores!.defender;
if (!stoneResult && quote.leaderSkillPenalties)
for (const [target, amount] of [[a, quote.leaderSkillPenalties.attacker], [d, quote.leaderSkillPenalties.defender]] as const)
if (amount) log(g, `${target.name}'s battle total fell by ${amount} because the opposing Bureaucrat counted ${amount} occupied strongholds. Advisors do not count; the leader's printed strength and bounty are unchanged.`, { faction: (target === a ? d : a).faction, name: 'Bureaucrat' });
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)
@@ -14376,6 +14398,18 @@ function resolveBattle(g: Game) {
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
};
if (winner && quote.sandmaster) {
const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice));
if (placement) {
g.spice[placement.key] = placement.after;
g.lastBattleContext.sandmaster = { leader: quote.sandmaster, ...placement };
log(g, `${winner.name}'s surviving Sandmaster added 3 spice to the existing pile in ${combatLocationName(g, b.territory)}, sector ${splitLocation(placement.key).sector}. The territory now contains ${placement.after} spice; this is board spice, not income.`, { faction: winner.faction, name: 'Sandmaster' });
}
}
if (winner && quote.rihani) {
g.lastBattleContext.rihani = { skill: quote.rihani, cards: [...quote.winnerCards], completed: false, signature: '' };
g.lastBattleContext.rihani.signature = rihaniObligationSignature(g.lastBattleContext);
}
if (winner && quote.sukGraduate && casualtyCommitment) {
requireRule(!g.pendingSukRescue, 'Finish the preceding Suk Graduate rescue.');
g.pendingSukRescue = {
@@ -14646,7 +14680,117 @@ function winnerDiscardsIntegrity(g: Game) {
[...pending.cards, ...pending.optional].every((id) => typeof id === 'string' && owner.hand.some((card) => card.id === id)),
'The saved mandatory winner discards no longer match their battle or physical cards.');
}
function rihaniObligationSignature(context: NonNullable<Game['lastBattleContext']>) {
return JSON.stringify([context.event, context.turn, context.winner, context.territory,
context.rihani?.skill, context.rihani?.cards, context.rihani?.completed]);
}
function pendingRihani(g: Game) {
return g.rihaniHistory?.find((r) => r.stage !== 'complete') ?? null;
}
function rihaniIntegrity(g: Game) {
const context = g.lastBattleContext, obligation = context?.rihani;
const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'rihani');
const history = g.rihaniHistory ?? [], pending = pendingRihani(g);
if (history.length) {
requireRule(g.leaderSkills && new Set(history.map((r) => r.event)).size === history.length,
'Rihani history requires its original skill module and distinct battles.');
const universe = nexusTraitorUniverse(g);
for (const receipt of history) nexusRule(() => validateRihani(receipt, universe));
}
requireRule(history.filter((r) => r.stage !== 'complete').length <= 1,
'Finish the preceding Rihani exchange.');
if (pending) {
requireRule(obligation && !obligation.completed && context.event === pending.event &&
context.winner === pending.owner && context.turn === pending.turn && g.turn === pending.turn &&
g.status === 'playing' && g.phase === 6 && !g.battle &&
JSON.stringify(obligation.skill) === JSON.stringify(pending.skill) &&
JSON.stringify(getPlayer(g, pending.owner).revealedTraitors ?? []) === JSON.stringify(pending.used) &&
decisions.length === 1 && decisions[0].event === pending.event && decisions[0].player === pending.owner &&
decisions[0].stage === pending.stage, 'The saved Rihani exchange lost its battle or owned choice.');
nexusRule(() => validateRihani(pending, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
} else requireRule(!decisions.length, 'The Rihani choice has lost its physical draw receipt.');
if (!obligation) return;
requireRule(obligation.signature === rihaniObligationSignature(context!), 'The saved Rihani victory obligation changed.');
const receipt = history.find((r) => r.event === context!.event);
if (obligation.completed) {
requireRule(receipt?.stage === 'complete' && receipt.owner === context!.winner &&
receipt.turn === context!.turn && JSON.stringify(receipt.skill) === JSON.stringify(obligation.skill),
'The completed Rihani victory lost its receipt.');
if (g.phase === 6 && context!.turn === g.turn && !g.battle)
nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g)));
} else {
const winner = g.players.find((p) => p.id === context!.winner);
requireRule(winner && g.status === 'playing' && g.phase === 6 && !g.battle && context!.turn === g.turn &&
g.leaderSkills?.assignments.some((a) => a.skill === 'rihani-decipherer' && a.leader === obligation.skill.leader) &&
obligation.cards.every((id) => winner.hand.some((c) => c.id === id)) &&
(receipt ? receipt === pending : !!g.pendingTreacheryDiscard || !!g.pendingSukRescue ||
homeworldSavedDecisions(g).some((d) => d.kind === 'battleLosses' && d.player === winner.id)),
'The Rihani victory lost its skilled leader, cards or preceding casualty settlement.');
}
}
function completeRihaniObligation(g: Game) {
g.lastBattleContext!.rihani!.completed = true;
g.lastBattleContext!.rihani!.signature = rihaniObligationSignature(g.lastBattleContext!);
}
function startRihaniVictory(g: Game, winner: Player, territory: string, cards: string[]): boolean {
const context = g.lastBattleContext, obligation = context?.rihani;
if (!obligation || obligation.completed) return false;
requireRule(context.winner === winner.id && context.territory === territory &&
JSON.stringify(obligation.cards) === JSON.stringify(cards) && !g.rihaniHistory?.some((r) => r.event === context.event),
'Resume the original Rihani victory before winner card cleanup.');
const receipt = nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
{ event: context.event, turn: g.turn, owner: winner.id, skill: obligation.skill }, winner.revealedTraitors ?? [], random));
(g.rihaniHistory ??= []).push(receipt);
applyNexusTraitorSnapshot(g, receipt.state);
if (receipt.peeked.length) log(g, `${winner.name}'s Rihani Decipherer privately inspected two random Traitor Deck cards and shuffled them back. The identities are in that player's private skill history.`, { faction: winner.faction, name: 'Rihani Decipherer' });
if (receipt.stage === 'complete') {
completeRihaniObligation(g);
if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no unused held Traitor Card to exchange, so Rihani offers no draw.`);
return false;
}
g.decision = { kind: 'rihani', event: receipt.event, player: winner.id, stage: 'offer' };
return true;
}
function actRihani(g: Game, player: Player, decision: Extract<Decision, { kind: 'rihani' }>, action: Action) {
const receipt = pendingRihani(g), context = g.lastBattleContext!;
requireRule(receipt?.owner === player.id && receipt.event === action.event && receipt.event === decision.event,
'Choose only the current Rihani exchange.');
let next: RihaniReceipt;
if (decision.stage === 'offer') {
requireRule(typeof action.draw === 'boolean' && Object.keys(action).every((key) => ['type', 'event', 'draw'].includes(key)),
'Decide whether to draw before seeing the two new Traitors.');
next = nexusRule(() => chooseRihaniDraw(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), action.draw as boolean));
} else {
requireRule(Array.isArray(action.cards) && action.cards.length === 2 && action.cards.every((id) => typeof id === 'string') &&
Object.keys(action).every((key) => ['type', 'event', 'cards'].includes(key)), 'Choose one newly drawn and one unused old Traitor.');
const cards = action.cards as string[];
next = nexusRule(() => finishRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), cards[0], cards[1], random));
}
g.rihaniHistory![g.rihaniHistory!.findIndex((r) => r.event === receipt.event)] = next;
applyNexusTraitorSnapshot(g, next.state);
if (next.stage === 'return') {
g.decision = { kind: 'rihani', event: next.event, player: player.id, stage: 'return' };
log(g, `${player.name} drew two private Traitor Cards through Rihani and must keep one by revealing an unused old card.`);
} else {
completeRihaniObligation(g);
if (next.given) {
const name = next.given === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : g.players.flatMap((p) => p.leaders).find((l) => l.id === next.given)?.name;
log(g, `${player.name} revealed the unused ${name ?? next.given} Traitor Card for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`);
} else log(g, `${player.name} declined Rihani's optional Traitor exchange.`);
finishWinner(g, player, context.territory, context.rihani!.cards);
}
}
function projectedRihani(g: Game, owner: string) {
if (!g.leaderSkills) return null;
const pending = pendingRihani(g);
return { history: (g.rihaniHistory ?? []).filter((r) => r.owner === owner).map((r) => ({
event: r.event, turn: r.turn, peeked: [...r.peeked], drawn: [...r.drawn], kept: r.kept, given: r.given,
})), pending: pending ? { event: pending.event, owner: pending.owner, stage: pending.stage,
...(pending.owner === owner && pending.stage === 'return' ? { drawn: [...pending.drawn], eligible: [...pending.eligible] } : {}),
} : null };
}
function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
if (startRihaniVictory(g, winner, t, cards)) return;
if (g.pendingWinnerDiscards) {
winnerDiscardsIntegrity(g);
const pending = g.pendingWinnerDiscards;
@@ -20210,6 +20354,8 @@ function applyActionInner(
'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 === 'rihani') {
actRihani(g, p, decision, action);
} else if (decision.kind === 'battleLosses') {
const choice =
decision.options[
@@ -22446,6 +22592,7 @@ export function viewGame(state: Game, id: string) {
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
setupPending: setupPending(g),
leaderSkills: projectedLeaderSkills(g, id),
rihani: projectedRihani(g, id),
advanced: g.advanced,
dukeVidal: g.dukeVidal
? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) }
+26
View File
@@ -0,0 +1,26 @@
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import { fighterCount } from './advisors';
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import type { ForcePresence } from './force-presence';
import type { FactionId } from './catalog';
/** Occupation is physical public presence, independent of victory control or storm. */
export function leaderSkillStrongholdCount(
board: MobileBoard,
player: ForcePresence & { faction: FactionId; advisors?: Record<string, { lockedTurn?: number }> },
): number {
return gameTerritories(board).filter((t) => t.type === 'stronghold' &&
(!isDiscoveryLocationId(t.id) || t.id === JACURUTU_SIETCH) &&
fighterCount(player, t.id) > 0).length;
}
/** The card adds board spice to an existing pile; it grants no direct faction income. */
export function sandmasterVictorySpice(territory: string, spice: Readonly<Record<string, number>>) {
const piles = Object.entries(spice).filter(([key, amount]) => amount > 0 && splitLocation(key).territory === territory);
if (piles.length > 1) throw new Error('Sandmaster placement among multiple spice piles awaits its sector ruling.');
if (!piles.length) return null;
const [key, before] = piles[0];
if (!Number.isSafeInteger(before) || before < 1 || !Number.isSafeInteger(before + 3))
throw new Error('Sandmaster requires a valid existing spice pile.');
return { key, before, after: before + 3 };
}
+28 -1
View File
@@ -24,7 +24,7 @@ export type BattleLeaderSkill = {
};
export type AppliedLeaderSkill = {
skill: DirectLeaderSkillId | 'planetologist';
skill: DirectLeaderSkillId | 'planetologist' | 'mentat';
amount: 1 | 2 | 3;
mode: 'normal' | 'skilled';
};
@@ -114,6 +114,30 @@ function isDirectSkill(skill: string): skill is DirectLeaderSkillId {
return directSkills.has(skill as LeaderSkillId);
}
/** A captive grants only this actual disc's lower effect, never a normal effect. */
export function usesSurvivingSkilledLeader(
assignments: readonly BattleLeaderSkill[],
skill: LeaderSkillId,
leader: string | undefined | null,
survives: boolean,
): boolean {
return survives && !!leader && assignments.some((a) =>
a.skill === skill && a.leader === leader && (!a.faceUp || a.captured));
}
/** Bureaucrat changes the opponent's total, not either leader disc or bounty. */
export function bureaucratBattlePenalty(
assignments: readonly BattleLeaderSkill[],
leader: string | undefined | null,
survives: boolean,
opponentStrongholds: number | undefined,
): number {
if (!usesSurvivingSkilledLeader(assignments, 'bureaucrat', leader, survives)) return 0;
if (opponentStrongholds === undefined || !Number.isSafeInteger(opponentStrongholds) || opponentStrongholds < 0)
throw new Error('Bureaucrat needs a valid occupied stronghold count.');
return opponentStrongholds;
}
function qualifies(
skill: DirectLeaderSkillId,
weapon: Card | undefined,
@@ -144,6 +168,9 @@ export function leaderSkillBattleBonus(input: {
}): LeaderSkillBattleBonus {
if (!input.selectedLeader) return { bonus: 0, applied: [] };
const applied: AppliedLeaderSkill[] = [];
if (input.selectedLeader.kind === 'disc' && usesSurvivingSkilledLeader(
input.assignments, 'mentat', input.selectedLeader.id, input.skilledLeaderSurvives,
)) applied.push({ skill: 'mentat', amount: 2, mode: 'skilled' });
if (
input.selectedLeader.kind === 'disc' &&
input.skilledLeaderSurvives &&
+10 -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, Planetologist movement/battle effects and Suk Graduate rescues 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, Mentat/Bureaucrat scoring, Sandmaster victory spice and Rihani exchanges 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.',
@@ -3572,16 +3572,19 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'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.',
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Normal Mentat questioning, Bureaucrat payments and Sandmaster movement collection, plus all Spice Banker, Diplomat and Smuggler effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['setup','choam-modules','implementation-checklist'],
checklist: [
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. 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'] },
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Their other bands, Spice Banker, Diplomat, Smuggler and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.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, Rihani exchanges and shared battle scoring. 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','docs/LEADER_BATTLE_EFFECTS.md'] },
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery and battle-effect regressions 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','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx'] },
],
},
...[
+1
View File
@@ -7,6 +7,7 @@ const DECISIONS = {
leaderSkillVisibility: true,
leaderSkillRevival: true,
sukRescue: true,
rihani: true,
choamAudit: true,
choamAuditPayment: true,
strongholdCopy: true,
+133
View File
@@ -0,0 +1,133 @@
import type { BattleLeaderSkill } from './leader-skill-combat';
import { usesSurvivingSkilledLeader } from './leader-skill-combat';
import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
export type RihaniSkill = { leader: string; normal: boolean; skilled: boolean };
export type RihaniReceipt = {
event: string; turn: number; owner: string; skill: RihaniSkill;
stage: 'offer' | 'return' | 'complete';
before: NexusTraitorSnapshot; state: NexusTraitorSnapshot;
deckAfterPeek: string[];
peeked: string[]; drawn: string[]; eligible: string[]; used: string[];
kept: string | null; given: string | null;
signature: string;
};
function requireRihani(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const members = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && same([...a].sort(), [...b].sort());
function shuffled<T>(values: readonly T[], rng: () => number): T[] {
const result = [...values];
for (let i = result.length - 1; i > 0; i--) {
const value = rng();
requireRihani(value >= 0 && value < 1, 'Invalid Traitor Deck shuffle.');
const j = Math.floor(value * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
export function rihaniSignature(receipt: RihaniReceipt): string {
const { signature: _signature, ...body } = receipt;
return JSON.stringify(body);
}
function signed(receipt: RihaniReceipt): RihaniReceipt {
receipt.signature = rihaniSignature(receipt);
return receipt;
}
export function rihaniVictorySkill(assignments: readonly BattleLeaderSkill[], leader: string | undefined, survives: boolean): RihaniSkill | null {
const a = assignments.find((skill) => skill.skill === 'rihani-decipherer');
if (!a) return null;
const skilled = usesSurvivingSkilledLeader(assignments, 'rihani-decipherer', leader, survives);
const normal = !a.captured && (a.faceUp || skilled) && (a.leader !== leader || survives);
return normal || skilled ? { leader: a.leader, normal, skilled } : null;
}
/** Complete the compulsory private peek before offering the separately optional draw. */
export function beginRihani(snapshot: NexusTraitorSnapshot, universe: readonly string[],
identity: Pick<RihaniReceipt, 'event' | 'turn' | 'owner' | 'skill'>, used: readonly string[], rng: () => number): RihaniReceipt {
validateNexusTraitorSnapshot(snapshot, universe);
const owner = snapshot.players.find((p) => p.id === identity.owner);
requireRihani(owner && owner.faction !== 'tleilaxu', 'Rihani Face Dancer exchanges are still being integrated.');
const eligible = owner.traitors.filter((id) => !used.includes(id));
const optional = identity.skill.skilled && eligible.length > 0;
requireRihani(!(identity.skill.normal || optional) || snapshot.reserve.length >= 2,
'Rihani requires two physical cards in the Traitor Deck.');
const state = structuredClone(snapshot);
const peeked = identity.skill.normal ? shuffled(snapshot.reserve, rng).slice(0, 2) : [];
if (identity.skill.normal) state.reserve = shuffled(state.reserve, rng);
return signed({ ...identity, stage: optional ? 'offer' : 'complete', before: structuredClone(snapshot), state,
deckAfterPeek: [...state.reserve], peeked, eligible, used: [...used], drawn: [], kept: null, given: null, signature: '' });
}
/** Validate recorded physical transitions, then bind a pending choice to current custody. */
export function validateRihani(receipt: RihaniReceipt, universe: readonly string[], current?: NexusTraitorSnapshot) {
requireRihani(receipt.signature === rihaniSignature(receipt) &&
['offer', 'return', 'complete'].includes(receipt.stage) &&
Number.isSafeInteger(receipt.turn) && receipt.turn > 0 && receipt.event &&
typeof receipt.skill.normal === 'boolean' && typeof receipt.skill.skilled === 'boolean' &&
(receipt.skill.normal || receipt.skill.skilled), 'The saved Rihani receipt is invalid.');
validateNexusTraitorSnapshot(receipt.before, universe);
validateNexusTraitorSnapshot(receipt.state, universe);
const old = receipt.before.players.find((p) => p.id === receipt.owner);
const owner = receipt.state.players.find((p) => p.id === receipt.owner);
requireRihani(old && owner && old.faction !== 'tleilaxu' && owner.faction === old.faction &&
same(receipt.before.players.filter((p) => p.id !== receipt.owner), receipt.state.players.filter((p) => p.id !== receipt.owner)),
'Rihani changed another player’s physical Traitors.');
requireRihani(Array.isArray(receipt.used) && new Set(receipt.used).size === receipt.used.length &&
members(receipt.deckAfterPeek, receipt.before.reserve) &&
(receipt.skill.normal || same(receipt.deckAfterPeek, receipt.before.reserve)) &&
same(receipt.eligible, old.traitors.filter((id) => !receipt.used.includes(id))) &&
receipt.peeked.length === (receipt.skill.normal ? 2 : 0) &&
new Set(receipt.peeked).size === receipt.peeked.length &&
receipt.peeked.every((id) => receipt.before.reserve.includes(id)), 'Rihani lost its original inspection or unused cards.');
requireRihani((receipt.drawn.length === 0 || receipt.drawn.length === 2) &&
new Set(receipt.drawn).size === receipt.drawn.length && receipt.drawn.every((id) => receipt.before.reserve.includes(id)),
'Rihani must draw two distinct physical cards.');
if (!receipt.drawn.length) {
requireRihani(receipt.stage !== 'return' && !receipt.kept && !receipt.given && same(owner.traitors, old.traitors) &&
same(receipt.state.reserve, receipt.deckAfterPeek), 'The undrawn Rihani offer changed hand custody.');
} else {
requireRihani(receipt.skill.skilled && receipt.eligible.length > 0, 'This Rihani cannot exchange an unused card.');
requireRihani(same(receipt.drawn, receipt.deckAfterPeek.slice(0, 2)), 'Rihani did not draw from the saved deck order.');
const remaining = receipt.deckAfterPeek.slice(2);
if (receipt.stage === 'return') requireRihani(!receipt.kept && !receipt.given &&
same(owner.traitors, [...old.traitors, ...receipt.drawn]) && same(receipt.state.reserve, remaining),
'The pending Rihani return lost its drawn cards.');
else requireRihani(receipt.stage === 'complete' && receipt.kept && receipt.drawn.includes(receipt.kept) &&
receipt.given && receipt.eligible.includes(receipt.given) &&
members(owner.traitors, [...old.traitors.filter((id) => id !== receipt.given), receipt.kept]) &&
members(receipt.state.reserve, [...remaining, receipt.given, ...receipt.drawn.filter((id) => id !== receipt.kept)]),
'The completed Rihani exchange lost its exact new or returned card.');
}
if (receipt.stage === 'offer') requireRihani(receipt.skill.skilled && receipt.eligible.length > 0 && !receipt.drawn.length,
'The Rihani draw offer is no longer available.');
if (current) requireRihani(same(current, receipt.state), 'The Rihani choice no longer matches current Traitor custody.');
}
export function chooseRihaniDraw(receipt: RihaniReceipt, universe: readonly string[], current: NexusTraitorSnapshot, draw: boolean): RihaniReceipt {
validateRihani(receipt, universe, current);
requireRihani(receipt.stage === 'offer', 'This Rihani draw has already been answered.');
const next = structuredClone(receipt);
if (!draw) next.stage = 'complete';
else {
requireRihani(next.state.reserve.length >= 2, 'Rihani requires two physical cards in the Traitor Deck.');
next.drawn = next.state.reserve.splice(0, 2);
next.state.players.find((p) => p.id === next.owner)!.traitors.push(...next.drawn);
next.stage = 'return';
}
signed(next); validateRihani(next, universe); return next;
}
export function finishRihani(receipt: RihaniReceipt, universe: readonly string[], current: NexusTraitorSnapshot,
kept: string, given: string, rng: () => number): RihaniReceipt {
validateRihani(receipt, universe, current);
requireRihani(receipt.stage === 'return' && receipt.drawn.includes(kept) && receipt.eligible.includes(given),
'Keep one newly drawn Traitor and reveal one unused Traitor held before the draw.');
const next = structuredClone(receipt), other = next.drawn.find((id) => id !== kept)!;
const owner = next.state.players.find((p) => p.id === next.owner)!;
owner.traitors = owner.traitors.filter((id) => id !== given && id !== other);
next.state.reserve = shuffled([...next.state.reserve, given, other], rng);
next.kept = kept; next.given = given; next.stage = 'complete';
signed(next); validateRihani(next, universe); return next;
}