Compare commits
3 commits
188d091166
...
529d18dfa4
| Author | SHA1 | Date | |
|---|---|---|---|
| 529d18dfa4 | |||
| 96db725383 | |||
| 12e2ba0d01 |
27 changed files with 2196 additions and 184 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
393
IdleSpectator/Narrative/BeatSignificance.cs
Normal file
393
IdleSpectator/Narrative/BeatSignificance.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
IdleSpectator/Narrative/WorldCriticalPolicy.cs
Normal file
68
IdleSpectator/Narrative/WorldCriticalPolicy.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
509
IdleSpectator/RailFaceCatalogAudit.cs
Normal file
509
IdleSpectator/RailFaceCatalogAudit.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue