feat(spectator): make narrative threads own camera direction
- Route confirmed life events into evidence-based threads, consequences, and Legacy chapters - Schedule episode shots with critical interrupts, replay guards, and a combat budget - Admit emerging main characters through story momentum instead of spectacle - Remove causal heat, hard-arc memory writes, and synthetic epilogue continuity - Expand narrative harness coverage, soak auditing, and product documentation
This commit is contained in:
parent
a0d1b9a688
commit
739cc6492c
39 changed files with 4505 additions and 679 deletions
|
|
@ -61,6 +61,10 @@ public static class ActivityLog
|
||||||
private static int _statusRefreshesSinceClear;
|
private static int _statusRefreshesSinceClear;
|
||||||
private static string _lastStatusGainId = "";
|
private static string _lastStatusGainId = "";
|
||||||
private static string _lastStatusLossId = "";
|
private static string _lastStatusLossId = "";
|
||||||
|
private static readonly Dictionary<string, int> StatusGainHitsSinceReset =
|
||||||
|
new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static readonly Dictionary<string, int> StatusLossHitsSinceReset =
|
||||||
|
new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
private static int _happinessSinceClear;
|
private static int _happinessSinceClear;
|
||||||
private static string _lastHappinessId = "";
|
private static string _lastHappinessId = "";
|
||||||
/// <summary>When non-zero, status counters only track this subject (harness isolation).</summary>
|
/// <summary>When non-zero, status counters only track this subject (harness isolation).</summary>
|
||||||
|
|
@ -78,6 +82,21 @@ public static class ActivityLog
|
||||||
public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear;
|
public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear;
|
||||||
public static string LastStatusGainId => _lastStatusGainId ?? "";
|
public static string LastStatusGainId => _lastStatusGainId ?? "";
|
||||||
public static string LastStatusLossId => _lastStatusLossId ?? "";
|
public static string LastStatusLossId => _lastStatusLossId ?? "";
|
||||||
|
public static int StatusGainCount(string statusId)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(statusId)
|
||||||
|
&& StatusGainHitsSinceReset.TryGetValue(statusId, out int count)
|
||||||
|
? count
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int StatusLossCount(string statusId)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(statusId)
|
||||||
|
&& StatusLossHitsSinceReset.TryGetValue(statusId, out int count)
|
||||||
|
? count
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
public static int HappinessSinceClear => _happinessSinceClear;
|
public static int HappinessSinceClear => _happinessSinceClear;
|
||||||
public static string LastHappinessId => _lastHappinessId ?? "";
|
public static string LastHappinessId => _lastHappinessId ?? "";
|
||||||
public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear;
|
public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear;
|
||||||
|
|
@ -119,6 +138,8 @@ public static class ActivityLog
|
||||||
_statusRefreshesSinceClear = 0;
|
_statusRefreshesSinceClear = 0;
|
||||||
_lastStatusGainId = "";
|
_lastStatusGainId = "";
|
||||||
_lastStatusLossId = "";
|
_lastStatusLossId = "";
|
||||||
|
StatusGainHitsSinceReset.Clear();
|
||||||
|
StatusLossHitsSinceReset.Clear();
|
||||||
_statusCounterSubjectId = 0;
|
_statusCounterSubjectId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,6 +812,8 @@ public static class ActivityLog
|
||||||
{
|
{
|
||||||
_statusGainsSinceClear++;
|
_statusGainsSinceClear++;
|
||||||
_lastStatusGainId = id;
|
_lastStatusGainId = id;
|
||||||
|
StatusGainHitsSinceReset.TryGetValue(id, out int hits);
|
||||||
|
StatusGainHitsSinceReset[id] = hits + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -799,6 +822,8 @@ public static class ActivityLog
|
||||||
{
|
{
|
||||||
_statusLossesSinceClear++;
|
_statusLossesSinceClear++;
|
||||||
_lastStatusLossId = id;
|
_lastStatusLossId = id;
|
||||||
|
StatusLossHitsSinceReset.TryGetValue(id, out int hits);
|
||||||
|
StatusLossHitsSinceReset[id] = hits + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ public static class ActorChroniclePatches
|
||||||
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
|
public static void PostfixNewKill(Actor __instance, Actor pDeadUnit)
|
||||||
{
|
{
|
||||||
LifeSagaMemory.RecordKill(__instance, pDeadUnit);
|
LifeSagaMemory.RecordKill(__instance, pDeadUnit);
|
||||||
|
NarrativeDevelopmentRouter.Kill(__instance, pDeadUnit, "newKillAction");
|
||||||
Chronicle.NoteKill(__instance, pDeadUnit);
|
Chronicle.NoteKill(__instance, pDeadUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ public static class ActorChroniclePatches
|
||||||
}
|
}
|
||||||
|
|
||||||
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
|
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
|
||||||
|
NarrativeDevelopmentRouter.Death(__instance, __state, pType.ToString(), "die");
|
||||||
Chronicle.NoteDeath(__instance, pType, __state);
|
Chronicle.NoteDeath(__instance, pType, __state);
|
||||||
GraveMarkers.AddDeath(__instance);
|
GraveMarkers.AddDeath(__instance);
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +64,7 @@ public static class ActorChroniclePatches
|
||||||
public static void PostfixLovers(Actor __instance, Actor pTarget)
|
public static void PostfixLovers(Actor __instance, Actor pTarget)
|
||||||
{
|
{
|
||||||
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
|
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
|
||||||
|
NarrativeDevelopmentRouter.BondFormed(__instance, pTarget, "becomeLoversWith");
|
||||||
Chronicle.NoteLovers(__instance, pTarget);
|
Chronicle.NoteLovers(__instance, pTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +78,7 @@ public static class ActorChroniclePatches
|
||||||
}
|
}
|
||||||
|
|
||||||
LifeSagaMemory.RecordFriendFormed(__instance, pActor);
|
LifeSagaMemory.RecordFriendFormed(__instance, pActor);
|
||||||
|
NarrativeDevelopmentRouter.FriendFormed(__instance, pActor, "setBestFriend");
|
||||||
Chronicle.NoteBestFriends(__instance, pActor);
|
Chronicle.NoteBestFriends(__instance, pActor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2884,6 +2884,14 @@ public static class AgentHarness
|
||||||
Emit(cmd, ok: true, detail: "variety_cleared");
|
Emit(cmd, ok: true, detail: "variety_cleared");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "editorial_replay_probe":
|
||||||
|
{
|
||||||
|
bool ok = InterestVariety.HarnessProbeEditorialReplay(out string detail);
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "interest_story_clear":
|
case "interest_story_clear":
|
||||||
StoryPlanner.Clear();
|
StoryPlanner.Clear();
|
||||||
_cmdOk++;
|
_cmdOk++;
|
||||||
|
|
@ -2898,6 +2906,326 @@ public static class AgentHarness
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "narrative_scheduler_probe":
|
||||||
|
{
|
||||||
|
bool ok = StoryScheduler.HarnessProbePolicy(out string detail);
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "narrative_camera_cutover_probe":
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
string detail = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||||
|
Actor child = FindOtherAliveUnit(protagonist, "human");
|
||||||
|
Actor outsider = null;
|
||||||
|
foreach (Actor candidate in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (candidate != null && candidate.isAlive()
|
||||||
|
&& candidate != protagonist && candidate != child)
|
||||||
|
{
|
||||||
|
outsider = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protagonist == null || child == null || outsider == null)
|
||||||
|
{
|
||||||
|
detail = "missing actors";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
InterestDirector.HarnessEndCurrent("narrative_cutover_setup");
|
||||||
|
InterestRegistry.Clear();
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
|
NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_cutover");
|
||||||
|
|
||||||
|
InterestCandidate unrelated = HarnessNarrativeCandidate(
|
||||||
|
"harness:narrative:unrelated", outsider, "Unrelated high event", 90f);
|
||||||
|
InterestCandidate related = HarnessNarrativeCandidate(
|
||||||
|
"harness:narrative:related", protagonist, "Lineage develops", 60f);
|
||||||
|
related.AssetId = "add_child";
|
||||||
|
EventFeedUtil.RegisterCandidate(unrelated);
|
||||||
|
EventFeedUtil.RegisterCandidate(related);
|
||||||
|
StoryScheduler.Tick(Time.unscaledTime + 1f);
|
||||||
|
InterestCandidate first = InterestDirector.HarnessPeekNext();
|
||||||
|
bool relatedWon = first != null && first.Key == related.Key;
|
||||||
|
bool watched = relatedWon && InterestDirector.HarnessForceSession(first);
|
||||||
|
InterestDirector.HarnessAgeCurrent(20f);
|
||||||
|
|
||||||
|
InterestCandidate critical = HarnessNarrativeCandidate(
|
||||||
|
"harness:narrative:critical", outsider, "Kingdom shattered", 100f);
|
||||||
|
critical.Category = "Disaster";
|
||||||
|
EventFeedUtil.RegisterCandidate(critical);
|
||||||
|
StoryScheduler.Tick(Time.unscaledTime + 2f);
|
||||||
|
InterestCandidate second = InterestDirector.HarnessPeekNext();
|
||||||
|
bool criticalWon = second != null && second.Key == critical.Key;
|
||||||
|
ok = relatedWon && watched && criticalWon;
|
||||||
|
detail = "first=" + (first?.Key ?? "none")
|
||||||
|
+ " watched=" + watched
|
||||||
|
+ " second=" + (second?.Key ?? "none")
|
||||||
|
+ " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none")
|
||||||
|
+ " pass=" + ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "narrative_story_inputs_probe":
|
||||||
|
{
|
||||||
|
Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||||
|
Actor child = FindOtherAliveUnit(protagonist, "human");
|
||||||
|
bool recorded = false;
|
||||||
|
bool admitted = false;
|
||||||
|
float potential = 0f;
|
||||||
|
if (protagonist != null && child != null)
|
||||||
|
{
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
|
LifeSagaRoster.Clear();
|
||||||
|
recorded = NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_story_inputs");
|
||||||
|
potential = StoryScheduler.StoryPotential(EventFeedUtil.SafeId(protagonist));
|
||||||
|
LifeSagaRoster.Tick(Time.unscaledTime + 2f);
|
||||||
|
LifeSagaSlot slot = LifeSagaRoster.Get(EventFeedUtil.SafeId(protagonist));
|
||||||
|
admitted = slot != null && slot.AdmissionReason == LifeSagaAdmissionReason.EmergingStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
var consequence = new InterestCandidate
|
||||||
|
{
|
||||||
|
AssetId = "add_child", Category = "Family", EventStrength = 52f
|
||||||
|
};
|
||||||
|
var escalation = new InterestCandidate
|
||||||
|
{
|
||||||
|
Completion = InterestCompletionKind.WarFront, Category = "War", EventStrength = 65f
|
||||||
|
};
|
||||||
|
var texture = new InterestCandidate
|
||||||
|
{
|
||||||
|
LeadKind = InterestLeadKind.CharacterLed,
|
||||||
|
Completion = InterestCompletionKind.CharacterVignette,
|
||||||
|
EventStrength = 80f
|
||||||
|
};
|
||||||
|
NarrativeFunction cf = NarrativeFunctionPolicy.Classify(consequence);
|
||||||
|
NarrativeFunction ef = NarrativeFunctionPolicy.Classify(escalation);
|
||||||
|
NarrativeFunction tf = NarrativeFunctionPolicy.Classify(texture);
|
||||||
|
bool ok = recorded && potential >= 6f && admitted
|
||||||
|
&& cf == NarrativeFunction.Consequence
|
||||||
|
&& ef == NarrativeFunction.Escalation
|
||||||
|
&& tf == NarrativeFunction.CharacterTexture;
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: "recorded=" + recorded
|
||||||
|
+ " potential=" + potential.ToString("0.0")
|
||||||
|
+ " admitted=" + admitted
|
||||||
|
+ " functions=" + cf + "/" + ef + "/" + tf);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "narrative_soak_seed":
|
||||||
|
{
|
||||||
|
Actor protagonist = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
|
||||||
|
Actor child = FindOtherAliveUnit(protagonist, "human");
|
||||||
|
bool ok = protagonist != null && child != null;
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
InterestDirector.HarnessEndCurrent("narrative_soak_seed");
|
||||||
|
InterestRegistry.Clear();
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
|
NarrativeDevelopmentRouter.ChildBorn(protagonist, child, "harness_story_soak");
|
||||||
|
InterestCandidate related = HarnessNarrativeCandidate(
|
||||||
|
"harness:narrative:add_child", protagonist, "Welcomes a child", 58f);
|
||||||
|
related.AssetId = "add_child";
|
||||||
|
related.Category = "Family";
|
||||||
|
EventFeedUtil.RegisterCandidate(related);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: "seeded=" + ok
|
||||||
|
+ " protagonist=" + EventFeedUtil.SafeId(protagonist)
|
||||||
|
+ " child=" + EventFeedUtil.SafeId(child));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "narrative_telemetry_probe":
|
||||||
|
{
|
||||||
|
bool ok = NarrativeTelemetry.Count > 0
|
||||||
|
&& NarrativeTelemetry.RelatedProposalCount > 0
|
||||||
|
&& NarrativeTelemetry.ComparedCount > 0
|
||||||
|
&& NarrativeTelemetry.RelatedActualCount > 0;
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: NarrativeTelemetry.Summary
|
||||||
|
+ " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "narrative_catalog_probe":
|
||||||
|
{
|
||||||
|
NarrativeFunctionCatalog.ClearCoverage();
|
||||||
|
var child = new InterestCandidate { Key = "catalog:child", AssetId = "add_child" };
|
||||||
|
var war = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "catalog:war", AssetId = "live_war", Completion = InterestCompletionKind.WarFront
|
||||||
|
};
|
||||||
|
var daily = new InterestCandidate { Key = "catalog:daily", HappinessEffectId = "just_slept" };
|
||||||
|
var disaster = new InterestCandidate { Key = "catalog:disaster", AssetId = "disaster_tornado" };
|
||||||
|
NarrativeFunctionCatalog.Stamp(child);
|
||||||
|
NarrativeFunctionCatalog.Stamp(war);
|
||||||
|
NarrativeFunctionCatalog.Stamp(daily);
|
||||||
|
NarrativeFunctionCatalog.Stamp(disaster);
|
||||||
|
bool ok = child.NarrativeFunction == NarrativeFunction.Consequence
|
||||||
|
&& war.NarrativeFunction == NarrativeFunction.Escalation
|
||||||
|
&& daily.NarrativeFunction == NarrativeFunction.CharacterTexture
|
||||||
|
&& disaster.NarrativeFunction == NarrativeFunction.WorldContext
|
||||||
|
&& NarrativeFunctionCatalog.ExplicitCount == 4
|
||||||
|
&& NarrativeFunctionCatalog.FallbackCount == 0;
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: NarrativeFunctionCatalog.CoverageSummary);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "narrative_natural_soak_begin":
|
||||||
|
InterestDirector.HarnessEndCurrent("narrative_natural_soak");
|
||||||
|
InterestRegistry.Clear();
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
|
NarrativeFunctionCatalog.ClearCoverage();
|
||||||
|
NarrativeTelemetry.Clear();
|
||||||
|
_cmdOk++;
|
||||||
|
Emit(cmd, ok: true, detail: "natural_soak_reset");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "narrative_natural_soak_probe":
|
||||||
|
{
|
||||||
|
int typed = NarrativeFunctionCatalog.ExplicitCount + NarrativeFunctionCatalog.StructuralCount;
|
||||||
|
float typedRatio = NarrativeFunctionCatalog.ObservedCount > 0
|
||||||
|
? typed / (float)NarrativeFunctionCatalog.ObservedCount
|
||||||
|
: 0f;
|
||||||
|
bool cameraHealthy = MoveCamera.hasFocusUnit()
|
||||||
|
&& MoveCamera._focus_unit != null
|
||||||
|
&& MoveCamera._focus_unit.isAlive();
|
||||||
|
bool ok = NarrativeFunctionCatalog.ObservedCount > 0
|
||||||
|
&& typed > 0
|
||||||
|
&& typedRatio >= 0.25f
|
||||||
|
&& cameraHealthy;
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: NarrativeFunctionCatalog.CoverageSummary
|
||||||
|
+ " typedRatio=" + typedRatio.ToString("0.00")
|
||||||
|
+ " cameraHealthy=" + cameraHealthy
|
||||||
|
+ " telemetry=" + NarrativeTelemetry.Summary
|
||||||
|
+ " episode=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "narrative_multithread_probe":
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
string detail = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var actors = new List<Actor>(6);
|
||||||
|
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||||
|
{
|
||||||
|
if (actor != null && actor.isAlive() && !actors.Contains(actor)) actors.Add(actor);
|
||||||
|
if (actors.Count >= 5) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actors.Count < 5)
|
||||||
|
{
|
||||||
|
detail = "actors=" + actors.Count;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Actor a = actors[0];
|
||||||
|
Actor childA = actors[1];
|
||||||
|
Actor b = actors[2];
|
||||||
|
Actor childB = actors[3];
|
||||||
|
Actor outsider = actors[4];
|
||||||
|
long aid = EventFeedUtil.SafeId(a);
|
||||||
|
long bid = EventFeedUtil.SafeId(b);
|
||||||
|
|
||||||
|
InterestDirector.HarnessEndCurrent("narrative_multithread_setup");
|
||||||
|
InterestRegistry.Clear();
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
|
LifeSagaRoster.Clear();
|
||||||
|
LifeSagaRoster.HarnessForceAdmit(a);
|
||||||
|
LifeSagaRoster.SetPrefer(aid, true);
|
||||||
|
NarrativeDevelopmentRouter.ChildBorn(a, childA, "harness_multithread_a");
|
||||||
|
NarrativeDevelopmentRouter.ChildBorn(b, childB, "harness_multithread_b");
|
||||||
|
|
||||||
|
InterestCandidate shotA = HarnessNarrativeCandidate(
|
||||||
|
"harness:multi:a", a, "Lineage A develops", 62f);
|
||||||
|
shotA.AssetId = "add_child";
|
||||||
|
InterestCandidate shotB = HarnessNarrativeCandidate(
|
||||||
|
"harness:multi:b", b, "Lineage B develops", 68f);
|
||||||
|
shotB.AssetId = "add_child";
|
||||||
|
EventFeedUtil.RegisterCandidate(shotA);
|
||||||
|
EventFeedUtil.RegisterCandidate(shotB);
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
StoryScheduler.Tick(now + 1f);
|
||||||
|
string firstThread = StoryScheduler.ActiveEpisode?.ThreadId ?? "";
|
||||||
|
InterestCandidate first = InterestDirector.HarnessPeekNext();
|
||||||
|
bool startsA = StoryScheduler.ActiveEpisode?.ProtagonistId == aid
|
||||||
|
&& first?.Key == shotA.Key;
|
||||||
|
bool watchedA = startsA && InterestDirector.HarnessForceSession(first);
|
||||||
|
|
||||||
|
NarrativeDevelopmentRouter.Role(b, "become_king", true, "harness_multithread_b");
|
||||||
|
StoryScheduler.Tick(now + 2f);
|
||||||
|
bool retainedA = StoryScheduler.ActiveEpisode?.ThreadId == firstThread;
|
||||||
|
|
||||||
|
InterestCandidate critical = HarnessNarrativeCandidate(
|
||||||
|
"harness:multi:critical", outsider, "Kingdom shattered", 100f);
|
||||||
|
critical.Category = "Disaster";
|
||||||
|
EventFeedUtil.RegisterCandidate(critical);
|
||||||
|
InterestDirector.HarnessAgeCurrent(20f);
|
||||||
|
StoryScheduler.Tick(now + 3f);
|
||||||
|
InterestCandidate criticalPick = InterestDirector.HarnessPeekNext();
|
||||||
|
bool criticalWon = criticalPick?.Key == critical.Key;
|
||||||
|
bool watchedCritical = criticalWon && InterestDirector.HarnessForceSession(criticalPick);
|
||||||
|
bool survivedInterrupt = StoryScheduler.ActiveEpisode?.ThreadId == firstThread;
|
||||||
|
|
||||||
|
InterestDirector.HarnessEndCurrent("critical_complete");
|
||||||
|
|
||||||
|
InterestCandidate resumeA = HarnessNarrativeCandidate(
|
||||||
|
"harness:multi:a:resume", a, "Lineage A continues", 64f);
|
||||||
|
resumeA.AssetId = "add_child";
|
||||||
|
EventFeedUtil.RegisterCandidate(resumeA);
|
||||||
|
StoryScheduler.Tick(now + 4f);
|
||||||
|
InterestCandidate resumed = StoryScheduler.SelectForCamera(
|
||||||
|
new List<InterestCandidate> { resumeA }, current: null, now: now + 4f);
|
||||||
|
bool resumedA = resumed?.Key == resumeA.Key
|
||||||
|
&& StoryScheduler.ActiveEpisode?.ThreadId == firstThread;
|
||||||
|
|
||||||
|
NarrativeThread closing = NarrativeStoryStore.Thread(firstThread);
|
||||||
|
if (closing != null) closing.Status = NarrativeThreadStatus.Abandoned;
|
||||||
|
StoryScheduler.Tick(now + 20f);
|
||||||
|
bool handedToB = StoryScheduler.ActiveEpisode?.ProtagonistId == bid;
|
||||||
|
ok = startsA && watchedA && retainedA && criticalWon && watchedCritical
|
||||||
|
&& survivedInterrupt && resumedA && handedToB;
|
||||||
|
detail = "startsA=" + startsA + " retainedA=" + retainedA
|
||||||
|
+ " critical=" + criticalWon + "/" + watchedCritical
|
||||||
|
+ " survived=" + survivedInterrupt + " resumedA=" + resumedA
|
||||||
|
+ " handedToB=" + handedToB
|
||||||
|
+ " firstThread=" + firstThread
|
||||||
|
+ " finalThread=" + (StoryScheduler.ActiveEpisode?.ThreadId ?? "none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
detail = "exception=" + ex.GetType().Name + ":" + ex.Message;
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
if (ok) _cmdOk++; else _cmdFail++;
|
||||||
|
Emit(cmd, ok, detail: detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "caption_status_banner_probe":
|
case "caption_status_banner_probe":
|
||||||
{
|
{
|
||||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
|
@ -3238,6 +3566,69 @@ public static class AgentHarness
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "saga_legacy_combat_probe":
|
||||||
|
{
|
||||||
|
Actor killer = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||||
|
int recorded = 0;
|
||||||
|
if (killer != null && killer.isAlive() && World.world?.units != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (Actor victim in World.world.units)
|
||||||
|
{
|
||||||
|
if (victim == null || !victim.isAlive() || victim == killer)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
LifeSagaMemory.RecordKill(
|
||||||
|
killer, victim, "harness_combat_summary:" + recorded);
|
||||||
|
recorded++;
|
||||||
|
if (recorded >= 3) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// failure is reported by the assertions below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LifeSagaViewModel model = LifeSagaPresentation.Build(EventFeedUtil.SafeId(killer));
|
||||||
|
int summaryCount = 0;
|
||||||
|
int rawKillCount = 0;
|
||||||
|
string lines = "";
|
||||||
|
if (model != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < model.Legacy.Count; i++)
|
||||||
|
{
|
||||||
|
string line = model.Legacy[i]?.Line ?? "";
|
||||||
|
if (string.IsNullOrEmpty(line)) continue;
|
||||||
|
if (lines.Length > 0) lines += " | ";
|
||||||
|
lines += line;
|
||||||
|
if (line.IndexOf(
|
||||||
|
"Slew 3 opponents across repeated clashes",
|
||||||
|
StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
summaryCount++;
|
||||||
|
}
|
||||||
|
else if (model.Legacy[i].Kind == LifeSagaFactKind.Kill)
|
||||||
|
{
|
||||||
|
rawKillCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = recorded == 3 && summaryCount == 1 && rawKillCount == 0;
|
||||||
|
if (ok) _cmdOk++;
|
||||||
|
else _cmdFail++;
|
||||||
|
Emit(
|
||||||
|
cmd,
|
||||||
|
ok,
|
||||||
|
detail: $"recorded={recorded} summary={summaryCount} rawKills={rawKillCount}"
|
||||||
|
+ $" legacy='{lines}'");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "interest_bind_related_partner":
|
case "interest_bind_related_partner":
|
||||||
{
|
{
|
||||||
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
||||||
|
|
@ -3403,39 +3794,6 @@ public static class AgentHarness
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "saga_consider_hard_focus":
|
|
||||||
{
|
|
||||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
|
||||||
long id = EventFeedUtil.SafeId(focus);
|
|
||||||
float heat = 6f;
|
|
||||||
if (!string.IsNullOrEmpty(cmd.value)
|
|
||||||
&& float.TryParse(
|
|
||||||
cmd.value.Trim(),
|
|
||||||
System.Globalization.NumberStyles.Float,
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
out float parsed)
|
|
||||||
&& parsed > 0f)
|
|
||||||
{
|
|
||||||
heat = parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id == 0)
|
|
||||||
{
|
|
||||||
_cmdFail++;
|
|
||||||
Emit(cmd, ok: false, detail: "no focus");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool admitted = LifeSagaRoster.ConsiderAdmit(id, heat: heat, hardArcAdmit: true);
|
|
||||||
_cmdOk++;
|
|
||||||
Emit(
|
|
||||||
cmd,
|
|
||||||
ok: true,
|
|
||||||
detail:
|
|
||||||
$"hardAdmit id={id} on={admitted} heat={heat:0.##} roster={LifeSagaRoster.Count}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "saga_rail_refresh":
|
case "saga_rail_refresh":
|
||||||
{
|
{
|
||||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||||
|
|
@ -3594,11 +3952,6 @@ public static class AgentHarness
|
||||||
{
|
{
|
||||||
kind = LifeSagaFactKind.RoleChange;
|
kind = LifeSagaFactKind.RoleChange;
|
||||||
}
|
}
|
||||||
else if (kindRaw.IndexOf("combat", StringComparison.OrdinalIgnoreCase) >= 0
|
|
||||||
|| kindRaw.IndexOf("hardarc", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
||||||
{
|
|
||||||
kind = LifeSagaFactKind.HardArcCombat;
|
|
||||||
}
|
|
||||||
else if (kindRaw.IndexOf("parent", StringComparison.OrdinalIgnoreCase) >= 0
|
else if (kindRaw.IndexOf("parent", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|| kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0)
|
|| kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -3660,16 +4013,6 @@ public static class AgentHarness
|
||||||
|
|
||||||
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
|
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
|
||||||
}
|
}
|
||||||
else if (kind == LifeSagaFactKind.HardArcCombat)
|
|
||||||
{
|
|
||||||
LifeSagaMemory.RecordHardArc(
|
|
||||||
EventFeedUtil.SafeId(focus),
|
|
||||||
StoryArcKind.CombatDuel,
|
|
||||||
string.IsNullOrEmpty(cmd.value) ? "A fight marked them" : cmd.value,
|
|
||||||
relatedId: EventFeedUtil.SafeId(other),
|
|
||||||
provenance: "harness");
|
|
||||||
fact = LifeSagaMemory.StrongestUnresolved(EventFeedUtil.SafeId(focus));
|
|
||||||
}
|
|
||||||
else if (kind == LifeSagaFactKind.ParentChild)
|
else if (kind == LifeSagaFactKind.ParentChild)
|
||||||
{
|
{
|
||||||
if (other == null || !other.isAlive() || other == focus)
|
if (other == null || !other.isAlive() || other == focus)
|
||||||
|
|
@ -4449,6 +4792,7 @@ public static class AgentHarness
|
||||||
{
|
{
|
||||||
LifeSagaRoster.Clear();
|
LifeSagaRoster.Clear();
|
||||||
LifeSagaMemory.Clear();
|
LifeSagaMemory.Clear();
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
|
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
|
||||||
|
|
@ -4796,6 +5140,15 @@ public static class AgentHarness
|
||||||
|
|
||||||
string assetId = cmd.asset;
|
string assetId = cmd.asset;
|
||||||
Actor follow = null;
|
Actor follow = null;
|
||||||
|
if (string.IsNullOrEmpty(assetId) && MoveCamera.hasFocusUnit())
|
||||||
|
{
|
||||||
|
Actor focused = MoveCamera._focus_unit;
|
||||||
|
if (focused != null && focused.isAlive())
|
||||||
|
{
|
||||||
|
follow = focused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(assetId))
|
if (!string.IsNullOrEmpty(assetId))
|
||||||
{
|
{
|
||||||
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId);
|
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId);
|
||||||
|
|
@ -8815,6 +9168,37 @@ public static class AgentHarness
|
||||||
Emit(cmd, ok, detail: detail);
|
Emit(cmd, ok, detail: detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static InterestCandidate HarnessNarrativeCandidate(
|
||||||
|
string key,
|
||||||
|
Actor subject,
|
||||||
|
string label,
|
||||||
|
float eventStrength)
|
||||||
|
{
|
||||||
|
long id = EventFeedUtil.SafeId(subject);
|
||||||
|
return new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = key ?? "",
|
||||||
|
Source = "harness",
|
||||||
|
Category = "Story",
|
||||||
|
AssetId = key ?? "",
|
||||||
|
Label = EventPresentability.EnsureSubjectLedLabel(subject, label ?? ""),
|
||||||
|
FollowUnit = subject,
|
||||||
|
SubjectId = id,
|
||||||
|
Position = subject != null ? subject.current_position : Vector3.zero,
|
||||||
|
EventStrength = eventStrength,
|
||||||
|
CharacterSignificance = 5f,
|
||||||
|
VisualConfidence = 0.8f,
|
||||||
|
Novelty = 1f,
|
||||||
|
Completion = InterestCompletionKind.FixedDwell,
|
||||||
|
MinWatch = 1f,
|
||||||
|
MaxWatch = 15f,
|
||||||
|
CreatedAt = Time.unscaledTime,
|
||||||
|
LastSeenAt = Time.unscaledTime,
|
||||||
|
ExpiresAt = Time.unscaledTime + 30f,
|
||||||
|
Resumable = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static void DoAssert(HarnessCommand cmd)
|
private static void DoAssert(HarnessCommand cmd)
|
||||||
{
|
{
|
||||||
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
||||||
|
|
@ -10338,6 +10722,19 @@ public static class AgentHarness
|
||||||
detail = $"tip='{tip}' contains='{needle}'";
|
detail = $"tip='{tip}' contains='{needle}'";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "tip_contains_or_story_hold":
|
||||||
|
{
|
||||||
|
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;
|
||||||
|
string tip = CameraDirector.LastWatchLabel ?? "";
|
||||||
|
bool contains = !string.IsNullOrEmpty(needle)
|
||||||
|
&& tip.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
bool held = StoryScheduler.ActiveEpisode != null
|
||||||
|
&& !contains;
|
||||||
|
pass = contains || held;
|
||||||
|
detail = $"tip='{tip}' contains='{needle}' storyHold={held}"
|
||||||
|
+ $" episode={StoryScheduler.ActiveEpisode?.ThreadId ?? "none"}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "enabled":
|
case "enabled":
|
||||||
case "setting_enabled":
|
case "setting_enabled":
|
||||||
{
|
{
|
||||||
|
|
@ -12308,10 +12705,14 @@ public static class AgentHarness
|
||||||
case "activity_status_gain_count":
|
case "activity_status_gain_count":
|
||||||
{
|
{
|
||||||
int want = ParseCountExpect(cmd, defaultValue: 1);
|
int want = ParseCountExpect(cmd, defaultValue: 1);
|
||||||
int got = ActivityLog.StatusGainsSinceClear;
|
string statusId = cmd.asset ?? "";
|
||||||
|
int got = string.IsNullOrEmpty(statusId)
|
||||||
|
? ActivityLog.StatusGainsSinceClear
|
||||||
|
: ActivityLog.StatusGainCount(statusId);
|
||||||
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant();
|
||||||
pass = mode == "min" ? got >= want : got == want;
|
pass = mode == "min" ? got >= want : got == want;
|
||||||
detail = $"gains={got} want={want} mode={mode} last={ActivityLog.LastStatusGainId}";
|
detail = $"gains={got} want={want} mode={mode} status={statusId}"
|
||||||
|
+ $" total={ActivityLog.StatusGainsSinceClear} last={ActivityLog.LastStatusGainId}";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "activity_status_loss_count":
|
case "activity_status_loss_count":
|
||||||
|
|
@ -12365,15 +12766,28 @@ public static class AgentHarness
|
||||||
|
|
||||||
string want = cmd.value ?? "";
|
string want = cmd.value ?? "";
|
||||||
string got = "";
|
string got = "";
|
||||||
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 1);
|
bool found = false;
|
||||||
if (list != null && list.Count > 0 && list[0] != null)
|
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 12);
|
||||||
|
if (list != null)
|
||||||
{
|
{
|
||||||
got = list[0].TaskId ?? "";
|
for (int i = 0; i < list.Count; i++)
|
||||||
|
{
|
||||||
|
string taskId = list[i]?.TaskId ?? "";
|
||||||
|
if (i == 0)
|
||||||
|
{
|
||||||
|
got = taskId;
|
||||||
|
}
|
||||||
|
if (taskId.Equals(want, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pass = !string.IsNullOrEmpty(want)
|
pass = !string.IsNullOrEmpty(want)
|
||||||
&& got.Equals(want, StringComparison.OrdinalIgnoreCase);
|
&& found;
|
||||||
detail = $"got='{got}' want='{want}'";
|
detail = $"latest='{got}' found={found} want='{want}'";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "activity_prose_showcase":
|
case "activity_prose_showcase":
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,13 @@ public static class CameraDirector
|
||||||
MoveCamera.clearFocusUnitOnly();
|
MoveCamera.clearFocusUnitOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void ClearWatchReason()
|
||||||
|
{
|
||||||
|
LastWatchLabel = "";
|
||||||
|
LastWatchAssetId = "";
|
||||||
|
LastFormattedWatchTip = "Watching";
|
||||||
|
}
|
||||||
|
|
||||||
public static void PanTo(Vector3 pos)
|
public static void PanTo(Vector3 pos)
|
||||||
{
|
{
|
||||||
PanCamera(pos);
|
PanCamera(pos);
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ public static class EventFeedUtil
|
||||||
MaxWatch = maxWatch,
|
MaxWatch = maxWatch,
|
||||||
Completion = completion
|
Completion = completion
|
||||||
};
|
};
|
||||||
|
NarrativeFunctionCatalog.Stamp(candidate);
|
||||||
if (!EventPresentability.WouldShow(candidate))
|
if (!EventPresentability.WouldShow(candidate))
|
||||||
{
|
{
|
||||||
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
|
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
|
||||||
|
|
@ -151,6 +152,8 @@ public static class EventFeedUtil
|
||||||
candidate.RelatedId = SafeId(candidate.RelatedUnit);
|
candidate.RelatedId = SafeId(candidate.RelatedUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NarrativeFunctionCatalog.Stamp(candidate);
|
||||||
|
|
||||||
// Harness injects synthetic tip labels for director tests; dossier still blanks them.
|
// Harness injects synthetic tip labels for director tests; dossier still blanks them.
|
||||||
// Production feeds (Source != harness) must be presentable to enter the registry.
|
// Production feeds (Source != harness) must be presentable to enter the registry.
|
||||||
if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate))
|
if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate))
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,10 @@ public static class InterestFeeds
|
||||||
|
|
||||||
float evtSeed = entry.EventStrength;
|
float evtSeed = entry.EventStrength;
|
||||||
Vector3 position = message.getLocation();
|
Vector3 position = message.getLocation();
|
||||||
Actor unit = null;
|
// The message's unit is an explicit event participant even when the WorldLog asset
|
||||||
if (message.hasFollowLocation())
|
// suppresses vanilla follow-location UI (for example alien invasion). Preserve that
|
||||||
{
|
// ownership so camera-worthy events do not become anchorless and disappear.
|
||||||
unit = message.unit;
|
Actor unit = message.unit;
|
||||||
}
|
|
||||||
|
|
||||||
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
||||||
string kingdom = message.special1 ?? "";
|
string kingdom = message.special1 ?? "";
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,13 @@ public static class RelationshipEventPatches
|
||||||
if (pActor == null)
|
if (pActor == null)
|
||||||
{
|
{
|
||||||
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
|
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
|
||||||
|
NarrativeDevelopmentRouter.BondBroken(__instance, __state, "setLover(null)");
|
||||||
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
|
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
|
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
|
||||||
|
NarrativeDevelopmentRouter.BondFormed(__instance, pActor, "setLover");
|
||||||
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
|
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +128,7 @@ public static class RelationshipEventPatches
|
||||||
}
|
}
|
||||||
|
|
||||||
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
|
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
|
||||||
|
NarrativeDevelopmentRouter.BondBroken(unit, lover, "dead_lover_cleanup");
|
||||||
RelationshipInterestFeed.Emit("clear_lover", unit, null);
|
RelationshipInterestFeed.Emit("clear_lover", unit, null);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|
@ -168,6 +171,7 @@ public static class RelationshipEventPatches
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
LifeSagaMemory.RecordParentChild(parent, child);
|
LifeSagaMemory.RecordParentChild(parent, child);
|
||||||
|
NarrativeDevelopmentRouter.ChildBorn(parent, child, "setParent");
|
||||||
RelationshipInterestFeed.Emit("add_child", parent, child);
|
RelationshipInterestFeed.Emit("add_child", parent, child);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +194,7 @@ public static class RelationshipEventPatches
|
||||||
}
|
}
|
||||||
|
|
||||||
LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
|
LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
|
||||||
|
NarrativeDevelopmentRouter.Founding(anchor, "family", familyKey, "new_family");
|
||||||
}
|
}
|
||||||
|
|
||||||
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
|
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
|
||||||
|
|
@ -450,6 +455,7 @@ public static class RelationshipEventPatches
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
|
LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
|
||||||
|
NarrativeDevelopmentRouter.Role(pActor, "family alpha", true, "family_set_alpha");
|
||||||
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
|
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,30 @@ internal static class HarnessScenarios
|
||||||
return StoryBoardParkResume();
|
return StoryBoardParkResume();
|
||||||
case "story_cross_arc_correlation":
|
case "story_cross_arc_correlation":
|
||||||
return StoryCrossArcCorrelation();
|
return StoryCrossArcCorrelation();
|
||||||
|
case "narrative_scheduler_policy":
|
||||||
|
case "narrative_interrupt_policy":
|
||||||
|
return NarrativeSchedulerPolicy();
|
||||||
|
case "narrative_camera_cutover":
|
||||||
|
case "story_first_camera":
|
||||||
|
return NarrativeCameraCutover();
|
||||||
|
case "narrative_story_inputs":
|
||||||
|
case "story_potential_roster":
|
||||||
|
return NarrativeStoryInputs();
|
||||||
|
case "narrative_story_first_soak":
|
||||||
|
case "story_first_soak":
|
||||||
|
return NarrativeStoryFirstSoak();
|
||||||
|
case "narrative_catalog":
|
||||||
|
case "narrative_catalog_policy":
|
||||||
|
return NarrativeCatalogPolicy();
|
||||||
|
case "narrative_natural_soak":
|
||||||
|
case "story_first_natural_soak":
|
||||||
|
return NarrativeNaturalSoak();
|
||||||
|
case "narrative_multithread_stress":
|
||||||
|
case "story_first_multithread":
|
||||||
|
return NarrativeMultiThreadStress();
|
||||||
|
case "narrative_editorial_replay":
|
||||||
|
case "story_first_replay_guard":
|
||||||
|
return NarrativeEditorialReplay();
|
||||||
case "story_arc_preempt_resume":
|
case "story_arc_preempt_resume":
|
||||||
return StoryArcPreemptResume();
|
return StoryArcPreemptResume();
|
||||||
case "story_ledger_revisit":
|
case "story_ledger_revisit":
|
||||||
|
|
@ -183,12 +207,12 @@ internal static class HarnessScenarios
|
||||||
return SagaCrossMcKill();
|
return SagaCrossMcKill();
|
||||||
case "saga_rival_not_single_kill":
|
case "saga_rival_not_single_kill":
|
||||||
return SagaRivalNotSingleKill();
|
return SagaRivalNotSingleKill();
|
||||||
|
case "saga_legacy_combat_summary":
|
||||||
|
return SagaLegacyCombatSummary();
|
||||||
case "saga_rival_rematch":
|
case "saga_rival_rematch":
|
||||||
return SagaRivalRematch();
|
return SagaRivalRematch();
|
||||||
case "saga_prefer_cuts_ambient":
|
case "saga_prefer_cuts_ambient":
|
||||||
return SagaPreferCutsAmbient();
|
return SagaPreferCutsAmbient();
|
||||||
case "saga_hard_arc_escalates":
|
|
||||||
return SagaHardArcEscalates();
|
|
||||||
case "saga_overview_has_mc":
|
case "saga_overview_has_mc":
|
||||||
return SagaOverviewHasMc();
|
return SagaOverviewHasMc();
|
||||||
case "saga_snapshot_popover":
|
case "saga_snapshot_popover":
|
||||||
|
|
@ -420,6 +444,14 @@ internal static class HarnessScenarios
|
||||||
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
|
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
|
||||||
Nested("reg_story_board_park", "story_board_park_resume"),
|
Nested("reg_story_board_park", "story_board_park_resume"),
|
||||||
Nested("reg_story_cross_arc", "story_cross_arc_correlation"),
|
Nested("reg_story_cross_arc", "story_cross_arc_correlation"),
|
||||||
|
Nested("reg_narrative_scheduler", "narrative_scheduler_policy"),
|
||||||
|
Nested("reg_narrative_camera", "narrative_camera_cutover"),
|
||||||
|
Nested("reg_narrative_inputs", "narrative_story_inputs"),
|
||||||
|
Nested("reg_narrative_soak", "narrative_story_first_soak"),
|
||||||
|
Nested("reg_narrative_catalog", "narrative_catalog_policy"),
|
||||||
|
Nested("reg_narrative_natural", "narrative_natural_soak"),
|
||||||
|
Nested("reg_narrative_multithread", "narrative_multithread_stress"),
|
||||||
|
Nested("reg_narrative_replay", "narrative_editorial_replay"),
|
||||||
Nested("reg_caption_status_banner", "caption_status_banner_live"),
|
Nested("reg_caption_status_banner", "caption_status_banner_live"),
|
||||||
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
|
Nested("reg_saga_relation_revision", "saga_relation_cache_revision"),
|
||||||
Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"),
|
Nested("reg_saga_cast_dedupe", "saga_cast_dedupe_scoped"),
|
||||||
|
|
@ -438,7 +470,6 @@ internal static class HarnessScenarios
|
||||||
Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"),
|
Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"),
|
||||||
Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
|
Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
|
||||||
Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"),
|
Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"),
|
||||||
Nested("reg_saga_hard_arc_heat", "saga_hard_arc_escalates"),
|
|
||||||
Nested("reg_saga_overview", "saga_overview_has_mc"),
|
Nested("reg_saga_overview", "saga_overview_has_mc"),
|
||||||
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
|
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
|
||||||
Nested("reg_saga_memory", "saga_memory_survives"),
|
Nested("reg_saga_memory", "saga_memory_survives"),
|
||||||
|
|
@ -447,6 +478,7 @@ internal static class HarnessScenarios
|
||||||
Nested("reg_saga_cast_kin", "saga_cast_kin_non_pack_lens"),
|
Nested("reg_saga_cast_kin", "saga_cast_kin_non_pack_lens"),
|
||||||
Nested("reg_saga_stake_role", "saga_stake_from_role_live"),
|
Nested("reg_saga_stake_role", "saga_stake_from_role_live"),
|
||||||
Nested("reg_saga_legacy_quality", "saga_legacy_quality"),
|
Nested("reg_saga_legacy_quality", "saga_legacy_quality"),
|
||||||
|
Nested("reg_saga_legacy_combat", "saga_legacy_combat_summary"),
|
||||||
Nested("reg_saga_session_reset", "saga_session_reset"),
|
Nested("reg_saga_session_reset", "saga_session_reset"),
|
||||||
Nested("reg_saga_hover_pause", "saga_hover_read_pause"),
|
Nested("reg_saga_hover_pause", "saga_hover_read_pause"),
|
||||||
Nested("reg_saga_diversity", "saga_roster_diversity"),
|
Nested("reg_saga_diversity", "saga_roster_diversity"),
|
||||||
|
|
@ -633,14 +665,14 @@ internal static class HarnessScenarios
|
||||||
|
|
||||||
// Gain path (confused is widely applicable across assets)
|
// Gain path (confused is widely applicable across assets)
|
||||||
Step("st12", "status_apply", value: "confused"),
|
Step("st12", "status_apply", value: "confused"),
|
||||||
Step("st13", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
|
Step("st13", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "confused"),
|
||||||
Step("st14", "assert", expect: "activity_log_contains", value: "confused"),
|
Step("st14", "assert", expect: "activity_log_contains", value: "confused"),
|
||||||
Step("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"),
|
Step("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"),
|
||||||
Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"),
|
Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"),
|
||||||
|
|
||||||
// Refresh must not create another gain line
|
// Refresh must not create another gain line
|
||||||
Step("st17", "status_apply", value: "confused"),
|
Step("st17", "status_apply", value: "confused"),
|
||||||
Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
|
Step("st18", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "confused"),
|
||||||
Step("st19", "assert", expect: "activity_status_refresh_count", value: "1", label: "min"),
|
Step("st19", "assert", expect: "activity_status_refresh_count", value: "1", label: "min"),
|
||||||
|
|
||||||
// Second distinct gain (min: world may emit an extra status mid-suite)
|
// Second distinct gain (min: world may emit an extra status mid-suite)
|
||||||
|
|
@ -666,7 +698,7 @@ internal static class HarnessScenarios
|
||||||
Step("st28c", "activity_status_reset"),
|
Step("st28c", "activity_status_reset"),
|
||||||
Step("st28d", "fast_timing", value: "true"),
|
Step("st28d", "fast_timing", value: "true"),
|
||||||
Step("st29", "status_apply", value: "surprised", label: "0.25"),
|
Step("st29", "status_apply", value: "surprised", label: "0.25"),
|
||||||
Step("st30", "assert", expect: "activity_status_gain_count", value: "1", label: "exact"),
|
Step("st30", "assert", expect: "activity_status_gain_count", value: "1", label: "exact", asset: "surprised"),
|
||||||
Step("st31", "wait", wait: 1.25f),
|
Step("st31", "wait", wait: 1.25f),
|
||||||
Step("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"),
|
Step("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"),
|
||||||
Step("st33", "assert", expect: "activity_status_loss_count", value: "1", label: "min"),
|
Step("st33", "assert", expect: "activity_status_loss_count", value: "1", label: "min"),
|
||||||
|
|
@ -3124,6 +3156,124 @@ internal static class HarnessScenarios
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeSchedulerPolicy()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("nsp0", "dismiss_windows"),
|
||||||
|
Step("nsp1", "wait_world"),
|
||||||
|
Step("nsp2", "narrative_scheduler_probe"),
|
||||||
|
Step("nsp3", "assert", expect: "no_bad"),
|
||||||
|
Step("nsp99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeCameraCutover()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("ncc0", "dismiss_windows"),
|
||||||
|
Step("ncc1", "wait_world"),
|
||||||
|
Step("ncc2", "spawn", asset: "human", count: 3),
|
||||||
|
Step("ncc3", "focus", asset: "human"),
|
||||||
|
Step("ncc4", "narrative_camera_cutover_probe"),
|
||||||
|
Step("ncc5", "assert", expect: "no_bad"),
|
||||||
|
Step("ncc99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeStoryInputs()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("nsi0", "dismiss_windows"),
|
||||||
|
Step("nsi1", "wait_world"),
|
||||||
|
Step("nsi2", "spawn", asset: "human", count: 3),
|
||||||
|
Step("nsi3", "focus", asset: "human"),
|
||||||
|
Step("nsi4", "narrative_story_inputs_probe"),
|
||||||
|
Step("nsi5", "assert", expect: "no_bad"),
|
||||||
|
Step("nsi99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeStoryFirstSoak()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("nss0", "dismiss_windows"),
|
||||||
|
Step("nss1", "wait_world"),
|
||||||
|
Step("nss2", "spawn", asset: "human", count: 4),
|
||||||
|
Step("nss3", "spectator", value: "off"),
|
||||||
|
Step("nss4", "spectator", value: "on"),
|
||||||
|
Step("nss5", "focus", asset: "human"),
|
||||||
|
Step("nss8", "narrative_soak_seed"),
|
||||||
|
Step("nss9", "director_run", wait: 8f),
|
||||||
|
Step("nss10", "narrative_telemetry_probe"),
|
||||||
|
Step("nss11", "assert", expect: "has_focus"),
|
||||||
|
Step("nss12", "assert", expect: "tip_matches_focus"),
|
||||||
|
Step("nss13", "assert", expect: "no_bad"),
|
||||||
|
Step("nss99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeCatalogPolicy()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("ncp0", "dismiss_windows"),
|
||||||
|
Step("ncp1", "wait_world"),
|
||||||
|
Step("ncp2", "narrative_catalog_probe"),
|
||||||
|
Step("ncp3", "assert", expect: "no_bad"),
|
||||||
|
Step("ncp99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeNaturalSoak()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("nns0", "dismiss_windows"),
|
||||||
|
Step("nns1", "wait_world"),
|
||||||
|
Step("nns2", "spawn", asset: "human", count: 12),
|
||||||
|
Step("nns3", "spectator", value: "off"),
|
||||||
|
Step("nns4", "spectator", value: "on"),
|
||||||
|
Step("nns5", "focus", asset: "human"),
|
||||||
|
Step("nns6", "force_live_feeds", value: "true"),
|
||||||
|
Step("nns9", "narrative_natural_soak_begin"),
|
||||||
|
Step("nns10", "director_run", wait: 20f),
|
||||||
|
Step("nns11", "narrative_natural_soak_probe"),
|
||||||
|
Step("nns12", "assert", expect: "tip_matches_focus"),
|
||||||
|
Step("nns13", "assert", expect: "no_bad"),
|
||||||
|
Step("nns15", "force_live_feeds", value: "false"),
|
||||||
|
Step("nns99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeMultiThreadStress()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("nmt0", "dismiss_windows"),
|
||||||
|
Step("nmt1", "wait_world"),
|
||||||
|
Step("nmt2", "spawn", asset: "human", count: 6),
|
||||||
|
Step("nmt3", "narrative_multithread_probe"),
|
||||||
|
Step("nmt4", "assert", expect: "no_bad"),
|
||||||
|
Step("nmt99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> NarrativeEditorialReplay()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("ner0", "dismiss_windows"),
|
||||||
|
Step("ner1", "wait_world"),
|
||||||
|
Step("ner2", "editorial_replay_probe"),
|
||||||
|
Step("ner3", "assert", expect: "no_bad"),
|
||||||
|
Step("ner99", "snapshot")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
|
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -3479,6 +3629,20 @@ internal static class HarnessScenarios
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<HarnessCommand> SagaLegacyCombatSummary()
|
||||||
|
{
|
||||||
|
return new List<HarnessCommand>
|
||||||
|
{
|
||||||
|
Step("slcs0", "dismiss_windows"),
|
||||||
|
Step("slcs1", "wait_world"),
|
||||||
|
Step("slcs2", "spawn", asset: "human", count: 4),
|
||||||
|
Step("slcs3", "focus", asset: "human"),
|
||||||
|
Step("slcs4", "interest_saga_clear"),
|
||||||
|
Step("slcs5", "saga_legacy_combat_probe"),
|
||||||
|
Step("slcs99", "snapshot"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Threshold living rematches earn RivalEarned.</summary>
|
/// <summary>Threshold living rematches earn RivalEarned.</summary>
|
||||||
private static List<HarnessCommand> SagaRivalRematch()
|
private static List<HarnessCommand> SagaRivalRematch()
|
||||||
{
|
{
|
||||||
|
|
@ -3535,40 +3699,6 @@ internal static class HarnessScenarios
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Prolonged hard-arc heat must beat a full Cap of stiff slots; one-shot heat 1.5 must not.
|
|
||||||
/// Mirrors soak: stranger duel stuck off-rail until admit heat escalates.
|
|
||||||
/// </summary>
|
|
||||||
private static List<HarnessCommand> SagaHardArcEscalates()
|
|
||||||
{
|
|
||||||
return new List<HarnessCommand>
|
|
||||||
{
|
|
||||||
Step("she0", "dismiss_windows"),
|
|
||||||
Step("she1", "wait_world"),
|
|
||||||
Step("she2", "set_setting", expect: "enabled", value: "true"),
|
|
||||||
Step("she3", "fast_timing", value: "true"),
|
|
||||||
Step("she4", "spawn", asset: "human", count: 14),
|
|
||||||
Step("she5", "spectator", value: "off"),
|
|
||||||
Step("she6", "spectator", value: "on"),
|
|
||||||
Step("she7", "interest_saga_clear"),
|
|
||||||
Step("she8", "pick_unit", asset: "human"),
|
|
||||||
Step("she9", "happiness_remember_partner"),
|
|
||||||
Step("she10", "saga_fill_cap", asset: "human"),
|
|
||||||
Step("she50", "assert", expect: "saga_roster_count", value: "10"),
|
|
||||||
Step("she51", "saga_stiffen_roster", value: "8"),
|
|
||||||
Step("she60", "focus_partner"),
|
|
||||||
Step("she62", "assert", expect: "saga_has_focus", value: "false"),
|
|
||||||
Step("she63", "saga_consider_hard_focus", value: "1.5"),
|
|
||||||
Step("she64", "assert", expect: "saga_has_focus", value: "false"),
|
|
||||||
Step("she65", "saga_consider_hard_focus", value: "12"),
|
|
||||||
Step("she66", "assert", expect: "saga_has_focus"),
|
|
||||||
Step("she67", "saga_rail_refresh"),
|
|
||||||
Step("she68", "assert", expect: "saga_rail_active_highlight", value: "true"),
|
|
||||||
Step("she90", "fast_timing", value: "false"),
|
|
||||||
Step("she99", "snapshot"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
|
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
|
||||||
/// no planner jargon / trait descs / Chronicle dumps.
|
/// no planner jargon / trait descs / Chronicle dumps.
|
||||||
|
|
@ -3874,7 +4004,7 @@ internal static class HarnessScenarios
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Live roster role rise updates Identity without duplicating its prior combat stake.</summary>
|
/// <summary>Live roster role rise updates Identity and the current supported stake.</summary>
|
||||||
private static List<HarnessCommand> SagaStakeFromRoleLive()
|
private static List<HarnessCommand> SagaStakeFromRoleLive()
|
||||||
{
|
{
|
||||||
return new List<HarnessCommand>
|
return new List<HarnessCommand>
|
||||||
|
|
@ -3890,8 +4020,6 @@ internal static class HarnessScenarios
|
||||||
Step("srl8", "pick_unit", asset: "human"),
|
Step("srl8", "pick_unit", asset: "human"),
|
||||||
Step("srl9", "focus", asset: "human"),
|
Step("srl9", "focus", asset: "human"),
|
||||||
Step("srl10", "saga_force_admit_focus"),
|
Step("srl10", "saga_force_admit_focus"),
|
||||||
Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "Survived a hard fight"),
|
|
||||||
Step("srl12", "assert", expect: "saga_stake_contains", value: "Survived a hard fight|hard fight"),
|
|
||||||
Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"),
|
Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"),
|
||||||
Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
|
Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
|
||||||
Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"),
|
Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"),
|
||||||
|
|
@ -4177,7 +4305,7 @@ internal static class HarnessScenarios
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extended roles: singleton dragon overview + hard-arc nobody admit path.
|
/// Extended roles: singleton dragon overview + explicit ordinary-character admission.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<HarnessCommand> SagaAdmitRoles()
|
private static List<HarnessCommand> SagaAdmitRoles()
|
||||||
{
|
{
|
||||||
|
|
@ -4199,10 +4327,10 @@ internal static class HarnessScenarios
|
||||||
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_role_tip", value: "Lone|Last of their kind|Dragon"),
|
||||||
// Nobody human may enter only via hard-arc ConsiderAdmit.
|
// 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("sar22", "saga_consider_hard_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"),
|
||||||
Step("sar90", "fast_timing", value: "false"),
|
Step("sar90", "fast_timing", value: "false"),
|
||||||
|
|
@ -4210,7 +4338,7 @@ internal static class HarnessScenarios
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Full Cap: Prefer pin survives; hotter hard-arc admit displaces weakest non-pin.</summary>
|
/// <summary>Full Cap: Prefer pin survives; a new admitted character displaces the weakest non-pin.</summary>
|
||||||
private static List<HarnessCommand> SagaReplaceWeaker()
|
private static List<HarnessCommand> SagaReplaceWeaker()
|
||||||
{
|
{
|
||||||
return new List<HarnessCommand>
|
return new List<HarnessCommand>
|
||||||
|
|
@ -4258,10 +4386,10 @@ internal static class HarnessScenarios
|
||||||
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: "10"),
|
||||||
Step("srw51", "saga_weaken_nonprefer"),
|
Step("srw51", "saga_weaken_nonprefer"),
|
||||||
// 11th human challenges Cap via hard-arc admit path.
|
// 11th human challenges the full 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_consider_hard_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: "10"),
|
||||||
// Prefer pin still present; challenger focus itself is not Prefer.
|
// Prefer pin still present; challenger focus itself is not Prefer.
|
||||||
|
|
@ -5019,8 +5147,8 @@ internal static class HarnessScenarios
|
||||||
Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"),
|
Step("wl10", "trigger_interest", asset: "auto", label: "Story: kingdom_new", tier: "Story"),
|
||||||
Step("wl11", "age_current", wait: 1.2f),
|
Step("wl11", "age_current", wait: 1.2f),
|
||||||
Step("wl12", "director_run", wait: 1.2f),
|
Step("wl12", "director_run", wait: 1.2f),
|
||||||
Step("wl13", "assert", expect: "tip_contains", value: "kingdom_new"),
|
Step("wl13", "assert", expect: "tip_contains_or_story_hold", value: "kingdom_new"),
|
||||||
Step("wl14", "assert", expect: "tip_contains", value: "kingdom_new"),
|
Step("wl14", "assert", expect: "tip_contains_or_story_hold", value: "kingdom_new"),
|
||||||
|
|
||||||
Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"),
|
Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"),
|
||||||
Step("wl21", "age_current", wait: 1.2f),
|
Step("wl21", "age_current", wait: 1.2f),
|
||||||
|
|
@ -5344,7 +5472,7 @@ internal static class HarnessScenarios
|
||||||
Step("act4", "set_setting", expect: "chronicle_enabled", value: "true"),
|
Step("act4", "set_setting", expect: "chronicle_enabled", value: "true"),
|
||||||
Step("act5", "chronicle_clear"),
|
Step("act5", "chronicle_clear"),
|
||||||
Step("act6", "activity_clear"),
|
Step("act6", "activity_clear"),
|
||||||
Step("act7", "pick_unit", asset: "auto"),
|
Step("act7", "spawn", asset: "human"),
|
||||||
Step("act8", "spectator", value: "off"),
|
Step("act8", "spectator", value: "off"),
|
||||||
Step("act9", "spectator", value: "on"),
|
Step("act9", "spectator", value: "on"),
|
||||||
Step("act10", "focus", asset: "auto"),
|
Step("act10", "focus", asset: "auto"),
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ public sealed class InterestCandidate
|
||||||
public float Novelty = 1f;
|
public float Novelty = 1f;
|
||||||
public float VisualConfidence = 0.5f;
|
public float VisualConfidence = 0.5f;
|
||||||
public float TotalScore;
|
public float TotalScore;
|
||||||
|
/// <summary>Editorial role assigned by the narrative catalog at registry intake.</summary>
|
||||||
|
public NarrativeFunction NarrativeFunction;
|
||||||
|
public bool HasNarrativeFunction;
|
||||||
|
public string NarrativeFunctionSource = "";
|
||||||
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
|
/// <summary>Live fighters / participants in a cluster (battles, multi-unit events).</summary>
|
||||||
public int ParticipantCount;
|
public int ParticipantCount;
|
||||||
/// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary>
|
/// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary>
|
||||||
|
|
@ -316,6 +320,9 @@ public sealed class InterestCandidate
|
||||||
Novelty = Novelty,
|
Novelty = Novelty,
|
||||||
VisualConfidence = VisualConfidence,
|
VisualConfidence = VisualConfidence,
|
||||||
TotalScore = TotalScore,
|
TotalScore = TotalScore,
|
||||||
|
NarrativeFunction = NarrativeFunction,
|
||||||
|
HasNarrativeFunction = HasNarrativeFunction,
|
||||||
|
NarrativeFunctionSource = NarrativeFunctionSource,
|
||||||
ParticipantCount = ParticipantCount,
|
ParticipantCount = ParticipantCount,
|
||||||
NotableParticipantCount = NotableParticipantCount,
|
NotableParticipantCount = NotableParticipantCount,
|
||||||
Position = Position,
|
Position = Position,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,21 @@ public static partial class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate != _current && InterestVariety.IsEditorialReplayCooled(candidate))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record("editorial_replay", candidate.Label ?? candidate.Key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate != _current
|
||||||
|
&& !StoryScheduler.MayUseCombatShot(candidate))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record(
|
||||||
|
"episode_combat_budget",
|
||||||
|
candidate.Label ?? candidate.Key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (_current == null)
|
if (_current == null)
|
||||||
{
|
{
|
||||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
||||||
|
|
@ -88,6 +103,29 @@ public static partial class InterestDirector
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Once an episode has a meaningful related development ready, let it reclaim the
|
||||||
|
// camera from ordinary unrelated material after a brief visual settling beat.
|
||||||
|
// Legitimate critical/payoff interruptions and live-combat holds remain protected.
|
||||||
|
if (StoryScheduler.ActiveEpisode != null
|
||||||
|
&& onCurrent >= 0.75f)
|
||||||
|
{
|
||||||
|
NarrativeThread activeThread =
|
||||||
|
NarrativeStoryStore.Thread(StoryScheduler.ActiveEpisode.ThreadId);
|
||||||
|
NarrativeInterruptClass nextNarrativeClass =
|
||||||
|
InterruptPolicy.Classify(candidate, StoryScheduler.ActiveEpisode, activeThread);
|
||||||
|
NarrativeInterruptClass currentNarrativeClass =
|
||||||
|
InterruptPolicy.Classify(_current, StoryScheduler.ActiveEpisode, activeThread);
|
||||||
|
if (nextNarrativeClass == NarrativeInterruptClass.RelatedEscalation
|
||||||
|
&& !InterruptPolicy.MayInterrupt(currentNarrativeClass)
|
||||||
|
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record(
|
||||||
|
"episode_reclaim",
|
||||||
|
$"cur={_current.Key} next={candidate.Key}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool inGrace = InQuietGrace;
|
bool inGrace = InQuietGrace;
|
||||||
if (inGrace)
|
if (inGrace)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -654,6 +654,7 @@ public static partial class InterestDirector
|
||||||
StoryPlanner.Tick(now);
|
StoryPlanner.Tick(now);
|
||||||
LifeSagaRoster.Tick(now);
|
LifeSagaRoster.Tick(now);
|
||||||
LifeSagaSession.EnsureBound();
|
LifeSagaSession.EnsureBound();
|
||||||
|
StoryScheduler.Tick(now);
|
||||||
IdleHitchProbe.Mark("story_done");
|
IdleHitchProbe.Mark("story_done");
|
||||||
|
|
||||||
if (ReadPauseActive)
|
if (ReadPauseActive)
|
||||||
|
|
@ -1950,6 +1951,7 @@ public static partial class InterestDirector
|
||||||
_current = null;
|
_current = null;
|
||||||
_harnessCombatForcedCold = false;
|
_harnessCombatForcedCold = false;
|
||||||
_inactiveSince = -999f;
|
_inactiveSince = -999f;
|
||||||
|
CameraDirector.ClearWatchReason();
|
||||||
EnsureIdleFocus(now);
|
EnsureIdleFocus(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2045,6 +2047,18 @@ public static partial class InterestDirector
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InterestCandidate storyChoice = StoryScheduler.SelectForCamera(PendingScratch, _current, now);
|
||||||
|
if (storyChoice != null)
|
||||||
|
{
|
||||||
|
return storyChoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StoryScheduler.ShouldHoldForEpisode(now))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When no episode owns a usable development, select bounded ambient/world context.
|
||||||
// EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here.
|
// EnrichTopK runs inside InterestVariety.Pick once - avoid a second top-K meta walk here.
|
||||||
return InterestVariety.Pick(PendingScratch, _current);
|
return InterestVariety.Pick(PendingScratch, _current);
|
||||||
}
|
}
|
||||||
|
|
@ -2718,6 +2732,7 @@ public static partial class InterestDirector
|
||||||
|
|
||||||
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
||||||
{
|
{
|
||||||
|
StoryScheduler.NoteActualSelection(next);
|
||||||
if (next == null)
|
if (next == null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,6 @@ public static class InterestScoring
|
||||||
|
|
||||||
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
|
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
|
||||||
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
|
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
|
||||||
float causalBonus = CausalHeat.ScoreBonus(c);
|
|
||||||
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
|
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
|
||||||
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
|
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
|
||||||
c.TotalScore = c.EventStrength + scaleBonus
|
c.TotalScore = c.EventStrength + scaleBonus
|
||||||
|
|
@ -168,7 +167,6 @@ public static class InterestScoring
|
||||||
+ c.Novelty * w.noveltyMultiplier
|
+ c.Novelty * w.noveltyMultiplier
|
||||||
- repeatPenalty
|
- repeatPenalty
|
||||||
+ frequencyAdjust
|
+ frequencyAdjust
|
||||||
+ causalBonus
|
|
||||||
+ sagaBonus
|
+ sagaBonus
|
||||||
+ ownershipBonus;
|
+ ownershipBonus;
|
||||||
string detail =
|
string detail =
|
||||||
|
|
@ -185,11 +183,6 @@ public static class InterestScoring
|
||||||
+ frequencyAdjust.ToString("0.#");
|
+ frequencyAdjust.ToString("0.#");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (causalBonus > 0.05f)
|
|
||||||
{
|
|
||||||
detail += $" causal=+{causalBonus:0.#}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sagaBonus > 0.05f)
|
if (sagaBonus > 0.05f)
|
||||||
{
|
{
|
||||||
detail += $" saga=+{sagaBonus:0.#}";
|
detail += $" saga=+{sagaBonus:0.#}";
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ public static class InterestVariety
|
||||||
public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds;
|
public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds;
|
||||||
public static float FixedDwellBeatCooldownSeconds =>
|
public static float FixedDwellBeatCooldownSeconds =>
|
||||||
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
|
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
|
||||||
|
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
|
||||||
|
|
||||||
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
|
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
|
||||||
private static readonly List<string> RecentArcs = new List<string>(32);
|
private static readonly List<string> RecentArcs = new List<string>(32);
|
||||||
|
|
@ -163,10 +164,83 @@ public static class InterestVariety
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sticky scenes are allowed to remain on screen while live, but once the editor
|
||||||
|
// leaves one it must not immediately re-announce the identical phase/card.
|
||||||
|
// The organic story-first soak otherwise produced Duel A-vs-B and "A is fighting"
|
||||||
|
// several times in succession, plus repeated unchanged family-pack cards.
|
||||||
|
string replayKey = EditorialReplayKey(chosen);
|
||||||
|
if (!string.IsNullOrEmpty(replayKey))
|
||||||
|
{
|
||||||
|
StampCooldown(
|
||||||
|
replayKey,
|
||||||
|
Time.unscaledTime + EditorialReplayCooldownSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
||||||
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the exact sticky editorial card was shown recently. This is deliberately
|
||||||
|
/// separate from moment-beat cooling: a live scene may continue, but cannot be selected
|
||||||
|
/// again as a fresh cut until another editorial beat has had room to breathe.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsEditorialReplayCooled(InterestCandidate c, float now = -1f)
|
||||||
|
{
|
||||||
|
string key = EditorialReplayKey(c);
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now < 0f)
|
||||||
|
{
|
||||||
|
now = Time.unscaledTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HarnessProbeEditorialReplay(out string detail)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
var first = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "combat:pair:1:2",
|
||||||
|
Label = "Duel - Alpha vs Beta",
|
||||||
|
Completion = InterestCompletionKind.CombatActive
|
||||||
|
};
|
||||||
|
NoteSelection(first, updateMix: false);
|
||||||
|
var replay = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "combat:pair:1:2:refresh",
|
||||||
|
Label = "Duel - Alpha vs Beta",
|
||||||
|
Completion = InterestCompletionKind.CombatActive
|
||||||
|
};
|
||||||
|
var different = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "combat:pair:1:3",
|
||||||
|
Label = "Duel - Alpha vs Gamma",
|
||||||
|
Completion = InterestCompletionKind.CombatActive
|
||||||
|
};
|
||||||
|
var texture = new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "status:sleeping:1",
|
||||||
|
Label = "Alpha · Falls asleep",
|
||||||
|
Completion = InterestCompletionKind.StatusPhase,
|
||||||
|
StatusId = "sleeping"
|
||||||
|
};
|
||||||
|
bool sameCooled = IsEditorialReplayCooled(replay);
|
||||||
|
bool differentOpen = !IsEditorialReplayCooled(different);
|
||||||
|
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture);
|
||||||
|
bool ok = sameCooled && differentOpen && textureUsesBeatPolicy;
|
||||||
|
detail = "sameCooled=" + sameCooled
|
||||||
|
+ " differentOpen=" + differentOpen
|
||||||
|
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy;
|
||||||
|
Clear();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Score to subtract when this candidate matches the last shown variety arc.
|
/// Score to subtract when this candidate matches the last shown variety arc.
|
||||||
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
|
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
|
||||||
|
|
@ -637,6 +711,12 @@ public static class InterestVariety
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsEditorialReplayCooled(c, now))
|
||||||
|
{
|
||||||
|
InterestDropLog.Record("editorial_replay", c.Label ?? c.Key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (c.LeadKind == InterestLeadKind.CharacterLed)
|
if (c.LeadKind == InterestLeadKind.CharacterLed)
|
||||||
{
|
{
|
||||||
CharPool.Add(c);
|
CharPool.Add(c);
|
||||||
|
|
@ -838,6 +918,35 @@ public static class InterestVariety
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string EditorialReplayKey(InterestCandidate c)
|
||||||
|
{
|
||||||
|
if (c == null)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|
||||||
|
|| c.Completion == InterestCompletionKind.WarFront
|
||||||
|
|| c.Completion == InterestCompletionKind.PlotActive
|
||||||
|
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
||||||
|
|| c.Completion == InterestCompletionKind.FamilyPack
|
||||||
|
|| StoryReason.IsStoryAsset(c.AssetId)
|
||||||
|
|| string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!stickyCard)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrEmpty(label))
|
||||||
|
{
|
||||||
|
return "replay:label:" + label;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = (c.Key ?? "").Trim().ToLowerInvariant();
|
||||||
|
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
|
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
92
IdleSpectator/Narrative/EpisodeShotSelector.cs
Normal file
92
IdleSpectator/Narrative/EpisodeShotSelector.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
public sealed class EpisodeShotProposal
|
||||||
|
{
|
||||||
|
public InterestCandidate Candidate;
|
||||||
|
public NarrativeInterruptClass InterruptClass;
|
||||||
|
public string Reason = "";
|
||||||
|
public float EditorialScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Selects the best presentable shot that can explain its place in the active episode.</summary>
|
||||||
|
public static class EpisodeShotSelector
|
||||||
|
{
|
||||||
|
public static EpisodeShotProposal Pick(
|
||||||
|
IList<InterestCandidate> candidates,
|
||||||
|
EpisodePlan episode,
|
||||||
|
NarrativeThread thread,
|
||||||
|
bool requirePresentable = true)
|
||||||
|
{
|
||||||
|
if (candidates == null || candidates.Count == 0 || episode == null) return null;
|
||||||
|
EpisodeShotProposal bestRelated = null;
|
||||||
|
EpisodeShotProposal bestInterrupt = null;
|
||||||
|
for (int i = 0; i < candidates.Count; i++)
|
||||||
|
{
|
||||||
|
InterestCandidate c = candidates[i];
|
||||||
|
bool harness = c != null && string.Equals(c.Source, "harness", System.StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (c == null || c.Selected
|
||||||
|
|| InterestVariety.IsEditorialReplayCooled(c)
|
||||||
|
|| !StoryScheduler.MayUseCombatShot(episode, c)
|
||||||
|
|| (requirePresentable && !harness && !EventPresentability.WouldShow(c))) continue;
|
||||||
|
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
||||||
|
if (function == NarrativeFunction.Noise) continue;
|
||||||
|
NarrativeInterruptClass kind = InterruptPolicy.Classify(c, episode, thread);
|
||||||
|
float score = EditorialScore(c, episode, kind, function);
|
||||||
|
var proposal = new EpisodeShotProposal
|
||||||
|
{
|
||||||
|
Candidate = c,
|
||||||
|
InterruptClass = kind,
|
||||||
|
EditorialScore = score,
|
||||||
|
Reason = Reason(kind)
|
||||||
|
};
|
||||||
|
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
||||||
|
{
|
||||||
|
if (bestRelated == null || score > bestRelated.EditorialScore) bestRelated = proposal;
|
||||||
|
}
|
||||||
|
else if (InterruptPolicy.MayInterrupt(kind)
|
||||||
|
&& (bestInterrupt == null || score > bestInterrupt.EditorialScore))
|
||||||
|
{
|
||||||
|
bestInterrupt = proposal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A productive related development owns the episode. Critical/payoff material interrupts
|
||||||
|
// only when no related shot is ready, or when its editorial advantage is decisive.
|
||||||
|
if (bestRelated != null
|
||||||
|
&& (bestInterrupt == null || bestInterrupt.EditorialScore < bestRelated.EditorialScore + 25f))
|
||||||
|
{
|
||||||
|
return bestRelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestInterrupt ?? bestRelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float EditorialScore(
|
||||||
|
InterestCandidate c,
|
||||||
|
EpisodePlan episode,
|
||||||
|
NarrativeInterruptClass kind,
|
||||||
|
NarrativeFunction function)
|
||||||
|
{
|
||||||
|
float relation = c.SubjectId == episode.ProtagonistId ? 40f
|
||||||
|
: c.RelatedId == episode.ProtagonistId ? 30f : 0f;
|
||||||
|
float interrupt = kind == NarrativeInterruptClass.WorldCritical ? 80f
|
||||||
|
: kind == NarrativeInterruptClass.StoryPayoff ? 35f
|
||||||
|
: kind == NarrativeInterruptClass.RelatedEscalation ? 25f : 0f;
|
||||||
|
return relation + interrupt + NarrativeFunctionPolicy.EditorialBonus(function)
|
||||||
|
+ c.EventStrength * 0.35f
|
||||||
|
+ c.VisualConfidence * 10f + c.Novelty * 3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Reason(NarrativeInterruptClass kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case NarrativeInterruptClass.RelatedEscalation: return "active_episode";
|
||||||
|
case NarrativeInterruptClass.WorldCritical: return "world_critical_interrupt";
|
||||||
|
case NarrativeInterruptClass.StoryPayoff: return "story_payoff_interrupt";
|
||||||
|
default: return "not_episode_worthy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
IdleSpectator/Narrative/InterruptPolicy.cs
Normal file
94
IdleSpectator/Narrative/InterruptPolicy.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
public enum NarrativeInterruptClass
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
RelatedEscalation,
|
||||||
|
StoryPayoff,
|
||||||
|
WorldCritical,
|
||||||
|
OrdinaryInteresting,
|
||||||
|
TextureNoise
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Explicit editorial interrupt classification for episode scheduling.</summary>
|
||||||
|
public static class InterruptPolicy
|
||||||
|
{
|
||||||
|
public static NarrativeInterruptClass Classify(
|
||||||
|
InterestCandidate candidate,
|
||||||
|
EpisodePlan episode,
|
||||||
|
NarrativeThread thread)
|
||||||
|
{
|
||||||
|
if (candidate == null) return NarrativeInterruptClass.None;
|
||||||
|
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
|
||||||
|
if (function == NarrativeFunction.Noise || function == NarrativeFunction.CharacterTexture)
|
||||||
|
{
|
||||||
|
return NarrativeInterruptClass.TextureNoise;
|
||||||
|
}
|
||||||
|
if (IsRelated(candidate, episode, thread)
|
||||||
|
&& NarrativeFunctionPolicy.ChangesStoryState(function))
|
||||||
|
{
|
||||||
|
return NarrativeInterruptClass.RelatedEscalation;
|
||||||
|
}
|
||||||
|
if (IsWorldCritical(candidate)) return NarrativeInterruptClass.WorldCritical;
|
||||||
|
if (IsStoryPayoff(candidate)) return NarrativeInterruptClass.StoryPayoff;
|
||||||
|
|
||||||
|
return NarrativeInterruptClass.OrdinaryInteresting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MayInterrupt(NarrativeInterruptClass kind)
|
||||||
|
{
|
||||||
|
return kind == NarrativeInterruptClass.WorldCritical
|
||||||
|
|| kind == NarrativeInterruptClass.StoryPayoff
|
||||||
|
|| kind == NarrativeInterruptClass.RelatedEscalation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsRelated(InterestCandidate candidate, EpisodePlan episode, NarrativeThread thread)
|
||||||
|
{
|
||||||
|
if (candidate == null || episode == null) return false;
|
||||||
|
if (Touches(candidate, episode.ProtagonistId)) return true;
|
||||||
|
for (int i = 0; i < episode.EligibleCastIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (Touches(candidate, episode.EligibleCastIds[i])) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thread == null || string.IsNullOrEmpty(thread.AnchorKey)) return false;
|
||||||
|
return EqualsKey(candidate.CorrelationKey, thread.AnchorKey)
|
||||||
|
|| EqualsKey(candidate.CityKey, thread.AnchorKey)
|
||||||
|
|| EqualsKey(candidate.KingdomKey, thread.AnchorKey)
|
||||||
|
|| (candidate.Key ?? "").IndexOf(thread.AnchorKey, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Touches(InterestCandidate c, long id)
|
||||||
|
{
|
||||||
|
if (id == 0) return false;
|
||||||
|
if (c.SubjectId == id || c.RelatedId == id || c.PairOwnerId == id
|
||||||
|
|| c.PairPartnerId == id || c.TheaterLeadId == id
|
||||||
|
|| EventFeedUtil.SafeId(c.FollowUnit) == id) return true;
|
||||||
|
for (int i = 0; i < c.ParticipantIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (c.ParticipantIds[i] == id) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 IsStoryPayoff(InterestCandidate c)
|
||||||
|
{
|
||||||
|
return StoryReason.IsStoryAsset(c.AssetId)
|
||||||
|
|| (c.Source ?? "").IndexOf("story", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EqualsKey(string a, string b) =>
|
||||||
|
!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b)
|
||||||
|
&& string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
162
IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs
Normal file
162
IdleSpectator/Narrative/NarrativeDevelopmentRouter.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>Thin adapters from confirmed game observations to semantic narrative developments.</summary>
|
||||||
|
public static class NarrativeDevelopmentRouter
|
||||||
|
{
|
||||||
|
public static bool BondFormed(Actor subject, Actor partner, string source)
|
||||||
|
{
|
||||||
|
return Record(NarrativeDevelopmentKind.BondFormed, NarrativeFunction.ThreadOpener,
|
||||||
|
subject, partner, "bond:" + Pair(subject, partner) + ":formed:" + TimeBucket(), source, magnitude: 72f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool BondBroken(Actor subject, Actor former, string source)
|
||||||
|
{
|
||||||
|
return Record(NarrativeDevelopmentKind.BondBroken, NarrativeFunction.TurningPoint,
|
||||||
|
subject, former, "bond:" + Pair(subject, former) + ":broken:" + TimeBucket(), source, magnitude: 88f,
|
||||||
|
outcome: "partnership ended");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ChildBorn(Actor parent, Actor child, string source)
|
||||||
|
{
|
||||||
|
string family = FamilyKey(parent);
|
||||||
|
return Record(NarrativeDevelopmentKind.ChildBorn, NarrativeFunction.Consequence,
|
||||||
|
parent, child, "child:" + EventFeedUtil.SafeId(parent) + ":" + EventFeedUtil.SafeId(child),
|
||||||
|
source, familyKey: family, magnitude: 76f, outcome: "child born");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool FriendFormed(Actor subject, Actor friend, string source)
|
||||||
|
{
|
||||||
|
return Record(NarrativeDevelopmentKind.FriendFormed, NarrativeFunction.ThreadOpener,
|
||||||
|
subject, friend, "friend:" + Pair(subject, friend), source, magnitude: 55f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Death(Actor victim, Actor killer, string attackType, string source)
|
||||||
|
{
|
||||||
|
return Record(NarrativeDevelopmentKind.Death, NarrativeFunction.Resolution,
|
||||||
|
victim, killer, "death:" + EventFeedUtil.SafeId(victim), source,
|
||||||
|
newState: "dead", outcome: attackType ?? "", magnitude: 100f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Kill(Actor killer, Actor victim, string source)
|
||||||
|
{
|
||||||
|
return Record(NarrativeDevelopmentKind.Kill, NarrativeFunction.TurningPoint,
|
||||||
|
killer, victim, "kill:" + EventFeedUtil.SafeId(killer) + ":" + EventFeedUtil.SafeId(victim),
|
||||||
|
source, outcome: "victim died", magnitude: 82f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Role(Actor subject, string role, bool gained, string source)
|
||||||
|
{
|
||||||
|
return Record(gained ? NarrativeDevelopmentKind.RoleGained : NarrativeDevelopmentKind.RoleLost,
|
||||||
|
gained ? NarrativeFunction.TurningPoint : NarrativeFunction.Consequence,
|
||||||
|
subject, null, "role:" + EventFeedUtil.SafeId(subject) + ":" + (role ?? ""), source,
|
||||||
|
newState: role, magnitude: 78f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Founding(Actor subject, string kind, string key, string source)
|
||||||
|
{
|
||||||
|
return Record(NarrativeDevelopmentKind.HomeFounded, NarrativeFunction.TurningPoint,
|
||||||
|
subject, null, "founding:" + kind + ":" + key + ":" + EventFeedUtil.SafeId(subject), source,
|
||||||
|
cityKey: kind == "city" ? key : "", kingdomKey: kind == "kingdom" ? key : "",
|
||||||
|
newState: kind, magnitude: 82f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool War(Actor subject, Actor other, string warKey, bool ended, string source)
|
||||||
|
{
|
||||||
|
return Record(ended ? NarrativeDevelopmentKind.WarEnded : NarrativeDevelopmentKind.WarJoined,
|
||||||
|
ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation,
|
||||||
|
subject, other, "war:" + warKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended,
|
||||||
|
source, warKey: warKey, magnitude: ended ? 86f : 68f,
|
||||||
|
outcome: ended ? "war ended" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Plot(Actor subject, Actor other, string plotKey, bool ended, string source)
|
||||||
|
{
|
||||||
|
return Record(ended ? NarrativeDevelopmentKind.PlotEnded : NarrativeDevelopmentKind.PlotJoined,
|
||||||
|
ended ? NarrativeFunction.Resolution : NarrativeFunction.Escalation,
|
||||||
|
subject, other, "plot:" + plotKey + ":" + EventFeedUtil.SafeId(subject) + ":" + ended,
|
||||||
|
source, plotKey: plotKey, magnitude: ended ? 78f : 64f,
|
||||||
|
outcome: ended ? "plot ended" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Record(
|
||||||
|
NarrativeDevelopmentKind kind,
|
||||||
|
NarrativeFunction function,
|
||||||
|
Actor subject,
|
||||||
|
Actor other,
|
||||||
|
string id,
|
||||||
|
string source,
|
||||||
|
string familyKey = "",
|
||||||
|
string cityKey = "",
|
||||||
|
string kingdomKey = "",
|
||||||
|
string warKey = "",
|
||||||
|
string plotKey = "",
|
||||||
|
string newState = "",
|
||||||
|
string outcome = "",
|
||||||
|
float magnitude = 50f)
|
||||||
|
{
|
||||||
|
long subjectId = EventFeedUtil.SafeId(subject);
|
||||||
|
if (subjectId == 0) return false;
|
||||||
|
long otherId = EventFeedUtil.SafeId(other);
|
||||||
|
return NarrativeStoryStore.Record(new NarrativeDevelopment
|
||||||
|
{
|
||||||
|
Id = id ?? "",
|
||||||
|
Kind = kind,
|
||||||
|
Function = function,
|
||||||
|
SubjectId = subjectId,
|
||||||
|
Subject = LifeSagaMemory.Snapshot(subject),
|
||||||
|
OtherId = otherId,
|
||||||
|
Other = LifeSagaMemory.Snapshot(other),
|
||||||
|
CorrelationKey = id ?? "",
|
||||||
|
FamilyKey = familyKey ?? "",
|
||||||
|
CityKey = cityKey ?? "",
|
||||||
|
KingdomKey = kingdomKey ?? "",
|
||||||
|
WarKey = warKey ?? "",
|
||||||
|
PlotKey = plotKey ?? "",
|
||||||
|
NewState = newState ?? "",
|
||||||
|
Outcome = outcome ?? "",
|
||||||
|
EvidenceSource = source ?? "",
|
||||||
|
DateLabel = LatestDate(subjectId),
|
||||||
|
OccurredAt = Time.unscaledTime,
|
||||||
|
Magnitude = magnitude,
|
||||||
|
Confidence = 1f,
|
||||||
|
Presentable = true,
|
||||||
|
IsNewStateChange = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LatestDate(long subjectId)
|
||||||
|
{
|
||||||
|
var facts = LifeSagaMemory.FactsFor(subjectId);
|
||||||
|
if (facts != null && facts.Count > 0 && facts[0] != null) return facts[0].DateLabel ?? "";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Pair(Actor a, Actor b)
|
||||||
|
{
|
||||||
|
long ai = EventFeedUtil.SafeId(a);
|
||||||
|
long bi = EventFeedUtil.SafeId(b);
|
||||||
|
if (bi == 0) return ai.ToString();
|
||||||
|
return ai <= bi ? ai + ":" + bi : bi + ":" + ai;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FamilyKey(Actor actor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (actor != null && actor.hasFamily() && actor.family != null)
|
||||||
|
{
|
||||||
|
return actor.family.getID().ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Subject id remains a stable lineage fallback.
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventFeedUtil.SafeId(actor).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TimeBucket() => Mathf.FloorToInt(Time.unscaledTime * 10f);
|
||||||
|
}
|
||||||
205
IdleSpectator/Narrative/NarrativeFunctionCatalog.cs
Normal file
205
IdleSpectator/Narrative/NarrativeFunctionCatalog.cs
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explicit catalog-to-editorial-role mapping at the single candidate intake boundary.
|
||||||
|
/// Structural fallbacks cover live theaters; string heuristics remain only a compatibility fallback.
|
||||||
|
/// </summary>
|
||||||
|
public static class NarrativeFunctionCatalog
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, NarrativeFunction> Exact =
|
||||||
|
new Dictionary<string, NarrativeFunction>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static readonly HashSet<string> ObservedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
private static readonly int[] FunctionCounts = new int[9];
|
||||||
|
|
||||||
|
public static int ObservedCount { get; private set; }
|
||||||
|
public static int ExplicitCount { get; private set; }
|
||||||
|
public static int StructuralCount { get; private set; }
|
||||||
|
public static int FallbackCount { get; private set; }
|
||||||
|
|
||||||
|
static NarrativeFunctionCatalog()
|
||||||
|
{
|
||||||
|
Add(NarrativeFunction.ThreadOpener,
|
||||||
|
"set_lover", "fallen_in_love", "fell_in_love", "new_family", "family_group_new",
|
||||||
|
"just_made_friend");
|
||||||
|
Add(NarrativeFunction.Development,
|
||||||
|
"live_family", "family_group_join", "just_kissed", "alliance_join", "alliance_create",
|
||||||
|
"alliance_new", "just_gave_gift", "just_received_gift", "just_talked_gossip");
|
||||||
|
Add(NarrativeFunction.Escalation,
|
||||||
|
"live_combat", "live_battle", "live_war", "live_plot", "live_outbreak",
|
||||||
|
"new_war", "diplomacy_war_started", "just_started_war", "total_war_started",
|
||||||
|
"whisper_of_war", "rebellion", "cause_rebellion", "just_rebelled", "just_injured",
|
||||||
|
"just_cursed", "just_possessed", "status_soul_harvested", "attacker_stop_war");
|
||||||
|
Add(NarrativeFunction.TurningPoint,
|
||||||
|
"clear_lover", "just_killed", "milestone_kill", "become_alpha", "become_king",
|
||||||
|
"become_leader", "king_new", "kingdom_new", "city_new", "just_found_house",
|
||||||
|
"conquered_city", "was_conquered", "clan_ascension", "kingdom_royal_clan_changed",
|
||||||
|
"kingdom_royal_clan_new", "culture_divide", "religion_schism", "language_divergence");
|
||||||
|
Add(NarrativeFunction.Resolution,
|
||||||
|
"diplomacy_war_ended", "just_made_peace", "just_finished_plot", "just_won_war",
|
||||||
|
"aftermath_war_linger", "aftermath_plot_fallout", "recovery_outbreak");
|
||||||
|
Add(NarrativeFunction.Consequence,
|
||||||
|
"add_child", "just_had_child", "baby_created", "just_born", "just_got_out_of_egg",
|
||||||
|
"just_became_adult",
|
||||||
|
"death_best_friend", "death_child", "death_family_member", "death_lover", "king_dead",
|
||||||
|
"king_killed", "favorite_dead", "favorite_killed", "city_destroyed", "destroyed_city",
|
||||||
|
"kingdom_destroyed", "kingdom_fell_apart", "kingdom_fractured", "kingdom_shattered",
|
||||||
|
"just_lost_house", "just_lost_war", "lost_capital", "lost_city", "lost_crown",
|
||||||
|
"razed_capital", "razed_city", "family_removed", "alliance_destroy", "alliance_dissolved",
|
||||||
|
"aftermath_love_linger", "aftermath_mourner", "aftermath_survivor", "epilogue_crisis",
|
||||||
|
"epilogue_related", "wrote_book", "new_book");
|
||||||
|
Add(NarrativeFunction.WorldContext,
|
||||||
|
"earthquake", "small_earthquake", "small_meteorite", "alien_invasion", "ash_bandits",
|
||||||
|
"biomass", "dragon_from_farlands", "garden_surprise", "greg_abominations", "heatwave",
|
||||||
|
"hellspawn", "ice_ones_awoken", "mad_thoughts", "sudden_snowman", "tornado", "tumor",
|
||||||
|
"underground_necromancer", "new_culture", "new_language", "new_religion");
|
||||||
|
Add(NarrativeFunction.CharacterTexture,
|
||||||
|
"find_lover", "sexual_reproduction_try", "family_group_follow", "family_group_leave",
|
||||||
|
"just_ate", "just_cried", "just_laughed", "just_played", "just_pooped", "just_sang",
|
||||||
|
"just_slept", "slept_outside", "just_talked", "paid_tax", "had_bad_dream",
|
||||||
|
"had_good_dream", "had_nightmare", "just_surprised", "just_swore", "just_read_book");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearCoverage()
|
||||||
|
{
|
||||||
|
ObservedKeys.Clear();
|
||||||
|
Array.Clear(FunctionCounts, 0, FunctionCounts.Length);
|
||||||
|
ObservedCount = 0;
|
||||||
|
ExplicitCount = 0;
|
||||||
|
StructuralCount = 0;
|
||||||
|
FallbackCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stamp(InterestCandidate candidate)
|
||||||
|
{
|
||||||
|
if (candidate == null) return;
|
||||||
|
if (!candidate.HasNarrativeFunction)
|
||||||
|
{
|
||||||
|
if (TryExact(candidate.HappinessEffectId, out NarrativeFunction function)
|
||||||
|
|| TryExact(candidate.AssetId, out function)
|
||||||
|
|| TryExact(candidate.StatusId, out function)
|
||||||
|
|| TryExact(candidate.Verb, out function))
|
||||||
|
{
|
||||||
|
Set(candidate, function, "catalog");
|
||||||
|
}
|
||||||
|
else if (TryDomain(candidate, out function))
|
||||||
|
{
|
||||||
|
Set(candidate, function, "structural");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Compatibility fallback is stamped once, so downstream policy is deterministic.
|
||||||
|
Set(candidate, NarrativeFunctionPolicy.Classify(candidate), "fallback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteCoverage(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CoverageSummary => "observed=" + ObservedCount
|
||||||
|
+ " explicit=" + ExplicitCount + " structural=" + StructuralCount
|
||||||
|
+ " fallback=" + FallbackCount + " functions=" + FormatCounts();
|
||||||
|
|
||||||
|
private static bool TryDomain(InterestCandidate c, out NarrativeFunction function)
|
||||||
|
{
|
||||||
|
string source = c.Source ?? "";
|
||||||
|
string category = c.Category ?? "";
|
||||||
|
if (c.Completion == InterestCompletionKind.CombatActive
|
||||||
|
|| c.Completion == InterestCompletionKind.WarFront
|
||||||
|
|| c.Completion == InterestCompletionKind.PlotActive
|
||||||
|
|| c.Completion == InterestCompletionKind.StatusOutbreak)
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.Escalation;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c.Completion == InterestCompletionKind.HappinessGrief)
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.TurningPoint;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c.Completion == InterestCompletionKind.EarthquakeActive
|
||||||
|
|| Contains(source, "disaster") || Contains(category, "disaster") || Contains(source, "era"))
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.WorldContext;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Contains(source, "plot") || Contains(category, "plot"))
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.Development;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Contains(source, "book") || Contains(source, "trait")
|
||||||
|
|| c.Completion == InterestCompletionKind.CharacterVignette
|
||||||
|
|| c.Completion == InterestCompletionKind.StatusPhase)
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.CharacterTexture;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function = NarrativeFunction.Noise;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Set(InterestCandidate c, NarrativeFunction function, string source)
|
||||||
|
{
|
||||||
|
c.NarrativeFunction = function;
|
||||||
|
c.HasNarrativeFunction = true;
|
||||||
|
c.NarrativeFunctionSource = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NoteCoverage(InterestCandidate c)
|
||||||
|
{
|
||||||
|
string key = c.Key ?? "";
|
||||||
|
if (!ObservedKeys.Add(key)) return;
|
||||||
|
ObservedCount++;
|
||||||
|
int index = (int)c.NarrativeFunction;
|
||||||
|
if (index >= 0 && index < FunctionCounts.Length) FunctionCounts[index]++;
|
||||||
|
if (c.NarrativeFunctionSource == "catalog") ExplicitCount++;
|
||||||
|
else if (c.NarrativeFunctionSource == "structural") StructuralCount++;
|
||||||
|
else FallbackCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryExact(string id, out NarrativeFunction function)
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.Noise;
|
||||||
|
if (string.IsNullOrEmpty(id)) return false;
|
||||||
|
string key = id.Trim();
|
||||||
|
if (Exact.TryGetValue(key, out function)) return true;
|
||||||
|
if (key.StartsWith("disaster_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.WorldContext;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key.StartsWith("summon_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.Escalation;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key.StartsWith("asexual_reproduction_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
function = NarrativeFunction.CharacterTexture;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(NarrativeFunction function, params string[] ids)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < ids.Length; i++) Exact[ids[i]] = function;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Contains(string text, string needle) =>
|
||||||
|
text.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
private static string FormatCounts()
|
||||||
|
{
|
||||||
|
var parts = new List<string>(FunctionCounts.Length);
|
||||||
|
for (int i = 0; i < FunctionCounts.Length; i++)
|
||||||
|
{
|
||||||
|
if (FunctionCounts[i] > 0) parts.Add(((NarrativeFunction)i) + ":" + FunctionCounts[i]);
|
||||||
|
}
|
||||||
|
return string.Join(",", parts.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
110
IdleSpectator/Narrative/NarrativeFunctionPolicy.cs
Normal file
110
IdleSpectator/Narrative/NarrativeFunctionPolicy.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Editorial meaning of a camera candidate. This is deliberately separate from numeric
|
||||||
|
/// spectacle strength: a loud event can still be context, while a quiet state change can
|
||||||
|
/// advance a life story.
|
||||||
|
/// </summary>
|
||||||
|
public static class NarrativeFunctionPolicy
|
||||||
|
{
|
||||||
|
public static NarrativeFunction Classify(InterestCandidate candidate)
|
||||||
|
{
|
||||||
|
if (candidate == null) return NarrativeFunction.Noise;
|
||||||
|
if (candidate.HasNarrativeFunction) return candidate.NarrativeFunction;
|
||||||
|
|
||||||
|
string asset = (candidate.AssetId ?? "").ToLowerInvariant();
|
||||||
|
string category = (candidate.Category ?? "").ToLowerInvariant();
|
||||||
|
string source = (candidate.Source ?? "").ToLowerInvariant();
|
||||||
|
string verb = (candidate.Verb ?? "").ToLowerInvariant();
|
||||||
|
string text = asset + " " + category + " " + source + " " + verb;
|
||||||
|
|
||||||
|
if (ContainsAny(text, "add_child", "child_born", "new_child", "became_parent"))
|
||||||
|
return NarrativeFunction.Consequence;
|
||||||
|
if (ContainsAny(text, "clear_lover", "lover_died", "partner_died", "grief"))
|
||||||
|
return NarrativeFunction.TurningPoint;
|
||||||
|
if (ContainsAny(text, "set_lover", "new_family", "married", "bond_formed"))
|
||||||
|
return NarrativeFunction.ThreadOpener;
|
||||||
|
if (ContainsAny(text, "war_ended", "plot_ended", "plot_resolved", "recovered"))
|
||||||
|
return NarrativeFunction.Resolution;
|
||||||
|
if (ContainsAny(text, "death", "died", "killed", "destroyed", "role_lost", "lost_home"))
|
||||||
|
return NarrativeFunction.Consequence;
|
||||||
|
if (ContainsAny(text, "become_king", "new_king", "founded", "new_city", "role_gained"))
|
||||||
|
return NarrativeFunction.TurningPoint;
|
||||||
|
if (ContainsAny(text, "war_started", "joined_war", "plot_joined", "betray", "rival"))
|
||||||
|
return NarrativeFunction.Escalation;
|
||||||
|
|
||||||
|
switch (candidate.Completion)
|
||||||
|
{
|
||||||
|
case InterestCompletionKind.CombatActive:
|
||||||
|
case InterestCompletionKind.WarFront:
|
||||||
|
case InterestCompletionKind.PlotActive:
|
||||||
|
case InterestCompletionKind.StatusOutbreak:
|
||||||
|
return NarrativeFunction.Escalation;
|
||||||
|
case InterestCompletionKind.HappinessGrief:
|
||||||
|
return NarrativeFunction.TurningPoint;
|
||||||
|
case InterestCompletionKind.EarthquakeActive:
|
||||||
|
return NarrativeFunction.WorldContext;
|
||||||
|
case InterestCompletionKind.FamilyPack:
|
||||||
|
return NarrativeFunction.Development;
|
||||||
|
case InterestCompletionKind.ActivityActive:
|
||||||
|
return candidate.LeadKind == InterestLeadKind.EventLed
|
||||||
|
? NarrativeFunction.Development
|
||||||
|
: NarrativeFunction.CharacterTexture;
|
||||||
|
case InterestCompletionKind.CharacterVignette:
|
||||||
|
case InterestCompletionKind.StatusPhase:
|
||||||
|
return NarrativeFunction.CharacterTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StoryReason.IsStoryAsset(candidate.AssetId)
|
||||||
|
|| source.IndexOf("story", StringComparison.Ordinal) >= 0)
|
||||||
|
{
|
||||||
|
return NarrativeFunction.Development;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.LeadKind == InterestLeadKind.CharacterLed)
|
||||||
|
return NarrativeFunction.CharacterTexture;
|
||||||
|
if (candidate.EventStrength >= 95f) return NarrativeFunction.WorldContext;
|
||||||
|
if (candidate.EventStrength >= 70f) return NarrativeFunction.Development;
|
||||||
|
return candidate.EventStrength < 35f
|
||||||
|
? NarrativeFunction.Noise
|
||||||
|
: NarrativeFunction.CharacterTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ChangesStoryState(NarrativeFunction function)
|
||||||
|
{
|
||||||
|
return function == NarrativeFunction.ThreadOpener
|
||||||
|
|| function == NarrativeFunction.Development
|
||||||
|
|| function == NarrativeFunction.Escalation
|
||||||
|
|| function == NarrativeFunction.TurningPoint
|
||||||
|
|| function == NarrativeFunction.Resolution
|
||||||
|
|| function == NarrativeFunction.Consequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float EditorialBonus(NarrativeFunction function)
|
||||||
|
{
|
||||||
|
switch (function)
|
||||||
|
{
|
||||||
|
case NarrativeFunction.ThreadOpener: return 8f;
|
||||||
|
case NarrativeFunction.Development: return 12f;
|
||||||
|
case NarrativeFunction.Escalation: return 18f;
|
||||||
|
case NarrativeFunction.TurningPoint: return 28f;
|
||||||
|
case NarrativeFunction.Resolution: return 32f;
|
||||||
|
case NarrativeFunction.Consequence: return 24f;
|
||||||
|
case NarrativeFunction.WorldContext: return 10f;
|
||||||
|
case NarrativeFunction.CharacterTexture: return -15f;
|
||||||
|
default: return -35f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string text, params string[] needles)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < needles.Length; i++)
|
||||||
|
{
|
||||||
|
if (text.IndexOf(needles[i], StringComparison.Ordinal) >= 0) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
262
IdleSpectator/Narrative/NarrativeModels.cs
Normal file
262
IdleSpectator/Narrative/NarrativeModels.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
public enum NarrativeDevelopmentKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
BondFormed,
|
||||||
|
BondBroken,
|
||||||
|
ChildBorn,
|
||||||
|
FriendFormed,
|
||||||
|
Death,
|
||||||
|
Kill,
|
||||||
|
RoleGained,
|
||||||
|
RoleLost,
|
||||||
|
HomeFounded,
|
||||||
|
HomeGained,
|
||||||
|
HomeLost,
|
||||||
|
WarJoined,
|
||||||
|
WarEnded,
|
||||||
|
PlotJoined,
|
||||||
|
PlotEnded,
|
||||||
|
RivalEncounter,
|
||||||
|
Transformation,
|
||||||
|
Recovery
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NarrativeFunction
|
||||||
|
{
|
||||||
|
Noise,
|
||||||
|
CharacterTexture,
|
||||||
|
WorldContext,
|
||||||
|
ThreadOpener,
|
||||||
|
Development,
|
||||||
|
Escalation,
|
||||||
|
TurningPoint,
|
||||||
|
Resolution,
|
||||||
|
Consequence
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NarrativeThreadKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Courtship,
|
||||||
|
Partnership,
|
||||||
|
Lineage,
|
||||||
|
SeparationGrief,
|
||||||
|
RiseToRule,
|
||||||
|
Reign,
|
||||||
|
Succession,
|
||||||
|
Founding,
|
||||||
|
HomeLoss,
|
||||||
|
Rivalry,
|
||||||
|
PersonalCombat,
|
||||||
|
WarInvolvement,
|
||||||
|
PlotBetrayal,
|
||||||
|
Transformation,
|
||||||
|
Recovery
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NarrativePhase
|
||||||
|
{
|
||||||
|
Setup,
|
||||||
|
Pressure,
|
||||||
|
Escalation,
|
||||||
|
TurningPoint,
|
||||||
|
Outcome,
|
||||||
|
Consequence
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NarrativeThreadStatus
|
||||||
|
{
|
||||||
|
Emerging,
|
||||||
|
Active,
|
||||||
|
Dormant,
|
||||||
|
Resolved,
|
||||||
|
Abandoned
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CharacterConsequenceKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
FoundLove,
|
||||||
|
LostPartner,
|
||||||
|
BecameParent,
|
||||||
|
WasBorn,
|
||||||
|
LostFamily,
|
||||||
|
LostLife,
|
||||||
|
TookLife,
|
||||||
|
RoseToRole,
|
||||||
|
LostRole,
|
||||||
|
FoundedHome,
|
||||||
|
LostHome,
|
||||||
|
EnteredWar,
|
||||||
|
SurvivedWar,
|
||||||
|
JoinedPlot,
|
||||||
|
PlotResolved,
|
||||||
|
Transformed,
|
||||||
|
Recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Evidence-backed semantic state transition. Contains no final player-facing prose.</summary>
|
||||||
|
public sealed class NarrativeDevelopment
|
||||||
|
{
|
||||||
|
public string Id = "";
|
||||||
|
public NarrativeDevelopmentKind Kind;
|
||||||
|
public NarrativeFunction Function;
|
||||||
|
public long SubjectId;
|
||||||
|
public LifeSagaIdentity Subject;
|
||||||
|
public long OtherId;
|
||||||
|
public LifeSagaIdentity Other;
|
||||||
|
public readonly List<long> OtherIds = new List<long>(4);
|
||||||
|
public string CorrelationKey = "";
|
||||||
|
public string FamilyKey = "";
|
||||||
|
public string CityKey = "";
|
||||||
|
public string KingdomKey = "";
|
||||||
|
public string WarKey = "";
|
||||||
|
public string PlotKey = "";
|
||||||
|
public string PreviousState = "";
|
||||||
|
public string NewState = "";
|
||||||
|
public string Outcome = "";
|
||||||
|
public string EvidenceSource = "";
|
||||||
|
public string DateLabel = "";
|
||||||
|
public float OccurredAt;
|
||||||
|
public float Magnitude;
|
||||||
|
public float Confidence = 1f;
|
||||||
|
public bool Presentable = true;
|
||||||
|
public bool IsNewStateChange = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NarrativeCastRole
|
||||||
|
{
|
||||||
|
public long CharacterId;
|
||||||
|
public string Role = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Durable unresolved question centered on one life.</summary>
|
||||||
|
public sealed class NarrativeThread
|
||||||
|
{
|
||||||
|
public string Id = "";
|
||||||
|
public NarrativeThreadKind Kind;
|
||||||
|
public long ProtagonistId;
|
||||||
|
public readonly List<NarrativeCastRole> Cast = new List<NarrativeCastRole>(6);
|
||||||
|
public readonly List<string> DevelopmentIds = new List<string>(12);
|
||||||
|
public string AnchorKey = "";
|
||||||
|
public string OpenedByDevelopmentId = "";
|
||||||
|
public string LatestMeaningfulChangeId = "";
|
||||||
|
public string CentralQuestion = "";
|
||||||
|
public string PressureEvidence = "";
|
||||||
|
public string Resolution = "";
|
||||||
|
public NarrativePhase Phase;
|
||||||
|
public NarrativeThreadStatus Status;
|
||||||
|
public float OpenedAt;
|
||||||
|
public float UpdatedAt;
|
||||||
|
public float Momentum;
|
||||||
|
public float Urgency;
|
||||||
|
public float Coherence;
|
||||||
|
public float Novelty;
|
||||||
|
public float CoverageDebt;
|
||||||
|
|
||||||
|
public bool IsOpen => Status == NarrativeThreadStatus.Emerging
|
||||||
|
|| Status == NarrativeThreadStatus.Active
|
||||||
|
|| Status == NarrativeThreadStatus.Dormant;
|
||||||
|
|
||||||
|
public bool HasCast(long id)
|
||||||
|
{
|
||||||
|
if (id == 0) return false;
|
||||||
|
for (int i = 0; i < Cast.Count; i++)
|
||||||
|
{
|
||||||
|
if (Cast[i] != null && Cast[i].CharacterId == id) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddCast(long id, string role)
|
||||||
|
{
|
||||||
|
if (id == 0 || HasCast(id)) return;
|
||||||
|
Cast.Add(new NarrativeCastRole { CharacterId = id, Role = role ?? "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CharacterConsequence
|
||||||
|
{
|
||||||
|
public string Id = "";
|
||||||
|
public long CharacterId;
|
||||||
|
public string ThreadId = "";
|
||||||
|
public string DevelopmentId = "";
|
||||||
|
public CharacterConsequenceKind Kind;
|
||||||
|
public string Role = "";
|
||||||
|
public long OtherId;
|
||||||
|
public LifeSagaIdentity Other;
|
||||||
|
public string Context = "";
|
||||||
|
public string Outcome = "";
|
||||||
|
public string DateLabel = "";
|
||||||
|
public float OccurredAt;
|
||||||
|
public float Importance;
|
||||||
|
public float Confidence = 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CharacterStoryState
|
||||||
|
{
|
||||||
|
public long CharacterId;
|
||||||
|
public LifeSagaIdentity Identity;
|
||||||
|
public readonly List<string> OpenThreadIds = new List<string>(6);
|
||||||
|
public readonly List<string> ResolvedThreadIds = new List<string>(8);
|
||||||
|
public readonly List<string> RecentDevelopmentIds = new List<string>(16);
|
||||||
|
public readonly List<string> ConsequenceIds = new List<string>(16);
|
||||||
|
public float StoryMomentum;
|
||||||
|
public float StoryPotential;
|
||||||
|
public float LastMeaningfulChangeAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NarrativeChapter
|
||||||
|
{
|
||||||
|
public string Id = "";
|
||||||
|
public string ThreadId = "";
|
||||||
|
public string Line = "";
|
||||||
|
public string DateLabel = "";
|
||||||
|
public float At;
|
||||||
|
public float Importance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EpisodePurpose
|
||||||
|
{
|
||||||
|
Establish,
|
||||||
|
Develop,
|
||||||
|
Payoff,
|
||||||
|
Consequence,
|
||||||
|
Reintroduce
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class EpisodePlan
|
||||||
|
{
|
||||||
|
public string ThreadId = "";
|
||||||
|
public long ProtagonistId;
|
||||||
|
public readonly List<long> EligibleCastIds = new List<long>(6);
|
||||||
|
public string EntryDevelopmentId = "";
|
||||||
|
public EpisodePurpose Purpose;
|
||||||
|
public string InformationGoal = "";
|
||||||
|
public float StartedAt;
|
||||||
|
public float LastProgressAt;
|
||||||
|
public float InterruptedAt = -1f;
|
||||||
|
public string LastShotKey = "";
|
||||||
|
public int CombatShotCount;
|
||||||
|
public bool GoalMet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NarrativeBeatModel
|
||||||
|
{
|
||||||
|
public long PerspectiveCharacterId;
|
||||||
|
public string DevelopmentId = "";
|
||||||
|
public string ThreadId = "";
|
||||||
|
public NarrativeDevelopmentKind ActionKind;
|
||||||
|
public long OtherId;
|
||||||
|
public LifeSagaIdentity Other;
|
||||||
|
public string Change = "";
|
||||||
|
public string Outcome = "";
|
||||||
|
public NarrativePhase ThreadPhase;
|
||||||
|
public string ContextEvidence = "";
|
||||||
|
public float Confidence = 1f;
|
||||||
|
}
|
||||||
199
IdleSpectator/Narrative/NarrativeProse.cs
Normal file
199
IdleSpectator/Narrative/NarrativeProse.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>Evidence-backed renderer for structured narrative beats and consequences.</summary>
|
||||||
|
public static class NarrativeProse
|
||||||
|
{
|
||||||
|
public static bool TryBuildBeat(
|
||||||
|
Actor focus,
|
||||||
|
InterestCandidate tip,
|
||||||
|
out NarrativeBeatModel model,
|
||||||
|
out string beat,
|
||||||
|
out string context)
|
||||||
|
{
|
||||||
|
model = null;
|
||||||
|
beat = "";
|
||||||
|
context = "";
|
||||||
|
long focusId = EventFeedUtil.SafeId(focus);
|
||||||
|
if (focusId == 0 || tip == null) return false;
|
||||||
|
|
||||||
|
NarrativeDevelopmentKind kind = KindForTip(tip);
|
||||||
|
if (kind == NarrativeDevelopmentKind.Unknown) return false;
|
||||||
|
NarrativeDevelopment d = NarrativeStoryStore.LatestFor(focusId, kind, 8f);
|
||||||
|
if (d == null) return false;
|
||||||
|
|
||||||
|
model = new NarrativeBeatModel
|
||||||
|
{
|
||||||
|
PerspectiveCharacterId = focusId,
|
||||||
|
DevelopmentId = d.Id,
|
||||||
|
ActionKind = d.Kind,
|
||||||
|
OtherId = d.OtherId,
|
||||||
|
Other = d.Other,
|
||||||
|
Change = d.NewState,
|
||||||
|
Outcome = d.Outcome,
|
||||||
|
Confidence = d.Confidence
|
||||||
|
};
|
||||||
|
NarrativeThread thread = LatestThreadFor(focusId, d.Id);
|
||||||
|
if (thread != null)
|
||||||
|
{
|
||||||
|
model.ThreadId = thread.Id;
|
||||||
|
model.ThreadPhase = thread.Phase;
|
||||||
|
model.ContextEvidence = thread.PressureEvidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
beat = BeatLine(model);
|
||||||
|
context = ContextLine(model, thread);
|
||||||
|
return !string.IsNullOrEmpty(beat);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BeatLine(NarrativeBeatModel model)
|
||||||
|
{
|
||||||
|
if (model == null || model.Confidence < 0.75f) return "";
|
||||||
|
string other = model.Other.Name ?? "";
|
||||||
|
switch (model.ActionKind)
|
||||||
|
{
|
||||||
|
case NarrativeDevelopmentKind.BondFormed:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Finds love" : "Finds love with " + other;
|
||||||
|
case NarrativeDevelopmentKind.BondBroken:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Parts from a lover" : "Parts from " + other;
|
||||||
|
case NarrativeDevelopmentKind.ChildBorn:
|
||||||
|
{
|
||||||
|
int count = NarrativeStoryStore.CountConsequences(
|
||||||
|
model.PerspectiveCharacterId, CharacterConsequenceKind.BecameParent);
|
||||||
|
string first = count == 1 ? " first" : "";
|
||||||
|
return string.IsNullOrEmpty(other)
|
||||||
|
? "Welcomes a" + first + " child"
|
||||||
|
: "Welcomes" + first + " child " + other;
|
||||||
|
}
|
||||||
|
case NarrativeDevelopmentKind.FriendFormed:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Makes a true friend" : "Befriends " + other;
|
||||||
|
case NarrativeDevelopmentKind.Death:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Dies" : "Is slain by " + other;
|
||||||
|
case NarrativeDevelopmentKind.Kill:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Takes a life" : "Slays " + other;
|
||||||
|
case NarrativeDevelopmentKind.RoleGained:
|
||||||
|
return string.IsNullOrEmpty(model.Change) ? "Rises in standing" : "Becomes " + Humanize(model.Change);
|
||||||
|
case NarrativeDevelopmentKind.RoleLost:
|
||||||
|
return string.IsNullOrEmpty(model.Change) ? "Loses their standing" : "Is no longer " + Humanize(model.Change);
|
||||||
|
case NarrativeDevelopmentKind.HomeFounded:
|
||||||
|
return "Founds a new home";
|
||||||
|
case NarrativeDevelopmentKind.HomeLost:
|
||||||
|
return "Loses their home";
|
||||||
|
case NarrativeDevelopmentKind.WarJoined:
|
||||||
|
return "Enters the war";
|
||||||
|
case NarrativeDevelopmentKind.WarEnded:
|
||||||
|
return "Emerges from the war";
|
||||||
|
case NarrativeDevelopmentKind.PlotJoined:
|
||||||
|
return "Enters a plot";
|
||||||
|
case NarrativeDevelopmentKind.PlotEnded:
|
||||||
|
return "Sees the plot resolved";
|
||||||
|
case NarrativeDevelopmentKind.Transformation:
|
||||||
|
return string.IsNullOrEmpty(model.Change) ? "Is transformed" : "Becomes " + Humanize(model.Change);
|
||||||
|
case NarrativeDevelopmentKind.Recovery:
|
||||||
|
return "Recovers";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ContextLine(NarrativeBeatModel model, NarrativeThread thread)
|
||||||
|
{
|
||||||
|
if (model == null || thread == null) return "";
|
||||||
|
// Only show concrete pressure. A generic central question is useful scheduler state,
|
||||||
|
// but is not evidence suitable for player-facing narration.
|
||||||
|
if (!string.IsNullOrEmpty(thread.PressureEvidence)) return SagaProse.Sentence(thread.PressureEvidence);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ConsequenceLine(CharacterConsequence consequence)
|
||||||
|
{
|
||||||
|
if (consequence == null || consequence.Confidence < 0.75f) return "";
|
||||||
|
string other = consequence.Other.Name ?? "";
|
||||||
|
switch (consequence.Kind)
|
||||||
|
{
|
||||||
|
case CharacterConsequenceKind.FoundLove:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Found love" : "Found love with " + other;
|
||||||
|
case CharacterConsequenceKind.LostPartner:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Lost a partner" : "Lost " + other;
|
||||||
|
case CharacterConsequenceKind.BecameParent:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Had a child" : "Welcomed child " + other;
|
||||||
|
case CharacterConsequenceKind.WasBorn:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Was born" : "Was born to " + other;
|
||||||
|
case CharacterConsequenceKind.LostLife:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Died" : "Was slain by " + other;
|
||||||
|
case CharacterConsequenceKind.TookLife:
|
||||||
|
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
|
||||||
|
case CharacterConsequenceKind.RoseToRole:
|
||||||
|
return "Rose in standing";
|
||||||
|
case CharacterConsequenceKind.LostRole:
|
||||||
|
return "Lost their former standing";
|
||||||
|
case CharacterConsequenceKind.FoundedHome:
|
||||||
|
return "Founded a new home";
|
||||||
|
case CharacterConsequenceKind.LostHome:
|
||||||
|
return "Lost their home";
|
||||||
|
case CharacterConsequenceKind.EnteredWar:
|
||||||
|
return "Entered a war";
|
||||||
|
case CharacterConsequenceKind.SurvivedWar:
|
||||||
|
return "Survived the war";
|
||||||
|
case CharacterConsequenceKind.JoinedPlot:
|
||||||
|
return "Joined a plot";
|
||||||
|
case CharacterConsequenceKind.PlotResolved:
|
||||||
|
return "Saw a plot resolved";
|
||||||
|
case CharacterConsequenceKind.Transformed:
|
||||||
|
return "Was transformed";
|
||||||
|
case CharacterConsequenceKind.Recovered:
|
||||||
|
return "Recovered";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string TryMergeChapter(string current, CharacterConsequence next)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(current) || next == null) return "";
|
||||||
|
// Partnership chapters keep formation and loss separate because both are identity-changing.
|
||||||
|
// Lineage repeats collapse naturally in the presentation layer's family summary.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NarrativeDevelopmentKind KindForTip(InterestCandidate tip)
|
||||||
|
{
|
||||||
|
string id = (tip?.AssetId ?? "").ToLowerInvariant();
|
||||||
|
switch (id)
|
||||||
|
{
|
||||||
|
case "set_lover": return NarrativeDevelopmentKind.BondFormed;
|
||||||
|
case "clear_lover": return NarrativeDevelopmentKind.BondBroken;
|
||||||
|
case "add_child": return NarrativeDevelopmentKind.ChildBorn;
|
||||||
|
case "baby_created": return NarrativeDevelopmentKind.ChildBorn;
|
||||||
|
case "king_new":
|
||||||
|
case "become_alpha": return NarrativeDevelopmentKind.RoleGained;
|
||||||
|
default: return NarrativeDevelopmentKind.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NarrativeThread LatestThreadFor(long characterId, string developmentId)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = NarrativeStoryStore.Character(characterId);
|
||||||
|
if (state == null) return null;
|
||||||
|
for (int i = 0; i < state.OpenThreadIds.Count; i++)
|
||||||
|
{
|
||||||
|
NarrativeThread t = NarrativeStoryStore.Thread(state.OpenThreadIds[i]);
|
||||||
|
if (t != null && t.DevelopmentIds.Contains(developmentId)) return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < state.ResolvedThreadIds.Count; i++)
|
||||||
|
{
|
||||||
|
NarrativeThread t = NarrativeStoryStore.Thread(state.ResolvedThreadIds[i]);
|
||||||
|
if (t != null && t.DevelopmentIds.Contains(developmentId)) return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Humanize(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(raw)) return "";
|
||||||
|
return ActivityAssetCatalog.TitleCaseWords(raw.Replace('_', ' ').Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
375
IdleSpectator/Narrative/NarrativeStoryStore.cs
Normal file
375
IdleSpectator/Narrative/NarrativeStoryStore.cs
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>World-session narrative state. Updated from developments; independent of Saga roster churn.</summary>
|
||||||
|
public static class NarrativeStoryStore
|
||||||
|
{
|
||||||
|
private const int RecentPerCharacter = 16;
|
||||||
|
private const int ConsequencesPerCharacter = 24;
|
||||||
|
private static readonly Dictionary<string, NarrativeDevelopment> Developments =
|
||||||
|
new Dictionary<string, NarrativeDevelopment>(StringComparer.Ordinal);
|
||||||
|
private static readonly Dictionary<long, CharacterStoryState> Characters =
|
||||||
|
new Dictionary<long, CharacterStoryState>();
|
||||||
|
private static readonly Dictionary<string, NarrativeThread> Threads =
|
||||||
|
new Dictionary<string, NarrativeThread>(StringComparer.Ordinal);
|
||||||
|
private static readonly Dictionary<string, CharacterConsequence> Consequences =
|
||||||
|
new Dictionary<string, CharacterConsequence>(StringComparer.Ordinal);
|
||||||
|
private static readonly List<NarrativeThread> ThreadScratch = new List<NarrativeThread>(32);
|
||||||
|
|
||||||
|
public static int Revision { get; private set; }
|
||||||
|
public static int DevelopmentCount => Developments.Count;
|
||||||
|
public static int ThreadCount => Threads.Count;
|
||||||
|
public static int CharacterCount => Characters.Count;
|
||||||
|
|
||||||
|
public static void Clear()
|
||||||
|
{
|
||||||
|
Developments.Clear();
|
||||||
|
Characters.Clear();
|
||||||
|
Threads.Clear();
|
||||||
|
Consequences.Clear();
|
||||||
|
ThreadScratch.Clear();
|
||||||
|
Revision++;
|
||||||
|
StoryScheduler.Clear();
|
||||||
|
NarrativeTelemetry.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CharacterStoryState Character(long id)
|
||||||
|
{
|
||||||
|
Characters.TryGetValue(id, out CharacterStoryState state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NarrativeDevelopment Development(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id)) return null;
|
||||||
|
Developments.TryGetValue(id, out NarrativeDevelopment development);
|
||||||
|
return development;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NarrativeThread Thread(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id)) return null;
|
||||||
|
Threads.TryGetValue(id, out NarrativeThread thread);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CharacterConsequence Consequence(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id)) return null;
|
||||||
|
Consequences.TryGetValue(id, out CharacterConsequence consequence);
|
||||||
|
return consequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Record(NarrativeDevelopment development)
|
||||||
|
{
|
||||||
|
if (development == null || development.SubjectId == 0 || string.IsNullOrEmpty(development.Id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Developments.TryGetValue(development.Id, out NarrativeDevelopment existing))
|
||||||
|
{
|
||||||
|
existing.OccurredAt = Mathf.Max(existing.OccurredAt, development.OccurredAt);
|
||||||
|
existing.Magnitude = Mathf.Max(existing.Magnitude, development.Magnitude);
|
||||||
|
existing.Confidence = Mathf.Max(existing.Confidence, development.Confidence);
|
||||||
|
existing.IsNewStateChange = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Developments[development.Id] = development;
|
||||||
|
CharacterStoryState subject = GetOrCreateCharacter(development.SubjectId, development.Subject);
|
||||||
|
TouchDevelopment(subject, development);
|
||||||
|
if (development.OtherId != 0)
|
||||||
|
{
|
||||||
|
GetOrCreateCharacter(development.OtherId, development.Other);
|
||||||
|
}
|
||||||
|
|
||||||
|
NarrativeThreadRules.Apply(development);
|
||||||
|
Revision++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NarrativeThread OpenOrUpdateThread(
|
||||||
|
string id,
|
||||||
|
NarrativeThreadKind kind,
|
||||||
|
long protagonistId,
|
||||||
|
string anchor,
|
||||||
|
NarrativeDevelopment development,
|
||||||
|
string centralQuestion,
|
||||||
|
NarrativePhase phase,
|
||||||
|
NarrativeThreadStatus status)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id) || protagonistId == 0 || development == null) return null;
|
||||||
|
bool created = !Threads.TryGetValue(id, out NarrativeThread thread);
|
||||||
|
if (created)
|
||||||
|
{
|
||||||
|
thread = new NarrativeThread
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Kind = kind,
|
||||||
|
ProtagonistId = protagonistId,
|
||||||
|
AnchorKey = anchor ?? "",
|
||||||
|
OpenedByDevelopmentId = development.Id,
|
||||||
|
OpenedAt = development.OccurredAt,
|
||||||
|
Coherence = 1f,
|
||||||
|
Novelty = 1f
|
||||||
|
};
|
||||||
|
Threads[id] = thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread.Kind = kind;
|
||||||
|
thread.CentralQuestion = centralQuestion ?? thread.CentralQuestion;
|
||||||
|
thread.Phase = phase;
|
||||||
|
thread.Status = status;
|
||||||
|
thread.UpdatedAt = development.OccurredAt;
|
||||||
|
thread.LatestMeaningfulChangeId = development.Id;
|
||||||
|
if (!thread.DevelopmentIds.Contains(development.Id)) thread.DevelopmentIds.Add(development.Id);
|
||||||
|
thread.AddCast(protagonistId, "protagonist");
|
||||||
|
if (development.OtherId != 0) thread.AddCast(development.OtherId, CastRole(development.Kind));
|
||||||
|
thread.Momentum = Mathf.Min(12f, thread.Momentum + MomentumFor(development.Function));
|
||||||
|
thread.Urgency = Mathf.Max(thread.Urgency, UrgencyFor(development));
|
||||||
|
|
||||||
|
CharacterStoryState state = GetOrCreateCharacter(protagonistId, development.Subject);
|
||||||
|
MoveThreadReference(state, thread);
|
||||||
|
RecalculatePotential(state);
|
||||||
|
Revision++;
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AddConsequence(CharacterConsequence consequence)
|
||||||
|
{
|
||||||
|
if (consequence == null || consequence.CharacterId == 0 || string.IsNullOrEmpty(consequence.Id)) return;
|
||||||
|
if (Consequences.ContainsKey(consequence.Id)) return;
|
||||||
|
Consequences[consequence.Id] = consequence;
|
||||||
|
CharacterStoryState state = GetOrCreateCharacter(consequence.CharacterId, default);
|
||||||
|
state.ConsequenceIds.Insert(0, consequence.Id);
|
||||||
|
while (state.ConsequenceIds.Count > ConsequencesPerCharacter)
|
||||||
|
{
|
||||||
|
state.ConsequenceIds.RemoveAt(state.ConsequenceIds.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecalculatePotential(state);
|
||||||
|
Revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void CopyOpenThreads(List<NarrativeThread> into)
|
||||||
|
{
|
||||||
|
if (into == null) return;
|
||||||
|
into.Clear();
|
||||||
|
foreach (NarrativeThread thread in Threads.Values)
|
||||||
|
{
|
||||||
|
if (thread != null
|
||||||
|
&& (thread.IsOpen
|
||||||
|
|| (thread.Status == NarrativeThreadStatus.Resolved
|
||||||
|
&& Time.unscaledTime - thread.UpdatedAt < 20f)))
|
||||||
|
{
|
||||||
|
into.Add(thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies bounded story-potential challengers without scanning the world.</summary>
|
||||||
|
public static void CopyEmergingCharacters(List<CharacterStoryState> into, float minimumPotential, int max)
|
||||||
|
{
|
||||||
|
if (into == null) return;
|
||||||
|
into.Clear();
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
foreach (CharacterStoryState state in Characters.Values)
|
||||||
|
{
|
||||||
|
if (state != null && state.CharacterId != 0
|
||||||
|
&& EffectiveStoryPotential(state, now) >= minimumPotential)
|
||||||
|
{
|
||||||
|
into.Add(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
into.Sort((a, b) => EffectiveStoryPotential(b, now).CompareTo(EffectiveStoryPotential(a, now)));
|
||||||
|
if (max > 0 && into.Count > max) into.RemoveRange(max, into.Count - max);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float EffectiveStoryPotential(CharacterStoryState state, float now = -1f)
|
||||||
|
{
|
||||||
|
if (state == null) return 0f;
|
||||||
|
if (now < 0f) now = Time.unscaledTime;
|
||||||
|
float age = Mathf.Max(0f, now - state.LastMeaningfulChangeAt);
|
||||||
|
// Narrative momentum has a long half-life, but is not a permanent celebrity badge.
|
||||||
|
float freshness = Mathf.Pow(0.5f, age / 300f);
|
||||||
|
return state.StoryPotential * freshness;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NarrativeDevelopment LatestFor(long characterId, NarrativeDevelopmentKind kind, float maxAge)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = Character(characterId);
|
||||||
|
if (state == null) return null;
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
for (int i = 0; i < state.RecentDevelopmentIds.Count; i++)
|
||||||
|
{
|
||||||
|
NarrativeDevelopment development = Development(state.RecentDevelopmentIds[i]);
|
||||||
|
if (development == null || development.Kind != kind) continue;
|
||||||
|
if (maxAge > 0f && now - development.OccurredAt > maxAge) continue;
|
||||||
|
return development;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<NarrativeChapter> ChaptersFor(long characterId, int max)
|
||||||
|
{
|
||||||
|
var chapters = new List<NarrativeChapter>(Mathf.Max(0, max));
|
||||||
|
CharacterStoryState state = Character(characterId);
|
||||||
|
if (state == null || max <= 0) return chapters;
|
||||||
|
var seenThreads = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
for (int i = 0; i < state.ConsequenceIds.Count && chapters.Count < max; i++)
|
||||||
|
{
|
||||||
|
CharacterConsequence consequence = Consequence(state.ConsequenceIds[i]);
|
||||||
|
if (consequence == null || consequence.Confidence < 0.75f) continue;
|
||||||
|
string line;
|
||||||
|
if (consequence.Kind == CharacterConsequenceKind.BecameParent)
|
||||||
|
{
|
||||||
|
int children = CountConsequences(characterId, CharacterConsequenceKind.BecameParent);
|
||||||
|
line = children >= 3 ? "Raised a lineage of " + children + " children"
|
||||||
|
: NarrativeProse.ConsequenceLine(consequence);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
line = NarrativeProse.ConsequenceLine(consequence);
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(line)) continue;
|
||||||
|
if (!string.IsNullOrEmpty(consequence.ThreadId) && !seenThreads.Add(consequence.ThreadId))
|
||||||
|
{
|
||||||
|
NarrativeChapter current = FindChapter(chapters, consequence.ThreadId);
|
||||||
|
string merged = NarrativeProse.TryMergeChapter(current?.Line, consequence);
|
||||||
|
if (current != null && !string.IsNullOrEmpty(merged))
|
||||||
|
{
|
||||||
|
current.Line = merged;
|
||||||
|
current.Importance = Mathf.Max(current.Importance, consequence.Importance);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters.Add(new NarrativeChapter
|
||||||
|
{
|
||||||
|
Id = consequence.Id,
|
||||||
|
ThreadId = consequence.ThreadId,
|
||||||
|
Line = line,
|
||||||
|
DateLabel = consequence.DateLabel ?? "",
|
||||||
|
At = consequence.OccurredAt,
|
||||||
|
Importance = consequence.Importance
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters.Sort((a, b) => b.Importance.CompareTo(a.Importance));
|
||||||
|
return chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CountConsequences(long characterId, CharacterConsequenceKind kind)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = Character(characterId);
|
||||||
|
if (state == null) return 0;
|
||||||
|
int count = 0;
|
||||||
|
for (int i = 0; i < state.ConsequenceIds.Count; i++)
|
||||||
|
{
|
||||||
|
CharacterConsequence c = Consequence(state.ConsequenceIds[i]);
|
||||||
|
if (c != null && c.Kind == kind) count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterStoryState GetOrCreateCharacter(long id, LifeSagaIdentity identity)
|
||||||
|
{
|
||||||
|
if (!Characters.TryGetValue(id, out CharacterStoryState state))
|
||||||
|
{
|
||||||
|
state = new CharacterStoryState { CharacterId = id, Identity = identity };
|
||||||
|
Characters[id] = state;
|
||||||
|
}
|
||||||
|
else if (identity.Id != 0)
|
||||||
|
{
|
||||||
|
state.Identity = identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TouchDevelopment(CharacterStoryState state, NarrativeDevelopment development)
|
||||||
|
{
|
||||||
|
state.RecentDevelopmentIds.Insert(0, development.Id);
|
||||||
|
while (state.RecentDevelopmentIds.Count > RecentPerCharacter)
|
||||||
|
{
|
||||||
|
state.RecentDevelopmentIds.RemoveAt(state.RecentDevelopmentIds.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.LastMeaningfulChangeAt = development.OccurredAt;
|
||||||
|
state.StoryMomentum = Mathf.Min(12f, state.StoryMomentum + MomentumFor(development.Function));
|
||||||
|
RecalculatePotential(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveThreadReference(CharacterStoryState state, NarrativeThread thread)
|
||||||
|
{
|
||||||
|
state.OpenThreadIds.Remove(thread.Id);
|
||||||
|
state.ResolvedThreadIds.Remove(thread.Id);
|
||||||
|
if (thread.IsOpen) state.OpenThreadIds.Insert(0, thread.Id);
|
||||||
|
else state.ResolvedThreadIds.Insert(0, thread.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecalculatePotential(CharacterStoryState state)
|
||||||
|
{
|
||||||
|
if (state == null) return;
|
||||||
|
float unresolved = state.OpenThreadIds.Count * 2.5f;
|
||||||
|
float consequences = Mathf.Min(6f, state.ConsequenceIds.Count * 0.8f);
|
||||||
|
state.StoryPotential = state.StoryMomentum + unresolved + consequences;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float MomentumFor(NarrativeFunction function)
|
||||||
|
{
|
||||||
|
switch (function)
|
||||||
|
{
|
||||||
|
case NarrativeFunction.ThreadOpener: return 2f;
|
||||||
|
case NarrativeFunction.Development: return 1.5f;
|
||||||
|
case NarrativeFunction.Escalation: return 2.5f;
|
||||||
|
case NarrativeFunction.TurningPoint: return 4f;
|
||||||
|
case NarrativeFunction.Resolution: return 3.5f;
|
||||||
|
case NarrativeFunction.Consequence: return 3f;
|
||||||
|
default: return 0.25f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float UrgencyFor(NarrativeDevelopment development)
|
||||||
|
{
|
||||||
|
if (development == null) return 0f;
|
||||||
|
if (development.Function == NarrativeFunction.TurningPoint) return 4f;
|
||||||
|
if (development.Function == NarrativeFunction.Resolution) return 3f;
|
||||||
|
return Mathf.Clamp(development.Magnitude / 25f, 0f, 3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CastRole(NarrativeDevelopmentKind kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case NarrativeDevelopmentKind.BondFormed:
|
||||||
|
case NarrativeDevelopmentKind.BondBroken: return "partner";
|
||||||
|
case NarrativeDevelopmentKind.ChildBorn: return "child";
|
||||||
|
case NarrativeDevelopmentKind.FriendFormed: return "friend";
|
||||||
|
case NarrativeDevelopmentKind.Kill:
|
||||||
|
case NarrativeDevelopmentKind.RivalEncounter: return "opponent";
|
||||||
|
default: return "involved";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NarrativeChapter FindChapter(List<NarrativeChapter> chapters, string threadId)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < chapters.Count; i++)
|
||||||
|
{
|
||||||
|
if (chapters[i] != null && string.Equals(chapters[i].ThreadId, threadId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return chapters[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
IdleSpectator/Narrative/NarrativeTelemetry.cs
Normal file
127
IdleSpectator/Narrative/NarrativeTelemetry.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
public sealed class NarrativeTelemetryEntry
|
||||||
|
{
|
||||||
|
public float At;
|
||||||
|
public string ThreadId = "";
|
||||||
|
public long ProtagonistId;
|
||||||
|
public string ProposedKey = "";
|
||||||
|
public string ActualKey = "";
|
||||||
|
public string Reason = "";
|
||||||
|
public NarrativeInterruptClass InterruptClass;
|
||||||
|
public bool ActualRelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bounded in-memory trace for shadow-vs-actual story scheduling.</summary>
|
||||||
|
public static class NarrativeTelemetry
|
||||||
|
{
|
||||||
|
private const int Cap = 256;
|
||||||
|
private static readonly List<NarrativeTelemetryEntry> Entries = new List<NarrativeTelemetryEntry>(Cap);
|
||||||
|
public static int Count => Entries.Count;
|
||||||
|
public static int RelatedActualCount { get; private set; }
|
||||||
|
public static int ComparedCount { get; private set; }
|
||||||
|
public static int RelatedProposalCount { get; private set; }
|
||||||
|
public static int CriticalProposalCount { get; private set; }
|
||||||
|
public static int AlignedSampleCount { get; private set; }
|
||||||
|
public static int PermittedInterruptSampleCount { get; private set; }
|
||||||
|
public static int PermittedContinuitySampleCount { get; private set; }
|
||||||
|
public static int UnalignedSampleCount { get; private set; }
|
||||||
|
public static int UnrelatedActualCount => ComparedCount - RelatedActualCount;
|
||||||
|
|
||||||
|
public static void Clear()
|
||||||
|
{
|
||||||
|
Entries.Clear();
|
||||||
|
RelatedActualCount = 0;
|
||||||
|
ComparedCount = 0;
|
||||||
|
RelatedProposalCount = 0;
|
||||||
|
CriticalProposalCount = 0;
|
||||||
|
AlignedSampleCount = 0;
|
||||||
|
PermittedInterruptSampleCount = 0;
|
||||||
|
PermittedContinuitySampleCount = 0;
|
||||||
|
UnalignedSampleCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void NoteProposal(EpisodePlan episode, EpisodeShotProposal proposal, float now)
|
||||||
|
{
|
||||||
|
if (episode == null || proposal?.Candidate == null) return;
|
||||||
|
if (proposal.InterruptClass == NarrativeInterruptClass.RelatedEscalation) RelatedProposalCount++;
|
||||||
|
if (proposal.InterruptClass == NarrativeInterruptClass.WorldCritical) CriticalProposalCount++;
|
||||||
|
Entries.Add(new NarrativeTelemetryEntry
|
||||||
|
{
|
||||||
|
At = now,
|
||||||
|
ThreadId = episode.ThreadId,
|
||||||
|
ProtagonistId = episode.ProtagonistId,
|
||||||
|
ProposedKey = proposal.Candidate.Key ?? "",
|
||||||
|
Reason = proposal.Reason ?? "",
|
||||||
|
InterruptClass = proposal.InterruptClass
|
||||||
|
});
|
||||||
|
Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void NoteActual(EpisodePlan episode, NarrativeThread thread, InterestCandidate actual)
|
||||||
|
{
|
||||||
|
if (episode == null || actual == null) return;
|
||||||
|
bool related = InterruptPolicy.IsRelated(actual, episode, thread);
|
||||||
|
ComparedCount++;
|
||||||
|
if (related) RelatedActualCount++;
|
||||||
|
NarrativeTelemetryEntry last = Entries.Count > 0 ? Entries[Entries.Count - 1] : null;
|
||||||
|
if (last != null && last.ThreadId == episode.ThreadId && string.IsNullOrEmpty(last.ActualKey))
|
||||||
|
{
|
||||||
|
last.ActualKey = actual.Key ?? "";
|
||||||
|
last.ActualRelated = related;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void NoteCurrentAlignment(
|
||||||
|
EpisodePlan episode,
|
||||||
|
NarrativeThread thread,
|
||||||
|
InterestCandidate current)
|
||||||
|
{
|
||||||
|
if (episode == null || current == null) return;
|
||||||
|
if (InterruptPolicy.IsRelated(current, episode, thread))
|
||||||
|
{
|
||||||
|
AlignedSampleCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NarrativeInterruptClass kind = InterruptPolicy.Classify(current, episode, thread);
|
||||||
|
if (InterruptPolicy.MayInterrupt(kind))
|
||||||
|
{
|
||||||
|
AlignedSampleCount++;
|
||||||
|
PermittedInterruptSampleCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live sticky scenes are deliberately allowed to finish their immediate outcome
|
||||||
|
// before an episode reclaims the camera; report them separately from true drift.
|
||||||
|
if (current.Completion == InterestCompletionKind.CombatActive
|
||||||
|
|| current.Completion == InterestCompletionKind.WarFront
|
||||||
|
|| current.Completion == InterestCompletionKind.PlotActive
|
||||||
|
|| current.Completion == InterestCompletionKind.StatusOutbreak)
|
||||||
|
{
|
||||||
|
AlignedSampleCount++;
|
||||||
|
PermittedContinuitySampleCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UnalignedSampleCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Summary => "compared=" + ComparedCount
|
||||||
|
+ " actualRelated=" + RelatedActualCount
|
||||||
|
+ " actualUnrelated=" + UnrelatedActualCount
|
||||||
|
+ " proposals=" + Count
|
||||||
|
+ " relatedProposals=" + RelatedProposalCount
|
||||||
|
+ " criticalProposals=" + CriticalProposalCount
|
||||||
|
+ " alignedSamples=" + AlignedSampleCount
|
||||||
|
+ " permittedInterruptSamples=" + PermittedInterruptSampleCount
|
||||||
|
+ " permittedContinuitySamples=" + PermittedContinuitySampleCount
|
||||||
|
+ " unalignedSamples=" + UnalignedSampleCount;
|
||||||
|
|
||||||
|
private static void Trim()
|
||||||
|
{
|
||||||
|
while (Entries.Count > Cap) Entries.RemoveAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
IdleSpectator/Narrative/NarrativeThreadRules.cs
Normal file
265
IdleSpectator/Narrative/NarrativeThreadRules.cs
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>Maps semantic developments onto durable character-centered threads.</summary>
|
||||||
|
public static class NarrativeThreadRules
|
||||||
|
{
|
||||||
|
public static void Apply(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
if (d == null || d.SubjectId == 0) return;
|
||||||
|
switch (d.Kind)
|
||||||
|
{
|
||||||
|
case NarrativeDevelopmentKind.BondFormed:
|
||||||
|
ApplyBondFormed(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.BondBroken:
|
||||||
|
ApplyBondBroken(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.ChildBorn:
|
||||||
|
ApplyChildBorn(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.FriendFormed:
|
||||||
|
ApplyFriend(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.Death:
|
||||||
|
ApplyDeath(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.Kill:
|
||||||
|
case NarrativeDevelopmentKind.RivalEncounter:
|
||||||
|
ApplyConflict(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.RoleGained:
|
||||||
|
case NarrativeDevelopmentKind.RoleLost:
|
||||||
|
ApplyPower(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.HomeFounded:
|
||||||
|
case NarrativeDevelopmentKind.HomeGained:
|
||||||
|
case NarrativeDevelopmentKind.HomeLost:
|
||||||
|
ApplyHome(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.WarJoined:
|
||||||
|
case NarrativeDevelopmentKind.WarEnded:
|
||||||
|
ApplyWar(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.PlotJoined:
|
||||||
|
case NarrativeDevelopmentKind.PlotEnded:
|
||||||
|
ApplyPlot(d);
|
||||||
|
break;
|
||||||
|
case NarrativeDevelopmentKind.Transformation:
|
||||||
|
case NarrativeDevelopmentKind.Recovery:
|
||||||
|
ApplySurvival(d);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyBondFormed(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = PairAnchor("bond", d.SubjectId, d.OtherId);
|
||||||
|
string id = "thread:partnership:" + anchor + ":" + d.SubjectId;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
id, NarrativeThreadKind.Partnership, d.SubjectId, anchor, d,
|
||||||
|
"How will this partnership shape their lives?", NarrativePhase.Outcome,
|
||||||
|
NarrativeThreadStatus.Active);
|
||||||
|
t?.AddCast(d.OtherId, "partner");
|
||||||
|
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.FoundLove, "partner", d.OtherId, 72f);
|
||||||
|
if (d.OtherId != 0)
|
||||||
|
{
|
||||||
|
string mirrorId = "thread:partnership:" + anchor + ":" + d.OtherId;
|
||||||
|
NarrativeThread mirror = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
mirrorId, NarrativeThreadKind.Partnership, d.OtherId, anchor, d,
|
||||||
|
"How will this partnership shape their lives?", NarrativePhase.Outcome,
|
||||||
|
NarrativeThreadStatus.Active);
|
||||||
|
mirror?.AddCast(d.SubjectId, "partner");
|
||||||
|
AddConsequence(d, mirror, d.OtherId, CharacterConsequenceKind.FoundLove, "partner", d.SubjectId, 72f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyBondBroken(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = PairAnchor("bond", d.SubjectId, d.OtherId);
|
||||||
|
string id = "thread:partnership:" + anchor + ":" + d.SubjectId;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
id, NarrativeThreadKind.SeparationGrief, d.SubjectId, anchor, d,
|
||||||
|
"What follows the loss of this partnership?", NarrativePhase.Consequence,
|
||||||
|
NarrativeThreadStatus.Resolved);
|
||||||
|
if (t != null) t.Resolution = "partnership ended";
|
||||||
|
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostPartner, "survivor", d.OtherId, 88f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyChildBorn(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = !string.IsNullOrEmpty(d.FamilyKey) ? d.FamilyKey : d.SubjectId.ToString();
|
||||||
|
string id = "thread:lineage:" + anchor + ":" + d.SubjectId;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
id, NarrativeThreadKind.Lineage, d.SubjectId, anchor, d,
|
||||||
|
"How will this growing lineage endure?", NarrativePhase.Outcome,
|
||||||
|
NarrativeThreadStatus.Active);
|
||||||
|
t?.AddCast(d.OtherId, "child");
|
||||||
|
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.BecameParent, "parent", d.OtherId, 76f);
|
||||||
|
|
||||||
|
if (d.OtherId != 0)
|
||||||
|
{
|
||||||
|
string childId = "thread:lineage:" + anchor + ":" + d.OtherId;
|
||||||
|
NarrativeThread childThread = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
childId, NarrativeThreadKind.Lineage, d.OtherId, anchor, d,
|
||||||
|
"What life will begin from this lineage?", NarrativePhase.Setup,
|
||||||
|
NarrativeThreadStatus.Emerging);
|
||||||
|
childThread?.AddCast(d.SubjectId, "parent");
|
||||||
|
AddConsequence(d, childThread, d.OtherId, CharacterConsequenceKind.WasBorn, "child", d.SubjectId, 65f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyFriend(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = PairAnchor("friend", d.SubjectId, d.OtherId);
|
||||||
|
NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
"thread:partnership:" + anchor + ":" + d.SubjectId,
|
||||||
|
NarrativeThreadKind.Partnership, d.SubjectId, anchor, d,
|
||||||
|
"Where will this friendship lead?", NarrativePhase.Setup, NarrativeThreadStatus.Emerging);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyDeath(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string id = "thread:life-end:" + d.SubjectId;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
id, NarrativeThreadKind.SeparationGrief, d.SubjectId, id, d,
|
||||||
|
"Their life has ended", NarrativePhase.Outcome, NarrativeThreadStatus.Resolved);
|
||||||
|
if (t != null) t.Resolution = "died";
|
||||||
|
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.LostLife, "deceased", d.OtherId, 100f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyConflict(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = PairAnchor("conflict", d.SubjectId, d.OtherId);
|
||||||
|
NarrativeThreadKind kind = d.Kind == NarrativeDevelopmentKind.RivalEncounter
|
||||||
|
? NarrativeThreadKind.Rivalry : NarrativeThreadKind.PersonalCombat;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
"thread:conflict:" + anchor + ":" + d.SubjectId, kind, d.SubjectId, anchor, d,
|
||||||
|
"Will this conflict end their repeated struggle?", NarrativePhase.TurningPoint,
|
||||||
|
NarrativeThreadStatus.Active);
|
||||||
|
if (d.Kind == NarrativeDevelopmentKind.Kill)
|
||||||
|
{
|
||||||
|
AddConsequence(d, t, d.SubjectId, CharacterConsequenceKind.TookLife, "killer", d.OtherId, 82f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyPower(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = "role:" + d.SubjectId + ":" + d.NewState;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
"thread:power:" + anchor, NarrativeThreadKind.RiseToRule, d.SubjectId, anchor, d,
|
||||||
|
"What will they do with their new standing?", NarrativePhase.Outcome,
|
||||||
|
d.Kind == NarrativeDevelopmentKind.RoleLost ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
|
||||||
|
AddConsequence(d, t, d.SubjectId,
|
||||||
|
d.Kind == NarrativeDevelopmentKind.RoleLost ? CharacterConsequenceKind.LostRole : CharacterConsequenceKind.RoseToRole,
|
||||||
|
"office holder", 0, 78f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyHome(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = FirstNonEmpty(d.CityKey, d.KingdomKey, d.CorrelationKey, d.SubjectId.ToString());
|
||||||
|
bool lost = d.Kind == NarrativeDevelopmentKind.HomeLost;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
"thread:home:" + anchor + ":" + d.SubjectId,
|
||||||
|
lost ? NarrativeThreadKind.HomeLoss : NarrativeThreadKind.Founding,
|
||||||
|
d.SubjectId, anchor, d,
|
||||||
|
lost ? "Where will they go after losing their home?" : "Will what they founded endure?",
|
||||||
|
lost ? NarrativePhase.TurningPoint : NarrativePhase.Outcome,
|
||||||
|
NarrativeThreadStatus.Active);
|
||||||
|
AddConsequence(d, t, d.SubjectId,
|
||||||
|
lost ? CharacterConsequenceKind.LostHome : CharacterConsequenceKind.FoundedHome,
|
||||||
|
lost ? "displaced" : "founder", 0, lost ? 90f : 82f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyWar(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = FirstNonEmpty(d.WarKey, d.CorrelationKey, "unknown");
|
||||||
|
bool ended = d.Kind == NarrativeDevelopmentKind.WarEnded;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
"thread:war:" + anchor + ":" + d.SubjectId, NarrativeThreadKind.WarInvolvement,
|
||||||
|
d.SubjectId, anchor, d,
|
||||||
|
ended ? "What did the war change for them?" : "Will they survive this war?",
|
||||||
|
ended ? NarrativePhase.Outcome : NarrativePhase.Pressure,
|
||||||
|
ended ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
|
||||||
|
AddConsequence(d, t, d.SubjectId,
|
||||||
|
ended ? CharacterConsequenceKind.SurvivedWar : CharacterConsequenceKind.EnteredWar,
|
||||||
|
"participant", d.OtherId, ended ? 86f : 62f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyPlot(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
string anchor = FirstNonEmpty(d.PlotKey, d.CorrelationKey, "unknown");
|
||||||
|
bool ended = d.Kind == NarrativeDevelopmentKind.PlotEnded;
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
"thread:plot:" + anchor + ":" + d.SubjectId, NarrativeThreadKind.PlotBetrayal,
|
||||||
|
d.SubjectId, anchor, d,
|
||||||
|
ended ? "What did the plot change?" : "Will their plot succeed?",
|
||||||
|
ended ? NarrativePhase.Outcome : NarrativePhase.Escalation,
|
||||||
|
ended ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
|
||||||
|
AddConsequence(d, t, d.SubjectId,
|
||||||
|
ended ? CharacterConsequenceKind.PlotResolved : CharacterConsequenceKind.JoinedPlot,
|
||||||
|
"plotter", d.OtherId, ended ? 78f : 58f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplySurvival(NarrativeDevelopment d)
|
||||||
|
{
|
||||||
|
bool recovered = d.Kind == NarrativeDevelopmentKind.Recovery;
|
||||||
|
string anchor = FirstNonEmpty(d.CorrelationKey, "change:" + d.SubjectId + ":" + d.NewState);
|
||||||
|
NarrativeThread t = NarrativeStoryStore.OpenOrUpdateThread(
|
||||||
|
"thread:survival:" + anchor, recovered ? NarrativeThreadKind.Recovery : NarrativeThreadKind.Transformation,
|
||||||
|
d.SubjectId, anchor, d,
|
||||||
|
recovered ? "How will recovery change their path?" : "Will they endure this transformation?",
|
||||||
|
recovered ? NarrativePhase.Outcome : NarrativePhase.TurningPoint,
|
||||||
|
recovered ? NarrativeThreadStatus.Resolved : NarrativeThreadStatus.Active);
|
||||||
|
AddConsequence(d, t, d.SubjectId,
|
||||||
|
recovered ? CharacterConsequenceKind.Recovered : CharacterConsequenceKind.Transformed,
|
||||||
|
"subject", d.OtherId, 72f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddConsequence(
|
||||||
|
NarrativeDevelopment d,
|
||||||
|
NarrativeThread thread,
|
||||||
|
long characterId,
|
||||||
|
CharacterConsequenceKind kind,
|
||||||
|
string role,
|
||||||
|
long otherId,
|
||||||
|
float importance)
|
||||||
|
{
|
||||||
|
LifeSagaIdentity other = otherId == d.OtherId ? d.Other : LifeSagaMemory.SnapshotId(otherId);
|
||||||
|
NarrativeStoryStore.AddConsequence(new CharacterConsequence
|
||||||
|
{
|
||||||
|
Id = "consequence:" + d.Id + ":" + characterId + ":" + kind,
|
||||||
|
CharacterId = characterId,
|
||||||
|
ThreadId = thread?.Id ?? "",
|
||||||
|
DevelopmentId = d.Id,
|
||||||
|
Kind = kind,
|
||||||
|
Role = role ?? "",
|
||||||
|
OtherId = otherId,
|
||||||
|
Other = other,
|
||||||
|
Context = FirstNonEmpty(d.WarKey, d.PlotKey, d.CityKey, d.KingdomKey),
|
||||||
|
Outcome = d.Outcome ?? "",
|
||||||
|
DateLabel = d.DateLabel ?? "",
|
||||||
|
OccurredAt = d.OccurredAt,
|
||||||
|
Importance = importance,
|
||||||
|
Confidence = d.Confidence
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PairAnchor(string prefix, long a, long b)
|
||||||
|
{
|
||||||
|
if (b == 0) return prefix + ":" + a;
|
||||||
|
return a <= b ? prefix + ":" + a + ":" + b : prefix + ":" + b + ":" + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstNonEmpty(params string[] values)
|
||||||
|
{
|
||||||
|
if (values == null) return "";
|
||||||
|
for (int i = 0; i < values.Length; i++)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(values[i])) return values[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
357
IdleSpectator/Narrative/StoryScheduler.cs
Normal file
357
IdleSpectator/Narrative/StoryScheduler.cs
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace IdleSpectator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Story-first scheduler and sole camera-story owner.
|
||||||
|
/// </summary>
|
||||||
|
public static class StoryScheduler
|
||||||
|
{
|
||||||
|
private const float EpisodeNoProgressSeconds = 14f;
|
||||||
|
private const float PayoffSettleSeconds = 8f;
|
||||||
|
private const float EpisodeMaxSeconds = 75f;
|
||||||
|
public const int EpisodeCombatShotBudget = 3;
|
||||||
|
private static readonly List<NarrativeThread> OpenThreads = new List<NarrativeThread>(32);
|
||||||
|
private static readonly List<InterestCandidate> Pending = new List<InterestCandidate>(96);
|
||||||
|
private static float _nextTickAt;
|
||||||
|
private static string _lastProposal = "";
|
||||||
|
|
||||||
|
public static EpisodePlan ActiveEpisode { get; private set; }
|
||||||
|
public static EpisodeShotProposal CurrentProposal { get; private set; }
|
||||||
|
public static string LastProposal => _lastProposal ?? "";
|
||||||
|
|
||||||
|
public static void Clear()
|
||||||
|
{
|
||||||
|
ActiveEpisode = null;
|
||||||
|
CurrentProposal = null;
|
||||||
|
OpenThreads.Clear();
|
||||||
|
Pending.Clear();
|
||||||
|
_nextTickAt = 0f;
|
||||||
|
_lastProposal = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Tick(float now)
|
||||||
|
{
|
||||||
|
if (now < _nextTickAt) return;
|
||||||
|
_nextTickAt = now + 0.5f;
|
||||||
|
NarrativeStoryStore.CopyOpenThreads(OpenThreads);
|
||||||
|
NarrativeThread best = null;
|
||||||
|
float bestScore = float.MinValue;
|
||||||
|
for (int i = 0; i < OpenThreads.Count; i++)
|
||||||
|
{
|
||||||
|
NarrativeThread thread = OpenThreads[i];
|
||||||
|
float score = Score(thread, now);
|
||||||
|
if (score > bestScore)
|
||||||
|
{
|
||||||
|
best = thread;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NarrativeThread activeThread = ActiveEpisode != null
|
||||||
|
? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId)
|
||||||
|
: null;
|
||||||
|
if (ActiveEpisode != null && !EpisodeStillValid(ActiveEpisode, activeThread, now))
|
||||||
|
{
|
||||||
|
ActiveEpisode = null;
|
||||||
|
CurrentProposal = null;
|
||||||
|
activeThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best == null && activeThread == null)
|
||||||
|
{
|
||||||
|
ActiveEpisode = null;
|
||||||
|
CurrentProposal = null;
|
||||||
|
_lastProposal = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NarrativeThread selectedThread = RetainOrChoose(activeThread, best, bestScore, now);
|
||||||
|
if (selectedThread == null)
|
||||||
|
{
|
||||||
|
ActiveEpisode = null;
|
||||||
|
CurrentProposal = null;
|
||||||
|
_lastProposal = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ActiveEpisode == null || ActiveEpisode.ThreadId != selectedThread.Id)
|
||||||
|
{
|
||||||
|
ActiveEpisode = BuildEpisode(selectedThread, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (selectedThread.LatestMeaningfulChangeId != ActiveEpisode.EntryDevelopmentId)
|
||||||
|
{
|
||||||
|
ActiveEpisode.EntryDevelopmentId = selectedThread.LatestMeaningfulChangeId;
|
||||||
|
ActiveEpisode.LastProgressAt = now;
|
||||||
|
ActiveEpisode.CombatShotCount = 0;
|
||||||
|
ActiveEpisode.GoalMet = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
InterestRegistry.CopyPending(Pending);
|
||||||
|
CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread);
|
||||||
|
if (CurrentProposal != null)
|
||||||
|
{
|
||||||
|
NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now);
|
||||||
|
}
|
||||||
|
if (CurrentProposal?.Candidate != null)
|
||||||
|
{
|
||||||
|
NarrativeTelemetry.NoteCurrentAlignment(
|
||||||
|
ActiveEpisode, selectedThread, InterestDirector.CurrentCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastProposal = selectedThread.Id + " protagonist=" + selectedThread.ProtagonistId
|
||||||
|
+ " phase=" + selectedThread.Phase + " score=" + Score(selectedThread, now).ToString("0.0")
|
||||||
|
+ " shot=" + (CurrentProposal?.Candidate?.Key ?? "none")
|
||||||
|
+ " reason=" + (CurrentProposal?.Reason ?? "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void NoteActualSelection(InterestCandidate actual)
|
||||||
|
{
|
||||||
|
if (ActiveEpisode == null || actual == null) return;
|
||||||
|
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
|
||||||
|
NarrativeTelemetry.NoteActual(ActiveEpisode, thread, actual);
|
||||||
|
NarrativeInterruptClass kind = InterruptPolicy.Classify(actual, ActiveEpisode, thread);
|
||||||
|
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
||||||
|
{
|
||||||
|
if (IsCombatShot(actual))
|
||||||
|
{
|
||||||
|
ActiveEpisode.CombatShotCount++;
|
||||||
|
}
|
||||||
|
ActiveEpisode.LastProgressAt = UnityEngine.Time.unscaledTime;
|
||||||
|
ActiveEpisode.LastShotKey = actual.Key ?? "";
|
||||||
|
ActiveEpisode.InterruptedAt = -1f;
|
||||||
|
ActiveEpisode.GoalMet = thread != null
|
||||||
|
&& (thread.Phase == NarrativePhase.Outcome
|
||||||
|
|| thread.Phase == NarrativePhase.Consequence);
|
||||||
|
}
|
||||||
|
else if (kind == NarrativeInterruptClass.WorldCritical || kind == NarrativeInterruptClass.StoryPayoff)
|
||||||
|
{
|
||||||
|
ActiveEpisode.InterruptedAt = UnityEngine.Time.unscaledTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A narrative episode may establish and develop a fight without following every new
|
||||||
|
/// opponent. A decisive world-critical combat signal still bypasses the budget.
|
||||||
|
/// </summary>
|
||||||
|
public static bool MayUseCombatShot(EpisodePlan episode, InterestCandidate candidate)
|
||||||
|
{
|
||||||
|
if (episode == null || candidate == null || !IsCombatShot(candidate))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.EventStrength >= 95f)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return episode.CombatShotCount < EpisodeCombatShotBudget;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MayUseCombatShot(InterestCandidate candidate) =>
|
||||||
|
MayUseCombatShot(ActiveEpisode, candidate);
|
||||||
|
|
||||||
|
/// <summary>Camera-owner selection from candidates already admitted by director safety gates.</summary>
|
||||||
|
public static InterestCandidate SelectForCamera(
|
||||||
|
IList<InterestCandidate> eligible,
|
||||||
|
InterestCandidate current,
|
||||||
|
float now)
|
||||||
|
{
|
||||||
|
if (ActiveEpisode == null) return null;
|
||||||
|
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
|
||||||
|
if (!EpisodeStillValid(ActiveEpisode, thread, now))
|
||||||
|
{
|
||||||
|
ActiveEpisode = null;
|
||||||
|
CurrentProposal = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
EpisodeShotProposal proposal = EpisodeShotSelector.Pick(eligible, ActiveEpisode, thread);
|
||||||
|
CurrentProposal = proposal;
|
||||||
|
if (proposal?.Candidate == null || proposal.Candidate == current) return null;
|
||||||
|
return proposal.Candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ShouldHoldForEpisode(float now)
|
||||||
|
{
|
||||||
|
if (ActiveEpisode == null
|
||||||
|
|| CurrentProposal?.Candidate == null) return false;
|
||||||
|
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
|
||||||
|
return EpisodeStillValid(ActiveEpisode, thread, now)
|
||||||
|
&& now - ActiveEpisode.LastProgressAt < EpisodeNoProgressSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HarnessProbePolicy(out string detail)
|
||||||
|
{
|
||||||
|
var episode = new EpisodePlan { ThreadId = "probe:a", ProtagonistId = 101 };
|
||||||
|
episode.EligibleCastIds.Add(102);
|
||||||
|
var thread = new NarrativeThread
|
||||||
|
{
|
||||||
|
Id = "probe:a",
|
||||||
|
ProtagonistId = 101,
|
||||||
|
AnchorKey = "family-a",
|
||||||
|
Status = NarrativeThreadStatus.Active
|
||||||
|
};
|
||||||
|
var candidates = new List<InterestCandidate>
|
||||||
|
{
|
||||||
|
new InterestCandidate { Key = "unrelated", Label = "Unrelated event", SubjectId = 201, EventStrength = 90f },
|
||||||
|
new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "related", Label = "Family develops", AssetId = "add_child",
|
||||||
|
SubjectId = 102, EventStrength = 60f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
EpisodeShotProposal relatedPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false);
|
||||||
|
var episodeB = new EpisodePlan { ThreadId = "probe:b", ProtagonistId = 201 };
|
||||||
|
var threadB = new NarrativeThread
|
||||||
|
{
|
||||||
|
Id = "probe:b",
|
||||||
|
ProtagonistId = 201,
|
||||||
|
AnchorKey = "family-b",
|
||||||
|
Status = NarrativeThreadStatus.Active
|
||||||
|
};
|
||||||
|
EpisodeShotProposal concurrentPick = EpisodeShotSelector.Pick(
|
||||||
|
candidates, episodeB, threadB, requirePresentable: false);
|
||||||
|
candidates.Add(new InterestCandidate
|
||||||
|
{
|
||||||
|
Key = "critical", Label = "Kingdom shattered", SubjectId = 301,
|
||||||
|
Category = "Disaster", EventStrength = 100f
|
||||||
|
});
|
||||||
|
EpisodeShotProposal criticalPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false);
|
||||||
|
NarrativeInterruptClass ordinary = InterruptPolicy.Classify(candidates[0], episode, thread);
|
||||||
|
var saturated = new EpisodePlan
|
||||||
|
{
|
||||||
|
ThreadId = "probe:combat",
|
||||||
|
ProtagonistId = 101,
|
||||||
|
CombatShotCount = EpisodeCombatShotBudget
|
||||||
|
};
|
||||||
|
var routineCombat = new InterestCandidate
|
||||||
|
{
|
||||||
|
SubjectId = 101,
|
||||||
|
Completion = InterestCompletionKind.CombatActive,
|
||||||
|
EventStrength = 80f
|
||||||
|
};
|
||||||
|
var criticalCombat = new InterestCandidate
|
||||||
|
{
|
||||||
|
SubjectId = 101,
|
||||||
|
Completion = InterestCompletionKind.CombatActive,
|
||||||
|
EventStrength = 100f
|
||||||
|
};
|
||||||
|
bool routineBlocked = !MayUseCombatShot(saturated, routineCombat);
|
||||||
|
bool criticalAllowed = MayUseCombatShot(saturated, criticalCombat);
|
||||||
|
bool nonCombatAllowed = MayUseCombatShot(saturated, candidates[1]);
|
||||||
|
bool pass = relatedPick?.Candidate?.Key == "related"
|
||||||
|
&& concurrentPick?.Candidate?.Key == "unrelated"
|
||||||
|
&& criticalPick?.Candidate?.Key == "critical"
|
||||||
|
&& ordinary == NarrativeInterruptClass.OrdinaryInteresting
|
||||||
|
&& !InterruptPolicy.MayInterrupt(ordinary)
|
||||||
|
&& routineBlocked && criticalAllowed && nonCombatAllowed;
|
||||||
|
detail = "related=" + (relatedPick?.Candidate?.Key ?? "none")
|
||||||
|
+ " concurrent=" + (concurrentPick?.Candidate?.Key ?? "none")
|
||||||
|
+ " critical=" + (criticalPick?.Candidate?.Key ?? "none")
|
||||||
|
+ " ordinary=" + ordinary
|
||||||
|
+ " combatBudget=" + routineBlocked + "/" + criticalAllowed + "/" + nonCombatAllowed
|
||||||
|
+ " pass=" + pass;
|
||||||
|
return pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float StoryPotential(long characterId)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = NarrativeStoryStore.Character(characterId);
|
||||||
|
return NarrativeStoryStore.EffectiveStoryPotential(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Score(NarrativeThread thread, float now)
|
||||||
|
{
|
||||||
|
if (thread == null
|
||||||
|
|| (!thread.IsOpen
|
||||||
|
&& !(thread.Status == NarrativeThreadStatus.Resolved && now - thread.UpdatedAt < 20f)))
|
||||||
|
{
|
||||||
|
return float.MinValue;
|
||||||
|
}
|
||||||
|
CharacterStoryState state = NarrativeStoryStore.Character(thread.ProtagonistId);
|
||||||
|
float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now);
|
||||||
|
float freshness = Mathf.Max(0f, 8f - (now - thread.UpdatedAt) / 15f);
|
||||||
|
float payoff = thread.Phase == NarrativePhase.TurningPoint ? 8f
|
||||||
|
: thread.Phase == NarrativePhase.Outcome ? 7f
|
||||||
|
: thread.Phase == NarrativePhase.Consequence ? 6f : 0f;
|
||||||
|
float prefer = LifeSagaRoster.IsPrefer(thread.ProtagonistId) ? 8f : 0f;
|
||||||
|
float mc = LifeSagaRoster.IsMc(thread.ProtagonistId) ? 4f : 0f;
|
||||||
|
float repeat = ActiveEpisode != null && ActiveEpisode.ThreadId == thread.Id ? 2f : 0f;
|
||||||
|
return potential + thread.Momentum + thread.Urgency + thread.CoverageDebt
|
||||||
|
+ freshness + payoff + prefer + mc + repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NarrativeThread RetainOrChoose(
|
||||||
|
NarrativeThread active,
|
||||||
|
NarrativeThread best,
|
||||||
|
float bestScore,
|
||||||
|
float now)
|
||||||
|
{
|
||||||
|
if (active == null) return best;
|
||||||
|
if (best == null || best.Id == active.Id) return active;
|
||||||
|
float activeScore = Score(active, now);
|
||||||
|
bool settled = ActiveEpisode != null && ActiveEpisode.GoalMet
|
||||||
|
&& now - ActiveEpisode.LastProgressAt >= PayoffSettleSeconds;
|
||||||
|
bool stale = ActiveEpisode != null
|
||||||
|
&& now - ActiveEpisode.LastProgressAt >= EpisodeNoProgressSeconds;
|
||||||
|
return settled || stale || bestScore >= activeScore + 20f ? best : active;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EpisodeStillValid(EpisodePlan episode, NarrativeThread thread, float now)
|
||||||
|
{
|
||||||
|
bool recentResolution = thread != null
|
||||||
|
&& thread.Status == NarrativeThreadStatus.Resolved
|
||||||
|
&& now - thread.UpdatedAt < 20f;
|
||||||
|
if (episode == null || thread == null || (!thread.IsOpen && !recentResolution)) return false;
|
||||||
|
if (now - episode.StartedAt >= EpisodeMaxSeconds) return false;
|
||||||
|
if (episode.GoalMet && now - episode.LastProgressAt >= PayoffSettleSeconds) return false;
|
||||||
|
return now - episode.LastProgressAt < EpisodeNoProgressSeconds
|
||||||
|
|| thread.LatestMeaningfulChangeId != episode.EntryDevelopmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EpisodePlan BuildEpisode(NarrativeThread thread, float now)
|
||||||
|
{
|
||||||
|
var episode = new EpisodePlan
|
||||||
|
{
|
||||||
|
ThreadId = thread.Id,
|
||||||
|
ProtagonistId = thread.ProtagonistId,
|
||||||
|
EntryDevelopmentId = thread.LatestMeaningfulChangeId,
|
||||||
|
Purpose = PurposeFor(thread),
|
||||||
|
InformationGoal = thread.CentralQuestion ?? "",
|
||||||
|
StartedAt = now,
|
||||||
|
LastProgressAt = now
|
||||||
|
};
|
||||||
|
for (int i = 0; i < thread.Cast.Count; i++)
|
||||||
|
{
|
||||||
|
NarrativeCastRole cast = thread.Cast[i];
|
||||||
|
if (cast != null && cast.CharacterId != 0) episode.EligibleCastIds.Add(cast.CharacterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCombatShot(InterestCandidate candidate)
|
||||||
|
{
|
||||||
|
return candidate != null
|
||||||
|
&& (candidate.Completion == InterestCompletionKind.CombatActive
|
||||||
|
|| string.Equals(candidate.AssetId, "live_combat", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(candidate.AssetId, "live_battle", System.StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EpisodePurpose PurposeFor(NarrativeThread thread)
|
||||||
|
{
|
||||||
|
if (thread == null) return EpisodePurpose.Develop;
|
||||||
|
switch (thread.Phase)
|
||||||
|
{
|
||||||
|
case NarrativePhase.Setup: return EpisodePurpose.Establish;
|
||||||
|
case NarrativePhase.TurningPoint:
|
||||||
|
case NarrativePhase.Outcome: return EpisodePurpose.Payoff;
|
||||||
|
case NarrativePhase.Consequence: return EpisodePurpose.Consequence;
|
||||||
|
default: return EpisodePurpose.Develop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,24 @@ public static class CaptionComposer
|
||||||
model.ReasonRelatedId = reasonRelatedId;
|
model.ReasonRelatedId = reasonRelatedId;
|
||||||
|
|
||||||
string beat = presentableBeat ?? "";
|
string beat = presentableBeat ?? "";
|
||||||
|
string narrativeContext = "";
|
||||||
|
bool structuredNarrativeBeat = false;
|
||||||
|
if (!statusBannerOwnsBeat
|
||||||
|
&& NarrativeProse.TryBuildBeat(
|
||||||
|
focus,
|
||||||
|
ownedTip,
|
||||||
|
out NarrativeBeatModel narrativeModel,
|
||||||
|
out string narrativeBeat,
|
||||||
|
out narrativeContext))
|
||||||
|
{
|
||||||
|
beat = narrativeBeat;
|
||||||
|
structuredNarrativeBeat = true;
|
||||||
|
if (narrativeModel.OtherId != 0)
|
||||||
|
{
|
||||||
|
model.ReasonRelatedId = narrativeModel.OtherId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (statusBannerOwnsBeat)
|
if (statusBannerOwnsBeat)
|
||||||
{
|
{
|
||||||
// Banner owns orange row; composer still fills Identity + Context.
|
// Banner owns orange row; composer still fills Identity + Context.
|
||||||
|
|
@ -87,7 +105,9 @@ public static class CaptionComposer
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
model.BeatLine = EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry);
|
model.BeatLine = structuredNarrativeBeat
|
||||||
|
? beat
|
||||||
|
: EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry);
|
||||||
if (!isReentry)
|
if (!isReentry)
|
||||||
{
|
{
|
||||||
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
|
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
|
||||||
|
|
@ -101,6 +121,10 @@ public static class CaptionComposer
|
||||||
model.IdentityLine,
|
model.IdentityLine,
|
||||||
spineLabel,
|
spineLabel,
|
||||||
isReentry && !statusBannerOwnsBeat);
|
isReentry && !statusBannerOwnsBeat);
|
||||||
|
if (!string.IsNullOrEmpty(narrativeContext))
|
||||||
|
{
|
||||||
|
model.ContextLine = narrativeContext;
|
||||||
|
}
|
||||||
|
|
||||||
model.Fingerprint =
|
model.Fingerprint =
|
||||||
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine
|
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
namespace IdleSpectator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decaying per-unit heat from featured cast. Soft-spreads to live relations and Chronicle links.
|
|
||||||
/// </summary>
|
|
||||||
public static class CausalHeat
|
|
||||||
{
|
|
||||||
private struct HeatEntry
|
|
||||||
{
|
|
||||||
public float Value;
|
|
||||||
public float TouchedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly Dictionary<long, HeatEntry> HeatById = new Dictionary<long, HeatEntry>(64);
|
|
||||||
private static readonly List<long> ScratchIds = new List<long>(16);
|
|
||||||
private const float FeaturedAmount = 1f;
|
|
||||||
private const float RelatedSpread = 0.45f;
|
|
||||||
|
|
||||||
public static void Clear()
|
|
||||||
{
|
|
||||||
HeatById.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void NoteFeatured(long unitId, float amount = FeaturedAmount)
|
|
||||||
{
|
|
||||||
if (unitId == 0 || amount <= 0f)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
float now = Time.unscaledTime;
|
|
||||||
AddHeat(unitId, amount, now);
|
|
||||||
SpreadRelated(unitId, amount * RelatedSpread, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void NoteCast(IList<long> castIds, float amount = FeaturedAmount)
|
|
||||||
{
|
|
||||||
if (castIds == null || castIds.Count == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < castIds.Count; i++)
|
|
||||||
{
|
|
||||||
NoteFeatured(castIds[i], amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static float Get(long unitId)
|
|
||||||
{
|
|
||||||
if (unitId == 0 || !HeatById.TryGetValue(unitId, out HeatEntry entry))
|
|
||||||
{
|
|
||||||
return 0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Decayed(entry, Time.unscaledTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static float ScoreBonus(InterestCandidate c)
|
|
||||||
{
|
|
||||||
if (c == null)
|
|
||||||
{
|
|
||||||
return 0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
StoryWeights s = InterestScoringConfig.Story;
|
|
||||||
float subject = Get(c.SubjectId);
|
|
||||||
float related = c.RelatedId != 0 && c.RelatedId != c.SubjectId ? Get(c.RelatedId) : 0f;
|
|
||||||
return subject * s.causalHeatWeight + related * s.causalRelatedWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static float Decayed(HeatEntry entry, float now)
|
|
||||||
{
|
|
||||||
StoryWeights s = InterestScoringConfig.Story;
|
|
||||||
float halfLife = s.causalHeatHalfLifeSeconds > 1f ? s.causalHeatHalfLifeSeconds : 180f;
|
|
||||||
float age = Mathf.Max(0f, now - entry.TouchedAt);
|
|
||||||
return entry.Value * Mathf.Pow(0.5f, age / halfLife);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddHeat(long unitId, float amount, float now)
|
|
||||||
{
|
|
||||||
float cur = 0f;
|
|
||||||
if (HeatById.TryGetValue(unitId, out HeatEntry entry))
|
|
||||||
{
|
|
||||||
cur = Decayed(entry, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
HeatById[unitId] = new HeatEntry
|
|
||||||
{
|
|
||||||
Value = Mathf.Min(4f, cur + amount),
|
|
||||||
TouchedAt = now
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SpreadRelated(long unitId, float amount, float now)
|
|
||||||
{
|
|
||||||
if (amount < 0.05f)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor actor = EventFeedUtil.FindAliveById(unitId);
|
|
||||||
if (actor != null)
|
|
||||||
{
|
|
||||||
Actor lover = ActorRelation.GetLover(actor);
|
|
||||||
if (lover != null)
|
|
||||||
{
|
|
||||||
AddHeat(EventFeedUtil.SafeId(lover), amount, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor friend = ActorRelation.GetBestFriend(actor);
|
|
||||||
if (friend != null)
|
|
||||||
{
|
|
||||||
AddHeat(EventFeedUtil.SafeId(friend), amount * 0.75f, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScratchIds.Clear();
|
|
||||||
Chronicle.EnumerateRelatedIds(unitId, ScratchIds, max: 6);
|
|
||||||
for (int i = 0; i < ScratchIds.Count; i++)
|
|
||||||
{
|
|
||||||
long other = ScratchIds[i];
|
|
||||||
if (other == 0 || other == unitId)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
AddHeat(other, amount * 0.5f, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -297,6 +297,7 @@ public static class LifeSagaMemory
|
||||||
+ Time.unscaledTime.ToString("0.###"),
|
+ Time.unscaledTime.ToString("0.###"),
|
||||||
strength: 70f,
|
strength: 70f,
|
||||||
provenance: provenance);
|
provenance: provenance);
|
||||||
|
NarrativeDevelopmentRouter.BondFormed(a, b, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
|
public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
|
||||||
|
|
@ -342,6 +343,7 @@ public static class LifeSagaMemory
|
||||||
strength: 75f,
|
strength: 75f,
|
||||||
provenance: provenance,
|
provenance: provenance,
|
||||||
resolved: true);
|
resolved: true);
|
||||||
|
NarrativeDevelopmentRouter.BondBroken(survivor, former, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
|
public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
|
||||||
|
|
@ -360,6 +362,7 @@ public static class LifeSagaMemory
|
||||||
correlationKey: "parent:" + parentId + ":" + childId,
|
correlationKey: "parent:" + parentId + ":" + childId,
|
||||||
strength: 65f,
|
strength: 65f,
|
||||||
provenance: provenance);
|
provenance: provenance);
|
||||||
|
NarrativeDevelopmentRouter.ChildBorn(parent, child, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend")
|
public static void RecordFriendFormed(Actor a, Actor b, string provenance = "set_best_friend")
|
||||||
|
|
@ -376,6 +379,7 @@ public static class LifeSagaMemory
|
||||||
correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)),
|
correlationKey: "friend:" + PairKey(EventFeedUtil.SafeId(a), EventFeedUtil.SafeId(b)),
|
||||||
strength: 55f,
|
strength: 55f,
|
||||||
provenance: provenance);
|
provenance: provenance);
|
||||||
|
NarrativeDevelopmentRouter.FriendFormed(a, b, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
|
public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
|
||||||
|
|
@ -395,6 +399,7 @@ public static class LifeSagaMemory
|
||||||
+ Time.unscaledTime.ToString("0.###"),
|
+ Time.unscaledTime.ToString("0.###"),
|
||||||
strength: 80f,
|
strength: 80f,
|
||||||
provenance: provenance);
|
provenance: provenance);
|
||||||
|
NarrativeDevelopmentRouter.Kill(killer, victim, provenance);
|
||||||
|
|
||||||
// Kill/death must not increment living rematch counters (single kill ≠ rival).
|
// Kill/death must not increment living rematch counters (single kill ≠ rival).
|
||||||
TryMarkKinKillRival(killer, victim);
|
TryMarkKinKillRival(killer, victim);
|
||||||
|
|
@ -422,6 +427,7 @@ public static class LifeSagaMemory
|
||||||
provenance: provenance,
|
provenance: provenance,
|
||||||
resolved: true,
|
resolved: true,
|
||||||
note: attackType ?? "");
|
note: attackType ?? "");
|
||||||
|
NarrativeDevelopmentRouter.Death(victim, killer, attackType, provenance);
|
||||||
|
|
||||||
if (killer != null)
|
if (killer != null)
|
||||||
{
|
{
|
||||||
|
|
@ -644,6 +650,7 @@ public static class LifeSagaMemory
|
||||||
warKey: warKey,
|
warKey: warKey,
|
||||||
resolved: ended,
|
resolved: ended,
|
||||||
note: note ?? "");
|
note: note ?? "");
|
||||||
|
NarrativeDevelopmentRouter.War(subject, other, warKey, ended, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -738,6 +745,7 @@ public static class LifeSagaMemory
|
||||||
plotKey: plotKey,
|
plotKey: plotKey,
|
||||||
note: display,
|
note: display,
|
||||||
resolved: finished);
|
resolved: finished);
|
||||||
|
NarrativeDevelopmentRouter.Plot(subject, other, plotKey, finished, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -977,6 +985,7 @@ public static class LifeSagaMemory
|
||||||
familyKey: metaKind == "family" ? metaKey : "",
|
familyKey: metaKind == "family" ? metaKey : "",
|
||||||
clanKey: metaKind == "clan" ? metaKey : "",
|
clanKey: metaKind == "clan" ? metaKey : "",
|
||||||
note: metaKind ?? "");
|
note: metaKind ?? "");
|
||||||
|
NarrativeDevelopmentRouter.Founding(subject, metaKind, metaKey, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
|
public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
|
||||||
|
|
@ -994,6 +1003,7 @@ public static class LifeSagaMemory
|
||||||
strength: 68f,
|
strength: 68f,
|
||||||
provenance: provenance,
|
provenance: provenance,
|
||||||
note: roleId);
|
note: roleId);
|
||||||
|
NarrativeDevelopmentRouter.Role(subject, roleId, true, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
|
public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
|
||||||
|
|
@ -1013,33 +1023,6 @@ public static class LifeSagaMemory
|
||||||
provenance: provenance);
|
provenance: provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RecordHardArc(
|
|
||||||
long unitId,
|
|
||||||
StoryArcKind arcKind,
|
|
||||||
string note,
|
|
||||||
long relatedId = 0,
|
|
||||||
string provenance = "story_planner")
|
|
||||||
{
|
|
||||||
if (unitId == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LifeSagaIdentity subject = SnapshotId(unitId);
|
|
||||||
LifeSagaIdentity other = relatedId != 0 ? SnapshotId(relatedId) : default;
|
|
||||||
LifeSagaFactKind kind = HardArcKind(arcKind);
|
|
||||||
RecordIds(
|
|
||||||
kind,
|
|
||||||
unitId,
|
|
||||||
subject,
|
|
||||||
relatedId,
|
|
||||||
other,
|
|
||||||
correlationKey: "arc:" + unitId + ":" + arcKind + ":" + (note ?? "").GetHashCode(),
|
|
||||||
strength: 60f,
|
|
||||||
provenance: provenance,
|
|
||||||
note: note ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static LifeSagaIdentity Snapshot(Actor actor)
|
public static LifeSagaIdentity Snapshot(Actor actor)
|
||||||
{
|
{
|
||||||
if (actor == null)
|
if (actor == null)
|
||||||
|
|
@ -1786,13 +1769,11 @@ public static class LifeSagaMemory
|
||||||
{
|
{
|
||||||
if (a != 0)
|
if (a != 0)
|
||||||
{
|
{
|
||||||
CausalHeat.NoteFeatured(a, 1.15f);
|
|
||||||
LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
|
LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (b != 0 && b != a)
|
if (b != 0 && b != a)
|
||||||
{
|
{
|
||||||
CausalHeat.NoteFeatured(b, 1.15f);
|
|
||||||
LifeSagaRoster.NoteFeaturedUnit(b, 1.2f);
|
LifeSagaRoster.NoteFeaturedUnit(b, 1.2f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1859,26 +1840,6 @@ public static class LifeSagaMemory
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LifeSagaFactKind HardArcKind(StoryArcKind arcKind)
|
|
||||||
{
|
|
||||||
switch (arcKind)
|
|
||||||
{
|
|
||||||
case StoryArcKind.Love:
|
|
||||||
return LifeSagaFactKind.HardArcLove;
|
|
||||||
case StoryArcKind.Grief:
|
|
||||||
return LifeSagaFactKind.HardArcGrief;
|
|
||||||
case StoryArcKind.CombatDuel:
|
|
||||||
case StoryArcKind.CombatMass:
|
|
||||||
return LifeSagaFactKind.HardArcCombat;
|
|
||||||
case StoryArcKind.WarFront:
|
|
||||||
return LifeSagaFactKind.HardArcWar;
|
|
||||||
case StoryArcKind.Plot:
|
|
||||||
return LifeSagaFactKind.HardArcPlot;
|
|
||||||
default:
|
|
||||||
return LifeSagaFactKind.Unknown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
|
private static LifeSagaChapterKind ToChapterKind(LifeSagaFactKind kind)
|
||||||
{
|
{
|
||||||
switch (kind)
|
switch (kind)
|
||||||
|
|
|
||||||
|
|
@ -650,14 +650,103 @@ public static class LifeSagaPresentation
|
||||||
|
|
||||||
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
|
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
|
||||||
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seenLines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
List<NarrativeChapter> narrativeChapters = NarrativeStoryStore.ChaptersFor(unitId, 4);
|
||||||
|
bool narrativeOwnsFamily = false;
|
||||||
|
bool narrativeOwnsConflict = false;
|
||||||
|
for (int i = 0; i < narrativeChapters.Count && model.Legacy.Count < 4; i++)
|
||||||
|
{
|
||||||
|
NarrativeChapter chapter = narrativeChapters[i];
|
||||||
|
if (chapter == null || string.IsNullOrEmpty(chapter.Line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string line = !string.IsNullOrEmpty(chapter.DateLabel)
|
||||||
|
? chapter.DateLabel + " · " + chapter.Line
|
||||||
|
: chapter.Line;
|
||||||
|
if (!seenLines.Add(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NarrativeThread chapterThread = NarrativeStoryStore.Thread(chapter.ThreadId);
|
||||||
|
if (chapterThread != null)
|
||||||
|
{
|
||||||
|
narrativeOwnsFamily |= chapterThread.Kind == NarrativeThreadKind.Partnership
|
||||||
|
|| chapterThread.Kind == NarrativeThreadKind.Lineage
|
||||||
|
|| chapterThread.Kind == NarrativeThreadKind.SeparationGrief;
|
||||||
|
narrativeOwnsConflict |= chapterThread.Kind == NarrativeThreadKind.Rivalry
|
||||||
|
|| chapterThread.Kind == NarrativeThreadKind.PersonalCombat
|
||||||
|
|| chapterThread.Kind == NarrativeThreadKind.WarInvolvement;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.Legacy.Add(new LifeSagaLegacyBeat
|
||||||
|
{
|
||||||
|
Kind = NarrativeLegacyKind(chapterThread),
|
||||||
|
Line = line,
|
||||||
|
Truth = "observed",
|
||||||
|
At = chapter.At,
|
||||||
|
DateLabel = chapter.DateLabel ?? ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wroteCombatSummary = false;
|
||||||
|
if (TryBuildCombatSummary(unitId, out LifeSagaLegacyBeat combatSummary))
|
||||||
|
{
|
||||||
|
// PersonalCombat chapters are still one chapter per opponent. Replace those
|
||||||
|
// repeated kill lines with one observed run; earned Rivalry chapters use a
|
||||||
|
// different kind and remain intact.
|
||||||
|
for (int i = model.Legacy.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (model.Legacy[i] != null && model.Legacy[i].Kind == LifeSagaFactKind.Kill)
|
||||||
|
{
|
||||||
|
model.Legacy.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.Legacy.Count < factLimit && seenLines.Add(combatSummary.Line))
|
||||||
|
{
|
||||||
|
model.Legacy.Add(combatSummary);
|
||||||
|
wroteCombatSummary = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < facts.Count; i++)
|
for (int i = 0; i < facts.Count; i++)
|
||||||
{
|
{
|
||||||
|
if (model.Legacy.Count >= factLimit)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
LifeSagaFact f = facts[i];
|
LifeSagaFact f = facts[i];
|
||||||
if (f == null)
|
if (f == null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (narrativeOwnsFamily
|
||||||
|
&& (f.Kind == LifeSagaFactKind.BondFormed
|
||||||
|
|| f.Kind == LifeSagaFactKind.BondBroken
|
||||||
|
|| f.Kind == LifeSagaFactKind.ParentChild
|
||||||
|
|| f.Kind == LifeSagaFactKind.HardArcLove
|
||||||
|
|| f.Kind == LifeSagaFactKind.HardArcGrief))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (narrativeOwnsConflict
|
||||||
|
&& (f.Kind == LifeSagaFactKind.Kill
|
||||||
|
|| f.Kind == LifeSagaFactKind.HardArcCombat
|
||||||
|
|| f.Kind == LifeSagaFactKind.HardArcWar))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wroteCombatSummary && f.Kind == LifeSagaFactKind.Kill)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
|
// Stake already owns the strongest unresolved beat - do not repeat it in Legacy.
|
||||||
if (stake != null
|
if (stake != null
|
||||||
&& ((!string.IsNullOrEmpty(stake.Key)
|
&& ((!string.IsNullOrEmpty(stake.Key)
|
||||||
|
|
@ -725,6 +814,7 @@ public static class LifeSagaPresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wroteParentChild
|
if (!wroteParentChild
|
||||||
|
&& !narrativeOwnsFamily
|
||||||
&& parentedCount >= 2
|
&& parentedCount >= 2
|
||||||
&& model.Legacy.Count < 4)
|
&& model.Legacy.Count < 4)
|
||||||
{
|
{
|
||||||
|
|
@ -745,6 +835,81 @@ public static class LifeSagaPresentation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryBuildCombatSummary(long unitId, out LifeSagaLegacyBeat summary)
|
||||||
|
{
|
||||||
|
summary = null;
|
||||||
|
IReadOnlyList<LifeSagaFact> all = LifeSagaMemory.FactsFor(unitId);
|
||||||
|
if (all == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int kills = 0;
|
||||||
|
LifeSagaFact earliest = null;
|
||||||
|
LifeSagaFact latest = null;
|
||||||
|
for (int i = 0; i < all.Count; i++)
|
||||||
|
{
|
||||||
|
LifeSagaFact fact = all[i];
|
||||||
|
if (fact == null || fact.Kind != LifeSagaFactKind.Kill)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
kills++;
|
||||||
|
if (earliest == null || fact.At < earliest.At) earliest = fact;
|
||||||
|
if (latest == null || fact.At > latest.At) latest = fact;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kills < 2 || latest == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string date = latest.DateLabel ?? "";
|
||||||
|
if (earliest != null
|
||||||
|
&& !string.IsNullOrEmpty(earliest.DateLabel)
|
||||||
|
&& !string.IsNullOrEmpty(latest.DateLabel)
|
||||||
|
&& !string.Equals(earliest.DateLabel, latest.DateLabel, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
date = earliest.DateLabel + "–" + latest.DateLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
string body = "Slew " + kills + " opponents across repeated clashes";
|
||||||
|
string line = string.IsNullOrEmpty(date) ? body : date + " · " + body;
|
||||||
|
summary = new LifeSagaLegacyBeat
|
||||||
|
{
|
||||||
|
Kind = LifeSagaFactKind.Kill,
|
||||||
|
Line = line,
|
||||||
|
Truth = "observed",
|
||||||
|
At = latest.At,
|
||||||
|
DateLabel = date
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LifeSagaFactKind NarrativeLegacyKind(NarrativeThread thread)
|
||||||
|
{
|
||||||
|
if (thread == null) return LifeSagaFactKind.Unknown;
|
||||||
|
switch (thread.Kind)
|
||||||
|
{
|
||||||
|
case NarrativeThreadKind.Partnership: return LifeSagaFactKind.BondFormed;
|
||||||
|
case NarrativeThreadKind.Lineage: return LifeSagaFactKind.ParentChild;
|
||||||
|
case NarrativeThreadKind.SeparationGrief: return LifeSagaFactKind.BondBroken;
|
||||||
|
case NarrativeThreadKind.RiseToRule:
|
||||||
|
case NarrativeThreadKind.Reign:
|
||||||
|
case NarrativeThreadKind.Succession: return LifeSagaFactKind.RoleChange;
|
||||||
|
case NarrativeThreadKind.Founding: return LifeSagaFactKind.Founding;
|
||||||
|
case NarrativeThreadKind.HomeLoss: return LifeSagaFactKind.HomeLost;
|
||||||
|
case NarrativeThreadKind.Rivalry: return LifeSagaFactKind.RivalEarned;
|
||||||
|
case NarrativeThreadKind.PersonalCombat: return LifeSagaFactKind.Kill;
|
||||||
|
case NarrativeThreadKind.WarInvolvement: return LifeSagaFactKind.WarJoin;
|
||||||
|
case NarrativeThreadKind.PlotBetrayal: return LifeSagaFactKind.PlotJoin;
|
||||||
|
case NarrativeThreadKind.Transformation:
|
||||||
|
case NarrativeThreadKind.Recovery: return LifeSagaFactKind.Unknown;
|
||||||
|
default: return LifeSagaFactKind.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Living Cast already presents lover/friend/kin - do not echo those names as Legacy crumbs.
|
/// Living Cast already presents lover/friend/kin - do not echo those names as Legacy crumbs.
|
||||||
/// Dead / past relations and wars/plots/kills still belong in Legacy.
|
/// Dead / past relations and wars/plots/kills still belong in Legacy.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ public static class LifeSagaRoster
|
||||||
private static readonly Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
|
private static readonly Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
|
||||||
private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
|
private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
|
||||||
private static readonly List<long> TouchScratch = new List<long>(8);
|
private static readonly List<long> TouchScratch = new List<long>(8);
|
||||||
|
private static readonly List<CharacterStoryState> NarrativeChallengerScratch =
|
||||||
|
new List<CharacterStoryState>(8);
|
||||||
private static float _nextScanAt;
|
private static float _nextScanAt;
|
||||||
|
private static float _nextNarrativeAdmissionAt;
|
||||||
private static float _roleCacheAt = -999f;
|
private static float _roleCacheAt = -999f;
|
||||||
private static bool _dirty = true;
|
private static bool _dirty = true;
|
||||||
private static WorldScanPhase _scanPhase = WorldScanPhase.None;
|
private static WorldScanPhase _scanPhase = WorldScanPhase.None;
|
||||||
|
|
@ -77,7 +80,9 @@ public static class LifeSagaRoster
|
||||||
PlotAuthorCache.Clear();
|
PlotAuthorCache.Clear();
|
||||||
SpeciesCountCache.Clear();
|
SpeciesCountCache.Clear();
|
||||||
McOrbitCache.Clear();
|
McOrbitCache.Clear();
|
||||||
|
NarrativeChallengerScratch.Clear();
|
||||||
_nextScanAt = 0f;
|
_nextScanAt = 0f;
|
||||||
|
_nextNarrativeAdmissionAt = 0f;
|
||||||
_roleCacheAt = -999f;
|
_roleCacheAt = -999f;
|
||||||
_scanPhase = WorldScanPhase.None;
|
_scanPhase = WorldScanPhase.None;
|
||||||
_scanIndex = 0;
|
_scanIndex = 0;
|
||||||
|
|
@ -699,156 +704,16 @@ public static class LifeSagaRoster
|
||||||
s.TouchedAt = now;
|
s.TouchedAt = now;
|
||||||
s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f);
|
s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f);
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
|
|
||||||
LifeSagaMemory.RecordHardArc(
|
|
||||||
unitId,
|
|
||||||
kind == LifeSagaChapterKind.Love ? StoryArcKind.Love
|
|
||||||
: kind == LifeSagaChapterKind.Grief ? StoryArcKind.Grief
|
|
||||||
: kind == LifeSagaChapterKind.Combat ? StoryArcKind.CombatDuel
|
|
||||||
: kind == LifeSagaChapterKind.War ? StoryArcKind.WarFront
|
|
||||||
: kind == LifeSagaChapterKind.Plot ? StoryArcKind.Plot
|
|
||||||
: StoryArcKind.None,
|
|
||||||
trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hard-arc admit heat grows with climax age so prolonged theater can challenge a full Cap.
|
|
||||||
/// One-shot heat 1.5 loses to a roster of notables; ~2 minutes reaches <see cref="HeatCap"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static float HardArcAdmitHeat(StoryArc arc, float now = -1f)
|
|
||||||
{
|
|
||||||
if (now < 0f)
|
|
||||||
{
|
|
||||||
now = Time.unscaledTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arc == null || arc.StartedAt <= 0f)
|
|
||||||
{
|
|
||||||
return 1.5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float age = Mathf.Max(0f, now - arc.StartedAt);
|
|
||||||
return Mathf.Min(HeatCap, 1.5f + age / 12f);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void StampChapterFromArc(StoryArc arc, InterestCandidate tip, float now)
|
|
||||||
{
|
|
||||||
if (arc == null || !arc.IsActive)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string line = FormatChapterLine(arc, tip);
|
|
||||||
if (string.IsNullOrEmpty(line))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hard = IsHardStoryKind(arc.Kind);
|
|
||||||
float subjectHeat = hard ? HardArcAdmitHeat(arc, now) : 1.5f;
|
|
||||||
float relatedHeat = hard ? Mathf.Max(1.2f, subjectHeat * 0.85f) : 1.2f;
|
|
||||||
if (arc.CastIds != null)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < arc.CastIds.Count; i++)
|
|
||||||
{
|
|
||||||
long id = arc.CastIds[i];
|
|
||||||
if (hard)
|
|
||||||
{
|
|
||||||
ConsiderAdmit(id, subjectHeat, hardArcAdmit: true, now);
|
|
||||||
MarkHardArcContext(id, arc.Kind, line);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsMc(id))
|
|
||||||
{
|
|
||||||
LifeSagaMemory.RecordHardArc(id, arc.Kind, line, tip?.RelatedId ?? 0);
|
|
||||||
StampChapter(id, ToChapterKind(arc.Kind), line, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tip != null)
|
|
||||||
{
|
|
||||||
if (hard)
|
|
||||||
{
|
|
||||||
ConsiderAdmit(tip.SubjectId, subjectHeat, hardArcAdmit: true, now);
|
|
||||||
MarkHardArcContext(tip.SubjectId, arc.Kind, line);
|
|
||||||
if (tip.RelatedId != 0)
|
|
||||||
{
|
|
||||||
ConsiderAdmit(tip.RelatedId, relatedHeat, hardArcAdmit: true, now);
|
|
||||||
MarkHardArcContext(tip.RelatedId, arc.Kind, line);
|
|
||||||
}
|
|
||||||
|
|
||||||
long follow = EventFeedUtil.SafeId(tip.FollowUnit);
|
|
||||||
if (follow != 0 && follow != tip.SubjectId && follow != tip.RelatedId)
|
|
||||||
{
|
|
||||||
ConsiderAdmit(follow, subjectHeat, hardArcAdmit: true, now);
|
|
||||||
MarkHardArcContext(follow, arc.Kind, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsMc(tip.SubjectId))
|
|
||||||
{
|
|
||||||
StampChapter(tip.SubjectId, ToChapterKind(arc.Kind), line, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tip.RelatedId != 0 && IsMc(tip.RelatedId))
|
|
||||||
{
|
|
||||||
StampChapter(tip.RelatedId, ToChapterKind(arc.Kind), line, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
long followStamp = EventFeedUtil.SafeId(tip.FollowUnit);
|
|
||||||
if (followStamp != 0
|
|
||||||
&& followStamp != tip.SubjectId
|
|
||||||
&& followStamp != tip.RelatedId
|
|
||||||
&& IsMc(followStamp))
|
|
||||||
{
|
|
||||||
StampChapter(followStamp, ToChapterKind(arc.Kind), line, now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string FormatChapterLine(StoryArc arc, InterestCandidate tip)
|
|
||||||
{
|
|
||||||
if (arc == null)
|
|
||||||
{
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(arc.Headline))
|
|
||||||
{
|
|
||||||
return CleanChapterLine(arc.Headline);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tip != null && !string.IsNullOrEmpty(tip.Label))
|
|
||||||
{
|
|
||||||
string cleaned = CleanChapterLine(tip.Label);
|
|
||||||
if (!string.IsNullOrEmpty(cleaned))
|
|
||||||
{
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (arc.Kind)
|
|
||||||
{
|
|
||||||
case StoryArcKind.WarFront:
|
|
||||||
return "War front";
|
|
||||||
case StoryArcKind.CombatDuel:
|
|
||||||
return "Duel";
|
|
||||||
case StoryArcKind.CombatMass:
|
|
||||||
return "Battle";
|
|
||||||
case StoryArcKind.Love:
|
|
||||||
return "Love";
|
|
||||||
case StoryArcKind.Grief:
|
|
||||||
return "Grief";
|
|
||||||
case StoryArcKind.Plot:
|
|
||||||
return "Plot";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Tick(float now)
|
public static void Tick(float now)
|
||||||
{
|
{
|
||||||
|
if (now >= _nextNarrativeAdmissionAt)
|
||||||
|
{
|
||||||
|
_nextNarrativeAdmissionAt = now + 1f;
|
||||||
|
ConsiderEmergingStories(now);
|
||||||
|
}
|
||||||
|
|
||||||
if (_scanPhase != WorldScanPhase.None)
|
if (_scanPhase != WorldScanPhase.None)
|
||||||
{
|
{
|
||||||
ContinueAmortizedWorldScan(now);
|
ContinueAmortizedWorldScan(now);
|
||||||
|
|
@ -1086,9 +951,13 @@ public static class LifeSagaRoster
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Challenge Cap with a living unit. Admission roles always may compete;
|
/// Challenge Cap with a living unit. Admission roles always may compete;
|
||||||
/// nobodies only when <paramref name="hardArcAdmit"/> (hard-arc chapter path).
|
/// ordinary characters only when coherent story momentum qualifies them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ConsiderAdmit(long unitId, float heat, bool hardArcAdmit, float now = -1f)
|
public static bool ConsiderAdmit(
|
||||||
|
long unitId,
|
||||||
|
float heat,
|
||||||
|
float now = -1f,
|
||||||
|
bool emergingStoryAdmit = false)
|
||||||
{
|
{
|
||||||
if (unitId == 0)
|
if (unitId == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1117,7 +986,7 @@ public static class LifeSagaRoster
|
||||||
|
|
||||||
EnsureRoleCache(now, force: false);
|
EnsureRoleCache(now, force: false);
|
||||||
bool admission = IsAdmissionCandidate(actor);
|
bool admission = IsAdmissionCandidate(actor);
|
||||||
if (!admission && !hardArcAdmit)
|
if (!admission && !emergingStoryAdmit)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1125,7 +994,8 @@ public static class LifeSagaRoster
|
||||||
LifeSagaSlot neu = BuildSlot(
|
LifeSagaSlot neu = BuildSlot(
|
||||||
actor,
|
actor,
|
||||||
now,
|
now,
|
||||||
hardArcAdmit && !admission ? LifeSagaAdmissionReason.HardArc : LifeSagaAdmissionReason.None);
|
emergingStoryAdmit && !admission ? LifeSagaAdmissionReason.EmergingStory
|
||||||
|
: LifeSagaAdmissionReason.None);
|
||||||
neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat));
|
neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat));
|
||||||
Scratch.Clear();
|
Scratch.Clear();
|
||||||
PreviousIds.Clear();
|
PreviousIds.Clear();
|
||||||
|
|
@ -1150,6 +1020,22 @@ public static class LifeSagaRoster
|
||||||
return IsMc(unitId);
|
return IsMc(unitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConsiderEmergingStories(float now)
|
||||||
|
{
|
||||||
|
NarrativeStoryStore.CopyEmergingCharacters(NarrativeChallengerScratch, 6f, 4);
|
||||||
|
for (int i = 0; i < NarrativeChallengerScratch.Count; i++)
|
||||||
|
{
|
||||||
|
CharacterStoryState state = NarrativeChallengerScratch[i];
|
||||||
|
if (Get(state.CharacterId) != null) continue;
|
||||||
|
float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now);
|
||||||
|
ConsiderAdmit(
|
||||||
|
state.CharacterId,
|
||||||
|
heat: Mathf.Clamp(potential * 0.5f, 1f, HeatCap),
|
||||||
|
now: now,
|
||||||
|
emergingStoryAdmit: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void NoteUnit(long unitId, float heat, float now)
|
private static void NoteUnit(long unitId, float heat, float now)
|
||||||
{
|
{
|
||||||
LifeSagaSlot s = Get(unitId);
|
LifeSagaSlot s = Get(unitId);
|
||||||
|
|
@ -1527,7 +1413,8 @@ public static class LifeSagaRoster
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
float score = standing + heat;
|
float storyPotential = Mathf.Min(12f, StoryScheduler.StoryPotential(s.UnitId) * 0.8f);
|
||||||
|
float score = standing + heat + storyPotential;
|
||||||
if (s.GameFavorite)
|
if (s.GameFavorite)
|
||||||
{
|
{
|
||||||
score += 50f;
|
score += 50f;
|
||||||
|
|
@ -2400,19 +2287,6 @@ public static class LifeSagaRoster
|
||||||
return LifeSagaAdmissionReason.NotableLife;
|
return LifeSagaAdmissionReason.NotableLife;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MarkHardArcContext(long unitId, StoryArcKind kind, string line)
|
|
||||||
{
|
|
||||||
LifeSagaSlot slot = Get(unitId);
|
|
||||||
if (slot == null || slot.AdmissionReason != LifeSagaAdmissionReason.HardArc)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
slot.AdmissionArcKind = kind;
|
|
||||||
slot.AdmissionContext = CleanChapterLine(line);
|
|
||||||
_dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static float BaseInterest(Actor actor)
|
private static float BaseInterest(Actor actor)
|
||||||
{
|
{
|
||||||
float score = 1f;
|
float score = 1f;
|
||||||
|
|
@ -2482,34 +2356,6 @@ public static class LifeSagaRoster
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsHardStoryKind(StoryArcKind kind) =>
|
|
||||||
kind == StoryArcKind.CombatDuel
|
|
||||||
|| kind == StoryArcKind.CombatMass
|
|
||||||
|| kind == StoryArcKind.WarFront
|
|
||||||
|| kind == StoryArcKind.Plot
|
|
||||||
|| kind == StoryArcKind.Love
|
|
||||||
|| kind == StoryArcKind.Grief;
|
|
||||||
|
|
||||||
private static LifeSagaChapterKind ToChapterKind(StoryArcKind kind)
|
|
||||||
{
|
|
||||||
switch (kind)
|
|
||||||
{
|
|
||||||
case StoryArcKind.CombatDuel:
|
|
||||||
case StoryArcKind.CombatMass:
|
|
||||||
return LifeSagaChapterKind.Combat;
|
|
||||||
case StoryArcKind.WarFront:
|
|
||||||
return LifeSagaChapterKind.War;
|
|
||||||
case StoryArcKind.Plot:
|
|
||||||
return LifeSagaChapterKind.Plot;
|
|
||||||
case StoryArcKind.Love:
|
|
||||||
return LifeSagaChapterKind.Love;
|
|
||||||
case StoryArcKind.Grief:
|
|
||||||
return LifeSagaChapterKind.Grief;
|
|
||||||
default:
|
|
||||||
return LifeSagaChapterKind.Unknown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string CleanChapterLine(string line)
|
private static string CleanChapterLine(string line)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(line))
|
if (string.IsNullOrEmpty(line))
|
||||||
|
|
@ -2620,8 +2466,6 @@ public sealed class LifeSagaSlot
|
||||||
public float TouchedAt;
|
public float TouchedAt;
|
||||||
public float LastChapterAt;
|
public float LastChapterAt;
|
||||||
public LifeSagaAdmissionReason AdmissionReason;
|
public LifeSagaAdmissionReason AdmissionReason;
|
||||||
public StoryArcKind AdmissionArcKind;
|
|
||||||
public string AdmissionContext = "";
|
|
||||||
public readonly List<LifeSagaChapter> Chapters =
|
public readonly List<LifeSagaChapter> Chapters =
|
||||||
new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters);
|
new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters);
|
||||||
|
|
||||||
|
|
@ -2650,7 +2494,7 @@ public enum LifeSagaAdmissionReason
|
||||||
NotableKills,
|
NotableKills,
|
||||||
NotableRenown,
|
NotableRenown,
|
||||||
NotableLife,
|
NotableLife,
|
||||||
HardArc
|
EmergingStory
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum LifeSagaChapterKind
|
public enum LifeSagaChapterKind
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ public static class LifeSagaSession
|
||||||
LifeSagaViewController.Clear();
|
LifeSagaViewController.Clear();
|
||||||
LifeSagaRail.Clear();
|
LifeSagaRail.Clear();
|
||||||
LifeSagaPanel.Clear();
|
LifeSagaPanel.Clear();
|
||||||
|
NarrativeStoryStore.Clear();
|
||||||
_epoch++;
|
_epoch++;
|
||||||
|
|
||||||
object world = SafeWorld();
|
object world = SafeWorld();
|
||||||
|
|
|
||||||
|
|
@ -712,6 +712,8 @@ public static class SagaProse
|
||||||
string c = FirstNonEmpty(slot?.CityKey, CityName(actor));
|
string c = FirstNonEmpty(slot?.CityKey, CityName(actor));
|
||||||
return string.IsNullOrEmpty(c) ? Sentence("Leads a city") : Sentence("Leads " + c);
|
return string.IsNullOrEmpty(c) ? Sentence("Leads a city") : Sentence("Leads " + c);
|
||||||
}
|
}
|
||||||
|
case LifeSagaAdmissionReason.EmergingStory:
|
||||||
|
return Sentence("A life in motion");
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,12 +211,6 @@ public static class StoryPlanner
|
||||||
_active.HardHold = hardHold || _active.HardHold;
|
_active.HardHold = hardHold || _active.HardHold;
|
||||||
NoteCastFrom(current, _active);
|
NoteCastFrom(current, _active);
|
||||||
StampHeadline(_active, current);
|
StampHeadline(_active, current);
|
||||||
// Re-challenge Cap while climax holds: admit heat escalates with arc age so a
|
|
||||||
// prolonged duel/war/plot on a stranger can still join the rail (Active chrome).
|
|
||||||
if (IsHardStoryKind(_active.Kind))
|
|
||||||
{
|
|
||||||
LifeSagaRoster.StampChapterFromArc(_active, current, Time.unscaledTime);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCombatKind(StoryArcKind kind) =>
|
private static bool IsCombatKind(StoryArcKind kind) =>
|
||||||
|
|
@ -355,7 +349,6 @@ public static class StoryPlanner
|
||||||
_coldHookFired = false;
|
_coldHookFired = false;
|
||||||
_coldHookClimaxKey = "";
|
_coldHookClimaxKey = "";
|
||||||
_coldHookAnchor = "";
|
_coldHookAnchor = "";
|
||||||
CausalHeat.Clear();
|
|
||||||
// LifeSagaRoster survives short-arc Clear (browse / brief idle-off).
|
// LifeSagaRoster survives short-arc Clear (browse / brief idle-off).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -752,12 +745,6 @@ public static class StoryPlanner
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CausalHeat.NoteFeatured(next.SubjectId);
|
|
||||||
if (next.RelatedId != 0)
|
|
||||||
{
|
|
||||||
CausalHeat.NoteFeatured(next.RelatedId, 0.6f);
|
|
||||||
}
|
|
||||||
|
|
||||||
LifeSagaRoster.NoteFeatured(next);
|
LifeSagaRoster.NoteFeatured(next);
|
||||||
|
|
||||||
if (IsStoryAssetCandidate(next))
|
if (IsStoryAssetCandidate(next))
|
||||||
|
|
@ -797,7 +784,6 @@ public static class StoryPlanner
|
||||||
}
|
}
|
||||||
|
|
||||||
NoteCastFrom(next, _active);
|
NoteCastFrom(next, _active);
|
||||||
LifeSagaRoster.StampChapterFromArc(_active, next, now);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
@ -808,11 +794,6 @@ public static class StoryPlanner
|
||||||
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
|
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
|
||||||
{
|
{
|
||||||
BeginOrRefreshArc(next, kind, hardHold, now);
|
BeginOrRefreshArc(next, kind, hardHold, now);
|
||||||
if (_active != null && _active.IsActive)
|
|
||||||
{
|
|
||||||
LifeSagaRoster.StampChapterFromArc(_active, next, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1420,7 +1401,6 @@ public static class StoryPlanner
|
||||||
_coldHookFired = false;
|
_coldHookFired = false;
|
||||||
_coldHookClimaxKey = "";
|
_coldHookClimaxKey = "";
|
||||||
_coldHookAnchor = "";
|
_coldHookAnchor = "";
|
||||||
CausalHeat.NoteCast(_active.CastIds);
|
|
||||||
TrimParkedBoard();
|
TrimParkedBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2183,7 +2163,6 @@ public static class StoryPlanner
|
||||||
MaxWatch = registered.MaxWatch,
|
MaxWatch = registered.MaxWatch,
|
||||||
SynthesizeIfMissing = false
|
SynthesizeIfMissing = false
|
||||||
});
|
});
|
||||||
CausalHeat.NoteFeatured(followId);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2194,139 +2173,9 @@ public static class StoryPlanner
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crisis chapter owns the closer - do not also fire a related soft epilogue.
|
// Narrative threads carry consequences forward. The old synthetic related-character
|
||||||
if (_crisis != null && _crisis.IsLive)
|
// closer duplicated that responsibility and generated generic unsupported epilogues.
|
||||||
{
|
arc.Advance(StoryPhase.Done, now);
|
||||||
if (!_crisis.EpilogueInjected)
|
|
||||||
{
|
|
||||||
BeginCrisisEpilogue(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_crisis.EpilogueInjected)
|
|
||||||
{
|
|
||||||
arc.Advance(StoryPhase.Done, now);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor follow = null;
|
|
||||||
Actor related = null;
|
|
||||||
ActorScratch.Clear();
|
|
||||||
for (int i = 0; i < arc.CastIds.Count; i++)
|
|
||||||
{
|
|
||||||
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
|
|
||||||
if (a == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor lover = ActorRelation.GetLover(a);
|
|
||||||
Actor friend = ActorRelation.GetBestFriend(a);
|
|
||||||
bool hasBond = (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
|
|
||||||
|| (friend != null && friend.isAlive());
|
|
||||||
if (hasBond)
|
|
||||||
{
|
|
||||||
ActorScratch.Add(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor preferred = LifeSagaRoster.PreferRosterUnit(ActorScratch);
|
|
||||||
if (preferred != null)
|
|
||||||
{
|
|
||||||
follow = preferred;
|
|
||||||
related = ActorRelation.GetLover(preferred);
|
|
||||||
if (related == null || !related.isAlive() || EventFeedUtil.SafeId(related) == EventFeedUtil.SafeId(preferred))
|
|
||||||
{
|
|
||||||
related = ActorRelation.GetBestFriend(preferred);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (int i = 0; i < arc.CastIds.Count; i++)
|
|
||||||
{
|
|
||||||
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
|
|
||||||
if (a == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor lover = ActorRelation.GetLover(a);
|
|
||||||
if (lover != null && lover.isAlive() && EventFeedUtil.SafeId(lover) != EventFeedUtil.SafeId(a))
|
|
||||||
{
|
|
||||||
follow = a;
|
|
||||||
related = lover;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
Actor friend = ActorRelation.GetBestFriend(a);
|
|
||||||
if (friend != null && friend.isAlive())
|
|
||||||
{
|
|
||||||
follow = a;
|
|
||||||
related = friend;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (follow == null)
|
|
||||||
{
|
|
||||||
follow = a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (follow == null)
|
|
||||||
{
|
|
||||||
arc.Advance(StoryPhase.Done, now);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need a distinct related for a meaningful epilogue.
|
|
||||||
if (related == null)
|
|
||||||
{
|
|
||||||
arc.Advance(StoryPhase.Done, now);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
StoryWeights s = InterestScoringConfig.Story;
|
|
||||||
string assetId = StoryReason.EpilogueRelated;
|
|
||||||
string label = StoryReason.AftermathLabel(assetId, follow, related);
|
|
||||||
string key = "epilogue:" + arc.Kind + ":" + arc.AnchorKey;
|
|
||||||
long followId = EventFeedUtil.SafeId(follow);
|
|
||||||
long relatedId = EventFeedUtil.SafeId(related);
|
|
||||||
|
|
||||||
var candidate = new InterestCandidate
|
|
||||||
{
|
|
||||||
Key = key,
|
|
||||||
LeadKind = InterestLeadKind.EventLed,
|
|
||||||
Category = "Story",
|
|
||||||
Source = "story_planner",
|
|
||||||
AssetId = assetId,
|
|
||||||
Label = label,
|
|
||||||
FollowUnit = follow,
|
|
||||||
SubjectId = followId,
|
|
||||||
RelatedUnit = related,
|
|
||||||
RelatedId = relatedId,
|
|
||||||
Position = follow.current_position,
|
|
||||||
EventStrength = s.epilogueStrength,
|
|
||||||
VisualConfidence = 0.7f,
|
|
||||||
Novelty = 1f,
|
|
||||||
MinWatch = 8f,
|
|
||||||
MaxWatch = s.epilogueMaxWatch > 0f ? s.epilogueMaxWatch : 14f,
|
|
||||||
Completion = InterestCompletionKind.FixedDwell,
|
|
||||||
Resumable = false,
|
|
||||||
CorrelationKey = "story:" + arc.AnchorKey,
|
|
||||||
CreatedAt = now,
|
|
||||||
LastSeenAt = now,
|
|
||||||
ExpiresAt = now + 40f
|
|
||||||
};
|
|
||||||
|
|
||||||
if (EventFeedUtil.RegisterCandidate(candidate) == null)
|
|
||||||
{
|
|
||||||
arc.Advance(StoryPhase.Done, now);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
arc.Advance(StoryPhase.Epilogue, now);
|
|
||||||
CausalHeat.NoteFeatured(followId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Actor ResolveAftermathFollow(InterestCandidate climax)
|
private static Actor ResolveAftermathFollow(InterestCandidate climax)
|
||||||
|
|
@ -3134,10 +2983,6 @@ public static class StoryPlanner
|
||||||
EpilogueInjected = false
|
EpilogueInjected = false
|
||||||
};
|
};
|
||||||
StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate);
|
StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate);
|
||||||
if (followId != 0)
|
|
||||||
{
|
|
||||||
CausalHeat.NoteFeatured(followId, 1.1f);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip)
|
private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip)
|
||||||
|
|
@ -3474,7 +3319,6 @@ public static class StoryPlanner
|
||||||
}
|
}
|
||||||
|
|
||||||
crisis.FollowId = followId;
|
crisis.FollowId = followId;
|
||||||
CausalHeat.NoteFeatured(followId, 1.2f);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1122,12 +1122,6 @@ public static class WorldActivityScanner
|
||||||
score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
|
score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
|
||||||
}
|
}
|
||||||
|
|
||||||
float causal = CausalHeat.Get(id);
|
|
||||||
if (causal > 0f)
|
|
||||||
{
|
|
||||||
score += causal * 2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -10,9 +10,11 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
|
||||||
|
|
||||||
## What it follows
|
## What it follows
|
||||||
|
|
||||||
The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …).
|
The camera follows the developing lives and relationships of a small emerging cast. A story
|
||||||
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
|
scheduler chooses developments that advance an active character thread, yields to critical world
|
||||||
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
|
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
|
||||||
|
has a usable development.
|
||||||
|
|
||||||
While watching, a **character beat caption** shows who they are (Identity), an orange **event sentence** (Beat), and muted Context (story spine or open stake).
|
While watching, a **character beat caption** shows who they are (Identity), an orange **event sentence** (Beat), and muted Context (story spine or open stake).
|
||||||
Main characters get `Name · Title` when they have a real role; tips may gain a self-explanatory relation clause.
|
Main characters get `Name · Title` when they have a real role; tips may gain a self-explanatory relation clause.
|
||||||
|
|
@ -24,7 +26,7 @@ Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps
|
||||||
|
|
||||||
- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML
|
- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML
|
||||||
- `scripts/verify-nml.sh` - load verification helper
|
- `scripts/verify-nml.sh` - load verification helper
|
||||||
- `docs/event-reason.md` - events-own-camera + EventReason contract
|
- `docs/event-reason.md` - evidence, orange-reason, and camera-alignment contract
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,15 @@ Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner`
|
||||||
|
|
||||||
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
|
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
|
||||||
Discrete / status / WorldLog: `CreatesInterest` = A gate.
|
Discrete / status / WorldLog: `CreatesInterest` = A gate.
|
||||||
`InterestDirector` only ranks A candidates. It does not author inventory.
|
`InterestDirector` applies liveness and presentability gates to A candidates; the story scheduler
|
||||||
|
chooses episode developments before bounded ambient/world fallback. Neither authors inventory.
|
||||||
|
|
||||||
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
|
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
|
||||||
|
|
||||||
Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
|
Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
|
||||||
`CaptionComposer` may append a presentation-only relation clause (` · their 3rd clash`) and show a separate Identity line (`Name · King of X`).
|
`CaptionComposer` may append a presentation-only relation clause (` · their 3rd clash`) and show a separate Identity line (`Name · King of X`).
|
||||||
Identity titles and stake slogans never enter `InterestCandidate.Label` - `identity_filter` / `IsIdentityWhy` stay event-first.
|
Identity titles and stake slogans never enter `InterestCandidate.Label`; `identity_filter` /
|
||||||
|
`IsIdentityWhy` remain selection safety gates.
|
||||||
The focused unit must be a tip principal:
|
The focused unit must be a tip principal:
|
||||||
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
|
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
|
||||||
- Pair-locked duels: PairOwner/PairPartner only.
|
- Pair-locked duels: PairOwner/PairPartner only.
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,18 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
|
||||||
- Cap stays 10; order follows Effective = Standing + Heat + pin bonuses.
|
- Cap stays 10; 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.
|
||||||
- Non-admission "nobodies" challenge Cap only via hard-arc chapter stamps (Combat / War / Plot / Love / Grief).
|
- Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed
|
||||||
- Hard-arc admit heat escalates with climax age (re-stamped while the arc holds) so a prolonged stranger duel can displace dull Cap slots and light Active chrome.
|
semantic developments.
|
||||||
|
- Prolonged spectacle alone does not admit a character; coherent relationship, lineage, power,
|
||||||
|
home, conflict, war, plot, or survival momentum can.
|
||||||
- Soft `NoteFeatured` heats existing MCs only.
|
- Soft `NoteFeatured` heats existing MCs only.
|
||||||
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
|
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
|
||||||
|
|
||||||
## Camera (soft)
|
## Camera (soft)
|
||||||
|
|
||||||
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
|
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
|
||||||
- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > MC cast > CausalHeat.
|
- Score ladder: critical interrupt > narrative episode > Prefer soft > MC soft > MC cast > bounded
|
||||||
|
ambient/world context.
|
||||||
- `nearTieEpsilon` (8) picks #1 for ordinary gaps; Prefer/MC/cast may still win within `sagaNearTieEpsilon` (28).
|
- `nearTieEpsilon` (8) picks #1 for ordinary gaps; Prefer/MC/cast may still win within `sagaNearTieEpsilon` (28).
|
||||||
- Soft ranks: Prefer > CrossMC (>1 roster MC) > MC > MC cast (lover/friend/kin/earned rival of a roster MC).
|
- Soft ranks: Prefer > CrossMC (>1 roster MC) > MC > MC cast (lover/friend/kin/earned rival of a roster MC).
|
||||||
- Tips that touch two or more roster MCs get `sagaCrossMcBonus` (~11); scoring touch inventory matches rail Involved ids.
|
- Tips that touch two or more roster MCs get `sagaCrossMcBonus` (~11); scoring touch inventory matches rail Involved ids.
|
||||||
|
|
@ -72,7 +75,8 @@ Always-on AFK chrome (no Dossier/Saga tabs):
|
||||||
| 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 |
|
||||||
|
|
||||||
Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`.
|
Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`.
|
||||||
Selection/`identity_filter` stay event-first.
|
The story scheduler owns selection; `identity_filter` remains a safety gate against presenting
|
||||||
|
identity text as a development.
|
||||||
Beat other resolves `RelatedId` then `PairPartnerId` (no living-name parse).
|
Beat other resolves `RelatedId` then `PairPartnerId` (no living-name parse).
|
||||||
Status banners still own/suppress Beat; Identity + Context keep updating.
|
Status banners still own/suppress Beat; Identity + Context keep updating.
|
||||||
Outside hover, caption Identity/Beat/Context track camera focus. During Saga hover, the
|
Outside hover, caption Identity/Beat/Context track camera focus. During Saga hover, the
|
||||||
|
|
|
||||||
1078
docs/mc-story-director-rewrite-plan.md
Normal file
1078
docs/mc-story-director-rewrite-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -255,12 +255,12 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
|
- `IdleSpectator/Narrative/StoryScheduler.cs` - narrative episode and camera-story ownership
|
||||||
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
|
- `IdleSpectator/Story/StoryPlanner.cs` - visual completion, cold-hook, spine, and crisis lifecycle
|
||||||
|
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + emerging-story admission
|
||||||
- `IdleSpectator/Story/SagaProse.cs` - player-facing relation/stake/title English
|
- `IdleSpectator/Story/SagaProse.cs` - player-facing relation/stake/title English
|
||||||
- `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context
|
- `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context
|
||||||
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover
|
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover
|
||||||
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
|
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
|
||||||
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
|
|
||||||
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
|
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
|
||||||
- `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine
|
- `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,10 @@ drops = []
|
||||||
bads = []
|
bads = []
|
||||||
scenes = []
|
scenes = []
|
||||||
states = []
|
states = []
|
||||||
|
first_active_line = -1
|
||||||
last = None
|
last = None
|
||||||
|
|
||||||
for ln in lines:
|
for line_index, ln in enumerate(lines):
|
||||||
if harness_noise(ln):
|
if harness_noise(ln):
|
||||||
continue
|
continue
|
||||||
if "IdleSpectator" not in ln:
|
if "IdleSpectator" not in ln:
|
||||||
|
|
@ -105,6 +106,8 @@ for ln in lines:
|
||||||
scenes.append(ln.split("Scene end", 1)[1].strip())
|
scenes.append(ln.split("Scene end", 1)[1].strip())
|
||||||
elif "[STATE]" in ln:
|
elif "[STATE]" in ln:
|
||||||
states.append(ln)
|
states.append(ln)
|
||||||
|
if first_active_line < 0 and re.search(r"idle=True", ln):
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
@ -113,6 +116,7 @@ for d in drops:
|
||||||
drop_kinds[d.split(":", 1)[0].strip()] += 1
|
drop_kinds[d.split(":", 1)[0].strip()] += 1
|
||||||
|
|
||||||
focus_true = focus_false = 0
|
focus_true = focus_false = 0
|
||||||
|
idle_true = idle_false = 0
|
||||||
for s in states:
|
for s in states:
|
||||||
m = re.search(r"focus=(True|False)", s)
|
m = re.search(r"focus=(True|False)", s)
|
||||||
if not m:
|
if not m:
|
||||||
|
|
@ -121,6 +125,12 @@ for s in states:
|
||||||
focus_false += 1
|
focus_false += 1
|
||||||
else:
|
else:
|
||||||
focus_true += 1
|
focus_true += 1
|
||||||
|
idle = re.search(r"idle=(True|False)", s)
|
||||||
|
if idle:
|
||||||
|
if idle.group(1) == "True":
|
||||||
|
idle_true += 1
|
||||||
|
else:
|
||||||
|
idle_false += 1
|
||||||
|
|
||||||
def bucket(t: str) -> str:
|
def bucket(t: str) -> str:
|
||||||
tl = t.lower()
|
tl = t.lower()
|
||||||
|
|
@ -150,6 +160,7 @@ def bucket(t: str) -> str:
|
||||||
return "other"
|
return "other"
|
||||||
|
|
||||||
buckets = Counter(bucket(t) for t in watching)
|
buckets = Counter(bucket(t) for t in watching)
|
||||||
|
bucket_sequence = [bucket(t) for t in watching]
|
||||||
scene_reasons = Counter()
|
scene_reasons = Counter()
|
||||||
for s in scenes:
|
for s in scenes:
|
||||||
m = re.match(r"\(([^)]+)\)", s)
|
m = re.match(r"\(([^)]+)\)", s)
|
||||||
|
|
@ -186,6 +197,9 @@ print()
|
||||||
print(f"Watching cuts: {len(watching)}")
|
print(f"Watching cuts: {len(watching)}")
|
||||||
print(f"Focus BADs: {no_focus_bads} (all BADs={len(bads)})")
|
print(f"Focus BADs: {no_focus_bads} (all BADs={len(bads)})")
|
||||||
print(f"focus True/False: {focus_true}/{focus_false}")
|
print(f"focus True/False: {focus_true}/{focus_false}")
|
||||||
|
print(f"idle True/False: {idle_true}/{idle_false}")
|
||||||
|
if first_active_line >= 0:
|
||||||
|
print(f"first active state: line {first_active_line + 1}/{len(lines)}")
|
||||||
print(f"identity_filter: {identity}")
|
print(f"identity_filter: {identity}")
|
||||||
print(f"unpresentable: {unpresentable}")
|
print(f"unpresentable: {unpresentable}")
|
||||||
print(f"placeholder tips: {placeholder}")
|
print(f"placeholder tips: {placeholder}")
|
||||||
|
|
@ -228,23 +242,52 @@ genesis_intent = buckets.get("decision_genesis_intent", 0)
|
||||||
duel_n = buckets.get("combat_duel", 0)
|
duel_n = buckets.get("combat_duel", 0)
|
||||||
multi_n = buckets.get("combat_multi", 0)
|
multi_n = buckets.get("combat_multi", 0)
|
||||||
combat_total = duel_n + multi_n + buckets.get("combat_other", 0)
|
combat_total = duel_n + multi_n + buckets.get("combat_other", 0)
|
||||||
print(f"Variety: duel={duel_n} multi={multi_n} combat_total={combat_total} genesis_intent={genesis_intent}")
|
combat_share = combat_total / len(watching) if watching else 0.0
|
||||||
|
max_combat_run = 0
|
||||||
|
combat_run = 0
|
||||||
|
for kind in bucket_sequence:
|
||||||
|
if kind.startswith("combat_"):
|
||||||
|
combat_run += 1
|
||||||
|
max_combat_run = max(max_combat_run, combat_run)
|
||||||
|
else:
|
||||||
|
combat_run = 0
|
||||||
|
relationship_n = buckets.get("relationship", 0)
|
||||||
|
unique_tips = len(set(watching))
|
||||||
|
print(
|
||||||
|
f"Variety: duel={duel_n} multi={multi_n} combat_total={combat_total} "
|
||||||
|
f"combat_share={combat_share:.1%} max_combat_run={max_combat_run} "
|
||||||
|
f"relationship={relationship_n} unique_tips={unique_tips}/{len(watching)} "
|
||||||
|
f"genesis_intent={genesis_intent}"
|
||||||
|
)
|
||||||
if genesis_intent:
|
if genesis_intent:
|
||||||
print(" NOTE: decision_genesis_intent tips should be 0 after 0.28.28 (meta outcomes only).")
|
print(" NOTE: decision_genesis_intent tips should be 0 after 0.28.28 (meta outcomes only).")
|
||||||
if combat_total >= 4 and multi_n == 0 and duel_n >= 3:
|
if combat_total >= 4 and multi_n == 0 and duel_n >= 3:
|
||||||
print(" NOTE: combat tips are Duel-heavy; multi scrap may be scarce in this world.")
|
print(" NOTE: combat tips are Duel-heavy; multi scrap may be scarce in this world.")
|
||||||
|
if first_active_line >= 0 and len(lines) > 0 and first_active_line > len(lines) * 0.25:
|
||||||
|
print(" NOTE: Idle Spectator activated late; most of this audit window is not a directed soak.")
|
||||||
|
if len(watching) >= 20 and combat_share > 0.35:
|
||||||
|
print(" NOTE: combat exceeds the story-first release target of 35%.")
|
||||||
|
if max_combat_run > 3:
|
||||||
|
print(" NOTE: more than three consecutive combat cuts; inspect critical bypasses and episode boundaries.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
editorial_pacing_ok = len(watching) < 20 or combat_share <= 0.35
|
||||||
|
sample_valid = bool(watching) and idle_true > 0 and first_active_line >= 0
|
||||||
ok = (
|
ok = (
|
||||||
no_focus_bads == 0
|
sample_valid
|
||||||
|
and no_focus_bads == 0
|
||||||
and focus_false == 0
|
and focus_false == 0
|
||||||
|
and idle_false == 0
|
||||||
and identity == 0
|
and identity == 0
|
||||||
and placeholder == 0
|
and placeholder == 0
|
||||||
and len(veg) == 0
|
and len(veg) == 0
|
||||||
and len(mismatches) == 0
|
and len(mismatches) == 0
|
||||||
and len(sleep_lag) == 0
|
and len(sleep_lag) == 0
|
||||||
and genesis_intent == 0
|
and genesis_intent == 0
|
||||||
|
and editorial_pacing_ok
|
||||||
)
|
)
|
||||||
|
if not sample_valid:
|
||||||
|
print(" NOTE: no valid active spectator sample was captured.")
|
||||||
print("VERDICT:", "PASS" if ok else "REVIEW")
|
print("VERDICT:", "PASS" if ok else "REVIEW")
|
||||||
sys.exit(0 if ok else 1)
|
sys.exit(0 if ok else 1)
|
||||||
PY
|
PY
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue