fix(spectator): cool soft life crumbs and keep aftermath victim truth
- Soft-cool kill, courtship, intent, construction, status fx, hatch, rest, and worldlog meta - Block cooled tips from resuming after a cut-in - Name only dead theater partners in stands-over aftermath - Add harness gates through 0.28.54
This commit is contained in:
parent
7e02356b01
commit
9a059c17dd
19 changed files with 3765 additions and 125 deletions
|
|
@ -980,6 +980,53 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "decision_emit":
|
||||
{
|
||||
// Live DeferredInterestFeed path (same as setDecisionCooldown patch).
|
||||
Actor focus = ResolveUnit(
|
||||
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
||||
if (focus == null)
|
||||
{
|
||||
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
}
|
||||
|
||||
string decisionId = (cmd.expect ?? cmd.value ?? "sexual_reproduction_try").Trim();
|
||||
bool alive = false;
|
||||
try
|
||||
{
|
||||
alive = focus != null && focus.isAlive();
|
||||
}
|
||||
catch
|
||||
{
|
||||
alive = false;
|
||||
}
|
||||
|
||||
if (!alive || string.IsNullOrEmpty(decisionId))
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no unit or decision id");
|
||||
break;
|
||||
}
|
||||
|
||||
bool prevForce = ForceLiveEventFeeds;
|
||||
ForceLiveEventFeeds = true;
|
||||
try
|
||||
{
|
||||
DeferredInterestFeed.EmitDecision(decisionId, focus);
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"emitted decision={decisionId} unit={SafeName(focus)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ForceLiveEventFeeds = prevForce;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "status_clear_all":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -2714,6 +2761,10 @@ public static class AgentHarness
|
|||
DoCombatWireAttackSides(cmd);
|
||||
break;
|
||||
|
||||
case "combat_wipe_sticky_side":
|
||||
DoCombatWipeStickySide(cmd);
|
||||
break;
|
||||
|
||||
case "combat_swap_attack_target":
|
||||
DoCombatSwapAttackTarget(cmd);
|
||||
break;
|
||||
|
|
@ -2726,12 +2777,22 @@ public static class AgentHarness
|
|||
DoCombatParkBystanderFollow(cmd);
|
||||
break;
|
||||
|
||||
case "interest_hijack_follow":
|
||||
DoInterestHijackFollow(cmd);
|
||||
break;
|
||||
|
||||
case "interest_variety_clear":
|
||||
InterestVariety.Clear();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: "variety_cleared");
|
||||
break;
|
||||
|
||||
case "interest_story_clear":
|
||||
StoryPlanner.Clear();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: "story_cleared");
|
||||
break;
|
||||
|
||||
case "interest_variety_note":
|
||||
{
|
||||
// Stamp the current (or pending needle in value) as the last shown variety arc.
|
||||
|
|
@ -2941,6 +3002,31 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "interest_drop_follow":
|
||||
{
|
||||
// Null Follow only - keep SubjectId/Related for story cast recovery tests.
|
||||
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||
if (scene == null)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no_scene");
|
||||
break;
|
||||
}
|
||||
|
||||
long keepSubject = scene.SubjectId != 0
|
||||
? scene.SubjectId
|
||||
: EventFeedUtil.SafeId(scene.FollowUnit);
|
||||
scene.FollowUnit = null;
|
||||
if (scene.SubjectId == 0 && keepSubject != 0)
|
||||
{
|
||||
scene.SubjectId = keepSubject;
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"dropped_follow subjectId={scene.SubjectId} relatedId={scene.RelatedId}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "interest_clear_follow":
|
||||
{
|
||||
Actor related = ResolveUnit(string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
||||
|
|
@ -4157,13 +4243,7 @@ public static class AgentHarness
|
|||
InterestScoring.RecalcTotal(c);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(cmd.expect)
|
||||
&& (cmd.expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| cmd.expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
c.AssetId = "set_lover";
|
||||
c.Category = "Relationship";
|
||||
}
|
||||
ApplyHarnessRelationshipAssetHint(c, cmd.expect);
|
||||
|
||||
if (force)
|
||||
{
|
||||
|
|
@ -4177,6 +4257,163 @@ public static class AgentHarness
|
|||
detail: $"injected key={c.Key} score={c.TotalScore:0.#} lead={c.LeadKind} evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={c.ParticipantCount}/{c.NotableParticipantCount} sides={c.CombatSideACount}/{c.CombatSideBCount} pending={InterestRegistry.PendingCount}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map harness expect tokens onto relationship AssetIds used by StoryPlanner classification.
|
||||
/// </summary>
|
||||
private static void ApplyHarnessRelationshipAssetHint(InterestCandidate c, string expect)
|
||||
{
|
||||
if (c == null || string.IsNullOrEmpty(expect))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("just_had_child", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("parenthood", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.HappinessEffectId = "just_had_child";
|
||||
c.AssetId = "just_had_child";
|
||||
c.Category = "LifeChapter";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("just_kissed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("intimacy", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.HappinessEffectId = "just_kissed";
|
||||
c.AssetId = "just_kissed";
|
||||
c.Category = "Social";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("just_found_house", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("found_house", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("finds_a_home", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.HappinessEffectId = "just_found_house";
|
||||
c.AssetId = "just_found_house";
|
||||
c.Category = "LifeChapter";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("milestone_kill", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("just_killed", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.AssetId = "milestone_kill";
|
||||
c.HappinessEffectId = "just_killed";
|
||||
c.Category = "Relationship";
|
||||
c.Source = "chronicle";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("fallen_in_love", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("smitten", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.HappinessEffectId = "fallen_in_love";
|
||||
c.AssetId = "fallen_in_love";
|
||||
c.Category = "Social";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("sexual_reproduction", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("decision_intent", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.AssetId = "sexual_reproduction_try";
|
||||
c.Source = "decision";
|
||||
c.Category = "Politics";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("find_lover", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
// Decision path when tagged; relationship seeking otherwise.
|
||||
bool asDecision = expect.IndexOf("decision", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
c.AssetId = "find_lover";
|
||||
c.HappinessEffectId = asDecision ? "" : "find_lover";
|
||||
c.Source = asDecision ? "decision" : "relationship";
|
||||
c.Category = asDecision ? "Politics" : "Relationship";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("building_complete", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("construction", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.AssetId = expect.IndexOf("tent_", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
? "tent_human"
|
||||
: "building_complete";
|
||||
c.Source = "building";
|
||||
c.Category = "Settlement";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("taking_roots", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("soft_status", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.StatusId = "taking_roots";
|
||||
c.AssetId = "taking_roots";
|
||||
c.Completion = InterestCompletionKind.StatusPhase;
|
||||
c.Category = "Status";
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("just_slept", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("rest_life", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("ends a rest", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.HappinessEffectId = "just_slept";
|
||||
c.AssetId = "just_slept";
|
||||
c.Source = "happiness";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
c.Category = "Daily";
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("sleeping", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("falls asleep", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.StatusId = "sleeping";
|
||||
c.AssetId = "sleeping";
|
||||
c.Source = "status";
|
||||
c.Completion = InterestCompletionKind.StatusPhase;
|
||||
c.Category = "Status";
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("hatch", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.HappinessEffectId = "just_got_out_of_egg";
|
||||
c.AssetId = "just_got_out_of_egg";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("king_dead", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("worldlog_meta", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.AssetId = "king_dead";
|
||||
c.Source = "worldlog";
|
||||
c.Category = "Politics";
|
||||
c.Completion = InterestCompletionKind.FixedDwell;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.IndexOf("set_lover", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| expect.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
c.AssetId = "set_lover";
|
||||
c.Category = "Relationship";
|
||||
}
|
||||
}
|
||||
|
||||
private static Actor FindOtherAliveUnit(Actor exclude, string preferAsset)
|
||||
{
|
||||
if (World.world?.units == null)
|
||||
|
|
@ -4435,6 +4672,8 @@ public static class AgentHarness
|
|||
c.Category = "CharacterVignette";
|
||||
InterestScoring.ScoreCheap(c);
|
||||
}
|
||||
|
||||
ApplyHarnessRelationshipAssetHint(c, cmd.expect);
|
||||
bool ok = InterestDirector.HarnessForceSession(c);
|
||||
if (ok)
|
||||
{
|
||||
|
|
@ -5828,6 +6067,96 @@ public static class AgentHarness
|
|||
detail: $"n={want} scale={ensemble.Scale} frame={ensemble.Frame} tip='{CameraDirector.LastWatchLabel}' focus={SafeName(focus)} participants={InterestDirector.CurrentCandidate?.ParticipantCount}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point camera Follow at another living unit without rewriting SubjectId / Label.
|
||||
/// Proves life-tip reason principals reject bystander dossiers under a named tip.
|
||||
/// asset = optional species for the bystander (default human).
|
||||
/// </summary>
|
||||
private static void DoInterestHijackFollow(HarnessCommand cmd)
|
||||
{
|
||||
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||
if (scene == null)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no_scene");
|
||||
return;
|
||||
}
|
||||
|
||||
long subjectId = scene.SubjectId != 0
|
||||
? scene.SubjectId
|
||||
: EventFeedUtil.SafeId(scene.FollowUnit);
|
||||
Actor bystander = null;
|
||||
string wantAsset = string.IsNullOrEmpty(cmd.asset) ? "human" : cmd.asset.Trim();
|
||||
foreach (Actor actor in WorldActivityScanner.EnumerateAliveUnitsPublic())
|
||||
{
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
if (id == 0 || id == subjectId || id == scene.RelatedId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string species = actor.asset != null ? actor.asset.id : "";
|
||||
if (!string.IsNullOrEmpty(wantAsset)
|
||||
&& !species.Equals(wantAsset, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (bystander == null)
|
||||
{
|
||||
bystander = actor;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
bystander = actor;
|
||||
break;
|
||||
}
|
||||
|
||||
if (bystander == null)
|
||||
{
|
||||
bystander = SpawnAssetNear(
|
||||
wantAsset,
|
||||
scene.FollowUnit != null
|
||||
? scene.FollowUnit.current_position
|
||||
: CameraPos(),
|
||||
8f);
|
||||
}
|
||||
|
||||
if (bystander == null || !bystander.isAlive())
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no_bystander");
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep SubjectId / Label on the original tip owner; only Follow + camera move.
|
||||
if (scene.SubjectId == 0)
|
||||
{
|
||||
scene.SubjectId = subjectId;
|
||||
}
|
||||
|
||||
scene.FollowUnit = bystander;
|
||||
try
|
||||
{
|
||||
scene.Position = bystander.current_position;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep
|
||||
}
|
||||
|
||||
CameraDirector.FocusUnit(bystander);
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"hijackFollow={SafeName(bystander)} subjectId={scene.SubjectId} relatedId={scene.RelatedId} tip='{scene.Label}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawn a non-fighting same-side unit, park combat Follow on them (rostered bystander),
|
||||
/// while peers keep fighting. Maintain must retarget off the chore unit.
|
||||
|
|
@ -6267,6 +6596,105 @@ public static class AgentHarness
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness: zero one sticky combat camp while keeping PeakParticipants high
|
||||
/// (repro for Mass vs (0) mop-up labels). value = a|b (default b).
|
||||
/// </summary>
|
||||
private static void DoCombatWipeStickySide(HarnessCommand cmd)
|
||||
{
|
||||
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no sticky opposing combat sides");
|
||||
return;
|
||||
}
|
||||
|
||||
LiveSceneStickyState sticky = scene.Sticky;
|
||||
string which = (cmd.value ?? "b").Trim().ToLowerInvariant();
|
||||
bool wipeB = which != "a" && which != "sidea" && which != "0";
|
||||
int peakBefore = sticky.PeakParticipants;
|
||||
if (wipeB)
|
||||
{
|
||||
sticky.SideBIds.Clear();
|
||||
sticky.SideBCount = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
sticky.SideAIds.Clear();
|
||||
sticky.SideACount = 0;
|
||||
}
|
||||
|
||||
// Keep peak Mass so the bug class (peak hold + empty camp) is exercised.
|
||||
sticky.PeakParticipants = Math.Max(peakBefore, Math.Max(8, sticky.TotalCount));
|
||||
scene.ParticipantCount = sticky.TotalCount;
|
||||
scene.CombatSideACount = sticky.SideACount;
|
||||
scene.CombatSideBCount = sticky.SideBCount;
|
||||
scene.CombatPeakParticipants = sticky.PeakParticipants;
|
||||
|
||||
string reason = StickyScoreboard.FormatReason(scene, scene.FollowUnit, scene.RelatedUnit);
|
||||
if (!string.IsNullOrEmpty(reason))
|
||||
{
|
||||
scene.Label = reason;
|
||||
}
|
||||
|
||||
StickyScoreboard.StabilizeScale(sticky, new LiveEnsemble
|
||||
{
|
||||
Kind = EnsembleKind.Combat,
|
||||
Frame = sticky.Frame,
|
||||
ParticipantCount = Math.Max(1, sticky.TotalCount),
|
||||
SideA = new EnsembleSide
|
||||
{
|
||||
Key = sticky.SideAKey,
|
||||
Display = sticky.SideADisplay,
|
||||
Count = sticky.SideACount
|
||||
},
|
||||
SideB = new EnsembleSide
|
||||
{
|
||||
Key = sticky.SideBKey,
|
||||
Display = sticky.SideBDisplay,
|
||||
Count = sticky.SideBCount
|
||||
},
|
||||
Focus = scene.FollowUnit,
|
||||
Related = scene.RelatedUnit
|
||||
}, Time.unscaledTime);
|
||||
|
||||
string label = EventReason.Combat(new LiveEnsemble
|
||||
{
|
||||
Kind = EnsembleKind.Combat,
|
||||
Scale = sticky.HasPresentedScale ? sticky.PresentedScale : LiveEnsemble.ScaleForCount(Math.Max(1, sticky.TotalCount)),
|
||||
Frame = sticky.Frame,
|
||||
Focus = scene.FollowUnit,
|
||||
Related = scene.RelatedUnit,
|
||||
SideA = new EnsembleSide
|
||||
{
|
||||
Key = sticky.SideAKey,
|
||||
Display = sticky.SideADisplay,
|
||||
KingdomDisplay = sticky.SideAKingdom,
|
||||
Count = sticky.SideACount
|
||||
},
|
||||
SideB = new EnsembleSide
|
||||
{
|
||||
Key = sticky.SideBKey,
|
||||
Display = sticky.SideBDisplay,
|
||||
KingdomDisplay = sticky.SideBKingdom,
|
||||
Count = sticky.SideBCount
|
||||
},
|
||||
ParticipantCount = sticky.TotalCount
|
||||
});
|
||||
if (!string.IsNullOrEmpty(label))
|
||||
{
|
||||
scene.Label = label;
|
||||
CameraDirector.Watch(scene.ToInterestEvent());
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"wiped={(wipeB ? "b" : "a")} sides={sticky.SideACount}/{sticky.SideBCount} peak={sticky.PeakParticipants} tip='{scene.Label}'");
|
||||
}
|
||||
|
||||
/// <summary>Diagnostics: how many combat fighters LiveEnsemble sees near the scene focus.</summary>
|
||||
private static void DoCombatProbeFighters(HarnessCommand cmd)
|
||||
{
|
||||
|
|
@ -6574,6 +7002,21 @@ public static class AgentHarness
|
|||
detail = $"tipAsset='{tipAsset}' want='{want}'";
|
||||
break;
|
||||
}
|
||||
case "tip_not_partner":
|
||||
{
|
||||
// Remembered bystander must not be named as the fallen / sought victim.
|
||||
// Subject can still be the partner ("Petelor stands over the fallen" is fine).
|
||||
string partnerName = SafeName(_happinessPartner);
|
||||
string tip = CameraDirector.LastWatchLabel ?? "";
|
||||
bool namedAsOther = !string.IsNullOrEmpty(partnerName)
|
||||
&& (tip.IndexOf("stands over " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| tip.IndexOf("mourns " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| tip.IndexOf("seeks " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| tip.IndexOf("stays beside " + partnerName, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
pass = !namedAsOther;
|
||||
detail = $"tip='{tip}' partner='{partnerName}' namedAsOther={namedAsOther}";
|
||||
break;
|
||||
}
|
||||
case "story_phase":
|
||||
{
|
||||
string want = (cmd.value ?? "").Trim();
|
||||
|
|
@ -6584,6 +7027,119 @@ public static class AgentHarness
|
|||
detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}";
|
||||
break;
|
||||
}
|
||||
case "story_spine":
|
||||
{
|
||||
string want = (cmd.value ?? "").Trim();
|
||||
string have = WatchCaption.LastStorySpine ?? "";
|
||||
string formatted = StoryPlanner.FormatSpineLabel() ?? "";
|
||||
// Prefer live dossier text; fall back to planner format if HUD not painted yet.
|
||||
if (string.IsNullOrEmpty(have))
|
||||
{
|
||||
have = formatted;
|
||||
}
|
||||
|
||||
bool wantEmpty = string.IsNullOrEmpty(want)
|
||||
|| string.Equals(want, "empty", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(want, "none", StringComparison.OrdinalIgnoreCase);
|
||||
if (wantEmpty)
|
||||
{
|
||||
pass = string.IsNullOrEmpty(have);
|
||||
}
|
||||
else if (want.IndexOf('|') >= 0)
|
||||
{
|
||||
// Alternatives: "Battle · Climax|Mass · Climax|Skirmish · Climax"
|
||||
pass = false;
|
||||
string[] alts = want.Split('|');
|
||||
for (int i = 0; i < alts.Length; i++)
|
||||
{
|
||||
string alt = (alts[i] ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(alt)
|
||||
&& have.IndexOf(alt, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
pass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pass = have.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
detail =
|
||||
$"storySpine='{have}' want='{want}' formatted='{formatted}' "
|
||||
+ $"belongs={StoryPlanner.BelongsToActiveStoryBeat(InterestDirector.CurrentCandidate)} "
|
||||
+ $"phase={StoryPlanner.Active?.Phase.ToString() ?? "None"}";
|
||||
break;
|
||||
}
|
||||
case "story_hold_margin":
|
||||
{
|
||||
// value: "0" / "none" → expect no raised hold; "gt0" / "held" → expect hold > 0
|
||||
string want = (cmd.value ?? "gt0").Trim();
|
||||
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
||||
InterestCandidate probe = new InterestCandidate
|
||||
{
|
||||
Key = "harness:story_hold_probe",
|
||||
SubjectId = 999999001,
|
||||
EventStrength = 40f,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Completion = InterestCompletionKind.FixedDwell,
|
||||
Label = "HoldProbe"
|
||||
};
|
||||
InterestScoring.RecalcTotal(probe);
|
||||
float margin = StoryPlanner.ArcHoldMargin(cur, probe);
|
||||
bool wantHeld = string.Equals(want, "gt0", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(want, "held", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(want, "true", StringComparison.OrdinalIgnoreCase);
|
||||
bool wantNone = string.Equals(want, "0", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(want, "none", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(want, "false", StringComparison.OrdinalIgnoreCase);
|
||||
if (wantHeld)
|
||||
{
|
||||
pass = margin > 0f;
|
||||
}
|
||||
else if (wantNone)
|
||||
{
|
||||
pass = margin <= 0f;
|
||||
}
|
||||
else if (float.TryParse(want, NumberStyles.Float, CultureInfo.InvariantCulture, out float wantF))
|
||||
{
|
||||
pass = Mathf.Abs(margin - wantF) < 0.5f;
|
||||
}
|
||||
else
|
||||
{
|
||||
pass = false;
|
||||
}
|
||||
|
||||
detail =
|
||||
$"storyHold={margin:0.#} want={want} phase={StoryPlanner.Active?.Phase.ToString() ?? "None"} "
|
||||
+ $"belongs={StoryPlanner.BelongsToActiveStoryBeat(cur)}";
|
||||
break;
|
||||
}
|
||||
case "beat_cooled":
|
||||
{
|
||||
// value = happiness/asset needle; builds a probe on focus unit.
|
||||
string needle = (cmd.value ?? "just_had_child").Trim();
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
InterestCandidate probe = new InterestCandidate
|
||||
{
|
||||
Key = "harness:beat_cool:" + needle,
|
||||
SubjectId = id,
|
||||
FollowUnit = focus,
|
||||
HappinessEffectId = needle,
|
||||
AssetId = needle,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Completion = InterestCompletionKind.FixedDwell,
|
||||
EventStrength = 80f,
|
||||
Label = needle
|
||||
};
|
||||
// Map decision/building/status needles onto the same chapter fields as injects.
|
||||
ApplyHarnessRelationshipAssetHint(probe, needle);
|
||||
pass = InterestVariety.IsBeatCooled(probe);
|
||||
detail = $"beatCooled={pass} needle='{needle}' focus={SafeName(focus)} id={id}";
|
||||
break;
|
||||
}
|
||||
case "ledger_has_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -7713,6 +8269,7 @@ public static class AgentHarness
|
|||
case "reason_owns_focus":
|
||||
{
|
||||
// Reason must not name a different live unit than the owned active candidate.
|
||||
// Non-empty reason also requires focus to be a tip principal (duel pair / roster).
|
||||
UnitDossier dossier = WatchCaption.Current;
|
||||
string reason = dossier?.ReasonLine ?? "";
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -7733,6 +8290,13 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
if (!InterestDirector.UnitIsReasonPrincipal(focus, owned))
|
||||
{
|
||||
pass = false;
|
||||
detail = $"reason_without_principal reason='{reason}'";
|
||||
break;
|
||||
}
|
||||
|
||||
string stranger = FindStrangerNamedInReason(reason, owned);
|
||||
pass = string.IsNullOrEmpty(stranger);
|
||||
detail = pass
|
||||
|
|
|
|||
|
|
@ -278,6 +278,27 @@ public static partial class EventCatalog
|
|||
/// <summary>True when a decision id should be allowed to own the idle camera.</summary>
|
||||
public static bool IsCameraWorthy(string id) => IsInterestingDecision(id);
|
||||
|
||||
/// <summary>
|
||||
/// Life-cycle intent decisions: AI cooldown fires before the outcome tip.
|
||||
/// Outcomes (pregnancy, child, seeking/smitten, lovers) already own the story -
|
||||
/// these crumbs soft-cool as a class after one show.
|
||||
/// Derived from live <c>decisions_library</c> token shape, not tip strings.
|
||||
/// </summary>
|
||||
public static bool IsLifeIntentDecision(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string s = id.Trim().ToLowerInvariant();
|
||||
return s.IndexOf("reproduction", StringComparison.Ordinal) >= 0
|
||||
|| s == "find_lover"
|
||||
|| s.StartsWith("find_lover_", StringComparison.Ordinal)
|
||||
|| s.IndexOf("baby_make", StringComparison.Ordinal) >= 0
|
||||
|| s.IndexOf("have_child", StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>Authored action-phrase ids (JSON catalog ∪ builtin fallback).</summary>
|
||||
public static IEnumerable<string> AuthoredActionPhraseIds
|
||||
{
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ public static class EventFeedUtil
|
|||
Label = label ?? assetId ?? key,
|
||||
AssetId = assetId ?? "",
|
||||
SpeciesId = subject?.asset != null ? subject.asset.id : "",
|
||||
// StatusPhase publishes the status id as AssetId - stamp StatusId before Upsert
|
||||
// so NoteSelection / beat cools see the class on first SwitchTo.
|
||||
StatusId = completion == InterestCompletionKind.StatusPhase ? (assetId ?? "") : "",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 35f,
|
||||
|
|
@ -185,6 +188,27 @@ public static class EventFeedUtil
|
|||
/// <see cref="WorldActivityScanner.EnumerateAliveUnitsPublic"/>.
|
||||
/// </summary>
|
||||
public static Actor FindAliveById(long id)
|
||||
{
|
||||
Actor actor = FindUnitById(id);
|
||||
if (actor == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return actor.isAlive() ? actor : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// O(1) actor lookup including dead units (naming fallen duel partners, etc.).
|
||||
/// </summary>
|
||||
public static Actor FindUnitById(long id)
|
||||
{
|
||||
if (id == 0 || World.world?.units == null)
|
||||
{
|
||||
|
|
@ -193,18 +217,12 @@ public static class EventFeedUtil
|
|||
|
||||
try
|
||||
{
|
||||
Actor actor = World.world.units.get(id);
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
return World.world.units.get(id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore lookup failures
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string SafeName(Actor actor)
|
||||
|
|
|
|||
|
|
@ -172,7 +172,8 @@ public static class EventReason
|
|||
|
||||
/// <summary>
|
||||
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
|
||||
/// Multi form: <c>Battle - Humans (4) vs Wolves (0)</c> - counts may be 0 while sides stay sticky.
|
||||
/// Multi form: <c>Battle - Humans (4) vs Wolves (3)</c>. A wiped camp collapses to a
|
||||
/// living-side pack label at live scale (never <c>vs … (0)</c>).
|
||||
/// Never uses raw unit names as mass-side labels. Never prints thin <c>melee</c> fallbacks.
|
||||
/// </summary>
|
||||
public static string Combat(LiveEnsemble ensemble)
|
||||
|
|
@ -201,26 +202,54 @@ public static class EventReason
|
|||
return "Duel - " + a + " vs " + b;
|
||||
}
|
||||
|
||||
string tier = ScaleTierLabel(
|
||||
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
|
||||
|
||||
if (LiveEnsemble.HasOpposingCollectiveSides(ensemble))
|
||||
{
|
||||
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
|
||||
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
|
||||
if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB))
|
||||
int countA = ensemble.SideA != null ? Math.Max(0, ensemble.SideA.Count) : 0;
|
||||
int countB = ensemble.SideB != null ? Math.Max(0, ensemble.SideB.Count) : 0;
|
||||
// Mop-up: a wiped camp must not print "Mass - A (93) vs B (0)".
|
||||
// Present the living side at live scale (pack chase / remnant).
|
||||
if (countA <= 0 || countB <= 0)
|
||||
{
|
||||
return tier + " - " + sideA + " vs " + sideB;
|
||||
EnsembleSide liveSide = countA > 0 ? ensemble.SideA : ensemble.SideB;
|
||||
int liveN = Math.Max(countA, countB);
|
||||
if (liveSide != null && liveN > 0 && !string.IsNullOrEmpty(liveSide.Key))
|
||||
{
|
||||
EnsembleScale mopScale = LiveEnsemble.ScaleForCount(liveN);
|
||||
string mopTier = ScaleTierLabel(
|
||||
mopScale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : mopScale);
|
||||
EnsembleFrame mopFrame = ensemble.Frame == EnsembleFrame.KingdomVsKingdom
|
||||
? EnsembleFrame.KingdomVsKingdom
|
||||
: EnsembleFrame.SpeciesVsSpecies;
|
||||
string pack = FormatCombatSide(liveSide, mopFrame);
|
||||
if (!string.IsNullOrEmpty(pack))
|
||||
{
|
||||
return mopTier + " - " + pack;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
string tier = ScaleTierLabel(
|
||||
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
|
||||
string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame);
|
||||
string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame);
|
||||
if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB))
|
||||
{
|
||||
return tier + " - " + sideA + " vs " + sideB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string packTier = ScaleTierLabel(
|
||||
ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale);
|
||||
|
||||
// Same-species pack with no opposing sticky camps.
|
||||
if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key))
|
||||
{
|
||||
string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies);
|
||||
if (!string.IsNullOrEmpty(pack))
|
||||
{
|
||||
return tier + " - " + pack;
|
||||
return packTier + " - " + pack;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +350,7 @@ public static class EventReason
|
|||
}
|
||||
|
||||
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
|
||||
// Allow 0 so sticky camps can show a side briefly empty, then climb again on rejoin.
|
||||
// Callers must not pair a zero-count side into "vs" framing (see Combat mop-up).
|
||||
int n = Math.Max(0, side.Count);
|
||||
return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -541,7 +541,8 @@ public static class InterestFeeds
|
|||
FollowUnit = actor,
|
||||
SubjectId = id,
|
||||
Label = EventReason.Hatch(actor),
|
||||
AssetId = SafeAsset(actor),
|
||||
// Hatch id owns AssetId so cool keys are class-stable (not species).
|
||||
AssetId = "just_got_out_of_egg",
|
||||
SpeciesId = SafeAsset(actor),
|
||||
HappinessEffectId = "just_got_out_of_egg",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
|
|
@ -649,7 +650,9 @@ public static class InterestFeeds
|
|||
SubjectId = sid,
|
||||
RelatedId = rid,
|
||||
Label = label ?? milestoneKey,
|
||||
AssetId = subject.asset != null ? subject.asset.id : "",
|
||||
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class
|
||||
// (species id used to land every hunter tip on arc:asset:elf).
|
||||
AssetId = milestoneKey,
|
||||
SpeciesId = subject.asset != null ? subject.asset.id : "",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,25 @@ namespace IdleSpectator;
|
|||
}
|
||||
|
||||
EnsembleScale live = LiveEnsemble.ScaleForCount(n);
|
||||
// Opposing camp wiped: drop Peak Mass hold immediately so tip tier matches mop-up.
|
||||
bool wipedOpposing = sticky.HasOpposingSides
|
||||
&& sticky.TotalCount > 0
|
||||
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
|
||||
if (wipedOpposing)
|
||||
{
|
||||
sticky.HasPresentedScale = true;
|
||||
sticky.PresentedScale = live;
|
||||
sticky.ScaleChangeSince = -999f;
|
||||
sticky.ScaleDropSince = -999f;
|
||||
ensemble.Scale = live;
|
||||
if (ensemble.Frame == EnsembleFrame.NamedPair && live > EnsembleScale.Pair)
|
||||
{
|
||||
ensemble.Frame = EnsembleFrame.CountOnly;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sticky.HasPresentedScale)
|
||||
{
|
||||
sticky.HasPresentedScale = true;
|
||||
|
|
@ -458,10 +477,16 @@ namespace IdleSpectator;
|
|||
}
|
||||
|
||||
LiveSceneStickyState sticky = scene.Sticky;
|
||||
bool wipedOpposing = sticky.HasOpposingSides
|
||||
&& sticky.TotalCount > 0
|
||||
&& (sticky.SideACount <= 0 || sticky.SideBCount <= 0);
|
||||
int scaleN = wipedOpposing
|
||||
? Math.Max(1, sticky.TotalCount)
|
||||
: Math.Max(3, sticky.PeakParticipants);
|
||||
var ensemble = new LiveEnsemble
|
||||
{
|
||||
Kind = sticky.Kind,
|
||||
Scale = LiveEnsemble.ScaleForCount(Math.Max(3, sticky.PeakParticipants)),
|
||||
Scale = LiveEnsemble.ScaleForCount(scaleN),
|
||||
Focus = focus,
|
||||
Related = related,
|
||||
Frame = sticky.Frame,
|
||||
|
|
|
|||
|
|
@ -67,8 +67,57 @@ internal static class HarnessScenarios
|
|||
case "story_aftermath_combat":
|
||||
case "story_aftermath":
|
||||
return StoryAftermathCombat();
|
||||
case "story_aftermath_cast_noise":
|
||||
case "soft_duel_aftermath_noise":
|
||||
return StoryAftermathCastNoise();
|
||||
case "reason_duel_principal":
|
||||
case "story_reason_principal":
|
||||
return ReasonDuelPrincipal();
|
||||
case "reason_life_principal":
|
||||
case "story_reason_life":
|
||||
return ReasonLifePrincipal();
|
||||
case "story_intimacy_cool":
|
||||
case "intimacy_cool":
|
||||
return StoryIntimacyCool();
|
||||
case "story_settlement_cool":
|
||||
case "settlement_cool":
|
||||
return StorySettlementCool();
|
||||
case "story_kill_cool":
|
||||
case "kill_cool":
|
||||
return StoryKillCool();
|
||||
case "story_courtship_cool":
|
||||
case "courtship_cool":
|
||||
case "story_love_cool":
|
||||
return StoryCourtshipCool();
|
||||
case "combat_wiped_side":
|
||||
case "story_wiped_side":
|
||||
return CombatWipedSide();
|
||||
case "story_decision_intent_cool":
|
||||
case "decision_intent_cool":
|
||||
return StoryDecisionIntentCool();
|
||||
case "story_construction_cool":
|
||||
case "construction_cool":
|
||||
return StoryConstructionCool();
|
||||
case "story_status_fx_cool":
|
||||
case "status_fx_cool":
|
||||
return StoryStatusFxCool();
|
||||
case "story_resume_cooled":
|
||||
case "resume_cooled":
|
||||
return StoryResumeCooled();
|
||||
case "story_worldlog_meta_cool":
|
||||
case "worldlog_meta_cool":
|
||||
return StoryWorldLogMetaCool();
|
||||
case "story_rest_cool":
|
||||
case "rest_cool":
|
||||
return StoryRestCool();
|
||||
case "story_decision_intent_live":
|
||||
case "decision_intent_live":
|
||||
return StoryDecisionIntentLive();
|
||||
case "story_aftermath_war":
|
||||
return StoryAftermathWar();
|
||||
case "story_aftermath_partner_truth":
|
||||
case "aftermath_partner_truth":
|
||||
return StoryAftermathPartnerTruth();
|
||||
case "story_near_tie":
|
||||
case "variety_near_tie":
|
||||
return StoryNearTie();
|
||||
|
|
@ -78,6 +127,10 @@ internal static class HarnessScenarios
|
|||
return StoryArcPreemptResume();
|
||||
case "story_ledger_revisit":
|
||||
return StoryLedgerRevisit();
|
||||
case "story_spine_scoped":
|
||||
return StorySpineScoped();
|
||||
case "story_family_variety":
|
||||
return StoryFamilyVariety();
|
||||
case "war_front_sticky":
|
||||
case "war_sticky":
|
||||
return WarFrontSticky();
|
||||
|
|
@ -237,8 +290,26 @@ internal static class HarnessScenarios
|
|||
Nested("reg_combat_hold", "combat_hold_until_end"),
|
||||
Nested("reg_combat_stability", "combat_stability_live"),
|
||||
Nested("reg_story_aftermath", "story_aftermath_combat"),
|
||||
Nested("reg_story_aftermath_noise", "story_aftermath_cast_noise"),
|
||||
Nested("reg_story_aftermath_partner_truth", "story_aftermath_partner_truth"),
|
||||
Nested("reg_reason_duel_principal", "reason_duel_principal"),
|
||||
Nested("reg_reason_life_principal", "reason_life_principal"),
|
||||
Nested("reg_story_intimacy_cool", "story_intimacy_cool"),
|
||||
Nested("reg_story_settlement_cool", "story_settlement_cool"),
|
||||
Nested("reg_story_kill_cool", "story_kill_cool"),
|
||||
Nested("reg_story_courtship_cool", "story_courtship_cool"),
|
||||
Nested("reg_story_decision_intent_cool", "story_decision_intent_cool"),
|
||||
Nested("reg_story_construction_cool", "story_construction_cool"),
|
||||
Nested("reg_story_status_fx_cool", "story_status_fx_cool"),
|
||||
Nested("reg_story_resume_cooled", "story_resume_cooled"),
|
||||
Nested("reg_story_worldlog_meta_cool", "story_worldlog_meta_cool"),
|
||||
Nested("reg_story_rest_cool", "story_rest_cool"),
|
||||
Nested("reg_story_decision_intent_live", "story_decision_intent_live"),
|
||||
Nested("reg_combat_wiped_side", "combat_wiped_side"),
|
||||
Nested("reg_story_near_tie", "story_near_tie"),
|
||||
Nested("reg_story_arc", "story_arc_combat"),
|
||||
Nested("reg_story_spine", "story_spine_scoped"),
|
||||
Nested("reg_story_family", "story_family_variety"),
|
||||
Nested("reg_story_ledger", "story_ledger_revisit"),
|
||||
Nested("reg_discovery", "discovery"),
|
||||
Nested("reg_worldlog", "world_log"),
|
||||
|
|
@ -1482,11 +1553,21 @@ internal static class HarnessScenarios
|
|||
Step("sa24", "interest_expire_pending", value: ""),
|
||||
Step("sa25", "age_current", wait: 10f),
|
||||
Step("sa26", "interest_mark_combat_cold"),
|
||||
// Cold climax tip while phase is Aftermath must not show a lying spine.
|
||||
Step("sa26b", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("sa26c", "assert", expect: "story_phase", value: "Aftermath"),
|
||||
Step("sa26d", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("sa27", "director_run", wait: 1.5f),
|
||||
Step("sa28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"),
|
||||
Step("sa29", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("sa30", "assert", expect: "tip_not_contains", value: "Duel -"),
|
||||
Step("sa31", "assert", expect: "story_phase", value: "Aftermath"),
|
||||
Step("sa31b", "assert", expect: "story_spine", value: "Duel · Aftermath"),
|
||||
// Drop Follow mid-aftermath - cast recovery must keep the story tip.
|
||||
Step("sa32", "interest_drop_follow"),
|
||||
Step("sa33", "director_run", wait: 0.8f),
|
||||
Step("sa34", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("sa35", "assert", expect: "story_phase", value: "Aftermath"),
|
||||
Step("sa90", "fast_timing", value: "false"),
|
||||
Step("sa99", "snapshot"),
|
||||
};
|
||||
|
|
@ -1518,6 +1599,674 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combat aftermath must not name a living lover/friend as the fallen
|
||||
/// (soak: "stands over Clemond" after a duel vs someone else).
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryAftermathPartnerTruth()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("saf0", "dismiss_windows"),
|
||||
Step("saf1", "wait_world"),
|
||||
Step("saf2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("saf3", "fast_timing", value: "true"),
|
||||
// Lover bystander (not in the duel) remembered first; fighter is the next spawn.
|
||||
Step("saf4", "spawn", asset: "human", count: 1),
|
||||
Step("saf5", "happiness_remember_partner"),
|
||||
Step("saf6", "spawn", asset: "human", count: 1),
|
||||
Step("saf7", "focus", asset: "auto"),
|
||||
Step("saf8", "happiness_bond_lovers"),
|
||||
Step("saf9", "spawn", asset: "wolf", count: 1),
|
||||
Step("saf10", "spectator", value: "off"),
|
||||
Step("saf11", "spectator", value: "on"),
|
||||
// Keep the fighter (human focus), not the wolf, as combat Follow.
|
||||
Step("saf12", "pick_unit", asset: "human"),
|
||||
Step("saf12b", "focus", asset: "human"),
|
||||
Step("saf13", "interest_end_session"),
|
||||
Step("saf14", "interest_story_clear"),
|
||||
Step("saf20", "interest_combat_session", asset: "human", value: "wolf", expect: "saf_pack"),
|
||||
Step("saf20b", "combat_isolate_pair", value: "20"),
|
||||
Step("saf21", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("saf22", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("saf23", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("saf24", "interest_expire_pending", value: ""),
|
||||
Step("saf25", "age_current", wait: 10f),
|
||||
Step("saf26", "interest_mark_combat_cold"),
|
||||
Step("saf27", "director_run", wait: 1.5f),
|
||||
// Living theater partner → generic fallen prose; never the lover's name.
|
||||
Step("saf28", "assert", expect: "tip_contains", value: "stands over the fallen"),
|
||||
Step("saf29", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("saf30", "assert", expect: "tip_not_partner"),
|
||||
Step("saf90", "fast_timing", value: "false"),
|
||||
Step("saf99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-duel cold still injects aftermath when high-strength cast life noise is pending
|
||||
/// (hatch/eat must not count as natural grief/love peers).
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryAftermathCastNoise()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("san0", "dismiss_windows"),
|
||||
Step("san1", "wait_world"),
|
||||
Step("san2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("san3", "fast_timing", value: "true"),
|
||||
Step("san4", "spawn", asset: "human", count: 1),
|
||||
Step("san5", "spawn", asset: "wolf", count: 1),
|
||||
Step("san6", "spectator", value: "off"),
|
||||
Step("san7", "spectator", value: "on"),
|
||||
Step("san8", "pick_unit", asset: "human"),
|
||||
Step("san9", "focus", asset: "human"),
|
||||
Step("san10", "interest_end_session"),
|
||||
Step("san10b", "interest_story_clear"),
|
||||
Step("san10c", "interest_variety_clear"),
|
||||
Step("san10d", "interest_expire_pending", value: ""),
|
||||
Step("san20", "interest_combat_session", asset: "human", value: "wolf", expect: "san_pack"),
|
||||
Step("san20b", "combat_isolate_pair", value: "20"),
|
||||
Step("san21", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("san22", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("san23", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
// High-score cast-life distractor (expect id must NOT contain "hatch" or the
|
||||
// harness maps it onto just_got_out_of_egg and it can sticky-win the cold).
|
||||
Step("san24", "interest_inject", asset: "human", label: "{a} munches quietly nearby",
|
||||
tier: "Action", expect: "san_cast_noise",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("san25", "age_current", wait: 10f),
|
||||
Step("san26", "interest_mark_combat_cold"),
|
||||
Step("san27", "director_run", wait: 1.5f),
|
||||
Step("san28", "assert", expect: "tip_matches_any", value: "stands over|mourns|lingers"),
|
||||
Step("san29", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("san30", "assert", expect: "tip_not_contains", value: "Duel -"),
|
||||
Step("san31", "assert", expect: "story_phase", value: "Aftermath"),
|
||||
Step("san90", "fast_timing", value: "false"),
|
||||
Step("san99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Life-tip reason principals: Follow hijack onto a bystander must not keep a
|
||||
/// subject-led intimacy Label on the bystander dossier.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> ReasonLifePrincipal()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("rlp0", "dismiss_windows"),
|
||||
Step("rlp1", "wait_world"),
|
||||
Step("rlp2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("rlp3", "fast_timing", value: "true"),
|
||||
Step("rlp4", "spawn", asset: "human", count: 3),
|
||||
Step("rlp5", "spectator", value: "off"),
|
||||
Step("rlp6", "spectator", value: "on"),
|
||||
Step("rlp7", "pick_unit", asset: "human"),
|
||||
Step("rlp8", "focus", asset: "human"),
|
||||
Step("rlp9", "interest_end_session"),
|
||||
Step("rlp20", "interest_force_session", asset: "human",
|
||||
label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed",
|
||||
value: "lead=event;evt=62"),
|
||||
Step("rlp21", "assert", expect: "reason_matches_any", value: "intimacy|mates"),
|
||||
Step("rlp22", "assert", expect: "reason_owns_focus"),
|
||||
// Follow drifts to a bystander; SubjectId + Label stay on the couple tip.
|
||||
Step("rlp30", "interest_hijack_follow", asset: "human"),
|
||||
Step("rlp31", "wait", value: "0.25"),
|
||||
Step("rlp32", "assert", expect: "dossier_matches_focus"),
|
||||
Step("rlp33", "assert", expect: "reason_empty", value: "true"),
|
||||
Step("rlp34", "assert", expect: "reason_owns_focus"),
|
||||
Step("rlp35", "assert", expect: "dossier_not_contains", value: "shares intimacy"),
|
||||
Step("rlp90", "fast_timing", value: "false"),
|
||||
Step("rlp99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// just_found_house / finds-a-home soft-life cool: settlement crumbs must not
|
||||
/// keep cutting between combat beats.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StorySettlementCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("ssc0", "dismiss_windows"),
|
||||
Step("ssc1", "wait_world"),
|
||||
Step("ssc2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("ssc3", "fast_timing", value: "true"),
|
||||
Step("ssc4", "spawn", asset: "human", count: 2),
|
||||
Step("ssc5", "spectator", value: "off"),
|
||||
Step("ssc6", "spectator", value: "on"),
|
||||
Step("ssc7", "pick_unit", asset: "human"),
|
||||
Step("ssc8", "focus", asset: "human"),
|
||||
Step("ssc9", "interest_end_session"),
|
||||
Step("ssc10", "interest_variety_clear"),
|
||||
Step("ssc20", "interest_force_session", asset: "human",
|
||||
label: "{a} finds a home", tier: "Action", expect: "just_found_house",
|
||||
value: "lead=event;evt=58"),
|
||||
Step("ssc21", "assert", expect: "tip_contains", value: "finds a home"),
|
||||
Step("ssc22", "assert", expect: "beat_cooled", value: "just_found_house"),
|
||||
Step("ssc23", "interest_end_session"),
|
||||
Step("ssc30", "interest_inject", asset: "human",
|
||||
label: "{a} finds a home", tier: "Action", expect: "just_found_house",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("ssc31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "ssc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("ssc32", "director_run", wait: 1.2f),
|
||||
Step("ssc33", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("ssc34", "assert", expect: "tip_not_contains", value: "finds a home"),
|
||||
Step("ssc35", "assert", expect: "beat_cooled", value: "just_found_house"),
|
||||
Step("ssc90", "fast_timing", value: "false"),
|
||||
Step("ssc99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// just_kissed / intimacy soft-life cool: after one show, the same beat cannot
|
||||
/// immediately cut back in over a stronger peer.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryIntimacyCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sic0", "dismiss_windows"),
|
||||
Step("sic1", "wait_world"),
|
||||
Step("sic2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sic3", "fast_timing", value: "true"),
|
||||
Step("sic4", "spawn", asset: "human", count: 2),
|
||||
Step("sic5", "spectator", value: "off"),
|
||||
Step("sic6", "spectator", value: "on"),
|
||||
Step("sic7", "pick_unit", asset: "human"),
|
||||
Step("sic8", "focus", asset: "human"),
|
||||
Step("sic9", "interest_end_session"),
|
||||
Step("sic10", "interest_variety_clear"),
|
||||
Step("sic20", "interest_force_session", asset: "human",
|
||||
label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed",
|
||||
value: "lead=event;evt=62"),
|
||||
Step("sic21", "assert", expect: "tip_matches_any", value: "intimacy|mates"),
|
||||
Step("sic22", "assert", expect: "beat_cooled", value: "just_kissed"),
|
||||
Step("sic23", "interest_end_session"),
|
||||
// Same intimacy beat pending again - must stay cooled while a clear peer wins.
|
||||
Step("sic30", "interest_inject", asset: "human",
|
||||
label: "{a} shares intimacy with a partner", tier: "Action", expect: "just_kissed",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("sic31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "sic_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("sic32", "director_run", wait: 1.2f),
|
||||
Step("sic33", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("sic34", "assert", expect: "tip_not_contains", value: "intimacy"),
|
||||
Step("sic35", "assert", expect: "beat_cooled", value: "just_kissed"),
|
||||
Step("sic90", "fast_timing", value: "false"),
|
||||
Step("sic99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chronicle kill crumbs (Killed X) soft-life cool: after one show, kill beats
|
||||
/// cannot keep cutting between stronger peers / mass tips.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryKillCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("skc0", "dismiss_windows"),
|
||||
Step("skc1", "wait_world"),
|
||||
Step("skc2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("skc3", "fast_timing", value: "true"),
|
||||
Step("skc4", "spawn", asset: "human", count: 2),
|
||||
Step("skc5", "spectator", value: "off"),
|
||||
Step("skc6", "spectator", value: "on"),
|
||||
Step("skc7", "pick_unit", asset: "human"),
|
||||
Step("skc8", "focus", asset: "human"),
|
||||
Step("skc8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("skc9", "interest_end_session"),
|
||||
Step("skc10", "interest_variety_clear"),
|
||||
Step("skc20", "interest_force_session", asset: "human",
|
||||
label: "Killed Phixzen (fox)", tier: "Action", expect: "milestone_kill",
|
||||
value: "lead=event;evt=60"),
|
||||
Step("skc21", "assert", expect: "tip_contains", value: "Killed"),
|
||||
Step("skc22", "assert", expect: "beat_cooled", value: "milestone_kill"),
|
||||
Step("skc23", "interest_end_session"),
|
||||
Step("skc30", "interest_inject", asset: "human",
|
||||
label: "Killed OtherFox (fox)", tier: "Action", expect: "milestone_kill",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("skc31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "skc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("skc32", "director_run", wait: 1.2f),
|
||||
Step("skc33", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("skc34", "assert", expect: "tip_not_contains", value: "Killed"),
|
||||
Step("skc35", "assert", expect: "beat_cooled", value: "milestone_kill"),
|
||||
Step("skc90", "fast_timing", value: "false"),
|
||||
Step("skc99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// find_lover / fallen_in_love courtship soft-life cool: seeking stamps the shared
|
||||
/// love class so smitten/seeking crumbs cannot cut over a clear peer.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryCourtshipCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("scc0", "dismiss_windows"),
|
||||
Step("scc1", "wait_world"),
|
||||
Step("scc2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("scc3", "fast_timing", value: "true"),
|
||||
Step("scc4", "spawn", asset: "human", count: 3),
|
||||
Step("scc5", "spectator", value: "off"),
|
||||
Step("scc6", "spectator", value: "on"),
|
||||
Step("scc7", "pick_unit", asset: "human"),
|
||||
Step("scc8", "focus", asset: "human"),
|
||||
// Dirty worlds often have leftover wolves from combat gates.
|
||||
Step("scc8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("scc9", "interest_end_session"),
|
||||
Step("scc10", "interest_variety_clear"),
|
||||
Step("scc11", "interest_story_clear"),
|
||||
Step("scc20", "interest_force_session", asset: "human",
|
||||
label: "{a} is seeking a lover", tier: "Action", expect: "find_lover",
|
||||
value: "lead=event;evt=62"),
|
||||
Step("scc21", "assert", expect: "tip_contains", value: "seeking a lover"),
|
||||
Step("scc22", "assert", expect: "beat_cooled", value: "find_lover"),
|
||||
// Shared arc:life:love world cool also covers smitten.
|
||||
Step("scc23", "assert", expect: "beat_cooled", value: "fallen_in_love"),
|
||||
Step("scc24", "interest_end_session"),
|
||||
Step("scc25", "interest_story_clear"),
|
||||
Step("scc30", "interest_inject", asset: "human",
|
||||
label: "{a} is seeking a lover", tier: "Action", expect: "find_lover",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("scc31", "interest_inject", asset: "human",
|
||||
label: "{a} is smitten with a partner", tier: "Action", expect: "fallen_in_love",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("scc32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "scc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("scc33", "director_run", wait: 1.2f),
|
||||
Step("scc34", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("scc35", "assert", expect: "tip_not_contains", value: "smitten"),
|
||||
Step("scc36", "assert", expect: "tip_not_contains", value: "seeking a lover"),
|
||||
Step("scc37", "assert", expect: "beat_cooled", value: "fallen_in_love"),
|
||||
Step("scc38", "assert", expect: "beat_cooled", value: "find_lover"),
|
||||
Step("scc90", "fast_timing", value: "false"),
|
||||
Step("scc99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Life-intent decisions (reproduce / find lover) soft-cool as a class:
|
||||
/// one show cools the whole intent family so ClearPeer wins.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryDecisionIntentCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sdc0", "dismiss_windows"),
|
||||
Step("sdc1", "wait_world"),
|
||||
Step("sdc2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sdc3", "fast_timing", value: "true"),
|
||||
Step("sdc4", "spawn", asset: "human", count: 3),
|
||||
Step("sdc5", "spectator", value: "off"),
|
||||
Step("sdc6", "spectator", value: "on"),
|
||||
Step("sdc7", "pick_unit", asset: "human"),
|
||||
Step("sdc8", "focus", asset: "human"),
|
||||
Step("sdc8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("sdc9", "interest_end_session"),
|
||||
Step("sdc10", "interest_variety_clear"),
|
||||
Step("sdc11", "interest_story_clear"),
|
||||
Step("sdc20", "interest_force_session", asset: "human",
|
||||
label: "{a} decides to try to reproduce", tier: "Action",
|
||||
expect: "sexual_reproduction_try", value: "lead=event;evt=66"),
|
||||
Step("sdc21", "assert", expect: "tip_contains", value: "try to reproduce"),
|
||||
Step("sdc22", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
|
||||
// Shared intent class also cools decision find_lover.
|
||||
Step("sdc23", "assert", expect: "beat_cooled", value: "decision_find_lover"),
|
||||
Step("sdc24", "interest_end_session"),
|
||||
Step("sdc25", "interest_story_clear"),
|
||||
Step("sdc30", "interest_inject", asset: "human",
|
||||
label: "{a} decides to try to reproduce", tier: "Action",
|
||||
expect: "sexual_reproduction_try",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("sdc31", "interest_inject", asset: "human",
|
||||
label: "{a} decides to find a lover", tier: "Action",
|
||||
expect: "decision_find_lover",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("sdc32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "sdc_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("sdc33", "director_run", wait: 1.2f),
|
||||
Step("sdc34", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("sdc35", "assert", expect: "tip_not_contains", value: "reproduce"),
|
||||
Step("sdc36", "assert", expect: "tip_not_contains", value: "find a lover"),
|
||||
Step("sdc90", "fast_timing", value: "false"),
|
||||
Step("sdc99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordinary civ construction finishes soft-cool world-wide so tent spam cannot chain.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryConstructionCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sco0", "dismiss_windows"),
|
||||
Step("sco1", "wait_world"),
|
||||
Step("sco2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sco3", "fast_timing", value: "true"),
|
||||
Step("sco4", "spawn", asset: "human", count: 3),
|
||||
Step("sco5", "spectator", value: "off"),
|
||||
Step("sco6", "spectator", value: "on"),
|
||||
Step("sco7", "pick_unit", asset: "human"),
|
||||
Step("sco8", "focus", asset: "human"),
|
||||
Step("sco8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("sco9", "interest_end_session"),
|
||||
Step("sco10", "interest_variety_clear"),
|
||||
Step("sco11", "interest_story_clear"),
|
||||
Step("sco20", "interest_force_session", asset: "human",
|
||||
label: "{a} finishes Tent Human", tier: "Action", expect: "tent_human",
|
||||
value: "lead=event;evt=72"),
|
||||
Step("sco21", "assert", expect: "tip_contains", value: "finishes"),
|
||||
Step("sco22", "assert", expect: "beat_cooled", value: "tent_human"),
|
||||
Step("sco23", "interest_end_session"),
|
||||
Step("sco30", "interest_inject", asset: "human",
|
||||
label: "{a} finishes Tent Human", tier: "Action", expect: "tent_human",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("sco31", "interest_inject", asset: "human",
|
||||
label: "{a} finishes Tent Dwarf", tier: "Action", expect: "construction",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("sco32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "sco_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("sco33", "director_run", wait: 1.2f),
|
||||
Step("sco34", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("sco35", "assert", expect: "tip_not_contains", value: "finishes"),
|
||||
Step("sco90", "fast_timing", value: "false"),
|
||||
Step("sco99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-life tip interrupted by a stronger peer must not resume after the peer ends.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryResumeCooled()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("src0", "dismiss_windows"),
|
||||
Step("src1", "wait_world"),
|
||||
Step("src2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("src3", "fast_timing", value: "true"),
|
||||
Step("src4", "spawn", asset: "human", count: 3),
|
||||
Step("src5", "spectator", value: "off"),
|
||||
Step("src6", "spectator", value: "on"),
|
||||
Step("src7", "pick_unit", asset: "human"),
|
||||
Step("src8", "focus", asset: "human"),
|
||||
Step("src8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("src9", "interest_end_session"),
|
||||
Step("src10", "interest_variety_clear"),
|
||||
Step("src11", "interest_story_clear"),
|
||||
// Soft crumb first (resumable FixedDwell - not ForceActive, so epic can cut in).
|
||||
Step("src20", "interest_inject", asset: "human",
|
||||
label: "{a} decides to try to reproduce", tier: "Action",
|
||||
expect: "sexual_reproduction_try",
|
||||
value: "lead=event;evt=66;char=5;force=false;ttl=60"),
|
||||
Step("src21", "director_run", wait: 1.2f),
|
||||
Step("src22", "assert", expect: "tip_contains", value: "try to reproduce"),
|
||||
Step("src23", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
|
||||
// Stronger peer cuts in (margin above cutInMargin).
|
||||
Step("src30", "interest_inject", asset: "human", label: "EpicCutIn", tier: "Epic",
|
||||
expect: "src_epic", value: "lead=event;evt=150;char=10;force=false;ttl=60"),
|
||||
Step("src31", "director_run", wait: 1.2f),
|
||||
Step("src32", "assert", expect: "tip_contains", value: "EpicCutIn"),
|
||||
Step("src33", "assert", expect: "tip_not_contains", value: "reproduce"),
|
||||
// Ending the cut-in must not resume the cooled reproduce tip.
|
||||
Step("src40", "interest_end_session"),
|
||||
Step("src41", "interest_story_clear"),
|
||||
Step("src42", "interest_inject", asset: "human",
|
||||
label: "{a} decides to try to reproduce", tier: "Action",
|
||||
expect: "sexual_reproduction_try",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("src43", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "src_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("src44", "director_run", wait: 1.2f),
|
||||
Step("src45", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("src46", "assert", expect: "tip_not_contains", value: "reproduce"),
|
||||
Step("src47", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
|
||||
Step("src90", "fast_timing", value: "false"),
|
||||
Step("src99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WorldLog politics meta (king_dead) soft-cools so sticky death crumbs cannot re-monopolize.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryWorldLogMetaCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("swm0", "dismiss_windows"),
|
||||
Step("swm1", "wait_world"),
|
||||
Step("swm2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("swm3", "fast_timing", value: "true"),
|
||||
Step("swm4", "spawn", asset: "human", count: 3),
|
||||
Step("swm5", "spectator", value: "off"),
|
||||
Step("swm6", "spectator", value: "on"),
|
||||
Step("swm7", "pick_unit", asset: "human"),
|
||||
Step("swm8", "focus", asset: "human"),
|
||||
Step("swm8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("swm9", "interest_end_session"),
|
||||
Step("swm10", "interest_variety_clear"),
|
||||
Step("swm11", "interest_story_clear"),
|
||||
Step("swm20", "interest_force_session", asset: "human",
|
||||
label: "King died: Frortied (Rugen)", tier: "Action", expect: "king_dead",
|
||||
value: "lead=event;evt=84"),
|
||||
Step("swm21", "assert", expect: "tip_contains", value: "King died"),
|
||||
Step("swm22", "assert", expect: "beat_cooled", value: "king_dead"),
|
||||
Step("swm23", "interest_end_session"),
|
||||
Step("swm30", "interest_inject", asset: "human",
|
||||
label: "King died: Frortied (Rugen)", tier: "Action", expect: "king_dead",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("swm31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "swm_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("swm32", "director_run", wait: 1.2f),
|
||||
Step("swm33", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("swm34", "assert", expect: "tip_not_contains", value: "King died"),
|
||||
Step("swm90", "fast_timing", value: "false"),
|
||||
Step("swm99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft personal status FX (taking_roots) cool world-wide after one show.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryStatusFxCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sfx0", "dismiss_windows"),
|
||||
Step("sfx1", "wait_world"),
|
||||
Step("sfx2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sfx3", "fast_timing", value: "true"),
|
||||
Step("sfx4", "spawn", asset: "human", count: 3),
|
||||
Step("sfx5", "spectator", value: "off"),
|
||||
Step("sfx6", "spectator", value: "on"),
|
||||
Step("sfx7", "pick_unit", asset: "human"),
|
||||
Step("sfx8", "focus", asset: "human"),
|
||||
Step("sfx8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("sfx9", "interest_end_session"),
|
||||
Step("sfx10", "interest_variety_clear"),
|
||||
Step("sfx11", "interest_story_clear"),
|
||||
Step("sfx20", "interest_force_session", asset: "human",
|
||||
label: "{a} · Starts taking root", tier: "Action", expect: "taking_roots",
|
||||
value: "lead=event;evt=54"),
|
||||
Step("sfx21", "assert", expect: "tip_contains", value: "taking root"),
|
||||
Step("sfx22", "assert", expect: "beat_cooled", value: "taking_roots"),
|
||||
Step("sfx23", "interest_end_session"),
|
||||
Step("sfx30", "interest_inject", asset: "human",
|
||||
label: "{a} · Starts taking root", tier: "Action", expect: "taking_roots",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("sfx31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "sfx_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("sfx32", "director_run", wait: 1.2f),
|
||||
Step("sfx33", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("sfx34", "assert", expect: "tip_not_contains", value: "taking root"),
|
||||
Step("sfx90", "fast_timing", value: "false"),
|
||||
Step("sfx99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sleep / wake / dream rest cycle soft-cools as one class after one show.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryRestCool()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srt0", "dismiss_windows"),
|
||||
Step("srt1", "wait_world"),
|
||||
Step("srt2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("srt3", "fast_timing", value: "true"),
|
||||
Step("srt4", "spawn", asset: "human", count: 3),
|
||||
Step("srt5", "spectator", value: "off"),
|
||||
Step("srt6", "spectator", value: "on"),
|
||||
Step("srt7", "pick_unit", asset: "human"),
|
||||
Step("srt8", "focus", asset: "human"),
|
||||
Step("srt8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("srt9", "interest_end_session"),
|
||||
Step("srt10", "interest_variety_clear"),
|
||||
Step("srt11", "interest_story_clear"),
|
||||
Step("srt20", "interest_force_session", asset: "human",
|
||||
label: "{a} · Falls asleep", tier: "Action", expect: "sleeping",
|
||||
value: "lead=event;evt=54"),
|
||||
Step("srt21", "assert", expect: "tip_contains", value: "Falls asleep"),
|
||||
Step("srt22", "assert", expect: "beat_cooled", value: "sleeping"),
|
||||
// Shared rest class also cools wake / dream crumbs.
|
||||
Step("srt23", "assert", expect: "beat_cooled", value: "just_slept"),
|
||||
Step("srt24", "interest_end_session"),
|
||||
Step("srt30", "interest_inject", asset: "human",
|
||||
label: "{a} · Falls asleep", tier: "Action", expect: "sleeping",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("srt31", "interest_inject", asset: "human",
|
||||
label: "{a} ends a rest", tier: "Action", expect: "just_slept",
|
||||
value: "lead=event;evt=88;char=10;force=false;ttl=60"),
|
||||
Step("srt32", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "srt_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("srt33", "director_run", wait: 1.2f),
|
||||
Step("srt34", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("srt35", "assert", expect: "tip_not_contains", value: "Falls asleep"),
|
||||
Step("srt36", "assert", expect: "tip_not_contains", value: "ends a rest"),
|
||||
Step("srt90", "fast_timing", value: "false"),
|
||||
Step("srt99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live DeferredInterestFeed EmitDecision path cools intent class (not only harness inject).
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryDecisionIntentLive()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sdl0", "dismiss_windows"),
|
||||
Step("sdl1", "wait_world"),
|
||||
Step("sdl2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sdl3", "fast_timing", value: "true"),
|
||||
Step("sdl4", "spawn", asset: "human", count: 3),
|
||||
Step("sdl5", "spectator", value: "off"),
|
||||
Step("sdl6", "spectator", value: "on"),
|
||||
Step("sdl7", "pick_unit", asset: "human"),
|
||||
Step("sdl8", "focus", asset: "human"),
|
||||
Step("sdl8b", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("sdl9", "interest_end_session"),
|
||||
Step("sdl10", "interest_variety_clear"),
|
||||
Step("sdl11", "interest_story_clear"),
|
||||
Step("sdl20", "decision_emit", expect: "sexual_reproduction_try"),
|
||||
Step("sdl21", "director_run", wait: 1.2f),
|
||||
Step("sdl22", "assert", expect: "tip_contains", value: "try to reproduce"),
|
||||
Step("sdl23", "assert", expect: "beat_cooled", value: "sexual_reproduction_try"),
|
||||
Step("sdl24", "assert", expect: "beat_cooled", value: "decision_find_lover"),
|
||||
Step("sdl25", "interest_end_session"),
|
||||
Step("sdl26", "interest_story_clear"),
|
||||
Step("sdl30", "decision_emit", expect: "find_lover"),
|
||||
Step("sdl31", "interest_inject", asset: "human", label: "ClearPeer", tier: "Action",
|
||||
expect: "sdl_peer", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("sdl32", "director_run", wait: 1.2f),
|
||||
Step("sdl33", "assert", expect: "tip_contains", value: "ClearPeer"),
|
||||
Step("sdl34", "assert", expect: "tip_not_contains", value: "reproduce"),
|
||||
Step("sdl35", "assert", expect: "tip_not_contains", value: "find a lover"),
|
||||
Step("sdl90", "fast_timing", value: "false"),
|
||||
Step("sdl99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peak Mass with one sticky camp wiped to 0 must not print "vs … (0)".
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> CombatWipedSide()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("cws0", "dismiss_windows"),
|
||||
Step("cws1", "wait_world"),
|
||||
Step("cws2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("cws3", "fast_timing", value: "true"),
|
||||
Step("cws4", "spawn", asset: "human", count: 4),
|
||||
Step("cws5", "spawn", asset: "wolf", count: 4),
|
||||
Step("cws6", "spectator", value: "off"),
|
||||
Step("cws7", "spectator", value: "on"),
|
||||
Step("cws8", "pick_unit", asset: "human"),
|
||||
Step("cws9", "focus", asset: "human"),
|
||||
Step("cws10", "interest_end_session"),
|
||||
Step("cws20", "interest_combat_session", asset: "human", value: "wolf", expect: "cws_pack"),
|
||||
Step("cws21", "combat_ensemble_escalate", value: "8"),
|
||||
Step("cws22", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||
Step("cws23", "combat_maintain_focus"),
|
||||
Step("cws24", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||
Step("cws25", "assert", expect: "tip_matches_any", value: " vs "),
|
||||
// Wipe sticky B only (do not maintain - live wolves would re-enroll on SideBKey).
|
||||
Step("cws30", "combat_wipe_sticky_side", value: "b"),
|
||||
Step("cws31", "assert", expect: "tip_not_contains", value: "(0)"),
|
||||
Step("cws32", "assert", expect: "tip_not_contains", value: " vs "),
|
||||
Step("cws33", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||
Step("cws90", "fast_timing", value: "false"),
|
||||
Step("cws99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pair-locked duel reason only on PairOwner/Partner - parked sticky Follow bystander
|
||||
/// must not show "Duel - A vs B" on their dossier.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> ReasonDuelPrincipal()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("rdp0", "dismiss_windows"),
|
||||
Step("rdp1", "wait_world"),
|
||||
Step("rdp2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("rdp3", "fast_timing", value: "true"),
|
||||
Step("rdp4", "spawn", asset: "human", count: 1),
|
||||
Step("rdp5", "spawn", asset: "wolf", count: 1),
|
||||
Step("rdp6", "spectator", value: "off"),
|
||||
Step("rdp7", "spectator", value: "on"),
|
||||
Step("rdp8", "pick_unit", asset: "human"),
|
||||
Step("rdp9", "focus", asset: "human"),
|
||||
Step("rdp10", "interest_end_session"),
|
||||
Step("rdp20", "interest_combat_session", asset: "human", value: "wolf", expect: "rdp_duel"),
|
||||
Step("rdp20b", "combat_isolate_pair", value: "20"),
|
||||
Step("rdp21", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("rdp22", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("rdp23", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("rdp24", "assert", expect: "reason_matches_any", value: "Duel -"),
|
||||
Step("rdp25", "assert", expect: "reason_owns_focus"),
|
||||
// Park Follow on a rostered sleeper who is not PairOwner/Partner.
|
||||
Step("rdp30", "combat_park_bystander_follow", asset: "human"),
|
||||
Step("rdp31", "wait", value: "0.25"),
|
||||
Step("rdp32", "assert", expect: "dossier_matches_focus"),
|
||||
Step("rdp33", "assert", expect: "reason_empty", value: "true"),
|
||||
Step("rdp34", "assert", expect: "reason_owns_focus"),
|
||||
Step("rdp90", "fast_timing", value: "false"),
|
||||
Step("rdp99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Large score gap always picks #1; within epsilon may roll among top-3.</summary>
|
||||
private static List<HarnessCommand> StoryNearTie()
|
||||
{
|
||||
|
|
@ -1546,6 +2295,86 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spine only while the watch tip is the story beat; find_lover intent is not a Love climax;
|
||||
/// leaving a climax for an unrelated tip clears the arc.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StorySpineScoped()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("ss0", "dismiss_windows"),
|
||||
Step("ss1", "wait_world"),
|
||||
Step("ss2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("ss3", "fast_timing", value: "true"),
|
||||
Step("ss4", "spawn", asset: "human", count: 1),
|
||||
Step("ss5", "spawn", asset: "wolf", count: 1),
|
||||
Step("ss6", "spectator", value: "off"),
|
||||
Step("ss7", "spectator", value: "on"),
|
||||
Step("ss8", "pick_unit", asset: "human"),
|
||||
Step("ss9", "focus", asset: "human"),
|
||||
Step("ss10", "interest_end_session"),
|
||||
|
||||
// Seeking a lover must stay camera-worthy without opening Love · Climax.
|
||||
Step("ss20", "interest_force_session", asset: "human",
|
||||
label: "{a} is seeking a lover", tier: "Action", expect: "find_lover",
|
||||
value: "lead=event;evt=45"),
|
||||
Step("ss21", "assert", expect: "tip_contains", value: "seeking a lover"),
|
||||
Step("ss22", "assert", expect: "story_phase", value: "None"),
|
||||
Step("ss23", "assert", expect: "story_spine", value: "empty"),
|
||||
|
||||
// Real bond opens Love climax + spine.
|
||||
Step("ss30", "interest_force_session", asset: "human",
|
||||
label: "{a} became lovers with a partner", tier: "Action", expect: "set_lover",
|
||||
value: "lead=event;evt=88"),
|
||||
Step("ss31", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("ss32", "assert", expect: "story_spine", value: "Love · Climax"),
|
||||
|
||||
// Unrelated hatch tip must clear the arc and hide the spine.
|
||||
Step("ss40", "interest_force_session", asset: "human",
|
||||
label: "{a} hatches from an egg", tier: "Action", expect: "hatch_leave_love",
|
||||
value: "lead=event;evt=95"),
|
||||
Step("ss41", "assert", expect: "tip_contains", value: "hatches from an egg"),
|
||||
Step("ss42", "assert", expect: "story_phase", value: "None"),
|
||||
Step("ss43", "assert", expect: "story_spine", value: "empty"),
|
||||
|
||||
// Combat climax spine, then leave for hatch again.
|
||||
Step("ss50", "interest_end_session"),
|
||||
Step("ss51", "interest_combat_session", asset: "human", value: "wolf", expect: "ss_pack"),
|
||||
Step("ss52", "combat_isolate_pair", value: "20"),
|
||||
Step("ss53", "status_apply", asset: "human", value: "invincible"),
|
||||
Step("ss54", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("ss55", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("ss56", "assert", expect: "story_spine", value: "Duel · Climax"),
|
||||
// In-place escalate must retarget Kind Mass without a director SwitchTo.
|
||||
Step("ss57", "combat_ensemble_escalate", value: "6"),
|
||||
Step("ss57b", "combat_wire_attack_sides", asset: "human", value: "wolf"),
|
||||
Step("ss57c", "combat_maintain_focus"),
|
||||
Step("ss57d", "wait", value: "0.35"),
|
||||
Step("ss57e", "combat_maintain_focus"),
|
||||
Step("ss58", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
|
||||
// Spine Kind follows tip Label (Battle/Mass/Skirmish), not a collapsed Mass bucket.
|
||||
Step("ss59", "assert", expect: "story_spine",
|
||||
value: "Battle · Climax|Mass · Climax|Skirmish · Climax"),
|
||||
// Shrink back to a named duel - spine Kind must follow the Label, not scale-hold.
|
||||
Step("ss59b", "combat_isolate_pair", value: "20"),
|
||||
Step("ss59c", "combat_maintain_focus"),
|
||||
Step("ss59d", "wait", value: "0.35"),
|
||||
Step("ss59e", "combat_maintain_focus"),
|
||||
Step("ss59f", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("ss59g", "assert", expect: "story_spine", value: "Duel · Climax"),
|
||||
Step("ss60", "interest_force_session", asset: "human",
|
||||
label: "{a} appears in the world", tier: "Action", expect: "spawn_leave_duel",
|
||||
value: "lead=event;evt=95"),
|
||||
Step("ss61", "assert", expect: "tip_contains", value: "appears in the world"),
|
||||
Step("ss62", "assert", expect: "story_phase", value: "None"),
|
||||
Step("ss63", "assert", expect: "story_spine", value: "empty"),
|
||||
|
||||
Step("ss90", "fast_timing", value: "false"),
|
||||
Step("ss99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath.</summary>
|
||||
private static List<HarnessCommand> StoryArcCombat()
|
||||
{
|
||||
|
|
@ -1568,23 +2397,78 @@ internal static class HarnessScenarios
|
|||
Step("sac22", "status_apply", asset: "wolf", value: "invincible"),
|
||||
Step("sac23", "assert", expect: "tip_matches_any", value: "Duel -"),
|
||||
Step("sac24", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"),
|
||||
Step("sac25", "interest_expire_pending", value: ""),
|
||||
Step("sac26", "age_current", wait: 10f),
|
||||
Step("sac27", "interest_mark_combat_cold"),
|
||||
Step("sac28", "director_run", wait: 1.5f),
|
||||
Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("sac30", "assert", expect: "story_phase", value: "Aftermath"),
|
||||
Step("sac30b", "assert", expect: "story_spine", value: "Duel · Aftermath"),
|
||||
// Soft anonymous duels do not raise arcHoldMargin (HardHold=false); ownership still holds.
|
||||
// Weak ambient peer must not steal mid-aftermath.
|
||||
Step("sac31", "interest_inject", asset: "human", label: "WeakPeer", tier: "Curiosity",
|
||||
expect: "sac_weak", value: "lead=event;evt=30;char=5;force=false;ttl=60"),
|
||||
Step("sac32", "director_run", wait: 1.0f),
|
||||
Step("sac33", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("sac34", "assert", expect: "tip_not_contains", value: "WeakPeer"),
|
||||
// Leaving the story beat for cast life must drop the raised hold wall.
|
||||
Step("sac40", "interest_force_session", asset: "human",
|
||||
label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child",
|
||||
value: "lead=event;evt=76"),
|
||||
Step("sac41", "assert", expect: "tip_contains", value: "parenthood"),
|
||||
Step("sac42", "assert", expect: "story_phase", value: "None"),
|
||||
Step("sac43", "assert", expect: "story_hold_margin", value: "0"),
|
||||
Step("sac44", "assert", expect: "story_spine", value: "empty"),
|
||||
Step("sac90", "fast_timing", value: "false"),
|
||||
Step("sac99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parenthood cools world-wide for a while; a second villager cannot chain the same tip.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryFamilyVariety()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sf0", "dismiss_windows"),
|
||||
Step("sf1", "wait_world"),
|
||||
Step("sf2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sf3", "fast_timing", value: "true"),
|
||||
Step("sf4", "spawn", asset: "human", count: 2),
|
||||
Step("sf5", "spectator", value: "off"),
|
||||
Step("sf6", "spectator", value: "on"),
|
||||
Step("sf7", "pick_unit", asset: "human"),
|
||||
Step("sf8", "focus", asset: "human"),
|
||||
Step("sf9", "interest_end_session"),
|
||||
Step("sf10", "interest_variety_clear"),
|
||||
|
||||
Step("sf20", "interest_force_session", asset: "human",
|
||||
label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child",
|
||||
value: "lead=event;evt=76"),
|
||||
Step("sf21", "assert", expect: "tip_contains", value: "parenthood"),
|
||||
Step("sf22", "assert", expect: "beat_cooled", value: "just_had_child"),
|
||||
Step("sf23", "assert", expect: "variety_arc", value: "life:family"),
|
||||
|
||||
// Second human parenthood must stay beat-cooled; a peer tip may still take the camera.
|
||||
Step("sf30", "interest_end_session"),
|
||||
Step("sf31", "interest_expire_pending", value: ""),
|
||||
Step("sf32", "interest_inject", asset: "human",
|
||||
label: "{a} feels the joy of parenthood", tier: "Action", expect: "just_had_child_b",
|
||||
value: "lead=event;evt=90;char=5;force=false;ttl=60;pick=other"),
|
||||
Step("sf33", "interest_inject", asset: "human", label: "VillagePeer", tier: "Action",
|
||||
expect: "sf_peer", value: "lead=event;evt=55;char=5;force=true;ttl=60;pick=other"),
|
||||
Step("sf34", "director_run", wait: 0.8f),
|
||||
Step("sf35", "assert", expect: "tip_contains", value: "VillagePeer"),
|
||||
Step("sf36", "assert", expect: "tip_not_contains", value: "parenthood"),
|
||||
Step("sf37", "assert", expect: "beat_cooled", value: "just_had_child"),
|
||||
|
||||
Step("sf90", "fast_timing", value: "false"),
|
||||
Step("sf99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Epic preempt during climax; aftermath still available after cold.</summary>
|
||||
private static List<HarnessCommand> StoryArcPreemptResume()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ public static class InterestDirector
|
|||
/// <summary>
|
||||
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
|
||||
/// while the director still holds that scene (not quiet_grace).
|
||||
/// Sticky combat may briefly report !IsActive between swings - keep the reason up.
|
||||
/// Unit must be a tip principal (not a sticky bystander under a named duel).
|
||||
/// </summary>
|
||||
public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit)
|
||||
{
|
||||
|
|
@ -130,37 +130,125 @@ public static class InterestDirector
|
|||
return null;
|
||||
}
|
||||
|
||||
long unitId = 0;
|
||||
try
|
||||
// Dossier subject asking for the reason must match camera focus when focused.
|
||||
if (MoveCamera.hasFocusUnit()
|
||||
&& MoveCamera._focus_unit != null
|
||||
&& MoveCamera._focus_unit != unit
|
||||
&& EventFeedUtil.SafeId(MoveCamera._focus_unit) != EventFeedUtil.SafeId(unit))
|
||||
{
|
||||
unitId = unit.getID();
|
||||
}
|
||||
catch
|
||||
{
|
||||
unitId = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_current.FollowUnit == unit)
|
||||
return UnitIsReasonPrincipal(unit, _current) ? _current : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="unit"/> may show <paramref name="scene"/>'s Label as orange reason.
|
||||
/// Pair-locked duel tips only name PairOwner/PairPartner - sticky Follow alone is not enough
|
||||
/// (avoids bystander dossier + "Duel - A vs B" for unrelated fighters).
|
||||
/// </summary>
|
||||
public static bool UnitIsReasonPrincipal(Actor unit, InterestCandidate scene)
|
||||
{
|
||||
if (unit == null || scene == null)
|
||||
{
|
||||
return _current;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId)
|
||||
long unitId = EventFeedUtil.SafeId(unit);
|
||||
if (unitId == 0)
|
||||
{
|
||||
return _current;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_current.RelatedUnit == unit)
|
||||
bool ensemble = scene.Completion == InterestCompletionKind.CombatActive
|
||||
|| scene.Completion == InterestCompletionKind.WarFront
|
||||
|| scene.Completion == InterestCompletionKind.PlotActive
|
||||
|| scene.Completion == InterestCompletionKind.FamilyPack
|
||||
|| scene.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| InterestScoring.IsCombatAction(scene);
|
||||
|
||||
if (ensemble)
|
||||
{
|
||||
return _current;
|
||||
bool massTheater = scene.Sticky != null
|
||||
&& scene.Sticky.HasPresentedScale
|
||||
&& scene.Sticky.PresentedScale >= EnsembleScale.Skirmish;
|
||||
|
||||
if (scene.HasCombatPairLock && !massTheater)
|
||||
{
|
||||
// Named-pair duel: only the locked duelists own the reason.
|
||||
return unitId == scene.PairOwnerId || unitId == scene.PairPartnerId;
|
||||
}
|
||||
|
||||
if (unitId == scene.SubjectId
|
||||
|| scene.FollowUnit == unit
|
||||
|| unitId == scene.PairOwnerId
|
||||
|| unitId == scene.PairPartnerId
|
||||
|| unitId == scene.TheaterLeadId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (StickyRosterContains(scene, unitId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Related alone is not enough for ensemble theaters (crowd / wrong camp).
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId)
|
||||
// Life / discrete tips: Subject + Related are authoritative.
|
||||
// Follow alone is not enough after a camera handoff left SubjectId on someone else
|
||||
// (avoids Hillih dossier + "Iksssssa shares intimacy…" stale Label).
|
||||
if (scene.SubjectId != 0 && unitId == scene.SubjectId)
|
||||
{
|
||||
return _current;
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
if (scene.RelatedId != 0 && unitId == scene.RelatedId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (scene.RelatedUnit == unit)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (scene.FollowUnit == unit
|
||||
&& (scene.SubjectId == 0 || unitId == scene.SubjectId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool StickyRosterContains(InterestCandidate scene, long unitId)
|
||||
{
|
||||
if (scene?.Sticky == null || unitId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
LiveSceneStickyState sticky = scene.Sticky;
|
||||
for (int i = 0; i < sticky.SideAIds.Count; i++)
|
||||
{
|
||||
if (sticky.SideAIds[i] == unitId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < sticky.SideBIds.Count; i++)
|
||||
{
|
||||
if (sticky.SideBIds[i] == unitId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string InterruptedKey =>
|
||||
|
|
@ -708,6 +796,13 @@ public static class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
// Story aftermath/epilogue: promote another living cast member before ending.
|
||||
if (StoryPlanner.TryRecoverStoryFollow(_current))
|
||||
{
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
return;
|
||||
}
|
||||
|
||||
// Ownership-preserving handoff only: related survivor. Never invent a stranger.
|
||||
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
||||
{
|
||||
|
|
@ -849,6 +944,7 @@ public static class InterestDirector
|
|||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
}
|
||||
|
||||
StoryPlanner.SyncActiveClimax(_current);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -992,6 +1088,7 @@ public static class InterestDirector
|
|||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
}
|
||||
|
||||
StoryPlanner.SyncActiveClimax(_current);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1250,6 +1347,8 @@ public static class InterestDirector
|
|||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
}
|
||||
|
||||
// Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo).
|
||||
StoryPlanner.SyncActiveClimax(_current);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3231,6 +3330,15 @@ public static class InterestDirector
|
|||
}
|
||||
}
|
||||
|
||||
if (StoryPlanner.TryRecoverStoryFollow(_current))
|
||||
{
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
if (HasLivingCameraFocus())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
||||
{
|
||||
// Non-combat ownership handoff; sticky scenes already tried picker above.
|
||||
|
|
@ -3277,14 +3385,26 @@ public static class InterestDirector
|
|||
StoryPlanner.OnEndCurrent(ended, now);
|
||||
|
||||
// Resume interrupted Epic-preempted scene if still valid.
|
||||
// Soft-life / moment beats already NoteSelection-cooled must not cut back in
|
||||
// after a combat/peer cut (soak: hatch/reproduce resume spam).
|
||||
if (_interrupted != null
|
||||
&& _interrupted.HasValidPosition
|
||||
&& InterestCompletion.IsActive(_interrupted, now - _interrupted.MinWatch, now))
|
||||
{
|
||||
InterestCandidate resume = _interrupted;
|
||||
_interrupted = null;
|
||||
SwitchTo(resume, now, resumableInterrupt: false);
|
||||
return;
|
||||
if (InterestVariety.IsBeatCooled(resume, now))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"resume_cooled",
|
||||
resume.HappinessEffectId ?? resume.AssetId ?? resume.Key ?? "beat");
|
||||
InterestRegistry.Remove(resume.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
SwitchTo(resume, now, resumableInterrupt: false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer queued story aftermath/epilogue over ambient fill.
|
||||
|
|
@ -4548,7 +4668,9 @@ public static class InterestDirector
|
|||
|
||||
if (_current != null
|
||||
&& _current.Resumable
|
||||
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
|
||||
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin
|
||||
// Already-shown moment beats are cooled - parking them only enables resume spam.
|
||||
&& !InterestVariety.IsBeatCooled(_current, now))
|
||||
{
|
||||
_interrupted = _current.CloneShallow();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ public class StoryWeights
|
|||
public int ledgerCap = 16;
|
||||
public float ledgerWeight = 8f;
|
||||
public float historyDensityWindowSeconds = 600f;
|
||||
/// <summary>Multiply subject ledger heat after a family life chapter tip is shown.</summary>
|
||||
public float ledgerBleedLifeChapter = 0.4f;
|
||||
/// <summary>Multiply other ledger entries after a family life chapter (village cool).</summary>
|
||||
public float ledgerBleedVillage = 0.82f;
|
||||
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
|
||||
public float familyLifeBeatCooldownSeconds = 90f;
|
||||
}
|
||||
|
||||
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -151,6 +151,61 @@ public static class CharacterLedger
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough
|
||||
/// to monopolize the next few picks for the same village cast.
|
||||
/// </summary>
|
||||
public static void CoolAfterSoftLifeBeat(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null || !InterestVariety.IsSoftLifeChapter(scene))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f;
|
||||
float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f;
|
||||
Bleed(scene.SubjectId, subjectMul);
|
||||
if (scene.RelatedId != 0)
|
||||
{
|
||||
Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f));
|
||||
}
|
||||
|
||||
// Mild cool across the rest of the ledger so harness-featured villagers fade.
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
long id = Entries[i].UnitId;
|
||||
if (id == 0 || id == scene.SubjectId || id == scene.RelatedId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Bleed(id, villageMul);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Bleed(long unitId, float multiplyHeat)
|
||||
{
|
||||
if (unitId == 0 || multiplyHeat >= 0.999f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f);
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId != unitId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LedgerEntry e = Entries[i];
|
||||
e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat);
|
||||
Entries[i] = e;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Enumerate(List<long> into)
|
||||
{
|
||||
if (into == null)
|
||||
|
|
|
|||
|
|
@ -13,15 +13,259 @@ public static class StoryPlanner
|
|||
private static StoryArc _active;
|
||||
private static bool _coldHookFired;
|
||||
private static string _coldHookClimaxKey = "";
|
||||
private static string _coldHookAnchor = "";
|
||||
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(64);
|
||||
|
||||
public static StoryArc Active => _active != null && _active.IsActive ? _active : null;
|
||||
|
||||
/// <summary>
|
||||
/// Quiet dossier spine: "Duel · Aftermath".
|
||||
/// Empty when no live arc, or when the current watch tip is not that story beat.
|
||||
/// </summary>
|
||||
public static string FormatSpineLabel(InterestCandidate watch = null, StoryArc arc = null)
|
||||
{
|
||||
watch = watch ?? InterestDirector.CurrentCandidate;
|
||||
// Sticky combat can reframe Battle↔Duel without SwitchTo - keep Kind honest first.
|
||||
SyncActiveClimax(watch);
|
||||
arc = arc ?? Active;
|
||||
if (arc == null || !arc.IsActive || !BelongsToActiveStoryBeat(watch, arc))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string kind = FormatKindLabel(arc.Kind);
|
||||
// Combat tip prose owns the Kind word (Battle/Skirmish/Mass/Duel) so the spine
|
||||
// matches the orange tip, not a collapsed CombatMass bucket.
|
||||
string tipKind = CombatSpineKindFromTip(watch);
|
||||
if (!string.IsNullOrEmpty(tipKind))
|
||||
{
|
||||
kind = tipKind;
|
||||
}
|
||||
|
||||
string phase = FormatPhaseLabel(arc.Phase);
|
||||
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(phase))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return kind + " · " + phase;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spine Kind word from the live tip Label (Duel/Skirmish/Battle/Mass).
|
||||
/// Empty when the tip is not a framed combat scale tip.
|
||||
/// </summary>
|
||||
private static string CombatSpineKindFromTip(InterestCandidate watch)
|
||||
{
|
||||
if (watch == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string label = watch.Label ?? "";
|
||||
if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Duel";
|
||||
}
|
||||
|
||||
if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Skirmish";
|
||||
}
|
||||
|
||||
if (label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Battle";
|
||||
}
|
||||
|
||||
if (label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Mass";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the current combat tip changes scale in place (no director SwitchTo),
|
||||
/// retarget the active climax Kind (Mass ↔ Duel) so the spine matches the tip.
|
||||
/// </summary>
|
||||
public static void SyncActiveClimax(InterestCandidate current = null)
|
||||
{
|
||||
current = current ?? InterestDirector.CurrentCandidate;
|
||||
if (_active == null || !_active.IsActive || _active.Phase != StoryPhase.Climax || current == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryClassifyClimax(current, out StoryArcKind kind, out bool hardHold))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string anchor = AnchorFor(current, kind);
|
||||
bool sameAnchor = string.Equals(_active.AnchorKey, anchor, StringComparison.Ordinal);
|
||||
bool sameCombatTheater = IsCombatKind(_active.Kind)
|
||||
&& IsCombatKind(kind)
|
||||
&& SameCombatTheater(_active, current);
|
||||
if (!sameAnchor && !sameCombatTheater)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_active.Kind != kind)
|
||||
{
|
||||
_active.Kind = kind;
|
||||
_active.Id = "arc:" + kind + ":" + (_active.AnchorKey ?? anchor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(anchor)
|
||||
&& (sameAnchor || sameCombatTheater))
|
||||
{
|
||||
_active.AnchorKey = anchor;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(current.Key))
|
||||
{
|
||||
_active.ClimaxKey = current.Key;
|
||||
}
|
||||
|
||||
_active.HardHold = hardHold || _active.HardHold;
|
||||
NoteCastFrom(current, _active);
|
||||
}
|
||||
|
||||
private static bool IsCombatKind(StoryArcKind kind) =>
|
||||
kind == StoryArcKind.CombatDuel || kind == StoryArcKind.CombatMass;
|
||||
|
||||
private static bool SameCombatTheater(StoryArc arc, InterestCandidate c)
|
||||
{
|
||||
if (arc == null || c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(arc.ClimaxKey)
|
||||
&& string.Equals(arc.ClimaxKey, c.Key, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c.PairOwnerId != 0 && c.PairPartnerId != 0)
|
||||
{
|
||||
string pair = EventCatalog.Combat.PairKey(c.PairOwnerId, c.PairPartnerId);
|
||||
if (!string.IsNullOrEmpty(arc.AnchorKey)
|
||||
&& string.Equals(arc.AnchorKey, pair, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return arc.ContainsCast(c.SubjectId) && (c.RelatedId == 0 || arc.ContainsCast(c.RelatedId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="c"/> is the active arc's climax tip or story aftermath/epilogue beat.
|
||||
/// Cast membership alone is not enough (avoids stale Love/Duel spines on sleep/hatch tips).
|
||||
/// </summary>
|
||||
public static bool BelongsToActiveStoryBeat(InterestCandidate c, StoryArc arc = null)
|
||||
{
|
||||
arc = arc ?? Active;
|
||||
if (c == null || arc == null || !arc.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsStoryAssetCandidate(c))
|
||||
{
|
||||
// Aftermath/epilogue tips only while the arc is in those phases,
|
||||
// and only for THIS arc (CorrelationKey) - never a stale peer story tip.
|
||||
if (arc.Phase != StoryPhase.Aftermath && arc.Phase != StoryPhase.Epilogue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(arc.AnchorKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string expect = "story:" + arc.AnchorKey;
|
||||
return string.Equals(c.CorrelationKey, expect, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Cold climax tip must not show "Mass · Aftermath" while fleeing after inject.
|
||||
if (!string.IsNullOrEmpty(arc.ClimaxKey)
|
||||
&& string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal))
|
||||
{
|
||||
return arc.Phase == StoryPhase.Climax;
|
||||
}
|
||||
|
||||
// Same sticky theater refreshed under the same anchor while still in climax.
|
||||
// Kind may still be syncing Mass↔Duel - allow either combat kind on the same theater.
|
||||
if (arc.Phase == StoryPhase.Climax
|
||||
&& TryClassifyClimax(c, out StoryArcKind kind, out _)
|
||||
&& IsCombatKind(arc.Kind)
|
||||
&& IsCombatKind(kind)
|
||||
&& (kind == arc.Kind
|
||||
|| SameCombatTheater(arc, c)
|
||||
|| string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arc.Phase == StoryPhase.Climax
|
||||
&& TryClassifyClimax(c, out kind, out _)
|
||||
&& kind == arc.Kind
|
||||
&& string.Equals(AnchorFor(c, kind), arc.AnchorKey, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string FormatKindLabel(StoryArcKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case StoryArcKind.CombatDuel:
|
||||
return "Duel";
|
||||
case StoryArcKind.CombatMass:
|
||||
return "Mass";
|
||||
case StoryArcKind.WarFront:
|
||||
return "War front";
|
||||
case StoryArcKind.Plot:
|
||||
return "Plot";
|
||||
case StoryArcKind.Love:
|
||||
return "Love";
|
||||
case StoryArcKind.Grief:
|
||||
return "Grief";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static string FormatPhaseLabel(StoryPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case StoryPhase.Climax:
|
||||
return "Climax";
|
||||
case StoryPhase.Aftermath:
|
||||
return "Aftermath";
|
||||
case StoryPhase.Epilogue:
|
||||
return "Epilogue";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_active = null;
|
||||
_coldHookFired = false;
|
||||
_coldHookClimaxKey = "";
|
||||
_coldHookAnchor = "";
|
||||
CausalHeat.Clear();
|
||||
CharacterLedger.Clear();
|
||||
}
|
||||
|
|
@ -33,6 +277,8 @@ public static class StoryPlanner
|
|||
return;
|
||||
}
|
||||
|
||||
SyncActiveClimax(InterestDirector.CurrentCandidate);
|
||||
|
||||
// Stale arcs without a living cast eventually end.
|
||||
if (_active.Phase == StoryPhase.Aftermath || _active.Phase == StoryPhase.Epilogue)
|
||||
{
|
||||
|
|
@ -81,6 +327,13 @@ public static class StoryPlanner
|
|||
if (TryClassifyClimax(next, out StoryArcKind kind, out bool hardHold))
|
||||
{
|
||||
BeginOrRefreshArc(next, kind, hardHold, now);
|
||||
return;
|
||||
}
|
||||
|
||||
// Camera left the story for an unrelated tip - do not keep a lying active arc.
|
||||
if (_active != null && _active.IsActive && !BelongsToActiveStoryBeat(next, _active))
|
||||
{
|
||||
_active.Advance(StoryPhase.Done, now);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,15 +344,11 @@ public static class StoryPlanner
|
|||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_active.ClimaxKey)
|
||||
&& string.Equals(ended.Key, _active.ClimaxKey, StringComparison.Ordinal)
|
||||
&& _active.Phase == StoryPhase.Climax)
|
||||
// follow_lost / max_cap often end a soft duel before quiet-grace cold.
|
||||
// Match by theater (Mass↔Duel key flip), not exact ClimaxKey only.
|
||||
if (_active.Phase == StoryPhase.Climax && BelongsToClimaxTheater(ended, _active))
|
||||
{
|
||||
// Cold-hook should have advanced; if not, still try aftermath once.
|
||||
if (!_coldHookFired || !string.Equals(_coldHookClimaxKey, ended.Key, StringComparison.Ordinal))
|
||||
{
|
||||
OnClimaxCold(ended);
|
||||
}
|
||||
OnClimaxCold(ended);
|
||||
}
|
||||
|
||||
if (IsStoryAssetCandidate(ended))
|
||||
|
|
@ -126,9 +375,26 @@ public static class StoryPlanner
|
|||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
// Once per climax key - director may re-enter inactive while min-dwell resets grace.
|
||||
// Sticky combat can reframe keys in place before cold - keep arc/climax aligned.
|
||||
if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax)
|
||||
{
|
||||
SyncActiveClimax(climax);
|
||||
}
|
||||
|
||||
string climaxKey = climax.Key ?? "";
|
||||
string anchor = _active != null && _active.IsActive
|
||||
? (_active.AnchorKey ?? "")
|
||||
: "";
|
||||
if (string.IsNullOrEmpty(anchor) && TryClassifyClimax(climax, out StoryArcKind classifyKind, out _))
|
||||
{
|
||||
anchor = AnchorFor(climax, classifyKind);
|
||||
}
|
||||
|
||||
// Once per climax theater (anchor), not per sticky key refresh.
|
||||
if (_coldHookFired
|
||||
&& string.Equals(_coldHookClimaxKey, climax.Key, StringComparison.Ordinal))
|
||||
&& (!string.IsNullOrEmpty(_coldHookAnchor)
|
||||
&& string.Equals(_coldHookAnchor, anchor, StringComparison.Ordinal)
|
||||
|| string.Equals(_coldHookClimaxKey, climaxKey, StringComparison.Ordinal)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -139,8 +405,7 @@ public static class StoryPlanner
|
|||
return;
|
||||
}
|
||||
|
||||
if (_active == null || !_active.IsActive
|
||||
|| !string.Equals(_active.ClimaxKey, climax.Key, StringComparison.Ordinal))
|
||||
if (_active == null || !_active.IsActive || !BelongsToClimaxTheater(climax, _active))
|
||||
{
|
||||
if (!TryClassifyClimax(climax, out kind, out hardHold))
|
||||
{
|
||||
|
|
@ -148,10 +413,22 @@ public static class StoryPlanner
|
|||
}
|
||||
|
||||
BeginOrRefreshArc(climax, kind, hardHold, now);
|
||||
anchor = _active != null ? (_active.AnchorKey ?? anchor) : anchor;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(climaxKey))
|
||||
{
|
||||
_active.ClimaxKey = climaxKey;
|
||||
if (!string.IsNullOrEmpty(_active.AnchorKey))
|
||||
{
|
||||
anchor = _active.AnchorKey;
|
||||
}
|
||||
}
|
||||
|
||||
_coldHookFired = true;
|
||||
_coldHookClimaxKey = climax.Key ?? "";
|
||||
_coldHookClimaxKey = !string.IsNullOrEmpty(climaxKey)
|
||||
? climaxKey
|
||||
: (_active != null ? (_active.ClimaxKey ?? "") : "");
|
||||
_coldHookAnchor = anchor ?? "";
|
||||
|
||||
if (TryClaimNaturalGrief(climax, _active))
|
||||
{
|
||||
|
|
@ -168,14 +445,50 @@ public static class StoryPlanner
|
|||
|
||||
if (HasStrongerPendingPeer(climax))
|
||||
{
|
||||
// Natural drama already queued (e.g. lover tip in combat_hold) - do not steal.
|
||||
// Natural grief/love aftermath already queued - do not synthesize a twin.
|
||||
// End climax ownership so spine/hold cannot stick across the peer tip.
|
||||
if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax)
|
||||
{
|
||||
_active.Advance(StoryPhase.Done, now);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryInjectAftermath(climax, _active, now))
|
||||
{
|
||||
_active.Advance(StoryPhase.Aftermath, now);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_active != null && _active.IsActive && _active.Phase == StoryPhase.Climax)
|
||||
{
|
||||
_active.Advance(StoryPhase.Done, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same climax theater even when sticky combat refreshes the candidate key (Battle↔Duel).
|
||||
/// </summary>
|
||||
private static bool BelongsToClimaxTheater(InterestCandidate c, StoryArc arc)
|
||||
{
|
||||
if (c == null || arc == null || !arc.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(arc.ClimaxKey)
|
||||
&& string.Equals(c.Key, arc.ClimaxKey, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (BelongsToActiveStoryBeat(c, arc))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsCombatKind(arc.Kind) && SameCombatTheater(arc, c);
|
||||
}
|
||||
|
||||
public static bool IsContinuationOf(InterestCandidate current, InterestCandidate next)
|
||||
|
|
@ -269,6 +582,14 @@ public static class StoryPlanner
|
|||
return 0f;
|
||||
}
|
||||
|
||||
// Hold only while the camera is still on the aftermath/epilogue beat.
|
||||
// Cast life tips (parenthood, sleep) must not keep the raised wall or the village
|
||||
// ping-pongs under a fake sticky margin.
|
||||
if (!BelongsToActiveStoryBeat(current, _active))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (OwnsCandidate(next) || IsContinuationOf(current, next))
|
||||
{
|
||||
return 0f;
|
||||
|
|
@ -291,6 +612,18 @@ public static class StoryPlanner
|
|||
return false;
|
||||
}
|
||||
|
||||
// Injected aftermath/epilogue must win quiet-grace picks over cast life noise
|
||||
// (hatch/food/sleep), even when those tips have higher raw EventStrength.
|
||||
if (IsStoryAssetCandidate(a)
|
||||
&& !IsStoryAssetCandidate(b)
|
||||
&& (_active.Phase == StoryPhase.Aftermath
|
||||
|| _active.Phase == StoryPhase.Epilogue
|
||||
|| _active.Phase == StoryPhase.Climax)
|
||||
&& !IsNaturalAftermathPeer(b, InterestDirector.CurrentCandidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bool aOwn = OwnsCandidate(a);
|
||||
bool bOwn = OwnsCandidate(b);
|
||||
return aOwn && !bOwn;
|
||||
|
|
@ -332,6 +665,7 @@ public static class StoryPlanner
|
|||
NoteCastFrom(climax, _active);
|
||||
_coldHookFired = false;
|
||||
_coldHookClimaxKey = "";
|
||||
_coldHookAnchor = "";
|
||||
CausalHeat.NoteCast(_active.CastIds);
|
||||
}
|
||||
|
||||
|
|
@ -392,6 +726,10 @@ public static class StoryPlanner
|
|||
mass = true;
|
||||
}
|
||||
|
||||
// Live tip prose wins over sticky scale-hold so spine Kind matches the Label
|
||||
// (Duel - … must not keep Mass · Climax after a count dip).
|
||||
CombatTipScaleHint(c, ref mass);
|
||||
|
||||
bool notablePair = c.NotableParticipantCount >= 2;
|
||||
if (mass)
|
||||
{
|
||||
|
|
@ -411,6 +749,92 @@ public static class StoryPlanner
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefer the orange tip's presented scale over sticky hysteresis for arc Kind.
|
||||
/// </summary>
|
||||
private static void CombatTipScaleHint(InterestCandidate c, ref bool mass)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string label = c.Label ?? "";
|
||||
if (label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
|| label.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
mass = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (label.StartsWith("Skirmish", StringComparison.OrdinalIgnoreCase)
|
||||
|| label.StartsWith("Battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| label.StartsWith("Mass", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mass = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When a story aftermath/epilogue Follow dies, promote another living cast member
|
||||
/// so the beat is not dropped as follow_lost.
|
||||
/// </summary>
|
||||
public static bool TryRecoverStoryFollow(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null || !IsStoryAssetCandidate(scene))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scene.HasFollowUnit)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
StoryArc arc = Active;
|
||||
Actor next = null;
|
||||
if (arc != null && arc.IsActive)
|
||||
{
|
||||
for (int i = 0; i < arc.CastIds.Count; i++)
|
||||
{
|
||||
Actor a = EventFeedUtil.FindAliveById(arc.CastIds[i]);
|
||||
if (a != null)
|
||||
{
|
||||
next = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (next == null && scene.RelatedUnit != null && scene.RelatedUnit.isAlive())
|
||||
{
|
||||
next = scene.RelatedUnit;
|
||||
}
|
||||
|
||||
if (next == null && scene.RelatedId != 0)
|
||||
{
|
||||
next = EventFeedUtil.FindAliveById(scene.RelatedId);
|
||||
}
|
||||
|
||||
if (next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
scene.FollowUnit = next;
|
||||
scene.SubjectId = EventFeedUtil.SafeId(next);
|
||||
try
|
||||
{
|
||||
scene.Position = next.current_position;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep
|
||||
}
|
||||
|
||||
return scene.HasFollowUnit;
|
||||
}
|
||||
|
||||
private static bool IsLoveBeat(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
|
|
@ -419,23 +843,37 @@ public static class StoryPlanner
|
|||
}
|
||||
|
||||
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
||||
if (asset == "set_lover" || asset == "find_lover" || asset == AftermathLoveKey())
|
||||
// Outcome bonds only. Seeking / find_lover intent is camera-worthy but not a Love climax.
|
||||
if (asset == "set_lover"
|
||||
|| asset == "fallen_in_love"
|
||||
|| asset == "fell_in_love"
|
||||
|| asset == AftermathLoveKey())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (asset == "find_lover" || asset == "clear_lover")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string hap = (c.HappinessEffectId ?? "").Trim().ToLowerInvariant();
|
||||
if (hap.IndexOf("lover", StringComparison.Ordinal) >= 0
|
||||
|| hap.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0
|
||||
if (hap == "fallen_in_love"
|
||||
|| hap == "fell_in_love"
|
||||
|| hap.IndexOf("married", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Death-of-lover grief is Grief, not Love.
|
||||
if (hap.StartsWith("death_", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string cat = (c.Category ?? "").Trim();
|
||||
return string.Equals(cat, "Relationship", StringComparison.OrdinalIgnoreCase)
|
||||
&& (asset.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| asset.IndexOf("love", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
&& asset == "set_lover";
|
||||
}
|
||||
|
||||
private static string AftermathLoveKey() => StoryReason.AftermathLoveLinger;
|
||||
|
|
@ -584,8 +1022,6 @@ public static class StoryPlanner
|
|||
|
||||
private static bool HasStrongerPendingPeer(InterestCandidate climax)
|
||||
{
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
float floor = s.aftermathStrength - 5f;
|
||||
PendingScratch.Clear();
|
||||
InterestRegistry.CopyPending(PendingScratch);
|
||||
for (int i = 0; i < PendingScratch.Count; i++)
|
||||
|
|
@ -601,23 +1037,9 @@ public static class StoryPlanner
|
|||
continue;
|
||||
}
|
||||
|
||||
if (p.EventStrength < floor && p.TotalScore < floor)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer natural life/story peers already queued for this cast.
|
||||
if (climax != null
|
||||
&& (p.SubjectId == climax.SubjectId
|
||||
|| p.SubjectId == climax.RelatedId
|
||||
|| p.RelatedId == climax.SubjectId
|
||||
|| (_active != null && _active.ContainsCast(p.SubjectId))))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// High-strength parked Action tips (lover injects) also block synthetic aftermath.
|
||||
if (p.EventStrength >= s.aftermathStrength)
|
||||
// Only true natural aftermath peers block synthesis - not hatch/food/village
|
||||
// cast noise that used to starve soft-duel linger tips.
|
||||
if (IsNaturalAftermathPeer(p, climax))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -626,6 +1048,48 @@ public static class StoryPlanner
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grief / love-outcome tips that already finish the climax story for this cast.
|
||||
/// Ambient cast life (hatch, eat, sleep) must not suppress aftermath.
|
||||
/// </summary>
|
||||
private static bool IsNaturalAftermathPeer(InterestCandidate p, InterestCandidate climax)
|
||||
{
|
||||
if (p == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool grief = p.Completion == InterestCompletionKind.HappinessGrief
|
||||
|| (!string.IsNullOrEmpty(p.HappinessEffectId)
|
||||
&& p.HappinessEffectId.StartsWith("death_", StringComparison.OrdinalIgnoreCase));
|
||||
bool loveOutcome = IsLoveBeat(p);
|
||||
if (!grief && !loveOutcome)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (climax == null && _active == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (climax != null
|
||||
&& (p.SubjectId == climax.SubjectId
|
||||
|| p.SubjectId == climax.RelatedId
|
||||
|| p.RelatedId == climax.SubjectId
|
||||
|| p.RelatedId == climax.RelatedId
|
||||
|| p.SubjectId == climax.PairOwnerId
|
||||
|| p.SubjectId == climax.PairPartnerId
|
||||
|| p.RelatedId == climax.PairOwnerId
|
||||
|| p.RelatedId == climax.PairPartnerId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _active != null
|
||||
&& (_active.ContainsCast(p.SubjectId) || _active.ContainsCast(p.RelatedId));
|
||||
}
|
||||
|
||||
private static bool TryInjectAftermath(InterestCandidate climax, StoryArc arc, float now)
|
||||
{
|
||||
if (climax == null || arc == null)
|
||||
|
|
@ -639,7 +1103,7 @@ public static class StoryPlanner
|
|||
return false;
|
||||
}
|
||||
|
||||
Actor related = ResolveAftermathRelated(climax, follow);
|
||||
Actor related = ResolveAftermathRelated(climax, follow, arc.Kind);
|
||||
string assetId = PickAftermathAsset(arc.Kind, related);
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
DiscreteEventEntry catalog = EventCatalog.Story.GetOrFallback(assetId);
|
||||
|
|
@ -647,9 +1111,33 @@ public static class StoryPlanner
|
|||
? catalog.EventStrength
|
||||
: s.aftermathStrength;
|
||||
string label = StoryReason.AftermathLabel(assetId, follow, related);
|
||||
string key = "aftermath:" + arc.Kind + ":" + arc.AnchorKey;
|
||||
long followId = EventFeedUtil.SafeId(follow);
|
||||
long relatedId = EventFeedUtil.SafeId(related);
|
||||
// Camera RelatedUnit must be living; dead theater partners stay as RelatedId for cast/naming.
|
||||
Actor relatedLive = null;
|
||||
if (related != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (related.isAlive())
|
||||
{
|
||||
relatedLive = related;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
relatedLive = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsCombatKind(arc.Kind))
|
||||
{
|
||||
// Survivor tip never binds a living bystander as the "fallen" related.
|
||||
relatedLive = null;
|
||||
}
|
||||
// Unique per inject so a prior Selected aftermath cannot block the next cold.
|
||||
string key = "aftermath:" + arc.Kind + ":" + arc.AnchorKey + ":" + followId + ":"
|
||||
+ Mathf.RoundToInt(now * 1000f);
|
||||
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
|
|
@ -661,7 +1149,7 @@ public static class StoryPlanner
|
|||
Label = label,
|
||||
FollowUnit = follow,
|
||||
SubjectId = followId,
|
||||
RelatedUnit = related,
|
||||
RelatedUnit = relatedLive,
|
||||
RelatedId = relatedId,
|
||||
Position = follow.current_position,
|
||||
EventStrength = strength > 0f ? strength : s.aftermathStrength,
|
||||
|
|
@ -684,7 +1172,11 @@ public static class StoryPlanner
|
|||
}
|
||||
|
||||
arc.NoteCast(followId);
|
||||
arc.NoteCast(relatedId);
|
||||
// Theater partner (alive or fallen) only - never social fallbacks.
|
||||
if (relatedId != 0 && relatedId != followId)
|
||||
{
|
||||
arc.NoteCast(relatedId);
|
||||
}
|
||||
arc.PendingBeats.Enqueue(new StoryBeat
|
||||
{
|
||||
Phase = StoryPhase.Aftermath,
|
||||
|
|
@ -886,7 +1378,14 @@ public static class StoryPlanner
|
|||
return null;
|
||||
}
|
||||
|
||||
private static Actor ResolveAftermathRelated(InterestCandidate climax, Actor follow)
|
||||
/// <summary>
|
||||
/// Theater partner for aftermath naming. Combat never falls back to lover/best friend
|
||||
/// (those are living bystanders and produce lying "stands over X" tips).
|
||||
/// </summary>
|
||||
private static Actor ResolveAftermathRelated(
|
||||
InterestCandidate climax,
|
||||
Actor follow,
|
||||
StoryArcKind kind)
|
||||
{
|
||||
if (climax == null)
|
||||
{
|
||||
|
|
@ -894,23 +1393,61 @@ public static class StoryPlanner
|
|||
}
|
||||
|
||||
long followId = EventFeedUtil.SafeId(follow);
|
||||
if (kind == StoryArcKind.Love)
|
||||
{
|
||||
if (climax.RelatedUnit != null
|
||||
&& climax.RelatedUnit.isAlive()
|
||||
&& EventFeedUtil.SafeId(climax.RelatedUnit) != followId)
|
||||
{
|
||||
return climax.RelatedUnit;
|
||||
}
|
||||
|
||||
if (climax.RelatedId != 0 && climax.RelatedId != followId)
|
||||
{
|
||||
Actor bonded = EventFeedUtil.FindAliveById(climax.RelatedId);
|
||||
if (bonded != null)
|
||||
{
|
||||
return bonded;
|
||||
}
|
||||
}
|
||||
|
||||
Actor lover = ActorRelation.GetLover(follow);
|
||||
if (lover != null)
|
||||
{
|
||||
return lover;
|
||||
}
|
||||
|
||||
return ActorRelation.GetBestFriend(follow);
|
||||
}
|
||||
|
||||
if (kind == StoryArcKind.WarFront || kind == StoryArcKind.Plot)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Combat / grief / default: climax theater partner only (alive or dead for naming).
|
||||
return ResolveTheaterPartnerAny(climax, followId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Duel/mass partner from pair lock, RelatedId, or opposing sticky side - including dead units.
|
||||
/// </summary>
|
||||
private static Actor ResolveTheaterPartnerAny(InterestCandidate climax, long followId)
|
||||
{
|
||||
if (climax == null || followId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (climax.RelatedUnit != null
|
||||
&& climax.RelatedUnit.isAlive()
|
||||
&& EventFeedUtil.SafeId(climax.RelatedUnit) != followId)
|
||||
{
|
||||
return climax.RelatedUnit;
|
||||
}
|
||||
|
||||
if (climax.FollowUnit != null
|
||||
&& climax.FollowUnit.isAlive()
|
||||
&& EventFeedUtil.SafeId(climax.FollowUnit) != followId)
|
||||
{
|
||||
return climax.FollowUnit;
|
||||
}
|
||||
|
||||
if (climax.PairPartnerId != 0 && climax.PairPartnerId != followId)
|
||||
{
|
||||
Actor p = EventFeedUtil.FindAliveById(climax.PairPartnerId);
|
||||
Actor p = EventFeedUtil.FindUnitById(climax.PairPartnerId);
|
||||
if (p != null)
|
||||
{
|
||||
return p;
|
||||
|
|
@ -919,20 +1456,67 @@ public static class StoryPlanner
|
|||
|
||||
if (climax.PairOwnerId != 0 && climax.PairOwnerId != followId)
|
||||
{
|
||||
Actor p = EventFeedUtil.FindAliveById(climax.PairOwnerId);
|
||||
Actor p = EventFeedUtil.FindUnitById(climax.PairOwnerId);
|
||||
if (p != null)
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
Actor lover = ActorRelation.GetLover(follow);
|
||||
if (lover != null)
|
||||
if (climax.RelatedId != 0 && climax.RelatedId != followId)
|
||||
{
|
||||
return lover;
|
||||
Actor p = EventFeedUtil.FindUnitById(climax.RelatedId);
|
||||
if (p != null)
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return ActorRelation.GetBestFriend(follow);
|
||||
if (climax.Sticky == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool followOnA = false;
|
||||
for (int i = 0; i < climax.Sticky.SideAIds.Count; i++)
|
||||
{
|
||||
if (climax.Sticky.SideAIds[i] == followId)
|
||||
{
|
||||
followOnA = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var opposite = followOnA ? climax.Sticky.SideBIds : climax.Sticky.SideAIds;
|
||||
// Prefer a dead opposite (true fallen), else first opposite for lookup.
|
||||
Actor livingOpposite = null;
|
||||
for (int i = 0; i < opposite.Count; i++)
|
||||
{
|
||||
Actor a = EventFeedUtil.FindUnitById(opposite[i]);
|
||||
if (a == null || EventFeedUtil.SafeId(a) == followId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!a.isAlive())
|
||||
{
|
||||
return a;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return a;
|
||||
}
|
||||
|
||||
if (livingOpposite == null)
|
||||
{
|
||||
livingOpposite = a;
|
||||
}
|
||||
}
|
||||
|
||||
return livingOpposite;
|
||||
}
|
||||
|
||||
private static string PickAftermathAsset(StoryArcKind kind, Actor related)
|
||||
|
|
@ -948,9 +1532,7 @@ public static class StoryPlanner
|
|||
case StoryArcKind.Grief:
|
||||
return StoryReason.AftermathMourner;
|
||||
default:
|
||||
return related != null && !related.isAlive()
|
||||
? StoryReason.AftermathSurvivor
|
||||
: StoryReason.AftermathSurvivor;
|
||||
return StoryReason.AftermathSurvivor;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,9 +51,27 @@ public static class StoryReason
|
|||
: name + " seeks " + other + " after the story";
|
||||
case AftermathSurvivor:
|
||||
default:
|
||||
return string.IsNullOrEmpty(other)
|
||||
? name + " stands over the fallen"
|
||||
: name + " stands over " + other;
|
||||
// Only name a dead theater partner. Living lover/friend fallbacks must never
|
||||
// print as the fallen (soak: "stands over Clemond" after a duel vs someone else).
|
||||
if (related != null)
|
||||
{
|
||||
bool dead = false;
|
||||
try
|
||||
{
|
||||
dead = !related.isAlive();
|
||||
}
|
||||
catch
|
||||
{
|
||||
dead = true;
|
||||
}
|
||||
|
||||
if (dead && !string.IsNullOrEmpty(other))
|
||||
{
|
||||
return name + " stands over " + other;
|
||||
}
|
||||
}
|
||||
|
||||
return name + " stands over the fallen";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -410,6 +410,15 @@ public sealed class UnitDossier
|
|||
return "";
|
||||
}
|
||||
|
||||
// Dossier subject must own the leading proper name on non-ensemble tips.
|
||||
// Catches Follow/Subject drift where the tip Label still names someone else.
|
||||
if (!IsEnsembleCombatReason(beat) && ReasonLeadsWithForeignDossierName(beat, actor, d, scene))
|
||||
{
|
||||
InterestDropLog.Record("identity_filter", "dossier_foreign:" + beat);
|
||||
d.ReasonRelatedId = 0;
|
||||
return "";
|
||||
}
|
||||
|
||||
// Gold names inside the orange reason row (same palette as history/activity).
|
||||
string subjectName = d != null ? (d.Name ?? "") : "";
|
||||
if (string.IsNullOrEmpty(subjectName))
|
||||
|
|
@ -543,7 +552,18 @@ public sealed class UnitDossier
|
|||
}
|
||||
|
||||
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
|
||||
if (string.IsNullOrEmpty(subjectName))
|
||||
{
|
||||
Actor subject = EventFeedUtil.FindAliveById(scene.SubjectId);
|
||||
subjectName = EventFeedUtil.SafeName(subject);
|
||||
}
|
||||
|
||||
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
|
||||
if (string.IsNullOrEmpty(relatedName) && scene.RelatedId != 0)
|
||||
{
|
||||
relatedName = EventFeedUtil.SafeName(EventFeedUtil.FindAliveById(scene.RelatedId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(subjectName)
|
||||
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
@ -572,7 +592,101 @@ public sealed class UnitDossier
|
|||
continue;
|
||||
}
|
||||
|
||||
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
|
||||
long id = EventFeedUtil.SafeId(unit);
|
||||
if (unit == scene.FollowUnit
|
||||
|| unit == scene.RelatedUnit
|
||||
|| (scene.SubjectId != 0 && id == scene.SubjectId)
|
||||
|| (scene.RelatedId != 0 && id == scene.RelatedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string name = EventFeedUtil.SafeName(unit);
|
||||
if (string.IsNullOrEmpty(name) || name.Length < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first.Equals(FirstToken(name), System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the reason leads with a living unit name that is not the dossier subject
|
||||
/// (and not the scene's related partner when the dossier subject is that partner).
|
||||
/// </summary>
|
||||
private static bool ReasonLeadsWithForeignDossierName(
|
||||
string reason,
|
||||
Actor dossierActor,
|
||||
UnitDossier dossier,
|
||||
InterestCandidate scene)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason) || scene == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string first = FirstToken(reason);
|
||||
if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string dossierName = dossier != null ? (dossier.Name ?? "") : "";
|
||||
if (string.IsNullOrEmpty(dossierName))
|
||||
{
|
||||
dossierName = EventFeedUtil.SafeName(dossierActor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(dossierName)
|
||||
&& first.Equals(FirstToken(dossierName), System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Related partner may show a subject-led sentence ("Iksssssa shares intimacy with Anya")
|
||||
// only when the dossier subject is that related partner.
|
||||
long dossierId = dossier != null ? dossier.UnitId : EventFeedUtil.SafeId(dossierActor);
|
||||
if (dossierId != 0
|
||||
&& scene.RelatedId != 0
|
||||
&& dossierId == scene.RelatedId)
|
||||
{
|
||||
Actor subject = scene.FollowUnit != null && scene.FollowUnit.isAlive()
|
||||
? scene.FollowUnit
|
||||
: EventFeedUtil.FindAliveById(scene.SubjectId);
|
||||
string subjectName = EventFeedUtil.SafeName(subject);
|
||||
if (!string.IsNullOrEmpty(subjectName)
|
||||
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (World.world?.units == null || first.Length < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Actor unit in World.world.units)
|
||||
{
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unit == dossierActor)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ using UnityEngine.UI;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason.
|
||||
/// Compact dossier: nametag (species/name/lv/task/sex), avatar + mini History, statuses, traits, reason, story spine.
|
||||
/// </summary>
|
||||
public static class WatchCaption
|
||||
{
|
||||
|
|
@ -34,6 +34,7 @@ public static class WatchCaption
|
|||
private const float TraitsH = 14f;
|
||||
private const float StatusesH = 14f;
|
||||
private const float ReasonH = 14f;
|
||||
private const float StorySpineH = 12f;
|
||||
private const float HistoryLineMinH = 13f;
|
||||
private const float HistoryLineMaxH = 100f;
|
||||
private const float Gap = 2f;
|
||||
|
|
@ -51,6 +52,7 @@ public static class WatchCaption
|
|||
private static readonly Color StatValueColor = HudTheme.ValueOrange;
|
||||
private static readonly Color TaskColor = HudTheme.TaskGreen;
|
||||
private static readonly Color ReasonColor = HudTheme.ValueOrange;
|
||||
private static readonly Color StorySpineColor = HudTheme.Muted;
|
||||
private static readonly Color TraitNameColor = HudTheme.Body;
|
||||
private static readonly Color StatusNameColor = HudTheme.StatusBlue;
|
||||
private static readonly Color HistoryTextColor = HudTheme.Body;
|
||||
|
|
@ -62,6 +64,7 @@ public static class WatchCaption
|
|||
private static Image _sexIcon;
|
||||
private static Text _nameText;
|
||||
private static Text _reasonText;
|
||||
private static Text _storySpineText;
|
||||
private static long _reasonOtherId;
|
||||
|
||||
private static Image _levelIcon;
|
||||
|
|
@ -129,6 +132,9 @@ public static class WatchCaption
|
|||
|
||||
public static string LastCaptionText { get; private set; } = "";
|
||||
|
||||
/// <summary>Harness/UI: active story spine ("Duel · Aftermath"), empty when none.</summary>
|
||||
public static string LastStorySpine { get; private set; } = "";
|
||||
|
||||
/// <summary>Harness: newest mini-history line currently shown (if any).</summary>
|
||||
public static string LastHistoryPreview { get; private set; } = "";
|
||||
|
||||
|
|
@ -407,6 +413,7 @@ public static class WatchCaption
|
|||
LastHeadline = "";
|
||||
LastDetail = "";
|
||||
LastCaptionText = "";
|
||||
LastStorySpine = "";
|
||||
LastHistoryPreview = "";
|
||||
LastHistoryJoined = "";
|
||||
LastHistoryShown = 0;
|
||||
|
|
@ -461,6 +468,9 @@ public static class WatchCaption
|
|||
WireReasonClick(0);
|
||||
}
|
||||
|
||||
// Status banners temporarily own the reason row - hide the story spine.
|
||||
ApplyStorySpine("");
|
||||
|
||||
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
|
||||
// Re-fill from the current subject so Relayout cannot resurrect stale history slots
|
||||
// from a previously focused unit (activeSelf stays true while the column is hidden).
|
||||
|
|
@ -604,6 +614,7 @@ public static class WatchCaption
|
|||
RefreshLiveIdentity();
|
||||
RefreshLiveStatuses();
|
||||
RefreshOwnedReason();
|
||||
RefreshStorySpine();
|
||||
RefreshHistoryIfChanged();
|
||||
return;
|
||||
}
|
||||
|
|
@ -660,6 +671,7 @@ public static class WatchCaption
|
|||
RefreshLiveIdentity();
|
||||
RefreshLiveStatuses();
|
||||
RefreshOwnedReason();
|
||||
RefreshStorySpine();
|
||||
RefreshHistoryIfChanged();
|
||||
}
|
||||
|
||||
|
|
@ -836,7 +848,11 @@ public static class WatchCaption
|
|||
bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next);
|
||||
_current.ReasonLine = next;
|
||||
LastDetail = _current.DetailLine ?? "";
|
||||
LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine);
|
||||
LastCaptionText = JoinCaptionLines(
|
||||
_current.Headline,
|
||||
next,
|
||||
_current.DetailLine,
|
||||
StoryPlanner.FormatSpineLabel());
|
||||
|
||||
bool hasReason = !string.IsNullOrEmpty(next);
|
||||
if (_reasonText != null)
|
||||
|
|
@ -847,6 +863,7 @@ public static class WatchCaption
|
|||
// Mass tip headcount ticks must not Relayout the whole dossier every second.
|
||||
if (headcountOnly)
|
||||
{
|
||||
RefreshStorySpine();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -860,7 +877,61 @@ public static class WatchCaption
|
|||
CountActiveHistorySlots());
|
||||
}
|
||||
|
||||
private static string JoinCaptionLines(string headline, string reason, string detail)
|
||||
/// <summary>
|
||||
/// Quiet Kind · Phase line under the orange reason while StoryPlanner owns an arc.
|
||||
/// </summary>
|
||||
private static void RefreshStorySpine()
|
||||
{
|
||||
if (!_visible || HasStatusBanner())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string next = SpectatorMode.Active && ModSettings.ShowDossierCaption
|
||||
? StoryPlanner.FormatSpineLabel()
|
||||
: "";
|
||||
if (string.Equals(next, LastStorySpine, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyStorySpine(next);
|
||||
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
|
||||
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
|
||||
bool hasBody = _boundActor != null || CountActiveHistorySlots() > 0 || _current != null;
|
||||
Relayout(
|
||||
hasBody,
|
||||
CountActiveTraitSlots(),
|
||||
CountActiveStatusSlots(),
|
||||
hasTask,
|
||||
hasReason,
|
||||
CountActiveHistorySlots());
|
||||
}
|
||||
|
||||
private static void ApplyStorySpine(string spine)
|
||||
{
|
||||
LastStorySpine = spine ?? "";
|
||||
if (_storySpineText == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool show = !string.IsNullOrEmpty(LastStorySpine) && !HasStatusBanner();
|
||||
_storySpineText.text = show ? LastStorySpine : "";
|
||||
_storySpineText.color = StorySpineColor;
|
||||
_storySpineText.gameObject.SetActive(show);
|
||||
|
||||
if (_current != null)
|
||||
{
|
||||
LastCaptionText = JoinCaptionLines(
|
||||
_current.Headline,
|
||||
_current.ReasonLine,
|
||||
_current.DetailLine,
|
||||
LastStorySpine);
|
||||
}
|
||||
}
|
||||
|
||||
private static string JoinCaptionLines(string headline, string reason, string detail, string storySpine = null)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (!string.IsNullOrEmpty(headline))
|
||||
|
|
@ -878,6 +949,16 @@ public static class WatchCaption
|
|||
sb.Append(reason);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(storySpine))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(storySpine);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(detail))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
|
|
@ -970,7 +1051,11 @@ public static class WatchCaption
|
|||
_current.IdentityTag = tag;
|
||||
_current.Headline = headline;
|
||||
LastHeadline = headline;
|
||||
LastCaptionText = JoinCaptionLines(headline, _current.ReasonLine, _current.DetailLine);
|
||||
LastCaptionText = JoinCaptionLines(
|
||||
headline,
|
||||
_current.ReasonLine,
|
||||
_current.DetailLine,
|
||||
LastStorySpine);
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = headline;
|
||||
|
|
@ -1420,6 +1505,11 @@ public static class WatchCaption
|
|||
hasReason && dossier != null ? dossier.ReasonRelatedId : 0);
|
||||
}
|
||||
|
||||
string spine = SpectatorMode.Active && ModSettings.ShowDossierCaption && dossier != null
|
||||
? StoryPlanner.FormatSpineLabel()
|
||||
: "";
|
||||
ApplyStorySpine(spine);
|
||||
|
||||
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
|
||||
RefreshFavoriteVisual(hasLive ? actor : null);
|
||||
}
|
||||
|
|
@ -2287,6 +2377,15 @@ public static class WatchCaption
|
|||
y += reasonH + Gap;
|
||||
}
|
||||
|
||||
bool hasStorySpine = _storySpineText != null
|
||||
&& _storySpineText.gameObject.activeSelf
|
||||
&& !string.IsNullOrEmpty(_storySpineText.text);
|
||||
if (hasStorySpine)
|
||||
{
|
||||
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
|
||||
y += StorySpineH + Gap;
|
||||
}
|
||||
|
||||
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
|
||||
if (_rootRt != null)
|
||||
{
|
||||
|
|
@ -2925,6 +3024,8 @@ public static class WatchCaption
|
|||
return LineInside(_nameText.GetComponent<RectTransform>(), panelHeight)
|
||||
&& (_reasonText == null || !_reasonText.gameObject.activeSelf
|
||||
|| LineInside(_reasonText.GetComponent<RectTransform>(), panelHeight))
|
||||
&& (_storySpineText == null || !_storySpineText.gameObject.activeSelf
|
||||
|| LineInside(_storySpineText.GetComponent<RectTransform>(), panelHeight))
|
||||
&& (_statusesRow == null || !_statusesRow.activeSelf
|
||||
|| LineInside(_statusesRow.GetComponent<RectTransform>(), panelHeight))
|
||||
&& (_traitsRow == null || !_traitsRow.activeSelf
|
||||
|
|
@ -2997,7 +3098,9 @@ public static class WatchCaption
|
|||
_sexIcon = null;
|
||||
_nameText = null;
|
||||
_reasonText = null;
|
||||
_storySpineText = null;
|
||||
_reasonOtherId = 0;
|
||||
LastStorySpine = "";
|
||||
_levelIcon = null;
|
||||
_levelValue = null;
|
||||
_taskIcon = null;
|
||||
|
|
@ -3144,6 +3247,15 @@ public static class WatchCaption
|
|||
_reasonText.resizeTextForBestFit = false;
|
||||
WireReasonClick(0);
|
||||
|
||||
_storySpineText = HudCanvas.MakeText(_root.transform, "StorySpine", "", 7);
|
||||
_storySpineText.color = StorySpineColor;
|
||||
_storySpineText.supportRichText = false;
|
||||
_storySpineText.alignment = TextAnchor.UpperLeft;
|
||||
_storySpineText.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
_storySpineText.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
_storySpineText.resizeTextForBestFit = false;
|
||||
_storySpineText.raycastTarget = false;
|
||||
|
||||
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
|
||||
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
|
||||
|
||||
|
|
@ -3173,6 +3285,7 @@ public static class WatchCaption
|
|||
_statusesRow.SetActive(false);
|
||||
_traitsRow.SetActive(false);
|
||||
_reasonText.gameObject.SetActive(false);
|
||||
_storySpineText.gameObject.SetActive(false);
|
||||
_levelIcon.gameObject.SetActive(false);
|
||||
_levelValue.gameObject.SetActive(false);
|
||||
_taskIcon.gameObject.SetActive(false);
|
||||
|
|
@ -3180,7 +3293,7 @@ public static class WatchCaption
|
|||
Relayout(false, 0, 0, false, false, 0);
|
||||
_root.SetActive(false);
|
||||
_visible = false;
|
||||
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits)");
|
||||
LogService.LogInfo("[IdleSpectator] Dossier HUD ready (nametag + statuses + traits + story spine)");
|
||||
}
|
||||
|
||||
private static void BuildHeaderButtons()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.28.41",
|
||||
"version": "0.28.54",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"modVersion": "0.28.39",
|
||||
"modVersion": "0.28.49",
|
||||
"title": "IdleSpectator interest scoring model",
|
||||
"updated": "2026-07-18",
|
||||
"note": "Edit weights below - InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).",
|
||||
|
|
@ -126,7 +126,10 @@
|
|||
"epilogueMaxWatch": 14,
|
||||
"ledgerCap": 16,
|
||||
"ledgerWeight": 8,
|
||||
"historyDensityWindowSeconds": 600
|
||||
"historyDensityWindowSeconds": 600,
|
||||
"ledgerBleedLifeChapter": 0.4,
|
||||
"ledgerBleedVillage": 0.82,
|
||||
"familyLifeBeatCooldownSeconds": 90
|
||||
},
|
||||
|
||||
"sources": [
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ Discrete / status / WorldLog: `CreatesInterest` = A gate.
|
|||
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`).
|
||||
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.
|
||||
- Mass/war/plot/family/outbreak: sticky roster members OK.
|
||||
Dossier identity also rejects subject-led Labels that name another living unit
|
||||
(e.g. Hillih must not show “Iksssssa shares intimacy…”).
|
||||
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
|
||||
|
||||
## Activity History peek freshness
|
||||
|
|
|
|||
|
|
@ -27,10 +27,85 @@ Planner never calls `MoveCamera`.
|
|||
|
||||
Phases: `Climax → Aftermath → Epilogue → Done`.
|
||||
|
||||
- **Aftermath** injects on climax cold (quiet grace), or claims natural `death_*` grief.
|
||||
- **Aftermath** injects on climax cold (quiet grace / `follow_lost`), or claims natural `death_*` grief.
|
||||
- Only grief / love-outcome pending tips block synthesis - cast hatch/food noise does not.
|
||||
- Injected story beats bypass FixedDwell beat hard-skip and PreferOver cast life tips while the arc is live.
|
||||
- Soft duel Mass↔Duel key flips still cold-hook once per theater anchor.
|
||||
- **Epilogue** once after aftermath if a living lover/friend resolves.
|
||||
- Soft anonymous duels still get aftermath tips; they do not use raised `arcHoldMargin`.
|
||||
|
||||
## Dossier spine
|
||||
|
||||
While the **current watch tip** is the active arc's climax or story aftermath/epilogue beat,
|
||||
the dossier shows a muted line under the orange reason: `Kind · Phase`
|
||||
(e.g. `Duel · Aftermath`, `War front · Climax`).
|
||||
|
||||
Hidden when idle is off, the dossier is hidden, a status banner owns the reason row,
|
||||
or the camera has left the story for an unrelated tip (cast membership alone is not enough).
|
||||
|
||||
Love climax is outcome-only (`set_lover` / fallen-in-love). `find_lover` seeking stays camera-worthy
|
||||
but does not open a Love arc. Leaving a climax without aftermath ends the arc (`Done`).
|
||||
|
||||
Sticky combat reframes (Battle ↔ Duel) sync climax Kind in place via `SyncActiveClimax`
|
||||
so the spine cannot say `Mass · Climax` on a Duel tip.
|
||||
|
||||
`arcHoldMargin` applies only while the watch tip **is** the aftermath/epilogue beat.
|
||||
Cast life tips (parenthood, sleep) no longer keep the raised cut-in wall.
|
||||
|
||||
Family life chapters (`just_had_child`, pregnancy, …) share `arc:life:family`, use a longer
|
||||
beat cool (including world-wide happiness cool), and bleed ledger heat so the village cast
|
||||
cannot monopolize the next picks.
|
||||
|
||||
Intimacy chapters (`just_kissed` / shares intimacy) use `arc:life:intimacy` with the same
|
||||
soft-life cool class (pair + world) so one couple cannot keep cutting between combat tips.
|
||||
|
||||
Settlement finds (`just_found_house` / finds a home) use `arc:life:home` with the same soft cool
|
||||
(world + subject) so house crumbs cannot keep cutting between combat beats.
|
||||
|
||||
Kill crumbs (`milestone_kill` / `Killed X (species)` / `just_killed`) use `arc:life:kill` with
|
||||
the same soft cool (subject + world) so hunter kill tips cannot keep cutting between mass beats.
|
||||
|
||||
Courtship crumbs (`fallen_in_love` / smitten / `find_lover` seeking) use `arc:life:love` soft cool
|
||||
(subject + world + pair). Bond outcomes (`set_lover`) stay hotter.
|
||||
|
||||
Life-intent decisions (`sexual_reproduction_*`, decision `find_lover`, …) share
|
||||
`arc:decision:intent` soft cool (subject + world). Outcomes (pregnancy, child, seeking) stay hotter.
|
||||
|
||||
Ordinary civ construction finishes (`building:complete` / tents / houses) share `arc:construction`
|
||||
soft cool. Spectacle wonders stay hotter.
|
||||
|
||||
Soft personal status FX (`taking_roots`, singing, moods, …) share `arc:status:fx` soft cool.
|
||||
Danger / combat statuses stay hotter. Hatch crumbs use `arc:life:hatch`.
|
||||
|
||||
Sleep / wake / dream cycle (`sleeping`, `just_slept`, `had_*dream`, `had_nightmare`, …) share
|
||||
`arc:life:rest` soft cool so rest crumbs cannot monopolize between richer beats.
|
||||
|
||||
WorldLog politics meta (`king_dead`, `city_destroyed`, favorite fell, …) share `arc:worldlog:meta`
|
||||
soft cool so sticky death crumbs cannot re-monopolize.
|
||||
|
||||
Interrupted resume: already-cooled moment beats are not parked / not resumed after a cut-in
|
||||
(hatch/reproduce cannot bounce back after combat).
|
||||
|
||||
Story spine: aftermath tips must match the active arc `CorrelationKey` (no stale duel aftermath
|
||||
under a Mass spine). Combat Kind word follows tip Label (`Battle` / `Skirmish` / `Mass` / `Duel`).
|
||||
|
||||
Wiped opposing combat camps collapse to a living-side pack label at live scale
|
||||
(never `Mass - A (n) vs B (0)`).
|
||||
|
||||
Combat aftermath names only a dead theater partner (`stands over X`), else
|
||||
`stands over the fallen`. Living lover/friend fallbacks are never the fallen.
|
||||
|
||||
Spine Kind follows the live tip Label for combat (Duel vs Mass/Skirmish/Battle), not sticky
|
||||
scale-hold alone. Cold climax tips do not show `· Aftermath` - only the aftermath tip does.
|
||||
Aftermath Follow loss promotes another living cast member before `follow_lost`.
|
||||
|
||||
Harness: `story_spine` / `story_hold_margin` / `story_family_variety` / `story_intimacy_cool`
|
||||
/ `story_settlement_cool` / `story_kill_cool` / `story_courtship_cool`
|
||||
/ `story_decision_intent_cool` / `story_decision_intent_live` / `story_construction_cool`
|
||||
/ `story_status_fx_cool` / `story_rest_cool` / `story_resume_cooled` / `story_worldlog_meta_cool`
|
||||
/ `combat_wiped_side` / `reason_life_principal`
|
||||
(`story_arc_combat`, `story_spine_scoped`, `story_aftermath_combat`, `story_family_variety`).
|
||||
|
||||
## Selection discipline
|
||||
|
||||
- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
|
||||
|
|
@ -49,6 +124,22 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
|||
| Scenario | Proves |
|
||||
|----------|--------|
|
||||
| `story_aftermath_combat` | duel cold → aftermath tip |
|
||||
| `story_aftermath_partner_truth` | aftermath never names living lover as fallen |
|
||||
| `story_aftermath_cast_noise` | hatch noise pending still gets aftermath |
|
||||
| `reason_duel_principal` | bystander Follow cannot own duel reason |
|
||||
| `reason_life_principal` | Follow hijack cannot keep intimacy Label |
|
||||
| `story_intimacy_cool` | just_kissed cools; peer wins next pick |
|
||||
| `story_settlement_cool` | finds-a-home cools; peer wins next pick |
|
||||
| `story_kill_cool` | kill crumbs cool; peer wins next pick |
|
||||
| `story_courtship_cool` | smitten/seeking cool; peer wins next pick |
|
||||
| `story_decision_intent_cool` | reproduce/find-lover decisions cool as a class |
|
||||
| `story_decision_intent_live` | live EmitDecision path cools intent class |
|
||||
| `story_construction_cool` | tent/house finishes cool; peer wins |
|
||||
| `story_status_fx_cool` | taking_roots FX cool; peer wins |
|
||||
| `story_rest_cool` | sleep/wake rest cycle cools; peer wins |
|
||||
| `story_resume_cooled` | cooled soft tip does not resume after cut-in |
|
||||
| `story_worldlog_meta_cool` | king_dead cools; peer wins next pick |
|
||||
| `combat_wiped_side` | wiped camp never prints vs (0) |
|
||||
| `story_aftermath_war` | war sticky cold → aftermath |
|
||||
| `story_near_tie` | large gap always picks #1 |
|
||||
| `story_arc_combat` | climax → aftermath ownership |
|
||||
|
|
@ -57,14 +148,28 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
|||
|
||||
```bash
|
||||
./scripts/harness-run.sh --repeat 3 story_aftermath_combat
|
||||
./scripts/harness-run.sh --repeat 3 story_aftermath_partner_truth
|
||||
./scripts/harness-run.sh --repeat 3 story_aftermath_cast_noise
|
||||
./scripts/harness-run.sh --repeat 3 reason_duel_principal
|
||||
./scripts/harness-run.sh --repeat 3 story_near_tie
|
||||
./scripts/harness-run.sh --repeat 3 story_arc_combat
|
||||
./scripts/harness-run.sh --repeat 3 story_ledger_revisit
|
||||
./scripts/harness-run.sh --repeat 3 story_kill_cool
|
||||
./scripts/harness-run.sh --repeat 3 story_courtship_cool
|
||||
./scripts/harness-run.sh --repeat 3 story_decision_intent_cool
|
||||
./scripts/harness-run.sh --repeat 3 story_decision_intent_live
|
||||
./scripts/harness-run.sh --repeat 3 story_construction_cool
|
||||
./scripts/harness-run.sh --repeat 3 story_status_fx_cool
|
||||
./scripts/harness-run.sh --repeat 3 story_rest_cool
|
||||
./scripts/harness-run.sh --repeat 3 story_resume_cooled
|
||||
./scripts/harness-run.sh --repeat 3 story_worldlog_meta_cool
|
||||
./scripts/harness-run.sh --repeat 3 combat_wiped_side
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject
|
||||
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels
|
||||
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
|
||||
- `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16)
|
||||
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
|
||||
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row
|
||||
|
|
|
|||
Loading…
Reference in a new issue