feat(spectator): focus camera on a five-character story ensemble
- Prioritize MCs, related cast, meaningful disasters, and exceptional events - Show MC connections in the dossier when following related characters - Promote rare creatures and recurring antagonists through bounded scoring - Reduce roster churn and repetitive love/combat coverage - Add performance telemetry, soak auditing, documentation, and harness coverage
This commit is contained in:
parent
4becf1c5dc
commit
188d091166
16 changed files with 2107 additions and 59 deletions
|
|
@ -3856,6 +3856,36 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "saga_consider_admit_focus":
|
||||
{
|
||||
Actor actor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
bool ok = id != 0 && LifeSagaRoster.ConsiderAdmit(id, heat: 0f);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail: $"consider id={id} asset={actor?.asset?.id} roster={LifeSagaRoster.Count} on={LifeSagaRoster.IsMc(id)}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_consider_antagonist_partner":
|
||||
{
|
||||
Actor partner = _happinessPartner;
|
||||
long id = EventFeedUtil.SafeId(partner);
|
||||
bool eligible = LifeSagaRoster.TryGetRecurringAntagonist(
|
||||
partner,
|
||||
out long anchor,
|
||||
out LifeSagaFact evidence);
|
||||
bool ok = eligible && LifeSagaRoster.ConsiderAdmit(id, heat: 0f);
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail: $"antagonist={id} eligible={eligible} anchor={anchor} evidence={evidence?.Note} on={LifeSagaRoster.IsMc(id)}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_note_encounters":
|
||||
{
|
||||
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -4452,6 +4482,38 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "saga_force_admit_six":
|
||||
{
|
||||
int admitted = 0;
|
||||
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||
{
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LifeSagaRoster.HarnessForceAdmit(actor))
|
||||
{
|
||||
admitted++;
|
||||
}
|
||||
|
||||
if (admitted >= 6)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool ok = admitted >= 6 && LifeSagaRoster.Count == LifeSagaRoster.Cap;
|
||||
if (ok) _cmdOk++; else _cmdFail++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok,
|
||||
detail: "admitted=" + admitted
|
||||
+ " roster=" + LifeSagaRoster.Count
|
||||
+ " cap=" + LifeSagaRoster.Cap);
|
||||
break;
|
||||
}
|
||||
|
||||
case "hitch_probe":
|
||||
{
|
||||
_preserveSagaAfterBatch = true;
|
||||
|
|
@ -6735,10 +6797,18 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
Actor follow = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
||||
string requestedAsset =
|
||||
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset;
|
||||
Actor focused = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
Actor follow = focused != null
|
||||
&& focused.isAlive()
|
||||
&& (requestedAsset == null
|
||||
|| (focused.asset != null && focused.asset.id == requestedAsset))
|
||||
? focused
|
||||
: ResolveUnit(requestedAsset);
|
||||
if (follow == null)
|
||||
{
|
||||
follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
follow = focused;
|
||||
}
|
||||
|
||||
if (follow == null)
|
||||
|
|
@ -10026,6 +10096,18 @@ public static class AgentHarness
|
|||
detail = $"rival focus={SafeName(focus)} have={have} want={want} note={rival?.Note}";
|
||||
break;
|
||||
}
|
||||
case "saga_antagonist_partner":
|
||||
{
|
||||
Actor partner = _happinessPartner;
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = LifeSagaRoster.TryGetRecurringAntagonist(
|
||||
partner,
|
||||
out long anchor,
|
||||
out LifeSagaFact evidence);
|
||||
pass = have == want;
|
||||
detail = $"antagonist partner={SafeName(partner)} have={have} want={want} anchor={anchor} evidence={evidence?.Note}";
|
||||
break;
|
||||
}
|
||||
case "saga_crossover_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -10309,6 +10391,35 @@ public static class AgentHarness
|
|||
detail = $"context='{ctx}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
|
||||
case "caption_connection_present":
|
||||
{
|
||||
string connection = WatchCaption.LastConnectionLine ?? "";
|
||||
pass = !string.IsNullOrEmpty(connection);
|
||||
detail = "connection='" + connection + "' pass=" + pass;
|
||||
break;
|
||||
}
|
||||
|
||||
case "caption_connection_anchor":
|
||||
{
|
||||
string connection = WatchCaption.LastConnectionLine ?? "";
|
||||
CameraEligibilityModel eligibility =
|
||||
CameraEligibility.Classify(InterestDirector.CurrentCandidate);
|
||||
pass = !string.IsNullOrEmpty(connection)
|
||||
&& eligibility.AnchorMcId != 0
|
||||
&& connection == (eligibility.ConnectionLine ?? "");
|
||||
detail = "connection='" + connection
|
||||
+ "' anchor=" + eligibility.AnchorMcId
|
||||
+ " kind=" + eligibility.ConnectionKind
|
||||
+ " expected='" + eligibility.ConnectionLine
|
||||
+ "' pass=" + pass;
|
||||
break;
|
||||
}
|
||||
case "camera_exceptional_policy":
|
||||
{
|
||||
pass = CameraEligibility.HarnessProbeExceptionalPolicy(out detail);
|
||||
break;
|
||||
}
|
||||
case "caption_context_omits":
|
||||
{
|
||||
// Soft tip hitch: Context must not show banned biography fragments.
|
||||
|
|
@ -10855,6 +10966,52 @@ public static class AgentHarness
|
|||
$"admission={slot?.AdmissionReason.ToString() ?? "missing"} id={slot?.UnitId ?? 0}";
|
||||
break;
|
||||
}
|
||||
case "saga_focus_admission":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
LifeSagaSlot slot = LifeSagaRoster.Get(EventFeedUtil.SafeId(focus));
|
||||
string any = (cmd.value ?? "").Trim();
|
||||
string have = slot?.AdmissionReason.ToString() ?? "missing";
|
||||
pass = slot != null;
|
||||
if (pass && !string.IsNullOrEmpty(any))
|
||||
{
|
||||
pass = false;
|
||||
string[] parts = any.Split('|');
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (string.Equals(
|
||||
have,
|
||||
(parts[i] ?? "").Trim(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
pass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"focus={SafeName(focus)} admission={have} pop={slot?.SpeciesPopulation ?? 0} rare={slot?.IsRareCreature ?? false} special={slot?.IsSpecialCreature ?? false}";
|
||||
break;
|
||||
}
|
||||
case "saga_focus_admission_eligible":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = LifeSagaRoster.HarnessIsAdmissionCandidate(focus);
|
||||
pass = have == want;
|
||||
detail = $"focus={SafeName(focus)} asset={focus?.asset?.id} eligible={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "stability_budgets":
|
||||
{
|
||||
pass = InterestVariety.HarnessProbeStabilityBudgets(out detail);
|
||||
break;
|
||||
}
|
||||
case "saga_replacement_evidence":
|
||||
{
|
||||
pass = LifeSagaRoster.HarnessProbeReplacementEvidence(out detail);
|
||||
break;
|
||||
}
|
||||
case "saga_role_tip":
|
||||
{
|
||||
string have = SampleRailTip();
|
||||
|
|
@ -14491,7 +14648,7 @@ public static class AgentHarness
|
|||
|
||||
var board = new List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||
LifeSagaRoster.CopySlots(board);
|
||||
var names = new StringBuilder(160);
|
||||
var names = new StringBuilder(320);
|
||||
for (int i = 0; i < board.Count; i++)
|
||||
{
|
||||
LifeSagaSlot s = board[i];
|
||||
|
|
@ -14516,7 +14673,24 @@ public static class AgentHarness
|
|||
n += "^";
|
||||
}
|
||||
|
||||
names.Append(n);
|
||||
names.Append(n)
|
||||
.Append("{reason=")
|
||||
.Append(s.AdmissionReason)
|
||||
.Append(" pop=")
|
||||
.Append(s.SpeciesPopulation)
|
||||
.Append(" rare=")
|
||||
.Append(s.IsRareCreature)
|
||||
.Append(" special=")
|
||||
.Append(s.IsSpecialCreature)
|
||||
.Append(" antagonist=")
|
||||
.Append(s.IsRecurringAntagonist)
|
||||
.Append(" anchor=")
|
||||
.Append(s.AntagonistAnchorMcId)
|
||||
.Append(" evidence=")
|
||||
.Append(s.AntagonistEvidence)
|
||||
.Append(" score=")
|
||||
.Append(LifeSagaRoster.EffectiveInterest(s, now).ToString("0.##"))
|
||||
.Append('}');
|
||||
}
|
||||
|
||||
string tipLabel = tip?.Label ?? CameraDirector.LastWatchLabel ?? "";
|
||||
|
|
@ -14526,7 +14700,8 @@ public static class AgentHarness
|
|||
+ $"softRank={softRank} touchedMcs={touchedMcs} "
|
||||
+ $"rival={rival} crossoverFacts={crossovers} facts={facts} lives={LifeSagaMemory.LifeCount} "
|
||||
+ $"roster={LifeSagaRoster.Count} tip={tipLabel} "
|
||||
+ $"score={InterestDirector.CurrentScoreLabel} names=[{names}]";
|
||||
+ $"score={InterestDirector.CurrentScoreLabel} names=[{names}] "
|
||||
+ $"lastAdmission=[{LifeSagaRoster.LastAdmissionDiagnostic}]";
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: detail);
|
||||
|
|
|
|||
|
|
@ -215,6 +215,10 @@ internal static class HarnessScenarios
|
|||
return SagaCameraPrefersMc();
|
||||
case "saga_roster_diversity":
|
||||
return SagaRosterDiversity();
|
||||
case "saga_roster_cap_five":
|
||||
return SagaRosterCapFive();
|
||||
case "saga_connection_dossier":
|
||||
return SagaConnectionDossier();
|
||||
case "saga_camera_prefers_mc":
|
||||
return SagaCameraPrefersMc();
|
||||
case "saga_replace_on_death":
|
||||
|
|
@ -1456,6 +1460,9 @@ internal static class HarnessScenarios
|
|||
Step("cf8", "pick_unit", asset: "human"),
|
||||
Step("cf9", "focus", asset: "human"),
|
||||
Step("cf10", "interest_end_session"),
|
||||
// Main-camera policy requires combat to touch an MC or exact MC relation.
|
||||
Step("cf10a", "interest_saga_clear"),
|
||||
Step("cf10b", "saga_force_admit_focus"),
|
||||
|
||||
// Combat scene: human fighting wolf. Clear follow → related (wolf) must own tip+focus.
|
||||
// expect suffix no_attack: ownership assert must not race a fast-timing kill mid-wait.
|
||||
|
|
@ -1513,6 +1520,8 @@ internal static class HarnessScenarios
|
|||
Step("cf39b", "spawn", asset: "human", count: 1),
|
||||
Step("cf39c", "spawn", asset: "wolf", count: 1),
|
||||
Step("cf39d", "pick_unit", asset: "human"),
|
||||
Step("cf39e", "focus", asset: "human"),
|
||||
Step("cf39f", "saga_force_admit_focus"),
|
||||
Step("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
|
||||
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
||||
|
|
@ -1537,6 +1546,8 @@ internal static class HarnessScenarios
|
|||
Step("cf48b", "spawn", asset: "evil_mage", count: 1),
|
||||
Step("cf48c", "spawn", asset: "human", count: 1),
|
||||
Step("cf48d", "pick_unit", asset: "evil_mage"),
|
||||
Step("cf48d2", "focus", asset: "evil_mage"),
|
||||
Step("cf48d3", "saga_consider_admit_focus"),
|
||||
Step("cf48e", "interest_combat_session", asset: "evil_mage", value: "human", expect: "cf_mage"),
|
||||
// Soak shape: 1 spectacle vs a massed civ side (not a tiny 1v8 skirmish).
|
||||
Step("cf48f", "combat_ensemble_escalate", value: "24"),
|
||||
|
|
@ -1565,6 +1576,8 @@ internal static class HarnessScenarios
|
|||
Step("cf50b", "spawn", asset: "human", count: 1),
|
||||
Step("cf50c", "spawn", asset: "wolf", count: 1),
|
||||
Step("cf50d", "pick_unit", asset: "human"),
|
||||
Step("cf50e", "focus", asset: "human"),
|
||||
Step("cf50f", "saga_force_admit_focus"),
|
||||
Step("cf51", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_natural"),
|
||||
Step("cf51b", "combat_isolate_pair", value: "20"),
|
||||
Step("cf52", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||
|
|
@ -3303,6 +3316,9 @@ internal static class HarnessScenarios
|
|||
Step("ncc3", "focus", asset: "human"),
|
||||
Step("ncc4", "narrative_camera_cutover_probe"),
|
||||
Step("ncc5", "assert", expect: "no_bad"),
|
||||
Step("ncc6", "assert", expect: "camera_exceptional_policy"),
|
||||
Step("ncc7", "assert", expect: "stability_budgets"),
|
||||
Step("ncc8", "assert", expect: "saga_replacement_evidence"),
|
||||
Step("ncc99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
|
@ -3531,6 +3547,49 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> SagaConnectionDossier()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("scd0", "dismiss_windows"),
|
||||
Step("scd1", "wait_world"),
|
||||
Step("scd2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("scd3", "fast_timing", value: "true"),
|
||||
Step("scd4", "spawn", asset: "human", count: 2),
|
||||
Step("scd5", "spectator", value: "off"),
|
||||
Step("scd6", "spectator", value: "on"),
|
||||
Step("scd7", "pick_unit", asset: "human"),
|
||||
Step("scd8", "focus", asset: "human"),
|
||||
Step("scd9", "interest_saga_clear"),
|
||||
Step("scd10", "saga_force_admit_focus"),
|
||||
Step("scd11", "happiness_remember_partner"),
|
||||
Step("scd12", "interest_expire_pending", value: ""),
|
||||
Step("scd13", "interest_inject", asset: "human", label: "{a} faces a turning point",
|
||||
tier: "Epic", expect: "scd_related",
|
||||
value: "lead=event;evt=90;char=20;force=false;ttl=60;pick=other;related=partner"),
|
||||
Step("scd14", "director_run", wait: 1.2f),
|
||||
Step("scd15", "assert", expect: "caption_connection_present"),
|
||||
Step("scd16", "assert", expect: "caption_connection_anchor", value: "partner"),
|
||||
Step("scd17", "screenshot", value: "saga-connection-dossier.png"),
|
||||
Step("scd90", "fast_timing", value: "false"),
|
||||
Step("scd99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> SagaRosterCapFive()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("src0", "dismiss_windows"),
|
||||
Step("src1", "wait_world"),
|
||||
Step("src2", "interest_saga_clear"),
|
||||
Step("src3", "spawn", asset: "human", count: 6),
|
||||
Step("src4", "saga_force_admit_six"),
|
||||
Step("src5", "assert", expect: "saga_roster_count", value: "5"),
|
||||
Step("src99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Prefer toggle soft-biases a near-tie toward the Prefer'd MC.</summary>
|
||||
private static List<HarnessCommand> SagaPreferSoftBias()
|
||||
{
|
||||
|
|
@ -3767,6 +3826,7 @@ internal static class HarnessScenarios
|
|||
Step("srk11", "saga_force_admit_focus"),
|
||||
Step("srk12", "saga_record_kill_partner"),
|
||||
Step("srk13", "assert", expect: "saga_rival_focus", value: "false"),
|
||||
Step("srk14", "assert", expect: "saga_antagonist_partner", value: "false"),
|
||||
Step("srk99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
|
@ -3804,6 +3864,9 @@ internal static class HarnessScenarios
|
|||
Step("srr11", "saga_force_admit_focus"),
|
||||
Step("srr12", "saga_note_encounters", value: "3"),
|
||||
Step("srr13", "assert", expect: "saga_rival_focus"),
|
||||
Step("srr14", "assert", expect: "saga_antagonist_partner"),
|
||||
Step("srr15", "saga_consider_antagonist_partner"),
|
||||
Step("srr16", "assert", expect: "saga_roster_count", value: "2", label: "min"),
|
||||
Step("srr99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
|
@ -4464,14 +4527,15 @@ internal static class HarnessScenarios
|
|||
Step("sar8", "interest_saga_clear"),
|
||||
Step("sar9", "pick_unit", asset: "dragon"),
|
||||
Step("sar10", "focus", asset: "dragon"),
|
||||
// Force admit so role flags refresh from live singleton/pop helpers.
|
||||
Step("sar11", "saga_force_admit_focus"),
|
||||
// Real admission path: special/rare identity opens the challenger gate.
|
||||
Step("sar11", "saga_consider_admit_focus"),
|
||||
Step("sar12", "assert", expect: "saga_has_focus"),
|
||||
Step("sar13", "saga_rail_refresh"),
|
||||
Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"),
|
||||
Step("sar14", "assert", expect: "saga_focus_admission", value: "Singleton|SpecialCreature|RareCreature"),
|
||||
// Harness admission proves an ordinary human can occupy the ensemble.
|
||||
Step("sar20", "pick_unit", asset: "human"),
|
||||
Step("sar21", "focus", asset: "human"),
|
||||
Step("sar21b", "assert", expect: "saga_focus_admission_eligible", value: "false"),
|
||||
Step("sar22", "saga_force_admit_focus"),
|
||||
Step("sar23", "assert", expect: "saga_has_focus"),
|
||||
Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
|
||||
|
|
@ -4526,14 +4590,14 @@ internal static class HarnessScenarios
|
|||
Step("srw44", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw45", "focus", asset: "human"),
|
||||
Step("srw46", "saga_force_admit_focus"),
|
||||
Step("srw50", "assert", expect: "saga_roster_count", value: "10"),
|
||||
Step("srw50", "assert", expect: "saga_roster_count", value: "5"),
|
||||
Step("srw51", "saga_weaken_nonprefer"),
|
||||
// 11th human challenges the full ensemble.
|
||||
// Another human challenges the full five-character ensemble.
|
||||
Step("srw60", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srw61", "focus", asset: "human"),
|
||||
Step("srw62", "saga_force_admit_focus"),
|
||||
Step("srw63", "assert", expect: "saga_has_focus"),
|
||||
Step("srw64", "assert", expect: "saga_roster_count", value: "10"),
|
||||
Step("srw64", "assert", expect: "saga_roster_count", value: "5"),
|
||||
// Prefer pin still present; challenger focus itself is not Prefer.
|
||||
Step("srw70", "assert", expect: "saga_prefer_focus_on", value: "false"),
|
||||
Step("srw71", "assert", expect: "saga_prefer_count", value: "1", label: "min"),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ public static partial class InterestDirector
|
|||
return false;
|
||||
}
|
||||
|
||||
if (candidate != _current && InterestVariety.IsCombatRunBlocked(candidate))
|
||||
{
|
||||
InterestDropLog.Record("combat_run_budget", candidate.Label ?? candidate.Key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate != _current
|
||||
&& !StoryScheduler.MayUseCombatShot(candidate))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2056,9 +2056,20 @@ public static partial class InterestDirector
|
|||
continue;
|
||||
}
|
||||
|
||||
bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Background lives continue to feed narrative admission off-camera. Once an MC
|
||||
// ensemble exists, routine candidates outside that ensemble/orbit do not enter the
|
||||
// expensive presentability and camera-selection path.
|
||||
if (!harness && !CameraEligibility.MayUseCamera(c))
|
||||
{
|
||||
InterestDropLog.Record("discovery_only", c.AssetId ?? c.Key);
|
||||
PendingScratch.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop stale / not-worth-watching / unpresentable shots from the pending set.
|
||||
// Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests.
|
||||
bool harness = string.Equals(c.Source, "harness", StringComparison.OrdinalIgnoreCase);
|
||||
bool presentable = harness || EventPresentability.WouldShow(c);
|
||||
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|
||||
|| IsStaleFreshLifeMoment(c, now)
|
||||
|
|
@ -2839,6 +2850,13 @@ public static partial class InterestDirector
|
|||
InterestRegistry.MarkSelected(next.Key);
|
||||
InterestVariety.NoteSelection(next);
|
||||
StoryPlanner.OnSwitchTo(next, now);
|
||||
CameraEligibilityModel eligibility = CameraEligibility.Classify(next);
|
||||
LogService.LogInfo(
|
||||
"[IdleSpectator][CAMERA_ELIGIBILITY] kind=" + eligibility.Kind
|
||||
+ " subject=" + next.SubjectId
|
||||
+ " anchor=" + eligibility.AnchorMcId
|
||||
+ " evidence=" + eligibility.Evidence
|
||||
+ " connection=" + eligibility.ConnectionLine);
|
||||
CameraDirector.Watch(next.ToInterestEvent());
|
||||
}
|
||||
|
||||
|
|
@ -2870,12 +2888,27 @@ public static partial class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
|
||||
if (ambient == null)
|
||||
Actor grounding = LifeSagaRoster.BestLivingMc();
|
||||
if (grounding == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Quiet grounding belongs to the current ensemble. Background lives are still scanned
|
||||
// and scored for admission, but routine activity is not a reason to give them camera time.
|
||||
var ambient = new InterestEvent
|
||||
{
|
||||
Score = InterestScoringConfig.W.fillScoreMax > 0f
|
||||
? InterestScoringConfig.W.fillScoreMax * 0.5f
|
||||
: 20f,
|
||||
Position = grounding.current_position,
|
||||
FollowUnit = grounding,
|
||||
Label = "",
|
||||
CreatedAt = now,
|
||||
AssetId = grounding.asset != null ? grounding.asset.id : "",
|
||||
CharacterSignificance = 1f
|
||||
};
|
||||
|
||||
// Character fill never owns an orange reason - empty Label + CharacterLed.
|
||||
// Force fill score so RegisterDirect does not publish as EventLed.
|
||||
ambient.Label = "";
|
||||
|
|
|
|||
|
|
@ -45,6 +45,89 @@ public static class InterestVariety
|
|||
/// <summary>Harness/debug: tips recorded in the rarity window.</summary>
|
||||
public static int ArcWindowCount => RecentArcs.Count;
|
||||
|
||||
public static bool IsCombatRunBlocked(InterestCandidate candidate, int maxRun = 4)
|
||||
{
|
||||
if (candidate == null
|
||||
|| candidate.Completion != InterestCompletionKind.CombatActive
|
||||
|| RecentArcs.Count < maxRun)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
|
||||
string text = ((candidate.AssetId ?? "") + " "
|
||||
+ (candidate.Verb ?? "") + " "
|
||||
+ (candidate.Label ?? "")).ToLowerInvariant();
|
||||
bool decisive = function == NarrativeFunction.TurningPoint
|
||||
|| function == NarrativeFunction.Resolution
|
||||
|| function == NarrativeFunction.Consequence
|
||||
|| candidate.EventStrength >= 95f
|
||||
|| text.IndexOf("kill", StringComparison.Ordinal) >= 0
|
||||
|| text.IndexOf("slain", StringComparison.Ordinal) >= 0
|
||||
|| text.IndexOf("victory", StringComparison.Ordinal) >= 0;
|
||||
if (decisive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int start = RecentArcs.Count - maxRun;
|
||||
for (int i = start; i < RecentArcs.Count; i++)
|
||||
{
|
||||
if (!IsCombatArc(RecentArcs[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeStabilityBudgets(out string detail)
|
||||
{
|
||||
Clear();
|
||||
var texture = new InterestCandidate
|
||||
{
|
||||
SubjectId = 991101,
|
||||
AssetId = "find_lover",
|
||||
Verb = "decides to find a lover",
|
||||
Label = "Probe decides to find a lover",
|
||||
Completion = InterestCompletionKind.ActivityActive
|
||||
};
|
||||
NoteSelection(texture, updateMix: false);
|
||||
bool textureBlocked = IsBeatCooled(texture);
|
||||
bool loverStillBlockedAtTenMinutes =
|
||||
IsBeatCooled(texture, Time.unscaledTime + 600f);
|
||||
bool loverEventuallyReopens =
|
||||
!IsBeatCooled(texture, Time.unscaledTime + 901f);
|
||||
|
||||
Clear();
|
||||
HarnessPushArc("arc:combat:duel", 4);
|
||||
var combat = new InterestCandidate
|
||||
{
|
||||
SubjectId = 991102,
|
||||
AssetId = "live_combat",
|
||||
Label = "Duel - Probe A vs Probe B",
|
||||
Completion = InterestCompletionKind.CombatActive,
|
||||
EventStrength = 80f
|
||||
};
|
||||
bool combatBlocked = IsCombatRunBlocked(combat);
|
||||
combat.Label = "Probe A kills Probe B";
|
||||
combat.Verb = "kills";
|
||||
bool decisiveAllowed = !IsCombatRunBlocked(combat);
|
||||
Clear();
|
||||
|
||||
detail = "textureBlocked=" + textureBlocked
|
||||
+ " lover10m=" + loverStillBlockedAtTenMinutes
|
||||
+ " loverReopens=" + loverEventuallyReopens
|
||||
+ " combatBlocked=" + combatBlocked
|
||||
+ " decisiveAllowed=" + decisiveAllowed;
|
||||
return textureBlocked
|
||||
&& loverStillBlockedAtTenMinutes
|
||||
&& loverEventuallyReopens
|
||||
&& combatBlocked
|
||||
&& decisiveAllowed;
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
RecentMix.Clear();
|
||||
|
|
@ -148,6 +231,20 @@ public static class InterestVariety
|
|||
StampCooldown("verb:" + chosen.Verb, until * 0.4f);
|
||||
}
|
||||
|
||||
string textureFamily = LowInformationTextureFamily(chosen);
|
||||
if (!string.IsNullOrEmpty(textureFamily) && chosen.SubjectId != 0)
|
||||
{
|
||||
float textureCooldown = string.Equals(
|
||||
textureFamily,
|
||||
"seeking_love",
|
||||
StringComparison.Ordinal)
|
||||
? 900f
|
||||
: RoutineReplayCooldownSeconds;
|
||||
StampCooldown(
|
||||
"texture:" + chosen.SubjectId + ":" + textureFamily,
|
||||
Time.unscaledTime + textureCooldown);
|
||||
}
|
||||
|
||||
// Moment beats: hard suppress the same story on the same subject.
|
||||
if (IsMomentBeat(chosen))
|
||||
{
|
||||
|
|
@ -166,6 +263,7 @@ public static class InterestVariety
|
|||
{
|
||||
StampCooldown(beatKey, beatUntil);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Sticky scenes are allowed to remain on screen while live, but once the editor
|
||||
|
|
@ -815,7 +913,28 @@ public static class InterestVariety
|
|||
/// </summary>
|
||||
public static bool IsBeatCooled(InterestCandidate c, float now = -1f)
|
||||
{
|
||||
if (c == null || !IsMomentBeat(c))
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now < 0f)
|
||||
{
|
||||
now = Time.unscaledTime;
|
||||
}
|
||||
|
||||
string textureFamily = LowInformationTextureFamily(c);
|
||||
if (!string.IsNullOrEmpty(textureFamily)
|
||||
&& c.SubjectId != 0
|
||||
&& CooldownUntil.TryGetValue(
|
||||
"texture:" + c.SubjectId + ":" + textureFamily,
|
||||
out float textureUntil)
|
||||
&& now < textureUntil)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!IsMomentBeat(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -828,11 +947,6 @@ public static class InterestVariety
|
|||
return false;
|
||||
}
|
||||
|
||||
if (now < 0f)
|
||||
{
|
||||
now = Time.unscaledTime;
|
||||
}
|
||||
|
||||
foreach (string beatKey in EnumerateBeatKeys(c))
|
||||
{
|
||||
if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until)
|
||||
|
|
@ -844,6 +958,35 @@ public static class InterestVariety
|
|||
return false;
|
||||
}
|
||||
|
||||
private static string LowInformationTextureFamily(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string text = ((c.AssetId ?? "") + " "
|
||||
+ (c.HappinessEffectId ?? "") + " "
|
||||
+ (c.StatusId ?? "") + " "
|
||||
+ (c.Verb ?? "") + " "
|
||||
+ (c.Label ?? "")).ToLowerInvariant();
|
||||
if (text.IndexOf("sleep", StringComparison.Ordinal) >= 0
|
||||
|| text.IndexOf("nap", StringComparison.Ordinal) >= 0)
|
||||
return "rest";
|
||||
if (text.IndexOf("laugh", StringComparison.Ordinal) >= 0
|
||||
|| text.IndexOf("play", StringComparison.Ordinal) >= 0)
|
||||
return "play";
|
||||
if (text.IndexOf("caffeine", StringComparison.Ordinal) >= 0
|
||||
|| text.IndexOf("coffee", StringComparison.Ordinal) >= 0)
|
||||
return "caffeine";
|
||||
if (text.IndexOf("find_lover", StringComparison.Ordinal) >= 0
|
||||
|| text.IndexOf("find a lover", StringComparison.Ordinal) >= 0)
|
||||
return "seeking_love";
|
||||
if (text.IndexOf("wander", StringComparison.Ordinal) >= 0)
|
||||
return "wandering";
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick next candidate. Soft 70/30: prefer event-led when below target and pool non-empty.
|
||||
/// Empty event pool → character-led without inventing events (mix meter not updated for empty-side windows).
|
||||
|
|
|
|||
483
IdleSpectator/Narrative/CameraEligibility.cs
Normal file
483
IdleSpectator/Narrative/CameraEligibility.cs
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public enum CameraEligibilityKind
|
||||
{
|
||||
DiscoveryOnly = 0,
|
||||
GroundingFill = 1,
|
||||
ExceptionalBackground = 2,
|
||||
DisasterPersonalImpact = 3,
|
||||
McThreadContext = 4,
|
||||
McCast = 5,
|
||||
McDirect = 6,
|
||||
WorldCritical = 7
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed explanation for why a candidate may own the main camera. This is evaluated from
|
||||
/// structured ids/evidence before selection; presentation consumes the same result.
|
||||
/// </summary>
|
||||
public sealed class CameraEligibilityModel
|
||||
{
|
||||
public CameraEligibilityKind Kind;
|
||||
public long AnchorMcId;
|
||||
public string AnchorMcName = "";
|
||||
public string ConnectionKind = "";
|
||||
public string ConnectionLine = "";
|
||||
public string Evidence = "";
|
||||
|
||||
public bool IsMcOrbit =>
|
||||
Kind == CameraEligibilityKind.McDirect
|
||||
|| Kind == CameraEligibilityKind.McCast
|
||||
|| Kind == CameraEligibilityKind.McThreadContext;
|
||||
|
||||
public bool MayUseCamera => Kind != CameraEligibilityKind.DiscoveryOnly;
|
||||
}
|
||||
|
||||
public static class CameraEligibility
|
||||
{
|
||||
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||
|
||||
public static CameraEligibilityModel Classify(InterestCandidate candidate)
|
||||
{
|
||||
var model = new CameraEligibilityModel();
|
||||
if (candidate == null)
|
||||
{
|
||||
return model;
|
||||
}
|
||||
|
||||
long subjectId = candidate.SubjectId != 0
|
||||
? candidate.SubjectId
|
||||
: EventFeedUtil.SafeId(candidate.FollowUnit);
|
||||
if (IsWorldCritical(candidate))
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.WorldCritical;
|
||||
model.Evidence = "world_critical";
|
||||
if (subjectId != 0 && !LifeSagaRoster.IsMc(subjectId))
|
||||
{
|
||||
long criticalAnchor = FirstTouchedMc(candidate);
|
||||
if (criticalAnchor != 0)
|
||||
{
|
||||
StampAnchor(model, criticalAnchor, "event", "world_critical_mc_context");
|
||||
model.ConnectionLine = EventConnection(candidate, model.AnchorMcName);
|
||||
}
|
||||
else if (LifeSagaRoster.IsMcCast(subjectId)
|
||||
&& TryResolveCastConnection(
|
||||
subjectId,
|
||||
out long castAnchor,
|
||||
out string castKind,
|
||||
out string castLine))
|
||||
{
|
||||
StampAnchor(model, castAnchor, castKind, "world_critical_cast_relation");
|
||||
model.ConnectionLine = castLine;
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}
|
||||
if (LifeSagaRoster.IsMc(subjectId))
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.McDirect;
|
||||
StampAnchor(model, subjectId, "self", "mc_subject");
|
||||
return model;
|
||||
}
|
||||
|
||||
long touchedMc = FirstTouchedMc(candidate);
|
||||
if (touchedMc != 0)
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.McThreadContext;
|
||||
StampAnchor(model, touchedMc, "event", "exact_event_participant");
|
||||
model.ConnectionLine = EventConnection(candidate, model.AnchorMcName);
|
||||
return model;
|
||||
}
|
||||
|
||||
if (subjectId != 0 && LifeSagaRoster.IsMcCast(subjectId)
|
||||
&& TryResolveCastConnection(subjectId, out long anchorId, out string kind, out string line))
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.McCast;
|
||||
StampAnchor(model, anchorId, kind, "live_cast_relation");
|
||||
model.ConnectionLine = line;
|
||||
return model;
|
||||
}
|
||||
|
||||
EpisodePlan episode = StoryScheduler.ActiveEpisode;
|
||||
if (episode != null && LifeSagaRoster.IsMc(episode.ProtagonistId))
|
||||
{
|
||||
if (TouchesExactEpisodeParticipant(candidate, episode))
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.McThreadContext;
|
||||
StampAnchor(model, episode.ProtagonistId, "story", "active_thread");
|
||||
model.ConnectionLine = "In " + model.AnchorMcName + "'s story";
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
|
||||
if (StoryReason.IsStoryAsset(candidate.AssetId)
|
||||
|| (candidate.Source ?? "").IndexOf("story_planner", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.ExceptionalBackground;
|
||||
model.Evidence = "confirmed_story_payoff";
|
||||
return model;
|
||||
}
|
||||
|
||||
if (IsConfirmedDisasterImpact(candidate, function))
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.DisasterPersonalImpact;
|
||||
model.Evidence = "confirmed_disaster_impact";
|
||||
return model;
|
||||
}
|
||||
|
||||
if (IsExceptionalBackground(candidate, function))
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.ExceptionalBackground;
|
||||
model.Evidence = "exceptional_" + function.ToString().ToLowerInvariant();
|
||||
return model;
|
||||
}
|
||||
|
||||
if (candidate.LeadKind == InterestLeadKind.CharacterLed
|
||||
&& subjectId != 0
|
||||
&& (LifeSagaRoster.IsMc(subjectId) || LifeSagaRoster.IsMcCast(subjectId)))
|
||||
{
|
||||
model.Kind = CameraEligibilityKind.GroundingFill;
|
||||
model.Evidence = "mc_orbit_fill";
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public static bool MayUseCamera(InterestCandidate candidate)
|
||||
{
|
||||
// Before an ensemble has emerged, retain normal world coverage so candidates can
|
||||
// accumulate evidence and the first five can be discovered.
|
||||
if (LifeSagaRoster.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Classify(candidate).MayUseCamera;
|
||||
}
|
||||
|
||||
public static string ConnectionLine(InterestCandidate candidate, long focusId)
|
||||
{
|
||||
if (candidate == null || focusId == 0 || LifeSagaRoster.IsMc(focusId))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
CameraEligibilityModel model = Classify(candidate);
|
||||
return model.AnchorMcId != 0 ? model.ConnectionLine ?? "" : "";
|
||||
}
|
||||
|
||||
private static bool IsWorldCritical(InterestCandidate c)
|
||||
{
|
||||
if (c.EventStrength >= 95f) return true;
|
||||
return c.Completion == InterestCompletionKind.EarthquakeActive
|
||||
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static bool IsConfirmedDisasterImpact(
|
||||
InterestCandidate c,
|
||||
NarrativeFunction function)
|
||||
{
|
||||
bool disaster = c.Completion == InterestCompletionKind.EarthquakeActive
|
||||
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (c.Source ?? "").IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
if (!disaster || c.SubjectId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A personal typed change/outcome is required. WorldContext alone is theater coverage.
|
||||
return NarrativeFunctionPolicy.ChangesStoryState(function)
|
||||
&& (c.NarrativePresentation != null
|
||||
|| !string.IsNullOrEmpty(c.NarrativeDevelopmentId));
|
||||
}
|
||||
|
||||
private static bool IsExceptionalBackground(
|
||||
InterestCandidate c,
|
||||
NarrativeFunction function)
|
||||
{
|
||||
if (!NarrativeFunctionPolicy.ChangesStoryState(function))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool typed = c.NarrativePresentation != null
|
||||
|| !string.IsNullOrEmpty(c.NarrativeDevelopmentId);
|
||||
if (!typed || c.EventStrength < 82f || c.Novelty < 0.35f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string text = ((c.AssetId ?? "") + " "
|
||||
+ (c.Source ?? "") + " "
|
||||
+ (c.Verb ?? "") + " "
|
||||
+ (c.NarrativeDevelopmentId ?? "")).ToLowerInvariant();
|
||||
bool routineLife = ContainsAny(
|
||||
text,
|
||||
"just_born",
|
||||
"born",
|
||||
"hatch",
|
||||
"egg",
|
||||
"became_adult",
|
||||
"sleep",
|
||||
"caffeine",
|
||||
"find_lover",
|
||||
"reproduction");
|
||||
if (routineLife)
|
||||
{
|
||||
Actor subject = EventFeedUtil.FindAliveById(c.SubjectId);
|
||||
return subject != null
|
||||
&& LifeSagaRoster.IsSpecialCreature(subject)
|
||||
&& LifeSagaRoster.IsSpeciesSingleton(subject);
|
||||
}
|
||||
|
||||
bool decisive = function == NarrativeFunction.TurningPoint
|
||||
|| function == NarrativeFunction.Resolution
|
||||
|| function == NarrativeFunction.Consequence;
|
||||
if (!decisive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exceptional background is an explicit outcome lane, not a numeric escape hatch.
|
||||
return ContainsAny(
|
||||
text,
|
||||
"death",
|
||||
"died",
|
||||
"killed",
|
||||
"destroy",
|
||||
"lost_home",
|
||||
"become_king",
|
||||
"new_king",
|
||||
"founded",
|
||||
"war_ended",
|
||||
"plot_ended",
|
||||
"disaster",
|
||||
"earthquake",
|
||||
"meteor",
|
||||
"tornado",
|
||||
"volcano");
|
||||
}
|
||||
|
||||
private static bool TouchesExactEpisodeParticipant(InterestCandidate c, EpisodePlan episode)
|
||||
{
|
||||
if (c == null || episode == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long[] ids =
|
||||
{
|
||||
c.SubjectId,
|
||||
c.RelatedId,
|
||||
c.PairOwnerId,
|
||||
c.PairPartnerId,
|
||||
c.TheaterLeadId,
|
||||
EventFeedUtil.SafeId(c.FollowUnit)
|
||||
};
|
||||
for (int i = 0; i < ids.Length; i++)
|
||||
{
|
||||
long id = ids[i];
|
||||
if (id == 0) continue;
|
||||
if (id == episode.ProtagonistId) return true;
|
||||
for (int j = 0; j < episode.EligibleCastIds.Count; j++)
|
||||
{
|
||||
if (episode.EligibleCastIds[j] == id) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string value, params string[] needles)
|
||||
{
|
||||
for (int i = 0; i < needles.Length; i++)
|
||||
{
|
||||
if (value.IndexOf(needles[i], StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static long FirstTouchedMc(InterestCandidate c)
|
||||
{
|
||||
long[] direct =
|
||||
{
|
||||
c.RelatedId,
|
||||
c.PairOwnerId,
|
||||
c.PairPartnerId,
|
||||
c.TheaterLeadId,
|
||||
EventFeedUtil.SafeId(c.RelatedUnit)
|
||||
};
|
||||
for (int i = 0; i < direct.Length; i++)
|
||||
{
|
||||
if (LifeSagaRoster.IsMc(direct[i])) return direct[i];
|
||||
}
|
||||
|
||||
// ParticipantIds are accepted only when the MC is exact and the event is a semantic
|
||||
// state change; broad live clusters do not manufacture connections.
|
||||
if (NarrativeFunctionPolicy.ChangesStoryState(NarrativeFunctionPolicy.Classify(c)))
|
||||
{
|
||||
for (int i = 0; i < c.ParticipantIds.Count; i++)
|
||||
{
|
||||
if (LifeSagaRoster.IsMc(c.ParticipantIds[i])) return c.ParticipantIds[i];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void StampAnchor(
|
||||
CameraEligibilityModel model,
|
||||
long anchorId,
|
||||
string connectionKind,
|
||||
string evidence)
|
||||
{
|
||||
model.AnchorMcId = anchorId;
|
||||
model.ConnectionKind = connectionKind ?? "";
|
||||
model.Evidence = evidence ?? "";
|
||||
LifeSagaSlot slot = LifeSagaRoster.Get(anchorId);
|
||||
model.AnchorMcName = slot?.DisplayName ?? "";
|
||||
if (string.IsNullOrEmpty(model.AnchorMcName))
|
||||
{
|
||||
Actor actor = EventFeedUtil.FindAliveById(anchorId);
|
||||
model.AnchorMcName = EventFeedUtil.SafeName(actor);
|
||||
}
|
||||
if (string.IsNullOrEmpty(model.AnchorMcName)) model.AnchorMcName = "the main character";
|
||||
}
|
||||
|
||||
private static string EventConnection(InterestCandidate c, string mcName)
|
||||
{
|
||||
if (c.Completion == InterestCompletionKind.WarFront)
|
||||
return "Fights in " + mcName + "'s war";
|
||||
if (c.Completion == InterestCompletionKind.PlotActive)
|
||||
return "Caught in " + mcName + "'s plot";
|
||||
if (!string.IsNullOrEmpty(c.CityKey))
|
||||
return "Caught in " + mcName + "'s city";
|
||||
return "In " + mcName + "'s story";
|
||||
}
|
||||
|
||||
private static bool TryResolveCastConnection(
|
||||
long subjectId,
|
||||
out long anchorId,
|
||||
out string kind,
|
||||
out string line)
|
||||
{
|
||||
anchorId = 0;
|
||||
kind = "";
|
||||
line = "";
|
||||
Actor subject = EventFeedUtil.FindAliveById(subjectId);
|
||||
if (subject == null) return false;
|
||||
|
||||
Slots.Clear();
|
||||
LifeSagaRoster.CopySlots(Slots);
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
LifeSagaSlot slot = Slots[i];
|
||||
Actor mc = slot != null ? EventFeedUtil.FindAliveById(slot.UnitId) : null;
|
||||
if (mc == null) continue;
|
||||
string name = string.IsNullOrEmpty(slot.DisplayName)
|
||||
? EventFeedUtil.SafeName(mc)
|
||||
: slot.DisplayName;
|
||||
|
||||
if (Same(ActorRelation.GetLover(subject), mc)
|
||||
|| Same(ActorRelation.GetLover(mc), subject))
|
||||
return Set(slot.UnitId, "partner", name + "'s partner", out anchorId, out kind, out line);
|
||||
if (Contains(ActorRelation.EnumerateChildren(mc, 12), subject))
|
||||
return Set(slot.UnitId, "child", name + "'s child", out anchorId, out kind, out line);
|
||||
if (Contains(ActorRelation.EnumerateParents(mc, 2), subject))
|
||||
return Set(slot.UnitId, "parent", name + "'s parent", out anchorId, out kind, out line);
|
||||
if (Same(ActorRelation.GetBestFriend(subject), mc)
|
||||
|| Same(ActorRelation.GetBestFriend(mc), subject))
|
||||
return Set(slot.UnitId, "friend", name + "'s best friend", out anchorId, out kind, out line);
|
||||
if (Contains(ActorRelation.EnumerateFamilyMembers(mc, 12), subject))
|
||||
return Set(slot.UnitId, "kin", name + "'s kin", out anchorId, out kind, out line);
|
||||
if (LifeSagaRoster.TryGetRecurringAntagonist(
|
||||
subject,
|
||||
out long rivalAnchor,
|
||||
out LifeSagaFact rivalEvidence)
|
||||
&& rivalAnchor == slot.UnitId)
|
||||
{
|
||||
string evidence = rivalEvidence?.Note ?? "";
|
||||
string label = evidence.IndexOf("killed_kin", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| evidence.IndexOf("kin_slain", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
? "Killed one of " + name + "'s kin"
|
||||
: "Recurring antagonist of " + name;
|
||||
return Set(slot.UnitId, "antagonist", label, out anchorId, out kind, out line);
|
||||
}
|
||||
if (Same(ActorRelation.GetCombatOpponent(subject), mc)
|
||||
|| Same(ActorRelation.GetCombatOpponent(mc), subject))
|
||||
return Set(slot.UnitId, "foe", "Fighting " + name, out anchorId, out kind, out line);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool Same(Actor a, Actor b) =>
|
||||
a != null && b != null && EventFeedUtil.SafeId(a) == EventFeedUtil.SafeId(b);
|
||||
|
||||
private static bool Contains(IEnumerable<Actor> actors, Actor target)
|
||||
{
|
||||
long id = EventFeedUtil.SafeId(target);
|
||||
foreach (Actor actor in actors)
|
||||
{
|
||||
if (EventFeedUtil.SafeId(actor) == id) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool Set(
|
||||
long id,
|
||||
string relation,
|
||||
string text,
|
||||
out long anchorId,
|
||||
out string kind,
|
||||
out string line)
|
||||
{
|
||||
anchorId = id;
|
||||
kind = relation;
|
||||
line = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeExceptionalPolicy(out string detail)
|
||||
{
|
||||
var hatch = new InterestCandidate
|
||||
{
|
||||
Key = "harness:hatch",
|
||||
AssetId = "just_got_out_of_egg",
|
||||
Verb = "hatches from an egg",
|
||||
SubjectId = 991001,
|
||||
EventStrength = 90f,
|
||||
Novelty = 1f,
|
||||
HasNarrativeFunction = true,
|
||||
NarrativeFunction = NarrativeFunction.Consequence,
|
||||
NarrativeDevelopmentId = "harness_hatch"
|
||||
};
|
||||
var death = new InterestCandidate
|
||||
{
|
||||
Key = "harness:death",
|
||||
AssetId = "death",
|
||||
Verb = "died in battle",
|
||||
SubjectId = 991002,
|
||||
EventStrength = 90f,
|
||||
Novelty = 1f,
|
||||
HasNarrativeFunction = true,
|
||||
NarrativeFunction = NarrativeFunction.Consequence,
|
||||
NarrativeDevelopmentId = "harness_death"
|
||||
};
|
||||
|
||||
CameraEligibilityModel hatchModel = Classify(hatch);
|
||||
CameraEligibilityModel deathModel = Classify(death);
|
||||
bool pass = hatchModel.Kind == CameraEligibilityKind.DiscoveryOnly
|
||||
&& deathModel.Kind == CameraEligibilityKind.ExceptionalBackground;
|
||||
detail = "hatch=" + hatchModel.Kind + ":" + hatchModel.Evidence
|
||||
+ " death=" + deathModel.Kind + ":" + deathModel.Evidence;
|
||||
return pass;
|
||||
}
|
||||
}
|
||||
|
|
@ -86,6 +86,7 @@ public static class CaptionComposer
|
|||
}
|
||||
|
||||
model.IdentityLine = BuildIdentityLine(focus, focusId);
|
||||
model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId);
|
||||
model.ReasonRelatedId = reasonRelatedId;
|
||||
|
||||
string beat = presentableBeat ?? "";
|
||||
|
|
@ -136,7 +137,8 @@ public static class CaptionComposer
|
|||
}
|
||||
|
||||
model.Fingerprint =
|
||||
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine
|
||||
focusId + "|" + model.IdentityLine + "|" + model.ConnectionLine
|
||||
+ "|" + model.BeatLine + "|" + model.ContextLine
|
||||
+ "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision;
|
||||
|
||||
LastIdentityLine = model.IdentityLine;
|
||||
|
|
@ -842,6 +844,7 @@ public static class CaptionComposer
|
|||
public sealed class CaptionModel
|
||||
{
|
||||
public string IdentityLine = "";
|
||||
public string ConnectionLine = "";
|
||||
public string BeatLine = "";
|
||||
public string ContextLine = "";
|
||||
public long ReasonRelatedId;
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ using UnityEngine;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Durable dynamic top-10 life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
|
||||
/// Durable dynamic five-character life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
|
||||
/// Independent of short-arc <see cref="StoryPlanner"/> Clear.
|
||||
/// </summary>
|
||||
public static class LifeSagaRoster
|
||||
{
|
||||
public const int Cap = 10;
|
||||
public const int Cap = 5;
|
||||
public const int MaxChapters = 3;
|
||||
public const float ChallengerMargin = 1.5f;
|
||||
public const float HeatCap = 12f;
|
||||
|
|
@ -55,6 +55,13 @@ public static class LifeSagaRoster
|
|||
private const int WorldScanBuildsPerFrame = 2;
|
||||
|
||||
private const int MaxScanAdmits = Cap * 2;
|
||||
private const int RareSpeciesMaxPopulation = 5;
|
||||
private const float SpecialCreatureStandingBonus = 2f;
|
||||
private const float RareCreatureStandingBonus = 3f;
|
||||
private const float RecurringAntagonistStandingBonus = 7f;
|
||||
private const float EstablishedMcSeconds = 120f;
|
||||
private const float EstablishedMcMarginBonus = 3f;
|
||||
private const float UndevelopedChallengerMarginBonus = 2f;
|
||||
|
||||
/// <summary>Cadence for starting a spread world admission scan.</summary>
|
||||
private const float WorldScanIntervalSeconds = 3.5f;
|
||||
|
|
@ -64,6 +71,7 @@ public static class LifeSagaRoster
|
|||
|
||||
/// <summary>Last timed soft refill cost in ms (hitch probe).</summary>
|
||||
public static float LastSoftRefillMs { get; private set; }
|
||||
public static string LastAdmissionDiagnostic { get; private set; } = "";
|
||||
|
||||
public static int Count => Slots.Count;
|
||||
|
||||
|
|
@ -99,6 +107,7 @@ public static class LifeSagaRoster
|
|||
_scanLimit = 0;
|
||||
_scanCommitIndex = 0;
|
||||
_liveScanSource = null;
|
||||
LastAdmissionDiagnostic = "";
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
|
|
@ -429,6 +438,43 @@ public static class LifeSagaRoster
|
|||
return bestPrefer ?? bestMc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best living current MC for quiet camera grounding. Bounded by the five-slot roster;
|
||||
/// unlike the world activity scanner this never walks the population.
|
||||
/// </summary>
|
||||
public static Actor BestLivingMc()
|
||||
{
|
||||
float now = Time.unscaledTime;
|
||||
float dull = DullSeconds();
|
||||
Actor best = null;
|
||||
float bestScore = float.MinValue;
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
LifeSagaSlot slot = Slots[i];
|
||||
if (slot == null || slot.UnitId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Actor actor = EventFeedUtil.FindAliveById(slot.UnitId);
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float score = EffectiveInterest(slot, now, dull)
|
||||
+ (slot.Prefer ? 20f : 0f)
|
||||
+ (slot.GameFavorite ? 8f : 0f);
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
best = actor;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public static bool TipTouchesMc(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
|
|
@ -1032,6 +1078,9 @@ public static class LifeSagaRoster
|
|||
bool admission = IsAdmissionCandidate(actor);
|
||||
if (!admission && !emergingStoryAdmit)
|
||||
{
|
||||
LastAdmissionDiagnostic =
|
||||
"id=" + unitId + " result=rejected reason=ineligible asset="
|
||||
+ (actor.asset?.id ?? "");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1052,6 +1101,15 @@ public static class LifeSagaRoster
|
|||
}
|
||||
}
|
||||
|
||||
float challengerScore = EffectiveInterest(neu, now);
|
||||
float weakestScore = float.PositiveInfinity;
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
LifeSagaSlot incumbent = Slots[i];
|
||||
if (incumbent == null || IsPin(incumbent)) continue;
|
||||
weakestScore = Mathf.Min(weakestScore, EffectiveInterest(incumbent, now));
|
||||
}
|
||||
|
||||
Scratch.Add(neu);
|
||||
RankAndCap(Scratch, now, PreviousIds);
|
||||
Slots.Clear();
|
||||
|
|
@ -1061,7 +1119,23 @@ public static class LifeSagaRoster
|
|||
}
|
||||
|
||||
_dirty = true;
|
||||
return IsMc(unitId);
|
||||
bool admitted = IsMc(unitId);
|
||||
LastAdmissionDiagnostic =
|
||||
"id=" + unitId
|
||||
+ " result=" + (admitted ? "admitted" : "rejected")
|
||||
+ " reason=" + neu.AdmissionReason
|
||||
+ " score=" + challengerScore.ToString("0.##")
|
||||
+ " weakest=" + (float.IsPositiveInfinity(weakestScore)
|
||||
? "none"
|
||||
: weakestScore.ToString("0.##"))
|
||||
+ " margin=" + ChallengerMargin.ToString("0.##")
|
||||
+ " pop=" + neu.SpeciesPopulation
|
||||
+ " rare=" + neu.IsRareCreature
|
||||
+ " special=" + neu.IsSpecialCreature
|
||||
+ " antagonist=" + neu.IsRecurringAntagonist
|
||||
+ " anchor=" + neu.AntagonistAnchorMcId
|
||||
+ " evidence=" + neu.AntagonistEvidence;
|
||||
return admitted;
|
||||
}
|
||||
|
||||
private static void ConsiderEmergingStories(float now)
|
||||
|
|
@ -1327,7 +1401,6 @@ public static class LifeSagaRoster
|
|||
for (int d = 0; d < displaced.Count; d++)
|
||||
{
|
||||
LifeSagaSlot incumbent = displaced[d];
|
||||
float need = EffectiveInterest(incumbent, now, dull) + ChallengerMargin;
|
||||
int worstNewcomer = -1;
|
||||
float worstScore = float.MaxValue;
|
||||
for (int i = 0; i < kept.Count; i++)
|
||||
|
|
@ -1351,6 +1424,17 @@ public static class LifeSagaRoster
|
|||
break;
|
||||
}
|
||||
|
||||
LifeSagaSlot newcomer = kept[worstNewcomer];
|
||||
if (IsEstablishedMc(incumbent, now)
|
||||
&& IsOrdinaryChallenger(newcomer)
|
||||
&& !HasMeaningfulNarrativeEvidence(newcomer))
|
||||
{
|
||||
kept[worstNewcomer] = incumbent;
|
||||
continue;
|
||||
}
|
||||
|
||||
float need = EffectiveInterest(incumbent, now, dull)
|
||||
+ ReplacementMargin(incumbent, newcomer, now);
|
||||
if (worstScore + 0.001f < need)
|
||||
{
|
||||
kept[worstNewcomer] = incumbent;
|
||||
|
|
@ -1380,6 +1464,100 @@ public static class LifeSagaRoster
|
|||
}
|
||||
}
|
||||
|
||||
private static float ReplacementMargin(
|
||||
LifeSagaSlot incumbent,
|
||||
LifeSagaSlot challenger,
|
||||
float now)
|
||||
{
|
||||
float margin = ChallengerMargin;
|
||||
if (incumbent != null
|
||||
&& (incumbent.Chapters.Count > 0
|
||||
|| (incumbent.AdmittedAt > 0f
|
||||
&& now - incumbent.AdmittedAt >= EstablishedMcSeconds)))
|
||||
{
|
||||
margin += EstablishedMcMarginBonus;
|
||||
}
|
||||
|
||||
if (challenger != null
|
||||
&& challenger.Chapters.Count == 0
|
||||
&& challenger.Heat < 4f
|
||||
&& !challenger.IsRecurringAntagonist
|
||||
&& !challenger.IsSpecialCreature
|
||||
&& !challenger.IsKing
|
||||
&& !challenger.IsLeader
|
||||
&& !challenger.IsClanChief
|
||||
&& !challenger.IsAlpha)
|
||||
{
|
||||
margin += UndevelopedChallengerMarginBonus;
|
||||
}
|
||||
|
||||
return margin;
|
||||
}
|
||||
|
||||
private static bool IsEstablishedMc(LifeSagaSlot slot, float now)
|
||||
{
|
||||
return slot != null
|
||||
&& (slot.Chapters.Count > 0
|
||||
|| (slot.AdmittedAt > 0f
|
||||
&& now - slot.AdmittedAt >= EstablishedMcSeconds));
|
||||
}
|
||||
|
||||
private static bool IsOrdinaryChallenger(LifeSagaSlot slot)
|
||||
{
|
||||
return slot != null
|
||||
&& !slot.GameFavorite
|
||||
&& !slot.Prefer
|
||||
&& !slot.IsKing
|
||||
&& !slot.IsLeader
|
||||
&& !slot.IsClanChief
|
||||
&& !slot.IsArmyCaptain
|
||||
&& !slot.IsPlotAuthor
|
||||
&& !slot.IsFounder
|
||||
&& !slot.IsSpecialCreature
|
||||
&& !slot.IsRecurringAntagonist;
|
||||
}
|
||||
|
||||
private static bool HasMeaningfulNarrativeEvidence(LifeSagaSlot slot)
|
||||
{
|
||||
if (slot == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return slot.Chapters.Count > 0
|
||||
|| slot.Heat >= 4f
|
||||
|| StoryScheduler.StoryPotential(slot.UnitId) >= 4f;
|
||||
}
|
||||
|
||||
public static bool HarnessProbeReplacementEvidence(out string detail)
|
||||
{
|
||||
float now = EstablishedMcSeconds + 2f;
|
||||
var incumbent = new LifeSagaSlot
|
||||
{
|
||||
UnitId = 991201,
|
||||
AdmittedAt = 1f
|
||||
};
|
||||
var ordinary = new LifeSagaSlot
|
||||
{
|
||||
UnitId = 991202,
|
||||
AdmittedAt = now,
|
||||
Heat = 0f
|
||||
};
|
||||
|
||||
bool ordinaryBlocked = IsEstablishedMc(incumbent, now)
|
||||
&& IsOrdinaryChallenger(ordinary)
|
||||
&& !HasMeaningfulNarrativeEvidence(ordinary);
|
||||
ordinary.Heat = 4f;
|
||||
bool developedAllowed = HasMeaningfulNarrativeEvidence(ordinary);
|
||||
ordinary.Heat = 0f;
|
||||
ordinary.IsSpecialCreature = true;
|
||||
bool specialAllowed = !IsOrdinaryChallenger(ordinary);
|
||||
detail = "ordinaryBlocked=" + ordinaryBlocked
|
||||
+ " developedAllowed=" + developedAllowed
|
||||
+ " specialAllowed=" + specialAllowed;
|
||||
return ordinaryBlocked && developedAllowed && specialAllowed;
|
||||
}
|
||||
|
||||
private static int CompareSlots(LifeSagaSlot a, LifeSagaSlot b, float now, float dull)
|
||||
{
|
||||
if (a == null && b == null)
|
||||
|
|
@ -1403,6 +1581,12 @@ public static class LifeSagaRoster
|
|||
return pin;
|
||||
}
|
||||
|
||||
int prefer = (b.Prefer ? 1 : 0).CompareTo(a.Prefer ? 1 : 0);
|
||||
if (prefer != 0)
|
||||
{
|
||||
return prefer;
|
||||
}
|
||||
|
||||
int fav = (b.GameFavorite ? 1 : 0).CompareTo(a.GameFavorite ? 1 : 0);
|
||||
if (fav != 0)
|
||||
{
|
||||
|
|
@ -1417,7 +1601,8 @@ public static class LifeSagaRoster
|
|||
return cmp;
|
||||
}
|
||||
|
||||
return b.TouchedAt.CompareTo(a.TouchedAt);
|
||||
int touched = b.TouchedAt.CompareTo(a.TouchedAt);
|
||||
return touched != 0 ? touched : a.UnitId.CompareTo(b.UnitId);
|
||||
}
|
||||
|
||||
public static float EffectiveInterest(LifeSagaSlot s, float now = -1f, float dull = -1f)
|
||||
|
|
@ -1558,6 +1743,16 @@ public static class LifeSagaRoster
|
|||
// ignore
|
||||
}
|
||||
|
||||
long actorId = EventFeedUtil.SafeId(actor);
|
||||
if (actorId != 0
|
||||
&& NarrativePersistence.IsPreferred(
|
||||
actorId,
|
||||
EventFeedUtil.SafeName(actor),
|
||||
actor.asset != null ? actor.asset.id : ""))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (InterestScoring.IsNotable(actor))
|
||||
{
|
||||
return true;
|
||||
|
|
@ -1568,7 +1763,9 @@ public static class LifeSagaRoster
|
|||
|| IsArmyCaptain(actor)
|
||||
|| IsActivePlotAuthor(actor)
|
||||
|| IsFounder(actor)
|
||||
|| IsSpeciesSingleton(actor))
|
||||
|| IsSpecialCreature(actor)
|
||||
|| IsRareCreature(actor)
|
||||
|| TryGetRecurringAntagonist(actor, out _, out _))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1576,6 +1773,8 @@ public static class LifeSagaRoster
|
|||
return false;
|
||||
}
|
||||
|
||||
public static bool HarnessIsAdmissionCandidate(Actor actor) => IsAdmissionCandidate(actor);
|
||||
|
||||
/// <summary>
|
||||
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
|
||||
/// </summary>
|
||||
|
|
@ -1668,14 +1867,81 @@ public static class LifeSagaRoster
|
|||
p += 120f;
|
||||
}
|
||||
|
||||
if (IsActivePlotAuthor(actor) || IsFounder(actor) || IsSpeciesSingleton(actor))
|
||||
if (IsActivePlotAuthor(actor)
|
||||
|| IsFounder(actor)
|
||||
|| (IsSpecialCreature(actor) && IsSpeciesSingleton(actor)))
|
||||
{
|
||||
p += 80f;
|
||||
}
|
||||
|
||||
if (IsSpecialCreature(actor))
|
||||
{
|
||||
p += 45f;
|
||||
}
|
||||
|
||||
int rarePopulation = GetSpeciesPopulation(actor);
|
||||
if (IsSpecialCreature(actor)
|
||||
&& rarePopulation > 1
|
||||
&& rarePopulation <= RareSpeciesMaxPopulation)
|
||||
{
|
||||
p += 25f * (RareSpeciesMaxPopulation + 1 - rarePopulation)
|
||||
/ RareSpeciesMaxPopulation;
|
||||
}
|
||||
|
||||
if (TryGetRecurringAntagonist(actor, out _, out LifeSagaFact antagonistEvidence))
|
||||
{
|
||||
p += 150f + Mathf.Min(50f, antagonistEvidence?.Strength ?? 0f);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
public static bool TryGetRecurringAntagonist(
|
||||
Actor actor,
|
||||
out long anchorMcId,
|
||||
out LifeSagaFact evidence)
|
||||
{
|
||||
anchorMcId = 0;
|
||||
evidence = null;
|
||||
long actorId = EventFeedUtil.SafeId(actor);
|
||||
if (actorId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both searches are bounded: at most five MCs and each life-memory fact list is capped.
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
LifeSagaSlot slot = Slots[i];
|
||||
if (slot == null || slot.UnitId == 0 || slot.UnitId == actorId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LifeSagaMemory.TryGetEarnedRival(slot.UnitId, out LifeSagaFact mcRival)
|
||||
&& mcRival != null
|
||||
&& mcRival.OtherId == actorId
|
||||
&& LifeSagaMemory.IsCredibleRival(mcRival))
|
||||
{
|
||||
anchorMcId = slot.UnitId;
|
||||
evidence = mcRival;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (LifeSagaMemory.TryGetEarnedRival(actorId, out LifeSagaFact actorRival)
|
||||
&& actorRival != null
|
||||
&& LifeSagaRoster.IsMc(actorRival.OtherId)
|
||||
&& LifeSagaMemory.IsCredibleRival(actorRival))
|
||||
{
|
||||
anchorMcId = actorRival.OtherId;
|
||||
evidence = actorRival;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsPackAlpha(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
|
|
@ -1945,44 +2211,80 @@ public static class LifeSagaRoster
|
|||
|
||||
public static bool IsSpeciesSingleton(Actor actor)
|
||||
{
|
||||
if (actor?.asset == null)
|
||||
return GetSpeciesPopulation(actor) == 1;
|
||||
}
|
||||
|
||||
public static bool IsRareCreature(Actor actor)
|
||||
{
|
||||
if (!IsSpecialCreature(actor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int population = GetSpeciesPopulation(actor);
|
||||
return population > 0 && population <= RareSpeciesMaxPopulation;
|
||||
}
|
||||
|
||||
public static bool IsSpecialCreature(Actor actor)
|
||||
{
|
||||
string id = actor?.asset != null ? actor.asset.id : "";
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
id = id.Trim().ToLowerInvariant();
|
||||
return id == "dragon"
|
||||
|| id == "zombie_dragon"
|
||||
|| id == "druid"
|
||||
|| id == "necromancer"
|
||||
|| id == "evil_mage"
|
||||
|| id == "white_mage";
|
||||
}
|
||||
|
||||
private static int GetSpeciesPopulation(Actor actor)
|
||||
{
|
||||
if (actor?.asset == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
string speciesId = actor.asset.id ?? "";
|
||||
if (string.IsNullOrEmpty(speciesId))
|
||||
{
|
||||
return false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!SpeciesCountCache.TryGetValue(speciesId, out int population))
|
||||
if (SpeciesCountCache.TryGetValue(speciesId, out int population))
|
||||
{
|
||||
// Roster admission only needs singleton/not-singleton. The actor asset already
|
||||
// owns its units, so stop at two instead of rebuilding counts for the whole world.
|
||||
population = 0;
|
||||
try
|
||||
return population;
|
||||
}
|
||||
|
||||
// Count only far enough to distinguish singleton/rare from common. The result is
|
||||
// retained for the current role-cache/world-scan cycle.
|
||||
population = 0;
|
||||
try
|
||||
{
|
||||
if (actor.asset.units != null)
|
||||
{
|
||||
if (actor.asset.units != null)
|
||||
foreach (Actor sameSpecies in actor.asset.units)
|
||||
{
|
||||
foreach (Actor sameSpecies in actor.asset.units)
|
||||
if (sameSpecies != null
|
||||
&& sameSpecies.isAlive()
|
||||
&& ++population > RareSpeciesMaxPopulation)
|
||||
{
|
||||
if (sameSpecies != null && sameSpecies.isAlive() && ++population >= 2)
|
||||
{
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
population = 2;
|
||||
}
|
||||
|
||||
SpeciesCountCache[speciesId] = population;
|
||||
}
|
||||
catch
|
||||
{
|
||||
population = RareSpeciesMaxPopulation + 1;
|
||||
}
|
||||
|
||||
return population == 1;
|
||||
SpeciesCountCache[speciesId] = population;
|
||||
return population;
|
||||
}
|
||||
|
||||
private static bool ActorMatchesFounder(object meta, Actor actor)
|
||||
|
|
@ -2158,7 +2460,17 @@ public static class LifeSagaRoster
|
|||
s.IsArmyCaptain = IsArmyCaptain(actor);
|
||||
s.IsPlotAuthor = IsActivePlotAuthor(actor);
|
||||
s.IsFounder = IsFounder(actor);
|
||||
s.IsSingleton = IsSpeciesSingleton(actor);
|
||||
s.SpeciesPopulation = GetSpeciesPopulation(actor);
|
||||
s.IsSingleton = s.SpeciesPopulation == 1;
|
||||
s.IsSpecialCreature = IsSpecialCreature(actor);
|
||||
s.IsRareCreature = s.IsSpecialCreature
|
||||
&& s.SpeciesPopulation > 0
|
||||
&& s.SpeciesPopulation <= RareSpeciesMaxPopulation;
|
||||
s.IsRecurringAntagonist = TryGetRecurringAntagonist(
|
||||
actor,
|
||||
out s.AntagonistAnchorMcId,
|
||||
out LifeSagaFact antagonistEvidence);
|
||||
s.AntagonistEvidence = antagonistEvidence?.Note ?? "";
|
||||
s.Kills = meta?.Kills ?? 0;
|
||||
s.Renown = meta?.Renown ?? 0f;
|
||||
// Standing from flags already resolved above - avoid a second clan/alpha/founder reflection walk.
|
||||
|
|
@ -2227,10 +2539,28 @@ public static class LifeSagaRoster
|
|||
score += 7f;
|
||||
}
|
||||
|
||||
if (s.IsSingleton)
|
||||
if (s.IsSingleton && s.IsSpecialCreature)
|
||||
{
|
||||
score += 6f;
|
||||
}
|
||||
else if (s.IsSingleton)
|
||||
{
|
||||
score += 1f;
|
||||
}
|
||||
else if (s.IsRareCreature)
|
||||
{
|
||||
score += RareCreatureStandingBonus;
|
||||
}
|
||||
|
||||
if (s.IsSpecialCreature)
|
||||
{
|
||||
score += SpecialCreatureStandingBonus;
|
||||
}
|
||||
|
||||
if (s.IsRecurringAntagonist)
|
||||
{
|
||||
score += RecurringAntagonistStandingBonus;
|
||||
}
|
||||
|
||||
if (s.IsFounder)
|
||||
{
|
||||
|
|
@ -2374,7 +2704,10 @@ public static class LifeSagaRoster
|
|||
if (s.IsArmyCaptain) return LifeSagaAdmissionReason.ArmyCaptain;
|
||||
if (s.IsFounder) return LifeSagaAdmissionReason.Founder;
|
||||
if (s.IsPlotAuthor) return LifeSagaAdmissionReason.PlotAuthor;
|
||||
if (s.IsSingleton) return LifeSagaAdmissionReason.Singleton;
|
||||
if (s.IsSingleton && s.IsSpecialCreature) return LifeSagaAdmissionReason.Singleton;
|
||||
if (s.IsRecurringAntagonist) return LifeSagaAdmissionReason.RecurringAntagonist;
|
||||
if (s.IsSpecialCreature) return LifeSagaAdmissionReason.SpecialCreature;
|
||||
if (s.IsRareCreature) return LifeSagaAdmissionReason.RareCreature;
|
||||
if (s.Kills > 0) return LifeSagaAdmissionReason.NotableKills;
|
||||
if (s.Renown > 0f) return LifeSagaAdmissionReason.NotableRenown;
|
||||
return LifeSagaAdmissionReason.NotableLife;
|
||||
|
|
@ -2419,7 +2752,7 @@ public static class LifeSagaRoster
|
|||
score += 7f;
|
||||
}
|
||||
|
||||
if (IsSpeciesSingleton(actor))
|
||||
if (IsSpecialCreature(actor) && IsSpeciesSingleton(actor))
|
||||
{
|
||||
score += 6f;
|
||||
}
|
||||
|
|
@ -2549,6 +2882,12 @@ public sealed class LifeSagaSlot
|
|||
public bool IsPlotAuthor;
|
||||
public bool IsFounder;
|
||||
public bool IsSingleton;
|
||||
public bool IsRareCreature;
|
||||
public bool IsSpecialCreature;
|
||||
public int SpeciesPopulation;
|
||||
public bool IsRecurringAntagonist;
|
||||
public long AntagonistAnchorMcId;
|
||||
public string AntagonistEvidence = "";
|
||||
public int Kills;
|
||||
public float Renown;
|
||||
/// <summary>Live role/standing recomputed each refresh.</summary>
|
||||
|
|
@ -2584,6 +2923,9 @@ public enum LifeSagaAdmissionReason
|
|||
Founder,
|
||||
PlotAuthor,
|
||||
Singleton,
|
||||
RecurringAntagonist,
|
||||
RareCreature,
|
||||
SpecialCreature,
|
||||
NotableKills,
|
||||
NotableRenown,
|
||||
NotableLife,
|
||||
|
|
|
|||
|
|
@ -565,6 +565,12 @@ public static class SagaProse
|
|||
return Sentence("Leads a plot");
|
||||
case LifeSagaAdmissionReason.Singleton:
|
||||
return Sentence("Last of their kind");
|
||||
case LifeSagaAdmissionReason.RecurringAntagonist:
|
||||
return Sentence("A recurring threat");
|
||||
case LifeSagaAdmissionReason.RareCreature:
|
||||
return Sentence("One of few of their kind");
|
||||
case LifeSagaAdmissionReason.SpecialCreature:
|
||||
return Sentence("A rare power in the world");
|
||||
case LifeSagaAdmissionReason.Founder:
|
||||
return Sentence("Defends what they founded");
|
||||
case LifeSagaAdmissionReason.CityLeader:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ public static class WatchCaption
|
|||
private const float TraitsH = 13f;
|
||||
private const float StatusesH = 13f;
|
||||
private const float ReasonH = 13f;
|
||||
private const float ConnectionH = 11f;
|
||||
private const float StorySpineH = 11f;
|
||||
private const float HistoryLineMinH = 12f;
|
||||
private const float HistoryLineMaxH = 100f;
|
||||
|
|
@ -62,6 +63,7 @@ public static class WatchCaption
|
|||
private static readonly Color TaskColor = HudTheme.TaskGreen;
|
||||
private static readonly Color ReasonColor = HudTheme.ValueOrange;
|
||||
private static readonly Color StorySpineColor = HudTheme.Muted;
|
||||
private static readonly Color ConnectionColor = HudTheme.Muted;
|
||||
private static readonly Color TraitNameColor = HudTheme.Body;
|
||||
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
|
||||
private static readonly Color HistoryTextColor = HudTheme.Body;
|
||||
|
|
@ -73,6 +75,7 @@ public static class WatchCaption
|
|||
private static Image _sexIcon;
|
||||
private static Text _nameText;
|
||||
private static Text _reasonText;
|
||||
private static Text _connectionText;
|
||||
private static Text _storySpineText;
|
||||
private static long _reasonOtherId;
|
||||
|
||||
|
|
@ -194,6 +197,7 @@ public static class WatchCaption
|
|||
|
||||
/// <summary>Harness: composed context line (spine or stake).</summary>
|
||||
public static string LastContextLine => CaptionComposer.LastContextLine;
|
||||
public static string LastConnectionLine { get; private set; } = "";
|
||||
|
||||
/// <summary>Harness/UI: active context line under the beat (spine or stake), empty when none.</summary>
|
||||
public static string LastStorySpine { get; private set; } = "";
|
||||
|
|
@ -509,6 +513,7 @@ public static class WatchCaption
|
|||
LastDetail = "";
|
||||
LastCaptionText = "";
|
||||
LastStorySpine = "";
|
||||
LastConnectionLine = "";
|
||||
CaptionComposer.Clear();
|
||||
LifeSagaViewController.Clear();
|
||||
LifeSagaPanel.Clear();
|
||||
|
|
@ -1202,6 +1207,7 @@ public static class WatchCaption
|
|||
{
|
||||
RefreshSagaBody();
|
||||
ApplyReasonText("", 0);
|
||||
ApplyConnection("");
|
||||
ApplyStorySpine("");
|
||||
_dossierRowsNeedRestore = true;
|
||||
}
|
||||
|
|
@ -1240,7 +1246,7 @@ public static class WatchCaption
|
|||
|| historyCount > 0
|
||||
|| _current != null);
|
||||
string layoutKey =
|
||||
$"{(panelMode ? 1 : 0)}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
|
||||
$"{(panelMode ? 1 : 0)}|{LastConnectionLine}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
|
||||
+ $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}"
|
||||
+ $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}"
|
||||
+ $"|{LastBeatLine}|{LastHeadline}";
|
||||
|
|
@ -1274,6 +1280,7 @@ public static class WatchCaption
|
|||
reasonRelatedId,
|
||||
spineFiltered,
|
||||
statusBannerOwnsBeat);
|
||||
ApplyConnection(model.ConnectionLine ?? "");
|
||||
if (string.Equals(
|
||||
model.Fingerprint,
|
||||
_lastAppliedCaptionFingerprint,
|
||||
|
|
@ -1339,6 +1346,26 @@ public static class WatchCaption
|
|||
model.ContextLine);
|
||||
}
|
||||
|
||||
private static void ApplyConnection(string connection)
|
||||
{
|
||||
string next = connection ?? "";
|
||||
bool show = !string.IsNullOrEmpty(next)
|
||||
&& !LifeSagaViewController.IsHoverPreview;
|
||||
LastConnectionLine = show ? next : "";
|
||||
if (_connectionText == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(_connectionText.text, LastConnectionLine, StringComparison.Ordinal))
|
||||
{
|
||||
_connectionText.text = LastConnectionLine;
|
||||
EllipsisFitLabel(_connectionText, BodyColumnInnerWidth());
|
||||
}
|
||||
_connectionText.color = ConnectionColor;
|
||||
_connectionText.gameObject.SetActive(show);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-show trait/status/history chrome after hover hid them.
|
||||
/// Uses the bound dossier snapshot so hover-exit is instant; recomposes caption.
|
||||
|
|
@ -3129,6 +3156,15 @@ public static class WatchCaption
|
|||
|
||||
if (!panelMode)
|
||||
{
|
||||
bool hasConnection = _connectionText != null
|
||||
&& _connectionText.gameObject.activeSelf
|
||||
&& !string.IsNullOrEmpty(_connectionText.text);
|
||||
if (hasConnection)
|
||||
{
|
||||
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
|
||||
y += ConnectionH + Gap;
|
||||
}
|
||||
|
||||
// Row visibility is owned by Place*/fill - never leave empty rows active at stale
|
||||
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
|
||||
if (historyCount <= 0 && _historyCol != null)
|
||||
|
|
@ -4092,6 +4128,7 @@ public static class WatchCaption
|
|||
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform
|
||||
|| _historyCol == null
|
||||
|| _statusesRow == null
|
||||
|| _connectionText == null
|
||||
|| _favoriteBtn == null
|
||||
|| _root.transform.Find("HistoryBtn") != null
|
||||
|| _root.transform.Find("HistoryScroll") != null))
|
||||
|
|
@ -4112,9 +4149,11 @@ public static class WatchCaption
|
|||
_sexIcon = null;
|
||||
_nameText = null;
|
||||
_reasonText = null;
|
||||
_connectionText = null;
|
||||
_storySpineText = null;
|
||||
_reasonOtherId = 0;
|
||||
LastStorySpine = "";
|
||||
LastConnectionLine = "";
|
||||
_levelIcon = null;
|
||||
_levelValue = null;
|
||||
_taskIcon = null;
|
||||
|
|
@ -4261,6 +4300,15 @@ public static class WatchCaption
|
|||
_reasonText.resizeTextForBestFit = false;
|
||||
WireReasonClick(0);
|
||||
|
||||
_connectionText = HudCanvas.MakeText(_root.transform, "Connection", "", 7);
|
||||
_connectionText.color = ConnectionColor;
|
||||
_connectionText.supportRichText = false;
|
||||
_connectionText.alignment = TextAnchor.UpperLeft;
|
||||
_connectionText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
_connectionText.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
_connectionText.resizeTextForBestFit = false;
|
||||
_connectionText.raycastTarget = false;
|
||||
|
||||
_storySpineText = HudCanvas.MakeText(_root.transform, "StorySpine", "", 7);
|
||||
_storySpineText.color = StorySpineColor;
|
||||
_storySpineText.supportRichText = false;
|
||||
|
|
@ -4302,6 +4350,7 @@ public static class WatchCaption
|
|||
_statusesRow.SetActive(false);
|
||||
_traitsRow.SetActive(false);
|
||||
_reasonText.gameObject.SetActive(false);
|
||||
_connectionText.gameObject.SetActive(false);
|
||||
_storySpineText.gameObject.SetActive(false);
|
||||
_levelIcon.gameObject.SetActive(false);
|
||||
_levelValue.gameObject.SetActive(false);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
|
|||
|
||||
## What it follows
|
||||
|
||||
The camera follows the developing lives and relationships of a small emerging cast. A story
|
||||
The camera follows the developing lives and relationships of a five-character emerging cast. A story
|
||||
scheduler chooses developments that advance an active character thread, yields to critical world
|
||||
events, and returns only when the story has new evidence. `EventStrength` still models urgency and
|
||||
visual value, but it no longer owns continuity. Bounded character/world fill runs when no episode
|
||||
|
|
@ -21,6 +21,12 @@ sentence** (Beat), and muted Context (story spine or confirmed pressure). Main c
|
|||
`Name · Title` when they have a real role. The Beat is rendered once from typed event evidence, so
|
||||
relationship details are not appended a second time.
|
||||
|
||||
Routine camera shifts stay with MCs or their confirmed Cast/story context. When a related non-MC is
|
||||
followed, a muted Connection row explains the MC link. Background lives still accumulate story
|
||||
potential off-camera and may earn a slot; only typed turning points, consequences, story payoffs,
|
||||
or critical world/disaster events can give an unrelated background character exceptional screen
|
||||
time.
|
||||
|
||||
Confirmed MC events, threads, consequences, Legacy, and Prefer choices are stored in a versioned
|
||||
per-world sidecar at the game save boundary. Runtime camera state and episode timing remain
|
||||
session-only.
|
||||
|
|
|
|||
550
docs/five-mc-ensemble-plan.md
Normal file
550
docs/five-mc-ensemble-plan.md
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
# Five-MC ensemble and viewer-recognition plan
|
||||
|
||||
## Status
|
||||
|
||||
Core implementation complete; extended organic validation pending.
|
||||
|
||||
## Product outcome
|
||||
|
||||
IdleSpectator follows five recognizable main characters at a time. The five are the complete
|
||||
visible MC ensemble, not a ten-character roster with a hidden lead tier.
|
||||
|
||||
The player should be able to:
|
||||
|
||||
1. recognize a returning MC before reading a full dossier;
|
||||
2. understand what changed since the MC was last shown;
|
||||
3. notice when two MC stories cross;
|
||||
4. tell why a newcomer entered and why a former MC left; and
|
||||
5. recount at least one MC's story after an ordinary spectator session.
|
||||
|
||||
Five limits attention; it does not limit durable history. Narrative records, Chronicle history,
|
||||
Legacy, and saved Prefer choices survive roster churn.
|
||||
|
||||
Background lives continue to be observed and scored, but observation does not imply routine camera
|
||||
time. The camera primarily follows the five and their evidence-backed social/world orbit.
|
||||
|
||||
## Product contracts
|
||||
|
||||
### Roster
|
||||
|
||||
- `LifeSagaRoster.Cap` becomes 5.
|
||||
- All five slots use the existing standing, heat, story-potential, diversity, and challenger rules.
|
||||
- A slot means **current MC**. There is no second visible category such as lead, supporting MC, or
|
||||
bench.
|
||||
- A newcomer still needs the challenger margin to replace the weakest eligible incumbent.
|
||||
- Roster order changes only when effective rank changes materially; ordinary heat decay must not
|
||||
make the rail shuffle continuously.
|
||||
- Leaving the five removes current MC camera bias but never deletes the character's durable story.
|
||||
|
||||
### Prefer and favorite overflow
|
||||
|
||||
Five slots cannot promise simultaneous display for an unlimited number of preferred or WorldBox
|
||||
favorite characters.
|
||||
|
||||
- Prefer and WorldBox favorite remain strong, durable priority signals.
|
||||
- Persist every Prefer choice even while that character is outside the visible five.
|
||||
- Never clear a preference merely to satisfy Cap.
|
||||
- When more than five pinned candidates are alive, choose the visible five deterministically:
|
||||
1. current visible incumbent;
|
||||
2. active meaningful narrative thread;
|
||||
3. Prefer over WorldBox-favorite-only;
|
||||
4. effective story score;
|
||||
5. most recent confirmed meaningful development;
|
||||
6. stable unit-id tie-break.
|
||||
- An off-roster preferred life remains an eligible challenger and can return when its story develops
|
||||
or a visible slot opens.
|
||||
- Toggling Prefer on a visible MC remains immediate. Selecting an off-roster character in Chronicle
|
||||
may preserve its Prefer state, but does not forcibly evict a current MC without passing the same
|
||||
deterministic admission policy.
|
||||
|
||||
### Camera
|
||||
|
||||
- The scheduler remains story-first.
|
||||
- Main camera selection uses this eligibility ladder:
|
||||
1. world-critical interrupt;
|
||||
2. meaningful development directly involving a current MC;
|
||||
3. meaningful development involving confirmed MC Cast or an active MC thread/theater;
|
||||
4. significant disaster impact on a named, presentable life;
|
||||
5. exceptional background-character development;
|
||||
6. MC/MC-Cast grounding footage when no development is ready;
|
||||
7. location/world context when no suitable character footage exists.
|
||||
- Prefer > MC > MC Cast remains the soft ordering within an eligible tier.
|
||||
- Routine background activity is discovery-only. It may update observation, story potential, and
|
||||
challenger rank, but it does not become character-fill or an ordinary camera cut.
|
||||
- “Related to an MC” requires structured evidence, not proximity or name matching:
|
||||
- the MC is the subject, other, pair member, theater lead, or exact event participant;
|
||||
- the character is confirmed lover, friend, kin, credible foe, war/plot counterpart, or other
|
||||
current Cast relation;
|
||||
- the event advances the MC's active correlation, family, city, kingdom, war, plot, or consequence
|
||||
thread.
|
||||
- Being near an MC, sharing a species, performing the same task, or appearing in a mass participant
|
||||
list is not sufficient by itself.
|
||||
- Prefer state is a strong bias but does not turn routine texture into a meaningful development.
|
||||
- Exposure debt is recalibrated for five characters so it does not mechanically rotate through all
|
||||
five when no meaningful footage exists.
|
||||
- World-critical events retain interruption priority.
|
||||
- A displaced former MC loses MC bonus immediately but may still be shown for a payoff, consequence,
|
||||
death, or world-critical event.
|
||||
|
||||
### Background challengers
|
||||
|
||||
- Background characters accumulate story potential from confirmed developments without needing a
|
||||
camera cut.
|
||||
- Ordinary combat, chores, travel, moods, repeated intent, and unchanged status do not qualify as
|
||||
exceptional background developments.
|
||||
- An exceptional background development must be both semantically meaningful and rare/high-impact,
|
||||
for example:
|
||||
- founding a city or kingdom;
|
||||
- becoming or losing a major ruler/leader role;
|
||||
- confirmed birth, death, bond, betrayal/opposition, or decisive survival consequence with high
|
||||
narrative magnitude;
|
||||
- a decisive war/plot outcome;
|
||||
- confirmed severe disaster impact;
|
||||
- another typed turning point, resolution, or consequence above an explicit background threshold.
|
||||
- An exceptional background cut is an audition, not instant MC promotion. Admission still uses
|
||||
accumulated story potential, diversity, standing, and the challenger margin.
|
||||
- Repeated exceptional cuts for the same non-MC are cooled aggressively. If the life continues to
|
||||
matter, it should enter the five instead of functioning as an unofficial sixth MC.
|
||||
- Background camera share is measured and bounded. In a valid developed-world soak, non-critical
|
||||
cuts whose subject is neither MC nor confirmed MC Cast should be exceptional and individually
|
||||
explainable from telemetry.
|
||||
|
||||
### Disaster significance
|
||||
|
||||
Disasters remain world-critical at the event/theater level. A character caught in one may also earn
|
||||
a meaningful personal development.
|
||||
|
||||
- “Caught up in a disaster” requires confirmed impact, not mere distance from the effect:
|
||||
- injured, afflicted, trapped, displaced, made homeless, or killed by the disaster;
|
||||
- loses confirmed family, home, city, role, or another durable stake because of it;
|
||||
- survives a directly observed severe impact or participates in a confirmed rescue/response;
|
||||
- is an exact affected participant in a typed disaster event whose outcome is presentable.
|
||||
- Proximity, entering the camera rectangle, or membership in a large nearby-unit snapshot is not
|
||||
enough.
|
||||
- For an affected MC or MC Cast member, the personal impact becomes a related escalation,
|
||||
turning point, resolution, or consequence in that life/thread.
|
||||
- For an affected background character, severe confirmed impact increases story potential and may
|
||||
qualify for one exceptional background cut.
|
||||
- Disaster theater coverage and personal consequence are separate shots. Do not repeat the same
|
||||
fact once as “disaster spectacle” and again as a personal Beat unless the second shot adds a
|
||||
confirmed outcome.
|
||||
- Mass casualty events use bounded representative selection: prefer affected MCs, then MC Cast,
|
||||
then at most one exceptional background survivor/victim when it adds a distinct human-scale
|
||||
consequence.
|
||||
|
||||
### Performance
|
||||
|
||||
The five-MC change must reduce or preserve runtime cost. It must not trade a smaller rail for more
|
||||
frequent world scans, full-store walks, roster rebuilds, portrait churn, or UI relayout.
|
||||
|
||||
- Reuse the existing amortized world scan: walk a bounded unit slice per frame and build only a
|
||||
bounded challenger slice.
|
||||
- Cap reduction changes the number of visible slots, not the cadence of admission scans.
|
||||
- Rank existing slots or bounded challengers in memory; never rescan the world because heat,
|
||||
presentation, portrait state, or Prefer changes.
|
||||
- Pin overflow uses cached story/identity indexes and the existing challenger pool. It must not walk
|
||||
all persisted events or all living units during a normal refill.
|
||||
- Stable rail order should reduce portrait rebinding and layout work. A no-op refill must produce no
|
||||
rail rebuild, portrait capture, or dossier relayout.
|
||||
- Reentry, crossover, admission, and closure presentation must use exact event receipts and indexed
|
||||
story state rather than latest-similar searches.
|
||||
- Apply camera eligibility before expensive presentability/prose work where the required structured
|
||||
ids and narrative function are already available.
|
||||
- Carry Connection as a small typed model (`AnchorMcId`, relation/context kind, evidence source) and
|
||||
render it only when the dossier fingerprint changes. Do not query all five MC memories every
|
||||
frame.
|
||||
- Disaster impact routing must consume typed affected participants/outcomes from the event receipt;
|
||||
it must not add a recurring radius scan over the world.
|
||||
- Diagnostic logging remains rate-limited because the probe itself can create GC/I/O hitches.
|
||||
- Performance gates are evaluated on a developed world with Idle Spectator active, not only on a
|
||||
small synthetic map.
|
||||
|
||||
### Recognition
|
||||
|
||||
Recognition should come from repeated, stable signals rather than extra prose on every cut.
|
||||
|
||||
- Keep rail position stable for an incumbent whenever possible.
|
||||
- Keep each unit's latest unique portrait cached for the duration of roster membership and through a
|
||||
death/Legacy presentation.
|
||||
- On a meaningful return after the existing reentry threshold, allow one concise reintroduction:
|
||||
`Name again · <new development>`.
|
||||
- Do not use reintroduction wording for rapid A → B → A cuts, unchanged activity, or texture.
|
||||
- Identity remains `Name · real title`, otherwise `Name (species/job)`.
|
||||
- Hover remains the place for Cast and Legacy depth; the live caption stays Identity + Beat +
|
||||
Context.
|
||||
|
||||
### MC connection in the camera dossier
|
||||
|
||||
When the camera follows a non-MC because of an MC connection, the viewer must see that connection
|
||||
immediately.
|
||||
|
||||
- Add one muted, single-line **Connection** row directly below Identity and above the orange Beat:
|
||||
|
||||
```text
|
||||
Identity Omya (Human)
|
||||
Connection Waf's daughter
|
||||
Beat Escapes the burning city
|
||||
Context Waf's family is scattered
|
||||
```
|
||||
|
||||
- Connection is presentation metadata. It does not become part of the event sentence or replace
|
||||
story Context.
|
||||
- Prefer the clearest confirmed relation: `Waf's partner`, `Waf's daughter`, `Waf's father`,
|
||||
`Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
|
||||
`Caught in Waf's city`.
|
||||
- Name one anchor MC. If several MCs are involved, prefer the MC whose active thread admitted the
|
||||
shot; otherwise use the strongest direct relation. Additional MCs remain visible through involved
|
||||
rail glyphs.
|
||||
- Durable social relations outrank temporary theater/context relations.
|
||||
- Show Connection only when the focused character is not an MC, eligibility is `McCast` or
|
||||
`McThreadContext`, and an exact anchor MC id plus typed evidence is available.
|
||||
- Do not show Connection for an unrelated exceptional-background audition. Its Beat must explain
|
||||
the exceptional event directly.
|
||||
- Disaster wording distinguishes relationship from geography: use `Waf's daughter` when confirmed,
|
||||
otherwise factual context such as `Caught in Waf's city`.
|
||||
- Never derive Connection from display-name parsing, proximity alone, broad participant inventory,
|
||||
or final prose.
|
||||
- Clear Connection atomically when focus returns to an MC, world/location shot, unrelated
|
||||
exceptional character, or Saga hover.
|
||||
- Saga hover continues to show the hovered MC's Cast/Legacy and never retains the camera subject's
|
||||
Connection row.
|
||||
- Long lines remain one row. Ellipsize the MC name before removing the relation label; never wrap.
|
||||
|
||||
### Cross-MC moments
|
||||
|
||||
- A tip touching two or more of the five lights every involved rail glyph.
|
||||
- The orange Beat names the confirmed relationship or opposition once.
|
||||
- Both characters receive crossover heat and durable perspective projections where supported by the
|
||||
recorded event.
|
||||
- Cross-MC presentation must not invent “team,” “friend,” “rival,” or shared motive from mere
|
||||
co-location.
|
||||
|
||||
### Entrances, exits, and closure
|
||||
|
||||
- Admission records retain the evidence that made the character an MC.
|
||||
- The first meaningful camera appearance after admission may use Context to communicate that evidence
|
||||
when it is not already obvious from Identity or Beat.
|
||||
- Ordinary roster replacement is silent; avoid UI announcements for score churn.
|
||||
- Death, completed war/plot, lost leadership, and resolved relationship arcs may produce a bounded
|
||||
payoff or Legacy chapter before/after roster removal when presentable evidence exists.
|
||||
- No synthetic farewell is created solely because a character fell to rank six.
|
||||
|
||||
## Work phases
|
||||
|
||||
### Phase 0 — Baseline and instrumentation
|
||||
|
||||
1. Record a release baseline with the current ten-character roster:
|
||||
- authored cuts per MC;
|
||||
- unique MCs shown;
|
||||
- longest absence before return;
|
||||
- number of roster replacements;
|
||||
- exact rail reorder count;
|
||||
- cross-MC cuts;
|
||||
- cuts by subject class: MC, MC Cast, exceptional background, world/location-only;
|
||||
- reason for every exceptional background cut;
|
||||
- disaster theater cuts versus confirmed personal-impact cuts;
|
||||
- combat share and maximum combat run;
|
||||
- unresolved preferred/favorite overflow;
|
||||
- hitch count and maximum frame delta;
|
||||
- maximum director, discovery, and caption slice;
|
||||
- maximum roster world-scan and soft-refill slice;
|
||||
- scheduler tick time and candidate count;
|
||||
- portrait/reconcile time while rail membership is stable and while it changes.
|
||||
2. Add a compact roster diagnostic containing visible ids, effective scores, pin source, admission
|
||||
reason, last meaningful development, and replacement reason.
|
||||
3. Capture the baseline with the existing `hitch_probe`, `narrative_scheduler_health`, and
|
||||
`soak_probe` paths.
|
||||
4. Extend `scripts/soak-audit-player-log.sh` to summarize `[HITCH]` detail/summary lines in the
|
||||
selected byte window. Report active-only spike count, maximum frame delta, and maximum
|
||||
director/discovery/caption/roster/scheduler slices alongside editorial results.
|
||||
5. Do not tune scoring or performance thresholds during baseline capture.
|
||||
|
||||
Exit: one valid developed-world sample can explain every visible admission and eviction and provides
|
||||
a reproducible performance baseline from the same log window. It also establishes the current
|
||||
non-MC camera share and the selection path responsible for it.
|
||||
|
||||
### Phase 1 — Reduce the roster safely
|
||||
|
||||
1. Change the single roster capacity constant from 10 to 5.
|
||||
2. Audit loops, buffers, portrait caches, layout calculations, harness fixtures, prose, and
|
||||
documentation for hard-coded ten-slot assumptions.
|
||||
3. Keep scratch-buffer capacities independent where they represent challenger pools rather than
|
||||
visible slots.
|
||||
4. Implement deterministic pin overflow without deleting persisted preferences.
|
||||
5. On loading an existing sidecar, rebuild the visible five from live candidates; do not migrate or
|
||||
rewrite narrative event data merely because the presentation cap changed.
|
||||
6. Ensure a full five with all visible characters pinned cannot cause a crash, forced arbitrary
|
||||
removal, or permanent challenger starvation.
|
||||
7. Preserve the existing `WorldScanUnitsPerFrame`, `WorldScanBuildsPerFrame`, and timed scan cadence
|
||||
unless profiling demonstrates a measured need to change them.
|
||||
8. Keep overflow arbitration inside the bounded rank/cap stage. Do not add a second scan or a
|
||||
per-frame sort of every persisted preferred character.
|
||||
|
||||
Exit: old saves load, five glyphs render, all durable preferences remain queryable, and selection is
|
||||
stable across two rebuilds with identical inputs. A no-op rebuild stays within the baseline soft
|
||||
refill budget.
|
||||
|
||||
### Phase 2 — Stable recognition
|
||||
|
||||
1. Preserve incumbent rail order unless an admission, removal, death, or material rank crossing
|
||||
occurs.
|
||||
2. Verify portrait identity for same-species MCs and retain the last valid portrait for fallen MCs.
|
||||
3. Tighten the existing reentry behavior:
|
||||
- MC only;
|
||||
- meaningful typed development only;
|
||||
- minimum absence threshold;
|
||||
- one use per semantic development.
|
||||
4. Make the current camera MC and all involved MCs visually distinguishable without relying only on
|
||||
color.
|
||||
5. Add a small discoverable tooltip for Prefer that states it is a camera/story bias, not a camera
|
||||
lock.
|
||||
6. Bind and relayout the rail only when its ordered unit-id sequence or visible state actually
|
||||
changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits
|
||||
every frame.
|
||||
7. Add the one-line Connection slot to the camera dossier between Identity and Beat.
|
||||
8. Measure maximum authored Connection strings at supported UI scales and keep them to one
|
||||
ellipsized line.
|
||||
9. Include Connection identity in the dossier fingerprint so focus changes update atomically
|
||||
without recomposing unchanged prose every frame.
|
||||
|
||||
Exit: a player can scrub the five glyphs without geometry movement or identity ambiguity, and a
|
||||
returning MC is identifiable without opening Chronicle. Repeated no-change UI frames do not add
|
||||
caption or portrait slice regressions.
|
||||
|
||||
### Phase 3 — Entrances, crossovers, and closure
|
||||
|
||||
1. Expose admission evidence to presentation as structured context, not a final authored string.
|
||||
2. Show it only on the first suitable post-admission appearance and suppress it when Beat or
|
||||
Identity already communicates the same fact.
|
||||
3. Verify cross-MC participant inventory uses exact event receipts and lights the correct glyphs.
|
||||
4. Allow confirmed payoff/consequence shots for a recently displaced or dead MC without restoring
|
||||
roster membership solely for the shot.
|
||||
5. Verify Legacy records the lasting consequence once and does not echo current Cast or Identity.
|
||||
6. Confirm all new presentation lookups are O(1) or bounded by five/current event participants; no
|
||||
presentation frame may enumerate the full narrative event store.
|
||||
|
||||
Exit: admission, crossover, displacement, and death each have an evidence-backed presentation path
|
||||
with no synthetic emotion or duplicate prose.
|
||||
|
||||
### Phase 4 — MC-orbit camera eligibility
|
||||
|
||||
1. Add one shared camera-eligibility classifier at or immediately after candidate intake. It returns:
|
||||
- `McDirect`;
|
||||
- `McCast`;
|
||||
- `McThreadContext`;
|
||||
- `DisasterPersonalImpact`;
|
||||
- `ExceptionalBackground`;
|
||||
- `WorldCritical`;
|
||||
- `GroundingFill`;
|
||||
- `DiscoveryOnly`.
|
||||
2. Compute the class from typed participant ids, exact narrative development/thread identity,
|
||||
durable relation evidence, event outcome, and narrative function. Do not infer it from final
|
||||
prose.
|
||||
3. For `McCast` and `McThreadContext`, return a typed connection payload containing the selected
|
||||
anchor MC and evidence kind. Eligibility and UI consume the same result.
|
||||
4. Use the class consistently in scheduler proposals, interrupts, ordinary selection, character
|
||||
fill, switch policy, telemetry, and soak audit.
|
||||
5. Replace anonymous `TryCharacterFill` behavior with:
|
||||
- current Prefer/MC;
|
||||
- living confirmed MC Cast;
|
||||
- a location/theater establishing shot;
|
||||
- no character cut when none qualifies.
|
||||
6. Keep discovery-only candidates in the narrative/admission pipeline while excluding them from the
|
||||
ordinary camera pool.
|
||||
7. Define an authored/configurable exceptional-background threshold using narrative function,
|
||||
magnitude, event strength, novelty, and confirmed outcome. Raw combat score alone cannot pass it.
|
||||
8. Add a long cooldown and per-window budget for exceptional background cuts.
|
||||
9. Record why each non-MC/non-Cast character cut passed eligibility and the anchor/evidence used for
|
||||
every MC-related non-MC cut.
|
||||
|
||||
Exit: every routine character cut is MC-orbit footage; every background-character cut has an
|
||||
explicit exceptional reason; background story potential and roster challenges still advance. Every
|
||||
MC-related non-MC cut displays the same typed connection that granted camera eligibility.
|
||||
|
||||
### Phase 5 — Disaster impact
|
||||
|
||||
1. Extend canonical disaster observations with typed affected-character outcome where the game hook
|
||||
can confirm it.
|
||||
2. Project confirmed personal impact into the affected life without duplicating the world disaster
|
||||
record.
|
||||
3. Attach exact affected ids and outcome evidence to the candidate/event receipt.
|
||||
4. Route MC/MC-Cast impact into the active life story when related.
|
||||
5. Route severe background impact into story potential and the exceptional-background gate.
|
||||
6. Bound representative selection for mass impact and cool repeated theater/personal cards.
|
||||
7. Add telemetry for:
|
||||
- affected count;
|
||||
- confirmed versus proximity-only inventory;
|
||||
- selected representative class;
|
||||
- world card followed by distinct personal outcome;
|
||||
- suppressed duplicate/proximity candidates.
|
||||
|
||||
Exit: an MC struck by a disaster produces a coherent personal consequence, a severely affected
|
||||
background life can emerge, and nearby unaffected units do not receive significance.
|
||||
|
||||
### Phase 6 — Tune five-character pacing
|
||||
|
||||
Tune only after Phases 1–5 are observable.
|
||||
|
||||
1. Compare five-character results with the Phase 0 baseline.
|
||||
2. Adjust challenger margin, heat cap/decay, MC weight, Prefer weight, crossover bonus, and exposure
|
||||
debt only when a measured failure points to that knob.
|
||||
3. Target:
|
||||
- at least three of the five receive meaningful developments in a ten-minute developed-world
|
||||
session when footage exists;
|
||||
- no forced round-robin texture cuts;
|
||||
- no more than two unexplained roster replacements per ten minutes;
|
||||
- no immediate eviction/re-admission loop;
|
||||
- critical world-event latency remains unchanged;
|
||||
- combat remains below the existing 35% editorial gate;
|
||||
- routine background-character cuts are zero;
|
||||
- exceptional background cuts remain below 10% of authored character cuts unless a
|
||||
world-critical mass-casualty sequence is active;
|
||||
- every exceptional background cut has a typed reason and confirmed change/outcome;
|
||||
- at least one multi-development character thread is intelligible in the cut trace.
|
||||
4. If the same offices always occupy all five slots, tune diversity/admission rather than adding
|
||||
slots back.
|
||||
|
||||
Exit: the five feel recurring and distinct without narrowing world coverage to five camera targets.
|
||||
|
||||
### Phase 7 — Performance gate
|
||||
|
||||
1. Run `narrative_scheduler_health` with `hitch_probe` enabled on a developed world.
|
||||
2. Run a valid ten-minute active soak and audit the exact byte window with the extended soak tool.
|
||||
3. Compare against Phase 0:
|
||||
- no regression in maximum director, discovery, or caption slice;
|
||||
- no increase in scheduler candidate bound;
|
||||
- no recurring roster slice at or above the probe's 4 ms attribution threshold;
|
||||
- no new periodic frame-spike pattern at the 3.5-second roster-scan cadence;
|
||||
- no caption/portrait spike pattern while hovering or scrubbing all five glyphs;
|
||||
- showing/hiding Connection does not create a new caption relayout spike pattern;
|
||||
- no unbounded growth in narrative graph counts or compaction time;
|
||||
- fewer or equal rail rebuilds and portrait binds per minute.
|
||||
4. Exercise worst cases separately:
|
||||
- a mature high-population world;
|
||||
- more than five live preferred/favorite characters;
|
||||
- five same-species MCs with unique portrait captures;
|
||||
- rapid crossover events touching several MCs;
|
||||
- repeated admission challenges that do not cross the margin;
|
||||
- save/load followed by the first roster rebuild.
|
||||
5. Treat a threshold failure as a profiling task. Attribute it using the existing director,
|
||||
discovery, caption, scheduler-part, selector-part, feed-part, roster scan, and soft-refill
|
||||
timings before changing cadence or budgets.
|
||||
|
||||
Exit: the developed-world sample meets the existing hitch thresholds with no regression from the
|
||||
ten-MC baseline, and no new work scales per frame with world population or durable event count.
|
||||
|
||||
### Phase 8 — Viewer-comprehension validation
|
||||
|
||||
1. Run deterministic harness coverage.
|
||||
2. Run a ten-minute organic soak on a developed world.
|
||||
3. Capture screenshots/video for:
|
||||
- five full rail slots;
|
||||
- two same-species MCs;
|
||||
- active + involved crossover;
|
||||
- non-MC partner/child/friend/foe with visible Connection;
|
||||
- non-MC temporary war/disaster context with factual Connection;
|
||||
- preferred overflow;
|
||||
- newcomer admission;
|
||||
- returning MC reintroduction;
|
||||
- fallen MC Legacy.
|
||||
4. Review the cut trace without inspecting internal state and write a short account of each MC.
|
||||
5. The release succeeds only if at least one life can be summarized as a sequence of connected
|
||||
changes rather than a list of activities.
|
||||
|
||||
## Deterministic verification
|
||||
|
||||
Add or update these scenarios:
|
||||
|
||||
- `saga_roster_cap_five` — six eligible lives produce exactly five visible MCs.
|
||||
- `saga_cap_shrink_preserves_memory` — former slots retain durable events and Legacy.
|
||||
- `saga_prefer_overflow_stable` — six or more preferred/favorite lives select the same five on
|
||||
repeated rebuilds without erasing preferences.
|
||||
- `saga_preferred_challenger_returns` — an off-roster preferred life can re-enter after meaningful
|
||||
development.
|
||||
- `saga_incumbent_order_stable` — heat refresh without material rank crossing does not reorder the
|
||||
rail.
|
||||
- `saga_reentry_meaningful_once` — return voice appears once for a new semantic development and not
|
||||
for texture or rapid cutback.
|
||||
- `saga_cross_mc_five` — exact participants light Active/Involved glyphs and heat both MCs.
|
||||
- `saga_displaced_payoff` — a displaced former MC may receive a confirmed consequence shot without
|
||||
silently becoming an MC again.
|
||||
- `saga_death_closure_five` — death preserves the final portrait and produces one bounded Legacy
|
||||
consequence.
|
||||
- `saga_camera_world_coverage_five` — strangers and world-critical events remain camera-eligible.
|
||||
- `saga_background_texture_discovery_only` — a non-MC routine event grows no camera eligibility
|
||||
while remaining observable by admission/story systems.
|
||||
- `saga_background_exceptional_audition` — a confirmed high-impact turning point receives one
|
||||
cooled cut without automatically granting MC membership.
|
||||
- `saga_background_repeats_require_admission` — repeated non-MC developments cannot create an
|
||||
unofficial sixth MC through camera repetition.
|
||||
- `saga_fill_prefers_mc_orbit` — fill chooses MC, then confirmed MC Cast, then location/no cut;
|
||||
never an anonymous routine actor.
|
||||
- `saga_related_requires_evidence` — proximity, species, and broad participant snapshots do not
|
||||
classify a character as MC Cast/thread context.
|
||||
- `saga_connection_matches_eligibility` — every MC-related non-MC cut displays the same anchor MC
|
||||
and evidence kind that admitted it.
|
||||
- `saga_connection_relation_priority` — durable partner/kin/friend/foe wording wins over temporary
|
||||
shared-theater wording.
|
||||
- `saga_connection_multi_mc_anchor` — the active-thread MC is named while other exact MCs light as
|
||||
involved.
|
||||
- `saga_connection_clears_atomically` — the row disappears on MC, unrelated, location, and Saga
|
||||
hover transitions without showing stale identity for a frame.
|
||||
- `saga_connection_bounded_layout` — long names remain inside one row at every supported UI scale.
|
||||
- `saga_exceptional_background_no_fake_connection` — an unrelated exceptional audition shows no
|
||||
Connection row.
|
||||
- `saga_disaster_mc_impact` — confirmed disaster harm to an MC becomes a personal consequence after
|
||||
theater coverage.
|
||||
- `saga_disaster_background_significance` — severe confirmed impact can audition a background life
|
||||
and increase story potential.
|
||||
- `saga_disaster_proximity_not_significance` — nearby unaffected characters receive neither a
|
||||
personal Beat nor disaster story potential.
|
||||
- `saga_disaster_representative_bounded` — mass impact selects MC/MC Cast first and at most one
|
||||
distinct exceptional background representative.
|
||||
- `saga_five_refill_bounded` — no-op, challenger, and pin-overflow refills remain bounded and do not
|
||||
start an extra world scan.
|
||||
- `saga_five_ui_noop_perf` — stable rail frames do not relayout or recapture unchanged portraits.
|
||||
- `saga_five_perf_health` — hitch probe plus scheduler/roster metrics stay inside the release
|
||||
thresholds during a directed developed-world sample.
|
||||
|
||||
Retain the existing roster diversity, replacement, Prefer, camera-bias, crossover, persistence,
|
||||
caption, Legacy, scheduler, presentability, scheduler-health, and runtime-compaction regressions.
|
||||
|
||||
## Documentation updates at cutover
|
||||
|
||||
- Replace “top-10,” “up to 10,” and “ten-slot roster” with the five-MC contract in `README.md`,
|
||||
`docs/life-saga.md`, and `docs/story-planner.md`.
|
||||
- Update completed historical plans only with a short supersession note; do not rewrite historical
|
||||
soak evidence that accurately describes the former ten-character build.
|
||||
- Resolve `docs/saga-ux-redesign-plan.md` status against the implemented fixed panel and add its
|
||||
missing named UX scenarios to the release gate.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- The camera is not restricted to the five.
|
||||
- Five does not cap Chronicle, graves, durable narrative memory, or the number of lives that may
|
||||
become MCs over a world's history.
|
||||
- No lead/supporting sub-tier is added inside the five.
|
||||
- No fabricated transition, farewell, motive, emotion, or relationship is added to explain roster
|
||||
scoring.
|
||||
- No camera exclusivity that hides world-critical events or genuinely exceptional background
|
||||
turning points.
|
||||
- No automatic deletion of old Prefer choices.
|
||||
- This work does not reopen the story-first scheduler cutover or add new event families.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Exactly five MCs are visible and camera-biased at once.
|
||||
- Existing saves and narrative sidecars load without story loss or preference deletion.
|
||||
- Pin overflow is deterministic and reversible.
|
||||
- Routine background lives develop off-camera; camera character shifts primarily follow MCs and
|
||||
confirmed MC Cast/thread context.
|
||||
- Exceptional background and personal-disaster cuts require typed, telemetry-visible evidence.
|
||||
- Every MC-related non-MC focus explains its exact MC connection in the camera dossier.
|
||||
- Normal refill, presentation, and hover work remains bounded and does not introduce a new hitch
|
||||
pattern.
|
||||
- The extended soak audit reports performance and editorial health from the same active window.
|
||||
- Rail order and portraits make the five recognizable.
|
||||
- Entrances, returns, crossovers, and durable closure are evidence-backed and non-repetitive.
|
||||
- The camera still covers meaningful strangers and world-scale events.
|
||||
- Deterministic scenarios and the full existing regression pass.
|
||||
- A valid organic session produces at least one recountable multi-development character story.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Life-saga roster
|
||||
|
||||
Dynamic top-10 cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
|
||||
Dynamic five-character cast of interesting lives (MCs). Owns the dossier rail and camera priority.
|
||||
Chronicle stays a history book and never drives the camera.
|
||||
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
|
||||
Confirmed observations enter through
|
||||
|
|
@ -17,7 +17,7 @@ Beats. [`SagaProse`](../IdleSpectator/Story/SagaProse.cs) owns saga identity, Ca
|
|||
|
||||
| Concern | Owner |
|
||||
|---------|--------|
|
||||
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
|
||||
| Five MCs, diversity, Prefer | `LifeSagaRoster` |
|
||||
| Confirmed event identity and idempotency | `NarrativeEventRecorder` + `NarrativeEventStore` |
|
||||
| Durable projection/index facade | `LifeSagaMemory` + `NarrativeStoryStore` |
|
||||
| Versioned per-world persistence | `NarrativePersistence` + `NarrativePersistenceMapper` |
|
||||
|
|
@ -45,9 +45,9 @@ Heat comes from chapter stamps / featured tips and decays with idle time.
|
|||
Each slot keeps its original admission reason even when its live role later changes.
|
||||
Hard-arc admissions also retain the arc kind and chapter context that brought the life onto the roster.
|
||||
|
||||
## Dynamic top-10
|
||||
## Dynamic five
|
||||
|
||||
- Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
|
||||
- Cap is 5; order follows Effective = Standing + Heat + pin bonuses.
|
||||
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
|
||||
- Admission-role units always compete on the periodic scan.
|
||||
- Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed
|
||||
|
|
@ -78,6 +78,7 @@ Always-on AFK chrome (no Dossier/Saga tabs):
|
|||
| Row | Source | MC | Nobody |
|
||||
|-----|--------|----|--------|
|
||||
| Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` |
|
||||
| Connection | `CameraEligibility` | hidden | MC anchor/relation when this non-MC shot was admitted through MC Cast/thread context |
|
||||
| Beat | candidate `NarrativePresentationModel` | one complete semantic sentence | one complete semantic sentence |
|
||||
| Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only |
|
||||
|
||||
|
|
|
|||
135
docs/rare-creature-antagonist-plan.md
Normal file
135
docs/rare-creature-antagonist-plan.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Rare Creatures, Antagonists, and Camera Cleanup
|
||||
|
||||
Status: **Implemented; targeted and performance gates pass. Full regression is blocked by the
|
||||
legacy combat-presentability fixture's nondeterministic pair-to-mass reframe assertions.**
|
||||
|
||||
## Goal
|
||||
|
||||
Let rare supernatural creatures and evidence-backed recurring antagonists compete for one of the
|
||||
five MC slots without allowing species identity, incidental combat, or routine background events
|
||||
to destabilize the ensemble. Keep admission explainable in the dossier/logs and bounded on mature
|
||||
worlds.
|
||||
|
||||
## Phase 1 — Admission diagnostics
|
||||
|
||||
- [x] Record visible-slot admission reason, rarity population/tier, and effective score.
|
||||
- [x] Record antagonist evidence, challenger
|
||||
score, weakest replaceable MC score, and rejection/replacement reason.
|
||||
- [x] Include the compact visible-roster explanation in `soak_probe`.
|
||||
- [x] Include rejected challenger explanations in `soak_probe`.
|
||||
- [x] Rate-limit diagnostics so observation cannot create log/GC hitches.
|
||||
|
||||
Exit: a soak log explains why each visible MC entered and why a notable challenger did not.
|
||||
|
||||
## Phase 2 — Rare-creature admission
|
||||
|
||||
- [x] Add one authoritative special-creature classifier for dragons, druids, necromancers, evil
|
||||
mages, and verified equivalent supernatural actor assets.
|
||||
- [x] Combine a modest special-creature prior with a dynamic population tier: singleton, rare,
|
||||
uncommon/common.
|
||||
- [x] Make special/rare creatures admission candidates, but never guaranteed occupants.
|
||||
- [x] Feed the bounded bonus into challenger priority and roster standing.
|
||||
- [x] Resolve population once per existing amortized scan/cache cycle, never once per scored actor.
|
||||
- [x] Expose `RareCreature` and `SpecialCreature` admission reasons.
|
||||
|
||||
Exit: a lone dragon or mage enters the challenger pool; a common population receives little or no
|
||||
rarity benefit and cannot displace a developed MC on identity alone.
|
||||
|
||||
## Phase 3 — Recurring-antagonist lane
|
||||
|
||||
- [x] Reuse bounded durable-rival evidence keyed by antagonist id and anchor MC id.
|
||||
- [x] Accept typed hostile encounters, harm to MC cast/city/faction, repeated opposition, major
|
||||
destruction, and confirmed kills; reject incidental proximity and a single weak fight.
|
||||
- [x] Make threshold-crossing antagonists admission candidates and emerging-story challengers.
|
||||
- [x] Combine antagonist and rarity bonuses with the normal replacement margin.
|
||||
- [x] Remove dead/stale edges through the existing bounded life-memory pruning.
|
||||
- [x] Expose `RecurringAntagonist` admission reason and exact anchor/evidence.
|
||||
|
||||
Exit: O'Blight-like repeated opposition can earn a slot, while one incidental attacker cannot.
|
||||
|
||||
## Phase 4 — Exceptional-background policy
|
||||
|
||||
- [x] Replace the broad strong-typed-consequence gate with explicit exceptional outcomes.
|
||||
- [x] Reject unrelated routine birth, hatch, adulthood, sleep, food, reproduction, movement, and
|
||||
ordinary settlement activity.
|
||||
- [x] Allow direct MC relevance, first/last special species, major lineage change, disaster
|
||||
survival/loss, and major political/destructive consequences.
|
||||
- [x] Log the exact exception rule for every approved background cut.
|
||||
|
||||
Exit: routine unrelated background cuts approach zero without hiding important disaster victims or
|
||||
world-changing events.
|
||||
|
||||
## Phase 5 — Relationship/thread accuracy and dossier UI
|
||||
|
||||
- [x] Require an exact typed story edge for `McThreadContext`; do not infer it from broad city,
|
||||
kingdom, cluster, or stale thread proximity.
|
||||
- [x] Support family, partner, friend, and recurring-opponent evidence; exact event participants
|
||||
cover typed attacker/victim/rescuer events.
|
||||
- [x] Show friendly and hostile connection labels in the dossier, including the anchor MC.
|
||||
- [x] Fall back to exceptional background when no exact MC relationship exists.
|
||||
|
||||
Exit: every related non-MC cut has an accurate, visible explanation.
|
||||
|
||||
## Phase 6 — Texture pacing
|
||||
|
||||
- [x] Track recent low-information texture categories per MC.
|
||||
- [x] Cool down repeated sleep, laughter, play, caffeine, wandering, and generic lover-search cuts.
|
||||
- [x] Prefer unresolved developments, relationships, rivalries, and consequences through the
|
||||
existing story scheduler and variety score.
|
||||
- [x] Retain occasional quiet re-entry after the category cooldown.
|
||||
|
||||
Exit: quiet life remains visible without crowding out story development.
|
||||
|
||||
## Phase 7 — Deterministic coverage
|
||||
|
||||
- [x] Singleton dragon enters through the real admission path.
|
||||
- [ ] A larger dragon population receives a smaller bonus.
|
||||
- [ ] Rare identity alone does not evict a developed MC.
|
||||
- [x] Repeated conflict creates an antagonist challenger.
|
||||
- [x] One kill/fight cycle does not create an antagonist.
|
||||
- [ ] Harm to MC cast creates stronger evidence.
|
||||
- [ ] Combined rarity/antagonist bonuses respect their cap.
|
||||
- [x] Routine unrelated hatch is rejected; an explicit singleton-special exception is accepted.
|
||||
- [ ] Friendly/hostile dossier labels name the correct anchor.
|
||||
- [ ] Broad or stale faction correlation fails thread attribution.
|
||||
- [ ] The five-character roster stays stable across repeated scans.
|
||||
|
||||
## Phase 8 — Performance and organic soak gate
|
||||
|
||||
- [ ] Run the full deterministic regression suite without the legacy combat fixture failure.
|
||||
- [x] Run scheduler health with `hitch_probe` and an active organic soak audit.
|
||||
- [x] Compare maximum frame, director, discovery, caption, and scheduler slices
|
||||
with the pre-change baseline.
|
||||
- [x] Confirm population work is scan-cached and antagonist work is event-driven/bounded.
|
||||
- [x] Confirm routine unrelated background share approaches zero, significant antagonists can earn
|
||||
slots, and roster churn remains controlled.
|
||||
|
||||
Exit: editorial acceptance criteria pass with no material performance regression.
|
||||
|
||||
## Phase 9 — Long-soak stability tuning
|
||||
|
||||
- [x] Remove ordinary-species singleton identity as a standalone admission gate.
|
||||
- [x] Retain strong singleton standing only for curated special creatures; ordinary singleton
|
||||
metadata remains available to the dossier but receives only a small standing tie-breaker.
|
||||
- [x] Protect established MCs with an additional replacement margin after meaningful chapters or
|
||||
minimum tenure.
|
||||
- [x] Require undeveloped ordinary challengers to clear an additional replacement margin.
|
||||
- [x] Apply per-character texture-family cooldowns even when the event is not classified as a
|
||||
FixedDwell moment beat.
|
||||
- [x] Extend lover-search to a fifteen-minute per-character cooldown.
|
||||
- [x] Prevent undeveloped ordinary challengers from displacing established MCs regardless of raw
|
||||
standing; require a chapter, sufficient story heat, or meaningful scheduler potential.
|
||||
- [x] Stop a fifth consecutive ordinary combat cut while allowing kills, resolutions,
|
||||
consequences, and world-critical combat through.
|
||||
- [ ] Confirm reduced roster churn, no repeated lover-search cuts, and a maximum ordinary combat
|
||||
run of four in the next long organic soak.
|
||||
|
||||
Evidence prompting this phase: the first post-feature long soak successfully promoted evil mage
|
||||
Roum into a sustained MC story, but produced 26 distinct direct-MC subjects, four repeated
|
||||
lover-search cuts for Bim Llurr, and a seven-cut combat run. The first stability soak reduced the
|
||||
normalized churn rate by roughly 37% and capped combat runs at four, but still showed three
|
||||
lover-search cuts for Yaaahaona and 13 direct MCs in the developed-world portion.
|
||||
|
||||
Final deterministic probes confirm the fifteen-minute lover gate and ordinary-challenger evidence
|
||||
gate. A mature-save instrumented run settled from one 28.5 ms load/warm-up frame to 17.6 ms and
|
||||
then 16.7 ms windows; scheduler cost was 0.24 ms with 268 developments and 597 character states.
|
||||
|
|
@ -17,7 +17,7 @@ There is **no** rail Commit / hard pin anymore.
|
|||
- `StoryPlanner.Clear` does **not** wipe the life-saga roster.
|
||||
- Combat spine Kind comes from sticky/live ensemble scale + `StoryArcKind`, not tip Label prefixes.
|
||||
|
||||
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
|
||||
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 5 MCs).
|
||||
Click toggles Prefer (soft favorite).
|
||||
AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)).
|
||||
Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`).
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ drops = []
|
|||
bads = []
|
||||
scenes = []
|
||||
states = []
|
||||
hitch_details = []
|
||||
hitch_summaries = []
|
||||
first_active_line = -1
|
||||
last = None
|
||||
|
||||
|
|
@ -110,6 +112,10 @@ for line_index, ln in enumerate(lines):
|
|||
first_active_line = line_index
|
||||
if last is not None and last.get("unit") is None and "unit=" in ln:
|
||||
last["unit"] = ln.split("unit=", 1)[1].strip()
|
||||
elif "[HITCH] spike" in ln:
|
||||
hitch_details.append(ln)
|
||||
elif "[HITCH] summary" in ln:
|
||||
hitch_summaries.append(ln)
|
||||
|
||||
drop_kinds = Counter()
|
||||
for d in drops:
|
||||
|
|
@ -206,6 +212,52 @@ print(f"placeholder tips: {placeholder}")
|
|||
print(f"object falls: {len(veg)}")
|
||||
print(f"fight tip/focus mismatches: {len(mismatches)}")
|
||||
print(f"fighting+Sleeping lag: {len(sleep_lag)}")
|
||||
active_hitches = [h for h in hitch_details if "idle=on" in h]
|
||||
|
||||
def metric_max(rows, name):
|
||||
values = []
|
||||
pattern = re.compile(rf"\b{re.escape(name)}=([0-9.]+)ms")
|
||||
for row in rows:
|
||||
match = pattern.search(row)
|
||||
if match:
|
||||
values.append(float(match.group(1)))
|
||||
return max(values, default=0.0)
|
||||
|
||||
summary_spikes = 0
|
||||
summary_max_dt = 0.0
|
||||
summary_max_dir = 0.0
|
||||
summary_max_disc = 0.0
|
||||
summary_max_cap = 0.0
|
||||
for row in hitch_summaries:
|
||||
if "idle=on" not in row:
|
||||
continue
|
||||
spike = re.search(r"\bspikes=(\d+)", row)
|
||||
dt = re.search(r"\bmaxDt=([0-9.]+)ms", row)
|
||||
direct = re.search(r"\bmaxDir=([0-9.]+)ms", row)
|
||||
discovery = re.search(r"\bmaxDisc=([0-9.]+)ms", row)
|
||||
caption = re.search(r"\bmaxCap=([0-9.]+)ms", row)
|
||||
if spike:
|
||||
summary_spikes += int(spike.group(1))
|
||||
if dt:
|
||||
summary_max_dt = max(summary_max_dt, float(dt.group(1)))
|
||||
if direct:
|
||||
summary_max_dir = max(summary_max_dir, float(direct.group(1)))
|
||||
if discovery:
|
||||
summary_max_disc = max(summary_max_disc, float(discovery.group(1)))
|
||||
if caption:
|
||||
summary_max_cap = max(summary_max_cap, float(caption.group(1)))
|
||||
|
||||
print(
|
||||
"Hitches active: "
|
||||
f"detail={len(active_hitches)} summary_spikes={summary_spikes} "
|
||||
f"maxDt={max(summary_max_dt, metric_max(active_hitches, 'dt')):.1f}ms "
|
||||
f"dir={max(summary_max_dir, metric_max(active_hitches, 'dir')):.1f}ms "
|
||||
f"disc={max(summary_max_disc, metric_max(active_hitches, 'disc')):.1f}ms "
|
||||
f"cap={max(summary_max_cap, metric_max(active_hitches, 'cap')):.1f}ms "
|
||||
f"scan={metric_max(active_hitches, 'scanMs'):.1f}ms "
|
||||
f"soft={metric_max(active_hitches, 'softMs'):.1f}ms "
|
||||
f"scheduler={metric_max(active_hitches, 'schedulerMs'):.1f}ms"
|
||||
)
|
||||
print()
|
||||
if buckets:
|
||||
print("Buckets:")
|
||||
|
|
|
|||
Loading…
Reference in a new issue