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;
|
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":
|
case "saga_note_encounters":
|
||||||
{
|
{
|
||||||
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
|
@ -4452,6 +4482,38 @@ public static class AgentHarness
|
||||||
break;
|
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":
|
case "hitch_probe":
|
||||||
{
|
{
|
||||||
_preserveSagaAfterBatch = true;
|
_preserveSagaAfterBatch = true;
|
||||||
|
|
@ -6735,10 +6797,18 @@ public static class AgentHarness
|
||||||
return;
|
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)
|
if (follow == null)
|
||||||
{
|
{
|
||||||
follow = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
follow = focused;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (follow == null)
|
if (follow == null)
|
||||||
|
|
@ -10026,6 +10096,18 @@ public static class AgentHarness
|
||||||
detail = $"rival focus={SafeName(focus)} have={have} want={want} note={rival?.Note}";
|
detail = $"rival focus={SafeName(focus)} have={have} want={want} note={rival?.Note}";
|
||||||
break;
|
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":
|
case "saga_crossover_focus":
|
||||||
{
|
{
|
||||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
|
@ -10309,6 +10391,35 @@ public static class AgentHarness
|
||||||
detail = $"context='{ctx}' pass={pass}";
|
detail = $"context='{ctx}' pass={pass}";
|
||||||
break;
|
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":
|
case "caption_context_omits":
|
||||||
{
|
{
|
||||||
// Soft tip hitch: Context must not show banned biography fragments.
|
// 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}";
|
$"admission={slot?.AdmissionReason.ToString() ?? "missing"} id={slot?.UnitId ?? 0}";
|
||||||
break;
|
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":
|
case "saga_role_tip":
|
||||||
{
|
{
|
||||||
string have = SampleRailTip();
|
string have = SampleRailTip();
|
||||||
|
|
@ -14491,7 +14648,7 @@ public static class AgentHarness
|
||||||
|
|
||||||
var board = new List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
var board = new List<LifeSagaSlot>(LifeSagaRoster.Cap);
|
||||||
LifeSagaRoster.CopySlots(board);
|
LifeSagaRoster.CopySlots(board);
|
||||||
var names = new StringBuilder(160);
|
var names = new StringBuilder(320);
|
||||||
for (int i = 0; i < board.Count; i++)
|
for (int i = 0; i < board.Count; i++)
|
||||||
{
|
{
|
||||||
LifeSagaSlot s = board[i];
|
LifeSagaSlot s = board[i];
|
||||||
|
|
@ -14516,7 +14673,24 @@ public static class AgentHarness
|
||||||
n += "^";
|
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 ?? "";
|
string tipLabel = tip?.Label ?? CameraDirector.LastWatchLabel ?? "";
|
||||||
|
|
@ -14526,7 +14700,8 @@ public static class AgentHarness
|
||||||
+ $"softRank={softRank} touchedMcs={touchedMcs} "
|
+ $"softRank={softRank} touchedMcs={touchedMcs} "
|
||||||
+ $"rival={rival} crossoverFacts={crossovers} facts={facts} lives={LifeSagaMemory.LifeCount} "
|
+ $"rival={rival} crossoverFacts={crossovers} facts={facts} lives={LifeSagaMemory.LifeCount} "
|
||||||
+ $"roster={LifeSagaRoster.Count} tip={tipLabel} "
|
+ $"roster={LifeSagaRoster.Count} tip={tipLabel} "
|
||||||
+ $"score={InterestDirector.CurrentScoreLabel} names=[{names}]";
|
+ $"score={InterestDirector.CurrentScoreLabel} names=[{names}] "
|
||||||
|
+ $"lastAdmission=[{LifeSagaRoster.LastAdmissionDiagnostic}]";
|
||||||
|
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
Emit(cmd, ok: true, detail: detail);
|
Emit(cmd, ok: true, detail: detail);
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,10 @@ internal static class HarnessScenarios
|
||||||
return SagaCameraPrefersMc();
|
return SagaCameraPrefersMc();
|
||||||
case "saga_roster_diversity":
|
case "saga_roster_diversity":
|
||||||
return SagaRosterDiversity();
|
return SagaRosterDiversity();
|
||||||
|
case "saga_roster_cap_five":
|
||||||
|
return SagaRosterCapFive();
|
||||||
|
case "saga_connection_dossier":
|
||||||
|
return SagaConnectionDossier();
|
||||||
case "saga_camera_prefers_mc":
|
case "saga_camera_prefers_mc":
|
||||||
return SagaCameraPrefersMc();
|
return SagaCameraPrefersMc();
|
||||||
case "saga_replace_on_death":
|
case "saga_replace_on_death":
|
||||||
|
|
@ -1456,6 +1460,9 @@ internal static class HarnessScenarios
|
||||||
Step("cf8", "pick_unit", asset: "human"),
|
Step("cf8", "pick_unit", asset: "human"),
|
||||||
Step("cf9", "focus", asset: "human"),
|
Step("cf9", "focus", asset: "human"),
|
||||||
Step("cf10", "interest_end_session"),
|
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.
|
// 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.
|
// 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("cf39b", "spawn", asset: "human", count: 1),
|
||||||
Step("cf39c", "spawn", asset: "wolf", count: 1),
|
Step("cf39c", "spawn", asset: "wolf", count: 1),
|
||||||
Step("cf39d", "pick_unit", asset: "human"),
|
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("cf40", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_ensemble"),
|
||||||
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
Step("cf41", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
||||||
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
Step("cf42", "combat_ensemble_escalate", value: "6"),
|
||||||
|
|
@ -1537,6 +1546,8 @@ internal static class HarnessScenarios
|
||||||
Step("cf48b", "spawn", asset: "evil_mage", count: 1),
|
Step("cf48b", "spawn", asset: "evil_mage", count: 1),
|
||||||
Step("cf48c", "spawn", asset: "human", count: 1),
|
Step("cf48c", "spawn", asset: "human", count: 1),
|
||||||
Step("cf48d", "pick_unit", asset: "evil_mage"),
|
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"),
|
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).
|
// Soak shape: 1 spectacle vs a massed civ side (not a tiny 1v8 skirmish).
|
||||||
Step("cf48f", "combat_ensemble_escalate", value: "24"),
|
Step("cf48f", "combat_ensemble_escalate", value: "24"),
|
||||||
|
|
@ -1565,6 +1576,8 @@ internal static class HarnessScenarios
|
||||||
Step("cf50b", "spawn", asset: "human", count: 1),
|
Step("cf50b", "spawn", asset: "human", count: 1),
|
||||||
Step("cf50c", "spawn", asset: "wolf", count: 1),
|
Step("cf50c", "spawn", asset: "wolf", count: 1),
|
||||||
Step("cf50d", "pick_unit", asset: "human"),
|
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("cf51", "interest_combat_session", asset: "human", value: "wolf", expect: "cf_natural"),
|
||||||
Step("cf51b", "combat_isolate_pair", value: "20"),
|
Step("cf51b", "combat_isolate_pair", value: "20"),
|
||||||
Step("cf52", "assert", expect: "tip_matches_any", value: "Duel|fighting| vs "),
|
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("ncc3", "focus", asset: "human"),
|
||||||
Step("ncc4", "narrative_camera_cutover_probe"),
|
Step("ncc4", "narrative_camera_cutover_probe"),
|
||||||
Step("ncc5", "assert", expect: "no_bad"),
|
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")
|
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>
|
/// <summary>Prefer toggle soft-biases a near-tie toward the Prefer'd MC.</summary>
|
||||||
private static List<HarnessCommand> SagaPreferSoftBias()
|
private static List<HarnessCommand> SagaPreferSoftBias()
|
||||||
{
|
{
|
||||||
|
|
@ -3767,6 +3826,7 @@ internal static class HarnessScenarios
|
||||||
Step("srk11", "saga_force_admit_focus"),
|
Step("srk11", "saga_force_admit_focus"),
|
||||||
Step("srk12", "saga_record_kill_partner"),
|
Step("srk12", "saga_record_kill_partner"),
|
||||||
Step("srk13", "assert", expect: "saga_rival_focus", value: "false"),
|
Step("srk13", "assert", expect: "saga_rival_focus", value: "false"),
|
||||||
|
Step("srk14", "assert", expect: "saga_antagonist_partner", value: "false"),
|
||||||
Step("srk99", "snapshot"),
|
Step("srk99", "snapshot"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -3804,6 +3864,9 @@ internal static class HarnessScenarios
|
||||||
Step("srr11", "saga_force_admit_focus"),
|
Step("srr11", "saga_force_admit_focus"),
|
||||||
Step("srr12", "saga_note_encounters", value: "3"),
|
Step("srr12", "saga_note_encounters", value: "3"),
|
||||||
Step("srr13", "assert", expect: "saga_rival_focus"),
|
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"),
|
Step("srr99", "snapshot"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -4464,14 +4527,15 @@ internal static class HarnessScenarios
|
||||||
Step("sar8", "interest_saga_clear"),
|
Step("sar8", "interest_saga_clear"),
|
||||||
Step("sar9", "pick_unit", asset: "dragon"),
|
Step("sar9", "pick_unit", asset: "dragon"),
|
||||||
Step("sar10", "focus", asset: "dragon"),
|
Step("sar10", "focus", asset: "dragon"),
|
||||||
// Force admit so role flags refresh from live singleton/pop helpers.
|
// Real admission path: special/rare identity opens the challenger gate.
|
||||||
Step("sar11", "saga_force_admit_focus"),
|
Step("sar11", "saga_consider_admit_focus"),
|
||||||
Step("sar12", "assert", expect: "saga_has_focus"),
|
Step("sar12", "assert", expect: "saga_has_focus"),
|
||||||
Step("sar13", "saga_rail_refresh"),
|
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.
|
// Harness admission proves an ordinary human can occupy the ensemble.
|
||||||
Step("sar20", "pick_unit", asset: "human"),
|
Step("sar20", "pick_unit", asset: "human"),
|
||||||
Step("sar21", "focus", asset: "human"),
|
Step("sar21", "focus", asset: "human"),
|
||||||
|
Step("sar21b", "assert", expect: "saga_focus_admission_eligible", value: "false"),
|
||||||
Step("sar22", "saga_force_admit_focus"),
|
Step("sar22", "saga_force_admit_focus"),
|
||||||
Step("sar23", "assert", expect: "saga_has_focus"),
|
Step("sar23", "assert", expect: "saga_has_focus"),
|
||||||
Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
|
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("srw44", "pick_unit", asset: "human", value: "other"),
|
||||||
Step("srw45", "focus", asset: "human"),
|
Step("srw45", "focus", asset: "human"),
|
||||||
Step("srw46", "saga_force_admit_focus"),
|
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"),
|
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("srw60", "pick_unit", asset: "human", value: "other"),
|
||||||
Step("srw61", "focus", asset: "human"),
|
Step("srw61", "focus", asset: "human"),
|
||||||
Step("srw62", "saga_force_admit_focus"),
|
Step("srw62", "saga_force_admit_focus"),
|
||||||
Step("srw63", "assert", expect: "saga_has_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.
|
// Prefer pin still present; challenger focus itself is not Prefer.
|
||||||
Step("srw70", "assert", expect: "saga_prefer_focus_on", value: "false"),
|
Step("srw70", "assert", expect: "saga_prefer_focus_on", value: "false"),
|
||||||
Step("srw71", "assert", expect: "saga_prefer_count", value: "1", label: "min"),
|
Step("srw71", "assert", expect: "saga_prefer_count", value: "1", label: "min"),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ public static partial class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate != _current && InterestVariety.IsCombatRunBlocked(candidate))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record("combat_run_budget", candidate.Label ?? candidate.Key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (candidate != _current
|
if (candidate != _current
|
||||||
&& !StoryScheduler.MayUseCombatShot(candidate))
|
&& !StoryScheduler.MayUseCombatShot(candidate))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2056,9 +2056,20 @@ public static partial class InterestDirector
|
||||||
continue;
|
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.
|
// Drop stale / not-worth-watching / unpresentable shots from the pending set.
|
||||||
// Harness synthetic labels may be unpresentable (dossier blanks); keep them for tip tests.
|
// 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);
|
bool presentable = harness || EventPresentability.WouldShow(c);
|
||||||
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|
||||||
|| IsStaleFreshLifeMoment(c, now)
|
|| IsStaleFreshLifeMoment(c, now)
|
||||||
|
|
@ -2839,6 +2850,13 @@ public static partial class InterestDirector
|
||||||
InterestRegistry.MarkSelected(next.Key);
|
InterestRegistry.MarkSelected(next.Key);
|
||||||
InterestVariety.NoteSelection(next);
|
InterestVariety.NoteSelection(next);
|
||||||
StoryPlanner.OnSwitchTo(next, now);
|
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());
|
CameraDirector.Watch(next.ToInterestEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2870,12 +2888,27 @@ public static partial class InterestDirector
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
InterestEvent ambient = WorldActivityScanner.FindBestLiveTarget();
|
Actor grounding = LifeSagaRoster.BestLivingMc();
|
||||||
if (ambient == null)
|
if (grounding == null)
|
||||||
{
|
{
|
||||||
return;
|
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.
|
// Character fill never owns an orange reason - empty Label + CharacterLed.
|
||||||
// Force fill score so RegisterDirect does not publish as EventLed.
|
// Force fill score so RegisterDirect does not publish as EventLed.
|
||||||
ambient.Label = "";
|
ambient.Label = "";
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,89 @@ public static class InterestVariety
|
||||||
/// <summary>Harness/debug: tips recorded in the rarity window.</summary>
|
/// <summary>Harness/debug: tips recorded in the rarity window.</summary>
|
||||||
public static int ArcWindowCount => RecentArcs.Count;
|
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()
|
public static void Clear()
|
||||||
{
|
{
|
||||||
RecentMix.Clear();
|
RecentMix.Clear();
|
||||||
|
|
@ -148,6 +231,20 @@ public static class InterestVariety
|
||||||
StampCooldown("verb:" + chosen.Verb, until * 0.4f);
|
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.
|
// Moment beats: hard suppress the same story on the same subject.
|
||||||
if (IsMomentBeat(chosen))
|
if (IsMomentBeat(chosen))
|
||||||
{
|
{
|
||||||
|
|
@ -166,6 +263,7 @@ public static class InterestVariety
|
||||||
{
|
{
|
||||||
StampCooldown(beatKey, beatUntil);
|
StampCooldown(beatKey, beatUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sticky scenes are allowed to remain on screen while live, but once the editor
|
// Sticky scenes are allowed to remain on screen while live, but once the editor
|
||||||
|
|
@ -815,7 +913,28 @@ public static class InterestVariety
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsBeatCooled(InterestCandidate c, float now = -1f)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -828,11 +947,6 @@ public static class InterestVariety
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now < 0f)
|
|
||||||
{
|
|
||||||
now = Time.unscaledTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (string beatKey in EnumerateBeatKeys(c))
|
foreach (string beatKey in EnumerateBeatKeys(c))
|
||||||
{
|
{
|
||||||
if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until)
|
if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until)
|
||||||
|
|
@ -844,6 +958,35 @@ public static class InterestVariety
|
||||||
return false;
|
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>
|
/// <summary>
|
||||||
/// Pick next candidate. Soft 70/30: prefer event-led when below target and pool non-empty.
|
/// 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).
|
/// 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.IdentityLine = BuildIdentityLine(focus, focusId);
|
||||||
|
model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId);
|
||||||
model.ReasonRelatedId = reasonRelatedId;
|
model.ReasonRelatedId = reasonRelatedId;
|
||||||
|
|
||||||
string beat = presentableBeat ?? "";
|
string beat = presentableBeat ?? "";
|
||||||
|
|
@ -136,7 +137,8 @@ public static class CaptionComposer
|
||||||
}
|
}
|
||||||
|
|
||||||
model.Fingerprint =
|
model.Fingerprint =
|
||||||
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine
|
focusId + "|" + model.IdentityLine + "|" + model.ConnectionLine
|
||||||
|
+ "|" + model.BeatLine + "|" + model.ContextLine
|
||||||
+ "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision;
|
+ "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision;
|
||||||
|
|
||||||
LastIdentityLine = model.IdentityLine;
|
LastIdentityLine = model.IdentityLine;
|
||||||
|
|
@ -842,6 +844,7 @@ public static class CaptionComposer
|
||||||
public sealed class CaptionModel
|
public sealed class CaptionModel
|
||||||
{
|
{
|
||||||
public string IdentityLine = "";
|
public string IdentityLine = "";
|
||||||
|
public string ConnectionLine = "";
|
||||||
public string BeatLine = "";
|
public string BeatLine = "";
|
||||||
public string ContextLine = "";
|
public string ContextLine = "";
|
||||||
public long ReasonRelatedId;
|
public long ReasonRelatedId;
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ using UnityEngine;
|
||||||
namespace IdleSpectator;
|
namespace IdleSpectator;
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// Independent of short-arc <see cref="StoryPlanner"/> Clear.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class LifeSagaRoster
|
public static class LifeSagaRoster
|
||||||
{
|
{
|
||||||
public const int Cap = 10;
|
public const int Cap = 5;
|
||||||
public const int MaxChapters = 3;
|
public const int MaxChapters = 3;
|
||||||
public const float ChallengerMargin = 1.5f;
|
public const float ChallengerMargin = 1.5f;
|
||||||
public const float HeatCap = 12f;
|
public const float HeatCap = 12f;
|
||||||
|
|
@ -55,6 +55,13 @@ public static class LifeSagaRoster
|
||||||
private const int WorldScanBuildsPerFrame = 2;
|
private const int WorldScanBuildsPerFrame = 2;
|
||||||
|
|
||||||
private const int MaxScanAdmits = Cap * 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>
|
/// <summary>Cadence for starting a spread world admission scan.</summary>
|
||||||
private const float WorldScanIntervalSeconds = 3.5f;
|
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>
|
/// <summary>Last timed soft refill cost in ms (hitch probe).</summary>
|
||||||
public static float LastSoftRefillMs { get; private set; }
|
public static float LastSoftRefillMs { get; private set; }
|
||||||
|
public static string LastAdmissionDiagnostic { get; private set; } = "";
|
||||||
|
|
||||||
public static int Count => Slots.Count;
|
public static int Count => Slots.Count;
|
||||||
|
|
||||||
|
|
@ -99,6 +107,7 @@ public static class LifeSagaRoster
|
||||||
_scanLimit = 0;
|
_scanLimit = 0;
|
||||||
_scanCommitIndex = 0;
|
_scanCommitIndex = 0;
|
||||||
_liveScanSource = null;
|
_liveScanSource = null;
|
||||||
|
LastAdmissionDiagnostic = "";
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -429,6 +438,43 @@ public static class LifeSagaRoster
|
||||||
return bestPrefer ?? bestMc;
|
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)
|
public static bool TipTouchesMc(InterestCandidate c)
|
||||||
{
|
{
|
||||||
if (c == null)
|
if (c == null)
|
||||||
|
|
@ -1032,6 +1078,9 @@ public static class LifeSagaRoster
|
||||||
bool admission = IsAdmissionCandidate(actor);
|
bool admission = IsAdmissionCandidate(actor);
|
||||||
if (!admission && !emergingStoryAdmit)
|
if (!admission && !emergingStoryAdmit)
|
||||||
{
|
{
|
||||||
|
LastAdmissionDiagnostic =
|
||||||
|
"id=" + unitId + " result=rejected reason=ineligible asset="
|
||||||
|
+ (actor.asset?.id ?? "");
|
||||||
return false;
|
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);
|
Scratch.Add(neu);
|
||||||
RankAndCap(Scratch, now, PreviousIds);
|
RankAndCap(Scratch, now, PreviousIds);
|
||||||
Slots.Clear();
|
Slots.Clear();
|
||||||
|
|
@ -1061,7 +1119,23 @@ public static class LifeSagaRoster
|
||||||
}
|
}
|
||||||
|
|
||||||
_dirty = true;
|
_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)
|
private static void ConsiderEmergingStories(float now)
|
||||||
|
|
@ -1327,7 +1401,6 @@ public static class LifeSagaRoster
|
||||||
for (int d = 0; d < displaced.Count; d++)
|
for (int d = 0; d < displaced.Count; d++)
|
||||||
{
|
{
|
||||||
LifeSagaSlot incumbent = displaced[d];
|
LifeSagaSlot incumbent = displaced[d];
|
||||||
float need = EffectiveInterest(incumbent, now, dull) + ChallengerMargin;
|
|
||||||
int worstNewcomer = -1;
|
int worstNewcomer = -1;
|
||||||
float worstScore = float.MaxValue;
|
float worstScore = float.MaxValue;
|
||||||
for (int i = 0; i < kept.Count; i++)
|
for (int i = 0; i < kept.Count; i++)
|
||||||
|
|
@ -1351,6 +1424,17 @@ public static class LifeSagaRoster
|
||||||
break;
|
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)
|
if (worstScore + 0.001f < need)
|
||||||
{
|
{
|
||||||
kept[worstNewcomer] = incumbent;
|
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)
|
private static int CompareSlots(LifeSagaSlot a, LifeSagaSlot b, float now, float dull)
|
||||||
{
|
{
|
||||||
if (a == null && b == null)
|
if (a == null && b == null)
|
||||||
|
|
@ -1403,6 +1581,12 @@ public static class LifeSagaRoster
|
||||||
return pin;
|
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);
|
int fav = (b.GameFavorite ? 1 : 0).CompareTo(a.GameFavorite ? 1 : 0);
|
||||||
if (fav != 0)
|
if (fav != 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1417,7 +1601,8 @@ public static class LifeSagaRoster
|
||||||
return cmp;
|
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)
|
public static float EffectiveInterest(LifeSagaSlot s, float now = -1f, float dull = -1f)
|
||||||
|
|
@ -1558,6 +1743,16 @@ public static class LifeSagaRoster
|
||||||
// ignore
|
// 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))
|
if (InterestScoring.IsNotable(actor))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -1568,7 +1763,9 @@ public static class LifeSagaRoster
|
||||||
|| IsArmyCaptain(actor)
|
|| IsArmyCaptain(actor)
|
||||||
|| IsActivePlotAuthor(actor)
|
|| IsActivePlotAuthor(actor)
|
||||||
|| IsFounder(actor)
|
|| IsFounder(actor)
|
||||||
|| IsSpeciesSingleton(actor))
|
|| IsSpecialCreature(actor)
|
||||||
|
|| IsRareCreature(actor)
|
||||||
|
|| TryGetRecurringAntagonist(actor, out _, out _))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1576,6 +1773,8 @@ public static class LifeSagaRoster
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool HarnessIsAdmissionCandidate(Actor actor) => IsAdmissionCandidate(actor);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
|
/// Cheap challenger ordering before BuildSlot - prefer favorites / kings / leaders, then roles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -1668,14 +1867,81 @@ public static class LifeSagaRoster
|
||||||
p += 120f;
|
p += 120f;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsActivePlotAuthor(actor) || IsFounder(actor) || IsSpeciesSingleton(actor))
|
if (IsActivePlotAuthor(actor)
|
||||||
|
|| IsFounder(actor)
|
||||||
|
|| (IsSpecialCreature(actor) && IsSpeciesSingleton(actor)))
|
||||||
{
|
{
|
||||||
p += 80f;
|
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;
|
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)
|
public static bool IsPackAlpha(Actor actor)
|
||||||
{
|
{
|
||||||
if (actor == null)
|
if (actor == null)
|
||||||
|
|
@ -1945,44 +2211,80 @@ public static class LifeSagaRoster
|
||||||
|
|
||||||
public static bool IsSpeciesSingleton(Actor actor)
|
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;
|
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 ?? "";
|
string speciesId = actor.asset.id ?? "";
|
||||||
if (string.IsNullOrEmpty(speciesId))
|
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
|
return population;
|
||||||
// owns its units, so stop at two instead of rebuilding counts for the whole world.
|
}
|
||||||
population = 0;
|
|
||||||
try
|
// 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
|
}
|
||||||
{
|
catch
|
||||||
population = 2;
|
{
|
||||||
}
|
population = RareSpeciesMaxPopulation + 1;
|
||||||
|
|
||||||
SpeciesCountCache[speciesId] = population;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return population == 1;
|
SpeciesCountCache[speciesId] = population;
|
||||||
|
return population;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ActorMatchesFounder(object meta, Actor actor)
|
private static bool ActorMatchesFounder(object meta, Actor actor)
|
||||||
|
|
@ -2158,7 +2460,17 @@ public static class LifeSagaRoster
|
||||||
s.IsArmyCaptain = IsArmyCaptain(actor);
|
s.IsArmyCaptain = IsArmyCaptain(actor);
|
||||||
s.IsPlotAuthor = IsActivePlotAuthor(actor);
|
s.IsPlotAuthor = IsActivePlotAuthor(actor);
|
||||||
s.IsFounder = IsFounder(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.Kills = meta?.Kills ?? 0;
|
||||||
s.Renown = meta?.Renown ?? 0f;
|
s.Renown = meta?.Renown ?? 0f;
|
||||||
// Standing from flags already resolved above - avoid a second clan/alpha/founder reflection walk.
|
// Standing from flags already resolved above - avoid a second clan/alpha/founder reflection walk.
|
||||||
|
|
@ -2227,10 +2539,28 @@ public static class LifeSagaRoster
|
||||||
score += 7f;
|
score += 7f;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.IsSingleton)
|
if (s.IsSingleton && s.IsSpecialCreature)
|
||||||
{
|
{
|
||||||
score += 6f;
|
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)
|
if (s.IsFounder)
|
||||||
{
|
{
|
||||||
|
|
@ -2374,7 +2704,10 @@ public static class LifeSagaRoster
|
||||||
if (s.IsArmyCaptain) return LifeSagaAdmissionReason.ArmyCaptain;
|
if (s.IsArmyCaptain) return LifeSagaAdmissionReason.ArmyCaptain;
|
||||||
if (s.IsFounder) return LifeSagaAdmissionReason.Founder;
|
if (s.IsFounder) return LifeSagaAdmissionReason.Founder;
|
||||||
if (s.IsPlotAuthor) return LifeSagaAdmissionReason.PlotAuthor;
|
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.Kills > 0) return LifeSagaAdmissionReason.NotableKills;
|
||||||
if (s.Renown > 0f) return LifeSagaAdmissionReason.NotableRenown;
|
if (s.Renown > 0f) return LifeSagaAdmissionReason.NotableRenown;
|
||||||
return LifeSagaAdmissionReason.NotableLife;
|
return LifeSagaAdmissionReason.NotableLife;
|
||||||
|
|
@ -2419,7 +2752,7 @@ public static class LifeSagaRoster
|
||||||
score += 7f;
|
score += 7f;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsSpeciesSingleton(actor))
|
if (IsSpecialCreature(actor) && IsSpeciesSingleton(actor))
|
||||||
{
|
{
|
||||||
score += 6f;
|
score += 6f;
|
||||||
}
|
}
|
||||||
|
|
@ -2549,6 +2882,12 @@ public sealed class LifeSagaSlot
|
||||||
public bool IsPlotAuthor;
|
public bool IsPlotAuthor;
|
||||||
public bool IsFounder;
|
public bool IsFounder;
|
||||||
public bool IsSingleton;
|
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 int Kills;
|
||||||
public float Renown;
|
public float Renown;
|
||||||
/// <summary>Live role/standing recomputed each refresh.</summary>
|
/// <summary>Live role/standing recomputed each refresh.</summary>
|
||||||
|
|
@ -2584,6 +2923,9 @@ public enum LifeSagaAdmissionReason
|
||||||
Founder,
|
Founder,
|
||||||
PlotAuthor,
|
PlotAuthor,
|
||||||
Singleton,
|
Singleton,
|
||||||
|
RecurringAntagonist,
|
||||||
|
RareCreature,
|
||||||
|
SpecialCreature,
|
||||||
NotableKills,
|
NotableKills,
|
||||||
NotableRenown,
|
NotableRenown,
|
||||||
NotableLife,
|
NotableLife,
|
||||||
|
|
|
||||||
|
|
@ -565,6 +565,12 @@ public static class SagaProse
|
||||||
return Sentence("Leads a plot");
|
return Sentence("Leads a plot");
|
||||||
case LifeSagaAdmissionReason.Singleton:
|
case LifeSagaAdmissionReason.Singleton:
|
||||||
return Sentence("Last of their kind");
|
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:
|
case LifeSagaAdmissionReason.Founder:
|
||||||
return Sentence("Defends what they founded");
|
return Sentence("Defends what they founded");
|
||||||
case LifeSagaAdmissionReason.CityLeader:
|
case LifeSagaAdmissionReason.CityLeader:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ public static class WatchCaption
|
||||||
private const float TraitsH = 13f;
|
private const float TraitsH = 13f;
|
||||||
private const float StatusesH = 13f;
|
private const float StatusesH = 13f;
|
||||||
private const float ReasonH = 13f;
|
private const float ReasonH = 13f;
|
||||||
|
private const float ConnectionH = 11f;
|
||||||
private const float StorySpineH = 11f;
|
private const float StorySpineH = 11f;
|
||||||
private const float HistoryLineMinH = 12f;
|
private const float HistoryLineMinH = 12f;
|
||||||
private const float HistoryLineMaxH = 100f;
|
private const float HistoryLineMaxH = 100f;
|
||||||
|
|
@ -62,6 +63,7 @@ public static class WatchCaption
|
||||||
private static readonly Color TaskColor = HudTheme.TaskGreen;
|
private static readonly Color TaskColor = HudTheme.TaskGreen;
|
||||||
private static readonly Color ReasonColor = HudTheme.ValueOrange;
|
private static readonly Color ReasonColor = HudTheme.ValueOrange;
|
||||||
private static readonly Color StorySpineColor = HudTheme.Muted;
|
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 TraitNameColor = HudTheme.Body;
|
||||||
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
|
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
|
||||||
private static readonly Color HistoryTextColor = HudTheme.Body;
|
private static readonly Color HistoryTextColor = HudTheme.Body;
|
||||||
|
|
@ -73,6 +75,7 @@ public static class WatchCaption
|
||||||
private static Image _sexIcon;
|
private static Image _sexIcon;
|
||||||
private static Text _nameText;
|
private static Text _nameText;
|
||||||
private static Text _reasonText;
|
private static Text _reasonText;
|
||||||
|
private static Text _connectionText;
|
||||||
private static Text _storySpineText;
|
private static Text _storySpineText;
|
||||||
private static long _reasonOtherId;
|
private static long _reasonOtherId;
|
||||||
|
|
||||||
|
|
@ -194,6 +197,7 @@ public static class WatchCaption
|
||||||
|
|
||||||
/// <summary>Harness: composed context line (spine or stake).</summary>
|
/// <summary>Harness: composed context line (spine or stake).</summary>
|
||||||
public static string LastContextLine => CaptionComposer.LastContextLine;
|
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>
|
/// <summary>Harness/UI: active context line under the beat (spine or stake), empty when none.</summary>
|
||||||
public static string LastStorySpine { get; private set; } = "";
|
public static string LastStorySpine { get; private set; } = "";
|
||||||
|
|
@ -509,6 +513,7 @@ public static class WatchCaption
|
||||||
LastDetail = "";
|
LastDetail = "";
|
||||||
LastCaptionText = "";
|
LastCaptionText = "";
|
||||||
LastStorySpine = "";
|
LastStorySpine = "";
|
||||||
|
LastConnectionLine = "";
|
||||||
CaptionComposer.Clear();
|
CaptionComposer.Clear();
|
||||||
LifeSagaViewController.Clear();
|
LifeSagaViewController.Clear();
|
||||||
LifeSagaPanel.Clear();
|
LifeSagaPanel.Clear();
|
||||||
|
|
@ -1202,6 +1207,7 @@ public static class WatchCaption
|
||||||
{
|
{
|
||||||
RefreshSagaBody();
|
RefreshSagaBody();
|
||||||
ApplyReasonText("", 0);
|
ApplyReasonText("", 0);
|
||||||
|
ApplyConnection("");
|
||||||
ApplyStorySpine("");
|
ApplyStorySpine("");
|
||||||
_dossierRowsNeedRestore = true;
|
_dossierRowsNeedRestore = true;
|
||||||
}
|
}
|
||||||
|
|
@ -1240,7 +1246,7 @@ public static class WatchCaption
|
||||||
|| historyCount > 0
|
|| historyCount > 0
|
||||||
|| _current != null);
|
|| _current != null);
|
||||||
string layoutKey =
|
string layoutKey =
|
||||||
$"{(panelMode ? 1 : 0)}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
|
$"{(panelMode ? 1 : 0)}|{LastConnectionLine}|{LastContextLine}|{traitCount}|{statusCount}|{historyCount}"
|
||||||
+ $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}"
|
+ $"|{(hasTask ? 1 : 0)}|{(hasReason ? 1 : 0)}|{hasBody}"
|
||||||
+ $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}"
|
+ $"|{LifeSagaPanel.BodyHeight:F0}|{LifeSagaRail.LastShownCount}|{LifeSagaRail.Visible}"
|
||||||
+ $"|{LastBeatLine}|{LastHeadline}";
|
+ $"|{LastBeatLine}|{LastHeadline}";
|
||||||
|
|
@ -1274,6 +1280,7 @@ public static class WatchCaption
|
||||||
reasonRelatedId,
|
reasonRelatedId,
|
||||||
spineFiltered,
|
spineFiltered,
|
||||||
statusBannerOwnsBeat);
|
statusBannerOwnsBeat);
|
||||||
|
ApplyConnection(model.ConnectionLine ?? "");
|
||||||
if (string.Equals(
|
if (string.Equals(
|
||||||
model.Fingerprint,
|
model.Fingerprint,
|
||||||
_lastAppliedCaptionFingerprint,
|
_lastAppliedCaptionFingerprint,
|
||||||
|
|
@ -1339,6 +1346,26 @@ public static class WatchCaption
|
||||||
model.ContextLine);
|
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>
|
/// <summary>
|
||||||
/// Re-show trait/status/history chrome after hover hid them.
|
/// Re-show trait/status/history chrome after hover hid them.
|
||||||
/// Uses the bound dossier snapshot so hover-exit is instant; recomposes caption.
|
/// Uses the bound dossier snapshot so hover-exit is instant; recomposes caption.
|
||||||
|
|
@ -3129,6 +3156,15 @@ public static class WatchCaption
|
||||||
|
|
||||||
if (!panelMode)
|
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
|
// 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).
|
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
|
||||||
if (historyCount <= 0 && _historyCol != null)
|
if (historyCount <= 0 && _historyCol != null)
|
||||||
|
|
@ -4092,6 +4128,7 @@ public static class WatchCaption
|
||||||
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform
|
if (_root != null && (_nameText == null || _nameText.transform.parent != _root.transform
|
||||||
|| _historyCol == null
|
|| _historyCol == null
|
||||||
|| _statusesRow == null
|
|| _statusesRow == null
|
||||||
|
|| _connectionText == null
|
||||||
|| _favoriteBtn == null
|
|| _favoriteBtn == null
|
||||||
|| _root.transform.Find("HistoryBtn") != null
|
|| _root.transform.Find("HistoryBtn") != null
|
||||||
|| _root.transform.Find("HistoryScroll") != null))
|
|| _root.transform.Find("HistoryScroll") != null))
|
||||||
|
|
@ -4112,9 +4149,11 @@ public static class WatchCaption
|
||||||
_sexIcon = null;
|
_sexIcon = null;
|
||||||
_nameText = null;
|
_nameText = null;
|
||||||
_reasonText = null;
|
_reasonText = null;
|
||||||
|
_connectionText = null;
|
||||||
_storySpineText = null;
|
_storySpineText = null;
|
||||||
_reasonOtherId = 0;
|
_reasonOtherId = 0;
|
||||||
LastStorySpine = "";
|
LastStorySpine = "";
|
||||||
|
LastConnectionLine = "";
|
||||||
_levelIcon = null;
|
_levelIcon = null;
|
||||||
_levelValue = null;
|
_levelValue = null;
|
||||||
_taskIcon = null;
|
_taskIcon = null;
|
||||||
|
|
@ -4261,6 +4300,15 @@ public static class WatchCaption
|
||||||
_reasonText.resizeTextForBestFit = false;
|
_reasonText.resizeTextForBestFit = false;
|
||||||
WireReasonClick(0);
|
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 = HudCanvas.MakeText(_root.transform, "StorySpine", "", 7);
|
||||||
_storySpineText.color = StorySpineColor;
|
_storySpineText.color = StorySpineColor;
|
||||||
_storySpineText.supportRichText = false;
|
_storySpineText.supportRichText = false;
|
||||||
|
|
@ -4302,6 +4350,7 @@ public static class WatchCaption
|
||||||
_statusesRow.SetActive(false);
|
_statusesRow.SetActive(false);
|
||||||
_traitsRow.SetActive(false);
|
_traitsRow.SetActive(false);
|
||||||
_reasonText.gameObject.SetActive(false);
|
_reasonText.gameObject.SetActive(false);
|
||||||
|
_connectionText.gameObject.SetActive(false);
|
||||||
_storySpineText.gameObject.SetActive(false);
|
_storySpineText.gameObject.SetActive(false);
|
||||||
_levelIcon.gameObject.SetActive(false);
|
_levelIcon.gameObject.SetActive(false);
|
||||||
_levelValue.gameObject.SetActive(false);
|
_levelValue.gameObject.SetActive(false);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
|
||||||
|
|
||||||
## What it follows
|
## 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
|
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
|
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
|
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
|
`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.
|
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
|
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
|
per-world sidecar at the game save boundary. Runtime camera state and episode timing remain
|
||||||
session-only.
|
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
|
# 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.
|
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.
|
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
|
||||||
Confirmed observations enter through
|
Confirmed observations enter through
|
||||||
|
|
@ -17,7 +17,7 @@ Beats. [`SagaProse`](../IdleSpectator/Story/SagaProse.cs) owns saga identity, Ca
|
||||||
|
|
||||||
| Concern | Owner |
|
| Concern | Owner |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
|
| Five MCs, diversity, Prefer | `LifeSagaRoster` |
|
||||||
| Confirmed event identity and idempotency | `NarrativeEventRecorder` + `NarrativeEventStore` |
|
| Confirmed event identity and idempotency | `NarrativeEventRecorder` + `NarrativeEventStore` |
|
||||||
| Durable projection/index facade | `LifeSagaMemory` + `NarrativeStoryStore` |
|
| Durable projection/index facade | `LifeSagaMemory` + `NarrativeStoryStore` |
|
||||||
| Versioned per-world persistence | `NarrativePersistence` + `NarrativePersistenceMapper` |
|
| 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.
|
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.
|
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.
|
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
|
||||||
- Admission-role units always compete on the periodic scan.
|
- Admission-role units always compete on the periodic scan.
|
||||||
- Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed
|
- 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 |
|
| Row | Source | MC | Nobody |
|
||||||
|-----|--------|----|--------|
|
|-----|--------|----|--------|
|
||||||
| Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` |
|
| 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 |
|
| 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 |
|
| 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.
|
- `StoryPlanner.Clear` does **not** wipe the life-saga roster.
|
||||||
- Combat spine Kind comes from sticky/live ensemble scale + `StoryArcKind`, not tip Label prefixes.
|
- 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).
|
Click toggles Prefer (soft favorite).
|
||||||
AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)).
|
AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)).
|
||||||
Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`).
|
Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`).
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ drops = []
|
||||||
bads = []
|
bads = []
|
||||||
scenes = []
|
scenes = []
|
||||||
states = []
|
states = []
|
||||||
|
hitch_details = []
|
||||||
|
hitch_summaries = []
|
||||||
first_active_line = -1
|
first_active_line = -1
|
||||||
last = None
|
last = None
|
||||||
|
|
||||||
|
|
@ -110,6 +112,10 @@ for line_index, ln in enumerate(lines):
|
||||||
first_active_line = line_index
|
first_active_line = line_index
|
||||||
if last is not None and last.get("unit") is None and "unit=" in ln:
|
if last is not None and last.get("unit") is None and "unit=" in ln:
|
||||||
last["unit"] = ln.split("unit=", 1)[1].strip()
|
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()
|
drop_kinds = Counter()
|
||||||
for d in drops:
|
for d in drops:
|
||||||
|
|
@ -206,6 +212,52 @@ print(f"placeholder tips: {placeholder}")
|
||||||
print(f"object falls: {len(veg)}")
|
print(f"object falls: {len(veg)}")
|
||||||
print(f"fight tip/focus mismatches: {len(mismatches)}")
|
print(f"fight tip/focus mismatches: {len(mismatches)}")
|
||||||
print(f"fighting+Sleeping lag: {len(sleep_lag)}")
|
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()
|
print()
|
||||||
if buckets:
|
if buckets:
|
||||||
print("Buckets:")
|
print("Buckets:")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue