Compare commits

..

3 commits

Author SHA1 Message Date
529d18dfa4 feat(saga): enhance rail species frame handling and live capture retries
- Introduce new saga rail commands for species frame auditing and sweeping
- Implement retry logic for capturing inactive live frames in the rail
- Refactor face resolution logic to prioritize live frames over species icons
- Update harness scenarios to validate new rail functionality and performance
- Enhance documentation to clarify live capture behavior and fallback handling
2026-07-24 15:17:03 -05:00
96db725383 feat(caption): enhance saga hover functionality and visual stability
- Introduce new anchors for caption avatar and narrative positioning
- Implement stable typography capture during saga hover sessions
- Add diagnostics for avatar visibility and anchored position
- Refactor saga hover refresh logic to maintain visual consistency
- Update harness scenarios to validate hover visual stability
2026-07-24 14:29:10 -05:00
12e2ba0d01 feat(narrative): refine camera and narrative handling for civilized terminology
- Implement checks for civilized terminology in camera focus events
- Update event handling to suppress inappropriate pack language for civilized characters
- Enhance narrative presentation to differentiate between family and pack language
- Introduce new scenarios to validate significance of beats and relationships
- Improve documentation to clarify narrative structure and event significance
2026-07-24 14:06:23 -05:00
27 changed files with 2196 additions and 184 deletions

View file

@ -42,6 +42,9 @@ public static class AgentHarness
private static string LastScreenshotPath = "";
private static Vector2 _sagaPanelSizeAnchor;
private static string _captionBeatAnchor = "";
private static Vector2 _captionAvatarAnchor = Vector2.zero;
private static float _captionBodyAnchorY;
private static float _captionNarrativeAnchorY;
private static readonly string[] PreferredSpawnAssets =
{
"sheep", "cow", "pig", "chicken", "dog", "cat", "rabbit", "monkey", "fox", "crab"
@ -4171,6 +4174,7 @@ public static class AgentHarness
{
LifeSagaRoster.Tick(Time.unscaledTime);
LifeSagaRail.Refresh();
WatchCaption.RequestRelayout();
_cmdOk++;
Emit(
cmd,
@ -4180,10 +4184,60 @@ public static class AgentHarness
break;
}
case "saga_rail_species_frame_sweep":
{
if (!RailFaceCatalogAudit.Running)
{
WorldTile tile = _lastSpawned != null && _lastSpawned.isAlive()
? _lastSpawned.current_tile
: TileNearCamera();
if (!RailFaceCatalogAudit.BeginExhaustive(HarnessDir(), tile, out string startDetail))
{
_cmdFail++;
Emit(cmd, ok: false, detail: startDetail);
break;
}
}
bool finished = RailFaceCatalogAudit.StepExhaustive(
out bool sweepPass,
out string sweepDetail);
if (!finished)
{
RequeueFront(cmd);
break;
}
if (sweepPass)
{
_cmdOk++;
_assertPass++;
}
else
{
_cmdFail++;
_assertFail++;
}
Emit(cmd, sweepPass, sweepDetail);
LogService.LogInfo(
$"[IdleSpectator][ASSERT] {(sweepPass ? "PASS" : "FAIL")} "
+ $"id={cmd.id} expect=saga_rail_species_frame_sweep {sweepDetail}");
break;
}
case "caption_capture_beat":
_captionBeatAnchor = WatchCaption.VisibleBeatLine ?? "";
_captionAvatarAnchor = DossierAvatar.HostAnchoredPosition;
_captionBodyAnchorY = WatchCaption.LastBodyAnchorY;
_captionNarrativeAnchorY = WatchCaption.LastNarrativeAnchorY;
_cmdOk++;
Emit(cmd, ok: true, detail: $"beat='{_captionBeatAnchor}'");
Emit(
cmd,
ok: true,
detail:
$"beat='{_captionBeatAnchor}' avatar={_captionAvatarAnchor} "
+ $"anchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0}");
break;
case "saga_show_hover_first":
@ -4252,19 +4306,17 @@ public static class AgentHarness
LifeSagaRail.Refresh();
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
LifeSagaRail.HarnessClick(0);
int clicks = preferBefore ? 2 : 1;
bool clicked = true;
for (int i = 0; i < clicks; i++)
{
clicked = LifeSagaRail.HarnessClickUnit(focusId) && clicked;
}
WatchCaption.RequestRelayout();
// Prefer toggle only - does not open hover / pin saga subject.
bool preferAfter = LifeSagaRail.LastPreferPipVisible
|| (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
bool ok = preferAfter || preferBefore != (focusId != 0 && LifeSagaRoster.IsPrefer(focusId));
// After click, Prefer state on slot 0 should be readable.
var slots = new System.Collections.Generic.List<LifeSagaSlot>(LifeSagaRoster.Cap);
LifeSagaRoster.CopySlots(slots);
if (slots.Count > 0 && slots[0] != null)
{
ok = slots[0].Prefer || preferBefore;
}
bool focusPreferAfter = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
bool preferAfter = LifeSagaRail.LastPreferPipVisible || focusPreferAfter;
bool ok = clicked && focusPreferAfter;
if (ok)
{
@ -4275,7 +4327,12 @@ public static class AgentHarness
_cmdFail++;
}
Emit(cmd, ok, detail: $"preferBefore={preferBefore} preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
Emit(
cmd,
ok,
detail:
$"clicked={clicked} clicks={clicks} focus={focusId} preferBefore={preferBefore} "
+ $"preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
break;
}
@ -10066,6 +10123,22 @@ public static class AgentHarness
detail = $"cachedFaces={have} want={want} mode={(atLeast ? "min" : "exact")} shown={LifeSagaRail.LastShownCount}";
break;
}
case "saga_rail_fallback_cache_isolation":
{
pass = LifeSagaRail.HarnessProbeFallbackCacheIsolation(out detail);
break;
}
case "saga_rail_focus_live_frame_distinct":
{
LifeSagaRail.Refresh();
pass = LifeSagaRail.HarnessProbeFocusUsesDistinctLiveFrame(out detail);
break;
}
case "saga_rail_species_frame_audit":
{
pass = RailFaceCatalogAudit.Run(HarnessDir(), out detail);
break;
}
case "saga_soft_bias_rank":
{
InterestCandidate cur = InterestDirector.CurrentCandidate;
@ -10279,6 +10352,57 @@ public static class AgentHarness
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} outerH={WatchCaption.SagaHoverHeightFixed:0.#} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
break;
}
case "saga_hover_visual_stable":
{
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
string hoverName = (model?.Name ?? "").Replace("★ ", "").Trim();
// Organic idle refresh: no layout invalidation. This catches visibility
// ownership bugs hidden by the initial pointer-enter relayout.
WatchCaption.HarnessRefreshSagaSteadyState();
bool steadyPanel = LifeSagaPanel.Visible;
bool steadyAvatar = DossierAvatar.HostVisible;
string beforeName = WatchCaption.VisibleNametagText;
int beforeFont = WatchCaption.VisibleNametagFontSize;
Vector2 beforeAvatar = DossierAvatar.HostAnchoredPosition;
// A second same-hover refresh used to overwrite the hovered name with the
// camera headline, enlarge the font, and reveal the portrait before placement.
WatchCaption.RequestRelayout();
string afterName = WatchCaption.VisibleNametagText;
int afterFont = WatchCaption.VisibleNametagFontSize;
Vector2 afterAvatar = DossierAvatar.HostAnchoredPosition;
bool correctName = !string.IsNullOrEmpty(hoverName)
&& beforeName.IndexOf(hoverName, StringComparison.Ordinal) >= 0
&& afterName.IndexOf(hoverName, StringComparison.Ordinal) >= 0;
bool fontStable = beforeFont == afterFont
&& afterFont > 0
&& afterFont <= WatchCaption.SagaHoverFontCeiling;
bool avatarStable = DossierAvatar.HostVisible
&& Vector2.Distance(beforeAvatar, afterAvatar) < 0.1f
&& Vector2.Distance(_captionAvatarAnchor, afterAvatar) < 0.1f;
bool narrativeBelowBody = _captionNarrativeAnchorY
>= _captionBodyAnchorY + 39.9f;
pass = LifeSagaViewController.IsHoverPreview
&& LifeSagaPanel.Visible
&& steadyPanel
&& steadyAvatar
&& correctName
&& fontStable
&& avatarStable
&& narrativeBelowBody;
detail =
$"name='{beforeName}'/'{afterName}' hoverName='{hoverName}' "
+ $"font={beforeFont}/{afterFont} ceiling={WatchCaption.SagaHoverFontCeiling} "
+ $"avatar={_captionAvatarAnchor}/{beforeAvatar}/{afterAvatar} "
+ $"dossierAnchors={_captionBodyAnchorY:0.0}/{_captionNarrativeAnchorY:0.0} "
+ $"hoverAnchor={WatchCaption.LastBodyAnchorY:0.0} below={narrativeBelowBody} "
+ $"steady={steadyPanel}/{steadyAvatar} "
+ $"visible={DossierAvatar.HostVisible} pass={pass}";
break;
}
case "caption_mc_identity":
{
string idLine = WatchCaption.LastHeadline ?? "";
@ -10420,6 +10544,12 @@ public static class AgentHarness
pass = CameraEligibility.HarnessProbeExceptionalPolicy(out detail);
break;
}
case "saga_civilized_no_pack_language":
{
Actor actor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned;
pass = LifeSagaPresentation.HarnessProbeCivilizedTerminology(actor, out detail);
break;
}
case "caption_context_omits":
{
// Soft tip hitch: Context must not show banned biography fragments.
@ -11007,6 +11137,20 @@ public static class AgentHarness
pass = InterestVariety.HarnessProbeStabilityBudgets(out detail);
break;
}
case "beat_significance_policy":
{
pass = BeatSignificance.HarnessProbePolicy(out detail);
break;
}
case "beat_significance_live_partner":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
pass = BeatSignificance.HarnessProbeLiveRelation(
focus,
_happinessPartner,
out detail);
break;
}
case "saga_replacement_evidence":
{
pass = LifeSagaRoster.HarnessProbeReplacementEvidence(out detail);
@ -15516,6 +15660,7 @@ public static class AgentHarness
_rememberedFocusKey = "";
_rememberedTip = "";
_rememberedRelatedId = 0;
RailFaceCatalogAudit.Cancel();
InterestDirector.SetHarnessFastTiming(false);
SpeciesDiscovery.SetHarnessFastTiming(false);
try

View file

@ -44,7 +44,19 @@ public static class CameraDirector
&& WasPresentedRecently(incomingLabel, Time.unscaledTime))
{
// Sticky maintain paths may reassert a prior label without going through
// candidate selection. Do not turn that refresh into a new camera/caption cut.
// candidate selection. Do not turn that refresh into a new camera cut or log,
// but do commit the label when it belongs to the already-focused active scene.
long incomingId = EventFeedUtil.SafeId(interest.FollowUnit);
long focusedId = EventFeedUtil.SafeId(
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
InterestCandidate active = InterestDirector.CurrentCandidate;
if (incomingId != 0
&& incomingId == focusedId
&& active != null
&& string.Equals(active.Label ?? "", incomingLabel, StringComparison.Ordinal))
{
RefreshWatchPresentation(interest, suppressReplayLog: true);
}
return;
}
@ -62,6 +74,10 @@ public static class CameraDirector
{
WatchCaption.SetFromActor(follow);
}
else
{
WatchCaption.RefreshOwnedReasonNow();
}
CommitWatchTelemetry(interest);
return;
@ -69,15 +85,21 @@ public static class CameraDirector
// No accepted living follow: do not bind a rejected FollowUnit
// (dead / species mismatch) as the dossier subject.
if (!interest.HasFollowUnit)
bool unmatchedSpeciesTip = (interest.Label ?? "").StartsWith(
"New species:",
StringComparison.Ordinal);
if (unmatchedSpeciesTip)
{
// Discovery may know the species before a matching live unit can be resolved.
// Keep the current living focus; never clear it in favor of a ghost asset id.
WatchCaption.SetFromInterest(interest);
CommitWatchTelemetry(interest);
return;
}
if (!MoveCamera.hasFocusUnit())
{
PanCamera(interest.Position);
}
WatchCaption.SetFromLocationInterest(interest);
ClearFollow();
PanCamera(interest.Position);
CommitWatchTelemetry(interest);
}
@ -86,7 +108,9 @@ public static class CameraDirector
/// Publish reason telemetry only after camera focus and caption ownership have committed.
/// This makes Watching/focus/caption one observable transition rather than three frames.
/// </summary>
private static void CommitWatchTelemetry(InterestEvent interest)
private static void CommitWatchTelemetry(
InterestEvent interest,
bool suppressReplayLog = false)
{
string tip = FormatWatchTip(interest);
string label = interest?.Label ?? "";
@ -102,13 +126,30 @@ public static class CameraDirector
LastWatchLabel = label;
LastWatchAssetId = assetId;
// Sticky Mass tips tick headcounts / side-order / Battle↔Mass often - log structure only.
if (tipChanged && !framingOnly)
if (tipChanged && !framingOnly && !suppressReplayLog)
{
NotePresentedLabel(label, Time.unscaledTime);
LogService.LogInfo($"[IdleSpectator] {tip}");
}
}
/// <summary>
/// Commit a same-focus sticky reframe without retargeting the camera or rebuilding the
/// dossier. Used for Duel/Battle/Mass and headcount changes that are presentation-only.
/// </summary>
public static void RefreshWatchPresentation(
InterestEvent interest,
bool suppressReplayLog = false)
{
if (interest == null)
{
return;
}
WatchCaption.RefreshOwnedReasonNow();
CommitWatchTelemetry(interest, suppressReplayLog);
}
private static bool WasPresentedRecently(string label, float now)
{
string key = EventReason.ReplayStructureKey(label);

View file

@ -29,6 +29,14 @@ public static class DossierAvatar
/// <summary>Fallen archive species-icon portrait is showing.</summary>
public static bool ShowingSpeciesFallback => _shownActorId == -2;
/// <summary>Harness/UI diagnostic: portrait host is currently paintable.</summary>
public static bool HostVisible =>
_host != null && _host.gameObject.activeSelf;
/// <summary>Harness/UI diagnostic: final local placement within the dossier card.</summary>
public static Vector2 HostAnchoredPosition =>
_host != null ? _host.anchoredPosition : Vector2.zero;
/// <summary>True if <paramref name="screenPoint"/> is inside the live portrait rect.</summary>
public static bool ContainsScreenPoint(Vector2 screenPoint)
{

View file

@ -555,7 +555,9 @@ public static class EventReason
public static string BecomeAlpha(Actor a)
{
return NameOrSomeone(a) + " becomes the family alpha";
return LifeSagaRoster.UsesPackLanguage(a)
? NameOrSomeone(a) + " becomes the pack alpha"
: NameOrSomeone(a) + " becomes head of the family";
}
public static string Hatch(Actor a)
@ -585,24 +587,26 @@ public static class EventReason
string an = NameOrSomeone(a);
string bn = Name(peer);
string p = (phase ?? "").Trim().ToLowerInvariant();
bool pack = LifeSagaRoster.UsesPackLanguage(a);
string group = pack ? "a family pack" : "a family";
switch (p)
{
case "join":
case "family_group_join":
return string.IsNullOrEmpty(bn)
? an + " joins a family pack"
: an + " joins a family pack with " + bn;
? an + " joins " + group
: an + " joins " + group + " with " + bn;
case "leave":
case "family_group_leave":
return string.IsNullOrEmpty(bn)
? an + " leaves a family pack"
: an + " leaves a family pack with " + bn;
? an + " leaves " + group
: an + " leaves " + group + " with " + bn;
case "form":
case "new":
case "family_group_new":
return string.IsNullOrEmpty(bn)
? an + " forms a family pack"
: an + " forms a family pack with " + bn;
? an + " forms " + group
: an + " forms " + group + " with " + bn;
default:
return an + " gathers with kin";
}
@ -645,7 +649,10 @@ public static class EventReason
label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : "");
int n = Math.Max(0, ensemble.SideA.Count);
return "Pack - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ") · gathering";
string collective = LifeSagaRoster.UsesPackLanguage(ensemble.Focus)
? "Pack"
: "Family";
return collective + " - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ") · gathering";
}
public static string NewChild(Actor a, Actor b)

