Life saga first pass
This commit is contained in:
parent
c23b2b9c1e
commit
798e3dce05
26 changed files with 6095 additions and 3027 deletions
|
|
@ -2864,6 +2864,92 @@ public static class AgentHarness
|
|||
Emit(cmd, ok: true, detail: "story_cleared");
|
||||
break;
|
||||
|
||||
case "interest_saga_clear":
|
||||
LifeSagaRoster.Clear();
|
||||
LifeSagaRail.Clear();
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: "saga_cleared");
|
||||
break;
|
||||
|
||||
case "interest_story_purge_leftovers":
|
||||
StoryPlanner.PurgeCloserLeftovers();
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"purged crisisActive={StoryPlanner.CrisisActive} softQuiet={StoryPlanner.SoftFillQuietActive}");
|
||||
break;
|
||||
|
||||
case "story_resume_parked":
|
||||
{
|
||||
var board = new System.Collections.Generic.List<StoryArc>(4);
|
||||
StoryPlanner.CopyBoard(board);
|
||||
string id = "";
|
||||
for (int i = 0; i < board.Count; i++)
|
||||
{
|
||||
if (board[i] != null && board[i].IsParked)
|
||||
{
|
||||
id = board[i].Id ?? "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool ok = !string.IsNullOrEmpty(id) && StoryPlanner.TryResume(id);
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok,
|
||||
detail:
|
||||
$"resumeOk={ok} id='{id}' watching='{StoryPlanner.Active?.Id}' board={StoryPlanner.BoardCount}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_prefer_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
bool ok = id != 0 && LifeSagaRoster.IsMc(id) && LifeSagaRoster.TogglePrefer(id);
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok,
|
||||
detail:
|
||||
$"preferToggle id={id} onRoster={LifeSagaRoster.IsMc(id)} prefer={LifeSagaRoster.IsPrefer(id)}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_force_admit_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
bool ok = LifeSagaRoster.HarnessForceAdmit(focus);
|
||||
if (ok)
|
||||
{
|
||||
LifeSagaRoster.StampChapter(id, "Harness chapter", Time.unscaledTime);
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"admit id={id} roster={LifeSagaRoster.Count} on={ok}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "interest_variety_note":
|
||||
{
|
||||
// Stamp the current (or pending needle in value) as the last shown variety arc.
|
||||
|
|
@ -3417,6 +3503,9 @@ public static class AgentHarness
|
|||
|
||||
if (Queue.Count == 0)
|
||||
{
|
||||
// Free AFK after harness must not inherit a leftover crisis closer tip.
|
||||
StoryPlanner.PurgeCloserLeftovers();
|
||||
LifeSagaRoster.Clear();
|
||||
string status = _assertFail == 0 && _cmdFail == 0 ? "PASS" : "FAIL";
|
||||
WriteLastResult(status, $"ok={_cmdOk} fail={_cmdFail} assert_pass={_assertPass} assert_fail={_assertFail}");
|
||||
LogService.LogInfo(
|
||||
|
|
@ -7758,7 +7847,118 @@ public static class AgentHarness
|
|||
string have = arc != null ? arc.Phase.ToString() : "None";
|
||||
pass = !string.IsNullOrEmpty(want)
|
||||
&& string.Equals(have, want, StringComparison.OrdinalIgnoreCase);
|
||||
detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold}";
|
||||
detail = $"storyPhase={have} want={want} kind={arc?.Kind} hard={arc?.HardHold} board={StoryPlanner.BoardCount}";
|
||||
break;
|
||||
}
|
||||
case "story_board_count":
|
||||
{
|
||||
int want = ParseInt(cmd.value, 0);
|
||||
int have = StoryPlanner.BoardCount;
|
||||
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
||||
if (cmp == "min")
|
||||
{
|
||||
pass = have >= want;
|
||||
}
|
||||
else if (cmp == "max")
|
||||
{
|
||||
pass = have <= want;
|
||||
}
|
||||
else
|
||||
{
|
||||
pass = have == want;
|
||||
}
|
||||
|
||||
detail = $"boardCount={have} want={want} cmp={cmp}";
|
||||
break;
|
||||
}
|
||||
case "story_parked_count":
|
||||
{
|
||||
int want = ParseInt(cmd.value, 0);
|
||||
var board = new System.Collections.Generic.List<StoryArc>(4);
|
||||
StoryPlanner.CopyBoard(board);
|
||||
int parked = 0;
|
||||
for (int i = 0; i < board.Count; i++)
|
||||
{
|
||||
if (board[i] != null && board[i].IsParked)
|
||||
{
|
||||
parked++;
|
||||
}
|
||||
}
|
||||
|
||||
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
||||
pass = cmp == "min" ? parked >= want : parked == want;
|
||||
detail = $"parked={parked} want={want} board={board.Count}";
|
||||
break;
|
||||
}
|
||||
case "browse_paused":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = SpectatorMode.BrowsePaused;
|
||||
pass = have == want;
|
||||
detail = $"browsePaused={have} want={want} idle={SpectatorMode.Active}";
|
||||
break;
|
||||
}
|
||||
case "saga_roster_count":
|
||||
case "story_rail_count":
|
||||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
LifeSagaRail.Refresh();
|
||||
int want = ParseInt(cmd.value, 0);
|
||||
int have = LifeSagaRoster.Count;
|
||||
int shown = LifeSagaRail.LastShownCount;
|
||||
string cmp = (cmd.label ?? "").Trim().ToLowerInvariant();
|
||||
string expectKey = (cmd.expect ?? "").Trim().ToLowerInvariant();
|
||||
int probe = expectKey == "story_rail_count" ? shown : have;
|
||||
pass = cmp == "min" ? probe >= want : probe == want;
|
||||
detail = $"roster={have} railShown={shown} want={want} cmp={cmp} visible={LifeSagaRail.Visible}";
|
||||
break;
|
||||
}
|
||||
case "saga_overview_tip":
|
||||
case "story_rail_tip":
|
||||
{
|
||||
string have = SampleRailTip();
|
||||
string any = (cmd.value ?? "").Trim();
|
||||
pass = false;
|
||||
if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any))
|
||||
{
|
||||
string[] parts = any.Split('|');
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string needle = (parts[i] ?? "").Trim();
|
||||
if (needle.Length > 0
|
||||
&& have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
pass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"sagaTip='{have}' any='{any}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "saga_overview_tip_not":
|
||||
case "story_rail_tip_not":
|
||||
{
|
||||
string have = SampleRailTip();
|
||||
string any = (cmd.value ?? "").Trim();
|
||||
pass = true;
|
||||
if (!string.IsNullOrEmpty(have) && !string.IsNullOrEmpty(any))
|
||||
{
|
||||
string[] parts = any.Split('|');
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string needle = (parts[i] ?? "").Trim();
|
||||
if (needle.Length > 0
|
||||
&& have.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
pass = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detail = $"railTip='{have}' not_any='{any}' pass={pass}";
|
||||
break;
|
||||
}
|
||||
case "crisis_active":
|
||||
|
|
@ -7770,6 +7970,14 @@ public static class AgentHarness
|
|||
detail = $"crisisActive={have} want={want} kind={crisis?.Kind} phase={crisis?.Phase}";
|
||||
break;
|
||||
}
|
||||
case "soft_fill_quiet":
|
||||
{
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = StoryPlanner.SoftFillQuietActive;
|
||||
pass = have == want;
|
||||
detail = $"softFillQuiet={have} want={want}";
|
||||
break;
|
||||
}
|
||||
case "crisis_kind":
|
||||
{
|
||||
string want = (cmd.value ?? "").Trim();
|
||||
|
|
@ -7894,11 +8102,21 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
case "ledger_has_focus":
|
||||
case "saga_has_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
pass = id != 0 && CharacterLedger.IsOnLedger(id);
|
||||
detail = $"ledger focus={SafeName(focus)} id={id} on={pass} heat={CharacterLedger.Heat(id):0.##}";
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
pass = id != 0 && LifeSagaRoster.IsMc(id);
|
||||
detail = $"saga focus={SafeName(focus)} id={id} on={pass} prefer={LifeSagaRoster.IsPrefer(id)}";
|
||||
break;
|
||||
}
|
||||
case "saga_prefer_focus_on":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
pass = id != 0 && LifeSagaRoster.IsPrefer(id);
|
||||
detail = $"prefer focus={SafeName(focus)} id={id} on={pass}";
|
||||
break;
|
||||
}
|
||||
case "tip_matches_unit":
|
||||
|
|
@ -11945,6 +12163,26 @@ public static class AgentHarness
|
|||
return defaultValue;
|
||||
}
|
||||
|
||||
private static string SampleRailTip()
|
||||
{
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
LifeSagaRail.Refresh();
|
||||
string have = LifeSagaRail.LastTipSample ?? "";
|
||||
if (!string.IsNullOrEmpty(have))
|
||||
{
|
||||
return have;
|
||||
}
|
||||
|
||||
var slots = new System.Collections.Generic.List<LifeSagaSlot>(10);
|
||||
LifeSagaRoster.CopySlots(slots);
|
||||
if (slots.Count > 0 && slots[0] != null)
|
||||
{
|
||||
return LifeSagaOverview.Build(slots[0]) ?? "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static bool ParseBool(string raw, bool defaultValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
|
|
|
|||
|
|
@ -888,7 +888,7 @@ public static class ChronicleHud
|
|||
if (pauseIdle && SpectatorMode.Active)
|
||||
{
|
||||
_pausedIdleForLore = true;
|
||||
SpectatorMode.SetActive(false, quiet: true);
|
||||
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
|
||||
}
|
||||
else if (pauseIdle)
|
||||
{
|
||||
|
|
@ -1015,12 +1015,12 @@ public static class ChronicleHud
|
|||
_followFocus = followFocus;
|
||||
_detailPane = DetailPane.Life;
|
||||
|
||||
// Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that
|
||||
// would instantly drop the camera off the browse subject.
|
||||
// Pause idle first: browsePause keeps StoryPlanner / registry for Lore close resume.
|
||||
// SetActive(false) still ClearFollow() so focus the browse subject after.
|
||||
if (pauseIdle && SpectatorMode.Active)
|
||||
{
|
||||
_pausedIdleForLore = true;
|
||||
SpectatorMode.SetActive(false, quiet: true);
|
||||
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
|
||||
}
|
||||
else if (pauseIdle)
|
||||
{
|
||||
|
|
@ -2283,7 +2283,7 @@ public static class ChronicleHud
|
|||
{
|
||||
if (SpectatorMode.Active)
|
||||
{
|
||||
SpectatorMode.SetActive(false, quiet: true);
|
||||
SpectatorMode.SetActive(false, quiet: true, browsePause: true);
|
||||
WatchCaption.PinWhilePaused();
|
||||
WatchCaption.ShowStatusBanner(
|
||||
captured.Kind == ChronicleKind.World || captured.IsLegend
|
||||
|
|
|
|||
|
|
@ -85,5 +85,25 @@ public static partial class EventCatalog
|
|||
IsFallback = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="disasterId"/> is an authored overlay or a live
|
||||
/// <see cref="AssetManager.disasters"/> asset (not tip-string heuristics).
|
||||
/// </summary>
|
||||
public static bool IsLiveOrAuthored(string disasterId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(disasterId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string id = disasterId.Trim();
|
||||
if (HasAuthored(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ActivityAssetCatalog.TryGetDisasterAsset(id) != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,9 +182,11 @@ public static partial class EventCatalog
|
|||
string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim();
|
||||
float strength = 40f;
|
||||
string category = "WorldLog";
|
||||
if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
// Live disaster library / authored disaster overlays - not tip-string matching.
|
||||
if (Disaster.IsLiveOrAuthored(id))
|
||||
{
|
||||
strength = 95f;
|
||||
DisasterInterestEntry d = Disaster.GetOrFallback(id);
|
||||
strength = d.EventStrength > 0f ? d.EventStrength : 95f;
|
||||
category = "Disaster";
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +204,23 @@ public static partial class EventCatalog
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>WorldLog / disaster tips classified as Disaster via catalog or live library.</summary>
|
||||
public static bool IsDisasterCategory(string assetId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryGet(assetId, out WorldLogEventEntry entry)
|
||||
&& string.Equals(entry.Category, "Disaster", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Disaster.IsLiveOrAuthored(assetId);
|
||||
}
|
||||
|
||||
public static bool TryGetEventStrength(string assetId, out float eventStrength)
|
||||
{
|
||||
WorldLogEventEntry entry = GetOrFallback(assetId);
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ internal static class HarnessScenarios
|
|||
return StoryDecisionIntentLive();
|
||||
case "story_aftermath_war":
|
||||
return StoryAftermathWar();
|
||||
case "story_crisis_hygiene":
|
||||
case "crisis_hygiene":
|
||||
return StoryCrisisHygiene();
|
||||
case "story_crisis_war":
|
||||
case "crisis_war":
|
||||
return StoryCrisisWar();
|
||||
|
|
@ -133,10 +136,27 @@ internal static class HarnessScenarios
|
|||
return StoryNearTie();
|
||||
case "story_arc_combat":
|
||||
return StoryArcCombat();
|
||||
case "story_lore_pause_keeps":
|
||||
case "lore_pause_keeps_story":
|
||||
return StoryLorePauseKeeps();
|
||||
case "story_board_park_resume":
|
||||
case "story_park_resume":
|
||||
return StoryBoardParkResume();
|
||||
case "story_arc_preempt_resume":
|
||||
return StoryArcPreemptResume();
|
||||
case "story_ledger_revisit":
|
||||
return StoryLedgerRevisit();
|
||||
// Retired with CharacterLedger - soft MC bias lives in saga_* scenarios.
|
||||
return SagaCameraPrefersMc();
|
||||
case "saga_roster_diversity":
|
||||
return SagaRosterDiversity();
|
||||
case "saga_camera_prefers_mc":
|
||||
return SagaCameraPrefersMc();
|
||||
case "saga_replace_on_death":
|
||||
return SagaReplaceOnDeath();
|
||||
case "saga_prefer_soft_bias":
|
||||
return SagaPreferSoftBias();
|
||||
case "saga_overview_has_mc":
|
||||
return SagaOverviewHasMc();
|
||||
case "story_spine_scoped":
|
||||
return StorySpineScoped();
|
||||
case "story_family_variety":
|
||||
|
|
@ -318,9 +338,17 @@ internal static class HarnessScenarios
|
|||
Nested("reg_combat_wiped_side", "combat_wiped_side"),
|
||||
Nested("reg_story_near_tie", "story_near_tie"),
|
||||
Nested("reg_story_arc", "story_arc_combat"),
|
||||
Nested("reg_story_lore_pause", "story_lore_pause_keeps"),
|
||||
Nested("reg_story_board_park", "story_board_park_resume"),
|
||||
Nested("reg_story_aftermath_war", "story_aftermath_war"),
|
||||
Nested("reg_story_crisis_hygiene", "story_crisis_hygiene"),
|
||||
Nested("reg_story_spine", "story_spine_scoped"),
|
||||
Nested("reg_story_family", "story_family_variety"),
|
||||
Nested("reg_story_ledger", "story_ledger_revisit"),
|
||||
Nested("reg_saga_camera", "saga_camera_prefers_mc"),
|
||||
Nested("reg_saga_prefer", "saga_prefer_soft_bias"),
|
||||
Nested("reg_saga_overview", "saga_overview_has_mc"),
|
||||
Nested("reg_saga_diversity", "saga_roster_diversity"),
|
||||
Nested("reg_saga_death", "saga_replace_on_death"),
|
||||
Nested("reg_discovery", "discovery"),
|
||||
Nested("reg_worldlog", "world_log"),
|
||||
Nested("reg_presentability", "camera_presentability"),
|
||||
|
|
@ -1702,11 +1730,57 @@ internal static class HarnessScenarios
|
|||
Step("saw24", "director_run", wait: 1.5f),
|
||||
Step("saw25", "assert", expect: "tip_matches_any", value: "lingers|stands over|war front"),
|
||||
Step("saw26", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
// Large war auto-opens a crisis; short-arc linger must quiet-close it (no dual closer).
|
||||
Step("saw27", "assert", expect: "crisis_active", value: "false"),
|
||||
Step("saw28", "assert", expect: "soft_fill_quiet", value: "true"),
|
||||
Step("saw29", "director_run", wait: 2.0f),
|
||||
Step("saw30", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"),
|
||||
Step("saw90", "fast_timing", value: "false"),
|
||||
Step("saw99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Large WarFront opens crisis; short-arc war linger owns the exit; purge leaves no
|
||||
/// pending epilogue_crisis for free AFK.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryCrisisHygiene()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sch0", "dismiss_windows"),
|
||||
Step("sch1", "wait_world"),
|
||||
Step("sch2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sch3", "fast_timing", value: "true"),
|
||||
Step("sch4", "spawn", asset: "human", count: 2),
|
||||
Step("sch5", "spectator", value: "off"),
|
||||
Step("sch6", "spectator", value: "on"),
|
||||
Step("sch7", "pick_unit", asset: "human"),
|
||||
Step("sch8", "focus", asset: "human"),
|
||||
Step("sch9", "interest_end_session"),
|
||||
Step("sch10", "interest_story_clear"),
|
||||
Step("sch20", "interest_war_session", asset: "human", expect: "sch_war"),
|
||||
Step("sch21", "director_run", wait: 0.8f),
|
||||
Step("sch22", "assert", expect: "tip_matches_any", value: "War -"),
|
||||
// Live 12v8 sides open crisis without harness force.
|
||||
Step("sch23", "assert", expect: "crisis_active", value: "true"),
|
||||
Step("sch24", "assert", expect: "crisis_kind", value: "War"),
|
||||
Step("sch30", "interest_expire_pending", value: ""),
|
||||
Step("sch31", "interest_mark_sticky_cold"),
|
||||
Step("sch32", "director_run", wait: 1.5f),
|
||||
Step("sch33", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
Step("sch34", "assert", expect: "crisis_active", value: "false"),
|
||||
Step("sch35", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"),
|
||||
Step("sch36", "assert", expect: "soft_fill_quiet", value: "true"),
|
||||
Step("sch40", "interest_story_purge_leftovers"),
|
||||
Step("sch41", "assert", expect: "crisis_active", value: "false"),
|
||||
Step("sch42", "director_run", wait: 1.0f),
|
||||
Step("sch43", "assert", expect: "caption_not_contains", value: "surveys the aftermath of the crisis"),
|
||||
Step("sch90", "fast_timing", value: "false"),
|
||||
Step("sch99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Large WarFront opens a crisis chapter; forced closer is epilogue_crisis.
|
||||
/// </summary>
|
||||
|
|
@ -2573,6 +2647,95 @@ internal static class HarnessScenarios
|
|||
}
|
||||
|
||||
/// <summary>Combat climax → aftermath ownership; soft ambient must not steal mid-aftermath.</summary>
|
||||
/// <summary>
|
||||
/// Lore browse freeze must not wipe StoryPlanner; close resumes the same climax.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryLorePauseKeeps()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("slp0", "dismiss_windows"),
|
||||
Step("slp1", "wait_world"),
|
||||
Step("slp2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("slp3", "fast_timing", value: "true"),
|
||||
Step("slp4", "spawn", asset: "human", count: 2),
|
||||
Step("slp5", "spectator", value: "off"),
|
||||
Step("slp6", "spectator", value: "on"),
|
||||
Step("slp7", "pick_unit", asset: "human"),
|
||||
Step("slp8", "focus", asset: "human"),
|
||||
Step("slp9", "interest_end_session"),
|
||||
Step("slp10", "interest_story_clear"),
|
||||
Step("slp20", "interest_war_session", asset: "human", expect: "slp_war"),
|
||||
Step("slp21", "director_run", wait: 0.6f),
|
||||
Step("slp22", "assert", expect: "tip_matches_any", value: "War -"),
|
||||
Step("slp23", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("slp24", "assert", expect: "story_board_count", value: "1", label: "min"),
|
||||
Step("slp30", "lore_open_focus"),
|
||||
Step("slp31", "assert", expect: "idle", value: "false"),
|
||||
Step("slp32", "assert", expect: "browse_paused", value: "true"),
|
||||
Step("slp33", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("slp34", "assert", expect: "story_board_count", value: "1", label: "min"),
|
||||
Step("slp40", "lore_close"),
|
||||
Step("slp41", "wait", wait: 0.25f),
|
||||
Step("slp42", "assert", expect: "idle", value: "true"),
|
||||
Step("slp43", "assert", expect: "browse_paused", value: "false"),
|
||||
Step("slp44", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("slp45", "assert", expect: "tip_matches_any", value: "War -"),
|
||||
Step("slp90", "fast_timing", value: "false"),
|
||||
Step("slp99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hard war storyline parks when forced off-story; returning to War tip resumes the board slot.
|
||||
/// </summary>
|
||||
private static List<HarnessCommand> StoryBoardParkResume()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sbp0", "dismiss_windows"),
|
||||
Step("sbp1", "wait_world"),
|
||||
Step("sbp2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sbp3", "fast_timing", value: "true"),
|
||||
Step("sbp4", "spawn", asset: "human", count: 2),
|
||||
Step("sbp5", "spectator", value: "off"),
|
||||
Step("sbp6", "spectator", value: "on"),
|
||||
Step("sbp7", "pick_unit", asset: "human"),
|
||||
Step("sbp8", "focus", asset: "human"),
|
||||
Step("sbp9", "interest_end_session"),
|
||||
Step("sbp10", "interest_story_clear"),
|
||||
Step("sbp20", "interest_war_session", asset: "human", expect: "sbp_war"),
|
||||
Step("sbp21", "director_run", wait: 0.6f),
|
||||
Step("sbp22", "assert", expect: "tip_matches_any", value: "War -"),
|
||||
Step("sbp23", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("sbp23b", "director_run", wait: 0.3f),
|
||||
// Force an unrelated tip (war hold blocks ordinary epic inject cut-ins).
|
||||
Step("sbp30", "interest_force_session", asset: "human",
|
||||
label: "ParkProbe", tier: "Epic", expect: "sbp_park_probe",
|
||||
value: "lead=event;evt=150"),
|
||||
Step("sbp31", "assert", expect: "tip_contains", value: "ParkProbe"),
|
||||
Step("sbp32", "assert", expect: "story_parked_count", value: "1", label: "min"),
|
||||
Step("sbp33", "assert", expect: "story_board_count", value: "1", label: "min"),
|
||||
// Resume parked short arc without rail Commit (rail is life-saga now).
|
||||
Step("sbp40", "story_resume_parked"),
|
||||
Step("sbp41", "assert", expect: "story_parked_count", value: "0"),
|
||||
Step("sbp42", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("sbp43", "assert", expect: "story_board_count", value: "1"),
|
||||
// A second distinct war theater parks the first and watches the new climax.
|
||||
Step("sbp50", "interest_force_session", asset: "human",
|
||||
label: "ParkProbe2", tier: "Epic", expect: "sbp_park_probe2",
|
||||
value: "lead=event;evt=150"),
|
||||
Step("sbp51", "assert", expect: "story_parked_count", value: "1", label: "min"),
|
||||
Step("sbp52", "interest_war_session", asset: "human", expect: "sbp_war2"),
|
||||
Step("sbp53", "director_run", wait: 1.0f),
|
||||
Step("sbp54", "assert", expect: "tip_matches_any", value: "War -"),
|
||||
Step("sbp55", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("sbp56", "assert", expect: "story_board_count", value: "2", label: "min"),
|
||||
Step("sbp90", "fast_timing", value: "false"),
|
||||
Step("sbp99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> StoryArcCombat()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
@ -2588,6 +2751,9 @@ internal static class HarnessScenarios
|
|||
Step("sac8", "pick_unit", asset: "human"),
|
||||
Step("sac9", "focus", asset: "human"),
|
||||
Step("sac10", "interest_end_session"),
|
||||
Step("sac10b", "interest_story_clear"),
|
||||
Step("sac10c", "interest_variety_clear"),
|
||||
Step("sac10d", "interest_expire_pending", value: ""),
|
||||
Step("sac20", "interest_combat_session", asset: "human", value: "wolf", expect: "sac_pack"),
|
||||
Step("sac20b", "combat_isolate_pair", value: "20"),
|
||||
Step("sac21", "status_apply", asset: "human", value: "invincible"),
|
||||
|
|
@ -2596,7 +2762,10 @@ internal static class HarnessScenarios
|
|||
Step("sac24", "assert", expect: "story_phase", value: "Climax"),
|
||||
Step("sac24b", "assert", expect: "story_spine", value: "Duel · Climax"),
|
||||
Step("sac25", "interest_expire_pending", value: ""),
|
||||
// Survivor aftermath requires a fallen theater partner (both-alive must not mint stands-over).
|
||||
Step("sac25b", "combat_kill_related"),
|
||||
Step("sac26", "age_current", wait: 10f),
|
||||
Step("sac26b", "interest_expire_pending", value: ""),
|
||||
Step("sac27", "interest_mark_combat_cold"),
|
||||
Step("sac28", "director_run", wait: 1.5f),
|
||||
Step("sac29", "assert", expect: "tip_asset", value: "aftermath_"),
|
||||
|
|
@ -2706,39 +2875,154 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>Featured unit wins a near-tie against a stranger milestone.</summary>
|
||||
private static List<HarnessCommand> StoryLedgerRevisit()
|
||||
/// <summary>MC on the life-saga roster wins a near-tie against a stranger tip.</summary>
|
||||
private static List<HarnessCommand> SagaCameraPrefersMc()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sl0", "dismiss_windows"),
|
||||
Step("sl1", "wait_world"),
|
||||
Step("sl2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sl3", "fast_timing", value: "true"),
|
||||
Step("sl4", "spawn", asset: "human", count: 2),
|
||||
Step("sl5", "spectator", value: "off"),
|
||||
Step("sl6", "spectator", value: "on"),
|
||||
Step("sl7", "pick_unit", asset: "human"),
|
||||
Step("sl8", "focus", asset: "human"),
|
||||
Step("sl9", "interest_end_session"),
|
||||
// Feature unit A via a watched tip.
|
||||
Step("sl10", "interest_inject", asset: "human", label: "FeaturedLife", tier: "Action",
|
||||
expect: "sl_feat", value: "lead=event;evt=70;char=5;force=false;ttl=60"),
|
||||
Step("sl11", "director_run", wait: 1.2f),
|
||||
Step("sl12", "assert", expect: "tip_contains", value: "FeaturedLife"),
|
||||
Step("sl13", "assert", expect: "ledger_has_focus"),
|
||||
Step("sl14", "interest_end_session"),
|
||||
Step("sl15", "interest_expire_pending", value: ""),
|
||||
// Near-tie: same strength on focus vs other human - ledger/causal heat should tip focus.
|
||||
Step("sl20", "interest_inject", asset: "human", label: "LedgerWin", tier: "Action",
|
||||
expect: "sl_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
|
||||
Step("sl21", "interest_inject", asset: "human", label: "LedgerLose", tier: "Action",
|
||||
expect: "sl_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
|
||||
Step("sl22", "director_run", wait: 1.2f),
|
||||
Step("sl23", "assert", expect: "tip_contains", value: "LedgerWin"),
|
||||
Step("sl24", "assert", expect: "tip_not_contains", value: "LedgerLose"),
|
||||
Step("sl90", "fast_timing", value: "false"),
|
||||
Step("sl99", "snapshot"),
|
||||
Step("scm0", "dismiss_windows"),
|
||||
Step("scm1", "wait_world"),
|
||||
Step("scm2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("scm3", "fast_timing", value: "true"),
|
||||
Step("scm4", "spawn", asset: "human", count: 2),
|
||||
Step("scm5", "spectator", value: "off"),
|
||||
Step("scm6", "spectator", value: "on"),
|
||||
Step("scm7", "pick_unit", asset: "human"),
|
||||
Step("scm8", "focus", asset: "human"),
|
||||
Step("scm9", "interest_end_session"),
|
||||
Step("scm10", "interest_saga_clear"),
|
||||
Step("scm11", "saga_force_admit_focus"),
|
||||
Step("scm12", "assert", expect: "saga_has_focus"),
|
||||
Step("scm13", "interest_expire_pending", value: ""),
|
||||
Step("scm20", "interest_inject", asset: "human", label: "SagaMcWin", tier: "Action",
|
||||
expect: "scm_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
|
||||
Step("scm21", "interest_inject", asset: "human", label: "SagaMcLose", tier: "Action",
|
||||
expect: "scm_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
|
||||
Step("scm22", "director_run", wait: 1.2f),
|
||||
Step("scm23", "assert", expect: "tip_contains", value: "SagaMcWin"),
|
||||
Step("scm24", "assert", expect: "tip_not_contains", value: "SagaMcLose"),
|
||||
Step("scm90", "fast_timing", value: "false"),
|
||||
Step("scm99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Prefer toggle soft-biases a near-tie toward the Prefer'd MC.</summary>
|
||||
private static List<HarnessCommand> SagaPreferSoftBias()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("spb0", "dismiss_windows"),
|
||||
Step("spb1", "wait_world"),
|
||||
Step("spb2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("spb3", "fast_timing", value: "true"),
|
||||
Step("spb4", "spawn", asset: "human", count: 2),
|
||||
Step("spb5", "spectator", value: "off"),
|
||||
Step("spb6", "spectator", value: "on"),
|
||||
Step("spb7", "pick_unit", asset: "human"),
|
||||
Step("spb8", "focus", asset: "human"),
|
||||
Step("spb9", "interest_end_session"),
|
||||
Step("spb10", "interest_saga_clear"),
|
||||
Step("spb11", "saga_force_admit_focus"),
|
||||
Step("spb12", "saga_prefer_focus"),
|
||||
Step("spb13", "assert", expect: "saga_prefer_focus_on"),
|
||||
Step("spb14", "interest_expire_pending", value: ""),
|
||||
Step("spb20", "interest_inject", asset: "human", label: "PreferWin", tier: "Action",
|
||||
expect: "spb_win", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
|
||||
Step("spb21", "interest_inject", asset: "human", label: "PreferLose", tier: "Action",
|
||||
expect: "spb_lose", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
|
||||
Step("spb22", "director_run", wait: 1.2f),
|
||||
Step("spb23", "assert", expect: "tip_contains", value: "PreferWin"),
|
||||
Step("spb24", "assert", expect: "tip_not_contains", value: "PreferLose"),
|
||||
Step("spb90", "fast_timing", value: "false"),
|
||||
Step("spb99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Saga overview hover has MC name and no planner jargon / trait descs.</summary>
|
||||
private static List<HarnessCommand> SagaOverviewHasMc()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("som0", "dismiss_windows"),
|
||||
Step("som1", "wait_world"),
|
||||
Step("som2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("som3", "fast_timing", value: "true"),
|
||||
Step("som4", "spawn", asset: "human", count: 1),
|
||||
Step("som5", "spectator", value: "off"),
|
||||
Step("som6", "spectator", value: "on"),
|
||||
Step("som7", "pick_unit", asset: "human"),
|
||||
Step("som8", "focus", asset: "human"),
|
||||
Step("som9", "interest_saga_clear"),
|
||||
Step("som10", "saga_force_admit_focus"),
|
||||
Step("som11", "director_run", wait: 0.4f),
|
||||
Step("som12", "assert", expect: "saga_roster_count", value: "1", label: "min"),
|
||||
Step("som13", "assert", expect: "saga_overview_tip_not",
|
||||
value: "Climax|Aftermath|Epilogue|parked|watching|Known for:|Appearance brings comfort"),
|
||||
Step("som14", "assert", expect: "story_rail_count", value: "1", label: "min"),
|
||||
Step("som90", "fast_timing", value: "false"),
|
||||
Step("som99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Multi-species notables fill distinct roster slots.</summary>
|
||||
private static List<HarnessCommand> SagaRosterDiversity()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srd0", "dismiss_windows"),
|
||||
Step("srd1", "wait_world"),
|
||||
Step("srd2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("srd3", "fast_timing", value: "true"),
|
||||
Step("srd4", "spawn", asset: "human", count: 1),
|
||||
Step("srd5", "spawn", asset: "elf", count: 1),
|
||||
Step("srd6", "spawn", asset: "dwarf", count: 1),
|
||||
Step("srd7", "spectator", value: "off"),
|
||||
Step("srd8", "spectator", value: "on"),
|
||||
Step("srd9", "interest_saga_clear"),
|
||||
Step("srd10", "pick_unit", asset: "human"),
|
||||
Step("srd11", "focus", asset: "human"),
|
||||
Step("srd12", "saga_force_admit_focus"),
|
||||
Step("srd13", "pick_unit", asset: "elf"),
|
||||
Step("srd14", "focus", asset: "elf"),
|
||||
Step("srd15", "saga_force_admit_focus"),
|
||||
Step("srd16", "pick_unit", asset: "dwarf"),
|
||||
Step("srd17", "focus", asset: "dwarf"),
|
||||
Step("srd18", "saga_force_admit_focus"),
|
||||
Step("srd19", "assert", expect: "saga_roster_count", value: "3", label: "min"),
|
||||
Step("srd90", "fast_timing", value: "false"),
|
||||
Step("srd99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Dead MC leaves the living roster; a new living unit can refill the slot.</summary>
|
||||
private static List<HarnessCommand> SagaReplaceOnDeath()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srdth0", "dismiss_windows"),
|
||||
Step("srdth1", "wait_world"),
|
||||
Step("srdth2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("srdth3", "fast_timing", value: "true"),
|
||||
// Single MC so auto-refill cannot mask the death prune.
|
||||
Step("srdth4", "spawn", asset: "human", count: 1),
|
||||
Step("srdth5", "spectator", value: "off"),
|
||||
Step("srdth6", "spectator", value: "on"),
|
||||
Step("srdth7", "pick_unit", asset: "human"),
|
||||
Step("srdth8", "focus", asset: "human"),
|
||||
Step("srdth9", "interest_saga_clear"),
|
||||
Step("srdth10", "saga_force_admit_focus"),
|
||||
Step("srdth11", "assert", expect: "saga_roster_count", value: "1"),
|
||||
Step("srdth12", "kill_focus"),
|
||||
Step("srdth13", "wait", wait: 0.3f),
|
||||
Step("srdth14", "director_run", wait: 0.8f),
|
||||
Step("srdth15", "assert", expect: "saga_roster_count", value: "0"),
|
||||
Step("srdth16", "spawn", asset: "human", count: 1),
|
||||
Step("srdth17", "pick_unit", asset: "human"),
|
||||
Step("srdth18", "focus", asset: "human"),
|
||||
Step("srdth19", "saga_force_admit_focus"),
|
||||
Step("srdth20", "assert", expect: "saga_roster_count", value: "1"),
|
||||
Step("srdth90", "fast_timing", value: "false"),
|
||||
Step("srdth99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
1930
IdleSpectator/InterestDirector.StickyMaintain.cs
Normal file
1930
IdleSpectator/InterestDirector.StickyMaintain.cs
Normal file
File diff suppressed because it is too large
Load diff
659
IdleSpectator/InterestDirector.SwitchPolicy.cs
Normal file
659
IdleSpectator/InterestDirector.SwitchPolicy.cs
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public static partial class InterestDirector
|
||||
{
|
||||
private static bool CanSwitchTo(InterestCandidate candidate, float onCurrent, float sinceSwitch)
|
||||
{
|
||||
if (candidate == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same FixedDwell beat already watched on this subject - never cut back in.
|
||||
if (InterestVariety.IsBeatCooled(candidate))
|
||||
{
|
||||
InterestDropLog.Record("beat_cooldown", candidate.HappinessEffectId ?? candidate.AssetId ?? candidate.Key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_current == null)
|
||||
{
|
||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
|
||||
// Same fighter, new attack_target: do not cut Duel A-vs-B → A-vs-C mid-scrap.
|
||||
if (IsCombatPartnerSwapNoise(_current, candidate))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"combat_partner_hold",
|
||||
$"cur={_current.Key} next={candidate.Key}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Crisis signal tip (war / disaster / outbreak) owns the camera against Mass/Battle scraps.
|
||||
// Must run before crisis cut-in: a leftover war crisis can MatchCrisis(Mass) while a
|
||||
// small WarFront tip does not. Drop reason keeps war_theater_hold alias for harness.
|
||||
if (StoryPlanner.IsCrisisCameraSignal(_current)
|
||||
&& candidate.Completion == InterestCompletionKind.CombatActive)
|
||||
{
|
||||
string holdReason = _current.Completion == InterestCompletionKind.WarFront
|
||||
? "war_theater_hold"
|
||||
: "crisis_theater_hold";
|
||||
InterestDropLog.Record(
|
||||
holdReason,
|
||||
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Crisis-owned tips (esp. forced epilogue_crisis) may cut without score-margin wars
|
||||
// against aftermath / ambient peers that outscore the closer on raw EventStrength.
|
||||
if (StoryPlanner.CrisisActive
|
||||
&& StoryPlanner.MatchesCrisis(candidate)
|
||||
&& !StoryPlanner.MatchesCrisis(_current)
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: InQuietGrace))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Live fight: hold until cold (see who wins), unless something extremely urgent cuts in.
|
||||
// Same-arc theater refresh / absorb still allowed. WarFront on the same kingdom
|
||||
// theater may reclaim the chapter (Mass → War) without waiting for combat cold.
|
||||
if (CombatFightIsHot(_current, now)
|
||||
&& !IsSameStoryArc(_current, candidate)
|
||||
&& candidate.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
bool warTheaterReclaim = candidate.Completion == InterestCompletionKind.WarFront
|
||||
&& StickyScoreboard.SharesKingdomTheater(_current, candidate);
|
||||
if (warTheaterReclaim)
|
||||
{
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
if (IsCombatUrgentPeer(_current, candidate))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"combat_urgent_cut",
|
||||
$"cur={_current.Key} next={candidate.Key} evt={candidate.EventStrength:0.#}");
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
InterestDropLog.Record(
|
||||
"combat_hold",
|
||||
$"cur={_current.Key} next={candidate.Key}");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool inGrace = InQuietGrace;
|
||||
if (inGrace)
|
||||
{
|
||||
// Story aftermath / same-arc continuation may cut freely during quiet grace.
|
||||
if (IsSameStoryArc(_current, candidate)
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
|
||||
// Prevents family-join / love status from stealing a fight between swings.
|
||||
if (_current != null
|
||||
&& (_current.Completion == InterestCompletionKind.CombatActive
|
||||
|| _current.Completion == InterestCompletionKind.StatusPhase
|
||||
|| _current.Completion == InterestCompletionKind.HappinessGrief
|
||||
|| IsStickyStoryScene(_current)))
|
||||
{
|
||||
float graceCur = _current.TotalScore;
|
||||
float graceNext = candidate.TotalScore;
|
||||
float onCurrentGrace = now - _currentStartedAt;
|
||||
ScoringWeights gw = InterestScoringConfig.W;
|
||||
float graceMargin = IsSoftStickyCluster(_current)
|
||||
? gw.cutInMargin
|
||||
: Mathf.Max(
|
||||
gw.cutInMargin,
|
||||
gw.stickyCutInMargin > 0f ? gw.stickyCutInMargin : 50f);
|
||||
if (VarietyValveOpen(_current, onCurrentGrace) && IsVarietyValveCandidate(candidate))
|
||||
{
|
||||
float valveMargin = gw.stickyVarietyValveMargin > 0f
|
||||
? gw.stickyVarietyValveMargin
|
||||
: 20f;
|
||||
graceMargin = Mathf.Min(graceMargin, valveMargin);
|
||||
if (graceNext >= graceCur - gw.rotateSlack
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return graceNext >= graceCur + graceMargin
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
||||
}
|
||||
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
||||
}
|
||||
|
||||
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool protectedScene = SessionProtected(now, onCurrent);
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float curScore = _current.TotalScore;
|
||||
float nextScore = candidate.TotalScore;
|
||||
bool nextFill = InterestScoring.IsFillScore(nextScore);
|
||||
bool curAmbient = IsAmbientShot(_current);
|
||||
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
|
||||
bool sticky = IsStickyStoryScene(_current);
|
||||
float cutMargin = sticky
|
||||
? Mathf.Max(w.cutInMargin, w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f)
|
||||
: w.cutInMargin;
|
||||
bool varietyValve = VarietyValveOpen(_current, onCurrent)
|
||||
&& IsVarietyValveCandidate(candidate);
|
||||
if (varietyValve)
|
||||
{
|
||||
float valveMargin = w.stickyVarietyValveMargin > 0f ? w.stickyVarietyValveMargin : 20f;
|
||||
cutMargin = Mathf.Min(cutMargin, valveMargin);
|
||||
}
|
||||
|
||||
// Ambient fill: any real event may take the camera immediately (no min dwell).
|
||||
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Soft family pack: yield to discrete unit events after a short hold.
|
||||
if (IsSoftStickyCluster(_current)
|
||||
&& !IsSoftStickyCluster(candidate)
|
||||
&& !nextFill
|
||||
&& !IsAmbientShot(candidate)
|
||||
&& onCurrent >= SoftClusterYieldSeconds
|
||||
&& nextScore >= curScore - w.rotateSlack)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Variety valve: after a long combat/war hold, one Story/Epic/lover/discovery cut.
|
||||
// Score-margin alone can never clear Mass (~150) with lover (~78); use class gate.
|
||||
if (varietyValve && !nextFill && !IsAmbientShot(candidate))
|
||||
{
|
||||
bool hotEnough = InterestScoring.IsHotScore(nextScore)
|
||||
|| nextScore >= curScore - w.rotateSlack;
|
||||
bool storyEnough = nextScore >= w.noticeScoreMin
|
||||
|| candidate.EventStrength >= w.noticeScoreMin;
|
||||
if (hotEnough || storyEnough)
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"variety_valve",
|
||||
$"cur={curScore:0.#} next={nextScore:0.#} on={onCurrent:0.#}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Same-arc refresh (combat/hatch/grief keys) may replace without full margin.
|
||||
if (sticky && IsSameStoryArc(_current, candidate) && nextScore >= curScore - w.rotateSlack)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// StoryPlanner hard-arc / crisis hold against unrelated peers.
|
||||
float storyHold = Mathf.Max(
|
||||
StoryPlanner.ArcHoldMargin(_current, candidate),
|
||||
StoryPlanner.CrisisHoldMargin(_current, candidate));
|
||||
if (storyHold > 0f)
|
||||
{
|
||||
cutMargin = Mathf.Max(cutMargin, storyHold);
|
||||
}
|
||||
|
||||
// Instant score-margin cut - no settle grace, no MinDwell block.
|
||||
// Soft clusters use the normal cut-in margin (not stickyCutInMargin).
|
||||
float effectiveMargin = IsSoftStickyCluster(_current) ? w.cutInMargin : cutMargin;
|
||||
if (nextScore >= curScore + effectiveMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((sticky || storyHold > 0f) && !IsSoftStickyCluster(_current) && nextScore < curScore + cutMargin)
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"below_margin",
|
||||
$"sticky cur={curScore:0.#} next={nextScore:0.#} need={cutMargin:0.#}"
|
||||
+ (varietyValve ? " valve" : "")
|
||||
+ (storyHold > 0f ? " story" : ""));
|
||||
}
|
||||
|
||||
// Fill never cuts a protected non-fill session without margin (already failed above).
|
||||
if (nextFill && !curFill && protectedScene)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float fillRotate = _minDwell < MinDwellSeconds * 0.5f ? _curiosityRotate : w.fillRotateSeconds;
|
||||
if (nextFill && curFill && onCurrent < fillRotate)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Protected / sticky EventLed hold: only margin or same-arc (handled above).
|
||||
if ((protectedScene || sticky) && !curAmbient)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
|
||||
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return onCurrent >= MinDwellFor(_current)
|
||||
&& sinceSwitch >= _switchCooldown
|
||||
&& nextScore >= curScore - w.rotateSlack;
|
||||
}
|
||||
|
||||
/// <summary>Soft multi-actor holds that must yield to discrete unit events.</summary>
|
||||
private const float SoftClusterYieldSeconds = 4f;
|
||||
|
||||
private static bool IsSoftStickyCluster(InterestCandidate c) =>
|
||||
c != null && c.Completion == InterestCompletionKind.FamilyPack;
|
||||
|
||||
/// <summary>
|
||||
/// True when a peer may interrupt a live fight: sticky score margin, or catalog epic-world
|
||||
/// EventStrength with a smaller urgent margin (disasters / kingdom fall, not lovers).
|
||||
/// </summary>
|
||||
private static bool IsCombatUrgentPeer(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null || next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (InterestScoring.IsFillScore(next.TotalScore) || IsAmbientShot(next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float stickyMargin = w.stickyCutInMargin > 0f ? w.stickyCutInMargin : 50f;
|
||||
if (next.TotalScore >= current.TotalScore + stickyMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// King / leadership deaths sit under the epic floor (king_killed=88) but must cut
|
||||
// live scraps - soak missed a king slaying while parked on a Duel thrash.
|
||||
if (IsLeadershipDeathPeer(next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float epicMin = w.combatUrgentEventStrengthMin > 0f
|
||||
? w.combatUrgentEventStrengthMin
|
||||
: 95f;
|
||||
float urgentMargin = w.combatUrgentCutInMargin > 0f
|
||||
? w.combatUrgentCutInMargin
|
||||
: 20f;
|
||||
return next.EventStrength >= epicMin
|
||||
&& next.TotalScore >= current.TotalScore + urgentMargin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Politics leadership death spectacles (live WorldLog / catalog ids), not lovers.
|
||||
/// </summary>
|
||||
private static bool IsLeadershipDeathPeer(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string id = (c.AssetId ?? "").ToLowerInvariant();
|
||||
if (id.IndexOf("king_killed", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("king_dead", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("king_died", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("leader_killed", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("favorite_killed", StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return id.IndexOf("kill", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("dead", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("died", StringComparison.Ordinal) >= 0
|
||||
|| id.IndexOf("slain", StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sticky combat/war has held long enough that Story/Epic/lover/discovery may cut in.
|
||||
/// Live CombatActive scraps never open the valve - hold until the fight goes cold.
|
||||
/// </summary>
|
||||
private static bool VarietyValveOpen(InterestCandidate scene, float onCurrent) =>
|
||||
!_varietyValveUsed
|
||||
&& VarietyValveTimeReady(scene, onCurrent)
|
||||
&& !CombatFightIsHot(scene, Time.unscaledTime);
|
||||
|
||||
private static bool IsStickyStoryScene(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// FamilyPack is a soft cluster - not hard sticky (see IsSoftStickyCluster).
|
||||
if (c.Completion == InterestCompletionKind.CombatActive
|
||||
|| c.Completion == InterestCompletionKind.WarFront
|
||||
|| c.Completion == InterestCompletionKind.PlotActive
|
||||
|| c.Completion == InterestCompletionKind.StatusPhase
|
||||
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
||||
|| c.Completion == InterestCompletionKind.HappinessGrief)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hatch FixedDwell is a story beat - hold against weak peers.
|
||||
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
||||
&& (string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.HappinessEffectId, "just_hatched", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(c.Key)
|
||||
&& (c.Key.StartsWith("combat:", StringComparison.Ordinal)
|
||||
|| c.Key.StartsWith("hatch:", StringComparison.Ordinal)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsSameStoryArc(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null || next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(current.Key)
|
||||
&& !string.IsNullOrEmpty(next.Key)
|
||||
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hatch: same subject beat may refresh without full margin.
|
||||
if (!string.IsNullOrEmpty(current.Key)
|
||||
&& !string.IsNullOrEmpty(next.Key)
|
||||
&& SameKeyPrefix(current.Key, next.Key, "hatch:"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Combat: only the same unordered pair (or escalate of that scrap) is same-arc.
|
||||
// Different combat:pair keys used to soft-cut and thrash Duel partners.
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
&& next.Completion == InterestCompletionKind.CombatActive
|
||||
&& IsSameCombatPairArc(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mass → War reclaim on the same kingdom pair (never War → Mass soft-cut).
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
&& next.Completion == InterestCompletionKind.WarFront
|
||||
&& StickyScoreboard.SharesKingdomTheater(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Completion == InterestCompletionKind.WarFront
|
||||
&& next.Completion == InterestCompletionKind.WarFront
|
||||
&& StickyScoreboard.SharesKingdomTheater(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Completion == InterestCompletionKind.HappinessGrief
|
||||
&& next.Completion == InterestCompletionKind.HappinessGrief
|
||||
&& current.SubjectId != 0
|
||||
&& current.SubjectId == next.SubjectId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// StoryPlanner aftermath / epilogue of the active climax.
|
||||
if (StoryPlanner.IsContinuationOf(current, next))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when next is the same 1v1 pair or a multi escalate of the current scrap.
|
||||
/// </summary>
|
||||
private static bool IsSameCombatPairArc(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null || next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(current.Key)
|
||||
&& !string.IsNullOrEmpty(next.Key)
|
||||
&& string.Equals(current.Key, next.Key, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
CollectCombatActorIds(current, out long curA, out long curB);
|
||||
CollectCombatActorIds(next, out long nextA, out long nextB);
|
||||
bool samePair = curA != 0
|
||||
&& curB != 0
|
||||
&& nextA != 0
|
||||
&& nextB != 0
|
||||
&& ((curA == nextA && curB == nextB) || (curA == nextB && curB == nextA));
|
||||
if (samePair)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Duel → Mass/Battle escalate: shared fighter + larger theater.
|
||||
bool nextMulti = next.ParticipantCount >= 3
|
||||
|| string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
|| HasStickyCollectiveMulti(next);
|
||||
if (!nextMulti)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return SharesCombatFighterId(current, nextA)
|
||||
|| SharesCombatFighterId(current, nextB)
|
||||
|| SharesCombatFighterId(next, curA)
|
||||
|| SharesCombatFighterId(next, curB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peer Duel tips for the same scrap fighter (new attack_target) must not cut the hold.
|
||||
/// </summary>
|
||||
private static bool IsCombatPartnerSwapNoise(InterestCandidate current, InterestCandidate next)
|
||||
{
|
||||
if (current == null
|
||||
|| next == null
|
||||
|| current.Completion != InterestCompletionKind.CombatActive
|
||||
|| next.Completion != InterestCompletionKind.CombatActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Multi escalate / different theater is not partner-swap noise.
|
||||
bool nextMulti = next.ParticipantCount >= 3
|
||||
|| HasStickyCollectiveMulti(next)
|
||||
|| (string.Equals(next.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
||||
&& next.ParticipantCount > 2);
|
||||
if (nextMulti && !IsThinPairCombat(next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CollectCombatActorIds(current, out long curA, out long curB);
|
||||
CollectCombatActorIds(next, out long nextA, out long nextB);
|
||||
if (curA == 0 && curB == 0
|
||||
&& current.TheaterLeadId == 0
|
||||
&& current.PairOwnerId == 0
|
||||
&& current.SubjectId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mass/Battle held on a theater lead: peer Duel with a new attack_target is noise.
|
||||
if (!IsThinPairCombat(current) && !IsNamedPairCombatOwnership(current))
|
||||
{
|
||||
if (IsThinPairCombat(next) && SharesCombatOwnerOrLead(current, nextA, nextB))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsThinPairCombat(next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same unordered pair with flipped Follow (Duel A vs B ↔ B vs A) is ownership noise.
|
||||
// Same-subject refreshes may still soft-cut; A↔B tip order must hold while both live.
|
||||
bool sameUnorderedPair = curA != 0
|
||||
&& curB != 0
|
||||
&& nextA != 0
|
||||
&& nextB != 0
|
||||
&& ((curA == nextA && curB == nextB)
|
||||
|| (curA == nextB && curB == nextA));
|
||||
if (sameUnorderedPair)
|
||||
{
|
||||
long curSub = current.SubjectId != 0
|
||||
? current.SubjectId
|
||||
: (current.PairOwnerId != 0
|
||||
? current.PairOwnerId
|
||||
: EventFeedUtil.SafeId(current.FollowUnit));
|
||||
long nextSub = next.SubjectId != 0
|
||||
? next.SubjectId
|
||||
: EventFeedUtil.SafeId(next.FollowUnit);
|
||||
if (curSub != 0 && nextSub != 0 && curSub != nextSub)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shared fighter + different partner = attack_target thrash.
|
||||
return SharesCombatOwnerOrLead(current, nextA, nextB)
|
||||
|| SharesCombatFighterId(current, nextA)
|
||||
|| SharesCombatFighterId(current, nextB);
|
||||
}
|
||||
|
||||
private static bool SharesCombatOwnerOrLead(InterestCandidate current, long nextA, long nextB)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long lead = current.TheaterLeadId != 0
|
||||
? current.TheaterLeadId
|
||||
: (current.PairOwnerId != 0 ? current.PairOwnerId : current.SubjectId);
|
||||
if (lead == 0)
|
||||
{
|
||||
lead = EventFeedUtil.SafeId(current.FollowUnit);
|
||||
}
|
||||
|
||||
return lead != 0 && (lead == nextA || lead == nextB);
|
||||
}
|
||||
|
||||
private static bool IsThinPairCombat(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.HasCombatPairLock || c.ParticipantCount <= 2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string tip = c.Label ?? "";
|
||||
if (tip.StartsWith("Duel", StringComparison.OrdinalIgnoreCase)
|
||||
|| tip.IndexOf(" is fighting", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string key = c.Key ?? "";
|
||||
return key.StartsWith("combat:pair:", StringComparison.Ordinal)
|
||||
|| (key.StartsWith("combat:", StringComparison.Ordinal)
|
||||
&& key.IndexOf(":pair:", StringComparison.Ordinal) < 0
|
||||
&& !string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void CollectCombatActorIds(InterestCandidate c, out long a, out long b)
|
||||
{
|
||||
a = 0;
|
||||
b = 0;
|
||||
if (c == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
a = c.PairOwnerId != 0 ? c.PairOwnerId : c.SubjectId;
|
||||
b = c.PairPartnerId != 0 ? c.PairPartnerId : c.RelatedId;
|
||||
if (a == 0)
|
||||
{
|
||||
a = EventFeedUtil.SafeId(c.FollowUnit);
|
||||
}
|
||||
|
||||
if (b == 0)
|
||||
{
|
||||
b = EventFeedUtil.SafeId(c.RelatedUnit);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SharesCombatFighterId(InterestCandidate c, long id)
|
||||
{
|
||||
if (c == null || id == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CollectCombatActorIds(c, out long a, out long b);
|
||||
return id == a || id == b;
|
||||
}
|
||||
|
||||
private static bool SameKeyPrefix(string a, string b, string prefix)
|
||||
{
|
||||
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b) || string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!a.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| !b.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(a, b, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -269,7 +269,9 @@ public static class InterestRegistry
|
|||
|
||||
bool match = string.IsNullOrEmpty(needle)
|
||||
|| (c.Key != null
|
||||
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
&& c.Key.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
|| (c.AssetId != null
|
||||
&& c.AssetId.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
if (match)
|
||||
{
|
||||
c.ExpiresAt = now - 0.01f;
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ public static class InterestScoring
|
|||
float repeatPenalty = InterestVariety.RepeatArcPenalty(c);
|
||||
float frequencyAdjust = InterestVariety.FrequencyAdjust(c);
|
||||
float causalBonus = CausalHeat.ScoreBonus(c);
|
||||
float ledgerBonus = CharacterLedger.ScoreBonus(c);
|
||||
float sagaBonus = LifeSagaRoster.ScoreBonus(c);
|
||||
float ownershipBonus = StoryPlanner.OwnershipBoost(c);
|
||||
c.TotalScore = c.EventStrength + scaleBonus
|
||||
+ c.CharacterSignificance * charWeight
|
||||
|
|
@ -169,7 +169,7 @@ public static class InterestScoring
|
|||
- repeatPenalty
|
||||
+ frequencyAdjust
|
||||
+ causalBonus
|
||||
+ ledgerBonus
|
||||
+ sagaBonus
|
||||
+ ownershipBonus;
|
||||
string detail =
|
||||
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}";
|
||||
|
|
@ -190,9 +190,9 @@ public static class InterestScoring
|
|||
detail += $" causal=+{causalBonus:0.#}";
|
||||
}
|
||||
|
||||
if (ledgerBonus > 0.05f)
|
||||
if (sagaBonus > 0.05f)
|
||||
{
|
||||
detail += $" ledger=+{ledgerBonus:0.#}";
|
||||
detail += $" saga=+{sagaBonus:0.#}";
|
||||
}
|
||||
|
||||
if (ownershipBonus > 0.05f)
|
||||
|
|
|
|||
|
|
@ -36,10 +36,18 @@ public class StoryWeights
|
|||
public int ledgerCap = 16;
|
||||
public float ledgerWeight = 8f;
|
||||
public float historyDensityWindowSeconds = 600f;
|
||||
/// <summary>Multiply subject ledger heat after a family life chapter tip is shown.</summary>
|
||||
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
|
||||
public float ledgerBleedLifeChapter = 0.4f;
|
||||
/// <summary>Multiply other ledger entries after a family life chapter (village cool).</summary>
|
||||
/// <summary>Obsolete ledger bleed (CharacterLedger removed); kept for json compat.</summary>
|
||||
public float ledgerBleedVillage = 0.82f;
|
||||
/// <summary>Life-saga roster soft MC tip bonus.</summary>
|
||||
public float sagaMcWeight = 6f;
|
||||
/// <summary>Extra soft bonus when the saga slot is Prefer'd.</summary>
|
||||
public float sagaPreferWeight = 10f;
|
||||
/// <summary>Extra soft bonus when the unit is a WorldBox favorite.</summary>
|
||||
public float sagaGameFavoriteWeight = 8f;
|
||||
/// <summary>Chapter starvation window before dull non-favorite MCs lose rank.</summary>
|
||||
public float sagaDullSeconds = 300f;
|
||||
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
|
||||
public float familyLifeBeatCooldownSeconds = 90f;
|
||||
|
||||
|
|
@ -70,6 +78,11 @@ public class StoryWeights
|
|||
public float crisisEpilogueStrength = 52f;
|
||||
/// <summary>Forced closer max watch (seconds).</summary>
|
||||
public float crisisEpilogueMaxWatch = 16f;
|
||||
/// <summary>
|
||||
/// After a hard story/crisis closer, suppress soft-life crumbs and character-fill
|
||||
/// for this many seconds so AFK does not immediately crumb-surf.
|
||||
/// </summary>
|
||||
public float softFillQuietSeconds = 18f;
|
||||
}
|
||||
|
||||
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ public static class InterestVariety
|
|||
}
|
||||
|
||||
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
||||
CharacterLedger.CoolAfterSoftLifeBeat(chosen);
|
||||
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -727,7 +727,33 @@ public static class InterestVariety
|
|||
return Ranked[0];
|
||||
}
|
||||
|
||||
// Probabilistic among top-N only when scores are within epsilon.
|
||||
// Near-tie: Prefer'd MC, then any MC, else random among top-N.
|
||||
InterestCandidate bestSaga = null;
|
||||
int bestRank = -1;
|
||||
for (int i = 0; i < topN; i++)
|
||||
{
|
||||
if (Ranked[0].TotalScore - Ranked[i].TotalScore >= epsilon)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int rank = LifeSagaRoster.TipTouchesPrefer(Ranked[i])
|
||||
? 2
|
||||
: LifeSagaRoster.TipTouchesMc(Ranked[i])
|
||||
? 1
|
||||
: 0;
|
||||
if (rank > bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
bestSaga = Ranked[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSaga != null && bestRank > 0)
|
||||
{
|
||||
return bestSaga;
|
||||
}
|
||||
|
||||
int pick = Rng.Next(topN);
|
||||
return Ranked[pick];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ public static class SpectatorMode
|
|||
|
||||
public static bool Active { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when idle was frozen for Lore/browse without wiping StoryPlanner / registry.
|
||||
/// Resume via <see cref="SetActive"/>(true) restores that session instead of a full clear enable.
|
||||
/// </summary>
|
||||
public static bool BrowsePaused { get; private set; }
|
||||
|
||||
private static bool _forceFileChecked;
|
||||
|
||||
public static void Toggle()
|
||||
|
|
@ -19,7 +25,11 @@ public static class SpectatorMode
|
|||
SetActive(!Active);
|
||||
}
|
||||
|
||||
public static void SetActive(bool active, bool quiet = false)
|
||||
/// <param name="browsePause">
|
||||
/// When disabling: freeze story/crisis/ledger/registry for Lore browse (do not Clear).
|
||||
/// Ignored when enabling - resume uses <see cref="BrowsePaused"/>.
|
||||
/// </param>
|
||||
public static void SetActive(bool active, bool quiet = false, bool browsePause = false)
|
||||
{
|
||||
if (active && !ModSettings.Enabled)
|
||||
{
|
||||
|
|
@ -36,22 +46,45 @@ public static class SpectatorMode
|
|||
if (Active)
|
||||
{
|
||||
WatchCaption.ClearPausePin();
|
||||
InterestDirector.OnSpectatorEnabled();
|
||||
bool fromBrowse = BrowsePaused;
|
||||
BrowsePaused = false;
|
||||
if (fromBrowse)
|
||||
{
|
||||
InterestDirector.OnSpectatorBrowseResumed();
|
||||
}
|
||||
else
|
||||
{
|
||||
InterestDirector.OnSpectatorEnabled();
|
||||
}
|
||||
|
||||
SpeciesDiscovery.OnSpectatorEnabled();
|
||||
Chronicle.OnSpectatorEnabled();
|
||||
ChronicleHud.OnIdleResumed();
|
||||
FocusRelationshipArrows.OnSpectatorEnabled();
|
||||
LogService.LogInfo("[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
|
||||
LogService.LogInfo(
|
||||
fromBrowse
|
||||
? "[IdleSpectator] Spectator resumed after Lore/browse pause"
|
||||
: "[IdleSpectator] Spectator mode enabled (I or any input to stop; L for Lore)");
|
||||
}
|
||||
else
|
||||
{
|
||||
InterestDirector.OnSpectatorDisabled();
|
||||
if (browsePause)
|
||||
{
|
||||
BrowsePaused = true;
|
||||
InterestDirector.OnSpectatorBrowsePaused();
|
||||
}
|
||||
else
|
||||
{
|
||||
BrowsePaused = false;
|
||||
InterestDirector.OnSpectatorDisabled();
|
||||
}
|
||||
|
||||
SpeciesDiscovery.OnSpectatorDisabled();
|
||||
FocusRelationshipArrows.OnSpectatorDisabled();
|
||||
CameraDirector.ClearFollow();
|
||||
if (quiet)
|
||||
{
|
||||
// Manual-input pause: leave dossier up so ShowStatusBanner can reuse it.
|
||||
// Manual-input / Lore pause: leave dossier up so ShowStatusBanner can reuse it.
|
||||
StateProbe.Reset();
|
||||
}
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,295 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Small cast of watched lives. Heat from favorites, focus, CausalHeat, and Chronicle density.
|
||||
/// </summary>
|
||||
public static class CharacterLedger
|
||||
{
|
||||
private struct LedgerEntry
|
||||
{
|
||||
public long UnitId;
|
||||
public float Heat;
|
||||
public float TouchedAt;
|
||||
}
|
||||
|
||||
private static readonly List<LedgerEntry> Entries = new List<LedgerEntry>(20);
|
||||
private static readonly List<int> ScratchIdx = new List<int>(8);
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
Entries.Clear();
|
||||
}
|
||||
|
||||
public static int Count => Entries.Count;
|
||||
|
||||
public static bool IsOnLedger(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId == unitId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static float Heat(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId == unitId)
|
||||
{
|
||||
return Entries[i].Heat;
|
||||
}
|
||||
}
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
public static float ScoreBonus(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
float h = Heat(c.SubjectId);
|
||||
if (h <= 0f && c.RelatedId != 0)
|
||||
{
|
||||
h = Heat(c.RelatedId) * 0.5f;
|
||||
}
|
||||
|
||||
return h * s.ledgerWeight;
|
||||
}
|
||||
|
||||
public static void Touch(long unitId, float heatAdd = 1f)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId != unitId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LedgerEntry e = Entries[i];
|
||||
e.Heat = Mathf.Min(8f, e.Heat + heatAdd);
|
||||
e.TouchedAt = now;
|
||||
Entries[i] = e;
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCapacityForNew(unitId);
|
||||
Entries.Add(new LedgerEntry
|
||||
{
|
||||
UnitId = unitId,
|
||||
Heat = Mathf.Clamp(heatAdd, 0.5f, 8f),
|
||||
TouchedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
public static void NoteFeatured(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float add = 1f;
|
||||
if (scene.FollowUnit != null && Chronicle.IsFavoriteSubject(EventFeedUtil.SafeId(scene.FollowUnit)))
|
||||
{
|
||||
add += 1.5f;
|
||||
}
|
||||
|
||||
if (InterestScoring.IsNotable(scene.FollowUnit))
|
||||
{
|
||||
add += 0.75f;
|
||||
}
|
||||
|
||||
if (scene.SubjectId != 0)
|
||||
{
|
||||
Touch(scene.SubjectId, add);
|
||||
float density = Chronicle.RecentHistoryCount(
|
||||
scene.SubjectId,
|
||||
InterestScoringConfig.Story.historyDensityWindowSeconds);
|
||||
if (density > 0)
|
||||
{
|
||||
Touch(scene.SubjectId, Mathf.Min(2f, density * 0.15f));
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.RelatedId != 0)
|
||||
{
|
||||
Touch(scene.RelatedId, add * 0.6f);
|
||||
}
|
||||
|
||||
float causal = CausalHeat.Get(scene.SubjectId);
|
||||
if (causal > 0.5f)
|
||||
{
|
||||
Touch(scene.SubjectId, Mathf.Min(1.5f, causal));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft life chapters (parenthood, intimacy) should not keep ledger heat hot enough
|
||||
/// to monopolize the next few picks for the same village cast.
|
||||
/// </summary>
|
||||
public static void CoolAfterSoftLifeBeat(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null || !InterestVariety.IsSoftLifeChapter(scene))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
float subjectMul = s.ledgerBleedLifeChapter > 0f ? s.ledgerBleedLifeChapter : 0.4f;
|
||||
float villageMul = s.ledgerBleedVillage > 0f ? s.ledgerBleedVillage : 0.82f;
|
||||
Bleed(scene.SubjectId, subjectMul);
|
||||
if (scene.RelatedId != 0)
|
||||
{
|
||||
Bleed(scene.RelatedId, Mathf.Lerp(subjectMul, 1f, 0.35f));
|
||||
}
|
||||
|
||||
// Mild cool across the rest of the ledger so harness-featured villagers fade.
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
long id = Entries[i].UnitId;
|
||||
if (id == 0 || id == scene.SubjectId || id == scene.RelatedId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Bleed(id, villageMul);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Bleed(long unitId, float multiplyHeat)
|
||||
{
|
||||
if (unitId == 0 || multiplyHeat >= 0.999f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
multiplyHeat = Mathf.Clamp(multiplyHeat, 0.05f, 1f);
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
if (Entries[i].UnitId != unitId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LedgerEntry e = Entries[i];
|
||||
e.Heat = Mathf.Max(0.15f, e.Heat * multiplyHeat);
|
||||
Entries[i] = e;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Enumerate(List<long> into)
|
||||
{
|
||||
if (into == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
into.Clear();
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
into.Add(Entries[i].UnitId);
|
||||
}
|
||||
}
|
||||
|
||||
public static Actor PreferLedgerUnit(IEnumerable<Actor> candidates)
|
||||
{
|
||||
if (candidates == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor best = null;
|
||||
float bestHeat = -1f;
|
||||
foreach (Actor a in candidates)
|
||||
{
|
||||
if (a == null || !a.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(a);
|
||||
float h = Heat(id);
|
||||
if (h > bestHeat)
|
||||
{
|
||||
bestHeat = h;
|
||||
best = a;
|
||||
}
|
||||
}
|
||||
|
||||
return bestHeat > 0f ? best : null;
|
||||
}
|
||||
|
||||
private static void EnsureCapacityForNew(long incomingId)
|
||||
{
|
||||
StoryWeights s = InterestScoringConfig.Story;
|
||||
int cap = s.ledgerCap > 0 ? s.ledgerCap : 16;
|
||||
if (Entries.Count < cap)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Never evict current arc cast.
|
||||
ScratchIdx.Clear();
|
||||
int worst = -1;
|
||||
float worstHeat = float.MaxValue;
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
long id = Entries[i].UnitId;
|
||||
if (StoryPlanner.Active != null && StoryPlanner.Active.ContainsCast(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id == incomingId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float h = Entries[i].Heat;
|
||||
if (h < worstHeat)
|
||||
{
|
||||
worstHeat = h;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (worst >= 0)
|
||||
{
|
||||
Entries.RemoveAt(worst);
|
||||
}
|
||||
else if (Entries.Count > 0)
|
||||
{
|
||||
// All protected - drop oldest non-incoming.
|
||||
Entries.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
504
IdleSpectator/Story/LifeSagaOverview.cs
Normal file
504
IdleSpectator/Story/LifeSagaOverview.cs
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-line saga hover card: Title / Why / Circle / Chapters.
|
||||
/// No trait descs, no Chronicle dump, no planner jargon.
|
||||
/// </summary>
|
||||
public static class LifeSagaOverview
|
||||
{
|
||||
private const int EpithetMinScore = 80;
|
||||
|
||||
public static string Build(LifeSagaSlot slot)
|
||||
{
|
||||
if (slot == null || slot.UnitId == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
Actor actor = slot.Resolve();
|
||||
var sb = new StringBuilder(256);
|
||||
string title = BuildTitle(slot, actor);
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
sb.Append(title);
|
||||
}
|
||||
|
||||
string why = BuildWhy(slot, actor);
|
||||
AppendLine(sb, why);
|
||||
|
||||
string circle = BuildCircle(actor);
|
||||
AppendLine(sb, circle);
|
||||
|
||||
string chapters = BuildChapters(slot);
|
||||
AppendLine(sb, chapters);
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static void AppendLine(StringBuilder sb, string line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(line);
|
||||
}
|
||||
|
||||
private static string BuildTitle(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
string name = !string.IsNullOrEmpty(slot.DisplayName)
|
||||
? slot.DisplayName
|
||||
: "Someone";
|
||||
string epithet = TryEpithetName(actor);
|
||||
string role = BuildRole(slot, actor);
|
||||
bool starred = slot.Prefer || slot.GameFavorite;
|
||||
|
||||
var sb = new StringBuilder(64);
|
||||
if (starred)
|
||||
{
|
||||
sb.Append("★ ");
|
||||
}
|
||||
|
||||
sb.Append(name);
|
||||
if (!string.IsNullOrEmpty(epithet))
|
||||
{
|
||||
sb.Append(" the ").Append(epithet);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(role))
|
||||
{
|
||||
sb.Append(" · ").Append(role);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildRole(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
if (slot.IsKing)
|
||||
{
|
||||
string kingdom = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
|
||||
return string.IsNullOrEmpty(kingdom) ? "King" : "King of " + kingdom;
|
||||
}
|
||||
|
||||
if (slot.IsAlpha)
|
||||
{
|
||||
return "Alpha of the pack";
|
||||
}
|
||||
|
||||
if (slot.IsLeader)
|
||||
{
|
||||
string city = FirstNonEmpty(slot.CityKey, CityName(actor));
|
||||
return string.IsNullOrEmpty(city) ? "City leader" : "Leader of " + city;
|
||||
}
|
||||
|
||||
string species = SpeciesLabel(slot, actor);
|
||||
string kingdomFallback = FirstNonEmpty(slot.KingdomKey, KingdomName(actor));
|
||||
if (!string.IsNullOrEmpty(species) && !string.IsNullOrEmpty(kingdomFallback))
|
||||
{
|
||||
return species + " of " + kingdomFallback;
|
||||
}
|
||||
|
||||
return FirstNonEmpty(species, kingdomFallback);
|
||||
}
|
||||
|
||||
private static string BuildWhy(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
// Title already carries role / favorite star - Why adds standing/event only.
|
||||
var parts = new List<string>(3);
|
||||
if (slot.Kills >= 10)
|
||||
{
|
||||
parts.Add(slot.Kills + " kills");
|
||||
}
|
||||
|
||||
if (slot.Renown >= 80f)
|
||||
{
|
||||
parts.Add("renown " + Mathf.RoundToInt(slot.Renown));
|
||||
}
|
||||
|
||||
if (IsAtWar(actor))
|
||||
{
|
||||
string side = FirstNonEmpty(slot.KingdomKey, KingdomName(actor), SpeciesLabel(slot, actor));
|
||||
if (!string.IsNullOrEmpty(side))
|
||||
{
|
||||
parts.Add("leading " + side + " in war");
|
||||
}
|
||||
else
|
||||
{
|
||||
parts.Add("at war");
|
||||
}
|
||||
}
|
||||
else if (slot.IsKing && parts.Count == 0)
|
||||
{
|
||||
// Newly crowned with no kill/war signal - omit rather than reprint King.
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string BuildCircle(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var parts = new List<string>(3);
|
||||
Actor lover = ActorRelation.GetLover(actor);
|
||||
if (lover != null)
|
||||
{
|
||||
parts.Add("Lover " + SafeName(lover));
|
||||
}
|
||||
|
||||
Actor friend = ActorRelation.GetBestFriend(actor);
|
||||
if (friend != null && friend != lover)
|
||||
{
|
||||
parts.Add("Friend " + SafeName(friend));
|
||||
}
|
||||
|
||||
foreach (Actor child in ActorRelation.EnumerateChildren(actor, max: 1))
|
||||
{
|
||||
if (child != null && child != lover && child != friend)
|
||||
{
|
||||
parts.Add("Child " + SafeName(child));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string BuildChapters(LifeSagaSlot slot)
|
||||
{
|
||||
if (slot.Chapters == null || slot.Chapters.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(128);
|
||||
int n = Mathf.Min(LifeSagaRoster.MaxChapters, slot.Chapters.Count);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
string line = slot.Chapters[i];
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append("· ").Append(line);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static float StandoutTraitScore(Actor actor)
|
||||
{
|
||||
ActorTrait top = PickTopTrait(actor, out int score);
|
||||
if (top == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return Mathf.Max(0, score);
|
||||
}
|
||||
|
||||
private static string TryEpithetName(Actor actor)
|
||||
{
|
||||
ActorTrait top = PickTopTrait(actor, out int score);
|
||||
if (top == null || score < EpithetMinScore)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return TraitDisplayName(top);
|
||||
}
|
||||
|
||||
private static ActorTrait PickTopTrait(Actor actor, out int bestScore)
|
||||
{
|
||||
bestScore = int.MinValue;
|
||||
if (actor == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!actor.hasTraits())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ActorTrait best = null;
|
||||
foreach (ActorTrait trait in actor.getTraits())
|
||||
{
|
||||
if (trait == null || string.IsNullOrEmpty(trait.id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsDefaultTraitForActor(trait, actor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int score = TraitInterestScore(trait, actor);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = trait;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int TraitInterestScore(ActorTrait trait, Actor actor)
|
||||
{
|
||||
if (trait == null)
|
||||
{
|
||||
return int.MinValue;
|
||||
}
|
||||
|
||||
int score = 0;
|
||||
try
|
||||
{
|
||||
score += (int)trait.rarity * 100;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (trait.type == TraitType.Negative)
|
||||
{
|
||||
score += 25;
|
||||
}
|
||||
else if (trait.type == TraitType.Positive)
|
||||
{
|
||||
score += 15;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!IsDefaultTraitForActor(trait, actor))
|
||||
{
|
||||
score += 50;
|
||||
}
|
||||
else
|
||||
{
|
||||
score -= 40;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (trait.rate_birth > 0 && trait.rate_birth <= 5)
|
||||
{
|
||||
score += 20;
|
||||
}
|
||||
else if (trait.rate_birth >= 40)
|
||||
{
|
||||
score -= 15;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
string id = trait.id ?? "";
|
||||
if (id.IndexOf("miracle", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("immortal", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("zombie", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("plague", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("blessed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("king", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| id.IndexOf("evil", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
score += 35;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool IsDefaultTraitForActor(ActorTrait trait, Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (trait == null || actor == null || actor.asset == null
|
||||
|| trait.default_for_actor_assets == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return trait.default_for_actor_assets.Contains(actor.asset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TraitDisplayName(ActorTrait trait)
|
||||
{
|
||||
try
|
||||
{
|
||||
string locale = trait.getLocaleID();
|
||||
if (!string.IsNullOrEmpty(locale))
|
||||
{
|
||||
string localized = LocalizedTextManager.getText(locale);
|
||||
if (!string.IsNullOrEmpty(localized) && localized != locale)
|
||||
{
|
||||
return localized;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
string id = trait.id ?? "";
|
||||
if (id.Length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return char.ToUpperInvariant(id[0]) + id.Substring(1).Replace('_', ' ');
|
||||
}
|
||||
|
||||
private static bool IsAtWar(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Kingdom k = actor.kingdom;
|
||||
if (k == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return k.hasEnemies();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SpeciesLabel(LifeSagaSlot slot, Actor actor)
|
||||
{
|
||||
string id = FirstNonEmpty(slot?.SpeciesId, actor?.asset != null ? actor.asset.id : "");
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (id.Length == 1)
|
||||
{
|
||||
return id.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return char.ToUpperInvariant(id[0]) + id.Substring(1);
|
||||
}
|
||||
|
||||
private static string KingdomName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
return actor?.kingdom != null ? (actor.kingdom.name ?? "") : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string CityName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
return actor?.city != null ? (actor.city.name ?? "") : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
string n = actor.getName();
|
||||
if (!string.IsNullOrEmpty(n))
|
||||
{
|
||||
return n;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return actor?.asset != null ? actor.asset.id : "Someone";
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string[] values)
|
||||
{
|
||||
if (values == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(values[i]))
|
||||
{
|
||||
return values[i];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
385
IdleSpectator/Story/LifeSagaRail.cs
Normal file
385
IdleSpectator/Story/LifeSagaRail.cs
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Dossier rail of life-saga MCs. Click toggles Prefer (soft favorite). Hover shows overview card.
|
||||
/// </summary>
|
||||
public static class LifeSagaRail
|
||||
{
|
||||
private const int MaxSlots = LifeSagaRoster.Cap;
|
||||
private const float SlotSize = 14f;
|
||||
private const float Gap = 2f;
|
||||
private const float HoverWidth = 300f;
|
||||
private const float HoverLineHeight = 12f;
|
||||
private const float HoverMaxHeight = 84f;
|
||||
|
||||
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
|
||||
private static readonly Slot[] Slots = new Slot[MaxSlots];
|
||||
private static GameObject _row;
|
||||
private static RectTransform _rowRt;
|
||||
private static Text _hoverText;
|
||||
private static string _fingerprint = "";
|
||||
|
||||
/// <summary>Harness: tip text of the first visible rail slot after last Refresh.</summary>
|
||||
public static string LastTipSample { get; private set; } = "";
|
||||
|
||||
private sealed class Slot
|
||||
{
|
||||
public GameObject Root;
|
||||
public RectTransform Rt;
|
||||
public Image Bg;
|
||||
public Text Label;
|
||||
public Button Button;
|
||||
public long UnitId;
|
||||
public string Tip = "";
|
||||
public LifeSagaRailHover Hover;
|
||||
}
|
||||
|
||||
public static bool Visible =>
|
||||
_row != null && _row.activeSelf;
|
||||
|
||||
public static int LastShownCount { get; private set; }
|
||||
|
||||
public static void EnsureBuilt(Transform parent)
|
||||
{
|
||||
if (_row != null || parent == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_row = new GameObject("LifeSagaRail", typeof(RectTransform));
|
||||
_row.transform.SetParent(parent, false);
|
||||
_rowRt = _row.GetComponent<RectTransform>();
|
||||
_rowRt.pivot = new Vector2(0f, 1f);
|
||||
_rowRt.anchorMin = new Vector2(0f, 1f);
|
||||
_rowRt.anchorMax = new Vector2(0f, 1f);
|
||||
|
||||
for (int i = 0; i < MaxSlots; i++)
|
||||
{
|
||||
Slots[i] = BuildSlot(_row.transform, i);
|
||||
}
|
||||
|
||||
_hoverText = HudCanvas.MakeText(_row.transform, "Hover", "", 7);
|
||||
_hoverText.color = HudTheme.Muted;
|
||||
_hoverText.alignment = TextAnchor.UpperLeft;
|
||||
_hoverText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
_hoverText.verticalOverflow = VerticalWrapMode.Overflow;
|
||||
_hoverText.raycastTarget = false;
|
||||
_hoverText.gameObject.SetActive(false);
|
||||
|
||||
_row.SetActive(false);
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_fingerprint = "";
|
||||
LastShownCount = 0;
|
||||
LastTipSample = "";
|
||||
HideHover();
|
||||
if (_row != null)
|
||||
{
|
||||
_row.SetActive(false);
|
||||
}
|
||||
|
||||
for (int i = 0; i < Slots.Length; i++)
|
||||
{
|
||||
if (Slots[i]?.Root != null)
|
||||
{
|
||||
Slots[i].Root.SetActive(false);
|
||||
Slots[i].UnitId = 0;
|
||||
Slots[i].Tip = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Refresh()
|
||||
{
|
||||
if (_row == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SpectatorMode.Active && !SpectatorMode.BrowsePaused)
|
||||
{
|
||||
Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ModSettings.ShowDossierCaption || WatchCaption.HasStatusBannerActive())
|
||||
{
|
||||
Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaRoster.Tick(Time.unscaledTime);
|
||||
SlotScratch.Clear();
|
||||
LifeSagaRoster.CopySlots(SlotScratch);
|
||||
string fp = Fingerprint(SlotScratch);
|
||||
if (string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|
||||
&& !LifeSagaRoster.Dirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaRoster.Dirty = false;
|
||||
_fingerprint = fp;
|
||||
LastShownCount = 0;
|
||||
LastTipSample = "";
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
float x = 0f;
|
||||
for (int i = 0; i < Slots.Length; i++)
|
||||
{
|
||||
Slot slot = Slots[i];
|
||||
if (i >= SlotScratch.Count || SlotScratch[i] == null || SlotScratch[i].UnitId == 0)
|
||||
{
|
||||
slot.Root.SetActive(false);
|
||||
slot.UnitId = 0;
|
||||
slot.Tip = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
LifeSagaSlot saga = SlotScratch[i];
|
||||
bool watching = watchId != 0 && watchId == saga.UnitId;
|
||||
bool lit = watching || saga.Prefer || saga.GameFavorite;
|
||||
slot.UnitId = saga.UnitId;
|
||||
slot.Label.text = GlyphFor(saga);
|
||||
slot.Bg.color = lit
|
||||
? HudTheme.ValueOrange
|
||||
: new Color(HudTheme.Muted.r, HudTheme.Muted.g, HudTheme.Muted.b, 0.55f);
|
||||
slot.Label.color = lit ? Color.black : HudTheme.Body;
|
||||
slot.Tip = LifeSagaOverview.Build(saga);
|
||||
if (LastShownCount == 0)
|
||||
{
|
||||
LastTipSample = slot.Tip ?? "";
|
||||
}
|
||||
|
||||
slot.Rt.anchoredPosition = new Vector2(x, 0f);
|
||||
slot.Root.SetActive(true);
|
||||
x += SlotSize + Gap;
|
||||
LastShownCount++;
|
||||
}
|
||||
|
||||
_rowRt.sizeDelta = new Vector2(Mathf.Max(SlotSize, x - Gap), SlotSize);
|
||||
_row.SetActive(LastShownCount > 0);
|
||||
}
|
||||
|
||||
/// <summary>Place rail under the spine; returns next y.</summary>
|
||||
public static float Place(float y, float padX)
|
||||
{
|
||||
if (!Visible || _rowRt == null)
|
||||
{
|
||||
return y;
|
||||
}
|
||||
|
||||
_rowRt.anchoredPosition = new Vector2(padX, -y);
|
||||
float next = y + SlotSize + 2f;
|
||||
if (_hoverText != null && _hoverText.gameObject.activeSelf)
|
||||
{
|
||||
RectTransform ht = _hoverText.GetComponent<RectTransform>();
|
||||
ht.pivot = new Vector2(0f, 1f);
|
||||
ht.anchorMin = new Vector2(0f, 1f);
|
||||
ht.anchorMax = new Vector2(0f, 1f);
|
||||
ht.anchoredPosition = new Vector2(0f, -(SlotSize + 1f));
|
||||
float hoverH = EstimateHoverHeight(_hoverText.text);
|
||||
ht.sizeDelta = new Vector2(HoverWidth, hoverH);
|
||||
next += hoverH + 1f;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
internal static void ShowHover(int index)
|
||||
{
|
||||
if (_hoverText == null || index < 0 || index >= Slots.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string tip = Slots[index].Tip;
|
||||
if (string.IsNullOrEmpty(tip) && Slots[index].UnitId != 0)
|
||||
{
|
||||
LifeSagaSlot s = LifeSagaRoster.Get(Slots[index].UnitId);
|
||||
tip = LifeSagaOverview.Build(s);
|
||||
Slots[index].Tip = tip;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(tip))
|
||||
{
|
||||
HideHover();
|
||||
return;
|
||||
}
|
||||
|
||||
_hoverText.text = tip;
|
||||
_hoverText.gameObject.SetActive(true);
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
internal static void HideHover()
|
||||
{
|
||||
if (_hoverText != null)
|
||||
{
|
||||
_hoverText.gameObject.SetActive(false);
|
||||
_hoverText.text = "";
|
||||
}
|
||||
|
||||
WatchCaption.RequestRelayout();
|
||||
}
|
||||
|
||||
private static float EstimateHoverHeight(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return HoverLineHeight;
|
||||
}
|
||||
|
||||
int lines = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
|
||||
return Mathf.Min(HoverMaxHeight, lines * HoverLineHeight + 2f);
|
||||
}
|
||||
|
||||
private static Slot BuildSlot(Transform parent, int index)
|
||||
{
|
||||
GameObject go = new GameObject("SagaRail" + index, typeof(RectTransform), typeof(Image), typeof(Button));
|
||||
go.transform.SetParent(parent, false);
|
||||
RectTransform rt = go.GetComponent<RectTransform>();
|
||||
rt.pivot = new Vector2(0f, 1f);
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(0f, 1f);
|
||||
rt.sizeDelta = new Vector2(SlotSize, SlotSize);
|
||||
|
||||
Image bg = go.GetComponent<Image>();
|
||||
bg.color = HudTheme.ToolFill;
|
||||
bg.raycastTarget = true;
|
||||
|
||||
Text label = HudCanvas.MakeText(go.transform, "Glyph", "", 9);
|
||||
label.alignment = TextAnchor.MiddleCenter;
|
||||
label.resizeTextForBestFit = false;
|
||||
RectTransform labelRt = label.GetComponent<RectTransform>();
|
||||
labelRt.anchorMin = Vector2.zero;
|
||||
labelRt.anchorMax = Vector2.one;
|
||||
labelRt.offsetMin = Vector2.zero;
|
||||
labelRt.offsetMax = Vector2.zero;
|
||||
|
||||
Button button = go.GetComponent<Button>();
|
||||
ColorBlock colors = button.colors;
|
||||
colors.normalColor = Color.white;
|
||||
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
|
||||
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
|
||||
button.colors = colors;
|
||||
int captured = index;
|
||||
button.onClick.AddListener(new UnityAction(() => OnClick(captured)));
|
||||
|
||||
LifeSagaRailHover hover = go.AddComponent<LifeSagaRailHover>();
|
||||
hover.Index = index;
|
||||
|
||||
go.SetActive(false);
|
||||
return new Slot
|
||||
{
|
||||
Root = go,
|
||||
Rt = rt,
|
||||
Bg = bg,
|
||||
Label = label,
|
||||
Button = button,
|
||||
Hover = hover
|
||||
};
|
||||
}
|
||||
|
||||
private static void OnClick(int index)
|
||||
{
|
||||
if (index < 0 || index >= Slots.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long id = Slots[index].UnitId;
|
||||
if (id == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SpectatorMode.Active && SpectatorMode.BrowsePaused)
|
||||
{
|
||||
SpectatorMode.SetActive(true, quiet: true);
|
||||
}
|
||||
|
||||
LifeSagaRoster.TogglePrefer(id);
|
||||
_fingerprint = "";
|
||||
LifeSagaRoster.Dirty = true;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private static string GlyphFor(LifeSagaSlot saga)
|
||||
{
|
||||
if (saga == null)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
if (saga.IsKing)
|
||||
{
|
||||
return "K";
|
||||
}
|
||||
|
||||
if (saga.IsAlpha)
|
||||
{
|
||||
return "A";
|
||||
}
|
||||
|
||||
if (saga.IsLeader)
|
||||
{
|
||||
return "L";
|
||||
}
|
||||
|
||||
if (saga.Prefer || saga.GameFavorite)
|
||||
{
|
||||
return "★";
|
||||
}
|
||||
|
||||
string sp = saga.SpeciesId ?? "";
|
||||
if (sp.Length > 0)
|
||||
{
|
||||
return char.ToUpperInvariant(sp[0]).ToString();
|
||||
}
|
||||
|
||||
return "?";
|
||||
}
|
||||
|
||||
private static string Fingerprint(List<LifeSagaSlot> board)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
sb.Append(watchId).Append('|');
|
||||
for (int i = 0; i < board.Count; i++)
|
||||
{
|
||||
LifeSagaSlot s = board[i];
|
||||
if (s == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(s.UnitId).Append(':')
|
||||
.Append(s.Prefer ? 'P' : '-')
|
||||
.Append(s.GameFavorite ? 'F' : '-')
|
||||
.Append(s.IsKing ? 'K' : '-')
|
||||
.Append(s.Chapters.Count).Append(':')
|
||||
.Append(s.DisplayName ?? "").Append(';');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
14
IdleSpectator/Story/LifeSagaRailHover.cs
Normal file
14
IdleSpectator/Story/LifeSagaRailHover.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Pointer hover bridge for <see cref="LifeSagaRail"/> slots.</summary>
|
||||
public sealed class LifeSagaRailHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
|
||||
{
|
||||
public int Index;
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData) => LifeSagaRail.ShowHover(Index);
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData) => LifeSagaRail.HideHover();
|
||||
}
|
||||
885
IdleSpectator/Story/LifeSagaRoster.cs
Normal file
885
IdleSpectator/Story/LifeSagaRoster.cs
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Durable top-10 life-saga cast. Owns rail Prefer, diversity, and soft camera bias.
|
||||
/// Independent of short-arc <see cref="StoryPlanner"/> Clear.
|
||||
/// </summary>
|
||||
public static class LifeSagaRoster
|
||||
{
|
||||
public const int Cap = 10;
|
||||
public const int MaxChapters = 3;
|
||||
|
||||
private static readonly List<LifeSagaSlot> Slots = new List<LifeSagaSlot>(Cap);
|
||||
private static readonly List<LifeSagaSlot> Scratch = new List<LifeSagaSlot>(32);
|
||||
private static float _nextScanAt;
|
||||
private static bool _dirty = true;
|
||||
|
||||
public static int Count => Slots.Count;
|
||||
|
||||
public static bool Dirty
|
||||
{
|
||||
get => _dirty;
|
||||
set => _dirty = value;
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
Slots[i]?.Chapters.Clear();
|
||||
}
|
||||
|
||||
Slots.Clear();
|
||||
_nextScanAt = 0f;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
public static void CopySlots(List<LifeSagaSlot> into)
|
||||
{
|
||||
if (into == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
into.Clear();
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
if (Slots[i] != null && Slots[i].UnitId != 0)
|
||||
{
|
||||
into.Add(Slots[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static LifeSagaSlot Get(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
if (Slots[i] != null && Slots[i].UnitId == unitId)
|
||||
{
|
||||
return Slots[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsMc(long unitId) => Get(unitId) != null;
|
||||
|
||||
public static bool IsPrefer(long unitId)
|
||||
{
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
return s != null && s.Prefer;
|
||||
}
|
||||
|
||||
public static bool TogglePrefer(long unitId)
|
||||
{
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
if (s == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
s.Prefer = !s.Prefer;
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Harness: force a living unit onto the roster (bypasses admission for tests).</summary>
|
||||
public static bool HarnessForceAdmit(Actor actor)
|
||||
{
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
if (id == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
LifeSagaSlot existing = Get(id);
|
||||
if (existing != null)
|
||||
{
|
||||
RefreshSlot(existing, actor, now);
|
||||
existing.Interest = Mathf.Max(existing.Interest, 8f);
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
LifeSagaSlot neu = BuildSlot(actor, now);
|
||||
neu.Interest = Mathf.Max(neu.Interest, 8f);
|
||||
if (Slots.Count >= Cap)
|
||||
{
|
||||
// Evict lowest non-favorite.
|
||||
int worst = -1;
|
||||
float worstScore = float.MaxValue;
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
if (Slots[i] == null || Slots[i].GameFavorite)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Slots[i].Interest < worstScore)
|
||||
{
|
||||
worstScore = Slots[i].Interest;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (worst >= 0)
|
||||
{
|
||||
Slots.RemoveAt(worst);
|
||||
}
|
||||
else if (Slots.Count > 0)
|
||||
{
|
||||
Slots.RemoveAt(Slots.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
Slots.Add(neu);
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SetPrefer(long unitId, bool prefer)
|
||||
{
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
if (s == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (s.Prefer == prefer)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
s.Prefer = prefer;
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static float ScoreBonus(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
StoryWeights sw = InterestScoringConfig.Story;
|
||||
float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 6f;
|
||||
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 10f;
|
||||
float fav = sw.sagaGameFavoriteWeight > 0f ? sw.sagaGameFavoriteWeight : 8f;
|
||||
|
||||
float best = BonusForUnit(c.SubjectId, mc, prefer, fav);
|
||||
if (c.RelatedId != 0)
|
||||
{
|
||||
best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav) * 0.85f);
|
||||
}
|
||||
|
||||
long followId = EventFeedUtil.SafeId(c.FollowUnit);
|
||||
if (followId != 0 && followId != c.SubjectId && followId != c.RelatedId)
|
||||
{
|
||||
best = Mathf.Max(best, BonusForUnit(followId, mc, prefer, fav));
|
||||
}
|
||||
|
||||
// Soft bias only - never approach short-arc / crisis ownership magnitudes.
|
||||
float cap = Mathf.Max(prefer, fav) + mc;
|
||||
return Mathf.Min(best, cap);
|
||||
}
|
||||
|
||||
private static float BonusForUnit(long unitId, float mc, float prefer, float fav)
|
||||
{
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
if (s == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float bonus = mc;
|
||||
if (s.Prefer)
|
||||
{
|
||||
bonus += prefer;
|
||||
}
|
||||
|
||||
if (s.GameFavorite)
|
||||
{
|
||||
bonus += fav;
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// <summary>Among candidates, pick Prefer'd MC, else any MC, else null.</summary>
|
||||
public static Actor PreferRosterUnit(IEnumerable<Actor> candidates)
|
||||
{
|
||||
if (candidates == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor bestPrefer = null;
|
||||
Actor bestMc = null;
|
||||
float bestPreferScore = float.MinValue;
|
||||
float bestMcScore = float.MinValue;
|
||||
foreach (Actor a in candidates)
|
||||
{
|
||||
if (a == null || !a.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(a);
|
||||
LifeSagaSlot s = Get(id);
|
||||
if (s == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float score = s.Interest;
|
||||
if (s.Prefer && score >= bestPreferScore)
|
||||
{
|
||||
bestPreferScore = score;
|
||||
bestPrefer = a;
|
||||
}
|
||||
|
||||
if (score >= bestMcScore)
|
||||
{
|
||||
bestMcScore = score;
|
||||
bestMc = a;
|
||||
}
|
||||
}
|
||||
|
||||
return bestPrefer ?? bestMc;
|
||||
}
|
||||
|
||||
public static bool TipTouchesMc(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsMc(c.SubjectId) || IsMc(c.RelatedId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsMc(EventFeedUtil.SafeId(c.FollowUnit));
|
||||
}
|
||||
|
||||
public static bool TipTouchesPrefer(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsPrefer(c.SubjectId) || IsPrefer(c.RelatedId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsPrefer(EventFeedUtil.SafeId(c.FollowUnit));
|
||||
}
|
||||
|
||||
/// <summary>Near-tie breaker: Prefer > MC > neither.</summary>
|
||||
public static int CompareSoftBias(InterestCandidate a, InterestCandidate b)
|
||||
{
|
||||
int ap = TipTouchesPrefer(a) ? 2 : TipTouchesMc(a) ? 1 : 0;
|
||||
int bp = TipTouchesPrefer(b) ? 2 : TipTouchesMc(b) ? 1 : 0;
|
||||
return bp.CompareTo(ap);
|
||||
}
|
||||
|
||||
public static void StampChapter(long unitId, string line, float now = -1f)
|
||||
{
|
||||
if (unitId == 0 || string.IsNullOrEmpty(line))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
if (s == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (now < 0f)
|
||||
{
|
||||
now = Time.unscaledTime;
|
||||
}
|
||||
|
||||
string trimmed = line.Trim();
|
||||
if (trimmed.Length > 72)
|
||||
{
|
||||
trimmed = trimmed.Substring(0, 72).TrimEnd() + "…";
|
||||
}
|
||||
|
||||
if (s.Chapters.Count > 0
|
||||
&& string.Equals(s.Chapters[0], trimmed, StringComparison.Ordinal))
|
||||
{
|
||||
s.LastChapterAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
s.Chapters.Insert(0, trimmed);
|
||||
while (s.Chapters.Count > MaxChapters)
|
||||
{
|
||||
s.Chapters.RemoveAt(s.Chapters.Count - 1);
|
||||
}
|
||||
|
||||
s.LastChapterAt = now;
|
||||
s.Interest = Mathf.Min(20f, s.Interest + 1.5f);
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
public static void StampChapterFromArc(StoryArc arc, InterestCandidate tip, float now)
|
||||
{
|
||||
if (arc == null || !arc.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string line = FormatChapterLine(arc, tip);
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (arc.CastIds != null)
|
||||
{
|
||||
for (int i = 0; i < arc.CastIds.Count; i++)
|
||||
{
|
||||
if (IsMc(arc.CastIds[i]))
|
||||
{
|
||||
StampChapter(arc.CastIds[i], line, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tip != null)
|
||||
{
|
||||
if (IsMc(tip.SubjectId))
|
||||
{
|
||||
StampChapter(tip.SubjectId, line, now);
|
||||
}
|
||||
|
||||
if (tip.RelatedId != 0 && IsMc(tip.RelatedId))
|
||||
{
|
||||
StampChapter(tip.RelatedId, line, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string FormatChapterLine(StoryArc arc, InterestCandidate tip)
|
||||
{
|
||||
if (arc == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(arc.Headline))
|
||||
{
|
||||
return arc.Headline.Trim();
|
||||
}
|
||||
|
||||
if (tip != null && !string.IsNullOrEmpty(tip.Label))
|
||||
{
|
||||
string label = tip.Label.Trim();
|
||||
if (label.Length > 0 && label.IndexOf("·", StringComparison.Ordinal) < 0)
|
||||
{
|
||||
return label.Length > 72 ? label.Substring(0, 72) : label;
|
||||
}
|
||||
}
|
||||
|
||||
switch (arc.Kind)
|
||||
{
|
||||
case StoryArcKind.WarFront:
|
||||
return "War front";
|
||||
case StoryArcKind.CombatDuel:
|
||||
return "Duel";
|
||||
case StoryArcKind.CombatMass:
|
||||
return "Battle";
|
||||
case StoryArcKind.Love:
|
||||
return "Love";
|
||||
case StoryArcKind.Grief:
|
||||
return "Grief";
|
||||
case StoryArcKind.Plot:
|
||||
return "Plot";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static void Tick(float now)
|
||||
{
|
||||
if (now < _nextScanAt && !_dirty)
|
||||
{
|
||||
PruneDead(now);
|
||||
return;
|
||||
}
|
||||
|
||||
_nextScanAt = now + 2.5f;
|
||||
Refill(now);
|
||||
}
|
||||
|
||||
public static void NoteFeatured(InterestCandidate scene)
|
||||
{
|
||||
if (scene == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
NoteUnit(scene.SubjectId, 0.8f, now);
|
||||
if (scene.RelatedId != 0)
|
||||
{
|
||||
NoteUnit(scene.RelatedId, 0.5f, now);
|
||||
}
|
||||
|
||||
long follow = EventFeedUtil.SafeId(scene.FollowUnit);
|
||||
if (follow != 0)
|
||||
{
|
||||
NoteUnit(follow, 1f, now);
|
||||
}
|
||||
}
|
||||
|
||||
private static void NoteUnit(long unitId, float heat, float now)
|
||||
{
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
if (s == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
s.Interest = Mathf.Min(20f, s.Interest + heat);
|
||||
s.TouchedAt = now;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
private static void Refill(float now)
|
||||
{
|
||||
Scratch.Clear();
|
||||
HashSet<long> seen = new HashSet<long>();
|
||||
|
||||
// Keep existing Prefer / game-favorite slots first.
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
LifeSagaSlot s = Slots[i];
|
||||
if (s == null || s.UnitId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
|
||||
if (a == null || !a.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RefreshSlot(s, a, now);
|
||||
Scratch.Add(s);
|
||||
seen.Add(s.UnitId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (World.world?.units != null)
|
||||
{
|
||||
foreach (Actor actor in World.world.units)
|
||||
{
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
if (id == 0 || seen.Contains(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsAdmissionCandidate(actor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LifeSagaSlot neu = BuildSlot(actor, now);
|
||||
Scratch.Add(neu);
|
||||
seen.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore scan failures
|
||||
}
|
||||
|
||||
RankAndCap(Scratch, now);
|
||||
Slots.Clear();
|
||||
for (int i = 0; i < Scratch.Count && Slots.Count < Cap; i++)
|
||||
{
|
||||
Slots.Add(Scratch[i]);
|
||||
}
|
||||
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
private static void PruneDead(float now)
|
||||
{
|
||||
bool changed = false;
|
||||
for (int i = Slots.Count - 1; i >= 0; i--)
|
||||
{
|
||||
LifeSagaSlot s = Slots[i];
|
||||
if (s == null)
|
||||
{
|
||||
Slots.RemoveAt(i);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
Actor a = EventFeedUtil.FindAliveById(s.UnitId);
|
||||
if (a == null || !a.isAlive())
|
||||
{
|
||||
Slots.RemoveAt(i);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
RefreshSlot(s, a, now);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_dirty = true;
|
||||
_nextScanAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RankAndCap(List<LifeSagaSlot> list, float now)
|
||||
{
|
||||
StoryWeights sw = InterestScoringConfig.Story;
|
||||
float dull = sw.sagaDullSeconds > 0f ? sw.sagaDullSeconds : 300f;
|
||||
|
||||
list.Sort((a, b) =>
|
||||
{
|
||||
if (a == null && b == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (a == null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b == null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Game favorites always rank first.
|
||||
int fav = (b.GameFavorite ? 1 : 0).CompareTo(a.GameFavorite ? 1 : 0);
|
||||
if (fav != 0)
|
||||
{
|
||||
return fav;
|
||||
}
|
||||
|
||||
float ai = EffectiveInterest(a, now, dull);
|
||||
float bi = EffectiveInterest(b, now, dull);
|
||||
int cmp = bi.CompareTo(ai);
|
||||
if (cmp != 0)
|
||||
{
|
||||
return cmp;
|
||||
}
|
||||
|
||||
return b.TouchedAt.CompareTo(a.TouchedAt);
|
||||
});
|
||||
|
||||
// Diversity pass: when over Cap, drop overcrowded kingdom/species before rare buckets.
|
||||
if (list.Count <= Cap)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, int> kingdomCounts = new Dictionary<string, int>();
|
||||
Dictionary<string, int> speciesCounts = new Dictionary<string, int>();
|
||||
List<LifeSagaSlot> kept = new List<LifeSagaSlot>(Cap);
|
||||
for (int pass = 0; pass < 2; pass++)
|
||||
{
|
||||
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
|
||||
{
|
||||
LifeSagaSlot s = list[i];
|
||||
if (s == null || kept.Contains(s))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.GameFavorite)
|
||||
{
|
||||
kept.Add(s);
|
||||
Bump(kingdomCounts, s.KingdomKey);
|
||||
Bump(speciesCounts, s.SpeciesId);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool kingdomCrowded = CountOf(kingdomCounts, s.KingdomKey) >= 2 && pass == 0;
|
||||
bool speciesCrowded = CountOf(speciesCounts, s.SpeciesId) >= 2 && pass == 0;
|
||||
if (pass == 0 && (kingdomCrowded || speciesCrowded))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
kept.Add(s);
|
||||
Bump(kingdomCounts, s.KingdomKey);
|
||||
Bump(speciesCounts, s.SpeciesId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining by rank if diversity pass left holes.
|
||||
for (int i = 0; i < list.Count && kept.Count < Cap; i++)
|
||||
{
|
||||
if (!kept.Contains(list[i]))
|
||||
{
|
||||
kept.Add(list[i]);
|
||||
}
|
||||
}
|
||||
|
||||
list.Clear();
|
||||
list.AddRange(kept);
|
||||
}
|
||||
|
||||
private static float EffectiveInterest(LifeSagaSlot s, float now, float dull)
|
||||
{
|
||||
float score = s.Interest;
|
||||
if (s.GameFavorite)
|
||||
{
|
||||
score += 50f;
|
||||
}
|
||||
|
||||
if (s.Prefer)
|
||||
{
|
||||
score += 8f;
|
||||
}
|
||||
|
||||
if (s.LastChapterAt > 0f && now - s.LastChapterAt > dull && !s.GameFavorite)
|
||||
{
|
||||
score *= 0.35f;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static void Bump(Dictionary<string, int> map, string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
key = "_";
|
||||
}
|
||||
|
||||
if (!map.TryGetValue(key, out int n))
|
||||
{
|
||||
n = 0;
|
||||
}
|
||||
|
||||
map[key] = n + 1;
|
||||
}
|
||||
|
||||
private static int CountOf(Dictionary<string, int> map, string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
key = "_";
|
||||
}
|
||||
|
||||
return map.TryGetValue(key, out int n) ? n : 0;
|
||||
}
|
||||
|
||||
private static bool IsAdmissionCandidate(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (actor.isFavorite())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (InterestScoring.IsNotable(actor))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsPackAlpha(actor);
|
||||
}
|
||||
|
||||
public static bool IsPackAlpha(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Family family = actor.family;
|
||||
if (family == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Actor alpha = family.getAlpha();
|
||||
return alpha != null && alpha == actor;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static LifeSagaSlot BuildSlot(Actor actor, float now)
|
||||
{
|
||||
LifeSagaSlot s = new LifeSagaSlot();
|
||||
RefreshSlot(s, actor, now);
|
||||
s.Interest = BaseInterest(actor);
|
||||
s.TouchedAt = now;
|
||||
return s;
|
||||
}
|
||||
|
||||
private static void RefreshSlot(LifeSagaSlot s, Actor actor, float now)
|
||||
{
|
||||
s.UnitId = EventFeedUtil.SafeId(actor);
|
||||
s.DisplayName = SafeName(actor);
|
||||
bool gameFav = false;
|
||||
try
|
||||
{
|
||||
gameFav = actor.isFavorite();
|
||||
}
|
||||
catch
|
||||
{
|
||||
gameFav = false;
|
||||
}
|
||||
|
||||
s.GameFavorite = gameFav;
|
||||
InterestMetadataSnapshot meta = InterestScoring.GetOrBuildMeta(actor, now);
|
||||
s.SpeciesId = meta?.SpeciesId ?? (actor.asset != null ? actor.asset.id : "");
|
||||
s.KingdomKey = meta?.KingdomKey ?? "";
|
||||
s.CityKey = meta?.CityKey ?? "";
|
||||
s.IsKing = meta != null && meta.King;
|
||||
s.IsLeader = meta != null && meta.Leader;
|
||||
s.IsAlpha = IsPackAlpha(actor);
|
||||
s.Kills = meta?.Kills ?? 0;
|
||||
s.Renown = meta?.Renown ?? 0f;
|
||||
if (s.Interest < 0.5f)
|
||||
{
|
||||
s.Interest = BaseInterest(actor);
|
||||
}
|
||||
}
|
||||
|
||||
private static float BaseInterest(Actor actor)
|
||||
{
|
||||
float score = 1f;
|
||||
try
|
||||
{
|
||||
if (actor.isFavorite())
|
||||
{
|
||||
score += 12f;
|
||||
}
|
||||
|
||||
if (actor.isKing())
|
||||
{
|
||||
score += 10f;
|
||||
}
|
||||
else if (actor.isCityLeader())
|
||||
{
|
||||
score += 6f;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (IsPackAlpha(actor))
|
||||
{
|
||||
score += 7f;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int kills = actor.data != null ? actor.data.kills : 0;
|
||||
score += Mathf.Min(8f, kills * 0.05f);
|
||||
score += Mathf.Min(4f, actor.renown / 100f);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
score += LifeSagaOverview.StandoutTraitScore(actor) * 0.02f;
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string SafeName(Actor actor)
|
||||
{
|
||||
try
|
||||
{
|
||||
string n = actor.getName();
|
||||
if (!string.IsNullOrEmpty(n))
|
||||
{
|
||||
return n;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return actor.asset != null ? actor.asset.id : "Unit";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One life on the saga roster.</summary>
|
||||
public sealed class LifeSagaSlot
|
||||
{
|
||||
public long UnitId;
|
||||
public string DisplayName = "";
|
||||
public string SpeciesId = "";
|
||||
public string KingdomKey = "";
|
||||
public string CityKey = "";
|
||||
public bool GameFavorite;
|
||||
public bool Prefer;
|
||||
public bool IsKing;
|
||||
public bool IsLeader;
|
||||
public bool IsAlpha;
|
||||
public int Kills;
|
||||
public float Renown;
|
||||
public float Interest;
|
||||
public float TouchedAt;
|
||||
public float LastChapterAt;
|
||||
public readonly List<string> Chapters = new List<string>(LifeSagaRoster.MaxChapters);
|
||||
|
||||
public Actor Resolve() => EventFeedUtil.FindAliveById(UnitId);
|
||||
}
|
||||
|
|
@ -32,8 +32,15 @@ public sealed class StoryArc
|
|||
public StoryPhase Phase = StoryPhase.None;
|
||||
public string AnchorKey = "";
|
||||
public string ClimaxKey = "";
|
||||
/// <summary>
|
||||
/// Short cast/theater identity for the dossier rail (e.g. "Evil Mage vs Lona").
|
||||
/// Not Kind/Phase taxonomy - who the story is about.
|
||||
/// </summary>
|
||||
public string Headline = "";
|
||||
public float StartedAt;
|
||||
public float PhaseStartedAt;
|
||||
/// <summary>When parked off-camera (0 = currently watching / never parked).</summary>
|
||||
public float ParkedAt;
|
||||
/// <summary>True when this arc uses hard hold margins (not soft anonymous duels).</summary>
|
||||
public bool HardHold;
|
||||
public readonly List<long> CastIds = new List<long>(4);
|
||||
|
|
@ -42,6 +49,8 @@ public sealed class StoryArc
|
|||
public bool IsActive =>
|
||||
Phase != StoryPhase.None && Phase != StoryPhase.Done;
|
||||
|
||||
public bool IsParked => ParkedAt > 0f && IsActive;
|
||||
|
||||
public bool ContainsCast(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -414,6 +414,7 @@ public static class WatchCaption
|
|||
LastDetail = "";
|
||||
LastCaptionText = "";
|
||||
LastStorySpine = "";
|
||||
LifeSagaRail.Clear();
|
||||
LastHistoryPreview = "";
|
||||
LastHistoryJoined = "";
|
||||
LastHistoryShown = 0;
|
||||
|
|
@ -720,6 +721,26 @@ public static class WatchCaption
|
|||
SetFromActor(focus);
|
||||
}
|
||||
|
||||
/// <summary>True while a temporary status banner owns the reason row.</summary>
|
||||
public static bool HasStatusBannerActive() => HasStatusBanner();
|
||||
|
||||
/// <summary>Storyline rail hover/commit asks for a soft relayout without a full rebuild.</summary>
|
||||
public static void RequestRelayout()
|
||||
{
|
||||
if (!_visible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Relayout(
|
||||
_boundActor != null || CountActiveHistorySlots() > 0 || _current != null,
|
||||
CountActiveTraitSlots(),
|
||||
CountActiveStatusSlots(),
|
||||
_taskText != null && _taskText.gameObject.activeSelf,
|
||||
_reasonText != null && _reasonText.gameObject.activeSelf,
|
||||
CountActiveHistorySlots());
|
||||
}
|
||||
|
||||
private static bool HasStatusBanner()
|
||||
{
|
||||
return !string.IsNullOrEmpty(_statusBanner) && Time.unscaledTime < _statusBannerUntil;
|
||||
|
|
@ -845,6 +866,20 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
// Transient ownership miss mid-handoff: keep the last orange reason while a
|
||||
// labeled event tip is still active (avoids a blank frame during Love Follow swaps).
|
||||
// Do not keep across character-fill / empty-Label vignettes.
|
||||
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||
if (string.IsNullOrEmpty(next)
|
||||
&& !string.IsNullOrEmpty(prev)
|
||||
&& scene != null
|
||||
&& !InterestDirector.InQuietGrace
|
||||
&& scene.LeadKind != InterestLeadKind.CharacterLed
|
||||
&& !string.IsNullOrEmpty(scene.Label))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool headcountOnly = EventReason.IsHeadcountOnlyChange(prev, next);
|
||||
_current.ReasonLine = next;
|
||||
LastDetail = _current.DetailLine ?? "";
|
||||
|
|
@ -884,18 +919,19 @@ public static class WatchCaption
|
|||
{
|
||||
if (!_visible || HasStatusBanner())
|
||||
{
|
||||
LifeSagaRail.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
string next = SpectatorMode.Active && ModSettings.ShowDossierCaption
|
||||
string next = (SpectatorMode.Active || SpectatorMode.BrowsePaused) && ModSettings.ShowDossierCaption
|
||||
? StoryPlanner.FormatSpineLabel()
|
||||
: "";
|
||||
if (string.Equals(next, LastStorySpine, StringComparison.Ordinal))
|
||||
if (!string.Equals(next, LastStorySpine, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
ApplyStorySpine(next);
|
||||
}
|
||||
|
||||
ApplyStorySpine(next);
|
||||
LifeSagaRail.Refresh();
|
||||
bool hasReason = _reasonText != null && _reasonText.gameObject.activeSelf;
|
||||
bool hasTask = _taskText != null && _taskText.gameObject.activeSelf;
|
||||
bool hasBody = _boundActor != null || CountActiveHistorySlots() > 0 || _current != null;
|
||||
|
|
@ -2386,6 +2422,11 @@ public static class WatchCaption
|
|||
y += StorySpineH + Gap;
|
||||
}
|
||||
|
||||
if (LifeSagaRail.Visible)
|
||||
{
|
||||
y = LifeSagaRail.Place(y, PadX);
|
||||
}
|
||||
|
||||
float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap);
|
||||
if (_rootRt != null)
|
||||
{
|
||||
|
|
@ -3256,6 +3297,8 @@ public static class WatchCaption
|
|||
_storySpineText.resizeTextForBestFit = false;
|
||||
_storySpineText.raycastTarget = false;
|
||||
|
||||
LifeSagaRail.EnsureBuilt(_root.transform);
|
||||
|
||||
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
|
||||
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);
|
||||
|
||||
|
|
|
|||
|
|
@ -1092,12 +1092,15 @@ public static class WorldActivityScanner
|
|||
SplitActorScore(actor, speciesCounts, out float action, out float character);
|
||||
// Action-primary total used for ranking; character is a light tie-break.
|
||||
float score = action + character * InterestScoringConfig.W.scannerRankCharWeight;
|
||||
// Character Ledger bias for ambient fill - prefer watched lives over strangers.
|
||||
// Life-saga bias for ambient fill - prefer roster MCs (Prefer first) over strangers.
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
float ledger = CharacterLedger.Heat(id);
|
||||
if (ledger > 0f)
|
||||
if (LifeSagaRoster.IsPrefer(id))
|
||||
{
|
||||
score += ledger * Mathf.Max(1f, InterestScoringConfig.Story.ledgerWeight * 0.35f);
|
||||
score += Mathf.Max(4f, InterestScoringConfig.Story.sagaPreferWeight * 0.45f);
|
||||
}
|
||||
else if (LifeSagaRoster.IsMc(id))
|
||||
{
|
||||
score += Mathf.Max(2f, InterestScoringConfig.Story.sagaMcWeight * 0.45f);
|
||||
}
|
||||
|
||||
float causal = CausalHeat.Get(id);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.7",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@
|
|||
"historyDensityWindowSeconds": 600,
|
||||
"ledgerBleedLifeChapter": 0.4,
|
||||
"ledgerBleedVillage": 0.82,
|
||||
"sagaMcWeight": 6,
|
||||
"sagaPreferWeight": 10,
|
||||
"sagaGameFavoriteWeight": 8,
|
||||
"sagaDullSeconds": 300,
|
||||
"familyLifeBeatCooldownSeconds": 90,
|
||||
"crisisWarParticipantMin": 8,
|
||||
"crisisDisasterStrengthMin": 95,
|
||||
|
|
@ -142,7 +146,8 @@
|
|||
"crisisCooldownSeconds": 45,
|
||||
"crisisDisasterCooldownSeconds": 120,
|
||||
"crisisEpilogueStrength": 52,
|
||||
"crisisEpilogueMaxWatch": 16
|
||||
"crisisEpilogueMaxWatch": 16,
|
||||
"softFillQuietSeconds": 18
|
||||
},
|
||||
|
||||
"sources": [
|
||||
|
|
|
|||
47
docs/life-saga.md
Normal file
47
docs/life-saga.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Life-saga roster
|
||||
|
||||
Long-lived cast of interesting lives (MCs). Owns the dossier rail and soft camera bias.
|
||||
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.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
|---------|--------|
|
||||
| Top-10 MCs, diversity, Prefer | `LifeSagaRoster` |
|
||||
| Hover card | `LifeSagaOverview` |
|
||||
| Rail glyphs / Prefer click | `LifeSagaRail` |
|
||||
| Short climax → aftermath → epilogue | `StoryPlanner` (thinned) |
|
||||
| Spine under tip | `StoryPlanner.FormatSpineLabel` |
|
||||
| Full history | Chronicle / Lore |
|
||||
|
||||
## Camera (soft)
|
||||
|
||||
- No rail camera lock. Prefer is a toggle soft-favorite for idle scoring.
|
||||
- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > CausalHeat.
|
||||
- Near-ties prefer Prefer'd MCs, then any MC.
|
||||
- Ambient fill prefers roster MCs over random villagers.
|
||||
|
||||
## Rail UX
|
||||
|
||||
- Up to 10 glyphs (role/species letter).
|
||||
- Click toggles saga Prefer (does not mutate WorldBox unit favorite).
|
||||
- Hover: Title (name + optional epithet + role) / Why / Circle / Chapters.
|
||||
- Chapters are stamped saga beats only - not Chronicle peeks.
|
||||
- Trait names may appear once as a title epithet; trait descs never appear on the hover.
|
||||
|
||||
## Session
|
||||
|
||||
- `StoryPlanner.Clear` does not wipe the roster.
|
||||
- Lore browse / brief idle-off keeps the roster.
|
||||
- Harness batch end and `interest_saga_clear` wipe the roster.
|
||||
|
||||
## Harness
|
||||
|
||||
- `saga_roster_diversity`
|
||||
- `saga_camera_prefers_mc`
|
||||
- `saga_prefer_soft_bias`
|
||||
- `saga_overview_has_mc`
|
||||
- `saga_replace_on_death`
|
||||
|
||||
See also: [story-planner.md](story-planner.md).
|
||||
|
|
@ -5,7 +5,33 @@ IdleSpectator commits to short multi-beat stories instead of channel-surfing ran
|
|||
Feeds, sticky ensembles, presentability, and `InterestDirector` stay in place.
|
||||
`StoryPlanner` sits above the director and owns *which story* the next 30–90s belongs to.
|
||||
|
||||
See also: [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
|
||||
## Short-arc board (not the dossier rail)
|
||||
|
||||
Hard storylines (notable duel / mass / war / plot / love / grief) still form a small
|
||||
watching + parked board (cap 4) for short-chapter resume.
|
||||
Cutting away **parks** a hard arc; soft anonymous scraps still end.
|
||||
There is **no** rail Commit / hard pin anymore.
|
||||
|
||||
- Lore browse uses `browsePause` - freezes idle without `StoryPlanner.Clear()`.
|
||||
- `StoryPlanner.Clear` does **not** wipe the life-saga roster.
|
||||
- Combat spine Kind comes from sticky/live ensemble scale + `StoryArcKind`, not tip Label prefixes.
|
||||
|
||||
Dossier **life-saga rail** is owned by `LifeSagaRoster` / `LifeSagaRail` (up to 10 MCs).
|
||||
Click toggles Prefer (soft favorite). Hover is a multi-line saga overview.
|
||||
See [life-saga.md](life-saga.md).
|
||||
|
||||
Crisis camera hold: WarFront always blocks Mass/Battle cut-ins (`war_theater_hold`).
|
||||
Disaster / outbreak signals use the same class rule while their chapter is live
|
||||
(`crisis_theater_hold`), classified via live `AssetManager.disasters` + catalog categories
|
||||
(not tip-string `IndexOf`).
|
||||
|
||||
`InterestDirector` is a partial class: session orchestrator + `StickyMaintain` + `SwitchPolicy`.
|
||||
|
||||
Harness: `story_lore_pause_keeps`, `story_board_park_resume` (short-arc park/resume via
|
||||
`story_resume_parked`), `story_crisis_war`, `story_crisis_disaster`, `war_front_sticky`,
|
||||
plus `saga_*` scenarios.
|
||||
|
||||
See also: [life-saga.md](life-saga.md), [scoring-model.md](scoring-model.md), [event-reason.md](event-reason.md).
|
||||
|
||||
## Pipeline
|
||||
|
||||
|
|
@ -135,6 +161,11 @@ warm and cannot steal the War tip (`war_theater_hold`). Crisis closers clear sho
|
|||
and suppress combat task detail crumbs under the orange reason.
|
||||
|
||||
Short-arc related epilogue is skipped when a crisis closer owns the exit.
|
||||
When a short-arc war linger injects while a war crisis is live, the crisis quiet-closes
|
||||
(no second `epilogue_crisis`). Harness batch end and `interest_story_purge_leftovers`
|
||||
drop leftover crisis closers so free AFK does not inherit a fake ending.
|
||||
After hard story/crisis ends, `softFillQuietSeconds` suppresses soft-life crumbs and
|
||||
character-fill ambient briefly.
|
||||
|
||||
## Selection discipline
|
||||
|
||||
|
|
@ -172,7 +203,8 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
|||
| `story_resume_cooled` | cooled soft tip does not resume after cut-in |
|
||||
| `story_worldlog_meta_cool` | king_dead cools; peer wins next pick |
|
||||
| `combat_wiped_side` | wiped camp never prints vs (0) |
|
||||
| `story_aftermath_war` | war sticky cold → aftermath |
|
||||
| `story_aftermath_war` | war sticky cold → aftermath; no dual crisis closer |
|
||||
| `story_crisis_hygiene` | auto-opened war crisis + short-arc linger; purge leaves no epilogue leak |
|
||||
| `story_near_tie` | large gap always picks #1 |
|
||||
| `story_arc_combat` | climax → aftermath ownership |
|
||||
| `story_arc_preempt_resume` | epic cut then fresh scrap aftermath |
|
||||
|
|
@ -204,9 +236,10 @@ Catalog policy ids live under `event-catalog.json` → `story`
|
|||
|
||||
## Files
|
||||
|
||||
- `IdleSpectator/Story/StoryPlanner.cs` - ownership, cold-hook, inject, spine labels, crisis overlay
|
||||
- `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/CrisisChapter.cs` - parallel crisis chapter state
|
||||
- `IdleSpectator/Story/CausalHeat.cs` - decaying cast heat
|
||||
- `IdleSpectator/Story/CharacterLedger.cs` - watched lives (cap 16)
|
||||
- `IdleSpectator/Story/StoryReason.cs` - presentable aftermath Labels
|
||||
- `IdleSpectator/WatchCaption.cs` - dossier `Kind · Phase` spine row
|
||||
|
|
|
|||
Loading…
Reference in a new issue