Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/sim/Dynasty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,29 @@ export function applyForeignClaimantDiplomacy(victim: Empire, backer: Empire, ti
addRelationModifier(relBetween(victim, backer.id), { ...mod });
}

// ── Dynasty legitimacy ────────────────────────────────────────────────────────────

const LEGITIMACY_POSITIVE: ReadonlySet<string> = new Set(["heir-born", "succession", "dynasty-restored", "dynastic-marriage"]);
const LEGITIMACY_NEGATIVE: ReadonlySet<string> = 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;
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions src/sim/Headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -110,9 +110,11 @@ function snapshotMetrics(state: GalaxyState, originalIds: Set<Id>) {
const shipsByRole: Record<string, number> = {};
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,
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 23 additions & 14 deletions src/ui/InspectorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -155,19 +155,28 @@ function LineageSection({ snapshot, emp }: { snapshot: Readonly<GalaxyState>; em
</span>
</div>
)}
{dynasty && dynasty.historicalEventIds.length > 0 && (
<>
<h4 style={{ marginTop: 6, marginBottom: 3, opacity: 0.85 }}>Dynasty Chronicle</h4>
{[...dynasty.historicalEventIds].reverse().slice(0, 5).map(eid => {
const ev = snapshot.events[eid];
return ev ? (
<div key={eid} className="event-mini" style={{ borderLeft: `3px solid ${dynastyEventColor(ev.type)}`, paddingLeft: 5, marginBottom: 2 }}>
<span style={{ opacity: 0.55, fontSize: 9 }}>t{ev.tick} </span>{ev.title}
</div>
) : 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 (
<>
<div className="info-row">
<span>Legitimacy</span>
<span style={{ color: legitColor, fontWeight: 600 }}>{legitLabel}</span>
</div>
<h4 style={{ marginTop: 6, marginBottom: 3, opacity: 0.85 }}>Dynasty Chronicle</h4>
{[...dynasty.historicalEventIds].reverse().slice(0, 5).map(eid => {
const ev = snapshot.events[eid];
return ev ? (
<div key={eid} className="event-mini" style={{ borderLeft: `3px solid ${dynastyEventColor(ev.type)}`, paddingLeft: 5, marginBottom: 2 }}>
<span style={{ opacity: 0.55, fontSize: 9 }}>t{ev.tick} </span>{ev.title}
</div>
) : null;
})}
</>
);
})()}
</>
);
}
Expand Down
Loading