View file

@ -276,6 +276,8 @@ internal static class HarnessScenarios
return SagaReplaceWeaker();
case "saga_rail_active_highlight":
return SagaRailActiveHighlight();
case "saga_rail_live_animal_frame":
return SagaRailLiveAnimalFrame();
case "saga_rail_prefer_click":
return SagaRailPreferClick();
case "saga_soft_fill_no_pack":
@ -3319,6 +3321,15 @@ internal static class HarnessScenarios
Step("ncc6", "assert", expect: "camera_exceptional_policy"),
Step("ncc7", "assert", expect: "stability_budgets"),
Step("ncc8", "assert", expect: "saga_replacement_evidence"),
Step("ncc9", "assert", expect: "beat_significance_policy"),
Step("ncc10", "happiness_remember_partner"),
Step("ncc11", "pick_unit", asset: "human", value: "other"),
Step("ncc12", "focus", asset: "human"),
Step("ncc13", "interest_saga_clear"),
Step("ncc14", "saga_force_admit_focus"),
Step("ncc15", "saga_memory_record_focus", asset: "child"),
Step("ncc16", "assert", expect: "beat_significance_live_partner"),
Step("ncc17", "assert", expect: "saga_civilized_no_pack_language"),
Step("ncc99", "snapshot")
};
}
@ -3970,6 +3981,7 @@ internal static class HarnessScenarios
Step("sad10a", "saga_memory_record_focus", asset: "War", value: "opposing commander",
expect: "harness_cast_relation"),
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
Step("sad11a", "saga_rail_refresh"),
// saga_select_tab saga → hover preview of watched MC (compat).
Step("sad11b", "caption_capture_beat"),
Step("sad12", "saga_select_tab", value: "saga"),
@ -3977,6 +3989,7 @@ internal static class HarnessScenarios
Step("sad14", "assert", expect: "saga_panel_bound"),
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
Step("sad14c", "assert", expect: "caption_hover_keeps_beat"),
Step("sad14c2", "assert", expect: "saga_hover_visual_stable"),
Step("sad14d", "assert", expect: "saga_panel_cast_first"),
Step("sad14e", "assert", expect: "saga_panel_cast_relations", value: "1"),
Step("sad15", "screenshot", value: "saga-hover-depth"),
@ -4634,11 +4647,47 @@ internal static class HarnessScenarios
Step("sra24", "assert", expect: "saga_rail_active_highlight", value: "true"),
Step("sra25", "assert", expect: "saga_rail_prefer_pip", value: "true"),
Step("sra25b", "assert", expect: "saga_rail_cached_faces", value: "2", label: "min"),
Step("sra25c", "assert", expect: "saga_rail_fallback_cache_isolation"),
Step("sra90", "fast_timing", value: "false"),
Step("sra99", "snapshot"),
};
}
/// <summary>
/// Tall and short creature glyphs retain animation frames, then the full living
/// species catalog is checked under the same extraction policy.
/// </summary>
private static List<HarnessCommand> SagaRailLiveAnimalFrame()
{
return new List<HarnessCommand>
{
Step("srl0", "dismiss_windows"),
Step("srl1", "wait_world"),
Step("srl2", "set_setting", expect: "enabled", value: "true"),
Step("srl3", "fast_timing", value: "true"),
Step("srl4", "spawn", asset: "cat", count: 1),
Step("srl5", "spectator", value: "off"),
Step("srl6", "spectator", value: "on"),
Step("srl7", "interest_saga_clear"),
Step("srl8", "pick_unit", asset: "cat"),
Step("srl9", "focus", asset: "cat"),
Step("srl10", "saga_force_admit_focus"),
Step("srl11", "saga_rail_refresh"),
Step("srl12", "assert", expect: "saga_rail_focus_live_frame_distinct"),
Step("srl13", "screenshot", value: "saga-rail-cat-live"),
Step("srl14", "spawn", asset: "fox", count: 1),
Step("srl15", "pick_unit", asset: "fox"),
Step("srl16", "focus", asset: "fox"),
Step("srl17", "saga_force_admit_focus"),
Step("srl18", "saga_rail_refresh"),
Step("srl19", "assert", expect: "saga_rail_focus_live_frame_distinct"),
Step("srl20", "screenshot", value: "saga-rail-fox-live"),
Step("srl21", "saga_rail_species_frame_sweep"),
Step("srl90", "fast_timing", value: "false"),
Step("srl99", "snapshot"),
};
}
/// <summary>Prefer toggle flips on then off with immediate pip state.</summary>
private static List<HarnessCommand> SagaRailPreferClick()
{

View file

@ -142,37 +142,123 @@ public static class HudIcons
}
/// <summary>
/// Readable saga-rail face: live inspect sprite when usable, else species icon.
/// Skips egg forms and near-empty tiny crops that read as a single pixel.
/// Actor-specific saga-rail frame only. Returns null for eggs, dead actors, or
/// unusable render frames; callers decide whether to display a temporary fallback.
/// </summary>
public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null)
public static Sprite FromActorLiveRailFace(Actor actor)
{
if (actor != null && actor.isAlive())
if (actor == null || !actor.isAlive())
{
bool egg = false;
return null;
}
bool egg = false;
try
{
egg = actor.isEgg();
}
catch
{
egg = false;
}
if (egg)
{
return null;
}
try
{
Sprite main = actor.calculateMainSprite();
if (main == null || LooksLikeEggSprite(main))
{
return null;
}
Sprite speciesIcon = FromActor(actor);
bool civilized = actor.asset != null && actor.asset.civ;
Sprite colored = null;
try
{
egg = actor.isEgg();
// Some creatures report hasColoredSprite=false even though this returns the
// palette-resolved live frame (cat: walk_0 -> gen_walk_0).
colored = actor.calculateColoredSprite(main);
}
catch
{
egg = false;
colored = null;
}
if (!egg)
Sprite ui = null;
if (civilized)
{
Sprite live = FromActorLive(actor);
if (IsUsableRailFace(live))
try
{
return live;
ui = DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main);
}
Sprite asset = FromActor(actor);
if (IsUsableRailFace(asset) && !LooksLikeEggSprite(asset))
catch
{
return asset;
ui = null;
}
}
// Civ units benefit from the combined inspect frame (body/head/equipment).
if (civilized
&& IsUsableRailFace(ui)
&& !SameSpriteSource(ui, speciesIcon))
{
return ui;
}
// Creature palette generation is the closest single-frame equivalent to the
// live renderer. It must precede the raw atlas frame or palette-key colors leak.
if (IsUsableRailFace(colored)
&& !LooksLikeEggSprite(colored)
&& !SameSpriteSource(colored, speciesIcon))
{
return colored;
}
if (!civilized
&& IsUsableRailFace(main)
&& !SameSpriteSource(main, speciesIcon))
{
return main;
}
// The UI helper is allowed to synthesize a copy of the asset portrait for
// creatures. Source-region comparison cannot detect that copy, so never use
// it as a non-civ "live" frame. Their main/colored animation is authoritative.
if (civilized
&& IsUsableRailFace(ui)
&& !SameSpriteSource(ui, speciesIcon))
{
return ui;
}
return civilized
&& IsUsableRailFace(main)
&& !SameSpriteSource(main, speciesIcon)
? main
: null;
}
catch
{
return null;
}
}
/// <summary>
/// Readable saga-rail face: actor-specific live frame when available, else species icon.
/// The combined helper is for non-caching presentation such as Cast cells.
/// </summary>
public static Sprite FromActorRailFace(Actor actor, string speciesFallbackId = null)
{
Sprite live = FromActorLiveRailFace(actor);
if (IsUsableRailFace(live))
{
return live;
}
string species = speciesFallbackId;
@ -193,10 +279,12 @@ public static class HudIcons
try
{
// Reject 1-4px crops and blank atlas slices that look like dust on the rail.
// Actor animation frames are not square. Foxes and other long-bodied creatures
// legitimately use 5-7px-tall frames, so a per-axis 8px floor rejects the live
// animation and makes callers fall back to the standing species portrait.
float w = sprite.rect.width;
float h = sprite.rect.height;
if (w < 8f || h < 8f)
if (w < 2f || h < 2f || (w * h) < 4f)
{
return false;
}
@ -214,6 +302,146 @@ public static class HudIcons
}
}
/// <summary>True when two sprites point at the same atlas region, even as different objects.</summary>
public static bool SameSpriteSource(Sprite a, Sprite b)
{
if (a == null || b == null)
{
return false;
}
if (ReferenceEquals(a, b))
{
return true;
}
try
{
Rect ar = a.rect;
Rect br = b.rect;
return a.texture == b.texture
&& Mathf.Abs(ar.x - br.x) < 0.1f
&& Mathf.Abs(ar.y - br.y) < 0.1f
&& Mathf.Abs(ar.width - br.width) < 0.1f
&& Mathf.Abs(ar.height - br.height) < 0.1f;
}
catch
{
return false;
}
}
/// <summary>Harness diagnostic for WorldBox's competing actor-frame sources.</summary>
public static string DescribeActorRailSources(Actor actor)
{
if (actor == null || !actor.isAlive())
{
return "actor=none";
}
try
{
Sprite species = FromActor(actor);
Sprite main = actor.calculateMainSprite();
Sprite colored = null;
try
{
colored = main != null ? actor.calculateColoredSprite(main) : null;
}
catch
{
colored = null;
}
Sprite ui = null;
try
{
ui = main != null
? DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main)
: null;
}
catch
{
ui = null;
}
return "main=" + DescribeSprite(main, species)
+ " colored=" + DescribeSprite(colored, species)
+ " ui=" + DescribeSprite(ui, species)
+ " species=" + DescribeSprite(species, species);
}
catch (Exception ex)
{
return "sources_error=" + ex.GetType().Name;
}
}
/// <summary>Harness: retained frame matches the actor's palette/UI-generated live frame.</summary>
public static bool MatchesGeneratedActorFrame(Actor actor, Sprite retained)
{
if (actor == null || retained == null || !actor.isAlive())
{
return false;
}
try
{
Sprite main = actor.calculateMainSprite();
if (main == null)
{
return false;
}
Sprite colored = null;
Sprite ui = null;
try
{
colored = actor.calculateColoredSprite(main);
}
catch
{
colored = null;
}
try
{
ui = DynamicActorSpriteCreatorUI.getUnitSpriteForUI(actor, main);
}
catch
{
ui = null;
}
return (IsUsableRailFace(colored) && SameSpriteSource(retained, colored))
|| (IsUsableRailFace(main) && SameSpriteSource(retained, main))
|| (IsUsableRailFace(ui) && SameSpriteSource(retained, ui));
}
catch
{
return false;
}
}
private static string DescribeSprite(Sprite sprite, Sprite species)
{
if (sprite == null)
{
return "none";
}
try
{
return (sprite.name ?? "?")
+ ":" + sprite.rect.width.ToString("0")
+ "x" + sprite.rect.height.ToString("0")
+ (SameSpriteSource(sprite, species) ? ":species" : ":distinct");
}
catch
{
return "invalid";
}
}
private static bool LooksLikeEggSprite(Sprite sprite)
{
if (sprite == null)

View file

@ -634,6 +634,12 @@ public static partial class InterestDirector
{
CameraDirector.Watch(_current.ToInterestEvent());
}
else if (labelChanged)
{
// Keep the visible reason and Watching telemetry coherent without a camera cut
// or full dossier rebuild for same-focus Duel/Battle/Mass reframes.
CameraDirector.RefreshWatchPresentation(_current.ToInterestEvent());
}
// Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo).
StoryPlanner.SyncActiveClimax(_current);

View file

@ -153,6 +153,14 @@ public static partial class InterestDirector
return false;
}
// Quiet character grounding never owns an orange Beat. A transient live attack target
// must not promote its generated species presentation into an event; the combat feed
// will publish a separate EventLed candidate when there is an actual fight to show.
if (BeatSignificance.SuppressBeat(scene))
{
return false;
}
long unitId = EventFeedUtil.SafeId(unit);
if (unitId == 0)
{
@ -1989,13 +1997,27 @@ public static partial class InterestDirector
return;
}
if (HasLivingCameraFocus())
if (HasLivingMcCameraFocus())
{
return;
}
TryCharacterFill(force: true);
if (HasLivingCameraFocus())
if (HasLivingMcCameraFocus())
{
return;
}
Actor mc = LifeSagaRoster.BestLivingMc();
if (mc != null)
{
CameraDirector.FocusUnit(mc);
return;
}
// Before the first roster has formed, preserve the old discovery fallback.
// Once MCs exist, never make an arbitrary background unit the quiet landing shot.
if (LifeSagaRoster.Count > 0)
{
return;
}
@ -2009,6 +2031,12 @@ public static partial class InterestDirector
_ = now;
}
private static bool HasLivingMcCameraFocus()
{
return HasLivingCameraFocus()
&& LifeSagaRoster.IsMc(EventFeedUtil.SafeId(MoveCamera._focus_unit));
}
private static InterestCandidate SelectNext(float now)
{
bool profile = IdleHitchProbe.Enabled;
@ -2889,7 +2917,8 @@ public static partial class InterestDirector
}
Actor grounding = LifeSagaRoster.BestLivingMc();
if (grounding == null)
long groundingId = EventFeedUtil.SafeId(grounding);
if (grounding == null || groundingId == 0 || !LifeSagaRoster.IsMc(groundingId))
{
return;
}
@ -2931,6 +2960,22 @@ public static partial class InterestDirector
candidate.Completion = InterestCompletionKind.CharacterVignette;
// Fill is not an event - empty orange reason; task chip shows live job.
candidate.Label = "";
candidate.NarrativePresentation = null;
candidate.NarrativeDevelopmentId = "";
candidate.HasNarrativeFunction = true;
candidate.NarrativeFunction = NarrativeFunction.CharacterTexture;
candidate.NarrativeFunctionSource = "character_grounding";
// Roster refresh can replace an MC between BestLivingMc and registration.
// Revalidate the actual candidate immediately before it is allowed to switch.
if (!grounding.isAlive()
|| !LifeSagaRoster.IsMc(groundingId)
|| candidate.SubjectId != groundingId
|| !CameraEligibility.MayUseCamera(candidate))
{
InterestRegistry.Remove(candidate.Key);
return;
}
if (AgentHarness.Busy)
{
candidate.ForceActive = false;

View file

@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Adds a concise, evidence-backed explanation when an exact event participant matters
/// because of a current MC. Generic identity (species/job/traits) never belongs in the Beat.
/// </summary>
public static class BeatSignificance
{
private static readonly List<LifeSagaSlot> Slots =
new List<LifeSagaSlot>(LifeSagaRoster.Cap);
/// <summary>
/// Character grounding is footage, not an event. Its live task already appears in the
/// green task chip, so it must never manufacture an orange Beat.
/// </summary>
public static bool SuppressBeat(InterestCandidate candidate)
{
return candidate != null
&& (candidate.LeadKind == InterestLeadKind.CharacterLed
|| candidate.Completion == InterestCompletionKind.CharacterVignette);
}
public static string Enrich(
Actor focus,
InterestCandidate candidate,
string beat)
{
if (string.IsNullOrEmpty(beat) || candidate == null || SuppressBeat(candidate))
{
return SuppressBeat(candidate) ? "" : beat ?? "";
}
long focusId = EventFeedUtil.SafeId(focus);
long participantId = ExactOtherParticipant(candidate, focusId);
if (participantId == 0
|| participantId == focusId
|| LifeSagaRoster.IsMc(participantId)
|| SemanticsAlreadyExplainRelation(candidate, beat))
{
return beat;
}
string participantName = ExactOtherName(candidate, participantId);
if (string.IsNullOrEmpty(participantName)
|| !ContainsWholeName(StripRichText(beat), participantName))
{
return beat;
}
if (!TryDescribeMcRelation(
participantId,
out long anchorMcId,
out string anchorName,
out RelationEvidence relation))
{
return beat;
}
string qualifier = FormatQualifier(relation, anchorName, colorName: true);
if (string.IsNullOrEmpty(qualifier)
|| StripRichText(beat).IndexOf(
StripRichText(qualifier),
StringComparison.OrdinalIgnoreCase) >= 0)
{
return beat;
}
// Preserve click ownership on the event's named participant. The qualifier explains
// significance but does not replace the event's subject/other ids.
return beat.TrimEnd() + " (" + qualifier + ")";
}
private static long ExactOtherParticipant(InterestCandidate candidate, long focusId)
{
if (candidate?.NarrativePresentation != null)
{
long semanticOther = candidate.NarrativePresentation.OtherId;
if (semanticOther != 0 && semanticOther != focusId)
{
return semanticOther;
}
}
if (candidate == null)
{
return 0;
}
long[] exact =
{
candidate.RelatedId,
EventFeedUtil.SafeId(candidate.RelatedUnit),
candidate.PairOwnerId == focusId ? candidate.PairPartnerId : 0,
candidate.PairPartnerId == focusId ? candidate.PairOwnerId : 0
};
for (int i = 0; i < exact.Length; i++)
{
if (exact[i] != 0 && exact[i] != focusId)
{
return exact[i];
}
}
return 0;
}
private static string ExactOtherName(InterestCandidate candidate, long participantId)
{
NarrativePresentationModel presentation = candidate?.NarrativePresentation;
if (presentation != null
&& presentation.OtherId == participantId
&& !string.IsNullOrEmpty(presentation.Other.Name))
{
return presentation.Other.Name.Trim();
}
if (candidate?.RelatedUnit != null
&& EventFeedUtil.SafeId(candidate.RelatedUnit) == participantId)
{
string related = EventFeedUtil.SafeName(candidate.RelatedUnit);
if (!string.IsNullOrEmpty(related))
{
return related;
}
}
Actor live = EventFeedUtil.FindAliveById(participantId);
return EventFeedUtil.SafeName(live);
}
private static bool TryDescribeMcRelation(
long participantId,
out long anchorMcId,
out string anchorName,
out RelationEvidence relation)
{
anchorMcId = 0;
anchorName = "";
relation = RelationEvidence.None;
Actor participant = EventFeedUtil.FindAliveById(participantId);
Slots.Clear();
LifeSagaRoster.CopySlots(Slots);
int bestPriority = int.MaxValue;
for (int i = 0; i < Slots.Count; i++)
{
LifeSagaSlot slot = Slots[i];
if (slot == null || slot.UnitId == 0 || slot.UnitId == participantId)
{
continue;
}
Actor mc = EventFeedUtil.FindAliveById(slot.UnitId);
RelationEvidence evidence =
SagaProse.ResolveRelation(slot.UnitId, participantId, mc, participant);
string name = !string.IsNullOrEmpty(slot.DisplayName)
? slot.DisplayName
: EventFeedUtil.SafeName(mc);
string candidateQualifier = FormatQualifier(evidence, name, colorName: false);
if (string.IsNullOrEmpty(candidateQualifier))
{
continue;
}
int priority = evidence.Priority > 0 ? evidence.Priority : 9;
if (anchorMcId != 0 && priority >= bestPriority)
{
continue;
}
anchorMcId = slot.UnitId;
anchorName = name;
relation = evidence;
bestPriority = priority;
}
return anchorMcId != 0 && !string.IsNullOrEmpty(anchorName);
}
private static string FormatQualifier(
RelationEvidence relation,
string anchorName,
bool colorName)
{
if (relation == null
|| relation.Class == RelationClass.None
|| string.IsNullOrEmpty(anchorName))
{
return "";
}
string who = colorName ? ActivityProse.ColorName(anchorName) : anchorName;
switch (relation.Class)
{
case RelationClass.Bond:
return (relation.BondBroken ? "former partner of " : "partner of ") + who;
case RelationClass.Friend:
return (relation.BestFriend ? "best friend of " : "friend of ") + who;
case RelationClass.Kin:
switch (relation.KinKind)
{
case KinKind.Parent: return "parent of " + who;
case KinKind.Child: return "child of " + who;
case KinKind.Heir: return "heir of " + who;
case KinKind.Packmate: return "packmate of " + who;
default: return "kin of " + who;
}
case RelationClass.Foe:
switch (relation.FoeKind)
{
case FoeKind.Blood: return "blood foe of " + who;
case FoeKind.War: return "war enemy of " + who;
case FoeKind.Plot: return "plot enemy of " + who;
default: return "rival of " + who;
}
case RelationClass.PlotPartner:
return "plot ally of " + who;
default:
return "";
}
}
private static bool SemanticsAlreadyExplainRelation(
InterestCandidate candidate,
string beat)
{
NarrativePresentationModel presentation = candidate?.NarrativePresentation;
if (presentation != null
&& presentation.RelationRole != NarrativeRelationRole.None)
{
return true;
}
string plain = StripRichText(beat).ToLowerInvariant();
string[] relationWords =
{
"child", "parent", "lover", "partner", "friend", "kin",
"heir", "rival", "enemy", "foe", "ally"
};
for (int i = 0; i < relationWords.Length; i++)
{
if (plain.IndexOf(relationWords[i], StringComparison.Ordinal) >= 0)
{
return true;
}
}
return false;
}
private static bool ContainsWholeName(string text, string name)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(name))
{
return false;
}
int start = 0;
while (start <= text.Length - name.Length)
{
int at = text.IndexOf(name, start, StringComparison.OrdinalIgnoreCase);
if (at < 0)
{
return false;
}
bool left = at == 0 || !IsNameChar(text[at - 1]);
int end = at + name.Length;
bool right = end >= text.Length || !IsNameChar(text[end]);
if (left && right)
{
return true;
}
start = at + 1;
}
return false;
}
private static bool IsNameChar(char value)
{
return char.IsLetterOrDigit(value) || value == '\'' || value == '-';
}
private static string StripRichText(string value)
{
if (string.IsNullOrEmpty(value) || value.IndexOf('<') < 0)
{
return value ?? "";
}
var chars = new System.Text.StringBuilder(value.Length);
bool tag = false;
for (int i = 0; i < value.Length; i++)
{
char ch = value[i];
if (ch == '<')
{
tag = true;
}
else if (ch == '>')
{
tag = false;
}
else if (!tag)
{
chars.Append(ch);
}
}
return chars.ToString();
}
public static bool HarnessProbePolicy(out string detail)
{
var fill = new InterestCandidate
{
LeadKind = InterestLeadKind.CharacterLed,
Completion = InterestCompletionKind.CharacterVignette,
Label = "Elf",
NarrativePresentation = new NarrativePresentationModel
{
Kind = NarrativePresentationKind.AuthoredObservation,
AuthoredClause = "Elf"
}
};
var child = new RelationEvidence
{
Class = RelationClass.Kin,
KinKind = KinKind.Child,
Priority = 4
};
var rival = new RelationEvidence
{
Class = RelationClass.Foe,
FoeKind = FoeKind.Rematch,
Priority = 2
};
bool fillSuppressed = SuppressBeat(fill);
string childQualifier = FormatQualifier(child, "Norron", colorName: false);
string rivalQualifier = FormatQualifier(rival, "Woof", colorName: false);
bool pass = fillSuppressed
&& childQualifier == "child of Norron"
&& rivalQualifier == "rival of Woof";
detail = "fillSuppressed=" + fillSuppressed
+ " child='" + childQualifier
+ "' rival='" + rivalQualifier
+ "' pass=" + pass;
return pass;
}
public static bool HarnessProbeLiveRelation(
Actor focus,
Actor participant,
out string detail)
{
long participantId = EventFeedUtil.SafeId(participant);
string participantName = EventFeedUtil.SafeName(participant);
var candidate = new InterestCandidate
{
LeadKind = InterestLeadKind.EventLed,
Completion = InterestCompletionKind.FixedDwell,
SubjectId = EventFeedUtil.SafeId(focus),
RelatedId = participantId,
RelatedUnit = participant,
NarrativePresentation = new NarrativePresentationModel
{
Kind = NarrativePresentationKind.AuthoredObservation,
PerspectiveId = EventFeedUtil.SafeId(focus),
Perspective = LifeSagaMemory.Snapshot(focus),
OtherId = participantId,
Other = LifeSagaMemory.Snapshot(participant),
AuthoredClause = "Kills " + participantName
}
};
string raw = "Kills " + participantName;
string enriched = Enrich(focus, candidate, raw);
string focusName = EventFeedUtil.SafeName(focus);
bool pass = focus != null
&& participant != null
&& LifeSagaRoster.IsMc(EventFeedUtil.SafeId(focus))
&& enriched.IndexOf("child of", StringComparison.OrdinalIgnoreCase) >= 0
&& enriched.IndexOf(focusName, StringComparison.OrdinalIgnoreCase) >= 0;
detail = "raw='" + raw + "' enriched='" + enriched + "' pass=" + pass;
return pass;
}
}

