feat(saga): replace tabs with character beat captions
- Compose Identity, Beat, and Context without mutating camera labels - Move Cast and Legacy details into a hover-only panel - Centralize relation, title, stake, and legacy prose - Make rail clicks toggle Prefer without pinning a Saga subject - Add harness coverage for caption composition and hover behavior
This commit is contained in:
parent
2592e947aa
commit
58a67a198d
17 changed files with 2827 additions and 1144 deletions
|
|
@ -3317,13 +3317,26 @@ public static class AgentHarness
|
|||
case "saga_show_hover_first":
|
||||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
// Soft-admit focus if the roster was pruned mid-scenario (unit death /
|
||||
// bind churn) so hover depth stays testable on the watched life.
|
||||
if (LifeSagaRoster.Count == 0)
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
if (focus != null && focus.isAlive())
|
||||
{
|
||||
LifeSagaRoster.HarnessForceAdmit(focus);
|
||||
}
|
||||
}
|
||||
|
||||
LifeSagaRail.Refresh();
|
||||
LifeSagaRail.ShowHover(0);
|
||||
WatchCaption.RequestRelayout();
|
||||
LifeSagaViewModel model = LifeSagaViewController.BuildEffectivePresentation();
|
||||
// Height may be 0 when Cast+Legacy are empty; hover is still valid.
|
||||
bool ok = LifeSagaViewController.IsHoverPreview
|
||||
&& model != null
|
||||
&& model.UnitId != 0
|
||||
&& LifeSagaRail.LastHoverHeight > 0f;
|
||||
&& LifeSagaViewController.HoverPreviewId != 0;
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
|
|
@ -3337,7 +3350,7 @@ public static class AgentHarness
|
|||
cmd,
|
||||
ok,
|
||||
detail:
|
||||
$"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive}");
|
||||
$"hoverId={LifeSagaViewController.HoverPreviewId} rows={LifeSagaRail.LastHoverRowCount} height={LifeSagaRail.LastHoverHeight:0.0} pause={InterestDirector.ReadPauseActive} rail={LifeSagaRail.LastShownCount} roster={LifeSagaRoster.Count}");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -3365,11 +3378,22 @@ public static class AgentHarness
|
|||
case "saga_click_first":
|
||||
{
|
||||
LifeSagaRail.Refresh();
|
||||
long before = LifeSagaViewController.PersistentSagaId;
|
||||
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
bool preferBefore = focusId != 0 && LifeSagaRoster.IsPrefer(focusId);
|
||||
LifeSagaRail.HarnessClick(0);
|
||||
WatchCaption.RequestRelayout();
|
||||
long after = LifeSagaViewController.EffectiveSagaId;
|
||||
bool ok = after != 0;
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
|
|
@ -3379,7 +3403,7 @@ public static class AgentHarness
|
|||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"before={before} after={after} prefer={LifeSagaRail.LastPreferPipVisible}");
|
||||
Emit(cmd, ok, detail: $"preferBefore={preferBefore} preferAfter={preferAfter} hover={LifeSagaViewController.IsHoverPreview}");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -4427,19 +4451,27 @@ public static class AgentHarness
|
|||
return;
|
||||
}
|
||||
|
||||
WorldTile tile = TileNearCamera();
|
||||
// Prefer spawning next to an existing living unit so terrain is valid.
|
||||
Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f);
|
||||
if (anchor != null)
|
||||
WorldTile tile = null;
|
||||
try
|
||||
{
|
||||
WorldTile anchorTile = World.world.GetTile(
|
||||
Mathf.RoundToInt(anchor.current_position.x),
|
||||
Mathf.RoundToInt(anchor.current_position.y));
|
||||
if (anchorTile != null)
|
||||
tile = TileNearCamera();
|
||||
// Prefer spawning next to an existing living unit so terrain is valid.
|
||||
Actor anchor = WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 5000f);
|
||||
if (anchor != null)
|
||||
{
|
||||
tile = anchorTile;
|
||||
WorldTile anchorTile = World.world.GetTile(
|
||||
Mathf.RoundToInt(anchor.current_position.x),
|
||||
Mathf.RoundToInt(anchor.current_position.y));
|
||||
if (anchorTile != null)
|
||||
{
|
||||
tile = anchorTile;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
tile = null;
|
||||
}
|
||||
|
||||
if (tile == null)
|
||||
{
|
||||
|
|
@ -4456,31 +4488,51 @@ public static class AgentHarness
|
|||
int count = Math.Max(1, cmd.count);
|
||||
int spawned = 0;
|
||||
Actor last = null;
|
||||
Exception lastEx = null;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
|
||||
if (actor != null && actor.isAlive())
|
||||
try
|
||||
{
|
||||
spawned++;
|
||||
last = actor;
|
||||
Actor actor = World.world.units.spawnNewUnit(assetId, tile, pSpawnSound: false, pMiracleSpawn: true);
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
spawned++;
|
||||
last = actor;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastEx = ex;
|
||||
}
|
||||
}
|
||||
|
||||
if (spawned <= 0 || last == null || !last.isAlive())
|
||||
{
|
||||
if (RetryFront(cmd, "spawn_zero"))
|
||||
if (RetryFront(cmd, lastEx != null ? "spawn_ex:" + lastEx.GetType().Name : "spawn_zero"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: $"spawned=0/{count} asset={assetId}");
|
||||
Emit(
|
||||
cmd,
|
||||
ok: false,
|
||||
detail: lastEx != null
|
||||
? $"spawn_exception asset={assetId} msg={lastEx.Message}"
|
||||
: $"spawned=0/{count} asset={assetId}");
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSpawned = last;
|
||||
_lastSpawnedAssetId = last.asset != null ? last.asset.id : assetId;
|
||||
CameraDirector.FocusUnit(last);
|
||||
try
|
||||
{
|
||||
CameraDirector.FocusUnit(last);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Spawn succeeded; focus is best-effort.
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"spawned={spawned}/{count} asset={_lastSpawnedAssetId} last={SafeName(last)}");
|
||||
|
|
@ -9205,6 +9257,163 @@ public static class AgentHarness
|
|||
$"size={size} body={bodyW:0.#}x{bodyH:0.#} fixedW={LifeSagaPanel.BodyWidthFixed} maxH={LifeSagaPanel.BodyHeightMax} onSaga={onSaga} compact={compact} dbg={LifeSagaPanel.LastLayoutDebug}";
|
||||
break;
|
||||
}
|
||||
case "caption_mc_identity":
|
||||
{
|
||||
string idLine = WatchCaption.LastHeadline ?? "";
|
||||
pass = idLine.IndexOf(" · ", StringComparison.Ordinal) >= 0
|
||||
&& (idLine.IndexOf("King", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| idLine.IndexOf("Leader", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| idLine.IndexOf("Alpha", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| idLine.IndexOf("Chief", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| idLine.IndexOf("Captain", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| idLine.IndexOf("Founder", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| idLine.IndexOf("Plotter", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
detail = $"identity='{idLine}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_mc_identity_or_species":
|
||||
{
|
||||
string idLine = WatchCaption.LastHeadline ?? "";
|
||||
pass = !string.IsNullOrEmpty(idLine)
|
||||
&& idLine.IndexOf("A life in motion", StringComparison.OrdinalIgnoreCase) < 0;
|
||||
detail = $"identity='{idLine}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_nobody_species":
|
||||
{
|
||||
string idLine = WatchCaption.LastHeadline ?? "";
|
||||
bool hasTitleSep = idLine.IndexOf(" · ", StringComparison.Ordinal) >= 0
|
||||
&& (idLine.IndexOf("King", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| idLine.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
pass = !string.IsNullOrEmpty(idLine) && !hasTitleSep;
|
||||
detail = $"identity='{idLine}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_identity_not_in_label":
|
||||
{
|
||||
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
||||
string label = cur != null ? (cur.Label ?? "") : "";
|
||||
pass = label.IndexOf(" · King", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& label.IndexOf("Pack Alpha", StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& !label.StartsWith("King of", StringComparison.OrdinalIgnoreCase);
|
||||
detail = $"label='{label}' identity='{WatchCaption.LastHeadline}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_prose_no_jargon":
|
||||
{
|
||||
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
string tip = LifeSagaPresentation.BuildPlainText(id) ?? "";
|
||||
string beat = WatchCaption.LastBeatLine ?? "";
|
||||
string ctx = WatchCaption.LastContextLine ?? "";
|
||||
string all = tip + "\n" + beat + "\n" + ctx + "\n" + (WatchCaption.LastHeadline ?? "");
|
||||
pass = !SagaProse.ContainsBannedPhrase(all);
|
||||
string sample = all.Replace('\n', ' ');
|
||||
if (sample.Length > 120)
|
||||
{
|
||||
sample = sample.Substring(0, 120);
|
||||
}
|
||||
|
||||
detail = $"banned={!pass} sample='{sample}'";
|
||||
break;
|
||||
}
|
||||
case "caption_prose_casing_cast":
|
||||
{
|
||||
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
LifeSagaViewModel model = LifeSagaPresentation.Build(id);
|
||||
string any = (cmd.value ?? "Lover").Trim();
|
||||
pass = false;
|
||||
if (model != null)
|
||||
{
|
||||
string[] parts = any.Split('|');
|
||||
for (int i = 0; i < model.Cast.Count && !pass; i++)
|
||||
{
|
||||
string rel = model.Cast[i]?.Relation ?? "";
|
||||
for (int p = 0; p < parts.Length; p++)
|
||||
{
|
||||
string want = (parts[p] ?? "").Trim();
|
||||
if (want.Length > 0 && string.Equals(rel, want, StringComparison.Ordinal))
|
||||
{
|
||||
pass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"wantExact='{any}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_context_stake":
|
||||
{
|
||||
string ctx = WatchCaption.LastContextLine ?? "";
|
||||
pass = !string.IsNullOrEmpty(ctx)
|
||||
&& (ctx.IndexOf("war", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| ctx.IndexOf("Still", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| ctx.IndexOf("Feud", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| ctx.IndexOf("tangled", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| ctx.IndexOf("Bound", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| ctx.IndexOf("Rules", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
bool looksLikeSpine = ctx.IndexOf(" · ", StringComparison.Ordinal) >= 0
|
||||
&& (ctx.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
|| ctx.StartsWith("War front", StringComparison.OrdinalIgnoreCase)
|
||||
|| ctx.StartsWith("Love", StringComparison.OrdinalIgnoreCase)
|
||||
|| ctx.StartsWith("Grief", StringComparison.OrdinalIgnoreCase)
|
||||
|| ctx.StartsWith("Plot", StringComparison.OrdinalIgnoreCase)
|
||||
|| ctx.StartsWith("Mass", StringComparison.OrdinalIgnoreCase)
|
||||
|| ctx.StartsWith("Battle", StringComparison.OrdinalIgnoreCase));
|
||||
if (looksLikeSpine && ctx.IndexOf("Still", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
pass = false;
|
||||
}
|
||||
|
||||
detail = $"context='{ctx}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_context_omits":
|
||||
{
|
||||
// Soft tip hitch: Context must not show banned biography fragments.
|
||||
string ctx = WatchCaption.LastContextLine ?? "";
|
||||
string banned = (cmd.value ?? "Slew|Became|Founded").Trim();
|
||||
pass = true;
|
||||
string[] parts = banned.Split('|');
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string want = (parts[i] ?? "").Trim();
|
||||
if (want.Length > 0
|
||||
&& ctx.IndexOf(want, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
pass = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"context='{ctx}' banned='{banned}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "saga_panel_cast_first":
|
||||
{
|
||||
bool panel = LifeSagaPanel.Visible && LifeSagaPanel.BoundUnitId != 0;
|
||||
string dbg = LifeSagaPanel.LastLayoutDebug ?? "";
|
||||
bool castFirst = dbg.IndexOf("castFirst=1", StringComparison.Ordinal) >= 0;
|
||||
bool noAvatar = !LifeSagaPanel.OwnsAvatar;
|
||||
pass = panel && castFirst && noAvatar;
|
||||
detail =
|
||||
$"panel={panel} ownsAvatar={LifeSagaPanel.OwnsAvatar} dbg='{dbg}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "caption_hover_keeps_beat":
|
||||
{
|
||||
bool hover = LifeSagaViewController.IsHoverPreview;
|
||||
bool panel = LifeSagaPanel.Visible && LifeSagaPanel.BoundUnitId != 0;
|
||||
long focusId = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
string identity = WatchCaption.LastHeadline ?? "";
|
||||
bool identityOk = !string.IsNullOrEmpty(identity);
|
||||
pass = hover && panel && identityOk && focusId != 0
|
||||
&& LifeSagaPanel.BoundUnitId == LifeSagaViewController.HoverPreviewId;
|
||||
detail =
|
||||
$"hover={hover} panelBound={LifeSagaPanel.BoundUnitId} hoverId={LifeSagaViewController.HoverPreviewId} identity='{identity}' beat='{WatchCaption.LastBeatLine}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "saga_memory_has_focus":
|
||||
{
|
||||
long id = EventFeedUtil.SafeId(MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
|
|
|
|||
|
|
@ -218,6 +218,11 @@ internal static class HarnessScenarios
|
|||
return SagaRailPreferClick();
|
||||
case "saga_soft_fill_no_pack":
|
||||
return SagaSoftFillNoPack();
|
||||
case "caption_character_beat":
|
||||
case "caption_mc_identity":
|
||||
return CaptionCharacterBeat();
|
||||
case "caption_prose_voice":
|
||||
return CaptionProseVoice();
|
||||
case "story_spine_scoped":
|
||||
return StorySpineScoped();
|
||||
case "story_family_variety":
|
||||
|
|
@ -439,6 +444,8 @@ internal static class HarnessScenarios
|
|||
Nested("reg_saga_rail_active", "saga_rail_active_highlight"),
|
||||
Nested("reg_saga_rail_prefer", "saga_rail_prefer_click"),
|
||||
Nested("reg_saga_soft_fill", "saga_soft_fill_no_pack"),
|
||||
Nested("reg_caption_beat", "caption_character_beat"),
|
||||
Nested("reg_caption_prose", "caption_prose_voice"),
|
||||
Nested("reg_discovery", "discovery"),
|
||||
Nested("reg_worldlog", "world_log"),
|
||||
Nested("reg_presentability", "camera_presentability"),
|
||||
|
|
@ -3571,7 +3578,7 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>Dossier/Saga tabs, hover preview restore, Prefer-only click, fixed body.</summary>
|
||||
/// <summary>Hover depth panel under always-on character beat caption (no Dossier/Saga tabs).</summary>
|
||||
private static List<HarnessCommand> SagaAdaptiveDossier()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
@ -3588,65 +3595,51 @@ internal static class HarnessScenarios
|
|||
Step("sad9", "focus", asset: "human"),
|
||||
Step("sad10", "saga_force_admit_focus"),
|
||||
Step("sad11", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||
// saga_select_tab saga → hover preview of watched MC (compat).
|
||||
Step("sad12", "saga_select_tab", value: "saga"),
|
||||
Step("sad13", "assert", expect: "saga_tab_effective", value: "saga"),
|
||||
Step("sad14", "assert", expect: "saga_panel_bound"),
|
||||
Step("sad14b", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad15", "screenshot", value: "saga-tab-selected"),
|
||||
Step("sad14c", "assert", expect: "caption_hover_keeps_beat"),
|
||||
Step("sad14d", "assert", expect: "saga_panel_cast_first"),
|
||||
Step("sad15", "screenshot", value: "saga-hover-depth"),
|
||||
Step("sad15b", "saga_stamp_chapter_focus", asset: "Love", value: "Bound with a lasting partner"),
|
||||
Step("sad15c", "saga_memory_record_focus", asset: "Founding", value: "kingdom"),
|
||||
Step("sad15d", "saga_select_tab", value: "saga"),
|
||||
Step("sad15e", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad15f", "screenshot", value: "saga-tab-rich"),
|
||||
Step("sad15e2", "assert", expect: "saga_panel_lens", value: "true"),
|
||||
Step("sad15e3", "assert", expect: "saga_overview_tip_not",
|
||||
value: "Evidence|kills ·|remembered clashes|Open Lore|Earned rival|saga hero"),
|
||||
Step("sad15f", "screenshot", value: "saga-hover-rich"),
|
||||
Step("sad16", "saga_select_tab", value: "dossier"),
|
||||
Step("sad16b", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad16c", "screenshot", value: "saga-dossier-paired"),
|
||||
Step("sad16c", "screenshot", value: "saga-caption-idle"),
|
||||
Step("sad17", "saga_show_hover_first"),
|
||||
Step("sad17b", "assert", expect: "saga_panel_fixed_size"),
|
||||
Step("sad18", "assert", expect: "saga_tab_selected", value: "dossier"),
|
||||
Step("sad19", "assert", expect: "saga_tab_effective", value: "saga"),
|
||||
Step("sad20", "assert", expect: "saga_hover_preview", value: "true"),
|
||||
Step("sad21", "assert", expect: "saga_read_pause", value: "true"),
|
||||
// Leaving the glyph restores Dossier immediately (no panel-dwell keep-alive).
|
||||
Step("sad21b", "saga_hide_hover"),
|
||||
Step("sad21d", "saga_hover_tick_away"),
|
||||
Step("sad21h", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
Step("sad21i", "assert", expect: "saga_tab_effective", value: "dossier"),
|
||||
Step("sad21j", "assert", expect: "saga_read_pause", value: "false"),
|
||||
// Dossier chrome must restore immediately (not wait for a tip ownership change).
|
||||
Step("sad21j2", "assert", expect: "saga_dossier_chrome_restored"),
|
||||
Step("sad21k", "saga_show_hover_first"),
|
||||
Step("sad21l", "assert", expect: "saga_hover_preview", value: "true"),
|
||||
// Tab click during preview cancels preview and applies the selected tab.
|
||||
Step("sad21m", "saga_select_tab", value: "dossier"),
|
||||
Step("sad21n", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
Step("sad21o", "assert", expect: "saga_tab_effective", value: "dossier"),
|
||||
Step("sad22", "saga_show_hover_first"),
|
||||
Step("sad22b", "saga_hide_hover"),
|
||||
Step("sad23b", "saga_hover_tick_away"),
|
||||
Step("sad24", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
Step("sad25", "assert", expect: "saga_tab_effective", value: "dossier"),
|
||||
Step("sad26", "assert", expect: "saga_read_pause", value: "false"),
|
||||
Step("sad27", "assert", expect: "saga_overview_tip_not", value: "Fighting |Why |Now |Notable "),
|
||||
Step("sad28", "saga_select_tab", value: "saga"),
|
||||
Step("sad29", "assert", expect: "saga_panel_lens", value: "true"),
|
||||
Step("sad29b", "assert", expect: "saga_overview_tip_not",
|
||||
value: "Evidence|kills ·|remembered clashes|Open Lore"),
|
||||
// Non-MC watch with no pin → empty pick state; rail click pins subject.
|
||||
Step("sad30", "interest_saga_clear"),
|
||||
Step("sad31", "spawn", asset: "human", count: 2),
|
||||
Step("sad32", "pick_unit", asset: "human"),
|
||||
Step("sad33", "focus", asset: "human"),
|
||||
Step("sad34", "saga_force_admit_focus"),
|
||||
Step("sad35", "spawn", asset: "sheep", count: 1),
|
||||
Step("sad36", "pick_unit", asset: "sheep"),
|
||||
Step("sad37", "focus", asset: "sheep"),
|
||||
Step("sad38", "saga_select_tab", value: "saga"),
|
||||
Step("sad39", "assert", expect: "saga_panel_empty", value: "true"),
|
||||
Step("sad40", "saga_click_first"),
|
||||
Step("sad41", "assert", expect: "saga_panel_empty", value: "false"),
|
||||
Step("sad42", "assert", expect: "saga_panel_bound"),
|
||||
Step("sad43", "screenshot", value: "saga-rail-top"),
|
||||
// Prefer click does not open hover / pin saga subject.
|
||||
// Re-seed a human - long hover cycles can leave the map without living units.
|
||||
Step("sad30", "saga_select_tab", value: "dossier"),
|
||||
Step("sad30b", "spawn", asset: "human", count: 2),
|
||||
Step("sad30c", "pick_unit", asset: "human"),
|
||||
Step("sad30d", "focus", asset: "human"),
|
||||
Step("sad30e", "interest_saga_clear"),
|
||||
Step("sad30f", "saga_force_admit_focus"),
|
||||
Step("sad30g", "saga_rail_refresh"),
|
||||
Step("sad31", "saga_click_first"),
|
||||
Step("sad32", "assert", expect: "saga_prefer_focus_on", value: "true"),
|
||||
Step("sad33", "assert", expect: "saga_hover_preview", value: "false"),
|
||||
Step("sad34", "assert", expect: "caption_mc_identity_or_species"),
|
||||
Step("sad43", "screenshot", value: "saga-rail-prefer"),
|
||||
Step("sad90", "fast_timing", value: "false"),
|
||||
Step("sad99", "snapshot"),
|
||||
};
|
||||
|
|
@ -3707,7 +3700,7 @@ internal static class HarnessScenarios
|
|||
expect: "harness_war", label: "North"),
|
||||
Step("swm15", "assert", expect: "saga_memory_fact_focus", value: "WarJoin"),
|
||||
Step("swm16", "assert", expect: "saga_memory_fact_other", value: "WarJoin"),
|
||||
Step("swm17", "assert", expect: "saga_stake_contains", value: "War with|North vs South"),
|
||||
Step("swm17", "assert", expect: "saga_stake_contains", value: "Still at war|North vs South|War with"),
|
||||
Step("swm18", "saga_memory_resolve_war", value: "harness_war"),
|
||||
Step("swm19", "assert", expect: "saga_memory_fact_resolved", value: "harness_war", label: "true"),
|
||||
// Original partner from swm9 is the other principal - focus them directly.
|
||||
|
|
@ -3740,8 +3733,8 @@ internal static class HarnessScenarios
|
|||
Step("spc13", "saga_memory_record_focus", asset: "Plot", value: "coup", expect: "harness_plot"),
|
||||
Step("spc14", "assert", expect: "saga_memory_fact_other", value: "PlotJoin"),
|
||||
Step("spc15", "saga_select_tab", value: "saga"),
|
||||
Step("spc16", "assert", expect: "saga_cast_contains", value: "Plot ally"),
|
||||
Step("spc17", "assert", expect: "saga_stake_contains", value: "Plot with|Joined"),
|
||||
Step("spc16", "assert", expect: "saga_cast_contains", value: "Plot Partner"),
|
||||
Step("spc17", "assert", expect: "saga_stake_contains", value: "Still tangled|Plotting|Joined|Plotted"),
|
||||
Step("spc90", "fast_timing", value: "false"),
|
||||
Step("spc99", "snapshot"),
|
||||
};
|
||||
|
|
@ -3773,7 +3766,7 @@ internal static class HarnessScenarios
|
|||
Step("sck18", "assert", expect: "saga_cast_contains", value: "Parent"),
|
||||
Step("sck19", "saga_memory_record_focus", asset: "War", value: "North vs South",
|
||||
expect: "harness_war_crown", label: "North"),
|
||||
Step("sck20", "assert", expect: "saga_cast_contains", value: "War foe|Parent"),
|
||||
Step("sck20", "assert", expect: "saga_cast_contains", value: "War Enemy|Parent"),
|
||||
Step("sck90", "fast_timing", value: "false"),
|
||||
Step("sck99", "snapshot"),
|
||||
};
|
||||
|
|
@ -3839,7 +3832,7 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>Live roster role rise becomes RoleChange stake; combat is fallback when alone.</summary>
|
||||
/// <summary>Live roster role rise updates Identity without duplicating its prior combat stake.</summary>
|
||||
private static List<HarnessCommand> SagaStakeFromRoleLive()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
@ -3855,11 +3848,11 @@ internal static class HarnessScenarios
|
|||
Step("srl8", "pick_unit", asset: "human"),
|
||||
Step("srl9", "focus", asset: "human"),
|
||||
Step("srl10", "saga_force_admit_focus"),
|
||||
Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "A fight marked them"),
|
||||
Step("srl12", "assert", expect: "saga_stake_contains", value: "fight marked|A fight"),
|
||||
Step("srl11", "saga_memory_record_focus", asset: "HardArcCombat", value: "Survived a hard fight"),
|
||||
Step("srl12", "assert", expect: "saga_stake_contains", value: "Survived a hard fight|hard fight"),
|
||||
Step("srl13", "saga_role_rise_focus", value: "become_clan_chief"),
|
||||
Step("srl14", "assert", expect: "saga_memory_fact_focus", value: "RoleChange"),
|
||||
Step("srl15", "assert", expect: "saga_stake_contains", value: "Became clan chief"),
|
||||
Step("srl15", "assert", expect: "saga_stake_contains", value: "Clan Chief"),
|
||||
Step("srl90", "fast_timing", value: "false"),
|
||||
Step("srl99", "snapshot"),
|
||||
};
|
||||
|
|
@ -3899,6 +3892,96 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>Character beat caption: MC identity title, prose bans, hover keeps beat.</summary>
|
||||
private static List<HarnessCommand> CaptionCharacterBeat()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("ccb0", "dismiss_windows"),
|
||||
Step("ccb1", "wait_world"),
|
||||
Step("ccb2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("ccb3", "fast_timing", value: "true"),
|
||||
Step("ccb4", "spawn", asset: "human", count: 2),
|
||||
Step("ccb5", "spectator", value: "off"),
|
||||
Step("ccb6", "spectator", value: "on"),
|
||||
Step("ccb7", "interest_saga_clear"),
|
||||
Step("ccb8", "pick_unit", asset: "human"),
|
||||
Step("ccb9", "focus", asset: "human"),
|
||||
Step("ccb10", "saga_force_admit_focus"),
|
||||
Step("ccb11", "saga_role_rise_focus", value: "become_king"),
|
||||
Step("ccb12", "saga_rail_refresh"),
|
||||
Step("ccb13", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action",
|
||||
expect: "ccb_fight", value: "lead=event;evt=70;force=true"),
|
||||
Step("ccb14", "wait", value: "0.3"),
|
||||
Step("ccb15", "assert", expect: "caption_mc_identity"),
|
||||
Step("ccb16", "assert", expect: "caption_identity_not_in_label"),
|
||||
Step("ccb17", "assert", expect: "caption_prose_no_jargon"),
|
||||
Step("ccb18", "saga_memory_record_focus", asset: "War", value: "Northreach", expect: "harness_cap_war"),
|
||||
Step("ccb19", "interest_force_session", asset: "human", label: "{a} patrols the border", tier: "Action",
|
||||
expect: "ccb_quiet", value: "lead=event;evt=55;force=true"),
|
||||
Step("ccb20", "wait", value: "0.3"),
|
||||
Step("ccb21", "assert", expect: "caption_context_stake"),
|
||||
// Soft tip must omit kill / crowning biography hitch; war stake may remain.
|
||||
Step("ccb21b", "saga_memory_record_focus", asset: "Kill", value: "Named foe"),
|
||||
Step("ccb21c", "interest_force_session", asset: "human", label: "{a} mates with Waaaf", tier: "Action",
|
||||
expect: "ccb_mate", value: "lead=event;evt=60;force=true"),
|
||||
Step("ccb21d", "wait", value: "0.3"),
|
||||
Step("ccb21e", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan"),
|
||||
Step("ccb21f", "assert", expect: "caption_context_stake"),
|
||||
Step("ccb21g", "interest_force_session", asset: "human", label: "{a} dreams pleasantly", tier: "Action",
|
||||
expect: "ccb_dream", value: "lead=event;evt=50;force=true"),
|
||||
Step("ccb21h", "wait", value: "0.3"),
|
||||
Step("ccb21i", "assert", expect: "caption_context_omits", value: "Slew|Became Pack|Became Clan|Founded"),
|
||||
Step("ccb22", "saga_show_hover_first"),
|
||||
Step("ccb23", "assert", expect: "caption_hover_keeps_beat"),
|
||||
Step("ccb23b", "assert", expect: "saga_panel_cast_first"),
|
||||
Step("ccb24", "saga_hide_hover"),
|
||||
Step("ccb25", "saga_hover_tick_away"),
|
||||
Step("ccb26", "spawn", asset: "sheep", count: 1),
|
||||
Step("ccb27", "pick_unit", asset: "sheep"),
|
||||
Step("ccb28", "focus", asset: "sheep"),
|
||||
Step("ccb29", "interest_force_session", asset: "sheep", label: "{a} grazes", tier: "Action",
|
||||
expect: "ccb_sheep", value: "lead=event;evt=40;force=true"),
|
||||
Step("ccb30", "wait", value: "0.3"),
|
||||
Step("ccb31", "assert", expect: "caption_nobody_species"),
|
||||
Step("ccb90", "fast_timing", value: "false"),
|
||||
Step("ccb99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>SagaProse cast/stake voice across bond, foe, plot partner classes.</summary>
|
||||
private static List<HarnessCommand> CaptionProseVoice()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("cpv0", "dismiss_windows"),
|
||||
Step("cpv1", "wait_world"),
|
||||
Step("cpv2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("cpv3", "fast_timing", value: "true"),
|
||||
Step("cpv4", "spawn", asset: "human", count: 2),
|
||||
Step("cpv5", "spectator", value: "off"),
|
||||
Step("cpv6", "spectator", value: "on"),
|
||||
Step("cpv7", "interest_saga_clear"),
|
||||
Step("cpv8", "pick_unit", asset: "human"),
|
||||
Step("cpv9", "happiness_remember_partner"),
|
||||
Step("cpv10", "pick_unit", asset: "human", value: "other"),
|
||||
Step("cpv11", "focus", asset: "human"),
|
||||
Step("cpv12", "saga_force_admit_focus"),
|
||||
Step("cpv13", "happiness_bond_lovers"),
|
||||
Step("cpv14", "assert", expect: "saga_cast_contains", value: "Lover"),
|
||||
Step("cpv15", "assert", expect: "caption_prose_casing_cast", value: "Lover|Best Friend"),
|
||||
Step("cpv16", "saga_note_encounters", value: "3"),
|
||||
Step("cpv17", "assert", expect: "saga_rival_focus", value: "true"),
|
||||
Step("cpv18", "assert", expect: "saga_cast_contains", value: "Old Foe|War Enemy|Blood Foe|Lover"),
|
||||
Step("cpv19", "assert", expect: "caption_prose_no_jargon"),
|
||||
Step("cpv20", "saga_memory_record_focus", asset: "Plot", value: "coup", expect: "harness_cpv_plot"),
|
||||
Step("cpv21", "assert", expect: "saga_cast_contains", value: "Plot Partner|Lover|Old Foe"),
|
||||
Step("cpv22", "assert", expect: "saga_stake_contains", value: "Still tangled|Still at war|Feud|Bound|Lover|Plot"),
|
||||
Step("cpv90", "fast_timing", value: "false"),
|
||||
Step("cpv99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Hover acquires read-pause; camera owner stays fixed until release.</summary>
|
||||
private static List<HarnessCommand> SagaHoverReadPause()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -153,6 +153,15 @@ public static partial class InterestDirector
|
|||
return false;
|
||||
}
|
||||
|
||||
// Solo status tips: only the live carrier owns the orange reason.
|
||||
// Stops Prefer/MC soft-follow from showing "Becomes pregnant" on a king who lacks it.
|
||||
if (scene.Completion == InterestCompletionKind.StatusPhase
|
||||
&& !string.IsNullOrEmpty(scene.StatusId)
|
||||
&& !StatusGameApi.HasStatus(unit, scene.StatusId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ensemble = scene.Completion == InterestCompletionKind.CombatActive
|
||||
|| scene.Completion == InterestCompletionKind.WarFront
|
||||
|| scene.Completion == InterestCompletionKind.PlotActive
|
||||
|
|
|
|||
539
IdleSpectator/Story/CaptionComposer.cs
Normal file
539
IdleSpectator/Story/CaptionComposer.cs
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-layer character beat: Identity + Beat + Context.
|
||||
/// Does not mutate InterestCandidate.Label or weaken identity_filter.
|
||||
/// </summary>
|
||||
public static class CaptionComposer
|
||||
{
|
||||
public const float ReentrySeconds = 12f;
|
||||
|
||||
private static long _lastCaptionUnitId;
|
||||
private static float _lastCaptionAt = -999f;
|
||||
private static float _lastCutawayAt = -999f;
|
||||
private static long _cutawayFromId;
|
||||
private static string _lastFingerprint = "";
|
||||
|
||||
public static string LastIdentityLine { get; private set; } = "";
|
||||
public static string LastBeatLine { get; private set; } = "";
|
||||
public static string LastContextLine { get; private set; } = "";
|
||||
public static long LastReasonRelatedId { get; private set; }
|
||||
public static string LastFingerprint => _lastFingerprint;
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_lastCaptionUnitId = 0;
|
||||
_lastCaptionAt = -999f;
|
||||
_lastCutawayAt = -999f;
|
||||
_cutawayFromId = 0;
|
||||
_lastFingerprint = "";
|
||||
LastIdentityLine = "";
|
||||
LastBeatLine = "";
|
||||
LastContextLine = "";
|
||||
LastReasonRelatedId = 0;
|
||||
}
|
||||
|
||||
public static CaptionModel Compose(
|
||||
Actor focus,
|
||||
InterestCandidate ownedTip,
|
||||
string presentableBeat,
|
||||
long reasonRelatedId,
|
||||
string spineLabel,
|
||||
bool statusBannerOwnsBeat)
|
||||
{
|
||||
var model = new CaptionModel();
|
||||
long focusId = EventFeedUtil.SafeId(focus);
|
||||
float now = Time.time;
|
||||
|
||||
bool isReentry = false;
|
||||
if (focusId != 0 && focusId != _lastCaptionUnitId)
|
||||
{
|
||||
// Test the returning subject before replacing the cutaway record with
|
||||
// the subject we are leaving. Updating first made this comparison
|
||||
// impossible: on A -> B -> A, _cutawayFromId was changed to B before
|
||||
// A could be recognized as the returning life.
|
||||
if (_cutawayFromId == focusId
|
||||
&& _lastCutawayAt > 0f
|
||||
&& now - _lastCutawayAt >= ReentrySeconds
|
||||
&& LifeSagaRoster.IsMc(focusId))
|
||||
{
|
||||
isReentry = true;
|
||||
}
|
||||
|
||||
if (_lastCaptionUnitId != 0)
|
||||
{
|
||||
_cutawayFromId = _lastCaptionUnitId;
|
||||
_lastCutawayAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (focusId != 0)
|
||||
{
|
||||
_lastCaptionUnitId = focusId;
|
||||
_lastCaptionAt = now;
|
||||
}
|
||||
|
||||
model.IdentityLine = BuildIdentityLine(focus, focusId);
|
||||
model.ReasonRelatedId = reasonRelatedId;
|
||||
|
||||
string beat = presentableBeat ?? "";
|
||||
if (statusBannerOwnsBeat)
|
||||
{
|
||||
// Banner owns orange row; composer still fills Identity + Context.
|
||||
model.BeatLine = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
model.BeatLine = EnrichBeat(focus, focusId, ownedTip, beat, ref model.ReasonRelatedId, isReentry);
|
||||
if (!isReentry)
|
||||
{
|
||||
model.BeatLine = RemoveIdentitySubjectPrefix(focus, model.BeatLine);
|
||||
}
|
||||
}
|
||||
|
||||
model.ContextLine = BuildContext(
|
||||
focusId,
|
||||
ownedTip,
|
||||
model.BeatLine,
|
||||
model.IdentityLine,
|
||||
spineLabel,
|
||||
isReentry && !statusBannerOwnsBeat);
|
||||
|
||||
model.Fingerprint =
|
||||
focusId + "|" + model.IdentityLine + "|" + model.BeatLine + "|" + model.ContextLine
|
||||
+ "|" + model.ReasonRelatedId + "|" + SagaProse.MemoryRevision;
|
||||
|
||||
LastIdentityLine = model.IdentityLine;
|
||||
LastBeatLine = model.BeatLine;
|
||||
LastContextLine = model.ContextLine;
|
||||
LastReasonRelatedId = model.ReasonRelatedId;
|
||||
_lastFingerprint = model.Fingerprint;
|
||||
return model;
|
||||
}
|
||||
|
||||
public static string TruncateIdentity(string identity, int maxChars)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identity) || maxChars <= 0 || identity.Length <= maxChars)
|
||||
{
|
||||
return identity ?? "";
|
||||
}
|
||||
|
||||
// Prefer Name · FirstTitleToken…
|
||||
int sep = identity.IndexOf(" · ", StringComparison.Ordinal);
|
||||
if (sep > 0)
|
||||
{
|
||||
string name = identity.Substring(0, sep);
|
||||
string rest = identity.Substring(sep + 3).Trim();
|
||||
string firstToken = rest;
|
||||
int sp = rest.IndexOf(' ');
|
||||
if (sp > 0)
|
||||
{
|
||||
firstToken = rest.Substring(0, sp);
|
||||
}
|
||||
|
||||
string compact = name + " · " + firstToken;
|
||||
if (compact.Length <= maxChars)
|
||||
{
|
||||
return compact + "…";
|
||||
}
|
||||
|
||||
if (name.Length + 1 < maxChars)
|
||||
{
|
||||
return name.Substring(0, Math.Min(name.Length, maxChars - 1)) + "…";
|
||||
}
|
||||
}
|
||||
|
||||
return identity.Substring(0, Math.Max(1, maxChars - 1)) + "…";
|
||||
}
|
||||
|
||||
private static string BuildIdentityLine(Actor focus, long focusId)
|
||||
{
|
||||
if (focus == null || focusId == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string name = EventFeedUtil.SafeName(focus);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
name = "Someone";
|
||||
}
|
||||
|
||||
// Strip Prefer star if presentation added it elsewhere.
|
||||
if (name.StartsWith("★ ", StringComparison.Ordinal))
|
||||
{
|
||||
name = name.Substring(2).Trim();
|
||||
}
|
||||
|
||||
string speciesJob = UnitDossier.BuildHeadline(
|
||||
name,
|
||||
UnitDossier.BuildIdentityTag(
|
||||
focus.asset != null ? focus.asset.id : "",
|
||||
UnitDossier.ReadLiveJobLabel(focus)));
|
||||
|
||||
if (!LifeSagaRoster.IsMc(focusId))
|
||||
{
|
||||
return speciesJob;
|
||||
}
|
||||
|
||||
LifeSagaSlot slot = LifeSagaRoster.Get(focusId);
|
||||
LifeSagaLifeMemory memory = LifeSagaMemory.Get(focusId);
|
||||
string title = SagaProse.Title(slot, focus, memory);
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
return speciesJob;
|
||||
}
|
||||
|
||||
return name + " · " + title;
|
||||
}
|
||||
|
||||
private static string EnrichBeat(
|
||||
Actor focus,
|
||||
long focusId,
|
||||
InterestCandidate tip,
|
||||
string beat,
|
||||
ref long relatedId,
|
||||
bool isReentry)
|
||||
{
|
||||
if (string.IsNullOrEmpty(beat))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string result = beat;
|
||||
long otherId = relatedId;
|
||||
if (otherId == 0 && tip != null)
|
||||
{
|
||||
otherId = tip.RelatedId != 0 ? tip.RelatedId : tip.PairPartnerId;
|
||||
}
|
||||
|
||||
if (otherId == 0 && tip != null && tip.PairPartnerId != 0)
|
||||
{
|
||||
otherId = tip.PairPartnerId;
|
||||
}
|
||||
|
||||
if (otherId != 0)
|
||||
{
|
||||
relatedId = otherId;
|
||||
}
|
||||
|
||||
// Ensemble tips: no cast-name injection; rematch clause OK when pair/related already named.
|
||||
bool ensemble = SagaProse.IsEnsembleTip(beat);
|
||||
Actor other = otherId != 0 ? EventFeedUtil.FindAliveById(otherId) : null;
|
||||
if (other == null && otherId != 0)
|
||||
{
|
||||
other = EventFeedUtil.FindUnitById(otherId);
|
||||
}
|
||||
|
||||
if (focusId != 0 && otherId != 0 && other != null)
|
||||
{
|
||||
RelationEvidence ev = SagaProse.ResolveRelation(focusId, otherId, focus, other);
|
||||
string verb = SagaProse.ClassifyTipVerb(beat);
|
||||
string clause = SagaProse.BeatClause(focus, other, ev, beat, verb);
|
||||
if (!string.IsNullOrEmpty(clause))
|
||||
{
|
||||
// Skip weak clauses on pure ensemble without the other name in tip.
|
||||
if (ensemble)
|
||||
{
|
||||
string otherName = EventFeedUtil.SafeName(other);
|
||||
if (!string.IsNullOrEmpty(otherName)
|
||||
&& beat.IndexOf(otherName, StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& tip != null
|
||||
&& tip.PairPartnerId == 0
|
||||
&& tip.RelatedId == 0)
|
||||
{
|
||||
clause = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(clause)
|
||||
&& result.IndexOf(clause, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
result = result.TrimEnd() + " · " + clause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isReentry && LifeSagaRoster.IsMc(focusId))
|
||||
{
|
||||
string n = EventFeedUtil.SafeName(focus);
|
||||
if (!string.IsNullOrEmpty(n)
|
||||
&& result.StartsWith(n, StringComparison.Ordinal)
|
||||
&& result.IndexOf(" again", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
// "Name again · rest" or "Name is …" → "Name again is …" is awkward.
|
||||
// Prefer "Name again · " only when tip doesn't already start with Name + again.
|
||||
if (result.StartsWith(n + " ", StringComparison.Ordinal))
|
||||
{
|
||||
result = n + " again · " + result.Substring(n.Length).TrimStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity already names the focused life directly above the Beat. Remove only that
|
||||
/// leading subject (plain or our exact rich-name wrapper), leaving other names intact.
|
||||
/// </summary>
|
||||
private static string RemoveIdentitySubjectPrefix(Actor focus, string beat)
|
||||
{
|
||||
if (focus == null || string.IsNullOrEmpty(beat))
|
||||
{
|
||||
return beat ?? "";
|
||||
}
|
||||
|
||||
string name = EventFeedUtil.SafeName(focus);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return beat;
|
||||
}
|
||||
|
||||
string rest = "";
|
||||
string richName = ActivityProse.ColorName(name);
|
||||
if (beat.StartsWith(richName + " ", StringComparison.Ordinal))
|
||||
{
|
||||
rest = beat.Substring(richName.Length).TrimStart();
|
||||
}
|
||||
else if (beat.StartsWith(name + " ", StringComparison.Ordinal))
|
||||
{
|
||||
rest = beat.Substring(name.Length).TrimStart();
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(rest) ? beat : SagaProse.Sentence(rest);
|
||||
}
|
||||
|
||||
private static string BuildContext(
|
||||
long focusId,
|
||||
InterestCandidate tip,
|
||||
string beatLine,
|
||||
string identityLine,
|
||||
string spineLabel,
|
||||
bool isReentry)
|
||||
{
|
||||
string spine = spineLabel ?? "";
|
||||
if (!string.IsNullOrEmpty(spine))
|
||||
{
|
||||
return spine;
|
||||
}
|
||||
|
||||
if (!LifeSagaRoster.IsMc(focusId))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string tipMood = TipMood(beatLine, tip);
|
||||
// Walk strongest → weaker so a soft tip can skip a kill/role hitch and still
|
||||
// show an ongoing war/plot/rival stake when one exists.
|
||||
LifeSagaFact stake = PickHitchStake(focusId, beatLine, tipMood, identityLine ?? "");
|
||||
if (stake == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return SagaProse.StakeLine(stake) ?? "";
|
||||
}
|
||||
|
||||
private static LifeSagaFact PickHitchStake(
|
||||
long focusId,
|
||||
string beatLine,
|
||||
string tipMood,
|
||||
string identityLine)
|
||||
{
|
||||
LifeSagaFact best = LifeSagaMemory.StrongestUnresolved(focusId);
|
||||
if (best != null
|
||||
&& !BeatCoversStake(beatLine, best)
|
||||
&& !ShouldOmitStakeHitch(best, tipMood, identityLine))
|
||||
{
|
||||
return best;
|
||||
}
|
||||
|
||||
var facts = LifeSagaMemory.FactsFor(focusId);
|
||||
LifeSagaFact next = null;
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
LifeSagaFact f = facts[i];
|
||||
if (f == null || f.Resolved || ReferenceEquals(f, best))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BeatCoversStake(beatLine, f) || ShouldOmitStakeHitch(f, tipMood, identityLine))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only promote stake-worthy / fallback-worthy unresolved facts.
|
||||
if (LifeSagaMemory.IsUnresolvedStakeCandidate(f)
|
||||
&& (next == null
|
||||
|| f.Strength > next.Strength
|
||||
|| (Mathf.Approximately(f.Strength, next.Strength) && f.At > next.At)))
|
||||
{
|
||||
next = f;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft live tips (love/rest/dream) should not hitch closed biography
|
||||
/// (kills, crowning, founding). Ongoing pressure (war/plot/rival/grief) still may.
|
||||
/// </summary>
|
||||
private static bool ShouldOmitStakeHitch(LifeSagaFact stake, string tipMood, string identityLine)
|
||||
{
|
||||
if (stake == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// RoleChange that already sits in Identity ("King of X") is always redundant.
|
||||
if (stake.Kind == LifeSagaFactKind.RoleChange
|
||||
&& RoleChangeEchoesIdentity(stake, identityLine))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// "Founder" in Identity already carries the founding fact; repeating
|
||||
// "Founded kingdom" immediately below it adds no new information.
|
||||
if (stake.Kind == LifeSagaFactKind.Founding
|
||||
&& identityLine.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bool soft = tipMood == "love" || tipMood == "rest";
|
||||
if (!soft)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (stake.Kind)
|
||||
{
|
||||
case LifeSagaFactKind.Kill:
|
||||
case LifeSagaFactKind.CombatEncounter:
|
||||
case LifeSagaFactKind.HardArcCombat:
|
||||
case LifeSagaFactKind.RoleChange:
|
||||
case LifeSagaFactKind.Founding:
|
||||
case LifeSagaFactKind.HomeGained:
|
||||
case LifeSagaFactKind.HomeLost:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RoleChangeEchoesIdentity(LifeSagaFact stake, string identityLine)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identityLine))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string body = SagaProse.StakeLine(stake) ?? "";
|
||||
if (string.IsNullOrEmpty(body))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// "Became Pack Alpha" under Identity "Name · Pack Alpha".
|
||||
string lowerId = identityLine.ToLowerInvariant();
|
||||
string lowerBody = body.ToLowerInvariant();
|
||||
if (lowerBody.StartsWith("became ", StringComparison.Ordinal))
|
||||
{
|
||||
string role = lowerBody.Substring("became ".Length).Trim();
|
||||
if (role.Length >= 4 && lowerId.IndexOf(role, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
string[] roleTokens =
|
||||
{
|
||||
"king", "queen", "leader", "alpha", "chief", "captain", "founder"
|
||||
};
|
||||
for (int i = 0; i < roleTokens.Length; i++)
|
||||
{
|
||||
string t = roleTokens[i];
|
||||
if (lowerBody.IndexOf(t, StringComparison.Ordinal) >= 0
|
||||
&& lowerId.IndexOf(t, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string TipMood(string beatLine, InterestCandidate tip)
|
||||
{
|
||||
string verb = SagaProse.ClassifyTipVerb(beatLine);
|
||||
if (verb == "love" || verb == "rest" || verb == "grief" || verb == "combat" || verb == "plot")
|
||||
{
|
||||
return verb;
|
||||
}
|
||||
|
||||
if (tip != null && !string.IsNullOrEmpty(tip.StatusId))
|
||||
{
|
||||
string sid = tip.StatusId.ToLowerInvariant();
|
||||
if (sid.IndexOf("pregnant", StringComparison.Ordinal) >= 0
|
||||
|| sid.IndexOf("love", StringComparison.Ordinal) >= 0
|
||||
|| sid.IndexOf("fell_in_love", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return "love";
|
||||
}
|
||||
|
||||
if (sid.IndexOf("sleep", StringComparison.Ordinal) >= 0
|
||||
|| sid.IndexOf("dream", StringComparison.Ordinal) >= 0
|
||||
|| sid.IndexOf("afterglow", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return "rest";
|
||||
}
|
||||
}
|
||||
|
||||
return verb;
|
||||
}
|
||||
|
||||
private static bool BeatCoversStake(string beat, LifeSagaFact stake)
|
||||
{
|
||||
if (string.IsNullOrEmpty(beat) || stake == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stake.OtherId != 0 && !string.IsNullOrEmpty(stake.Other.Name)
|
||||
&& beat.IndexOf(stake.Other.Name, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string stakeText = SagaProse.StakeLine(stake);
|
||||
if (!string.IsNullOrEmpty(stakeText)
|
||||
&& beat.IndexOf(stakeText, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rematch clash clause already teaches the feud.
|
||||
if (beat.IndexOf("clash", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
&& stake.Kind == LifeSagaFactKind.RivalEarned)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CaptionModel
|
||||
{
|
||||
public string IdentityLine = "";
|
||||
public string BeatLine = "";
|
||||
public string ContextLine = "";
|
||||
public long ReasonRelatedId;
|
||||
public string Fingerprint = "";
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ public static class LifeSagaMemory
|
|||
PairEncounterCooldown.Clear();
|
||||
_boundWorld = null;
|
||||
_worldEpoch++;
|
||||
SagaProse.BumpMemoryRevision();
|
||||
}
|
||||
|
||||
public static void Clear() => ClearSession();
|
||||
|
|
@ -245,6 +246,7 @@ public static class LifeSagaMemory
|
|||
};
|
||||
life.Facts.Insert(0, fact);
|
||||
life.TouchedAt = now;
|
||||
SagaProse.BumpMemoryRevision();
|
||||
while (life.Facts.Count > MaxFactsPerLife)
|
||||
{
|
||||
life.Facts.RemoveAt(life.Facts.Count - 1);
|
||||
|
|
@ -1207,6 +1209,20 @@ public static class LifeSagaMemory
|
|||
return PickStrongest(life, strongOnly: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when an unresolved fact is eligible as a Context hitch candidate
|
||||
/// (primary stake-worthy or fallback-worthy).
|
||||
/// </summary>
|
||||
public static bool IsUnresolvedStakeCandidate(LifeSagaFact fact)
|
||||
{
|
||||
if (fact == null || fact.Resolved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsStakeWorthy(fact) || IsStakeFallbackWorthy(fact);
|
||||
}
|
||||
|
||||
private static LifeSagaFact PickStrongest(LifeSagaLifeMemory life, bool strongOnly)
|
||||
{
|
||||
LifeSagaFact best = null;
|
||||
|
|
@ -1331,21 +1347,7 @@ public static class LifeSagaMemory
|
|||
}
|
||||
|
||||
/// <summary>Legacy display line: optional world date · beat prose.</summary>
|
||||
public static string FormatLegacyLine(LifeSagaFact fact)
|
||||
{
|
||||
string line = FormatFactLine(fact);
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fact.DateLabel))
|
||||
{
|
||||
return fact.DateLabel + " · " + line;
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
public static string FormatLegacyLine(LifeSagaFact fact) => SagaProse.FormatLegacyLine(fact);
|
||||
|
||||
public static string FormatFamilySummary(int childCount)
|
||||
{
|
||||
|
|
@ -1402,153 +1404,7 @@ public static class LifeSagaMemory
|
|||
return chapters;
|
||||
}
|
||||
|
||||
public static string FormatFactLine(LifeSagaFact fact)
|
||||
{
|
||||
if (fact == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string other = !string.IsNullOrEmpty(fact.Other.Name) ? fact.Other.Name : "";
|
||||
switch (fact.Kind)
|
||||
{
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
if (fact.McCrossover)
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Bound to a fellow saga hero"
|
||||
: "Bound to fellow saga hero " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Found love" : "Bound with " + other;
|
||||
case LifeSagaFactKind.BondBroken:
|
||||
return string.IsNullOrEmpty(other) ? "Lost a lover" : "Lost " + other;
|
||||
case LifeSagaFactKind.FriendFormed:
|
||||
return string.IsNullOrEmpty(other) ? "Made a true friend" : "Befriended " + other;
|
||||
case LifeSagaFactKind.ParentChild:
|
||||
if (!IsParentPerspective(fact))
|
||||
{
|
||||
return string.IsNullOrEmpty(other) ? "Was born" : "Born to " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Had a child" : "Had child " + other;
|
||||
case LifeSagaFactKind.Kill:
|
||||
if (fact.McCrossover)
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Slew a fellow saga hero"
|
||||
: "Slew fellow saga hero " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
|
||||
case LifeSagaFactKind.Death:
|
||||
if (fact.McCrossover)
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Fallen to a fellow saga hero"
|
||||
: "Slain by fellow saga hero " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
|
||||
case LifeSagaFactKind.RivalEarned:
|
||||
if (!string.IsNullOrEmpty(fact.Note))
|
||||
{
|
||||
if (fact.Note.StartsWith("opposed_in_war:", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Opposed in war"
|
||||
: "Opposed " + other + " in war";
|
||||
}
|
||||
|
||||
if (fact.Note.StartsWith("opposed_in_plot:", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Opposed in a plot"
|
||||
: "Opposed " + other + " in a plot";
|
||||
}
|
||||
|
||||
if (fact.Note.StartsWith("killed_kin", StringComparison.Ordinal)
|
||||
|| fact.Note.StartsWith("kin_slain", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Blood feud over kin"
|
||||
: "Blood feud with " + other;
|
||||
}
|
||||
|
||||
if (fact.Note.StartsWith("mc_rematch:", StringComparison.Ordinal)
|
||||
|| fact.Note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Clashed again and again"
|
||||
: "Clashed thrice with " + other;
|
||||
}
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other;
|
||||
case LifeSagaFactKind.WarJoin:
|
||||
{
|
||||
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
|
||||
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(realm))
|
||||
{
|
||||
return "War with " + other + " (" + realm + ")";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(other))
|
||||
{
|
||||
return "War with " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(realm) ? "Entered a war" : "War: " + realm;
|
||||
}
|
||||
case LifeSagaFactKind.WarEnd:
|
||||
{
|
||||
string realm = FirstNonEmpty(fact.Note, fact.KingdomKey);
|
||||
return string.IsNullOrEmpty(realm) ? "War ended" : "War ended (" + realm + ")";
|
||||
}
|
||||
case LifeSagaFactKind.PlotJoin:
|
||||
{
|
||||
string plot = fact.Note ?? "";
|
||||
if (!string.IsNullOrEmpty(other) && !string.IsNullOrEmpty(plot))
|
||||
{
|
||||
return "Plot with " + other + " (" + plot + ")";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(other))
|
||||
{
|
||||
return "Plot with " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(plot) ? "Joined a plot" : "Joined " + plot;
|
||||
}
|
||||
case LifeSagaFactKind.PlotEnd:
|
||||
{
|
||||
string plot = fact.Note ?? "";
|
||||
return string.IsNullOrEmpty(plot) ? "Plot resolved" : "Plot resolved (" + plot + ")";
|
||||
}
|
||||
case LifeSagaFactKind.Founding:
|
||||
return !string.IsNullOrEmpty(fact.Note) ? "Founded " + fact.Note : "Founded a new home";
|
||||
case LifeSagaFactKind.RoleChange:
|
||||
return RoleNote(fact.Note);
|
||||
case LifeSagaFactKind.HomeGained:
|
||||
return "Found a home";
|
||||
case LifeSagaFactKind.HomeLost:
|
||||
return "Lost a home";
|
||||
case LifeSagaFactKind.HardArcLove:
|
||||
return FirstNonEmpty(fact.Note, "A relationship changed them");
|
||||
case LifeSagaFactKind.HardArcGrief:
|
||||
return FirstNonEmpty(fact.Note, "Carrying a loss");
|
||||
case LifeSagaFactKind.HardArcCombat:
|
||||
return FirstNonEmpty(fact.Note, "A fight marked them");
|
||||
case LifeSagaFactKind.HardArcWar:
|
||||
return FirstNonEmpty(fact.Note, "War closed in");
|
||||
case LifeSagaFactKind.HardArcPlot:
|
||||
return FirstNonEmpty(fact.Note, "Entangled in a plot");
|
||||
case LifeSagaFactKind.CombatEncounter:
|
||||
return string.IsNullOrEmpty(other) ? "Fought again" : "Clashed with " + other;
|
||||
default:
|
||||
return FirstNonEmpty(fact.Note, "A turning point");
|
||||
}
|
||||
}
|
||||
public static string FormatFactLine(LifeSagaFact fact) => SagaProse.FormatFactLine(fact);
|
||||
|
||||
private static void TryMarkKinKillRival(Actor killer, Actor victim)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
|
@ -6,45 +7,39 @@ using UnityEngine.UI;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Saga story card: identity + stake beside the portrait, Cast and Legacy full-width under it.
|
||||
/// No Evidence stats dump and no Open Lore button (L still opens Lore).
|
||||
/// Width is fixed to the tabbed chrome so right-anchored hover cannot slide the rail.
|
||||
/// Saga depth card under the character beat caption: Cast + Legacy.
|
||||
/// Bound to the hover preview id. Identity lives on the caption (name · title);
|
||||
/// no portrait / name / title / stake chrome here - that space goes to Cast.
|
||||
/// Width is fixed to the rail chrome so right-anchored hover cannot slide the rail.
|
||||
/// Height sizes to content (capped) so lines wrap fully instead of ellipsis.
|
||||
/// </summary>
|
||||
public static class LifeSagaPanel
|
||||
{
|
||||
/// <summary>Matches WatchCaption tabbed chrome inner width (PanelWidthMax - pads).</summary>
|
||||
/// <summary>Matches WatchCaption rail chrome inner width (PanelWidthMax - pads).</summary>
|
||||
public const float BodyWidthFixed = 232f;
|
||||
/// <summary>Hard cap so Saga never eats the screen.</summary>
|
||||
public const float BodyHeightMax = 148f;
|
||||
public const float BodyHeightMax = 172f;
|
||||
|
||||
public const float BodyWidthMin = BodyWidthFixed;
|
||||
public const float BodyWidthMax = BodyWidthFixed;
|
||||
public const float BodyHeightMin = 56f;
|
||||
public const float BodyHeightMin = 12f;
|
||||
public const float BodyHeightFixed = BodyHeightMax;
|
||||
|
||||
/// <summary>Shared cast budget for panel slots and presentation model.</summary>
|
||||
public const int MaxCast = 8;
|
||||
private const int MaxLegacy = 6;
|
||||
private const int CastPerRow = 4;
|
||||
|
||||
private const float Pad = 3f;
|
||||
private const float AvatarSize = 32f;
|
||||
private const float ColGap = 6f;
|
||||
private const float CastFace = 16f;
|
||||
private const float CastSlotW = 54f;
|
||||
private const float SecLabelH = 10f;
|
||||
private const float LineH = 10f;
|
||||
private const int EyebrowFont = 7;
|
||||
private const int NameFont = 9;
|
||||
private const int TitleFont = 7;
|
||||
private const int StakeFont = 8;
|
||||
private const int BodyFont = 7;
|
||||
private const int MaxCast = 4;
|
||||
private const int MaxLegacy = 5;
|
||||
private const int BuildVersion = 8;
|
||||
private const int BuildVersion = 10;
|
||||
|
||||
private static GameObject _root;
|
||||
private static RectTransform _rootRt;
|
||||
private static Text _eyebrowText;
|
||||
private static Text _nameText;
|
||||
private static Text _titleText;
|
||||
private static Text _stakeText;
|
||||
private static Text _castHead;
|
||||
private static Text _legacyHead;
|
||||
private static Text _legacyText;
|
||||
|
|
@ -52,7 +47,6 @@ public static class LifeSagaPanel
|
|||
private static long _boundId;
|
||||
private static string _speciesId = "";
|
||||
private static string _fingerprint = "";
|
||||
private static bool _ownsAvatar;
|
||||
private static int _builtVersion;
|
||||
private static float _laidOutHeight = BodyHeightMin;
|
||||
|
||||
|
|
@ -70,7 +64,8 @@ public static class LifeSagaPanel
|
|||
public static float BodyHeight => _laidOutHeight;
|
||||
public static float BodyWidthPx => BodyWidthFixed;
|
||||
public static long BoundUnitId => _boundId;
|
||||
public static bool OwnsAvatar => _ownsAvatar && Visible;
|
||||
/// <summary>Always false - caption owns identity; panel never steals DossierAvatar.</summary>
|
||||
public static bool OwnsAvatar => false;
|
||||
public static string LastLayoutDebug { get; private set; } = "";
|
||||
|
||||
public static void EnsureBuilt(Transform parent)
|
||||
|
|
@ -87,13 +82,9 @@ public static class LifeSagaPanel
|
|||
|
||||
if (_root != null)
|
||||
{
|
||||
Object.Destroy(_root);
|
||||
UnityEngine.Object.Destroy(_root);
|
||||
_root = null;
|
||||
_rootRt = null;
|
||||
_eyebrowText = null;
|
||||
_nameText = null;
|
||||
_titleText = null;
|
||||
_stakeText = null;
|
||||
_castHead = null;
|
||||
_legacyHead = null;
|
||||
_legacyText = null;
|
||||
|
|
@ -107,12 +98,6 @@ public static class LifeSagaPanel
|
|||
_rootRt.anchorMax = new Vector2(0f, 1f);
|
||||
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, BodyHeightMin);
|
||||
|
||||
_eyebrowText = MakeLabel(_root.transform, "SagaEyebrow", EyebrowFont, HudTheme.Muted, FontStyle.Normal);
|
||||
_nameText = MakeLabel(_root.transform, "SagaName", NameFont, HudTheme.ValueOrange, FontStyle.Bold);
|
||||
_titleText = MakeLabel(_root.transform, "SagaTitle", TitleFont, HudTheme.Muted, FontStyle.Normal);
|
||||
_stakeText = MakeLabel(_root.transform, "StakeBody", StakeFont, HudTheme.ValueOrange, FontStyle.Bold);
|
||||
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
_castHead = MakeLabel(_root.transform, "CastHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
|
||||
_castHead.text = "Cast";
|
||||
_legacyHead = MakeLabel(_root.transform, "LegacyHead", BodyFont, HudTheme.Muted, FontStyle.Bold);
|
||||
|
|
@ -135,7 +120,6 @@ public static class LifeSagaPanel
|
|||
_boundId = 0;
|
||||
_speciesId = "";
|
||||
_fingerprint = "";
|
||||
_ownsAvatar = false;
|
||||
_laidOutHeight = BodyHeightMin;
|
||||
if (_root != null)
|
||||
{
|
||||
|
|
@ -151,10 +135,6 @@ public static class LifeSagaPanel
|
|||
}
|
||||
|
||||
_root.SetActive(visible);
|
||||
if (!visible)
|
||||
{
|
||||
_ownsAvatar = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Bind(LifeSagaViewModel model)
|
||||
|
|
@ -166,7 +146,7 @@ public static class LifeSagaPanel
|
|||
|
||||
if (model == null || model.UnitId == 0)
|
||||
{
|
||||
BindEmptyPickState();
|
||||
Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -178,27 +158,28 @@ public static class LifeSagaPanel
|
|||
|
||||
if (!same)
|
||||
{
|
||||
_eyebrowText.text = model.LensEyebrow ?? "";
|
||||
_nameText.text = StripStar(model.Name);
|
||||
_titleText.text = model.Title ?? "";
|
||||
_stakeText.text = model.StakeLine ?? "";
|
||||
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
int shownCast = 0;
|
||||
for (int i = 0; i < model.Cast.Count && shownCast < CastSlots.Length; i++)
|
||||
{
|
||||
if (i < model.Cast.Count && model.Cast[i] != null)
|
||||
LifeSagaCastMember member = model.Cast[i];
|
||||
if (member == null || CastDuplicatesCurrentBeat(member))
|
||||
{
|
||||
BindCast(CastSlots[i], model.Cast[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
CastSlots[i].Root.SetActive(false);
|
||||
CastSlots[i].UnitId = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
BindCast(CastSlots[shownCast], member);
|
||||
shownCast++;
|
||||
}
|
||||
|
||||
for (int i = shownCast; i < CastSlots.Length; i++)
|
||||
{
|
||||
CastSlots[i].Root.SetActive(false);
|
||||
CastSlots[i].UnitId = 0;
|
||||
}
|
||||
|
||||
var legacyParts = new List<string>(MaxLegacy);
|
||||
var seen = new HashSet<string>();
|
||||
string stakeKey = (_stakeText.text ?? "").Trim().ToLowerInvariant();
|
||||
string stakeKey = (model.StakeLine ?? "").Trim().ToLowerInvariant();
|
||||
for (int i = 0; i < model.Legacy.Count && legacyParts.Count < MaxLegacy; i++)
|
||||
{
|
||||
if (model.Legacy[i] == null || string.IsNullOrEmpty(model.Legacy[i].Line))
|
||||
|
|
@ -225,7 +206,6 @@ public static class LifeSagaPanel
|
|||
_legacyText.text = legacyParts.Count > 0 ? string.Join("\n", legacyParts) : "";
|
||||
}
|
||||
|
||||
RefreshAvatar(model);
|
||||
Layout();
|
||||
_root.SetActive(true);
|
||||
}
|
||||
|
|
@ -239,89 +219,35 @@ public static class LifeSagaPanel
|
|||
|
||||
_rootRt.anchoredPosition = new Vector2(padX, -y);
|
||||
_rootRt.sizeDelta = new Vector2(BodyWidthFixed, _laidOutHeight);
|
||||
if (_ownsAvatar)
|
||||
{
|
||||
DossierAvatar.Place(padX + Pad, y + Pad, AvatarSize);
|
||||
DossierAvatar.DrawBehind(_root.transform);
|
||||
DossierAvatar.ForceCompactFrame();
|
||||
}
|
||||
|
||||
return y + _laidOutHeight + 2f;
|
||||
}
|
||||
|
||||
private static void RefreshAvatar(LifeSagaViewModel model)
|
||||
{
|
||||
if (model == null || model.UnitId == 0)
|
||||
{
|
||||
_ownsAvatar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
DossierAvatar.EnsureHost(_root.transform.parent, AvatarSize);
|
||||
DossierAvatar.SetHostVisible(true);
|
||||
Actor live = EventFeedUtil.FindAliveById(model.UnitId);
|
||||
if (live != null && live.isAlive())
|
||||
{
|
||||
DossierAvatar.Show(live);
|
||||
DossierAvatar.ForceCompactFrame();
|
||||
}
|
||||
else
|
||||
{
|
||||
DossierAvatar.ShowSpecies(model.SpeciesId);
|
||||
}
|
||||
|
||||
_ownsAvatar = true;
|
||||
}
|
||||
|
||||
private static void Layout()
|
||||
{
|
||||
bool useAvatarCol = _ownsAvatar;
|
||||
float colX = useAvatarCol ? Pad + AvatarSize + ColGap : Pad;
|
||||
float textW = BodyWidthFixed - colX - Pad;
|
||||
float fullX = Pad;
|
||||
float fullW = BodyWidthFixed - Pad * 2f;
|
||||
float y = Pad;
|
||||
float avatarBottom = useAvatarCol ? Pad + AvatarSize : Pad;
|
||||
float yMax = BodyHeightMax - Pad;
|
||||
|
||||
if (!string.IsNullOrEmpty(_eyebrowText.text))
|
||||
// The Beat can update after the presentation model binds in the same frame.
|
||||
// Recheck visible slots at layout time so the current scene partner is never
|
||||
// repeated immediately below ("Bound with X" + "Lover X").
|
||||
string liveBeat = WatchCaption.VisibleBeatLine ?? "";
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
PlaceText(_eyebrowText, colX, y, textW, LineH);
|
||||
y += LineH;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_eyebrowText);
|
||||
}
|
||||
CastSlot slot = CastSlots[i];
|
||||
if (slot?.Root == null || !slot.Root.activeSelf || slot.Name == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PlaceText(_nameText, colX, y, textW, NameFont + 2f);
|
||||
y += NameFont + 2f;
|
||||
if (!string.IsNullOrEmpty(_titleText.text))
|
||||
{
|
||||
PlaceText(_titleText, colX, y, textW, LineH);
|
||||
y += LineH;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_titleText);
|
||||
}
|
||||
|
||||
// Stake + Cast + Legacy wrap under the portrait (identity stays in the header band).
|
||||
y = Mathf.Max(y + 2f, avatarBottom + 2f);
|
||||
|
||||
if (!string.IsNullOrEmpty(_stakeText.text))
|
||||
{
|
||||
float stakeH = Mathf.Clamp(
|
||||
EstimateWrapHeight(_stakeText.text, fullW, StakeFont),
|
||||
LineH,
|
||||
LineH * 3f);
|
||||
PlaceText(_stakeText, fullX, y, fullW, stakeH);
|
||||
_stakeText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_stakeText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
y += stakeH + 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide(_stakeText);
|
||||
string castName = slot.Name.text ?? "";
|
||||
if (!string.IsNullOrEmpty(castName)
|
||||
&& liveBeat.IndexOf(castName, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
slot.Root.SetActive(false);
|
||||
slot.UnitId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
int castN = 0;
|
||||
|
|
@ -333,13 +259,13 @@ public static class LifeSagaPanel
|
|||
}
|
||||
}
|
||||
|
||||
float yMax = BodyHeightMax - Pad;
|
||||
if (castN > 0)
|
||||
{
|
||||
PlaceText(_castHead, fullX, y, fullW, SecLabelH);
|
||||
y += SecLabelH;
|
||||
float castX = fullX;
|
||||
float castRowH = CastFace + 18f;
|
||||
float castX = fullX;
|
||||
int col = 0;
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
if (!CastSlots[i].Root.activeSelf)
|
||||
|
|
@ -347,11 +273,19 @@ public static class LifeSagaPanel
|
|||
continue;
|
||||
}
|
||||
|
||||
if (col >= CastPerRow)
|
||||
{
|
||||
y += castRowH + 2f;
|
||||
castX = fullX;
|
||||
col = 0;
|
||||
}
|
||||
|
||||
RectTransform rt = CastSlots[i].Root.GetComponent<RectTransform>();
|
||||
rt.anchoredPosition = new Vector2(castX, -y);
|
||||
rt.sizeDelta = new Vector2(CastSlotW, castRowH);
|
||||
CastSlots[i].Root.transform.SetAsLastSibling();
|
||||
castX += CastSlotW + 2f;
|
||||
col++;
|
||||
}
|
||||
|
||||
y += castRowH + 2f;
|
||||
|
|
@ -369,7 +303,7 @@ public static class LifeSagaPanel
|
|||
float legacyH = Mathf.Clamp(
|
||||
EstimateWrapHeight(_legacyText.text, fullW, BodyFont),
|
||||
LineH,
|
||||
LineH * 5f);
|
||||
LineH * 6f);
|
||||
legacyH = Mathf.Min(legacyH, Mathf.Max(LineH, yMax - y));
|
||||
PlaceText(_legacyText, fullX, y, fullW, legacyH);
|
||||
_legacyText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
|
|
@ -383,7 +317,7 @@ public static class LifeSagaPanel
|
|||
hasLegacy = false;
|
||||
}
|
||||
|
||||
y = Mathf.Max(y + Pad, useAvatarCol ? AvatarSize + Pad * 2f : BodyHeightMin);
|
||||
y = Mathf.Max(y + Pad, BodyHeightMin);
|
||||
_laidOutHeight = Mathf.Clamp(y, BodyHeightMin, BodyHeightMax);
|
||||
|
||||
if (_rootRt != null)
|
||||
|
|
@ -392,7 +326,7 @@ public static class LifeSagaPanel
|
|||
}
|
||||
|
||||
LastLayoutDebug =
|
||||
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} name='{_nameText?.text}' eyebrow='{Short(_eyebrowText?.text)}' stake='{Short(_stakeText?.text)}' cast={castN} legacy={(hasLegacy ? 1 : 0)}";
|
||||
$"w={BodyWidthFixed} h={_laidOutHeight:0.#} cast={castN} legacy={(hasLegacy ? 1 : 0)} castFirst=1";
|
||||
}
|
||||
|
||||
private static float EstimateWrapHeight(string text, float width, float fontSize)
|
||||
|
|
@ -449,14 +383,13 @@ public static class LifeSagaPanel
|
|||
faceRt.sizeDelta = new Vector2(CastFace - 2f, CastFace - 2f);
|
||||
|
||||
Text name = MakeLabel(root.transform, "Name", BodyFont, HudTheme.Body, FontStyle.Normal);
|
||||
Text relation = MakeLabel(root.transform, "Rel", BodyFont, HudTheme.Muted, FontStyle.Normal);
|
||||
RectTransform nameRt = name.rectTransform;
|
||||
nameRt.anchoredPosition = new Vector2(CastFace + 2f, 0f);
|
||||
nameRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
|
||||
|
||||
Text relation = MakeLabel(root.transform, "Relation", BodyFont, HudTheme.Muted, FontStyle.Normal);
|
||||
nameRt.anchoredPosition = new Vector2(0f, -(CastFace + 1f));
|
||||
nameRt.sizeDelta = new Vector2(CastSlotW, 9f);
|
||||
RectTransform relRt = relation.rectTransform;
|
||||
relRt.anchoredPosition = new Vector2(CastFace + 2f, -9f);
|
||||
relRt.sizeDelta = new Vector2(CastSlotW - CastFace - 3f, 9f);
|
||||
relRt.anchoredPosition = new Vector2(0f, -(CastFace + 9f));
|
||||
relRt.sizeDelta = new Vector2(CastSlotW, 9f);
|
||||
|
||||
root.SetActive(false);
|
||||
return new CastSlot
|
||||
|
|
@ -472,8 +405,10 @@ public static class LifeSagaPanel
|
|||
private static void BindCast(CastSlot slot, LifeSagaCastMember member)
|
||||
{
|
||||
slot.UnitId = member.UnitId;
|
||||
slot.Name.text = FirstNonEmpty(member.Name, "Someone");
|
||||
slot.Relation.text = FirstNonEmpty(member.Relation, "Linked");
|
||||
string name = string.IsNullOrEmpty(member.Name) ? "Someone" : member.Name;
|
||||
string rel = string.IsNullOrEmpty(member.Relation) ? "Linked" : member.Relation;
|
||||
FitCastText(slot.Name, name);
|
||||
FitCastText(slot.Relation, rel);
|
||||
|
||||
Actor live = member.Alive ? EventFeedUtil.FindAliveById(member.UnitId) : null;
|
||||
Sprite sprite = HudIcons.FromActorRailFace(live, member.SpeciesId)
|
||||
|
|
@ -482,67 +417,44 @@ public static class LifeSagaPanel
|
|||
slot.Face.sprite = sprite;
|
||||
slot.Face.enabled = sprite != null;
|
||||
slot.Face.color = sprite != null ? Color.white : new Color(0.35f, 0.36f, 0.4f, 1f);
|
||||
slot.FaceBg.color = member.Alive
|
||||
? new Color(0.12f, 0.13f, 0.16f, 0.95f)
|
||||
: new Color(0.18f, 0.12f, 0.12f, 0.95f);
|
||||
slot.Root.SetActive(true);
|
||||
}
|
||||
|
||||
private static void BindEmptyPickState()
|
||||
/// <summary>The live Beat already explains the person currently on screen.</summary>
|
||||
private static bool CastDuplicatesCurrentBeat(LifeSagaCastMember member)
|
||||
{
|
||||
_boundId = 0;
|
||||
_speciesId = "";
|
||||
_fingerprint = "empty-pick";
|
||||
_ownsAvatar = false;
|
||||
if (_eyebrowText != null)
|
||||
{
|
||||
_eyebrowText.text = "";
|
||||
}
|
||||
|
||||
if (_nameText != null)
|
||||
{
|
||||
_nameText.text = "Pick a life";
|
||||
}
|
||||
|
||||
if (_titleText != null)
|
||||
{
|
||||
_titleText.text = "from the rail";
|
||||
}
|
||||
|
||||
if (_stakeText != null)
|
||||
{
|
||||
_stakeText.text = "Hover or click a glyph to read their saga.";
|
||||
}
|
||||
|
||||
if (_legacyText != null)
|
||||
{
|
||||
_legacyText.text = "";
|
||||
}
|
||||
|
||||
for (int i = 0; i < CastSlots.Length; i++)
|
||||
{
|
||||
if (CastSlots[i]?.Root != null)
|
||||
{
|
||||
CastSlots[i].Root.SetActive(false);
|
||||
CastSlots[i].UnitId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
DossierAvatar.SetHostVisible(false);
|
||||
Layout();
|
||||
if (_root != null)
|
||||
{
|
||||
_root.SetActive(true);
|
||||
}
|
||||
string beat = WatchCaption.VisibleBeatLine ?? "";
|
||||
return member != null
|
||||
&& !string.IsNullOrEmpty(member.Name)
|
||||
&& beat.IndexOf(member.Name, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static Text MakeLabel(Transform parent, string name, int size, Color color, FontStyle style)
|
||||
private static void FitCastText(Text text, string value)
|
||||
{
|
||||
int fitSize = size < 7 ? 7 : size;
|
||||
if (text == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
text.text = value ?? "";
|
||||
text.resizeTextForBestFit = true;
|
||||
text.resizeTextMinSize = 6;
|
||||
text.resizeTextMaxSize = BodyFont;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
}
|
||||
|
||||
private static Text MakeLabel(Transform parent, string name, int fontSize, Color color, FontStyle style)
|
||||
{
|
||||
int fitSize = fontSize < 7 ? 7 : fontSize;
|
||||
Text text = HudCanvas.MakeText(parent, name, "", fitSize);
|
||||
text.color = color;
|
||||
text.fontStyle = style;
|
||||
text.fontSize = fitSize;
|
||||
text.resizeTextForBestFit = false;
|
||||
text.alignment = TextAnchor.UpperLeft;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||||
text.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
text.raycastTarget = false;
|
||||
RectTransform rt = text.rectTransform;
|
||||
|
|
@ -585,8 +497,8 @@ public static class LifeSagaPanel
|
|||
private static string Fingerprint(LifeSagaViewModel model)
|
||||
{
|
||||
var sb = new StringBuilder(192);
|
||||
sb.Append(model.UnitId).Append('|').Append(model.Name).Append('|').Append(model.Title);
|
||||
sb.Append('|').Append(model.LensEyebrow).Append('|').Append(model.StakeLine);
|
||||
sb.Append(model.UnitId).Append('|').Append(model.StakeLine)
|
||||
.Append('|').Append(WatchCaption.VisibleBeatLine);
|
||||
for (int i = 0; i < model.Cast.Count; i++)
|
||||
{
|
||||
var c = model.Cast[i];
|
||||
|
|
@ -609,41 +521,4 @@ public static class LifeSagaPanel
|
|||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string StripStar(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return name.StartsWith("★ ") ? name.Substring(2) : name;
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string[] parts)
|
||||
{
|
||||
if (parts == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(parts[i]))
|
||||
{
|
||||
return parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string Short(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return s.Length <= 28 ? s : s.Substring(0, 27) + "…";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,10 +49,10 @@ public static class LifeSagaPresentation
|
|||
model.PrimaryLens = primary;
|
||||
model.SecondaryLens = secondary;
|
||||
|
||||
LifeSagaFact stake = LifeSagaMemory.StrongestUnresolved(unitId);
|
||||
LifeSagaFact stake = StrongestDisplayStake(unitId, model.Title);
|
||||
model.StakeLine = stake != null
|
||||
? LifeSagaMemory.FormatFactLine(stake)
|
||||
: BuildFallbackStake(slot, actor, memory);
|
||||
: (string.IsNullOrEmpty(model.Title) ? BuildFallbackStake(slot, actor, memory) : "");
|
||||
model.StakeProvenance = stake != null ? "observed" : (string.IsNullOrEmpty(model.StakeLine) ? "" : "is");
|
||||
|
||||
BuildCast(model, slot, actor, memory, primary);
|
||||
|
|
@ -61,6 +61,42 @@ public static class LifeSagaPresentation
|
|||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the strongest unresolved pressure that adds information beyond Identity.
|
||||
/// Current-role/founding facts belong in the title, not a duplicate tooltip stake.
|
||||
/// </summary>
|
||||
private static LifeSagaFact StrongestDisplayStake(long unitId, string title)
|
||||
{
|
||||
LifeSagaFact best = null;
|
||||
var facts = LifeSagaMemory.FactsFor(unitId);
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
LifeSagaFact fact = facts[i];
|
||||
if (fact == null || fact.Resolved || !LifeSagaMemory.IsUnresolvedStakeCandidate(fact))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool repeatsIdentity = SagaProse.RoleChangeEchoesTitle(fact, title)
|
||||
|| (fact.Kind == LifeSagaFactKind.Founding
|
||||
&& !string.IsNullOrEmpty(title)
|
||||
&& title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
if (repeatsIdentity)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best == null
|
||||
|| fact.Strength > best.Strength
|
||||
|| (Mathf.Approximately(fact.Strength, best.Strength) && fact.At > best.At))
|
||||
{
|
||||
best = fact;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Compatibility plain text for harness diagnostics.</summary>
|
||||
public static string BuildPlainText(long unitId)
|
||||
{
|
||||
|
|
@ -359,73 +395,32 @@ public static class LifeSagaPresentation
|
|||
|
||||
private static string BuildTitle(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
|
||||
{
|
||||
if (slot != null)
|
||||
{
|
||||
if (slot.IsKing)
|
||||
{
|
||||
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
|
||||
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
|
||||
}
|
||||
|
||||
if (slot.IsClanChief) return "Clan chief";
|
||||
if (slot.IsLeader)
|
||||
{
|
||||
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
|
||||
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
|
||||
}
|
||||
|
||||
if (slot.IsAlpha) return "Pack alpha";
|
||||
if (slot.IsArmyCaptain) return "Army captain";
|
||||
if (slot.IsFounder) return "Founder";
|
||||
if (slot.IsPlotAuthor) return "Plotter";
|
||||
if (slot.IsSingleton) return "Last of their kind";
|
||||
}
|
||||
|
||||
if (memory != null)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count; i++)
|
||||
{
|
||||
if (memory.Facts[i]?.Kind == LifeSagaFactKind.Founding)
|
||||
{
|
||||
return "Founder";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "A life in motion";
|
||||
return SagaProse.Title(slot, actor, memory);
|
||||
}
|
||||
|
||||
private static string BuildFallbackStake(LifeSagaSlot slot, Actor actor, LifeSagaLifeMemory memory)
|
||||
{
|
||||
if (LifeSagaMemory.TryGetEarnedRival(slot?.UnitId ?? 0, out LifeSagaFact rival))
|
||||
{
|
||||
return LifeSagaMemory.FormatFactLine(rival);
|
||||
return SagaProse.StakeLine(rival);
|
||||
}
|
||||
|
||||
if (slot != null)
|
||||
{
|
||||
switch (slot.AdmissionReason)
|
||||
string roleStake = SagaProse.RoleFallbackStake(slot.AdmissionReason, slot, actor);
|
||||
if (!string.IsNullOrEmpty(roleStake))
|
||||
{
|
||||
case LifeSagaAdmissionReason.King:
|
||||
return "Carries a kingdom's fate";
|
||||
case LifeSagaAdmissionReason.Alpha:
|
||||
return "Holds the pack together";
|
||||
case LifeSagaAdmissionReason.PlotAuthor:
|
||||
return "A scheme is still unfolding";
|
||||
case LifeSagaAdmissionReason.Singleton:
|
||||
return "Their lineage hangs by a thread";
|
||||
case LifeSagaAdmissionReason.Founder:
|
||||
return "What they built still needs defending";
|
||||
return roleStake;
|
||||
}
|
||||
}
|
||||
|
||||
if (actor != null && TryGetLikelySuccessor(actor, out Actor heir, out _))
|
||||
{
|
||||
return "Succession points toward " + SafeName(heir);
|
||||
return SagaProse.Sentence("Succession points toward " + SafeName(heir));
|
||||
}
|
||||
|
||||
return memory != null && memory.Facts.Count > 0
|
||||
? LifeSagaMemory.FormatFactLine(memory.Facts[0])
|
||||
? SagaProse.FormatFactLine(memory.Facts[0])
|
||||
: "";
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +437,7 @@ public static class LifeSagaPresentation
|
|||
|
||||
void AddLive(Actor other, string label, string truth)
|
||||
{
|
||||
if (other == null || model.Cast.Count >= 4)
|
||||
if (other == null || model.Cast.Count >= LifeSagaPanel.MaxCast)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -466,7 +461,7 @@ public static class LifeSagaPresentation
|
|||
|
||||
void AddObserved(LifeSagaIdentity identity, string label, string truth)
|
||||
{
|
||||
if (identity.Id == 0 || model.Cast.Count >= 4 || !seen.Add(identity.Id))
|
||||
if (identity.Id == 0 || model.Cast.Count >= LifeSagaPanel.MaxCast || !seen.Add(identity.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -485,41 +480,47 @@ public static class LifeSagaPresentation
|
|||
|
||||
if (actor != null)
|
||||
{
|
||||
AddLive(ActorRelation.GetLover(actor), "Lover", "is");
|
||||
AddLive(ActorRelation.GetBestFriend(actor), "Best friend", "is");
|
||||
AddLive(ActorRelation.GetLover(actor), SagaProse.CastLabelLive("lover"), "is");
|
||||
AddLive(ActorRelation.GetBestFriend(actor), SagaProse.CastLabelLive("best friend"), "is");
|
||||
foreach (Actor parent in ActorRelation.EnumerateParents(actor, 2))
|
||||
{
|
||||
AddLive(parent, "Parent", "is");
|
||||
AddLive(parent, SagaProse.CastLabelLive("parent"), "is");
|
||||
}
|
||||
|
||||
foreach (Actor child in ActorRelation.EnumerateChildren(actor, 2))
|
||||
{
|
||||
AddLive(child, "Child", "is");
|
||||
AddLive(child, SagaProse.CastLabelLive("child"), "is");
|
||||
}
|
||||
|
||||
if (TryGetLikelySuccessor(actor, out Actor heir, out _))
|
||||
{
|
||||
AddLive(heir, "Likely successor", "likely");
|
||||
AddLive(heir, SagaProse.CastLabelLive("heir"), "likely");
|
||||
}
|
||||
}
|
||||
|
||||
if (LifeSagaMemory.TryGetEarnedRival(model.UnitId, out LifeSagaFact rivalFact))
|
||||
{
|
||||
string foeLabel = SagaProse.CastLabel(rivalFact, EventFeedUtil.FindAliveById(rivalFact.OtherId));
|
||||
if (string.IsNullOrEmpty(foeLabel))
|
||||
{
|
||||
foeLabel = SagaProse.TitleLabel("Old Foe");
|
||||
}
|
||||
|
||||
Actor liveRival = EventFeedUtil.FindAliveById(rivalFact.OtherId);
|
||||
if (liveRival != null)
|
||||
{
|
||||
AddLive(liveRival, "Earned rival", "observed");
|
||||
AddLive(liveRival, foeLabel, "observed");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddObserved(rivalFact.Other, "Earned rival", "observed");
|
||||
AddObserved(rivalFact.Other, foeLabel, "observed");
|
||||
}
|
||||
}
|
||||
|
||||
// Former bonds from memory when live cast still has room.
|
||||
if (memory != null && model.Cast.Count < 4)
|
||||
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
|
||||
{
|
||||
LifeSagaFact f = memory.Facts[i];
|
||||
if (f == null || f.OtherId == 0 || seen.Contains(f.OtherId))
|
||||
|
|
@ -529,19 +530,19 @@ public static class LifeSagaPresentation
|
|||
|
||||
if (f.Kind == LifeSagaFactKind.BondBroken)
|
||||
{
|
||||
AddObserved(f.Other, "Lost lover", "observed");
|
||||
AddObserved(f.Other, SagaProse.CastLabelLive("once loved"), "observed");
|
||||
}
|
||||
else if (f.Kind == LifeSagaFactKind.ParentChild && EventFeedUtil.FindAliveById(f.OtherId) == null)
|
||||
{
|
||||
AddObserved(f.Other, "Child", "observed");
|
||||
AddObserved(f.Other, SagaProse.CastLabelLive("child"), "observed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Living Others from unresolved war/plot joins.
|
||||
if (memory != null && model.Cast.Count < 4)
|
||||
if (memory != null && model.Cast.Count < LifeSagaPanel.MaxCast)
|
||||
{
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < 4; i++)
|
||||
for (int i = 0; i < memory.Facts.Count && model.Cast.Count < LifeSagaPanel.MaxCast; i++)
|
||||
{
|
||||
LifeSagaFact f = memory.Facts[i];
|
||||
if (f == null || f.Resolved || f.OtherId == 0 || seen.Contains(f.OtherId))
|
||||
|
|
@ -557,26 +558,28 @@ public static class LifeSagaPresentation
|
|||
|
||||
if (f.Kind == LifeSagaFactKind.WarJoin)
|
||||
{
|
||||
AddLive(liveOther, "War foe", "observed");
|
||||
AddLive(liveOther, SagaProse.TitleLabel("War Enemy"), "observed");
|
||||
}
|
||||
else if (f.Kind == LifeSagaFactKind.PlotJoin)
|
||||
{
|
||||
AddLive(liveOther, "Plot ally", "observed");
|
||||
AddLive(liveOther, SagaProse.TitleLabel("Plot Partner"), "observed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Family peers fill remaining slots for crown/combat/plot/pack/bond lenses.
|
||||
if (actor != null
|
||||
&& model.Cast.Count < 4
|
||||
&& model.Cast.Count < LifeSagaPanel.MaxCast
|
||||
&& (primary == LifeSagaLens.PackAlpha
|
||||
|| primary == LifeSagaLens.LoveGrief
|
||||
|| primary == LifeSagaLens.CrownClan
|
||||
|| primary == LifeSagaLens.WarriorKiller
|
||||
|| primary == LifeSagaLens.Plotter))
|
||||
{
|
||||
string peerLabel = primary == LifeSagaLens.PackAlpha ? "Packmate" : "Kin";
|
||||
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: 8))
|
||||
string peerLabel = primary == LifeSagaLens.PackAlpha
|
||||
? SagaProse.CastLabelLive("packmate")
|
||||
: SagaProse.CastLabelLive("kin");
|
||||
foreach (Actor peer in ActorRelation.EnumerateFamilyMembers(actor, max: LifeSagaPanel.MaxCast + 2))
|
||||
{
|
||||
if (peer == null || peer == actor)
|
||||
{
|
||||
|
|
@ -584,7 +587,7 @@ public static class LifeSagaPresentation
|
|||
}
|
||||
|
||||
AddLive(peer, peerLabel, "is");
|
||||
if (model.Cast.Count >= 4)
|
||||
if (model.Cast.Count >= LifeSagaPanel.MaxCast)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
|
@ -640,6 +643,9 @@ public static class LifeSagaPresentation
|
|||
int parentedCount = LifeSagaMemory.CountParentedChildren(unitId);
|
||||
// Many children: prefer one lineage summary over named birth crumbs.
|
||||
bool preferFamilySummary = parentedCount >= 3;
|
||||
// Reserve the final Legacy slot for that summary; otherwise a death/bond/war
|
||||
// burst can fill all four slots and make the lineage disappear nondeterministically.
|
||||
int factLimit = parentedCount >= 2 ? 3 : 4;
|
||||
bool wroteParentChild = false;
|
||||
|
||||
List<LifeSagaFact> facts = LifeSagaMemory.SelectLegacy(unitId, 8);
|
||||
|
|
@ -667,6 +673,21 @@ public static class LifeSagaPresentation
|
|||
continue;
|
||||
}
|
||||
|
||||
// Current role is already stated in Identity (for example
|
||||
// "Adisolf · King of Nuanand"). Do not repeat "Became King" below it.
|
||||
if (f.Kind == LifeSagaFactKind.RoleChange
|
||||
&& SagaProse.RoleChangeEchoesTitle(f, model.Title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.Kind == LifeSagaFactKind.Founding
|
||||
&& !string.IsNullOrEmpty(model.Title)
|
||||
&& model.Title.IndexOf("founder", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.Kind == LifeSagaFactKind.ParentChild)
|
||||
{
|
||||
if (preferFamilySummary || wroteParentChild)
|
||||
|
|
@ -697,7 +718,7 @@ public static class LifeSagaPresentation
|
|||
wroteParentChild = true;
|
||||
}
|
||||
|
||||
if (model.Legacy.Count >= 4)
|
||||
if (model.Legacy.Count >= factLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ using UnityEngine.UI;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Dossier rail of life-saga MCs as a horizontal strip under the tabs.
|
||||
/// Click toggles Prefer (and pins Saga subject while on Saga). Hover previews.
|
||||
/// Dossier rail of life-saga MCs as a horizontal strip at the top of caption chrome.
|
||||
/// Click toggles Prefer only. Hover previews Cast/Legacy depth under the caption.
|
||||
/// </summary>
|
||||
public static class LifeSagaRail
|
||||
{
|
||||
|
|
@ -302,7 +302,7 @@ public static class LifeSagaRail
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Place the horizontal rail under the tabs (stable top chrome).
|
||||
/// Place the horizontal rail at the top of caption chrome (stable under hover).
|
||||
/// Returns the next y below the rail.
|
||||
/// </summary>
|
||||
public static float Place(float y, float padX)
|
||||
|
|
@ -318,16 +318,43 @@ public static class LifeSagaRail
|
|||
|
||||
internal static void ShowHover(int index)
|
||||
{
|
||||
if (index < 0)
|
||||
{
|
||||
index = 0;
|
||||
}
|
||||
|
||||
if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
|
||||
{
|
||||
// Rail glyphs can be empty after a Clear while roster still has MCs
|
||||
// (fingerprint early-out). Force a structural rebuild once.
|
||||
_fingerprint = "";
|
||||
Refresh();
|
||||
}
|
||||
|
||||
if (index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
|
||||
{
|
||||
for (int i = 0; i < Slots.Length; i++)
|
||||
{
|
||||
if (Slots[i] != null && Slots[i].UnitId != 0)
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (index < 0 || index >= Slots.Length || Slots[index] == null || Slots[index].UnitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaViewController.BeginHoverPreview(Slots[index].UnitId);
|
||||
LastHoverHeight = LifeSagaPanel.BodyHeight;
|
||||
LastHoverHeight = Mathf.Max(LifeSagaPanel.BodyHeight, BodyHeightMinFallback);
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
private const float BodyHeightMinFallback = 1f;
|
||||
|
||||
internal static void HideHover()
|
||||
{
|
||||
LifeSagaViewController.EndHoverPreview();
|
||||
|
|
@ -577,12 +604,6 @@ public static class LifeSagaRail
|
|||
}
|
||||
|
||||
LifeSagaRoster.TogglePrefer(id);
|
||||
if (LifeSagaViewController.SelectedTab == LifeSagaViewController.Tab.Saga
|
||||
|| (LifeSagaViewController.EffectiveTab == LifeSagaViewController.Tab.Saga
|
||||
&& !LifeSagaViewController.IsHoverPreview))
|
||||
{
|
||||
LifeSagaViewController.SetPersistentSaga(id);
|
||||
}
|
||||
|
||||
_fingerprint = "";
|
||||
LifeSagaRoster.Dirty = true;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ using UnityEngine;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Owns Dossier/Saga tab selection, hover preview, and camera read-pause lease.
|
||||
/// Owns hover preview and camera read-pause lease for the life-saga rail.
|
||||
/// AFK chrome is always character beat caption (Identity + Beat + Context);
|
||||
/// the saga panel is hover depth only (no Dossier/Saga tabs).
|
||||
/// </summary>
|
||||
public static class LifeSagaViewController
|
||||
{
|
||||
|
|
@ -12,129 +14,71 @@ public static class LifeSagaViewController
|
|||
/// <summary>Only used if an exit was deferred; glyph leave finishes immediately.</summary>
|
||||
public const float HoverExitGraceSeconds = 0.05f;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility enum for harness asserts. AFK chrome never pins Saga;
|
||||
/// <see cref="EffectiveTab"/> is Saga only while a hover preview is active.
|
||||
/// </summary>
|
||||
public enum Tab
|
||||
{
|
||||
Dossier = 0,
|
||||
Saga = 1
|
||||
}
|
||||
|
||||
private static Tab _selectedTab = Tab.Dossier;
|
||||
private static long _persistentSagaId;
|
||||
private static bool _persistentPinned;
|
||||
private static long _hoverPreviewId;
|
||||
private static Tab _tabBeforeHover = Tab.Dossier;
|
||||
private static float _hoverExitAt = -1f;
|
||||
private static bool _pauseHeld;
|
||||
private static bool _loreReturnToSaga;
|
||||
private static long _loreReturnSagaId;
|
||||
|
||||
public static Tab SelectedTab => _selectedTab;
|
||||
public static long PersistentSagaId => _persistentSagaId;
|
||||
public static Tab SelectedTab => Tab.Dossier;
|
||||
public static long PersistentSagaId => 0;
|
||||
public static long HoverPreviewId => _hoverPreviewId;
|
||||
public static bool IsHoverPreview => _hoverPreviewId != 0;
|
||||
|
||||
/// <summary>Saga while hovering a rail glyph; otherwise Dossier (caption body).</summary>
|
||||
public static Tab EffectiveTab =>
|
||||
_hoverPreviewId != 0 ? Tab.Saga : _selectedTab;
|
||||
_hoverPreviewId != 0 ? Tab.Saga : Tab.Dossier;
|
||||
|
||||
public static long EffectiveSagaId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hoverPreviewId != 0)
|
||||
{
|
||||
return _hoverPreviewId;
|
||||
}
|
||||
|
||||
return ResolvePersistentSagaId();
|
||||
}
|
||||
}
|
||||
public static long EffectiveSagaId => _hoverPreviewId;
|
||||
|
||||
/// <summary>
|
||||
/// Harness/compat: Dossier cancels hover. Saga begins hover preview of the watched MC
|
||||
/// when on the roster (no persistent pin / empty pick chrome).
|
||||
/// </summary>
|
||||
public static void SelectTab(Tab tab)
|
||||
{
|
||||
// Explicit tab click wins over hover preview restore.
|
||||
if (_hoverPreviewId != 0 || _pauseHeld)
|
||||
if (tab == Tab.Dossier)
|
||||
{
|
||||
CancelPreviewAndPause();
|
||||
_tabBeforeHover = tab;
|
||||
}
|
||||
|
||||
_selectedTab = tab;
|
||||
if (tab == Tab.Saga)
|
||||
{
|
||||
long watch = WatchedMcId();
|
||||
if (watch != 0)
|
||||
{
|
||||
_persistentSagaId = watch;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
else if (!IsValidMc(_persistentSagaId) || !_persistentPinned)
|
||||
{
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
}
|
||||
}
|
||||
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
public static void SetPersistentSaga(long unitId)
|
||||
{
|
||||
if (unitId == 0 || !LifeSagaRoster.IsMc(unitId))
|
||||
{
|
||||
WatchCaption.RequestRelayout();
|
||||
return;
|
||||
}
|
||||
|
||||
_persistentSagaId = unitId;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
|
||||
/// <summary>Stash Saga return target before opening Lore from the Saga panel.</summary>
|
||||
public static void StashLoreReturn(long unitId)
|
||||
{
|
||||
_loreReturnToSaga = true;
|
||||
_loreReturnSagaId = unitId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After Lore closes, restore Saga tab + subject when a stash is present.
|
||||
/// Returns true when a restore was applied.
|
||||
/// </summary>
|
||||
public static bool TryRestoreAfterLore()
|
||||
{
|
||||
if (!_loreReturnToSaga)
|
||||
long watch = WatchedMcId();
|
||||
if (watch != 0)
|
||||
{
|
||||
return false;
|
||||
BeginHoverPreview(watch);
|
||||
return;
|
||||
}
|
||||
|
||||
long id = _loreReturnSagaId;
|
||||
_loreReturnToSaga = false;
|
||||
_loreReturnSagaId = 0;
|
||||
|
||||
CancelPreviewAndPause();
|
||||
_selectedTab = Tab.Saga;
|
||||
_tabBeforeHover = Tab.Saga;
|
||||
if (IsValidMc(id))
|
||||
{
|
||||
_persistentSagaId = id;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
long watch = WatchedMcId();
|
||||
if (watch != 0)
|
||||
{
|
||||
_persistentSagaId = watch;
|
||||
_persistentPinned = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
}
|
||||
}
|
||||
|
||||
WatchCaption.RequestRelayout();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>No-op: Prefer click no longer pins a persistent Saga subject.</summary>
|
||||
public static void SetPersistentSaga(long unitId)
|
||||
{
|
||||
_ = unitId;
|
||||
}
|
||||
|
||||
/// <summary>No-op: Lore return no longer restores a Saga tab.</summary>
|
||||
public static void StashLoreReturn(long unitId)
|
||||
{
|
||||
_ = unitId;
|
||||
}
|
||||
|
||||
/// <summary>No-op: Lore close leaves caption chrome as-is.</summary>
|
||||
public static bool TryRestoreAfterLore()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void BeginHoverPreview(long unitId)
|
||||
|
|
@ -145,11 +89,6 @@ public static class LifeSagaViewController
|
|||
}
|
||||
|
||||
_hoverExitAt = -1f;
|
||||
if (_hoverPreviewId == 0)
|
||||
{
|
||||
_tabBeforeHover = _selectedTab;
|
||||
}
|
||||
|
||||
_hoverPreviewId = unitId;
|
||||
if (!_pauseHeld)
|
||||
{
|
||||
|
|
@ -168,7 +107,7 @@ public static class LifeSagaViewController
|
|||
}
|
||||
|
||||
// Relayout / glyph→glyph can fire Exit while the pointer is still on a glyph.
|
||||
// Keep the preview in that case; otherwise restore Dossier immediately.
|
||||
// Keep the preview in that case; otherwise restore caption body immediately.
|
||||
if (LifeSagaRail.IsPointerOverGlyph())
|
||||
{
|
||||
_hoverExitAt = -1f;
|
||||
|
|
@ -220,12 +159,6 @@ public static class LifeSagaViewController
|
|||
public static void Clear()
|
||||
{
|
||||
CancelPreviewAndPause();
|
||||
_selectedTab = Tab.Dossier;
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
_tabBeforeHover = Tab.Dossier;
|
||||
_loreReturnToSaga = false;
|
||||
_loreReturnSagaId = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -251,7 +184,6 @@ public static class LifeSagaViewController
|
|||
{
|
||||
_hoverExitAt = -1f;
|
||||
_hoverPreviewId = 0;
|
||||
_selectedTab = _tabBeforeHover;
|
||||
if (_pauseHeld)
|
||||
{
|
||||
InterestDirector.ReleaseReadPause(ReadPauseOwner);
|
||||
|
|
@ -261,37 +193,6 @@ public static class LifeSagaViewController
|
|||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watched MC wins when on roster. Otherwise only an explicitly pinned alive MC.
|
||||
/// Never falls back to first-roster / stale last-active filler.
|
||||
/// </summary>
|
||||
private static long ResolvePersistentSagaId()
|
||||
{
|
||||
long watch = WatchedMcId();
|
||||
if (watch != 0)
|
||||
{
|
||||
_persistentSagaId = watch;
|
||||
_persistentPinned = true;
|
||||
return watch;
|
||||
}
|
||||
|
||||
if (_persistentPinned && IsValidMc(_persistentSagaId))
|
||||
{
|
||||
return _persistentSagaId;
|
||||
}
|
||||
|
||||
_persistentSagaId = 0;
|
||||
_persistentPinned = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsValidMc(long unitId)
|
||||
{
|
||||
return unitId != 0
|
||||
&& LifeSagaRoster.IsMc(unitId)
|
||||
&& EventFeedUtil.FindAliveById(unitId) != null;
|
||||
}
|
||||
|
||||
private static long WatchedMcId()
|
||||
{
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
|
|
|
|||
1252
IdleSpectator/Story/SagaProse.cs
Normal file
1252
IdleSpectator/Story/SagaProse.cs
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.29.76",
|
||||
"version": "0.29.89",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ The camera follows **ranked events** from live feeds (wars, fights, relationship
|
|||
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
|
||||
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
|
||||
|
||||
While watching, a **dossier caption** shows who they are and an orange **event sentence** (why the camera is here), e.g. `Kaszus is fighting Waaaf`.
|
||||
See [`docs/event-reason.md`](docs/event-reason.md).
|
||||
While watching, a **character beat caption** shows who they are (Identity), an orange **event sentence** (Beat), and muted Context (story spine or open stake).
|
||||
Main characters get `Name · Title` when they have a real role; tips may gain a self-explanatory relation clause.
|
||||
See [`docs/life-saga.md`](docs/life-saga.md) and [`docs/event-reason.md`](docs/event-reason.md).
|
||||
|
||||
Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ Idle *quality* fails on ranking / ownership mismatch, not missing catalogs.
|
|||
Authored dials set strength / CreatesInterest / watch windows.
|
||||
They do not invent which buildings exist.
|
||||
4. **Layer B stays honest.**
|
||||
5. **Display compose is after WouldShow.**
|
||||
`CaptionComposer` Identity/Beat clauses never change whether a Label is presentable.
|
||||
Titles stay out of `InterestCandidate.Label`.
|
||||
Falls may still hit Activity / Life when useful.
|
||||
Demotion means "not camera A," not "erase from the world."
|
||||
5. **One invariant over many patches.**
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ Discrete / status / WorldLog: `CreatesInterest` = A gate.
|
|||
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
|
||||
|
||||
Orange dossier reason = the owning A event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
|
||||
`CaptionComposer` may append a presentation-only relation clause (` · their 3rd clash`) and show a separate Identity line (`Name · King of X`).
|
||||
Identity titles and stake slogans never enter `InterestCandidate.Label` - `identity_filter` / `IsIdentityWhy` stay event-first.
|
||||
The focused unit must be a tip principal:
|
||||
- Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff).
|
||||
- Pair-locked duels: PairOwner/PairPartner only.
|
||||
|
|
|
|||
|
|
@ -5,19 +5,24 @@ Chronicle stays a history book and never drives the camera.
|
|||
Short combat/love/war arcs stay in [`StoryPlanner`](../IdleSpectator/Story/StoryPlanner.cs) as chapter beats.
|
||||
Durable observed facts live in [`LifeSagaMemory`](../IdleSpectator/Story/LifeSagaMemory.cs) and survive roster churn.
|
||||
|
||||
AFK caption is a **character beat**: Identity (who) + Beat (live tip) + Context (spine or stake).
|
||||
[`CaptionComposer`](../IdleSpectator/Story/CaptionComposer.cs) merges saga identity into the live caption without baking titles into `InterestCandidate.Label`.
|
||||
[`SagaProse`](../IdleSpectator/Story/SagaProse.cs) owns all player-facing relation/stake English.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|--------|
|
||||
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
|
||||
| Durable observed facts | `LifeSagaMemory` |
|
||||
| Adaptive presentation | `LifeSagaPresentation` |
|
||||
| Dossier / Saga tabs + hover preview | `LifeSagaViewController` |
|
||||
| Saga body UI | `LifeSagaPanel` |
|
||||
| Adaptive presentation model | `LifeSagaPresentation` |
|
||||
| Player-facing saga/caption English | `SagaProse` |
|
||||
| Identity + Beat + Context caption | `CaptionComposer` → `WatchCaption` |
|
||||
| Hover Cast/Legacy depth | `LifeSagaViewController` + `LifeSagaPanel` |
|
||||
| Rail icons / Prefer click | `LifeSagaRail` |
|
||||
| Camera read-pause lease | `InterestDirector` |
|
||||
| Short climax → aftermath → epilogue | `StoryPlanner` (thinned) |
|
||||
| Spine under tip | `StoryPlanner.FormatSpineLabel` |
|
||||
| Context spine (`Kind · Phase`) | `StoryPlanner.FormatSpineLabel` (via ContextLine) |
|
||||
| Full history | Chronicle / Lore |
|
||||
|
||||
## Admission
|
||||
|
|
@ -55,35 +60,58 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
|
|||
- Soft-fill quiet still suppresses anonymous crumbs; Prefer/MC tips may break quiet.
|
||||
- Glyph hover acquires a camera read-pause lease: feeds/story/roster continue, but selection and focus repair are deferred until release.
|
||||
|
||||
## Character beat caption
|
||||
|
||||
Always-on AFK chrome (no Dossier/Saga tabs):
|
||||
|
||||
| Row | Source | MC | Nobody |
|
||||
|-----|--------|----|--------|
|
||||
| Identity | `CaptionComposer` | `Name · Title` (real roles only; else species/job) | `Name (Species/Job)` |
|
||||
| Beat | owned tip + `SagaProse.BeatClause` | tip enriched with one relation clause | tip as today |
|
||||
| Context | muted | spine if live short-arc; else unresolved stake hitch (omits kill/role/founding under love/rest tips; RoleChange that echoes Identity always omitted) | spine only |
|
||||
|
||||
Hard rule: saga titles and stake slogans never enter `InterestCandidate.Label`.
|
||||
Selection/`identity_filter` stay event-first.
|
||||
Beat other resolves `RelatedId` then `PairPartnerId` (no living-name parse).
|
||||
Status banners still own/suppress Beat; Identity + Context keep updating.
|
||||
Hover panel tracks the hovered MC; caption Identity/Beat/Context always track camera focus.
|
||||
|
||||
## Rail UX
|
||||
|
||||
- Up to 10 slots with live inspect-UI sprites (refreshed ~3x/sec for animation frames).
|
||||
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last).
|
||||
- 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 (does not mutate WorldBox unit favorite).
|
||||
- While on the **Saga** tab, click also pins that life as the persistent Saga subject (panel only; director bias stays Prefer/MC/cast).
|
||||
- Tabs sit above the cast rail; the rail is a **horizontal strip under the tabs** so Dossier↔Saga body swaps cannot move the glyphs.
|
||||
- **Dossier** shows the watched unit dossier; **Saga** shows the adaptive saga panel for the watched MC when on the roster.
|
||||
- If the watched unit is not an MC and nothing is pinned, Saga shows an empty *Pick a life from the rail* state (never a stale first-roster filler).
|
||||
- Hovering a glyph temporarily previews that life in the Saga body and pauses camera switching.
|
||||
- Leaving the glyph restores the prior tab (usually Dossier) immediately and releases the pause lease.
|
||||
- Hover-exit re-applies dossier reason/traits/status/history so the orange tip does not wait for a tip change.
|
||||
- Moving onto the panel or empty rail chrome does not keep the preview - only glyph hover does.
|
||||
- Relayout / glyph-to-glyph exits are ignored while the pointer is still over a glyph.
|
||||
- Clicking a tab during hover cancels the preview immediately and applies that tab.
|
||||
- Tabbed chrome locks a constant outer width so Dossier↔Saga hover cannot slide glyphs.
|
||||
- Saga body is a story card: lens eyebrow + name + title beside the portrait; stake, Cast, and Legacy wrap full-width under it.
|
||||
- No Evidence stats dump (kills/realm live on Dossier). No Open Lore button (press L).
|
||||
- Body height sizes to content up to a cap; Legacy beats stack as separate lines (no · soup).
|
||||
- Legacy prefers turning points (war, bond, role, kill, founding) over Cast-echo kin crumbs.
|
||||
- Click toggles saga Prefer only (does not mutate WorldBox unit favorite; does not pin a Saga subject).
|
||||
- Rail sits at the top of caption chrome (no tabs).
|
||||
- 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 never swaps the orange tip to the hovered life.
|
||||
- Panel is Cast + Legacy only (caption already owns name · title; no portrait / stake chrome).
|
||||
- Cast budget is 8 (two rows); Legacy prefers turning points over Cast-echo kin crumbs.
|
||||
- No Evidence stats dump. No Open Lore button (press L).
|
||||
- ParentChild is capped (one named beat, or a lineage summary when there are 3+ children).
|
||||
- Living Cast lover/friend/kin are not repeated as Legacy relationship lines.
|
||||
- Legacy lines may prefix the world date (`Month Year · beat`).
|
||||
- Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax.
|
||||
- Context spine (`Kind · Phase`) is hidden when the orange beat already names the same kind and phase is Climax.
|
||||
- Never show live task/status/trait rows or call a live attack target a rival.
|
||||
|
||||
## Earned rivals
|
||||
## Saga prose
|
||||
|
||||
`SagaProse` is the single voice owner. Evidence drives copy; 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.
|
||||
|
||||
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.
|
||||
|
||||
Capitalization:
|
||||
|
||||
- Identity titles / Cast labels: Title Case (`Pack Alpha`, `Old Foe`, `Best Friend`)
|
||||
- Beat clauses after ` · `: lowercase start
|
||||
- Stake / Legacy: Sentence case
|
||||
- Unit names and owned tip body: leave as-is
|
||||
|
||||
## Opposition facts (memory)
|
||||
|
||||
Notable only:
|
||||
|
||||
|
|
@ -91,21 +119,24 @@ Notable only:
|
|||
- Kin blood (`killed_kin` / `kin_slain`).
|
||||
- War / plot opposition (opposing principals on the same war/plot tip).
|
||||
- MC↔MC living rematches earn rivalry one encounter sooner; a single MC kill is crossover Legacy, not automatic RivalEarned.
|
||||
- Cast shows living credible rivals only; weak kill-cycle `repeated_encounters` facts are pruned.
|
||||
- Likely succession is omitted unless a game-authored heir API probe succeeds.
|
||||
- Unresolved `WarJoin` / `PlotJoin` Others fill Cast as `War foe` / `Plot ally` 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/rival.
|
||||
- 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.
|
||||
|
||||
## Stake
|
||||
|
||||
- Primary stake: strongest unresolved war/plot/rival/role/founding/grief (and McCrossover beats of those kinds).
|
||||
- Fallback stake when nothing stronger exists: unresolved `HardArcCombat`, open `BondFormed`, or McCrossover `Kill`.
|
||||
- War/plot prose names the opposing principal and kingdom/plot display when known.
|
||||
- War/plot prose names the opposing principal and kingdom/plot display when known (`Still at war with…`).
|
||||
- Role rises (king/leader/alpha/clan chief/army captain) emit `RoleChange` from live roster deltas.
|
||||
- Current role/founding facts live in Identity and are omitted from Context, Legacy, and rail tooltip stakes when they would only repeat the title.
|
||||
- Titleless MCs (hard-arc, no crown) keep species/job Identity; Context stake hitch is required when memory has unresolved facts.
|
||||
|
||||
## Cross-MC moments
|
||||
|
||||
When subject and other are both roster MCs, facts are flagged `McCrossover` (named stake/legacy prose + heat on both).
|
||||
Prose stays plain (`Bound with X`, `Slew X`) - no “saga hero” wording.
|
||||
Both rail glyphs light (Active + Involved) when the current tip touches multiple MCs.
|
||||
|
||||
## Soft-fill quiet (AFK hygiene)
|
||||
|
|
@ -120,17 +151,18 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
|
|||
- Roster role refresh runs on the ~3.5s amortized scan (not every frame). Soft heat does not force a world Refill.
|
||||
- Dirty Prefer / membership changes re-rank existing slots only; full world admission stays on the timed cadence.
|
||||
- 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 Saga/hover restores dossier rows once.
|
||||
- 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).
|
||||
- Rail face animation refreshes the Active glyph ~1/s; other glyphs stay static between structure changes.
|
||||
- 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`.
|
||||
|
||||
## Session
|
||||
|
||||
- `StoryPlanner.Clear` does not wipe the roster or saga memory.
|
||||
- Lore browse / brief idle-off keeps the roster and memory.
|
||||
- World replacement, map load-complete (`MapBox.finishingUpLoading`), `clearWorld`, harness batch end, and `interest_saga_clear` / `saga_session_reset` wipe roster + memory.
|
||||
- World replacement, map load-complete (`MapBox.finishingUpLoading`), `clearWorld`, harness batch end, and `interest_saga_clear` / `saga_session_reset` wipe roster + memory (+ `CaptionComposer.Clear`).
|
||||
- Bind key uses live `map_stats.name`, `current_world_seed_id`, `map_stats` identity, `_load_counter`, and map size - not world reference alone.
|
||||
- Same-map spectator re-enable does not clear when the bind key is unchanged.
|
||||
- Read-only soak dumps use `soak_probe` (or `snapshot` with `value=preserve`) and skip the batch-end wipe.
|
||||
|
|
@ -151,7 +183,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
|
|||
- `saga_prefer_cuts_ambient`
|
||||
- `saga_hard_arc_escalates`
|
||||
- `saga_overview_has_mc`
|
||||
- `saga_adaptive_dossier`
|
||||
- `saga_adaptive_dossier` (hover depth; no tabs / empty-pick)
|
||||
- `saga_memory_survives`
|
||||
- `saga_war_memory_both_sides`
|
||||
- `saga_plot_cast_members`
|
||||
|
|
@ -166,5 +198,7 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
|
|||
- `saga_rail_active_highlight`
|
||||
- `saga_rail_prefer_click`
|
||||
- `saga_soft_fill_no_pack`
|
||||
- `caption_character_beat`
|
||||
- `caption_prose_voice`
|
||||
|
||||
See also: [story-planner.md](story-planner.md).
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ There is **no** rail Commit / hard pin anymore.
|
|||
|
||||
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
|
||||
Click toggles Prefer (soft favorite).
|
||||
Dossier/Saga tabs and hover preview are owned by `LifeSagaViewController` + `LifeSagaPanel`.
|
||||
AFK caption is Identity + Beat + Context via `CaptionComposer` (see [life-saga.md](life-saga.md)).
|
||||
Cast/Legacy depth is rail hover only (`LifeSagaViewController` + `LifeSagaPanel`).
|
||||
Durable observed facts live in `LifeSagaMemory` (independent of ChronicleEnabled).
|
||||
Hover temporarily pauses camera switching without disabling Idle Spectator.
|
||||
See [life-saga.md](life-saga.md).
|
||||
|
|
@ -68,11 +69,13 @@ Phases: `Climax → Aftermath → Epilogue → Done`.
|
|||
## Dossier spine
|
||||
|
||||
While the **current watch tip** is the active arc's climax or story aftermath/epilogue beat,
|
||||
the dossier shows a muted line under the orange reason: `Kind · Phase`
|
||||
(e.g. `Duel · Aftermath`, `War front · Climax`).
|
||||
ContextLine shows a muted `Kind · Phase` (e.g. `Duel · Aftermath`, `War front · Climax`)
|
||||
via `StoryPlanner.FormatSpineLabel` → `CaptionComposer` (wins over stake hitch).
|
||||
|
||||
Hidden when idle is off, the dossier is hidden, a status banner owns the reason row,
|
||||
or the camera has left the story for an unrelated tip (cast membership alone is not enough).
|
||||
Also hidden when the orange beat already leads with the same Kind at Climax
|
||||
(`WatchCaption.FilterRedundantStorySpine` filters against the presentable beat before clause enrichment).
|
||||
|
||||
Love climax is outcome-only (`set_lover` / fallen-in-love). `find_lover` seeking stays camera-worthy
|
||||
but does not open a Love arc. Leaving a climax without aftermath ends the arc (`Done`).
|
||||
|
|
@ -253,8 +256,10 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
|||
|
||||
- `IdleSpectator/Story/StoryPlanner.cs` - short-arc ownership, cold-hook, inject, spine, crisis
|
||||
- `IdleSpectator/Story/LifeSagaRoster.cs` - durable MC cast + Prefer + chapter stamps
|
||||
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - saga hover + rail
|
||||
- `IdleSpectator/Story/SagaProse.cs` - player-facing relation/stake/title English
|
||||
- `IdleSpectator/Story/CaptionComposer.cs` - Identity + Beat + Context
|
||||
- `IdleSpectator/Story/LifeSagaOverview.cs` / `LifeSagaRail.cs` - rail Prefer + hover
|
||||
- `IdleSpectator/Story/CrisisChapter.cs` - parallel crisis chapter state
|
||||
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
|
||||
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
|
||||
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row
|
||||
- `IdleSpectator/WatchCaption.cs` - character beat caption + ContextLine
|
||||
|
|
|
|||
Loading…
Reference in a new issue