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 string _lastStatusGainId = "";
|
||||
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 string _lastHappinessId = "";
|
||||
/// <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 string LastStatusGainId => _lastStatusGainId ?? "";
|
||||
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 string LastHappinessId => _lastHappinessId ?? "";
|
||||
public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear;
|
||||
|
|
@ -119,6 +138,8 @@ public static class ActivityLog
|
|||
_statusRefreshesSinceClear = 0;
|
||||
_lastStatusGainId = "";
|
||||
_lastStatusLossId = "";
|
||||
StatusGainHitsSinceReset.Clear();
|
||||
StatusLossHitsSinceReset.Clear();
|
||||
_statusCounterSubjectId = 0;
|
||||
}
|
||||
|
||||
|
|
@ -791,6 +812,8 @@ public static class ActivityLog
|
|||
{
|
||||
_statusGainsSinceClear++;
|
||||
_lastStatusGainId = id;
|
||||
StatusGainHitsSinceReset.TryGetValue(id, out int hits);
|
||||
StatusGainHitsSinceReset[id] = hits + 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
@ -799,6 +822,8 @@ public static class ActivityLog
|
|||
{
|
||||
_statusLossesSinceClear++;
|
||||
_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)
|
||||
{
|
||||
LifeSagaMemory.RecordKill(__instance, pDeadUnit);
|
||||
NarrativeDevelopmentRouter.Kill(__instance, pDeadUnit, "newKillAction");
|
||||
Chronicle.NoteKill(__instance, pDeadUnit);
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ public static class ActorChroniclePatches
|
|||
}
|
||||
|
||||
LifeSagaMemory.RecordDeath(__instance, __state, pType.ToString());
|
||||
NarrativeDevelopmentRouter.Death(__instance, __state, pType.ToString(), "die");
|
||||
Chronicle.NoteDeath(__instance, pType, __state);
|
||||
GraveMarkers.AddDeath(__instance);
|
||||
}
|
||||
|
|
@ -62,6 +64,7 @@ public static class ActorChroniclePatches
|
|||
public static void PostfixLovers(Actor __instance, Actor pTarget)
|
||||
{
|
||||
LifeSagaMemory.RecordBondFormed(__instance, pTarget, "becomeLoversWith");
|
||||
NarrativeDevelopmentRouter.BondFormed(__instance, pTarget, "becomeLoversWith");
|
||||
Chronicle.NoteLovers(__instance, pTarget);
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +78,7 @@ public static class ActorChroniclePatches
|
|||
}
|
||||
|
||||
LifeSagaMemory.RecordFriendFormed(__instance, pActor);
|
||||
NarrativeDevelopmentRouter.FriendFormed(__instance, pActor, "setBestFriend");
|
||||
Chronicle.NoteBestFriends(__instance, pActor);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2884,6 +2884,14 @@ public static class AgentHarness
|
|||
Emit(cmd, ok: true, detail: "variety_cleared");
|
||||
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":
|
||||
StoryPlanner.Clear();
|
||||
_cmdOk++;
|
||||
|
|
@ -2898,6 +2906,326 @@ public static class AgentHarness
|
|||
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":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -3238,6 +3566,69 @@ public static class AgentHarness
|
|||
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":
|
||||
{
|
||||
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
||||
|
|
@ -3403,39 +3794,6 @@ public static class AgentHarness
|
|||
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":
|
||||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
|
|
@ -3594,11 +3952,6 @@ public static class AgentHarness
|
|||
{
|
||||
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
|
||||
|| kindRaw.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
|
|
@ -3660,16 +4013,6 @@ public static class AgentHarness
|
|||
|
||||
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)
|
||||
{
|
||||
if (other == null || !other.isAlive() || other == focus)
|
||||
|
|
@ -4449,6 +4792,7 @@ public static class AgentHarness
|
|||
{
|
||||
LifeSagaRoster.Clear();
|
||||
LifeSagaMemory.Clear();
|
||||
NarrativeStoryStore.Clear();
|
||||
}
|
||||
|
||||
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
|
||||
|
|
@ -4796,6 +5140,15 @@ public static class AgentHarness
|
|||
|
||||
string assetId = cmd.asset;
|
||||
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))
|
||||
{
|
||||
follow = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f, assetId);
|
||||
|
|
@ -8815,6 +9168,37 @@ public static class AgentHarness
|
|||
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)
|
||||
{
|
||||
string expect = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
||||
|
|
@ -10338,6 +10722,19 @@ public static class AgentHarness
|
|||
detail = $"tip='{tip}' contains='{needle}'";
|
||||
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 "setting_enabled":
|
||||
{
|
||||
|
|
@ -12308,10 +12705,14 @@ public static class AgentHarness
|
|||
case "activity_status_gain_count":
|
||||
{
|
||||
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();
|
||||
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;
|
||||
}
|
||||
case "activity_status_loss_count":
|
||||
|
|
@ -12365,15 +12766,28 @@ public static class AgentHarness
|
|||
|
||||
string want = cmd.value ?? "";
|
||||
string got = "";
|
||||
IReadOnlyList<ActivityEntry> list = ActivityLog.LatestForSubject(id, 1);
|
||||
if (list != null && list.Count > 0 && list[0] != null)
|
||||
bool found = false;
|
||||
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)
|
||||
&& got.Equals(want, StringComparison.OrdinalIgnoreCase);
|
||||
detail = $"got='{got}' want='{want}'";
|
||||
&& found;
|
||||
detail = $"latest='{got}' found={found} want='{want}'";
|
||||
break;
|
||||
}
|
||||
case "activity_prose_showcase":
|
||||
|
|
|
|||
|
|
@ -136,6 +136,13 @@ public static class CameraDirector
|
|||
MoveCamera.clearFocusUnitOnly();
|
||||
}
|
||||
|
||||
public static void ClearWatchReason()
|
||||
{
|
||||
LastWatchLabel = "";
|
||||
LastWatchAssetId = "";
|
||||
LastFormattedWatchTip = "Watching";
|
||||
}
|
||||
|
||||
public static void PanTo(Vector3 pos)
|
||||
{
|
||||
PanCamera(pos);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ public static class EventFeedUtil
|
|||
MaxWatch = maxWatch,
|
||||
Completion = completion
|
||||
};
|
||||
NarrativeFunctionCatalog.Stamp(candidate);
|
||||
if (!EventPresentability.WouldShow(candidate))
|
||||
{
|
||||
InterestDropLog.Record("unpresentable", candidate.Label ?? key);
|
||||
|
|
@ -151,6 +152,8 @@ public static class EventFeedUtil
|
|||
candidate.RelatedId = SafeId(candidate.RelatedUnit);
|
||||
}
|
||||
|
||||
NarrativeFunctionCatalog.Stamp(candidate);
|
||||
|
||||
// Harness injects synthetic tip labels for director tests; dossier still blanks them.
|
||||
// Production feeds (Source != harness) must be presentable to enter the registry.
|
||||
if (!IsHarnessSource(candidate.Source) && !EventPresentability.WouldShow(candidate))
|
||||
|
|
|
|||
|
|
@ -134,11 +134,10 @@ public static class InterestFeeds
|
|||
|
||||
float evtSeed = entry.EventStrength;
|
||||
Vector3 position = message.getLocation();
|
||||
Actor unit = null;
|
||||
if (message.hasFollowLocation())
|
||||
{
|
||||
unit = message.unit;
|
||||
}
|
||||
// The message's unit is an explicit event participant even when the WorldLog asset
|
||||
// suppresses vanilla follow-location UI (for example alien invasion). Preserve that
|
||||
// ownership so camera-worthy events do not become anchorless and disappear.
|
||||
Actor unit = message.unit;
|
||||
|
||||
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
||||
string kingdom = message.special1 ?? "";
|
||||
|
|
|
|||
|
|
@ -71,11 +71,13 @@ public static class RelationshipEventPatches
|
|||
if (pActor == null)
|
||||
{
|
||||
LifeSagaMemory.RecordBondBroken(__instance, __state, "setLover(null)");
|
||||
NarrativeDevelopmentRouter.BondBroken(__instance, __state, "setLover(null)");
|
||||
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaMemory.RecordBondFormed(__instance, pActor, "setLover");
|
||||
NarrativeDevelopmentRouter.BondFormed(__instance, pActor, "setLover");
|
||||
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +128,7 @@ public static class RelationshipEventPatches
|
|||
}
|
||||
|
||||
LifeSagaMemory.RecordBondBroken(unit, lover, "dead_lover_cleanup");
|
||||
NarrativeDevelopmentRouter.BondBroken(unit, lover, "dead_lover_cleanup");
|
||||
RelationshipInterestFeed.Emit("clear_lover", unit, null);
|
||||
}
|
||||
catch
|
||||
|
|
@ -168,6 +171,7 @@ public static class RelationshipEventPatches
|
|||
() =>
|
||||
{
|
||||
LifeSagaMemory.RecordParentChild(parent, child);
|
||||
NarrativeDevelopmentRouter.ChildBorn(parent, child, "setParent");
|
||||
RelationshipInterestFeed.Emit("add_child", parent, child);
|
||||
});
|
||||
}
|
||||
|
|
@ -190,6 +194,7 @@ public static class RelationshipEventPatches
|
|||
}
|
||||
|
||||
LifeSagaMemory.RecordFounding(anchor, "family", familyKey, "new_family");
|
||||
NarrativeDevelopmentRouter.Founding(anchor, "family", familyKey, "new_family");
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
|
||||
|
|
@ -450,6 +455,7 @@ public static class RelationshipEventPatches
|
|||
() =>
|
||||
{
|
||||
LifeSagaMemory.RecordRole(pActor, "become_alpha", "family_set_alpha");
|
||||
NarrativeDevelopmentRouter.Role(pActor, "family alpha", true, "family_set_alpha");
|
||||
InterestFeeds.EmitAlpha(pActor, "family_set_alpha");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,30 @@ internal static class HarnessScenarios
|
|||
return StoryBoardParkResume();
|
||||
case "story_cross_arc_correlation":
|
||||
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":
|
||||
return StoryArcPreemptResume();
|
||||
case "story_ledger_revisit":
|
||||
|
|
@ -183,12 +207,12 @@ internal static class HarnessScenarios
|
|||
return SagaCrossMcKill();
|
||||
case "saga_rival_not_single_kill":
|
||||
return SagaRivalNotSingleKill();
|
||||
case "saga_legacy_combat_summary":
|
||||
return SagaLegacyCombatSummary();
|
||||
case "saga_rival_rematch":
|
||||
return SagaRivalRematch();
|
||||
case "saga_prefer_cuts_ambient":
|
||||
return SagaPreferCutsAmbient();
|
||||
case "saga_hard_arc_escalates":
|
||||
return SagaHardArcEscalates();
|
||||
case "saga_overview_has_mc":
|
||||
return SagaOverviewHasMc();
|
||||
case "saga_snapshot_popover":
|
||||
|
|
@ -420,6 +444,14 @@ internal static class HarnessScenarios
|
|||
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
|
||||
Nested("reg_story_board_park", "story_board_park_resume"),
|
||||
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_saga_relation_revision", "saga_relation_cache_revision"),
|
||||
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_rematch", "saga_rival_rematch"),
|
||||
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_snapshot", "saga_adaptive_dossier"),
|
||||
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_stake_role", "saga_stake_from_role_live"),
|
||||
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_hover_pause", "saga_hover_read_pause"),
|
||||
Nested("reg_saga_diversity", "saga_roster_diversity"),
|
||||
|
|
@ -633,14 +665,14 @@ internal static class HarnessScenarios
|
|||
|
||||
// Gain path (confused is widely applicable across assets)
|
||||
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("st15", "assert", expect: "activity_status_task_id", value: "status_gain:confused"),
|
||||
Step("st16", "assert", expect: "activity_status_active", value: "confused", label: "true"),
|
||||
|
||||
// Refresh must not create another gain line
|
||||
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"),
|
||||
|
||||
// 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("st28d", "fast_timing", value: "true"),
|
||||
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("st32", "assert", expect: "activity_status_active", value: "surprised", label: "false"),
|
||||
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>
|
||||
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
|
||||
/// </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>
|
||||
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>
|
||||
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
|
||||
/// 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()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
@ -3890,8 +4020,6 @@ internal static class HarnessScenarios
|
|||
Step("srl8", "pick_unit", asset: "human"),
|
||||
Step("srl9", "focus", asset: "human"),
|
||||
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("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
|
||||
Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"),
|
||||
|
|
@ -4177,7 +4305,7 @@ internal static class HarnessScenarios
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended roles: singleton dragon overview + hard-arc nobody admit path.
|
||||
/// Extended roles: singleton dragon overview + explicit ordinary-character admission.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> SagaAdmitRoles()
|
||||
{
|
||||
|
|
@ -4199,10 +4327,10 @@ internal static class HarnessScenarios
|
|||
Step("sar12", "assert", expect: "saga_has_focus"),
|
||||
Step("sar13", "saga_rail_refresh"),
|
||||
Step("sar14", "assert", expect: "saga_role_tip", value: "Lone|Last of their kind|Dragon"),
|
||||
// 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("sar21", "focus", asset: "human"),
|
||||
Step("sar22", "saga_consider_hard_focus"),
|
||||
Step("sar22", "saga_force_admit_focus"),
|
||||
Step("sar23", "assert", expect: "saga_has_focus"),
|
||||
Step("sar24", "assert", expect: "saga_roster_count", value: "2", label: "min"),
|
||||
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()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
@ -4258,10 +4386,10 @@ internal static class HarnessScenarios
|
|||
Step("srw46", "saga_force_admit_focus"),
|
||||
Step("srw50", "assert", expect: "saga_roster_count", value: "10"),
|
||||
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("srw61", "focus", asset: "human"),
|
||||
Step("srw62", "saga_consider_hard_focus"),
|
||||
Step("srw62", "saga_force_admit_focus"),
|
||||
Step("srw63", "assert", expect: "saga_has_focus"),
|
||||
Step("srw64", "assert", expect: "saga_roster_count", value: "10"),
|
||||
// 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("wl11", "age_current", wait: 1.2f),
|
||||
Step("wl12", "director_run", wait: 1.2f),
|
||||
Step("wl13", "assert", expect: "tip_contains", value: "kingdom_new"),
|
||||
Step("wl14", "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_or_story_hold", value: "kingdom_new"),
|
||||
|
||||
Step("wl20", "trigger_interest", asset: "auto", label: "Epic: diplomacy_war_started", tier: "Epic"),
|
||||
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("act5", "chronicle_clear"),
|
||||
Step("act6", "activity_clear"),
|
||||
Step("act7", "pick_unit", asset: "auto"),
|
||||
Step("act7", "spawn", asset: "human"),
|
||||
Step("act8", "spectator", value: "off"),
|
||||
Step("act9", "spectator", value: "on"),
|
||||
Step("act10", "focus", asset: "auto"),
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ public sealed class InterestCandidate
|
|||
public float Novelty = 1f;
|
||||
public float VisualConfidence = 0.5f;
|
||||
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>
|
||||
public int ParticipantCount;
|
||||
/// <summary>Sticky multi-actor scoreboard (combat first; war/plot/family later).</summary>
|
||||
|
|
@ -316,6 +320,9 @@ public sealed class InterestCandidate
|
|||
Novelty = Novelty,
|
||||
VisualConfidence = VisualConfidence,
|
||||
TotalScore = TotalScore,
|
||||
NarrativeFunction = NarrativeFunction,
|
||||
HasNarrativeFunction = HasNarrativeFunction,
|
||||
NarrativeFunctionSource = NarrativeFunctionSource,
|
||||
ParticipantCount = ParticipantCount,
|
||||
NotableParticipantCount = NotableParticipantCount,
|
||||
Position = Position,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@ public static partial class InterestDirector
|
|||
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)
|
||||
{
|
||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
||||
|
|
@ -88,6 +103,29 @@ public static partial class InterestDirector
|
|||
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;
|
||||
if (inGrace)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -654,6 +654,7 @@ public static partial class InterestDirector
|
|||
StoryPlanner.Tick(now);
|
||||
LifeSagaRoster.Tick(now);
|
||||
LifeSagaSession.EnsureBound();
|
||||
StoryScheduler.Tick(now);
|
||||
IdleHitchProbe.Mark("story_done");
|
||||
|
||||
if (ReadPauseActive)
|
||||
|
|
@ -1950,6 +1951,7 @@ public static partial class InterestDirector
|
|||
_current = null;
|
||||
_harnessCombatForcedCold = false;
|
||||
_inactiveSince = -999f;
|
||||
CameraDirector.ClearWatchReason();
|
||||
EnsureIdleFocus(now);
|
||||
}
|
||||
|
||||
|
|
@ -2045,6 +2047,18 @@ public static partial class InterestDirector
|
|||
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.
|
||||
return InterestVariety.Pick(PendingScratch, _current);
|
||||
}
|
||||
|
|
@ -2718,6 +2732,7 @@ public static partial class InterestDirector
|
|||
|
||||
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
||||
{
|
||||
StoryScheduler.NoteActualSelection(next);
|
||||
if (next == null)
|
||||
{
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -159,7 +159,6 @@ public static class InterestScoring
|
|||
|
||||
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
|
||||
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
|
||||
float causalBonus = CausalHeat.ScoreBonus(c);
|
||||
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
|
||||
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
|
||||
c.TotalScore = c.EventStrength + scaleBonus
|
||||
|
|
@ -168,7 +167,6 @@ public static class InterestScoring
|
|||
+ c.Novelty * w.noveltyMultiplier
|
||||
- repeatPenalty
|
||||
+ frequencyAdjust
|
||||
+ causalBonus
|
||||
+ sagaBonus
|
||||
+ ownershipBonus;
|
||||
string detail =
|
||||
|
|
@ -185,11 +183,6 @@ public static class InterestScoring
|
|||
+ frequencyAdjust.ToString("0.#");
|
||||
}
|
||||
|
||||
if (causalBonus > 0.05f)
|
||||
{
|
||||
detail += $" causal=+{causalBonus:0.#}";
|
||||
}
|
||||
|
||||
if (sagaBonus > 0.05f)
|
||||
{
|
||||
detail += $" saga=+{sagaBonus:0.#}";
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public static class InterestVariety
|
|||
public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds;
|
||||
public static float 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<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 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>
|
||||
/// 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.
|
||||
|
|
@ -637,6 +711,12 @@ public static class InterestVariety
|
|||
continue;
|
||||
}
|
||||
|
||||
if (IsEditorialReplayCooled(c, now))
|
||||
{
|
||||
InterestDropLog.Record("editorial_replay", c.Label ?? c.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.LeadKind == InterestLeadKind.CharacterLed)
|
||||
{
|
||||
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>
|
||||
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
|
||||
/// </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;
|
||||
|
||||
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)
|
||||
{
|
||||
// Banner owns orange row; composer still fills Identity + Context.
|
||||
|
|
@ -87,7 +105,9 @@ public static class CaptionComposer
|
|||
}
|
||||
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)
|
||||
{
|
||||
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
|
||||
|
|
@ -101,6 +121,10 @@ public static class CaptionComposer
|
|||
model.IdentityLine,
|
||||
spineLabel,
|
||||
isReentry && !statusBannerOwnsBeat);
|
||||
if (!string.IsNullOrEmpty(narrativeContext))
|
||||
{
|
||||
model.ContextLine = narrativeContext;
|
||||
}
|
||||
|
||||
model.Fingerprint =
|
||||
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.###"),
|
||||
strength: 70f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.BondFormed(a, b, provenance);
|
||||
}
|
||||
|
||||
public static void RecordBondBroken(Actor survivor, Actor former, string provenance = "clear_lover")
|
||||
|
|
@ -342,6 +343,7 @@ public static class LifeSagaMemory
|
|||
strength: 75f,
|
||||
provenance: provenance,
|
||||
resolved: true);
|
||||
NarrativeDevelopmentRouter.BondBroken(survivor, former, provenance);
|
||||
}
|
||||
|
||||
public static void RecordParentChild(Actor parent, Actor child, string provenance = "add_child")
|
||||
|
|
@ -360,6 +362,7 @@ public static class LifeSagaMemory
|
|||
correlationKey: "parent:" + parentId + ":" + childId,
|
||||
strength: 65f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.ChildBorn(parent, child, provenance);
|
||||
}
|
||||
|
||||
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)),
|
||||
strength: 55f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.FriendFormed(a, b, provenance);
|
||||
}
|
||||
|
||||
public static void RecordKill(Actor killer, Actor victim, string provenance = "newKillAction")
|
||||
|
|
@ -395,6 +399,7 @@ public static class LifeSagaMemory
|
|||
+ Time.unscaledTime.ToString("0.###"),
|
||||
strength: 80f,
|
||||
provenance: provenance);
|
||||
NarrativeDevelopmentRouter.Kill(killer, victim, provenance);
|
||||
|
||||
// Kill/death must not increment living rematch counters (single kill ≠ rival).
|
||||
TryMarkKinKillRival(killer, victim);
|
||||
|
|
@ -422,6 +427,7 @@ public static class LifeSagaMemory
|
|||
provenance: provenance,
|
||||
resolved: true,
|
||||
note: attackType ?? "");
|
||||
NarrativeDevelopmentRouter.Death(victim, killer, attackType, provenance);
|
||||
|
||||
if (killer != null)
|
||||
{
|
||||
|
|
@ -644,6 +650,7 @@ public static class LifeSagaMemory
|
|||
warKey: warKey,
|
||||
resolved: ended,
|
||||
note: note ?? "");
|
||||
NarrativeDevelopmentRouter.War(subject, other, warKey, ended, provenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -738,6 +745,7 @@ public static class LifeSagaMemory
|
|||
plotKey: plotKey,
|
||||
note: display,
|
||||
resolved: finished);
|
||||
NarrativeDevelopmentRouter.Plot(subject, other, plotKey, finished, provenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -977,6 +985,7 @@ public static class LifeSagaMemory
|
|||
familyKey: metaKind == "family" ? metaKey : "",
|
||||
clanKey: metaKind == "clan" ? metaKey : "",
|
||||
note: metaKind ?? "");
|
||||
NarrativeDevelopmentRouter.Founding(subject, metaKind, metaKey, provenance);
|
||||
}
|
||||
|
||||
public static void RecordRole(Actor subject, string roleId, string provenance = "happiness")
|
||||
|
|
@ -994,6 +1003,7 @@ public static class LifeSagaMemory
|
|||
strength: 68f,
|
||||
provenance: provenance,
|
||||
note: roleId);
|
||||
NarrativeDevelopmentRouter.Role(subject, roleId, true, provenance);
|
||||
}
|
||||
|
||||
public static void RecordHome(Actor subject, bool gained, string provenance = "happiness")
|
||||
|
|
@ -1013,33 +1023,6 @@ public static class LifeSagaMemory
|
|||
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)
|
||||
{
|
||||
if (actor == null)
|
||||
|
|
@ -1786,13 +1769,11 @@ public static class LifeSagaMemory
|
|||
{
|
||||
if (a != 0)
|
||||
{
|
||||
CausalHeat.NoteFeatured(a, 1.15f);
|
||||
LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
|
||||
}
|
||||
|
||||
if (b != 0 && b != a)
|
||||
{
|
||||
CausalHeat.NoteFeatured(b, 1.15f);
|
||||
LifeSagaRoster.NoteFeaturedUnit(b, 1.2f);
|
||||
}
|
||||
}
|
||||
|
|
@ -1859,26 +1840,6 @@ public static class LifeSagaMemory
|
|||
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)
|
||||
{
|
||||
switch (kind)
|
||||
|
|
|
|||
|
|
@ -650,14 +650,103 @@ public static class LifeSagaPresentation
|
|||
|
||||
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
|
||||
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++)
|
||||
{
|
||||
if (model.Legacy.Count >= factLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
LifeSagaFact f = facts[i];
|
||||
if (f == null)
|
||||
{
|
||||
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.
|
||||
if (stake != null
|
||||
&& ((!string.IsNullOrEmpty(stake.Key)
|
||||
|
|
@ -725,6 +814,7 @@ public static class LifeSagaPresentation
|
|||
}
|
||||
|
||||
if (!wroteParentChild
|
||||
&& !narrativeOwnsFamily
|
||||
&& parentedCount >= 2
|
||||
&& 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>
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ public static class LifeSagaRoster
|
|||
private static readonly Dictionary<string, int> SpeciesCountCache = new Dictionary<string, int>();
|
||||
private static readonly HashSet<long> McOrbitCache = new HashSet<long>();
|
||||
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 _nextNarrativeAdmissionAt;
|
||||
private static float _roleCacheAt = -999f;
|
||||
private static bool _dirty = true;
|
||||
private static WorldScanPhase _scanPhase = WorldScanPhase.None;
|
||||
|
|
@ -77,7 +80,9 @@ public static class LifeSagaRoster
|
|||
PlotAuthorCache.Clear();
|
||||
SpeciesCountCache.Clear();
|
||||
McOrbitCache.Clear();
|
||||
NarrativeChallengerScratch.Clear();
|
||||
_nextScanAt = 0f;
|
||||
_nextNarrativeAdmissionAt = 0f;
|
||||
_roleCacheAt = -999f;
|
||||
_scanPhase = WorldScanPhase.None;
|
||||
_scanIndex = 0;
|
||||
|
|
@ -699,156 +704,16 @@ public static class LifeSagaRoster
|
|||
s.TouchedAt = now;
|
||||
s.Heat = Mathf.Min(HeatCap, s.Heat + 1.5f);
|
||||
_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)
|
||||
{
|
||||
if (now >= _nextNarrativeAdmissionAt)
|
||||
{
|
||||
_nextNarrativeAdmissionAt = now + 1f;
|
||||
ConsiderEmergingStories(now);
|
||||
}
|
||||
|
||||
if (_scanPhase != WorldScanPhase.None)
|
||||
{
|
||||
ContinueAmortizedWorldScan(now);
|
||||
|
|
@ -1086,9 +951,13 @@ public static class LifeSagaRoster
|
|||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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)
|
||||
{
|
||||
|
|
@ -1117,7 +986,7 @@ public static class LifeSagaRoster
|
|||
|
||||
EnsureRoleCache(now, force: false);
|
||||
bool admission = IsAdmissionCandidate(actor);
|
||||
if (!admission && !hardArcAdmit)
|
||||
if (!admission && !emergingStoryAdmit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1125,7 +994,8 @@ public static class LifeSagaRoster
|
|||
LifeSagaSlot neu = BuildSlot(
|
||||
actor,
|
||||
now,
|
||||
hardArcAdmit && !admission ? LifeSagaAdmissionReason.HardArc : LifeSagaAdmissionReason.None);
|
||||
emergingStoryAdmit && !admission ? LifeSagaAdmissionReason.EmergingStory
|
||||
: LifeSagaAdmissionReason.None);
|
||||
neu.Heat = Mathf.Min(HeatCap, Mathf.Max(0f, heat));
|
||||
Scratch.Clear();
|
||||
PreviousIds.Clear();
|
||||
|
|
@ -1150,6 +1020,22 @@ public static class LifeSagaRoster
|
|||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
score += 50f;
|
||||
|
|
@ -2400,19 +2287,6 @@ public static class LifeSagaRoster
|
|||
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)
|
||||
{
|
||||
float score = 1f;
|
||||
|
|
@ -2482,34 +2356,6 @@ public static class LifeSagaRoster
|
|||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line))
|
||||
|
|
@ -2620,8 +2466,6 @@ public sealed class LifeSagaSlot
|
|||
public float TouchedAt;
|
||||
public float LastChapterAt;
|
||||
public LifeSagaAdmissionReason AdmissionReason;
|
||||
public StoryArcKind AdmissionArcKind;
|
||||
public string AdmissionContext = "";
|
||||
public readonly List<LifeSagaChapter> Chapters =
|
||||
new List<LifeSagaChapter>(LifeSagaRoster.MaxChapters);
|
||||
|
||||
|
|
@ -2650,7 +2494,7 @@ public enum LifeSagaAdmissionReason
|
|||
NotableKills,
|
||||
NotableRenown,
|
||||
NotableLife,
|
||||
HardArc
|
||||
EmergingStory
|
||||
}
|
||||
|
||||
public enum LifeSagaChapterKind
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public static class LifeSagaSession
|
|||
LifeSagaViewController.Clear();
|
||||
LifeSagaRail.Clear();
|
||||
LifeSagaPanel.Clear();
|
||||
NarrativeStoryStore.Clear();
|
||||
_epoch++;
|
||||
|
||||
object world = SafeWorld();
|
||||
|
|
|
|||
|
|
@ -712,6 +712,8 @@ public static class SagaProse
|
|||
string c = FirstNonEmpty(slot?.CityKey, CityName(actor));
|
||||
return string.IsNullOrEmpty(c) ? Sentence("Leads a city") : Sentence("Leads " + c);
|
||||
}
|
||||
case LifeSagaAdmissionReason.EmergingStory:
|
||||
return Sentence("A life in motion");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,12 +211,6 @@ public static class StoryPlanner
|
|||
_active.HardHold = hardHold || _active.HardHold;
|
||||
NoteCastFrom(current, _active);
|
||||
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) =>
|
||||
|
|
@ -355,7 +349,6 @@ public static class StoryPlanner
|
|||
_coldHookFired = false;
|
||||
_coldHookClimaxKey = "";
|
||||
_coldHookAnchor = "";
|
||||
CausalHeat.Clear();
|
||||
// LifeSagaRoster survives short-arc Clear (browse / brief idle-off).
|
||||
}
|
||||
|
||||
|
|
@ -752,12 +745,6 @@ public static class StoryPlanner
|
|||
return;
|
||||
}
|
||||
|
||||
CausalHeat.NoteFeatured(next.SubjectId);
|
||||
if (next.RelatedId != 0)
|
||||
{
|
||||
CausalHeat.NoteFeatured(next.RelatedId, 0.6f);
|
||||
}
|
||||
|
||||
LifeSagaRoster.NoteFeatured(next);
|
||||
|
||||
if (IsStoryAssetCandidate(next))
|
||||
|
|
@ -797,7 +784,6 @@ public static class StoryPlanner
|
|||
}
|
||||
|
||||
NoteCastFrom(next, _active);
|
||||
LifeSagaRoster.StampChapterFromArc(_active, next, now);
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
@ -808,11 +794,6 @@ public static class StoryPlanner
|
|||
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
|
||||
{
|
||||
BeginOrRefreshArc(next, kind, hardHold, now);
|
||||
if (_active != null && _active.IsActive)
|
||||
{
|
||||
LifeSagaRoster.StampChapterFromArc(_active, next, now);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1420,7 +1401,6 @@ public static class StoryPlanner
|
|||
_coldHookFired = false;
|
||||
_coldHookClimaxKey = "";
|
||||
_coldHookAnchor = "";
|
||||
CausalHeat.NoteCast(_active.CastIds);
|
||||
TrimParkedBoard();
|
||||
}
|
||||
|
||||
|
|
@ -2183,7 +2163,6 @@ public static class StoryPlanner
|
|||
MaxWatch = registered.MaxWatch,
|
||||
SynthesizeIfMissing = false
|
||||
});
|
||||
CausalHeat.NoteFeatured(followId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -2194,139 +2173,9 @@ public static class StoryPlanner
|
|||
return;
|
||||
}
|
||||
|
||||
// Crisis chapter owns the closer - do not also fire a related soft epilogue.
|
||||
if (_crisis != null && _crisis.IsLive)
|
||||
{
|
||||
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);
|
||||
// Narrative threads carry consequences forward. The old synthetic related-character
|
||||
// closer duplicated that responsibility and generated generic unsupported epilogues.
|
||||
arc.Advance(StoryPhase.Done, now);
|
||||
}
|
||||
|
||||
private static Actor ResolveAftermathFollow(InterestCandidate climax)
|
||||
|
|
@ -3134,10 +2983,6 @@ public static class StoryPlanner
|
|||
EpilogueInjected = false
|
||||
};
|
||||
StampCrisisTheater(_crisis, InterestDirector.CurrentCandidate);
|
||||
if (followId != 0)
|
||||
{
|
||||
CausalHeat.NoteFeatured(followId, 1.1f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void StampCrisisTheater(CrisisChapter crisis, InterestCandidate tip)
|
||||
|
|
@ -3474,7 +3319,6 @@ public static class StoryPlanner
|
|||
}
|
||||
|
||||
crisis.FollowId = followId;
|
||||
CausalHeat.NoteFeatured(followId, 1.2f);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1122,12 +1122,6 @@ public static class WorldActivityScanner
|
|||
score += Mathf.Max(5f, InterestScoringConfig.Story.sagaMcWeight * 0.7f);
|
||||
}
|
||||
|
||||
float causal = CausalHeat.Get(id);
|
||||
if (causal > 0f)
|
||||
{
|
||||
score += causal * 2f;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -10,9 +10,11 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
|
|||
|
||||
## What it follows
|
||||
|
||||
The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …).
|
||||
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
|
||||
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
|
||||
The camera follows the developing lives and relationships of a small emerging cast. A story
|
||||
scheduler chooses developments that advance an active character thread, yields to critical world
|
||||
events, and returns only when the story has new evidence. `EventStrength` still models urgency and
|
||||
visual value, but it no longer owns continuity. Bounded character/world fill runs when no episode
|
||||
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).
|
||||
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
|
||||
- `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
|
||||
|
||||
|
|
|
|||
|
|
@ -11,13 +11,15 @@ Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner`
|
|||
|
||||
Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only.
|
||||
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).
|
||||
|
||||
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`).
|
||||
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:
|
||||
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
|
||||
- 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.
|
||||
- Prefer and WorldBox favorites are hard pins and fully count toward Cap.
|
||||
- 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).
|
||||
- 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.
|
||||
- Ordinary non-notables challenge the Cap through decaying story potential earned from confirmed
|
||||
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.
|
||||
- Newcomers need a small challenger margin over the weakest non-pin so the rail does not thrash.
|
||||
|
||||
## Camera (soft)
|
||||
|
||||
- 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).
|
||||
- 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.
|
||||
|
|
@ -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 |
|
||||
|
||||
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).
|
||||
Status banners still own/suppress Beat; Identity + Context keep updating.
|
||||
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
|
||||
|
||||
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
|
||||
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
|
||||
- `IdleSpectator/Narrative/StoryScheduler.cs` - narrative episode and camera-story ownership
|
||||
- `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/CaptionComposer.cs` - Identity + Beat + Context
|
||||
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover
|
||||
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
|
||||
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
|
||||
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
|
||||
- `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine
|
||||
|
|
|
|||
|
|
@ -82,9 +82,10 @@ drops = []
|
|||
bads = []
|
||||
scenes = []
|
||||
states = []
|
||||
first_active_line = -1
|
||||
last = None
|
||||
|
||||
for ln in lines:
|
||||
for line_index, ln in enumerate(lines):
|
||||
if harness_noise(ln):
|
||||
continue
|
||||
if "IdleSpectator" not in ln:
|
||||
|
|
@ -105,6 +106,8 @@ for ln in lines:
|
|||
scenes.append(ln.split("Scene end", 1)[1].strip())
|
||||
elif "[STATE]" in 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:
|
||||
last["unit"] = ln.split("unit=", 1)[1].strip()
|
||||
|
||||
|
|
@ -113,6 +116,7 @@ for d in drops:
|
|||
drop_kinds[d.split(":", 1)[0].strip()] += 1
|
||||
|
||||
focus_true = focus_false = 0
|
||||
idle_true = idle_false = 0
|
||||
for s in states:
|
||||
m = re.search(r"focus=(True|False)", s)
|
||||
if not m:
|
||||
|
|
@ -121,6 +125,12 @@ for s in states:
|
|||
focus_false += 1
|
||||
else:
|
||||
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:
|
||||
tl = t.lower()
|
||||
|
|
@ -150,6 +160,7 @@ def bucket(t: str) -> str:
|
|||
return "other"
|
||||
|
||||
buckets = Counter(bucket(t) for t in watching)
|
||||
bucket_sequence = [bucket(t) for t in watching]
|
||||
scene_reasons = Counter()
|
||||
for s in scenes:
|
||||
m = re.match(r"\(([^)]+)\)", s)
|
||||
|
|
@ -186,6 +197,9 @@ print()
|
|||
print(f"Watching cuts: {len(watching)}")
|
||||
print(f"Focus BADs: {no_focus_bads} (all BADs={len(bads)})")
|
||||
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"unpresentable: {unpresentable}")
|
||||
print(f"placeholder tips: {placeholder}")
|
||||
|
|
@ -228,23 +242,52 @@ genesis_intent = buckets.get("decision_genesis_intent", 0)
|
|||
duel_n = buckets.get("combat_duel", 0)
|
||||
multi_n = buckets.get("combat_multi", 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:
|
||||
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:
|
||||
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()
|
||||
|
||||
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 = (
|
||||
no_focus_bads == 0
|
||||
sample_valid
|
||||
and no_focus_bads == 0
|
||||
and focus_false == 0
|
||||
and idle_false == 0
|
||||
and identity == 0
|
||||
and placeholder == 0
|
||||
and len(veg) == 0
|
||||
and len(mismatches) == 0
|
||||
and len(sleep_lag) == 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")
|
||||
sys.exit(0 if ok else 1)
|
||||
PY
|
||||
|
|
|
|||
Loading…
Reference in a new issue