From 1b03cbd0fff3c9965053e24290720683b914e1b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 03:28:50 +0000 Subject: [PATCH] feat(dynasty): add legitimacy signal derived from chronicle events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dynastyLegitimacy(state, dynastyId) — a [-1, 1] score computed from the dynasty's recent historicalEventIds. Positive events (heir-born, succession, dynasty-restored, dynastic-marriage) push toward +1; negative events (heir-died, succession-crisis, pretender-revolt, dynasty-extinct) push toward -1. The score lightly shifts the pretender revolt cohesion threshold by ±0.10: - High legitimacy (+1) requires cohesion ≤ 0.45 before revolt can fire - Low legitimacy (−1) allows revolt when cohesion ≤ 0.65 The inspector now shows "Legitimacy: Stable / Contested / Fragile" above the Dynasty Chronicle when chronicle events exist. The headless report shows the top dynasty's legitimacy score alongside its prestige. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RPPpxsKfPvjTpLdZLBLC6V --- src/sim/Dynasty.ts | 31 +++++++++++++++++++++++++++++-- src/sim/Headless.ts | 6 ++++-- src/ui/InspectorPanel.tsx | 37 +++++++++++++++++++++++-------------- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/sim/Dynasty.ts b/src/sim/Dynasty.ts index c408bf4..3008965 100644 --- a/src/sim/Dynasty.ts +++ b/src/sim/Dynasty.ts @@ -599,6 +599,29 @@ export function applyForeignClaimantDiplomacy(victim: Empire, backer: Empire, ti addRelationModifier(relBetween(victim, backer.id), { ...mod }); } +// ── Dynasty legitimacy ──────────────────────────────────────────────────────────── + +const LEGITIMACY_POSITIVE: ReadonlySet = new Set(["heir-born", "succession", "dynasty-restored", "dynastic-marriage"]); +const LEGITIMACY_NEGATIVE: ReadonlySet = new Set(["heir-died", "succession-crisis", "pretender-revolt", "dynasty-extinct"]); + +/** Derive a [-1, 1] legitimacy score from recent dynasty chronicle events. + * Positive events (smooth successions, heir births, marriages) push toward +1. + * Negative events (crises, revolts, heir deaths) push toward -1. + * Returns 0 for dynasties with no chronicle yet. */ +export function dynastyLegitimacy(state: GalaxyState, dynastyId: Id | undefined): number { + if (!dynastyId || !state.dynasties || !state.events) return 0; + const dyn = state.dynasties[dynastyId]; + if (!dyn || dyn.historicalEventIds.length === 0) return 0; + let score = 0; + for (const eid of dyn.historicalEventIds) { + const ev = state.events[eid]; + if (!ev) continue; + if (LEGITIMACY_POSITIVE.has(ev.type)) score++; + else if (LEGITIMACY_NEGATIVE.has(ev.type)) score--; + } + return Math.max(-1, Math.min(1, score / dyn.historicalEventIds.length)); +} + // ── Dynasty chronicle helpers ───────────────────────────────────────────────────── const DYNASTY_HISTORY_LIMIT = 12; @@ -796,8 +819,12 @@ function heirDies(state: GalaxyState, emp: Empire, rng: PRNG): void { } function maybePretenderRevolt(state: GalaxyState, emp: Empire, rng: PRNG): void { - // Only shaky realms breed open revolt. - if (emp.cohesion > 0.55) return; + // Legitimacy shifts the revolt threshold: a dynasty with a strong positive chronicle + // requires deeper instability before a pretender dares rise (threshold falls to 0.45), + // while a dynasty with a troubled recent history is vulnerable even at higher cohesion (up to 0.65). + const legit = dynastyLegitimacy(state, emp.dynastyId); + const revoltThreshold = 0.55 - legit * 0.10; + if (emp.cohesion > revoltThreshold) return; const { person, reason } = findOrMakePretender(state, emp, rng); const cap = state.systems[emp.capitalSystemId]; emp.cohesion = Math.max(0.1, emp.cohesion - 0.05); diff --git a/src/sim/Headless.ts b/src/sim/Headless.ts index a4370b7..ceed029 100644 --- a/src/sim/Headless.ts +++ b/src/sim/Headless.ts @@ -3,7 +3,7 @@ import { SeededRandom } from "./Random"; import { generateGalaxy } from "./Galaxy"; import { executeTick } from "./Tick"; import { ensureArtifactObjects } from "./Artifacts"; -import { lineageChain } from "./Dynasty"; +import { lineageChain, dynastyLegitimacy } from "./Dynasty"; import { summarizeGrievances } from "./Relations"; export interface Tally { @@ -110,9 +110,11 @@ function snapshotMetrics(state: GalaxyState, originalIds: Set) { const shipsByRole: Record = {}; for (const f of Object.values(state.fleets)) if (f.role) shipsByRole[f.role] = (shipsByRole[f.role] ?? 0) + 1; const topDynasty = Object.values(dynasties).filter(d => d.extinctTick === undefined).sort((a, b) => b.prestige - a.prestige)[0] ?? null; + const topDynastyLegit = topDynasty ? dynastyLegitimacy(state, topDynasty.id) : 0; const grievances = summarizeGrievances(state); return { grievances, + topDynastyLegit, shipsByRole, topDynasty, factionsActive: factionList.length, @@ -204,7 +206,7 @@ export function runHeadlessStats(settings: SimSettings, milestones: number[] = [ `- Subjects: ${m.subjects} active${m.subjects ? ` (${Object.entries(m.subjectsByStatus).map(([k, n]) => `${n} ${k}`).join(", ")})` : ""} · ${cumulative.subjectsCreated} created · ${cumulative.subjectRebellions} rebellions · ${cumulative.subjectIntegrations} integrations · ${cumulative.subjectLiberations} liberations`, `- Quests: ${cumulative.questsLaunched} launched · ${cumulative.questsCompleted} completed`, `- Specialist ships: ${Object.keys(m.shipsByRole).length ? Object.entries(m.shipsByRole).map(([r, n]) => `${n} ${r}`).join(", ") : "none"} active`, - `- Careers: ${cumulative.promotions} promotions · ${cumulative.falls} falls · top house: ${m.topDynasty ? `${m.topDynasty.name} (prestige ${m.topDynasty.prestige.toFixed(0)})` : "none"}`, + `- Careers: ${cumulative.promotions} promotions · ${cumulative.falls} falls · top house: ${m.topDynasty ? `${m.topDynasty.name} (prestige ${m.topDynasty.prestige.toFixed(0)}, legitimacy ${m.topDynastyLegit >= 0 ? "+" : ""}${m.topDynastyLegit.toFixed(2)})` : "none"}`, ``, ); lastFounded = cumulative.founded; diff --git a/src/ui/InspectorPanel.tsx b/src/ui/InspectorPanel.tsx index 7be1b1b..8d6729a 100644 --- a/src/ui/InspectorPanel.tsx +++ b/src/ui/InspectorPanel.tsx @@ -3,7 +3,7 @@ import type { ArtifactKind, GalaxyState, Id, Empire, Person, EmpireAdjustablePro import { SHIP_ROLES, SHIP_ROLE_SPEC } from "../sim/ShipRoles"; import { MOOD_LABEL, MOOD_COLOR, IDEOLOGY_LABEL, IDEOLOGY_COLOR, rulerDisplayName } from "../sim/Moods"; import { ROLE_LABEL, TRAIT_LABEL } from "../sim/Characters"; -import { lineageChain, livingDynastyCount, dynastyMembers, personDisplayName } from "../sim/Dynasty"; +import { lineageChain, livingDynastyCount, dynastyMembers, personDisplayName, dynastyLegitimacy } from "../sim/Dynasty"; import { GOVERNMENT_LABEL, PLANET_TAG_LABEL } from "../sim/Galaxy"; import { ARTIFACT_LABEL } from "../sim/Artifacts"; import { SUBJECT_STATUS_LABEL, subjectOf, subjectsOf } from "../sim/Subjects"; @@ -155,19 +155,28 @@ function LineageSection({ snapshot, emp }: { snapshot: Readonly; em )} - {dynasty && dynasty.historicalEventIds.length > 0 && ( - <> -

Dynasty Chronicle

- {[...dynasty.historicalEventIds].reverse().slice(0, 5).map(eid => { - const ev = snapshot.events[eid]; - return ev ? ( -
- t{ev.tick} {ev.title} -
- ) : null; - })} - - )} + {dynasty && dynasty.historicalEventIds.length > 0 && (() => { + const legit = dynastyLegitimacy(snapshot, emp.dynastyId); + const legitLabel = legit > 0.3 ? "Stable" : legit > -0.3 ? "Contested" : "Fragile"; + const legitColor = legit > 0.3 ? "rgba(120,220,140,0.9)" : legit > -0.3 ? "rgba(220,200,100,0.9)" : "rgba(220,80,60,0.9)"; + return ( + <> +
+ Legitimacy + {legitLabel} +
+

Dynasty Chronicle

+ {[...dynasty.historicalEventIds].reverse().slice(0, 5).map(eid => { + const ev = snapshot.events[eid]; + return ev ? ( +
+ t{ev.tick} {ev.title} +
+ ) : null; + })} + + ); + })()} ); }