View file

@ -51,7 +51,7 @@ public static class CameraEligibility
long subjectId = candidate.SubjectId != 0
? candidate.SubjectId
: EventFeedUtil.SafeId(candidate.FollowUnit);
if (IsWorldCritical(candidate))
if (WorldCriticalPolicy.IsWorldCritical(candidate))
{
model.Kind = CameraEligibilityKind.WorldCritical;
model.Evidence = "world_critical";
@ -170,22 +170,13 @@ public static class CameraEligibility
return model.AnchorMcId != 0 ? model.ConnectionLine ?? "" : "";
}
private static bool IsWorldCritical(InterestCandidate c)
{
if (c.EventStrength >= 95f) return true;
return c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsConfirmedDisasterImpact(
InterestCandidate c,
NarrativeFunction function)
{
bool disaster = c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0
|| (c.Source ?? "").IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0;
|| WorldCriticalPolicy.IsTypedDisaster(c);
if (!disaster || c.SubjectId == 0)
{
return false;
@ -471,13 +462,77 @@ public static class CameraEligibility
NarrativeFunction = NarrativeFunction.Consequence,
NarrativeDevelopmentId = "harness_death"
};
var loudGrief = new InterestCandidate
{
Key = "harness:loud_grief",
AssetId = "death_friend",
Category = "FamilyEmotion",
Completion = InterestCompletionKind.HappinessGrief,
SubjectId = 991003,
EventStrength = 99f,
Novelty = 1f,
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.TurningPoint
};
var loudDuel = new InterestCandidate
{
Key = "harness:loud_duel",
AssetId = "live_combat",
Category = "Combat",
Completion = InterestCompletionKind.CombatActive,
SubjectId = 991004,
EventStrength = 99f,
Novelty = 1f,
HasNarrativeFunction = true,
NarrativeFunction = NarrativeFunction.Escalation
};
var garden = new InterestCandidate
{
Key = "harness:garden",
AssetId = "disaster_garden_surprise",
Category = "Disaster",
EventStrength = 90f
};
var meteor = new InterestCandidate
{
Key = "harness:meteor",
AssetId = "disaster_meteorite",
Category = "Disaster",
EventStrength = 95f
};
var war = new InterestCandidate
{
Key = "harness:war",
AssetId = "live_war",
Category = "War",
Completion = InterestCompletionKind.WarFront,
ParticipantCount = 10,
CombatSideACount = 5,
CombatSideBCount = 5,
EventStrength = 65f
};
CameraEligibilityModel hatchModel = Classify(hatch);
CameraEligibilityModel deathModel = Classify(death);
CameraEligibilityModel griefModel = Classify(loudGrief);
CameraEligibilityModel duelModel = Classify(loudDuel);
CameraEligibilityModel gardenModel = Classify(garden);
CameraEligibilityModel meteorModel = Classify(meteor);
CameraEligibilityModel warModel = Classify(war);
bool pass = hatchModel.Kind == CameraEligibilityKind.DiscoveryOnly
&& deathModel.Kind == CameraEligibilityKind.ExceptionalBackground;
&& deathModel.Kind == CameraEligibilityKind.ExceptionalBackground
&& griefModel.Kind != CameraEligibilityKind.WorldCritical
&& duelModel.Kind != CameraEligibilityKind.WorldCritical
&& gardenModel.Kind != CameraEligibilityKind.WorldCritical
&& meteorModel.Kind == CameraEligibilityKind.WorldCritical
&& warModel.Kind == CameraEligibilityKind.WorldCritical;
detail = "hatch=" + hatchModel.Kind + ":" + hatchModel.Evidence
+ " death=" + deathModel.Kind + ":" + deathModel.Evidence;
detail += " grief=" + griefModel.Kind
+ " duel=" + duelModel.Kind
+ " garden=" + gardenModel.Kind
+ " meteor=" + meteorModel.Kind
+ " war=" + warModel.Kind;
return pass;
}
}

View file

@ -31,7 +31,8 @@ public static class InterruptPolicy
{
return NarrativeInterruptClass.RelatedEscalation;
}
if (IsWorldCritical(candidate)) return NarrativeInterruptClass.WorldCritical;
if (WorldCriticalPolicy.IsWorldCritical(candidate))
return NarrativeInterruptClass.WorldCritical;
if (IsStoryPayoff(candidate)) return NarrativeInterruptClass.StoryPayoff;
return NarrativeInterruptClass.OrdinaryInteresting;
@ -74,14 +75,6 @@ public static class InterruptPolicy
return false;
}
private static bool IsWorldCritical(InterestCandidate c)
{
if (c.EventStrength >= 95f) return true;
return c.Completion == InterestCompletionKind.EarthquakeActive
|| c.Completion == InterestCompletionKind.StatusOutbreak
|| (c.Category ?? "").IndexOf("Disaster", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsStoryPayoff(InterestCandidate c)
{
return StoryReason.IsStoryAsset(c.AssetId)

View file

@ -482,7 +482,10 @@ public static class NarrativePersistence
/// references must point at retained developments. Active casts and player-selected
/// lives are protected from character compaction.
/// </summary>
private static void CompactGraph(NarrativeSaveState state, int characterLimit)
private static void CompactGraph(
NarrativeSaveState state,
int characterLimit,
bool protectRuntimeRoster = true)
{
if (state == null)
{
@ -611,8 +614,9 @@ public static class NarrativePersistence
{
CharacterStoryStateDto character = state.characters[i];
if (character == null) continue;
if (LifeSagaRoster.IsMc(character.characterId)
|| LifeSagaRoster.IsPrefer(character.characterId))
if (protectRuntimeRoster
&& (LifeSagaRoster.IsMc(character.characterId)
|| LifeSagaRoster.IsPrefer(character.characterId)))
{
protectedCharacters.Add(character.characterId);
}
@ -745,7 +749,9 @@ public static class NarrativePersistence
storyPotential = 200f
});
CompactGraph(state, 2);
// This probe owns a synthetic graph and must not inherit protection from
// whichever live-world actors happen to share its deliberately small IDs.
CompactGraph(state, 2, protectRuntimeRoster: false);
CharacterStoryStateDto protagonist =
state.characters.Find(item => item.characterId == 10);
bool ok = state.developments.Count == 1

View file

@ -486,17 +486,32 @@ public static class NarrativeRenderer
case NarrativePresentationKind.FamilyEnded:
return "Their family line ends";
case NarrativePresentationKind.FamilyPackFormed:
{
bool pack = LifeSagaRoster.UsesPackLanguage(
EventFeedUtil.FindAliveById(model.PerspectiveId));
string group = pack ? "a family pack" : "a family";
return string.IsNullOrEmpty(other)
? "Forms a family pack"
: "Forms a family pack with " + other;
? "Forms " + group
: "Forms " + group + " with " + other;
}
case NarrativePresentationKind.FamilyPackJoined:
{
bool pack = LifeSagaRoster.UsesPackLanguage(
EventFeedUtil.FindAliveById(model.PerspectiveId));
string group = pack ? "a family pack" : "a family";
return string.IsNullOrEmpty(other)
? "Joins a family pack"
: "Joins a family pack with " + other;
? "Joins " + group
: "Joins " + group + " with " + other;
}
case NarrativePresentationKind.FamilyPackLeft:
{
bool pack = LifeSagaRoster.UsesPackLanguage(
EventFeedUtil.FindAliveById(model.PerspectiveId));
string group = pack ? "a family pack" : "a family";
return string.IsNullOrEmpty(other)
? "Leaves a family pack"
: "Leaves a family pack shared with " + other;
? "Leaves " + group
: "Leaves " + group + " shared with " + other;
}
case NarrativePresentationKind.GiftGiven:
return string.IsNullOrEmpty(other) ? "Gives a gift" : "Gives " + other + " a gift";
case NarrativePresentationKind.GiftReceived:

View file

@ -1275,8 +1275,13 @@ public static class NarrativeStoryStore
const int importedDevelopments = 2300;
const int importedThreads = 900;
const int importedConsequences = 1300;
// Keep synthetic ids outside normal WorldBox actor-id ranges. Low ids could collide
// with the live five-MC roster, falsely protecting five ambient consequences and
// making this deterministic compaction probe depend on the currently loaded world.
const long importedCharacterBase = 9100000;
for (int i = 0; i < importedDevelopments; i++)
{
long characterId = importedCharacterBase + i;
string developmentId = "probe:compact:development:" + i;
ImportDevelopment(new NarrativeDevelopment
{
@ -1285,7 +1290,7 @@ public static class NarrativeStoryStore
Function = i == importedDevelopments - 1
? NarrativeFunction.TurningPoint
: NarrativeFunction.CharacterTexture,
SubjectId = i + 1,
SubjectId = characterId,
OccurredAt = i + 1,
Magnitude = i == importedDevelopments - 1 ? 100f : 5f
});
@ -1293,13 +1298,13 @@ public static class NarrativeStoryStore
{
Id = "event:" + developmentId,
DevelopmentId = developmentId,
SubjectId = i + 1,
SubjectId = characterId,
ObservedAt = i + 1,
WorldTime = i + 1
});
ImportCharacter(new CharacterStoryState
{
CharacterId = i + 1,
CharacterId = characterId,
LastMeaningfulChangeAt = i + 1,
StoryPotential = i % 20
});
@ -1309,7 +1314,7 @@ public static class NarrativeStoryStore
{
Id = "probe:compact:thread:" + i,
Kind = NarrativeThreadKind.Founding,
ProtagonistId = i + 1,
ProtagonistId = characterId,
OpenedByDevelopmentId = developmentId,
LatestMeaningfulChangeId = developmentId,
Status = NarrativeThreadStatus.Resolved,
@ -1319,7 +1324,7 @@ public static class NarrativeStoryStore
Momentum = i / 100f
};
thread.DevelopmentIds.Add(developmentId);
thread.AddCast(i + 1, "protagonist");
thread.AddCast(characterId, "protagonist");
ImportThread(thread);
}
if (i < importedConsequences)
@ -1327,7 +1332,7 @@ public static class NarrativeStoryStore
ImportConsequence(new CharacterConsequence
{
Id = "probe:compact:consequence:" + i,
CharacterId = i + 1,
CharacterId = characterId,
DevelopmentId = developmentId,
Kind = CharacterConsequenceKind.FoundHome,
OccurredAt = i + 1,

View file

@ -0,0 +1,68 @@
using System;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Typed camera/interrupt gate for events important enough to leave the MC orbit.
/// A large numeric score is deliberately insufficient: grief, duels, and other personal
/// outcomes can be intense without becoming a world-scale cutaway.
/// </summary>
public static class WorldCriticalPolicy
{
public static bool IsWorldCritical(InterestCandidate candidate)
{
if (candidate == null)
{
return false;
}
if (candidate.Completion == InterestCompletionKind.EarthquakeActive
|| candidate.Completion == InterestCompletionKind.StatusOutbreak)
{
return true;
}
if (candidate.Completion == InterestCompletionKind.WarFront)
{
int sideA = Mathf.Max(0, candidate.CombatSideACount);
int sideB = Mathf.Max(0, candidate.CombatSideBCount);
int participants = Mathf.Max(
candidate.ParticipantCount,
sideA + sideB);
int minimum = Mathf.Max(2, InterestScoringConfig.Story.crisisWarParticipantMin);
return sideA > 0 && sideB > 0 && participants >= minimum;
}
if (!IsTypedDisaster(candidate))
{
return false;
}
float minimumStrength = Mathf.Max(
1f,
InterestScoringConfig.Story.crisisDisasterStrengthMin);
return candidate.EventStrength >= minimumStrength;
}
public static bool IsTypedDisaster(InterestCandidate candidate)
{
if (candidate == null)
{
return false;
}
if (string.Equals(
candidate.Category,
"Disaster",
StringComparison.OrdinalIgnoreCase))
{
return true;
}
string asset = (candidate.AssetId ?? "").Trim();
return !string.IsNullOrEmpty(asset)
&& (EventCatalog.Disaster.IsLiveOrAuthored(asset)
|| EventCatalog.WorldLog.IsDisasterCategory(asset));
}
}

View file

@ -0,0 +1,509 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Non-mutating audit of every registered actor asset and every species represented
/// by a living unit in the loaded world. Keeps rail-frame regressions catalog-visible
/// without spawning test creatures into a player's world.
/// </summary>
public static class RailFaceCatalogAudit
{
private static readonly List<string> Assets = new List<string>(384);
private static readonly StringBuilder Rows = new StringBuilder(32768);
private static string _outputDirectory = "";
private static WorldTile _spawnTile;
private static int _cursor;
private static int _catalog;
private static int _represented;
private static int _spawned;
private static int _passed;
private static int _failed;
private static int _unspawnable;
private static int _notApplicable;
private static float _captureTotalMs;
private static float _captureMaxMs;
private static float _stepMaxMs;
private static string _failSample = "";
private static Actor _pendingTemporary;
private static string _pendingId = "";
private static ActorAsset _pendingAsset;
public static bool Running { get; private set; }
/// <summary>
/// Starts an exhaustive, one-asset-per-frame sweep. Missing representatives are
/// temporarily spawned and immediately destroyed with AttackType.None.
/// </summary>
public static bool BeginExhaustive(string outputDirectory, WorldTile spawnTile, out string detail)
{
Cancel();
if (spawnTile == null || World.world?.units == null)
{
detail = "world/spawn tile unavailable";
return false;
}
ActorAssetLibrary library = AssetManager.actor_library;
if (library?.list == null)
{
detail = "actor library unavailable";
return false;
}
for (int i = 0; i < library.list.Count; i++)
{
ActorAsset asset = library.list[i];
if (asset != null && !string.IsNullOrEmpty(asset.id))
{
Assets.Add(asset.id);
}
}
_outputDirectory = outputDirectory;
_spawnTile = spawnTile;
_catalog = Assets.Count;
Rows.AppendLine(
"asset\tciv\torigin\tliving\tstatus\tselected\tsources\tspecies\tcapture_ms\tstep_ms");
Running = Assets.Count > 0;
detail = "catalog sweep started assets=" + Assets.Count;
return Running;
}
/// <summary>Processes one registered asset so test-only creation is amortized by frame.</summary>
public static bool StepExhaustive(out bool success, out string detail)
{
success = false;
detail = "catalog sweep not running";
if (!Running)
{
return true;
}
long stepStarted = Stopwatch.GetTimestamp();
bool warmedTemporary = _pendingTemporary != null;
string id = warmedTemporary ? _pendingId : Assets[_cursor++];
ActorAsset asset = warmedTemporary
? _pendingAsset
: ActivityAssetCatalog.TryGetActorAsset(id);
Actor actor = warmedTemporary ? _pendingTemporary : null;
Actor temporary = actor;
_pendingTemporary = null;
_pendingId = "";
_pendingAsset = null;
int living = 0;
string origin = warmedTemporary ? "temporary+warmed" : "world";
string status = "";
float captureMs = 0f;
Sprite selected = null;
Sprite species = null;
string sources = "actor=none";
try
{
if (!warmedTemporary)
{
actor = FindLivingNonEgg(asset, out living);
if (actor == null)
{
origin = "temporary";
try
{
temporary = World.world.units.spawnNewUnit(
id,
_spawnTile,
pSpawnSound: false,
pMiracleSpawn: true);
}
catch (Exception ex)
{
status = "spawn_exception:" + ex.GetType().Name;
}
if (temporary == null || !temporary.isAlive())
{
_unspawnable++;
if (string.IsNullOrEmpty(status))
{
status = "not_spawnable";
}
}
else
{
actor = temporary;
_spawned++;
}
}
else
{
temporary = null;
_represented++;
}
}
if (actor != null && actor.isAlive())
{
bool egg = false;
try
{
egg = actor.isEgg();
}
catch
{
egg = false;
}
if (egg)
{
_notApplicable++;
status = "egg";
}
else
{
long captureStarted = Stopwatch.GetTimestamp();
selected = HudIcons.FromActorLiveRailFace(actor);
captureMs = ElapsedMs(captureStarted);
_captureTotalMs += captureMs;
_captureMaxMs = Mathf.Max(_captureMaxMs, captureMs);
species = HudIcons.FromActor(actor);
sources = HudIcons.DescribeActorRailSources(actor);
bool usable = HudIcons.IsUsableRailFace(selected);
// Some special actors (notably Crabzilla) need one Update after
// spawn before calculateMainSprite has initialized its renderer.
if (!usable && temporary != null && !warmedTemporary)
{
_pendingTemporary = temporary;
_pendingId = id;
_pendingAsset = asset;
temporary = null;
detail = $"catalog sweep {_cursor}/{Assets.Count} warming={id}";
return false;
}
// Player-controlled/special-renderer assets may be registered as Actors
// without exposing an actor sprite at all. They cannot supply a live rail
// frame; keeping the species fallback is a capability constraint, not a
// bad selection of the default icon.
if (!usable
&& warmedTemporary
&& sources.StartsWith("sources_error=", StringComparison.Ordinal))
{
_notApplicable++;
status = "special_renderer";
}
else
{
bool distinct = usable && !HudIcons.SameSpriteSource(selected, species);
bool liveSource = usable
&& HudIcons.MatchesGeneratedActorFrame(actor, selected);
if (distinct && liveSource)
{
_passed++;
status = "pass";
}
else
{
_failed++;
status = usable ? "non_live_source" : "no_live_frame";
AddSample(ref _failSample, id + ":" + status);
}
}
}
}
else if (warmedTemporary)
{
_failed++;
status = "died_before_warm_capture";
AddSample(ref _failSample, id + ":" + status);
}
}
catch (Exception ex)
{
_failed++;
status = "audit_exception:" + ex.GetType().Name;
AddSample(ref _failSample, id + ":" + status);
}
finally
{
if (temporary != null)
{
try
{
temporary.dieAndDestroy(AttackType.None);
}
catch
{
// Test actor cleanup is best-effort; the row remains diagnostic.
}
}
}
float stepMs = ElapsedMs(stepStarted);
_stepMaxMs = Mathf.Max(_stepMaxMs, stepMs);
Rows.Append(id).Append('\t')
.Append(asset?.civ ?? false).Append('\t')
.Append(origin).Append('\t')
.Append(living).Append('\t')
.Append(status).Append('\t')
.Append(Describe(selected)).Append('\t')
.Append(sources).Append('\t')
.Append(Describe(species)).Append('\t')
.Append(captureMs.ToString("0.000")).Append('\t')
.Append(stepMs.ToString("0.000"))
.AppendLine();
if (_cursor < Assets.Count)
{
detail = $"catalog sweep {_cursor}/{Assets.Count}";
return false;
}
Running = false;
string path = Path.Combine(_outputDirectory, "saga-rail-species-frames.tsv");
try
{
Directory.CreateDirectory(_outputDirectory);
File.WriteAllText(path, Rows.ToString());
}
catch (Exception ex)
{
detail = "audit write exception=" + ex.GetType().Name + ":" + ex.Message;
return true;
}
int audited = _passed + _failed;
success = audited > 0 && _failed == 0;
detail =
$"catalog={_catalog} audited={audited} represented={_represented} spawned={_spawned} "
+ $"pass={_passed} fail={_failed} unspawnable={_unspawnable} n/a={_notApplicable} "
+ $"captureMs={_captureTotalMs:0.00}/{_captureMaxMs:0.00} stepMaxMs={_stepMaxMs:0.00} "
+ $"sample='{_failSample}' path={path}";
return true;
}
public static void Cancel()
{
Running = false;
Assets.Clear();
Rows.Length = 0;
_outputDirectory = "";
_spawnTile = null;
_cursor = 0;
_catalog = 0;
_represented = 0;
_spawned = 0;
_passed = 0;
_failed = 0;
_unspawnable = 0;
_notApplicable = 0;
_captureTotalMs = 0f;
_captureMaxMs = 0f;
_stepMaxMs = 0f;
_failSample = "";
if (_pendingTemporary != null)
{
try
{
_pendingTemporary.dieAndDestroy(AttackType.None);
}
catch
{
// best effort
}
}
_pendingTemporary = null;
_pendingId = "";
_pendingAsset = null;
}
public static bool Run(string outputDirectory, out string detail)
{
var rows = new StringBuilder(32768);
rows.AppendLine("asset\tciv\tliving\tstatus\tselected\tsources\tspecies\tcapture_ms");
int catalog = 0;
int represented = 0;
int passed = 0;
int failed = 0;
int unavailable = 0;
float totalMs = 0f;
float maxMs = 0f;
string failSample = "";
try
{
ActorAssetLibrary library = AssetManager.actor_library;
if (library?.list == null)
{
detail = "actor library unavailable";
return false;
}
for (int i = 0; i < library.list.Count; i++)
{
ActorAsset asset = library.list[i];
if (asset == null || string.IsNullOrEmpty(asset.id))
{
continue;
}
catalog++;
Actor actor = FindLivingNonEgg(asset, out int living);
if (actor == null)
{
unavailable++;
rows.Append(asset.id).Append('\t')
.Append(asset.civ).Append('\t')
.Append(living).Append('\t')
.AppendLine(living > 0 ? "egg_only" : "no_living_actor");
continue;
}
represented++;
long started = Stopwatch.GetTimestamp();
Sprite selected = HudIcons.FromActorLiveRailFace(actor);
float captureMs = (float)(
(Stopwatch.GetTimestamp() - started) * 1000.0 / Stopwatch.Frequency);
totalMs += captureMs;
maxMs = Mathf.Max(maxMs, captureMs);
Sprite species = HudIcons.FromActor(actor);
bool usable = HudIcons.IsUsableRailFace(selected);
bool distinct = usable && !HudIcons.SameSpriteSource(selected, species);
bool liveSource = usable && HudIcons.MatchesGeneratedActorFrame(actor, selected);
bool ok = distinct && liveSource;
if (ok)
{
passed++;
}
else
{
failed++;
AddSample(
ref failSample,
asset.id + ":" + (usable ? "non-live-source" : "no-frame"));
}
rows.Append(asset.id).Append('\t')
.Append(asset.civ).Append('\t')
.Append(living).Append('\t')
.Append(ok ? "pass" : "fail").Append('\t')
.Append(Describe(selected)).Append('\t')
.Append(HudIcons.DescribeActorRailSources(actor)).Append('\t')
.Append(Describe(species)).Append('\t')
.Append(captureMs.ToString("0.000"))
.AppendLine();
}
}
catch (Exception ex)
{
detail = "audit exception=" + ex.GetType().Name + ":" + ex.Message;
return false;
}
string path = Path.Combine(outputDirectory, "saga-rail-species-frames.tsv");
try
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllText(path, rows.ToString());
}
catch (Exception ex)
{
detail = "audit write exception=" + ex.GetType().Name + ":" + ex.Message;
return false;
}
detail =
$"catalog={catalog} represented={represented} pass={passed} fail={failed} "
+ $"unavailable={unavailable} captureMs={totalMs:0.00}/{maxMs:0.00} "
+ $"sample='{failSample}' path={path}";
return represented > 0 && failed == 0;
}
private static Actor FindLivingNonEgg(ActorAsset asset, out int living)
{
living = 0;
Actor first = null;
try
{
if (asset?.units == null)
{
return null;
}
foreach (Actor actor in asset.units)
{
if (actor == null || !actor.isAlive())
{
continue;
}
living++;
bool egg = false;
try
{
egg = actor.isEgg();
}
catch
{
egg = false;
}
if (!egg && first == null)
{
first = actor;
}
}
}
catch
{
return first;
}
return first;
}
private static string Describe(Sprite sprite)
{
if (sprite == null)
{
return "none";
}
try
{
return (sprite.name ?? "?")
+ ":" + sprite.rect.width.ToString("0")
+ "x" + sprite.rect.height.ToString("0");
}
catch
{
return "invalid";
}
}
private static void AddSample(ref string sample, string value)
{
if (sample.Length >= 240)
{
return;
}
if (sample.Length > 0)
{
sample += "; ";
}
sample += value;
}
private static float ElapsedMs(long started) =>
(float)((Stopwatch.GetTimestamp() - started) * 1000.0 / Stopwatch.Frequency);
}

View file

@ -89,10 +89,12 @@ public static class CaptionComposer
model.ConnectionLine = CameraEligibility.ConnectionLine(ownedTip, focusId);
model.ReasonRelatedId = reasonRelatedId;
string beat = presentableBeat ?? "";
bool suppressBeat = BeatSignificance.SuppressBeat(ownedTip);
string beat = suppressBeat ? "" : presentableBeat ?? "";
string narrativeContext = "";
bool structuredNarrativeBeat = false;
if (!statusBannerOwnsBeat
&& !suppressBeat
&& NarrativeProse.TryBuildBeat(
focus,
ownedTip,
@ -122,6 +124,8 @@ public static class CaptionComposer
{
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
}
model.BeatLine = BeatSignificance.Enrich(focus, ownedTip, model.BeatLine);
}
model.ContextLine = BuildContext(

View file

@ -61,6 +61,39 @@ public static class LifeSagaPresentation
return model;
}
public static bool HarnessProbeCivilizedTerminology(Actor actor, out string detail)
{
bool civilized = false;
try
{
civilized = actor != null && actor.asset != null && actor.asset.civ;
}
catch
{
civilized = false;
}
if (!civilized)
{
detail = "civilized=false";
return false;
}
LifeSagaViewModel model = Build(EventFeedUtil.SafeId(actor));
string panel = model?.ToPlainText() ?? "";
string relation = EventReason.FamilyPack(actor, null, "join");
bool packAlpha = LifeSagaRoster.IsPackAlpha(actor);
bool leaked = panel.IndexOf("Packmate", StringComparison.OrdinalIgnoreCase) >= 0
|| panel.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) >= 0
|| relation.IndexOf("pack", StringComparison.OrdinalIgnoreCase) >= 0;
bool pass = !packAlpha && !leaked;
detail = "alpha=" + packAlpha
+ " relation='" + relation + "'"
+ " panelPack=" + (panel.IndexOf("pack", StringComparison.OrdinalIgnoreCase) >= 0)
+ " pass=" + pass;
return pass;
}
/// <summary>
/// Pick the strongest unresolved pressure that adds information beyond Identity.
/// Current-role/founding facts belong in the title, not a duplicate tooltip stake.
@ -284,7 +317,10 @@ public static class LifeSagaPresentation
Add(LifeSagaLens.CrownClan, 8f);
break;
case LifeSagaAdmissionReason.Alpha:
Add(LifeSagaLens.PackAlpha, 8f);
if (LifeSagaRoster.UsesPackLanguage(actor))
{
Add(LifeSagaLens.PackAlpha, 8f);
}
break;
case LifeSagaAdmissionReason.PlotAuthor:
Add(LifeSagaLens.Plotter, 8f);
@ -341,7 +377,10 @@ public static class LifeSagaPresentation
case LifeSagaFactKind.RoleChange:
if ((f.Note ?? "").IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
Add(LifeSagaLens.PackAlpha, 14f);
if (LifeSagaRoster.UsesPackLanguage(actor))
{
Add(LifeSagaLens.PackAlpha, 14f);
}
}
else
{
@ -350,7 +389,10 @@ public static class LifeSagaPresentation
break;
case LifeSagaFactKind.ParentChild:
Add(LifeSagaLens.PackAlpha, 6f);
if (LifeSagaRoster.UsesPackLanguage(actor))
{
Add(LifeSagaLens.PackAlpha, 6f);
}
Add(LifeSagaLens.LoveGrief, 4f);
break;
}
@ -577,6 +619,7 @@ public static class LifeSagaPresentation
|| primary == LifeSagaLens.Plotter))
{
string peerLabel = primary == LifeSagaLens.PackAlpha
&& LifeSagaRoster.UsesPackLanguage(actor)
? SagaProse.CastLabelLive("packmate")
: SagaProse.CastLabelLive("kin");
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCastCandidates + 2))

View file

@ -27,6 +27,7 @@ public static class LifeSagaRail
private static RectTransform _rowRt;
private static string _fingerprint = "";
private static float _nextFaceRefreshAt;
private static int _retryCursor;
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
public static string LastTipSample { get; private set; } = "";
@ -110,6 +111,10 @@ public static class LifeSagaRail
public static int LastShownCount { get; private set; }
/// <summary>Harness: unit-keyed last-face snapshots retained for the current roster.</summary>
public static int CachedFaceCount => LastFaces.Count;
/// <summary>Harness/telemetry: bounded inactive live-capture attempts.</summary>
public static int LiveCaptureRetryCount { get; private set; }
public static float LastLiveCaptureMs { get; private set; }
public static float MaxLiveCaptureMs { get; private set; }
public static void EnsureBuilt(Transform parent)
{
@ -143,6 +148,10 @@ public static class LifeSagaRail
LastPreferPipVisible = false;
LastHoverRowCount = 0;
LastHoverHeight = 0f;
_retryCursor = 0;
LiveCaptureRetryCount = 0;
LastLiveCaptureMs = 0f;
MaxLiveCaptureMs = 0f;
LifeSagaViewController.CancelPreviewAndPause();
if (_row != null)
{
@ -269,6 +278,7 @@ public static class LifeSagaRail
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
_row.SetActive(LastShownCount > 0);
RetryOneMissingLiveFace(watchId);
}
else
{
@ -287,6 +297,12 @@ public static class LifeSagaRail
ApplyFace(Slots[i], SlotScratch[i], refreshLive: true);
}
// A unit may enter the roster while its render frame is unavailable (egg,
// atlas not ready, transient tiny crop). Retry at most one inactive missing
// capture per one-second rail cadence; round-robin prevents an egg from
// starving later slots. Species fallbacks are display-only and never cached.
RetryOneMissingLiveFace(watchId);
}
if (LifeSagaViewController.IsHoverPreview)
@ -482,30 +498,7 @@ public static class LifeSagaRail
return;
}
Actor actor = saga.Resolve();
Sprite sprite = null;
if (!refreshLive)
{
LastFaces.TryGetValue(saga.UnitId, out sprite);
}
// Capture every character once, then refresh only the watched glyph. When focus
// leaves, its last phenotype/kingdom-tinted frame remains instead of reverting
// to a shared species icon.
if (refreshLive || !HudIcons.IsUsableRailFace(sprite))
{
Sprite live = HudIcons.FromActorRailFace(actor, saga.SpeciesId);
if (HudIcons.IsUsableRailFace(live))
{
sprite = live;
LastFaces[saga.UnitId] = live;
}
}
if (!HudIcons.IsUsableRailFace(sprite))
{
sprite = HudIcons.FromSpeciesId(saga.SpeciesId);
}
Sprite sprite = ResolveFace(saga, refreshLive);
if (HudIcons.IsUsableRailFace(sprite) && slot.Face != null)
{
@ -545,6 +538,144 @@ public static class LifeSagaRail
}
}
private static Sprite ResolveFace(LifeSagaSlot saga, bool refreshLive)
{
if (saga == null || saga.UnitId == 0)
{
return null;
}
LastFaces.TryGetValue(saga.UnitId, out Sprite sprite);
// Capture every character once, then refresh only the watched glyph. A failed
// refresh never discards the last good phenotype/kingdom-tinted frame.
if (refreshLive)
{
long started = System.Diagnostics.Stopwatch.GetTimestamp();
Sprite live = HudIcons.FromActorLiveRailFace(saga.Resolve());
long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - started;
LastLiveCaptureMs = (float)(
elapsed * 1000.0 / System.Diagnostics.Stopwatch.Frequency);
MaxLiveCaptureMs = Mathf.Max(MaxLiveCaptureMs, LastLiveCaptureMs);
if (HudIcons.IsUsableRailFace(live))
{
sprite = live;
LastFaces[saga.UnitId] = live;
}
}
// Display fallback only. Do not let it masquerade as a retained live frame.
return HudIcons.IsUsableRailFace(sprite)
? sprite
: HudIcons.FromSpeciesId(saga.SpeciesId);
}
private static void RetryOneMissingLiveFace(long watchId)
{
int count = Mathf.Min(Slots.Length, SlotScratch.Count);
if (count <= 0)
{
return;
}
_retryCursor = Mathf.Clamp(_retryCursor, 0, count - 1);
for (int offset = 0; offset < count; offset++)
{
int index = (_retryCursor + offset) % count;
LifeSagaSlot saga = SlotScratch[index];
Slot slot = Slots[index];
if (saga == null
|| slot == null
|| !slot.Root.activeSelf
|| saga.UnitId == 0
|| saga.UnitId == watchId
|| LastFaces.ContainsKey(saga.UnitId))
{
continue;
}
Actor actor = saga.Resolve();
if (actor == null || !actor.isAlive())
{
continue;
}
ApplyFace(slot, saga, refreshLive: true);
LiveCaptureRetryCount++;
_retryCursor = (index + 1) % count;
return;
}
}
public static bool HarnessProbeFallbackCacheIsolation(out string detail)
{
const long syntheticId = -9100001;
int before = LastFaces.Count;
LastFaces.Remove(syntheticId);
var synthetic = new LifeSagaSlot
{
UnitId = syntheticId,
SpeciesId = "human"
};
Sprite fallback = ResolveFace(synthetic, refreshLive: true);
bool fallbackShown = HudIcons.IsUsableRailFace(fallback);
bool cachedFallback = LastFaces.ContainsKey(syntheticId);
// A retained frame must survive a later failed/dead refresh.
Sprite retainedSeed = HudIcons.FromSpeciesId("human");
bool retained = false;
if (HudIcons.IsUsableRailFace(retainedSeed))
{
LastFaces[syntheticId] = retainedSeed;
retained = ResolveFace(synthetic, refreshLive: true) == retainedSeed;
}
LastFaces.Remove(syntheticId);
bool countRestored = LastFaces.Count == before;
bool pass = fallbackShown && !cachedFallback && retained && countRestored;
detail =
$"fallbackShown={fallbackShown} cachedFallback={cachedFallback} "
+ $"retained={retained} count={before}/{LastFaces.Count} pass={pass}";
return pass;
}
public static bool HarnessProbeFocusUsesDistinctLiveFrame(out string detail)
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
long id = EventFeedUtil.SafeId(focus);
LastFaces.TryGetValue(id, out Sprite cached);
Sprite species = focus != null ? HudIcons.FromActor(focus) : null;
bool cachedUsable = HudIcons.IsUsableRailFace(cached);
bool distinct = cachedUsable
&& species != null
&& !HudIcons.SameSpriteSource(cached, species);
bool generated = HudIcons.MatchesGeneratedActorFrame(focus, cached);
detail =
$"id={id} asset={focus?.asset?.id} cached={DescribeSprite(cached)} "
+ $"species={DescribeSprite(species)} distinct={distinct} generated={generated} "
+ $"captureMs={LastLiveCaptureMs:0.00}/{MaxLiveCaptureMs:0.00} "
+ HudIcons.DescribeActorRailSources(focus);
return distinct && generated;
}
private static string DescribeSprite(Sprite sprite)
{
if (sprite == null)
{
return "none";
}
try
{
return (sprite.name ?? "?")
+ ":" + sprite.rect.width.ToString("0")
+ "x" + sprite.rect.height.ToString("0");
}
catch
{
return "invalid";
}
}
private static void PruneFaceCache()
{
var keep = new HashSet<long>();
@ -629,6 +760,26 @@ public static class LifeSagaRail
OnClick(index);
}
/// <summary>Harness: click the visible slot for an exact unit instead of assuming slot 0.</summary>
internal static bool HarnessClickUnit(long unitId)
{
if (unitId == 0)
{
return false;
}
for (int i = 0; i < Slots.Length; i++)
{
if (Slots[i] != null && Slots[i].UnitId == unitId)
{
OnClick(i);
return true;
}
}
return false;
}
private static void OnClick(int index)
{
if (index < 0 || index >= Slots.Length)

View file

@ -1944,7 +1944,7 @@ public static class LifeSagaRoster
public static bool IsPackAlpha(Actor actor)
{
if (actor == null)
if (!UsesPackLanguage(actor))
{
return false;
}
@ -1966,6 +1966,24 @@ public static class LifeSagaRoster
}
}
/// <summary>
/// WorldBox uses Family/alpha storage for civilized bloodlines as well as creature packs.
/// Only non-civilized species should surface that implementation detail as pack language.
/// </summary>
public static bool UsesPackLanguage(Actor actor)
{
try
{
return actor != null
&& actor.asset != null
&& !actor.asset.civ;
}
catch
{
return false;
}
}
public static bool IsClanChief(Actor actor)
{
if (actor == null)

View file

@ -88,8 +88,16 @@ public static class LifeSagaViewController
return;
}
if (_hoverPreviewId == 0)
{
// Capture the already-painted dossier typography before hover changes ownership
// of the header. Every glyph in this hover session uses that same ceiling.
WatchCaption.BeginSagaHoverSession();
}
_hoverExitAt = -1f;
_hoverPreviewId = unitId;
WatchCaption.PrimeSagaHoverHeader(unitId);
if (!_pauseHeld)
{
InterestDirector.AcquireReadPause(ReadPauseOwner);

View file

@ -214,7 +214,12 @@ public static class SagaProse
if (note.IndexOf("alpha", StringComparison.OrdinalIgnoreCase) >= 0)
{
return TitleLabel("Pack Alpha");
if (LifeSagaRoster.UsesPackLanguage(actor))
{
return TitleLabel("Pack Alpha");
}
continue;
}
if (note.IndexOf("chief", StringComparison.OrdinalIgnoreCase) >= 0
@ -560,7 +565,9 @@ public static class SagaProse
return string.IsNullOrEmpty(k) ? Sentence("Rules a kingdom") : Sentence("Rules " + k);
}
case LifeSagaAdmissionReason.Alpha:
return Sentence("Leads the pack");
return LifeSagaRoster.UsesPackLanguage(actor)
? Sentence("Leads the pack")
: Sentence("Leads a family");
case LifeSagaAdmissionReason.PlotAuthor:
return Sentence("Leads a plot");
case LifeSagaAdmissionReason.Singleton:

View file

@ -448,9 +448,9 @@ public sealed class UnitDossier
return "";
}
// Character-fill vignettes: hide reason (task chip shows live activity).
if (scene.LeadKind == InterestLeadKind.CharacterLed
&& !InterestScoring.IsCombatAction(scene))
// Character-fill vignettes: always hide the Beat. Live combat state cannot turn an
// ambient candidate's generated species presentation into an event reason.
if (BeatSignificance.SuppressBeat(scene))
{
if (dossier != null)
{

View file

@ -127,6 +127,10 @@ public static class WatchCaption
private static string _statusBanner = "";
private static float _statusBannerUntil;
private static string _lastLoggedCaption = "";
// Saga hover owns the painted header without changing the camera dossier's semantic
// LastHeadline. Keep its display text and entry typography separate from that cache.
private static string _sagaHoverHeadline = "";
private static int _sagaHoverFontCeiling;
// OwnedEventReason performs stranger-name presentability checks against the living
// population. A developed world makes that expensive, while the active director
// candidate is normally unchanged for many 0.2-second caption refreshes.
@ -175,6 +179,8 @@ public static class WatchCaption
public static string LastHeadline { get; private set; } = "";
public static string VisibleNametagText => _nameText != null ? (_nameText.text ?? "") : "";
public static int VisibleNametagFontSize => _nameText != null ? _nameText.fontSize : 0;
public static int SagaHoverFontCeiling => _sagaHoverFontCeiling;
public static string LastDetail { get; private set; } = "";
@ -246,6 +252,10 @@ public static class WatchCaption
+ LifeSagaPanel.BodyHeightFixed + 2f + PadTop - Gap;
public static bool LastLayoutOk { get; private set; }
/// <summary>Harness/UI: top anchor shared by the dossier and Saga portrait.</summary>
public static float LastBodyAnchorY { get; private set; }
/// <summary>Harness/UI: first bottom narrative row after body/status/trait details.</summary>
public static float LastNarrativeAnchorY { get; private set; }
/// <summary>True when the dossier card GameObject is active.</summary>
public static bool Visible => _visible && _root != null && _root.activeSelf;
@ -537,6 +547,8 @@ public static class WatchCaption
_nextRoutineLiveRefreshAt = 0f;
_nextRoutineSpineAt = 0f;
_headlineLocked = false;
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = 0;
_historyColW = HistoryColMinW;
_statusBanner = "";
_statusBannerUntil = 0f;
@ -617,24 +629,7 @@ public static class WatchCaption
{
if (interest != null)
{
// Location-only / no unit: tip text for logs; dossier has no owned subject reason.
LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = "";
LastCaptionText = LastHeadline;
LastHistoryPreview = "";
LastHistoryJoined = "";
InvalidateOwnedReasonCache();
_current = null;
_boundActor = null;
EnsureBuilt();
ApplyVisual(null, null);
if (_nameText != null)
{
_nameText.text = LastHeadline;
}
Relayout(false, 0, 0, false, false, 0);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
SetFromLocationInterest(interest);
}
return;
@ -645,6 +640,36 @@ public static class WatchCaption
SetFromActor(unit, label: null);
}
/// <summary>
/// Present an event as a location shot even when the previous camera focus is still alive.
/// Used when a supplied follow is rejected or a world event has no character subject.
/// </summary>
public static void SetFromLocationInterest(InterestEvent interest)
{
if (interest == null)
{
return;
}
LastHeadline = CameraDirector.FormatWatchTip(interest);
LastDetail = "";
LastCaptionText = LastHeadline;
LastHistoryPreview = "";
LastHistoryJoined = "";
InvalidateOwnedReasonCache();
_current = null;
_boundActor = null;
EnsureBuilt();
ApplyVisual(null, null);
if (_nameText != null)
{
_nameText.text = LastHeadline;
}
Relayout(false, 0, 0, false, false, 0);
SetVisible(ModSettings.ShowDossierCaption && SpectatorMode.Active);
}
public static void SetFromActor(Actor actor, string label = null)
{
InvalidateOwnedReasonCache();
@ -927,6 +952,73 @@ public static class WatchCaption
RefreshStorySpine(force: true);
}
/// <summary>
/// Capture stable typography before Saga hover takes temporary ownership of the
/// painted header. Switching between glyphs must not make the name pulse larger.
/// </summary>
public static void BeginSagaHoverSession()
{
_sagaHoverHeadline = "";
_sagaHoverFontCeiling = _nameText != null
? Mathf.Clamp(_nameText.fontSize, 7, 10)
: 10;
}
/// <summary>
/// Swap the hovered identity before building Cast/Legacy. This keeps pointer feedback
/// immediate even when the deeper saga model has several relationships to resolve.
/// </summary>
public static void PrimeSagaHoverHeader(long unitId)
{
if (!_visible || unitId == 0)
{
return;
}
LifeSagaSlot slot = LifeSagaRoster.Get(unitId);
Actor actor = EventFeedUtil.FindAliveById(unitId);
LifeSagaLifeMemory memory = LifeSagaMemory.Get(unitId);
string name = slot?.DisplayName ?? "";
if (string.IsNullOrEmpty(name))
{
name = EventFeedUtil.SafeName(actor);
}
var model = new LifeSagaViewModel
{
UnitId = unitId,
Name = (slot != null && (slot.Prefer || slot.GameFavorite) ? "★ " : "") + name,
Title = SagaProse.Title(slot, actor, memory),
SpeciesId = slot?.SpeciesId
?? (actor != null && actor.asset != null ? actor.asset.id : "")
};
ApplySagaHoverHeader(model, deferPortraitReveal: true);
}
/// <summary>
/// A same-subject camera event can change its orange reason without rebuilding the
/// dossier. Refresh only the owned reason/spine so UI and camera telemetry commit together.
/// </summary>
public static void RefreshOwnedReasonNow()
{
if (!_visible || _current == null)
{
return;
}
InvalidateOwnedReasonCache();
RefreshOwnedReason();
}
/// <summary>
/// Harness: exercise the periodic same-hover refresh path without marking geometry dirty.
/// Cast/Legacy and portrait must remain visible when Relayout correctly has no work.
/// </summary>
public static void HarnessRefreshSagaSteadyState()
{
RefreshStorySpine(force: true);
}
private static bool HasStatusBanner()
{
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
@ -1573,7 +1665,9 @@ public static class WatchCaption
LifeSagaPanel.Bind(model);
}
private static void ApplySagaHoverHeader(LifeSagaViewModel model)
private static void ApplySagaHoverHeader(
LifeSagaViewModel model,
bool deferPortraitReveal = false)
{
if (model == null || model.UnitId == 0)
{
@ -1597,11 +1691,12 @@ public static class WatchCaption
string headline = !string.IsNullOrEmpty(title)
? name + " · " + title
: (!string.IsNullOrEmpty(species) ? name + " (" + species + ")" : name);
_sagaHoverHeadline = headline.Length > 48
? CaptionComposer.TruncateIdentity(headline, 48)
: headline;
if (_nameText != null)
{
_nameText.text = headline.Length > 48
? CaptionComposer.TruncateIdentity(headline, 48)
: headline;
_nameText.text = _sagaHoverHeadline;
}
if (_speciesIcon != null)
@ -1615,12 +1710,15 @@ public static class WatchCaption
Sprite sexSprite = null;
if (actor != null && actor.isAlive())
{
UnitDossier hovered = UnitDossier.FromActor(actor);
if (hovered != null)
try
{
sexSprite = hovered.IsMale
sexSprite = actor.isSexMale()
? HudIcons.SexMale()
: (hovered.IsFemale ? HudIcons.SexFemale() : null);
: (actor.isSexFemale() ? HudIcons.SexFemale() : null);
}
catch
{
sexSprite = null;
}
}
@ -1630,6 +1728,13 @@ public static class WatchCaption
_sexIcon.gameObject.SetActive(sexSprite != null);
}
// The pointer-enter prime stages the new portrait while hidden. Periodic same-hover
// refreshes must preserve its current visibility because unchanged geometry correctly
// skips Relayout.
if (deferPortraitReveal)
{
DossierAvatar.SetHostVisible(false);
}
if (actor != null && actor.isAlive())
{
DossierAvatar.Show(actor);
@ -1638,7 +1743,6 @@ public static class WatchCaption
{
DossierAvatar.ShowSpecies(model.SpeciesId);
}
DossierAvatar.SetHostVisible(true);
RefreshFavoriteVisualFor(model.UnitId, actor, fallbackFavorite: false);
}
@ -3068,6 +3172,8 @@ public static class WatchCaption
bool hasReason,
int historyCount)
{
LastBodyAnchorY = 0f;
LastNarrativeAnchorY = 0f;
float headerW = MeasureHeaderWidth(hasTask);
// Traits beside avatar only when history and statuses leave the body-right slot free.
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
@ -3156,15 +3262,6 @@ public static class WatchCaption
if (!panelMode)
{
bool hasConnection = _connectionText != null
&& _connectionText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_connectionText.text);
if (hasConnection)
{
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
y += ConnectionH + Gap;
}
// Row visibility is owned by Place*/fill - never leave empty rows active at stale
// positions (SetDossierBodyVisible only hides on hover; it must not resurrect them).
if (historyCount <= 0 && _historyCol != null)
@ -3184,6 +3281,7 @@ public static class WatchCaption
if (hasBody)
{
LastBodyAnchorY = y;
float bodyH = historyCount > 0 ? MeasureHistoryColumnHeight(historyCount) : BodyH;
PlaceBody(y, historyCount, bodyH);
if (historyCount > 0)
@ -3214,30 +3312,50 @@ public static class WatchCaption
WidenPanelForChipRow(traitsUsed);
y += TraitsH + Gap;
}
}
if (hasReason)
{
float reasonH = MeasureReasonHeight();
PlaceLine(_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null, y, reasonH, PadX);
y += reasonH + Gap;
}
// Keep the portrait/body anchor invariant between Dossier and Saga hover.
// Connection, orange Beat, and Context explain the shot after the character
// details instead of inserting a variable-height gap above the portrait.
LastNarrativeAnchorY = y;
bool hasConnection = _connectionText != null
&& _connectionText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_connectionText.text);
if (hasConnection)
{
PlaceLine(_connectionText.GetComponent<RectTransform>(), y, ConnectionH, PadX);
y += ConnectionH + Gap;
}
bool hasStorySpine = _storySpineText != null
&& _storySpineText.gameObject.activeSelf
&& !string.IsNullOrEmpty(_storySpineText.text);
if (hasStorySpine)
{
PlaceLine(_storySpineText.GetComponent<RectTransform>(), y, StorySpineH, PadX);
y += StorySpineH + Gap;
if (hasReason)
{
float reasonH = MeasureReasonHeight();
PlaceLine(
_reasonText != null ? _reasonText.GetComponent<RectTransform>() : null,
y,
reasonH,
PadX);
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;
}
}
if (panelMode)
{
LifeSagaPanel.EnsureBuilt(_root.transform);
DossierAvatar.SetHostVisible(false);
LastBodyAnchorY = y;
LastNarrativeAnchorY = 0f;
DossierAvatar.Place(PadX, y, LiveMax);
DossierAvatar.SetHostVisible(true);
y = LifeSagaPanel.Place(y, PadX);
DossierAvatar.SetHostVisible(true);
if (railChrome)
{
_panelWidth = PanelWidthMax;
@ -3537,8 +3655,14 @@ public static class WatchCaption
maxW = Mathf.Max(NameMinW, maxW);
_nameText.resizeTextForBestFit = false;
_nameText.horizontalOverflow = HorizontalWrapMode.Overflow;
_nameText.fontSize = 10;
string full = !string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? "");
bool hover = LifeSagaViewController.IsHoverPreview;
int baseFontSize = hover && _sagaHoverFontCeiling > 0
? Mathf.Clamp(_sagaHoverFontCeiling, 7, 10)
: 10;
_nameText.fontSize = baseFontSize;
string full = hover && !string.IsNullOrEmpty(_sagaHoverHeadline)
? _sagaHoverHeadline
: (!string.IsNullOrEmpty(LastHeadline) ? LastHeadline : (_nameText.text ?? ""));
if (string.IsNullOrEmpty(full))
{
_nameText.text = "";
@ -3552,7 +3676,10 @@ public static class WatchCaption
return Mathf.Clamp(natural, NameMinW, maxW);
}
int fittedSize = Mathf.Clamp(Mathf.FloorToInt(10f * maxW / natural), 7, 10);
int fittedSize = Mathf.Clamp(
Mathf.FloorToInt(baseFontSize * maxW / natural),
7,
baseFontSize);
_nameText.fontSize = fittedSize;
_nameText.text = full;
_nameText.verticalOverflow = VerticalWrapMode.Truncate;

View file

@ -25,7 +25,13 @@ Routine camera shifts stay with MCs or their confirmed Cast/story context. When
followed, a muted Connection row explains the MC link. Background lives still accumulate story
potential off-camera and may earn a slot; only typed turning points, consequences, story payoffs,
or critical world/disaster events can give an unrelated background character exceptional screen
time.
time. “World critical” is not inferred from score alone: it requires a typed active quake/outbreak,
a crisis-scale disaster, or a materially contested large war.
The orange Beat answers “why this shot now?” and is event-only: ambient grounding never falls back
to species or job text. Exact named participants can carry a compact, typed MC-relative qualifier,
such as `Kills Omya (child of Waf)`, when that relationship explains the event's significance.
Civilized family Cast uses `Kin`; pack terminology is reserved for non-civilized creatures.
Confirmed MC events, threads, consequences, Legacy, and Prefer choices are stored in a versioned
per-world sidecar at the game save boundary. Runtime camera state and episode timing remain

View file

@ -2,7 +2,29 @@
## Status
Core implementation complete; extended organic validation pending.
Core implementation complete; post-soak camera/terminology fixes implemented. Another organic
validation window is pending.
### Post-soak follow-up (implemented 2026-07-24)
- World-critical camera access is typed: active quake/outbreak, a disaster at the configured crisis
threshold, or a materially contested large war. Raw `EventStrength >= 95` no longer promotes
grief, ordinary duels, or other unrelated high-score events.
- A sub-threshold disaster spectacle such as Garden Surprise does not take an unrelated character
shot. Confirmed named disaster impact remains significant through the personal-impact lane.
- Scene cleanup and character fill revalidate MC membership immediately before switching. With a
living roster MC available, quiet fallback cannot linger on or select an arbitrary background
character.
- Location events with a rejected/dead follow clear the old dossier/focus and pan to the event, so
Meteorite and similar shots cannot retain a sleeping character's identity.
- Same-character sticky updates refresh the owned orange reason without rebuilding the dossier.
- Civilized families use `Family` / `Kin`; `Pack` / `Packmate` / `Pack Alpha` are reserved for
non-civilized creatures. WorldBox's shared Family/alpha storage is never exposed as human pack
terminology.
- Saga hover primes the new identity immediately, reads sex directly from the hovered actor, keeps
the entry font ceiling, and reveals the staged portrait only after its fixed layout is placed;
steady-state refresh never hides already-placed hover chrome.
It no longer invokes the full dossier/species-population scan before the visible name swap.
## Product outcome
@ -166,6 +188,13 @@ Recognition should come from repeated, stable signals rather than extra prose on
- Keep rail position stable for an incumbent whenever possible.
- Keep each unit's latest unique portrait cached for the duration of roster membership and through a
death/Legacy presentation.
- Treat species icons as temporary display fallbacks, never cached portraits; retry one missing
inactive live frame per second so eggs/render-not-ready slots eventually acquire their own look.
- For creature glyphs, capture the palette-resolved generated current animation frame, falling
back to the raw frame only when generation fails, and reject frames that resolve to the shared
species-icon atlas region. Never accept the creature inspect composite as a live frame because
WorldBox can copy the standing species portrait into a new texture. Preserve genuine tiny
animation frames (down to the 3x2 fly/beetle family).
- On a meaningful return after the existing reentry threshold, allow one concise reintroduction:
`Name again · <new development>`.
- Do not use reintroduction wording for rapid A → B → A cuts, unchanged activity, or texture.
@ -178,7 +207,8 @@ Recognition should come from repeated, stable signals rather than extra prose on
When the camera follows a non-MC because of an MC connection, the viewer must see that connection
immediately.
- Add one muted, single-line **Connection** row directly below Identity and above the orange Beat:
- Add one muted, single-line **Connection** row at the start of the bottom narrative group,
directly above the orange Beat:
```text
Identity Omya (Human)
@ -189,6 +219,13 @@ immediately.
- Connection is presentation metadata. It does not become part of the event sentence or replace
story Context.
- The orange Beat is strictly event-owned. Character grounding has no Beat; species, job, traits,
city, and other identity fallbacks never occupy this row.
- When an event's exact named participant has a typed relation to a current MC and that relation is
not already stated, Beat may add one compact qualifier: `Kills Omya (child of Waf)`. This explains
significance without turning the focused character's Connection into duplicate prose.
- Render Identity → portrait/history → status/trait details → Connection → Beat → Context.
Bottom-grouping the narrative rows keeps the portrait anchored when Saga hover hides them.
- Prefer the clearest confirmed relation: `Waf's partner`, `Waf's daughter`, `Waf's father`,
`Waf's best friend`, `Waf's old foe`, `Fights beside Waf`, `Opposes Waf in the war`, or
`Caught in Waf's city`.
@ -301,7 +338,7 @@ refill budget.
6. Bind and relayout the rail only when its ordered unit-id sequence or visible state actually
changes. Refresh a live portrait at the existing bounded cadence; do not refresh five portraits
every frame.
7. Add the one-line Connection slot to the camera dossier between Identity and Beat.
7. Add the one-line Connection slot above Beat in the bottom narrative group.
8. Measure maximum authored Connection strings at supported UI scales and keep them to one
ellipsized line.
9. Include Connection identity in the dossier fingerprint so focus changes update atomically
@ -503,6 +540,8 @@ Add or update these scenarios:
- `saga_five_refill_bounded` — no-op, challenger, and pin-overflow refills remain bounded and do not
start an extra world scan.
- `saga_five_ui_noop_perf` — stable rail frames do not relayout or recapture unchanged portraits.
- `saga_rail_species_frame_sweep` — amortized exhaustive actor-library sweep proves every standard
renderer resolves to a live frame; separate special-renderer assets are reported explicitly.
- `saga_five_perf_health` — hitch probe plus scheduler/roster metrics stay inside the release
thresholds during a directed developed-world sample.

View file

@ -79,10 +79,14 @@ Always-on AFK chrome (no Dossier/Saga tabs):
|-----|--------|----|--------|
| Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` |
| Connection | `CameraEligibility` | hidden | MC anchor/relation when this non-MC shot was admitted through MC Cast/thread context |
| Beat | candidate `NarrativePresentationModel` | one complete semantic sentence | one complete semantic sentence |
| Beat | candidate `NarrativePresentationModel` + `BeatSignificance` | one complete event sentence; an exact participant may carry an MC-relative qualifier | one complete semantic sentence |
| Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only |
Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`.
Character-led grounding never owns Beat: species, job, traits, city, and ambient activity remain
Identity/task metadata. An actual event may explain the relevance of its exact named participant,
for example `Kills Omya (child of Waf)`, when the relation to a current MC is backed by live or
durable typed evidence. Broad participants, proximity, and name parsing never create this qualifier.
The story scheduler owns selection; `identity_filter` remains a safety gate against presenting
identity text as a development.
Beat participants come from the exact presentation/event receipt (no latest-similar lookup and no
@ -92,12 +96,29 @@ Outside hover, caption Identity/Beat/Context track camera focus. During Saga hov
primary nametag temporarily binds to the hovered MC (species, name/title, sex, favorite),
while camera Beat/Context are hidden to prevent false attribution. Camera composition keeps
running underneath and restores immediately when hover ends.
Outside hover, the visible order is Identity, portrait/history, status/trait details, then
Connection, Beat, and Context. Narrative explanation remains grouped at the bottom while the
portrait keeps the same body anchor used by Saga hover.
## Rail UX
- Up to 10 slots with unit-keyed inspect-UI portraits. Each character is captured on first
- Up to 5 slots with unit-keyed inspect-UI portraits. Each character is captured on first
display, refreshed while watched, and retains that latest unique frame after focus leaves.
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last).
- Egg forms and tiny/blank atlas crops temporarily fall back to the species icon (letter last).
Fallbacks are never stored as live snapshots; one uncaptured inactive slot retries per second,
round-robin, until a valid live frame can be retained through focus changes and death.
- Non-civilized creatures prefer the palette-resolved generated frame from their current
animation (`calculateColoredSprite(calculateMainSprite())`) over the raw atlas frame. This avoids
palette-key colors while still bypassing shared species icons. Their inspect-UI composite is
never accepted because WorldBox may synthesize a new texture containing the standing species
portrait, which cannot be caught by atlas-region identity. Any candidate pointing at the species
icon's atlas region is also rejected as a live capture.
- Live-frame usability is based on non-degenerate area, not an 8x8 square floor: short horizontal
animations such as fox movement frames and genuine 3x2 fly/beetle frames remain valid.
`saga_rail_species_frame_sweep` checks every registered asset one per frame, temporarily spawning
and immediately cleaning up missing representatives, and records the exhaustive results in
`saga-rail-species-frames.tsv`. Assets that only expose a separate special renderer rather than
an actor sprite (currently Crabzilla) are recorded as not applicable and keep the species icon.
- Active chrome = camera follow MC; Involved chrome = other tip-touched MCs (subject/related/follow/pair/short-arc cast only - not mass `ParticipantIds`).
- Prefer uses a star pip / distinct border.
- Click toggles saga Prefer only (does not mutate WorldBox unit favorite; does not pin a Saga subject).
@ -105,6 +126,10 @@ running underneath and restores immediately when hover ends.
- Hovering a glyph opens Cast/Legacy depth (`LifeSagaPanel`) and pauses camera switching.
- Leaving the glyph hides the panel, restores dossier body rows, and releases the pause lease.
- Hover temporarily swaps the primary nametag to the hovered life; it does not change camera focus.
- The hovered name, font, portrait, and Cast/Legacy body commit as one visual transition. The name
keeps the dossier's entry font ceiling for the full hover session, and pointer-enter stages the
portrait hidden until its fixed position is assigned. Steady-state refresh preserves visibility
when unchanged geometry correctly skips relayout.
- The orange camera Beat and Context are hidden during hover rather than attributed to the hovered life.
- Hover restores the hovered character's live stone-frame dossier portrait beside the panel
(species fallback only when no living actor is available).
@ -122,7 +147,8 @@ running underneath and restores immediately when hover ends.
`SagaProse` is the saga Identity/Cast/stake voice owner. `NarrativeRenderer` separately owns the
complete orange Beat. Evidence drives both; enum names never appear in UI.
Relation classes: Bond, Friend, Kin (Parent/Child/Heir/Packmate/Kin), Foe (Old Foe/War Enemy/Plot Enemy/Blood Foe), Plot Partner, Grief.
Relation classes: Bond, Friend, Kin (Parent/Child/Heir/Packmate/Kin), Foe (Old Foe/War Enemy/Plot Enemy/Blood Foe), Plot Partner, Grief. `Packmate` and `Pack Alpha` are creature-only language;
civilized actors use `Kin` and family wording even though WorldBox stores both through `Family`.
Ban list (player-facing): `earned rival`, `Earned a rival`, `Rivalry with`, `saga hero`, `fellow saga hero`, `War foe`, `Plot ally`, `Likely successor`, `A life in motion` on Identity, mood wallpaper stakes.
@ -156,7 +182,9 @@ Notable only:
- Cast shows living credible foes only; weak kill-cycle `repeated_encounters` facts are pruned.
- Heir cast label only when a game-authored heir API probe succeeds (`Heir`, not `Likely successor`).
- Unresolved `WarJoin` / `PlotJoin` Others fill Cast as `War Enemy` / `Plot Partner` when still living.
- Crown / Combat / Intrigue / Pack / Bond lenses fill remaining Cast slots with live family peers (`Packmate` for Pack, `Kin` otherwise) after lover/friend/parents/children/heir/foe.
- Crown / Combat / Intrigue / Pack / Bond lenses fill remaining Cast slots with live family peers
after lover/friend/parents/children/heir/foe. Non-civilized Pack lenses use `Packmate`;
civilized family peers always use `Kin`.
## Stake
@ -187,10 +215,18 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
- World admission walks ~260 units/frame, then BuildSlots only the best ~2× Cap challengers (skips when pins fill Cap).
- Dossier `Relayout` runs only when chrome geometry changes; leaving hover restores dossier rows once.
- Live portrait tile refresh is ~5 Hz; rail live sprites are only for the watched glyph (others use species icons).
- Hover identity is primed before Cast/Legacy binding and uses direct actor sex metadata; it does
not call the population-counting dossier builder.
- Hover fitting reads its own display headline rather than the camera dossier's cached identity;
repeated relayouts therefore cannot briefly restore the wrong name or enlarge the font.
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
- Missing live rail frames retry at most one inactive slot per ~1s rail tick; successful captures
replace the temporary species icon and unsuccessful attempts never discard an older live frame.
- Plot join memory records the joiner only; full member snapshots stay on create/finish/cancel.
- RelationEvidence caches invalidate on memory revision + live lover/friend change.
- Hitch probe: harness `hitch_probe` / load save via `load_save_slot` (slot 2 = Box of Magic). Optional `.harness/autoload_slot`.
- Hitch probe: run harness scenario `narrative_scheduler_health` (it enables the
`hitch_probe` action) / load save via `load_save_slot` (slot 2 = Box of Magic).
Optional `.harness/autoload_slot`.
## Session