feat(saga): bias director onto MCs and note cross-MC beats
- Prefer/MC/cast win moderate tip gaps; combat follow and ambient cut-in follow roster - Dual-light rail when tip touches multiple MCs; record MC↔MC crossover memory - Earn rivals only from rematches, kin, or war/plot opposition (not single kills) - Add harness coverage for gap, cast, rival, cross-MC, and ambient cut paths
This commit is contained in:
parent
6fb62c47d6
commit
3941dc33e5
16 changed files with 1290 additions and 78 deletions
|
|
@ -1359,6 +1359,24 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "focus_partner":
|
||||
{
|
||||
Actor partner = _happinessPartner;
|
||||
bool ok = partner != null && partner.isAlive();
|
||||
if (ok)
|
||||
{
|
||||
CameraDirector.FocusUnit(partner);
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: ok ? $"focusPartner={SafeName(partner)}" : "no partner");
|
||||
break;
|
||||
}
|
||||
|
||||
case "happiness_bond_lovers":
|
||||
{
|
||||
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -2954,6 +2972,99 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "saga_force_admit_partner":
|
||||
{
|
||||
Actor partner = _happinessPartner;
|
||||
long id = EventFeedUtil.SafeId(partner);
|
||||
bool ok = LifeSagaRoster.HarnessForceAdmit(partner);
|
||||
if (ok)
|
||||
{
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"admitPartner id={id} roster={LifeSagaRoster.Count} on={ok}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_note_encounters":
|
||||
{
|
||||
Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
Actor b = _happinessPartner;
|
||||
int times = 3;
|
||||
if (!string.IsNullOrEmpty(cmd.value) && int.TryParse(cmd.value.Trim(), out int parsed))
|
||||
{
|
||||
times = Mathf.Max(1, parsed);
|
||||
}
|
||||
|
||||
bool ok = a != null && b != null && a != b;
|
||||
if (ok)
|
||||
{
|
||||
// Bypass cooldown by clearing pair cool via Clear would wipe memory - call with
|
||||
// distinct provenance and temporarily force by calling NoteCombatEncounter
|
||||
// after clearing only PairEncounterCooldown through repeated ClearSession is bad.
|
||||
// Use EarnRival path for rematch: clear cooldown by noting with spaced calls.
|
||||
for (int i = 0; i < times; i++)
|
||||
{
|
||||
LifeSagaMemory.HarnessNoteCombatEncounter(a, b, "harness_rematch:" + i);
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"encounters={times} a={SafeName(a)} b={SafeName(b)}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_record_kill_partner":
|
||||
{
|
||||
Actor killer = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
Actor victim = _happinessPartner;
|
||||
bool ok = killer != null && victim != null && killer != victim;
|
||||
if (ok)
|
||||
{
|
||||
LifeSagaMemory.RecordKill(killer, victim, "harness_kill");
|
||||
LifeSagaMemory.RecordDeath(victim, killer, "harness", "harness_death");
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok, detail: $"kill {SafeName(killer)} -> {SafeName(victim)}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "interest_bind_related_partner":
|
||||
{
|
||||
InterestCandidate cur = InterestDirector.CurrentCandidate;
|
||||
Actor partner = _happinessPartner;
|
||||
bool ok = cur != null && partner != null && partner.isAlive();
|
||||
if (ok)
|
||||
{
|
||||
cur.RelatedUnit = partner;
|
||||
cur.RelatedId = EventFeedUtil.SafeId(partner);
|
||||
InterestScoring.RecalcTotal(cur);
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, ok,
|
||||
detail: $"bind related={SafeName(partner)} tip={cur?.Label} score={cur?.TotalScore:0.#}");
|
||||
break;
|
||||
}
|
||||
|
||||
case "saga_stamp_chapter_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -8476,6 +8587,60 @@ public static class AgentHarness
|
|||
$"preferPip={LifeSagaRail.LastPreferPipVisible} want={want} activeLit={LifeSagaRail.LastActiveHighlight}";
|
||||
break;
|
||||
}
|
||||
case "saga_rail_involved_count":
|
||||
{
|
||||
LifeSagaRail.Refresh();
|
||||
int want = 1;
|
||||
if (!string.IsNullOrEmpty(cmd.value) && int.TryParse(cmd.value.Trim(), out int parsed))
|
||||
{
|
||||
want = parsed;
|
||||
}
|
||||
|
||||
pass = LifeSagaRail.LastInvolvedHighlightCount >= want;
|
||||
detail =
|
||||
$"involvedLit={LifeSagaRail.LastInvolvedHighlightCount} want>={want} active={LifeSagaRail.LastActiveHighlight}";
|
||||
break;
|
||||
}
|
||||
case "saga_rival_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = LifeSagaMemory.TryGetEarnedRival(id, out LifeSagaFact rival) && rival != null;
|
||||
pass = have == want;
|
||||
detail = $"rival focus={SafeName(focus)} have={have} want={want} note={rival?.Note}";
|
||||
break;
|
||||
}
|
||||
case "saga_crossover_focus":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
long id = EventFeedUtil.SafeId(focus);
|
||||
bool want = ParseBool(cmd.value, defaultValue: true);
|
||||
bool have = false;
|
||||
var facts = LifeSagaMemory.FactsFor(id);
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
if (facts[i] != null && facts[i].McCrossover)
|
||||
{
|
||||
have = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pass = have == want;
|
||||
detail = $"crossover focus={SafeName(focus)} have={have} want={want} facts={facts.Count}";
|
||||
break;
|
||||
}
|
||||
case "saga_combat_weight_beats_partner":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
Actor partner = _happinessPartner;
|
||||
float wf = WorldActivityScanner.CombatFocusWeight(focus);
|
||||
float wp = WorldActivityScanner.CombatFocusWeight(partner);
|
||||
pass = focus != null && partner != null && wf > wp;
|
||||
detail = $"focusW={wf:0.#} partnerW={wp:0.#} focus={SafeName(focus)} partner={SafeName(partner)}";
|
||||
break;
|
||||
}
|
||||
case "saga_snapshot_layout":
|
||||
{
|
||||
int rows = LifeSagaRail.LastHoverRowCount;
|
||||
|
|
|
|||
|
|
@ -155,6 +155,22 @@ internal static class HarnessScenarios
|
|||
return SagaReplaceOnDeath();
|
||||
case "saga_prefer_soft_bias":
|
||||
return SagaPreferSoftBias();
|
||||
case "saga_wins_moderate_gap":
|
||||
return SagaWinsModerateGap();
|
||||
case "saga_combat_follow_mc":
|
||||
return SagaCombatFollowMc();
|
||||
case "saga_cast_soft_bias":
|
||||
return SagaCastSoftBias();
|
||||
case "saga_cross_mc_light":
|
||||
return SagaCrossMcLight();
|
||||
case "saga_cross_mc_kill":
|
||||
return SagaCrossMcKill();
|
||||
case "saga_rival_not_single_kill":
|
||||
return SagaRivalNotSingleKill();
|
||||
case "saga_rival_rematch":
|
||||
return SagaRivalRematch();
|
||||
case "saga_prefer_cuts_ambient":
|
||||
return SagaPreferCutsAmbient();
|
||||
case "saga_overview_has_mc":
|
||||
return SagaOverviewHasMc();
|
||||
case "saga_snapshot_popover":
|
||||
|
|
@ -363,6 +379,14 @@ internal static class HarnessScenarios
|
|||
Nested("reg_story_family", "story_family_variety"),
|
||||
Nested("reg_saga_camera", "saga_camera_prefers_mc"),
|
||||
Nested("reg_saga_prefer", "saga_prefer_soft_bias"),
|
||||
Nested("reg_saga_moderate_gap", "saga_wins_moderate_gap"),
|
||||
Nested("reg_saga_combat_follow", "saga_combat_follow_mc"),
|
||||
Nested("reg_saga_cast_bias", "saga_cast_soft_bias"),
|
||||
Nested("reg_saga_cross_light", "saga_cross_mc_light"),
|
||||
Nested("reg_saga_cross_kill", "saga_cross_mc_kill"),
|
||||
Nested("reg_saga_rival_kill", "saga_rival_not_single_kill"),
|
||||
Nested("reg_saga_rival_rematch", "saga_rival_rematch"),
|
||||
Nested("reg_saga_ambient_cut", "saga_prefer_cuts_ambient"),
|
||||
Nested("reg_saga_overview", "saga_overview_has_mc"),
|
||||
Nested("reg_saga_snapshot", "saga_adaptive_dossier"),
|
||||
Nested("reg_saga_memory", "saga_memory_survives"),
|
||||
|
|
@ -2963,6 +2987,232 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>Prefer/MC tip wins when a stranger is moderately hotter (inside sagaNearTieEpsilon).</summary>
|
||||
private static List<HarnessCommand> SagaWinsModerateGap()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("smg0", "dismiss_windows"),
|
||||
Step("smg1", "wait_world"),
|
||||
Step("smg2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("smg3", "fast_timing", value: "true"),
|
||||
Step("smg4", "spawn", asset: "human", count: 2),
|
||||
Step("smg5", "spectator", value: "off"),
|
||||
Step("smg6", "spectator", value: "on"),
|
||||
Step("smg7", "pick_unit", asset: "human"),
|
||||
Step("smg8", "focus", asset: "human"),
|
||||
Step("smg9", "interest_end_session"),
|
||||
Step("smg10", "interest_saga_clear"),
|
||||
Step("smg11", "saga_force_admit_focus"),
|
||||
Step("smg12", "assert", expect: "saga_has_focus"),
|
||||
Step("smg13", "interest_expire_pending", value: ""),
|
||||
// Stranger hotter by ~15-20 after RecalcTotal; sagaNearTieEpsilon (28) still picks MC.
|
||||
Step("smg20", "interest_inject", asset: "human", label: "ModGapMc", tier: "Action",
|
||||
expect: "smg_mc", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
|
||||
Step("smg21", "interest_inject", asset: "human", label: "ModGapStranger", tier: "Action",
|
||||
expect: "smg_stranger", value: "lead=event;evt=78;char=5;force=false;ttl=60;pick=other"),
|
||||
Step("smg22", "director_run", wait: 1.2f),
|
||||
Step("smg23", "assert", expect: "tip_contains", value: "ModGapMc"),
|
||||
Step("smg24", "assert", expect: "tip_not_contains", value: "ModGapStranger"),
|
||||
Step("smg90", "fast_timing", value: "false"),
|
||||
Step("smg99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>MC CombatFocusWeight beats a non-MC partner (mid-fight follow bias).</summary>
|
||||
private static List<HarnessCommand> SagaCombatFollowMc()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("scf0", "dismiss_windows"),
|
||||
Step("scf1", "wait_world"),
|
||||
Step("scf2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("scf3", "spawn", asset: "human", count: 2),
|
||||
Step("scf4", "spectator", value: "off"),
|
||||
Step("scf5", "spectator", value: "on"),
|
||||
// Partner first (non-MC), then admit a different focus as MC.
|
||||
Step("scf6", "pick_unit", asset: "human"),
|
||||
Step("scf7", "happiness_remember_partner"),
|
||||
Step("scf8", "pick_unit", asset: "human", value: "other"),
|
||||
Step("scf9", "focus", asset: "human"),
|
||||
Step("scf10", "interest_saga_clear"),
|
||||
Step("scf11", "saga_force_admit_focus"),
|
||||
Step("scf12", "assert", expect: "saga_has_focus"),
|
||||
Step("scf13", "assert", expect: "saga_combat_weight_beats_partner"),
|
||||
Step("scf99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>MC lover tip beats an equal stranger tip via cast soft bias.</summary>
|
||||
private static List<HarnessCommand> SagaCastSoftBias()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("scb0", "dismiss_windows"),
|
||||
Step("scb1", "wait_world"),
|
||||
Step("scb2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("scb3", "fast_timing", value: "true"),
|
||||
Step("scb4", "spawn", asset: "human", count: 3),
|
||||
Step("scb5", "spectator", value: "off"),
|
||||
Step("scb6", "spectator", value: "on"),
|
||||
// Remember lover candidate first, then admit a different MC and bond them.
|
||||
Step("scb7", "pick_unit", asset: "human"),
|
||||
Step("scb8", "happiness_remember_partner"),
|
||||
Step("scb9", "pick_unit", asset: "human", value: "other"),
|
||||
Step("scb10", "focus", asset: "human"),
|
||||
Step("scb11", "interest_end_session"),
|
||||
Step("scb12", "interest_saga_clear"),
|
||||
Step("scb13", "saga_force_admit_focus"),
|
||||
Step("scb14", "happiness_bond_lovers"),
|
||||
Step("scb15", "interest_expire_pending", value: ""),
|
||||
Step("scb20", "focus_partner"),
|
||||
Step("scb21", "interest_inject", asset: "human", label: "CastLoverWin", tier: "Action",
|
||||
expect: "scb_cast", value: "lead=event;evt=55;char=5;force=false;ttl=60"),
|
||||
Step("scb22", "interest_inject", asset: "human", label: "CastStrangerLose", tier: "Action",
|
||||
expect: "scb_stranger", value: "lead=event;evt=55;char=5;force=false;ttl=60;pick=other"),
|
||||
Step("scb23", "director_run", wait: 1.2f),
|
||||
Step("scb24", "assert", expect: "tip_contains", value: "CastLoverWin"),
|
||||
Step("scb25", "assert", expect: "tip_not_contains", value: "CastStrangerLose"),
|
||||
Step("scb90", "fast_timing", value: "false"),
|
||||
Step("scb99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Two admitted MCs on one tip light Active + Involved rail chrome.</summary>
|
||||
private static List<HarnessCommand> SagaCrossMcLight()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("scl0", "dismiss_windows"),
|
||||
Step("scl1", "wait_world"),
|
||||
Step("scl2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("scl3", "set_setting", expect: "show_dossier_caption", value: "true"),
|
||||
Step("scl4", "spawn", asset: "human", count: 2),
|
||||
Step("scl5", "spectator", value: "off"),
|
||||
Step("scl6", "spectator", value: "on"),
|
||||
Step("scl7", "pick_unit", asset: "human"),
|
||||
Step("scl8", "happiness_remember_partner"),
|
||||
Step("scl9", "pick_unit", asset: "human", value: "other"),
|
||||
Step("scl10", "focus", asset: "human"),
|
||||
Step("scl11", "interest_end_session"),
|
||||
Step("scl12", "interest_saga_clear"),
|
||||
Step("scl13", "saga_force_admit_focus"),
|
||||
Step("scl14", "saga_force_admit_partner"),
|
||||
Step("scl15", "interest_expire_pending", value: ""),
|
||||
Step("scl20", "interest_inject", asset: "human", label: "CrossMcTip", tier: "Action",
|
||||
expect: "scl_tip", value: "lead=event;evt=60;char=5;force=true;ttl=60"),
|
||||
Step("scl21", "interest_bind_related_partner"),
|
||||
Step("scl22", "wait", wait: 0.4f),
|
||||
Step("scl23", "assert", expect: "saga_rail_active_highlight"),
|
||||
Step("scl24", "assert", expect: "saga_rail_involved_count", value: "1"),
|
||||
Step("scl99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>MC kills MC → both memories hold McCrossover facts.</summary>
|
||||
private static List<HarnessCommand> SagaCrossMcKill()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("sck0", "dismiss_windows"),
|
||||
Step("sck1", "wait_world"),
|
||||
Step("sck2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("sck3", "spawn", asset: "human", count: 2),
|
||||
Step("sck4", "spectator", value: "off"),
|
||||
Step("sck5", "spectator", value: "on"),
|
||||
Step("sck6", "pick_unit", asset: "human"),
|
||||
Step("sck7", "happiness_remember_partner"),
|
||||
Step("sck8", "pick_unit", asset: "human", value: "other"),
|
||||
Step("sck9", "focus", asset: "human"),
|
||||
Step("sck10", "interest_saga_clear"),
|
||||
Step("sck11", "saga_force_admit_focus"),
|
||||
Step("sck12", "saga_force_admit_partner"),
|
||||
Step("sck13", "saga_record_kill_partner"),
|
||||
Step("sck14", "assert", expect: "saga_crossover_focus"),
|
||||
Step("sck15", "assert", expect: "saga_rival_focus", value: "false"),
|
||||
Step("sck99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One kill+death cycle must not earn RivalEarned.</summary>
|
||||
private static List<HarnessCommand> SagaRivalNotSingleKill()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srk0", "dismiss_windows"),
|
||||
Step("srk1", "wait_world"),
|
||||
Step("srk2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("srk3", "spawn", asset: "human", count: 2),
|
||||
Step("srk4", "spectator", value: "off"),
|
||||
Step("srk5", "spectator", value: "on"),
|
||||
Step("srk6", "pick_unit", asset: "human"),
|
||||
Step("srk7", "happiness_remember_partner"),
|
||||
Step("srk8", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srk9", "focus", asset: "human"),
|
||||
Step("srk10", "interest_saga_clear"),
|
||||
Step("srk11", "saga_force_admit_focus"),
|
||||
Step("srk12", "saga_record_kill_partner"),
|
||||
Step("srk13", "assert", expect: "saga_rival_focus", value: "false"),
|
||||
Step("srk99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Threshold living rematches earn RivalEarned.</summary>
|
||||
private static List<HarnessCommand> SagaRivalRematch()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("srr0", "dismiss_windows"),
|
||||
Step("srr1", "wait_world"),
|
||||
Step("srr2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("srr3", "spawn", asset: "human", count: 2),
|
||||
Step("srr4", "spectator", value: "off"),
|
||||
Step("srr5", "spectator", value: "on"),
|
||||
Step("srr6", "pick_unit", asset: "human"),
|
||||
Step("srr7", "happiness_remember_partner"),
|
||||
Step("srr8", "pick_unit", asset: "human", value: "other"),
|
||||
Step("srr9", "focus", asset: "human"),
|
||||
Step("srr10", "interest_saga_clear"),
|
||||
Step("srr11", "saga_force_admit_focus"),
|
||||
Step("srr12", "saga_note_encounters", value: "3"),
|
||||
Step("srr13", "assert", expect: "saga_rival_focus"),
|
||||
Step("srr99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Prefer tip cuts an ambient/fill hold without waiting for full margin.</summary>
|
||||
private static List<HarnessCommand> SagaPreferCutsAmbient()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("spa0", "dismiss_windows"),
|
||||
Step("spa1", "wait_world"),
|
||||
Step("spa2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("spa3", "fast_timing", value: "true"),
|
||||
Step("spa4", "spawn", asset: "human", count: 2),
|
||||
Step("spa5", "spectator", value: "off"),
|
||||
Step("spa6", "spectator", value: "on"),
|
||||
Step("spa7", "pick_unit", asset: "human"),
|
||||
Step("spa8", "focus", asset: "human"),
|
||||
Step("spa9", "interest_end_session"),
|
||||
Step("spa10", "interest_saga_clear"),
|
||||
Step("spa11", "saga_force_admit_focus"),
|
||||
Step("spa12", "saga_prefer_focus"),
|
||||
Step("spa13", "interest_expire_pending", value: ""),
|
||||
// Low fill-band ambient tip first.
|
||||
Step("spa20", "interest_inject", asset: "human", label: "AmbientFillHold", tier: "Curiosity",
|
||||
expect: "spa_fill", value: "lead=character;evt=12;char=8;force=true;ttl=60;pick=other"),
|
||||
Step("spa21", "director_run", wait: 0.6f),
|
||||
Step("spa22", "assert", expect: "tip_contains", value: "AmbientFillHold"),
|
||||
Step("spa23", "interest_inject", asset: "human", label: "PreferCutsAmbient", tier: "Action",
|
||||
expect: "spa_prefer", value: "lead=event;evt=50;char=5;force=false;ttl=60"),
|
||||
Step("spa24", "director_run", wait: 1.0f),
|
||||
Step("spa25", "assert", expect: "tip_contains", value: "PreferCutsAmbient"),
|
||||
Step("spa90", "fast_timing", value: "false"),
|
||||
Step("spa99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compact saga snapshot: admission headline, collapsible layout, structured chapters,
|
||||
/// no planner jargon / trait descs / Chronicle dumps.
|
||||
|
|
|
|||
|
|
@ -585,6 +585,14 @@ public static partial class InterestDirector
|
|||
|
||||
// Framing-only Battle↔Duel still needs story Kind sync (no SwitchTo).
|
||||
StoryPlanner.SyncActiveClimax(_current);
|
||||
|
||||
// Living rematch counter for rival earning (deduped inside NoteCombatEncounter).
|
||||
// Kill/death paths must never call this.
|
||||
if (best != null && foe != null)
|
||||
{
|
||||
LifeSagaMemory.NoteCombatEncounter(best, foe, "sticky_combat");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -658,6 +666,28 @@ public static partial class InterestDirector
|
|||
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
|
||||
}
|
||||
|
||||
// Opposing war camps: notable rivalry when both principals are living.
|
||||
if (_current.FollowUnit != null
|
||||
&& _current.RelatedUnit != null
|
||||
&& !string.IsNullOrEmpty(_current.CorrelationKey))
|
||||
{
|
||||
LifeSagaMemory.NoteWarOpposition(
|
||||
_current.FollowUnit,
|
||||
_current.RelatedUnit,
|
||||
_current.CorrelationKey,
|
||||
"war_front_maintain");
|
||||
}
|
||||
else if (_current.FollowUnit != null
|
||||
&& _current.RelatedUnit != null
|
||||
&& !string.IsNullOrEmpty(_current.AssetId))
|
||||
{
|
||||
LifeSagaMemory.NoteWarOpposition(
|
||||
_current.FollowUnit,
|
||||
_current.RelatedUnit,
|
||||
_current.AssetId + ":" + _current.SubjectId,
|
||||
"war_front_maintain");
|
||||
}
|
||||
|
||||
string label = EventReason.WarFront(held);
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
|
|
@ -782,6 +812,17 @@ public static partial class InterestDirector
|
|||
_current.RelatedId = EventFeedUtil.SafeId(held.Related);
|
||||
}
|
||||
|
||||
if (_current.FollowUnit != null
|
||||
&& _current.RelatedUnit != null
|
||||
&& !string.IsNullOrEmpty(_current.CorrelationKey))
|
||||
{
|
||||
LifeSagaMemory.NotePlotOpposition(
|
||||
_current.FollowUnit,
|
||||
_current.RelatedUnit,
|
||||
_current.CorrelationKey,
|
||||
"plot_cell_maintain");
|
||||
}
|
||||
|
||||
// Plot stickies are groups only - never "Plot - Plotters (1)" / (0).
|
||||
int plotters = held.SideA != null ? Math.Max(0, held.SideA.Count) : 0;
|
||||
if (plotters < 2)
|
||||
|
|
|
|||
|
|
@ -165,6 +165,23 @@ public static partial class InterestDirector
|
|||
return true;
|
||||
}
|
||||
|
||||
// Prefer / MC: cut character-fill or soft-quiet linger without full score margin.
|
||||
// Never weakens sticky combat, war, crisis, or short-arc hard holds.
|
||||
if ((curFill || StoryPlanner.SoftFillQuietActive)
|
||||
&& !sticky
|
||||
&& !StoryPlanner.CrisisActive
|
||||
&& !CombatFightIsHot(_current, now)
|
||||
&& _current.Completion != InterestCompletionKind.WarFront
|
||||
&& !nextFill
|
||||
&& !IsAmbientShot(candidate)
|
||||
&& (LifeSagaRoster.TipTouchesPrefer(candidate) || LifeSagaRoster.TipTouchesMc(candidate)))
|
||||
{
|
||||
InterestDropLog.Record(
|
||||
"saga_ambient_cut",
|
||||
$"cur={_current.Key} next={candidate.Key}");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Soft family pack: yield to discrete unit events after a short hold.
|
||||
if (IsSoftStickyCluster(_current)
|
||||
&& !IsSoftStickyCluster(candidate)
|
||||
|
|
|
|||
|
|
@ -2052,6 +2052,12 @@ public static partial class InterestDirector
|
|||
return true;
|
||||
}
|
||||
|
||||
// Prefer / MC life tips may reclaim the camera during soft-fill quiet.
|
||||
if (LifeSagaRoster.TipTouchesPrefer(c) || LifeSagaRoster.TipTouchesMc(c))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float epicFloor = InterestScoringConfig.Story.crisisDisasterStrengthMin > 0f
|
||||
? InterestScoringConfig.Story.crisisDisasterStrengthMin
|
||||
: 95f;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ public class ScoringModelDocument
|
|||
public class StoryWeights
|
||||
{
|
||||
public float nearTieEpsilon = 8f;
|
||||
/// <summary>
|
||||
/// Prefer / MC / MC-cast tips may beat a hotter stranger when the gap is below this
|
||||
/// (wider than <see cref="nearTieEpsilon"/>).
|
||||
/// </summary>
|
||||
public float sagaNearTieEpsilon = 28f;
|
||||
public float causalHeatHalfLifeSeconds = 180f;
|
||||
public float causalHeatWeight = 6f;
|
||||
public float causalRelatedWeight = 3f;
|
||||
|
|
@ -41,11 +46,17 @@ public class StoryWeights
|
|||
/// <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;
|
||||
public float sagaMcWeight = 12f;
|
||||
/// <summary>Extra soft bonus when the saga slot is Prefer'd.</summary>
|
||||
public float sagaPreferWeight = 10f;
|
||||
public float sagaPreferWeight = 16f;
|
||||
/// <summary>Extra soft bonus when the unit is a WorldBox favorite.</summary>
|
||||
public float sagaGameFavoriteWeight = 8f;
|
||||
/// <summary>Soft bonus when tip touches an MC's story cast (lover/friend/kin/rival).</summary>
|
||||
public float sagaCastWeight = 5f;
|
||||
/// <summary>Extra tip bonus when subject/related/follow touch two or more roster MCs.</summary>
|
||||
public float sagaCrossMcBonus = 6f;
|
||||
/// <summary>Living rematch count before EarnRival (kill/death do not count).</summary>
|
||||
public int sagaRivalEncounterThreshold = 3;
|
||||
/// <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>
|
||||
|
|
|
|||
|
|
@ -722,34 +722,29 @@ public static class InterestVariety
|
|||
epsilon = 8f;
|
||||
}
|
||||
|
||||
float sagaEpsilon = InterestScoringConfig.Story.sagaNearTieEpsilon;
|
||||
if (sagaEpsilon <= 0f)
|
||||
{
|
||||
sagaEpsilon = 20f;
|
||||
}
|
||||
|
||||
sagaEpsilon = Mathf.Max(sagaEpsilon, epsilon);
|
||||
|
||||
// Large gap: take #1 unless a Prefer/MC/cast tip sits within the wider saga band.
|
||||
if (Ranked[0].TotalScore - Ranked[1].TotalScore >= epsilon)
|
||||
{
|
||||
InterestCandidate sagaCut = PickBestSagaInBand(Ranked, Ranked.Count, sagaEpsilon);
|
||||
if (sagaCut != null)
|
||||
{
|
||||
return sagaCut;
|
||||
}
|
||||
|
||||
return Ranked[0];
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Near-tie: Prefer'd MC, then any MC, then MC cast, else random among top-N.
|
||||
InterestCandidate bestSaga = PickBestSagaInBand(Ranked, topN, epsilon);
|
||||
if (bestSaga != null)
|
||||
{
|
||||
return bestSaga;
|
||||
}
|
||||
|
|
@ -758,6 +753,47 @@ public static class InterestVariety
|
|||
return Ranked[pick];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefer (3) > MC (2) > MC cast (1) among candidates within <paramref name="band"/> of #1.
|
||||
/// </summary>
|
||||
private static InterestCandidate PickBestSagaInBand(
|
||||
List<InterestCandidate> ranked,
|
||||
int limit,
|
||||
float band)
|
||||
{
|
||||
if (ranked == null || ranked.Count == 0 || band < 0f)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int n = Mathf.Min(limit, ranked.Count);
|
||||
float top = ranked[0].TotalScore;
|
||||
InterestCandidate bestSaga = null;
|
||||
int bestRank = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
InterestCandidate c = ranked[i];
|
||||
if (c == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top - c.TotalScore >= band)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int rank = LifeSagaRoster.SoftBiasRank(c);
|
||||
if (rank > bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
bestSaga = c;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRank > 0 ? bestSaga : null;
|
||||
}
|
||||
|
||||
public static float EventShare()
|
||||
{
|
||||
if (RecentMix.Count == 0)
|
||||
|
|
|
|||
|
|
@ -13,18 +13,31 @@ public static class LifeSagaMemory
|
|||
{
|
||||
public const int MaxFactsPerLife = 48;
|
||||
public const int MaxLives = 120;
|
||||
public const int RivalEncounterThreshold = 2;
|
||||
/// <summary>Fallback when scoring-model omits sagaRivalEncounterThreshold.</summary>
|
||||
public const int RivalEncounterThresholdDefault = 3;
|
||||
private const float EncounterDedupeSeconds = 28f;
|
||||
|
||||
private static readonly Dictionary<long, LifeSagaLifeMemory> Lives =
|
||||
new Dictionary<long, LifeSagaLifeMemory>(64);
|
||||
private static readonly Dictionary<string, int> PairEncounters =
|
||||
new Dictionary<string, int>(64);
|
||||
private static readonly Dictionary<string, float> PairEncounterCooldown =
|
||||
new Dictionary<string, float>(64);
|
||||
private static object _boundWorld;
|
||||
private static int _worldEpoch;
|
||||
|
||||
public static int WorldEpoch => _worldEpoch;
|
||||
public static int LifeCount => Lives.Count;
|
||||
|
||||
public static int RivalEncounterThreshold
|
||||
{
|
||||
get
|
||||
{
|
||||
int t = InterestScoringConfig.Story.sagaRivalEncounterThreshold;
|
||||
return t > 0 ? t : RivalEncounterThresholdDefault;
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureWorldBound()
|
||||
{
|
||||
object world = null;
|
||||
|
|
@ -51,6 +64,7 @@ public static class LifeSagaMemory
|
|||
{
|
||||
Lives.Clear();
|
||||
PairEncounters.Clear();
|
||||
PairEncounterCooldown.Clear();
|
||||
_boundWorld = null;
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +268,7 @@ public static class LifeSagaMemory
|
|||
MirrorToOther(fact, otherId);
|
||||
}
|
||||
|
||||
TryMarkMcCrossover(fact);
|
||||
return fact;
|
||||
}
|
||||
|
||||
|
|
@ -371,8 +386,13 @@ public static class LifeSagaMemory
|
|||
strength: 80f,
|
||||
provenance: provenance);
|
||||
|
||||
NoteCombatEncounter(killer, victim, provenance);
|
||||
// Kill/death must not increment living rematch counters (single kill ≠ rival).
|
||||
TryMarkKinKillRival(killer, victim);
|
||||
if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
|
||||
{
|
||||
// MC slew MC: crossover stake only - not automatic RivalEarned.
|
||||
NoteMcCrossoverHeat(killerId, victimId);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecordDeath(Actor victim, Actor killer, string attackType, string provenance = "die")
|
||||
|
|
@ -395,17 +415,52 @@ public static class LifeSagaMemory
|
|||
|
||||
if (killer != null)
|
||||
{
|
||||
NoteCombatEncounter(killer, victim, provenance);
|
||||
long killerId = EventFeedUtil.SafeId(killer);
|
||||
if (LifeSagaRoster.IsMc(killerId) && LifeSagaRoster.IsMc(victimId))
|
||||
{
|
||||
NoteMcCrossoverHeat(killerId, victimId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Living rematch counter. Call only while both actors are alive (sticky combat / climax).
|
||||
/// Kill and death paths must not call this. Deduped per pair so one scrap cannot spam to threshold.
|
||||
/// </summary>
|
||||
public static void NoteCombatEncounter(Actor a, Actor b, string provenance = "combat")
|
||||
{
|
||||
NoteCombatEncounterCore(a, b, provenance, bypassCooldown: false);
|
||||
}
|
||||
|
||||
/// <summary>Harness: count distinct living rematches without waiting out the dedupe window.</summary>
|
||||
public static void HarnessNoteCombatEncounter(Actor a, Actor b, string provenance = "harness")
|
||||
{
|
||||
NoteCombatEncounterCore(a, b, provenance, bypassCooldown: true);
|
||||
}
|
||||
|
||||
private static void NoteCombatEncounterCore(
|
||||
Actor a,
|
||||
Actor b,
|
||||
string provenance,
|
||||
bool bypassCooldown)
|
||||
{
|
||||
if (a == null || b == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!a.isAlive() || !b.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long idA = EventFeedUtil.SafeId(a);
|
||||
long idB = EventFeedUtil.SafeId(b);
|
||||
if (idA == 0 || idB == 0 || idA == idB)
|
||||
|
|
@ -414,6 +469,16 @@ public static class LifeSagaMemory
|
|||
}
|
||||
|
||||
string pair = PairKey(idA, idB);
|
||||
float now = Time.unscaledTime;
|
||||
if (!bypassCooldown
|
||||
&& PairEncounterCooldown.TryGetValue(pair, out float until)
|
||||
&& now < until)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PairEncounterCooldown[pair] = now + EncounterDedupeSeconds;
|
||||
|
||||
if (!PairEncounters.TryGetValue(pair, out int count))
|
||||
{
|
||||
count = 0;
|
||||
|
|
@ -431,9 +496,20 @@ public static class LifeSagaMemory
|
|||
provenance: provenance,
|
||||
note: "x" + count);
|
||||
|
||||
if (count >= RivalEncounterThreshold)
|
||||
int need = RivalEncounterThreshold;
|
||||
bool bothMc = LifeSagaRoster.IsMc(idA) && LifeSagaRoster.IsMc(idB);
|
||||
// MC↔MC living rematches earn rivalry one encounter sooner (still never from a single kill).
|
||||
if (bothMc)
|
||||
{
|
||||
EarnRival(a, b, "repeated_encounters:" + count, provenance);
|
||||
need = Mathf.Max(2, need - 1);
|
||||
}
|
||||
|
||||
if (count >= need)
|
||||
{
|
||||
string evidence = bothMc
|
||||
? "mc_rematch:" + count
|
||||
: "repeated_encounters:" + count;
|
||||
EarnRival(a, b, evidence, provenance);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,7 +522,7 @@ public static class LifeSagaMemory
|
|||
|
||||
long subjectId = EventFeedUtil.SafeId(subject);
|
||||
long rivalId = EventFeedUtil.SafeId(rival);
|
||||
Record(
|
||||
LifeSagaFact fact = Record(
|
||||
LifeSagaFactKind.RivalEarned,
|
||||
subject,
|
||||
rival,
|
||||
|
|
@ -454,6 +530,83 @@ public static class LifeSagaMemory
|
|||
strength: 85f,
|
||||
provenance: provenance,
|
||||
note: evidence ?? "");
|
||||
if (fact != null && LifeSagaRoster.IsMc(subjectId) && LifeSagaRoster.IsMc(rivalId))
|
||||
{
|
||||
fact.McCrossover = true;
|
||||
NoteMcCrossoverHeat(subjectId, rivalId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opposing sides of the same war: durable rivalry when both units are known.
|
||||
/// </summary>
|
||||
public static void NoteWarOpposition(
|
||||
Actor a,
|
||||
Actor b,
|
||||
string warKey,
|
||||
string provenance = "war_opposition")
|
||||
{
|
||||
if (a == null || b == null || string.IsNullOrEmpty(warKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!a.isAlive() || !b.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long idA = EventFeedUtil.SafeId(a);
|
||||
long idB = EventFeedUtil.SafeId(b);
|
||||
if (idA == 0 || idB == 0 || idA == idB)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EarnRival(a, b, "opposed_in_war:" + warKey, provenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opposing sides of the same plot: durable rivalry when both units are known.
|
||||
/// </summary>
|
||||
public static void NotePlotOpposition(
|
||||
Actor a,
|
||||
Actor b,
|
||||
string plotKey,
|
||||
string provenance = "plot_opposition")
|
||||
{
|
||||
if (a == null || b == null || string.IsNullOrEmpty(plotKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!a.isAlive() || !b.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long idA = EventFeedUtil.SafeId(a);
|
||||
long idB = EventFeedUtil.SafeId(b);
|
||||
if (idA == 0 || idB == 0 || idA == idB)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EarnRival(a, b, "opposed_in_plot:" + plotKey, provenance);
|
||||
}
|
||||
|
||||
public static void RecordWar(
|
||||
|
|
@ -660,17 +813,99 @@ public static class LifeSagaMemory
|
|||
return false;
|
||||
}
|
||||
|
||||
PruneStaleRivals(life);
|
||||
LifeSagaFact bestLiving = null;
|
||||
LifeSagaFact bestAny = null;
|
||||
for (int i = 0; i < life.Facts.Count; i++)
|
||||
{
|
||||
LifeSagaFact f = life.Facts[i];
|
||||
if (f != null && f.Kind == LifeSagaFactKind.RivalEarned && f.OtherId != 0)
|
||||
if (f == null || f.Kind != LifeSagaFactKind.RivalEarned || f.OtherId == 0)
|
||||
{
|
||||
rivalFact = f;
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsCredibleRival(f))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bestAny == null || f.Strength > bestAny.Strength || f.At > bestAny.At)
|
||||
{
|
||||
bestAny = f;
|
||||
}
|
||||
|
||||
if (EventFeedUtil.FindAliveById(f.OtherId) != null)
|
||||
{
|
||||
if (bestLiving == null || f.Strength > bestLiving.Strength || f.At > bestLiving.At)
|
||||
{
|
||||
bestLiving = f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
// Cast prefers a living rival; callers that need Legacy can still read FactsFor.
|
||||
rivalFact = bestLiving;
|
||||
return rivalFact != null;
|
||||
}
|
||||
|
||||
/// <summary>True when RivalEarned evidence meets the notable bar (not a single kill-cycle).</summary>
|
||||
public static bool IsCredibleRival(LifeSagaFact f)
|
||||
{
|
||||
if (f == null || f.Kind != LifeSagaFactKind.RivalEarned)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string note = f.Note ?? "";
|
||||
string prov = f.Provenance ?? "";
|
||||
if (IsKillCycleProvenance(prov)
|
||||
&& note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
|
||||
{
|
||||
string num = note.Substring("repeated_encounters:".Length);
|
||||
if (int.TryParse(num, out int count) && count < RivalEncounterThreshold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsKillCycleProvenance(string provenance)
|
||||
{
|
||||
if (string.IsNullOrEmpty(provenance))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(provenance, "newKillAction", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(provenance, "die", StringComparison.OrdinalIgnoreCase)
|
||||
|| provenance.IndexOf("Kill", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| provenance.IndexOf("death", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static void PruneStaleRivals(LifeSagaLifeMemory life)
|
||||
{
|
||||
if (life?.Facts == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = life.Facts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
LifeSagaFact f = life.Facts[i];
|
||||
if (f != null
|
||||
&& f.Kind == LifeSagaFactKind.RivalEarned
|
||||
&& !IsCredibleRival(f))
|
||||
{
|
||||
life.Facts.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static LifeSagaFact StrongestUnresolved(long unitId)
|
||||
|
|
@ -690,7 +925,7 @@ public static class LifeSagaMemory
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!IsStakeWorthy(f.Kind))
|
||||
if (!IsStakeWorthy(f))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -774,6 +1009,13 @@ public static class LifeSagaMemory
|
|||
switch (fact.Kind)
|
||||
{
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
if (fact.McCrossover)
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Bound to a fellow saga hero"
|
||||
: "Bound to fellow saga hero " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Found love" : "Bound with " + other;
|
||||
case LifeSagaFactKind.BondBroken:
|
||||
return string.IsNullOrEmpty(other) ? "Lost a lover" : "Lost " + other;
|
||||
|
|
@ -782,10 +1024,57 @@ public static class LifeSagaMemory
|
|||
case LifeSagaFactKind.ParentChild:
|
||||
return string.IsNullOrEmpty(other) ? "A child was born" : "Child " + other;
|
||||
case LifeSagaFactKind.Kill:
|
||||
if (fact.McCrossover)
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Slew a fellow saga hero"
|
||||
: "Slew fellow saga hero " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Took a life" : "Slew " + other;
|
||||
case LifeSagaFactKind.Death:
|
||||
if (fact.McCrossover)
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Fallen to a fellow saga hero"
|
||||
: "Slain by fellow saga hero " + other;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Died" : "Slain by " + other;
|
||||
case LifeSagaFactKind.RivalEarned:
|
||||
if (!string.IsNullOrEmpty(fact.Note))
|
||||
{
|
||||
if (fact.Note.StartsWith("opposed_in_war:", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Opposed in war"
|
||||
: "Opposed " + other + " in war";
|
||||
}
|
||||
|
||||
if (fact.Note.StartsWith("opposed_in_plot:", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Opposed in a plot"
|
||||
: "Opposed " + other + " in a plot";
|
||||
}
|
||||
|
||||
if (fact.Note.StartsWith("killed_kin", StringComparison.Ordinal)
|
||||
|| fact.Note.StartsWith("kin_slain", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Blood feud over kin"
|
||||
: "Blood feud with " + other;
|
||||
}
|
||||
|
||||
if (fact.Note.StartsWith("mc_rematch:", StringComparison.Ordinal)
|
||||
|| fact.Note.StartsWith("repeated_encounters:", StringComparison.Ordinal))
|
||||
{
|
||||
return string.IsNullOrEmpty(other)
|
||||
? "Clashed again and again"
|
||||
: "Clashed thrice with " + other;
|
||||
}
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(other) ? "Earned a rival" : "Rivalry with " + other;
|
||||
case LifeSagaFactKind.WarJoin:
|
||||
return "Entered a war";
|
||||
|
|
@ -1042,7 +1331,7 @@ public static class LifeSagaMemory
|
|||
|
||||
for (int i = 0; i < life.Facts.Count; i++)
|
||||
{
|
||||
if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i].Kind))
|
||||
if (life.Facts[i] != null && !life.Facts[i].Resolved && IsStakeWorthy(life.Facts[i]))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
|
@ -1066,7 +1355,22 @@ public static class LifeSagaMemory
|
|||
return a <= b ? a + ":" + b : b + ":" + a;
|
||||
}
|
||||
|
||||
private static bool IsStakeWorthy(LifeSagaFactKind kind)
|
||||
private static bool IsStakeWorthy(LifeSagaFact fact)
|
||||
{
|
||||
if (fact == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fact.McCrossover)
|
||||
{
|
||||
return IsMcCrossoverKind(fact.Kind) || fact.Kind == LifeSagaFactKind.RivalEarned;
|
||||
}
|
||||
|
||||
return IsStakeWorthyKind(fact.Kind);
|
||||
}
|
||||
|
||||
private static bool IsStakeWorthyKind(LifeSagaFactKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
|
|
@ -1085,6 +1389,66 @@ public static class LifeSagaMemory
|
|||
}
|
||||
}
|
||||
|
||||
private static void TryMarkMcCrossover(LifeSagaFact fact)
|
||||
{
|
||||
if (fact == null || fact.OtherId == 0 || fact.SubjectId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsMcCrossoverKind(fact.Kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!LifeSagaRoster.IsMc(fact.SubjectId) || !LifeSagaRoster.IsMc(fact.OtherId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fact.McCrossover = true;
|
||||
NoteMcCrossoverHeat(fact.SubjectId, fact.OtherId);
|
||||
}
|
||||
|
||||
private static bool IsMcCrossoverKind(LifeSagaFactKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case LifeSagaFactKind.Kill:
|
||||
case LifeSagaFactKind.Death:
|
||||
case LifeSagaFactKind.BondFormed:
|
||||
case LifeSagaFactKind.BondBroken:
|
||||
case LifeSagaFactKind.FriendFormed:
|
||||
case LifeSagaFactKind.ParentChild:
|
||||
case LifeSagaFactKind.RivalEarned:
|
||||
case LifeSagaFactKind.HardArcLove:
|
||||
case LifeSagaFactKind.HardArcGrief:
|
||||
case LifeSagaFactKind.HardArcCombat:
|
||||
case LifeSagaFactKind.HardArcWar:
|
||||
case LifeSagaFactKind.HardArcPlot:
|
||||
case LifeSagaFactKind.WarJoin:
|
||||
case LifeSagaFactKind.PlotJoin:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void NoteMcCrossoverHeat(long a, long b)
|
||||
{
|
||||
if (a != 0)
|
||||
{
|
||||
CausalHeat.NoteFeatured(a, 1.15f);
|
||||
LifeSagaRoster.NoteFeaturedUnit(a, 1.2f);
|
||||
}
|
||||
|
||||
if (b != 0 && b != a)
|
||||
{
|
||||
CausalHeat.NoteFeatured(b, 1.15f);
|
||||
LifeSagaRoster.NoteFeaturedUnit(b, 1.2f);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLegacyWorthy(LifeSagaFactKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
|
|
@ -1321,6 +1685,8 @@ public sealed class LifeSagaFact
|
|||
public string PlotKey = "";
|
||||
public bool Resolved;
|
||||
public string Note = "";
|
||||
/// <summary>Both subject and other were roster MCs when recorded.</summary>
|
||||
public bool McCrossover;
|
||||
}
|
||||
|
||||
public sealed class LifeSagaLifeMemory
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public static class LifeSagaRail
|
|||
private const float FaceRefreshSeconds = 0.28f;
|
||||
|
||||
private static readonly List<LifeSagaSlot> SlotScratch = new List<LifeSagaSlot>(MaxSlots);
|
||||
private static readonly List<long> InvolvedScratch = new List<long>(8);
|
||||
private static readonly Slot[] Slots = new Slot[MaxSlots];
|
||||
private static GameObject _row;
|
||||
private static RectTransform _rowRt;
|
||||
|
|
@ -32,6 +33,9 @@ public static class LifeSagaRail
|
|||
/// <summary>Harness: true when the watched MC slot used Active (not Prefer) chrome.</summary>
|
||||
public static bool LastActiveHighlight { get; private set; }
|
||||
|
||||
/// <summary>Harness: count of Involved (non-follow) MC glyphs lit on last Refresh.</summary>
|
||||
public static int LastInvolvedHighlightCount { get; private set; }
|
||||
|
||||
/// <summary>Harness: Prefer pip visible on first Prefer'd slot.</summary>
|
||||
public static bool LastPreferPipVisible { get; private set; }
|
||||
|
||||
|
|
@ -132,6 +136,7 @@ public static class LifeSagaRail
|
|||
LastShownCount = 0;
|
||||
LastTipSample = "";
|
||||
LastActiveHighlight = false;
|
||||
LastInvolvedHighlightCount = 0;
|
||||
LastPreferPipVisible = false;
|
||||
LastHoverRowCount = 0;
|
||||
LastHoverHeight = 0f;
|
||||
|
|
@ -175,7 +180,12 @@ public static class LifeSagaRail
|
|||
LifeSagaRoster.Tick(now);
|
||||
SlotScratch.Clear();
|
||||
LifeSagaRoster.CopySlots(SlotScratch);
|
||||
string fp = Fingerprint(SlotScratch);
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
InvolvedScratch.Clear();
|
||||
InterestCandidate tip = InterestDirector.CurrentCandidate;
|
||||
LifeSagaRoster.CollectTouchedMcIds(tip, InvolvedScratch);
|
||||
string fp = Fingerprint(SlotScratch, watchId, InvolvedScratch);
|
||||
bool structureChanged = !string.Equals(fp, _fingerprint, StringComparison.Ordinal)
|
||||
|| LifeSagaRoster.Dirty;
|
||||
bool facesDue = now >= _nextFaceRefreshAt;
|
||||
|
|
@ -191,9 +201,8 @@ public static class LifeSagaRail
|
|||
LastShownCount = 0;
|
||||
LastTipSample = "";
|
||||
LastActiveHighlight = false;
|
||||
LastInvolvedHighlightCount = 0;
|
||||
LastPreferPipVisible = false;
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
float x = 0f;
|
||||
for (int i = 0; i < Slots.Length; i++)
|
||||
{
|
||||
|
|
@ -208,6 +217,7 @@ public static class LifeSagaRail
|
|||
|
||||
LifeSagaSlot saga = SlotScratch[i];
|
||||
bool watching = watchId != 0 && watchId == saga.UnitId;
|
||||
bool involved = !watching && IsInvolved(saga.UnitId);
|
||||
bool prefer = saga.Prefer || saga.GameFavorite;
|
||||
slot.UnitId = saga.UnitId;
|
||||
ApplyFace(slot, saga);
|
||||
|
|
@ -226,6 +236,12 @@ public static class LifeSagaRail
|
|||
slot.Bg.color = HudTheme.ValueOrange;
|
||||
LastActiveHighlight = true;
|
||||
}
|
||||
else if (involved)
|
||||
{
|
||||
// Softer gold - second saga lit while another MC is followed.
|
||||
slot.Bg.color = new Color(0.92f, 0.72f, 0.28f, 0.92f);
|
||||
LastInvolvedHighlightCount++;
|
||||
}
|
||||
else if (prefer)
|
||||
{
|
||||
slot.Bg.color = new Color(0.45f, 0.38f, 0.22f, 0.95f);
|
||||
|
|
@ -581,12 +597,37 @@ public static class LifeSagaRail
|
|||
return "?";
|
||||
}
|
||||
|
||||
private static string Fingerprint(List<LifeSagaSlot> board)
|
||||
private static bool IsInvolved(long unitId)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < InvolvedScratch.Count; i++)
|
||||
{
|
||||
if (InvolvedScratch[i] == unitId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Fingerprint(List<LifeSagaSlot> board, long watchId, List<long> involved)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
long watchId = EventFeedUtil.SafeId(
|
||||
MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null);
|
||||
sb.Append(watchId).Append('|');
|
||||
if (involved != null)
|
||||
{
|
||||
for (int i = 0; i < involved.Count; i++)
|
||||
{
|
||||
sb.Append('i').Append(involved[i]).Append(',');
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append('|');
|
||||
for (int i = 0; i < board.Count; i++)
|
||||
{
|
||||
LifeSagaSlot s = board[i];
|
||||
|
|
|
|||
|
|
@ -273,46 +273,58 @@ public static class LifeSagaRoster
|
|||
}
|
||||
|
||||
StoryWeights sw = InterestScoringConfig.Story;
|
||||
float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 6f;
|
||||
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 10f;
|
||||
float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 12f;
|
||||
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 16f;
|
||||
float fav = sw.sagaGameFavoriteWeight > 0f ? sw.sagaGameFavoriteWeight : 8f;
|
||||
float cast = sw.sagaCastWeight > 0f ? sw.sagaCastWeight : 5f;
|
||||
float cross = sw.sagaCrossMcBonus > 0f ? sw.sagaCrossMcBonus : 6f;
|
||||
|
||||
float best = BonusForUnit(c.SubjectId, mc, prefer, fav);
|
||||
float best = BonusForUnit(c.SubjectId, mc, prefer, fav, cast);
|
||||
if (c.RelatedId != 0)
|
||||
{
|
||||
best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav) * 0.85f);
|
||||
best = Mathf.Max(best, BonusForUnit(c.RelatedId, mc, prefer, fav, cast) * 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));
|
||||
best = Mathf.Max(best, BonusForUnit(followId, mc, prefer, fav, cast));
|
||||
}
|
||||
|
||||
float cap = Mathf.Max(prefer, fav) + mc;
|
||||
if (CountTouchedMcs(c) >= 2)
|
||||
{
|
||||
best += cross;
|
||||
}
|
||||
|
||||
float cap = Mathf.Max(prefer, fav) + mc + cross;
|
||||
return Mathf.Min(best, cap);
|
||||
}
|
||||
|
||||
private static float BonusForUnit(long unitId, float mc, float prefer, float fav)
|
||||
private static float BonusForUnit(long unitId, float mc, float prefer, float fav, float cast)
|
||||
{
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
if (s == null)
|
||||
if (unitId == 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float bonus = mc;
|
||||
if (s.Prefer)
|
||||
LifeSagaSlot s = Get(unitId);
|
||||
if (s != null)
|
||||
{
|
||||
bonus += prefer;
|
||||
float bonus = mc;
|
||||
if (s.Prefer)
|
||||
{
|
||||
bonus += prefer;
|
||||
}
|
||||
|
||||
if (s.GameFavorite)
|
||||
{
|
||||
bonus += fav;
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
if (s.GameFavorite)
|
||||
{
|
||||
bonus += fav;
|
||||
}
|
||||
|
||||
return bonus;
|
||||
return IsMcCast(unitId) ? cast : 0f;
|
||||
}
|
||||
|
||||
/// <summary>Among candidates, pick Prefer'd MC, else any MC, else null.</summary>
|
||||
|
|
@ -390,12 +402,208 @@ public static class LifeSagaRoster
|
|||
return IsPrefer(EventFeedUtil.SafeId(c.FollowUnit));
|
||||
}
|
||||
|
||||
/// <summary>Near-tie breaker: Prefer > MC > neither.</summary>
|
||||
public static bool TipTouchesMcCast(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsMcCast(c.SubjectId) || IsMcCast(c.RelatedId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsMcCast(EventFeedUtil.SafeId(c.FollowUnit));
|
||||
}
|
||||
|
||||
/// <summary>Prefer=3, MC=2, MC cast=1, else 0.</summary>
|
||||
public static int SoftBiasRank(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (TipTouchesPrefer(c))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (TipTouchesMc(c))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return TipTouchesMcCast(c) ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>True when unit is in a roster MC's live story orbit (not themselves an MC).</summary>
|
||||
public static bool IsMcCast(long unitId)
|
||||
{
|
||||
if (unitId == 0 || IsMc(unitId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Slots.Count; i++)
|
||||
{
|
||||
LifeSagaSlot slot = Slots[i];
|
||||
if (slot == null || slot.UnitId == 0 || slot.UnitId == unitId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsInMcOrbit(slot.UnitId, unitId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Near-tie breaker: Prefer > MC > MC cast > 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);
|
||||
return SoftBiasRank(b).CompareTo(SoftBiasRank(a));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roster MCs touched by the tip for rail involved chrome.
|
||||
/// Bounded to subject/related/follow/pair/short-arc cast - not mass ParticipantIds.
|
||||
/// </summary>
|
||||
public static void CollectTouchedMcIds(InterestCandidate tip, List<long> dest)
|
||||
{
|
||||
if (dest == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dest.Clear();
|
||||
if (tip == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
void Add(long id)
|
||||
{
|
||||
if (id == 0 || !IsMc(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < dest.Count; i++)
|
||||
{
|
||||
if (dest[i] == id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dest.Add(id);
|
||||
}
|
||||
|
||||
Add(tip.SubjectId);
|
||||
Add(tip.RelatedId);
|
||||
Add(EventFeedUtil.SafeId(tip.FollowUnit));
|
||||
Add(tip.PairOwnerId);
|
||||
Add(tip.PairPartnerId);
|
||||
Add(tip.TheaterLeadId);
|
||||
|
||||
StoryArc arc = StoryPlanner.Active;
|
||||
if (arc?.CastIds != null)
|
||||
{
|
||||
for (int i = 0; i < arc.CastIds.Count; i++)
|
||||
{
|
||||
Add(arc.CastIds[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static int CountTouchedMcs(InterestCandidate tip)
|
||||
{
|
||||
if (tip == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int n = 0;
|
||||
long a = tip.SubjectId;
|
||||
long b = tip.RelatedId;
|
||||
long f = EventFeedUtil.SafeId(tip.FollowUnit);
|
||||
if (IsMc(a))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
|
||||
if (b != 0 && b != a && IsMc(b))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
|
||||
if (f != 0 && f != a && f != b && IsMc(f))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
private static bool IsInMcOrbit(long mcId, long otherId)
|
||||
{
|
||||
if (mcId == 0 || otherId == 0 || mcId == otherId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Actor mc = EventFeedUtil.FindAliveById(mcId);
|
||||
if (mc != null)
|
||||
{
|
||||
Actor lover = ActorRelation.GetLover(mc);
|
||||
if (lover != null && EventFeedUtil.SafeId(lover) == otherId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Actor friend = ActorRelation.GetBestFriend(mc);
|
||||
if (friend != null && EventFeedUtil.SafeId(friend) == otherId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Actor parent in ActorRelation.EnumerateParents(mc, 2))
|
||||
{
|
||||
if (EventFeedUtil.SafeId(parent) == otherId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Actor child in ActorRelation.EnumerateChildren(mc, 4))
|
||||
{
|
||||
if (EventFeedUtil.SafeId(child) == otherId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore relation probe failures
|
||||
}
|
||||
}
|
||||
|
||||
if (LifeSagaMemory.TryGetEarnedRival(mcId, out LifeSagaFact rival)
|
||||
&& rival != null
|
||||
&& rival.OtherId == otherId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void StampChapter(long unitId, string line, float now = -1f)
|
||||
|
|
@ -609,6 +817,17 @@ public static class LifeSagaRoster
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Soft heat bump for an existing roster MC (cross-MC moments).</summary>
|
||||
public static void NoteFeaturedUnit(long unitId, float heat = 1f)
|
||||
{
|
||||
if (unitId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NoteUnit(unitId, heat, Time.unscaledTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Challenge Cap with a living unit. Admission roles always may compete;
|
||||
/// nobodies only when <paramref name="hardArcAdmit"/> (hard-arc chapter path).
|
||||
|
|
|
|||
|
|
@ -640,6 +640,25 @@ public static class WorldActivityScanner
|
|||
weight += Mathf.Max(35f, spectacle);
|
||||
}
|
||||
|
||||
// Soft saga bias: Prefer/MC beat ordinary notables in the same scrap; spectacle still wins.
|
||||
long id = EventFeedUtil.SafeId(actor);
|
||||
StoryWeights sw = InterestScoringConfig.Story;
|
||||
if (LifeSagaRoster.IsPrefer(id))
|
||||
{
|
||||
float prefer = sw.sagaPreferWeight > 0f ? sw.sagaPreferWeight : 16f;
|
||||
weight += Mathf.Max(18f, prefer);
|
||||
}
|
||||
else if (LifeSagaRoster.IsMc(id))
|
||||
{
|
||||
float mc = sw.sagaMcWeight > 0f ? sw.sagaMcWeight : 12f;
|
||||
weight += Mathf.Max(12f, mc);
|
||||
}
|
||||
else if (LifeSagaRoster.IsMcCast(id))
|
||||
{
|
||||
float cast = sw.sagaCastWeight > 0f ? sw.sagaCastWeight : 5f;
|
||||
weight += Mathf.Max(6f, cast);
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.29.53",
|
||||
"version": "0.29.57",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@
|
|||
|
||||
"story": {
|
||||
"nearTieEpsilon": 8,
|
||||
"sagaNearTieEpsilon": 28,
|
||||
"causalHeatHalfLifeSeconds": 180,
|
||||
"causalHeatWeight": 6,
|
||||
"causalRelatedWeight": 3,
|
||||
|
|
@ -131,9 +132,12 @@
|
|||
"historyDensityWindowSeconds": 600,
|
||||
"ledgerBleedLifeChapter": 0.4,
|
||||
"ledgerBleedVillage": 0.82,
|
||||
"sagaMcWeight": 6,
|
||||
"sagaPreferWeight": 10,
|
||||
"sagaMcWeight": 12,
|
||||
"sagaPreferWeight": 16,
|
||||
"sagaGameFavoriteWeight": 8,
|
||||
"sagaCastWeight": 5,
|
||||
"sagaCrossMcBonus": 6,
|
||||
"sagaRivalEncounterThreshold": 3,
|
||||
"sagaDullSeconds": 300,
|
||||
"familyLifeBeatCooldownSeconds": 90,
|
||||
"crisisWarParticipantMin": 8,
|
||||
|
|
|
|||
|
|
@ -44,18 +44,23 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
|
|||
## 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.
|
||||
- Score ladder: crisis ownership > active short-arc ownership > Prefer soft > MC soft > MC cast > CausalHeat.
|
||||
- `nearTieEpsilon` (8) picks #1 for ordinary gaps; Prefer/MC/cast may still win within `sagaNearTieEpsilon` (28).
|
||||
- Soft ranks: Prefer > MC > MC cast (lover/friend/kin/earned rival of a roster MC).
|
||||
- Tips that touch two or more roster MCs get `sagaCrossMcBonus`.
|
||||
- Mid-fight follow uses Prefer/MC/cast soft weight in `CombatFocusWeight` (spectacle floors can still win).
|
||||
- Ambient / character-fill yields immediately to Prefer/MC tips (not vs crisis/combat/short-arc holds).
|
||||
- Soft-fill quiet still suppresses anonymous crumbs; Prefer/MC tips may break quiet.
|
||||
- Glyph hover acquires a camera read-pause lease: feeds/story/roster continue, but selection and focus repair are deferred until release.
|
||||
|
||||
## Rail UX
|
||||
|
||||
- Up to 10 slots with live inspect-UI sprites (refreshed ~3x/sec for animation frames).
|
||||
- Egg forms and tiny/blank atlas crops fall back to the species icon (letter last).
|
||||
- Active chrome = camera follow only; Prefer uses a star pip / distinct border.
|
||||
- Active chrome = camera follow MC; Involved chrome = other tip-touched MCs (subject/related/follow/pair/short-arc cast only - not mass `ParticipantIds`).
|
||||
- Prefer uses a star pip / distinct border.
|
||||
- Click toggles saga Prefer (does not mutate WorldBox unit favorite).
|
||||
- While on the **Saga** tab, click also pins that life as the persistent Saga subject.
|
||||
- While on the **Saga** tab, click also pins that life as the persistent Saga subject (panel only; director bias stays Prefer/MC/cast).
|
||||
- Tabs sit above the cast rail; the rail is a **horizontal strip under the tabs** so Dossier↔Saga body swaps cannot move the glyphs.
|
||||
- **Dossier** shows the watched unit dossier; **Saga** shows the adaptive saga panel for the watched MC when on the roster.
|
||||
- If the watched unit is not an MC and nothing is pinned, Saga shows an empty *Pick a life from the rail* state (never a stale first-roster filler).
|
||||
|
|
@ -71,15 +76,29 @@ Hard-arc admissions also retain the arc kind and chapter context that brought th
|
|||
- Body height sizes to content up to a cap; Legacy beats stack as separate lines (no · soup).
|
||||
- Dossier story spine (`Kind · Phase`) is hidden when the orange reason already names the same kind and phase is Climax.
|
||||
- Never show live task/status/trait rows or call a live attack target a rival.
|
||||
- Earned rivals require memory evidence (repeated encounters, kin kills, war/plot opposition).
|
||||
|
||||
## Earned rivals
|
||||
|
||||
Notable only:
|
||||
|
||||
- Living rematches (≥ `sagaRivalEncounterThreshold`, default 3) while both are alive (sticky combat hook; kill/death do not count).
|
||||
- Kin blood (`killed_kin` / `kin_slain`).
|
||||
- War / plot opposition (opposing principals on the same war/plot tip).
|
||||
- MC↔MC living rematches earn rivalry one encounter sooner; a single MC kill is crossover Legacy, not automatic RivalEarned.
|
||||
- Cast shows living credible rivals only; weak kill-cycle `repeated_encounters` facts are pruned.
|
||||
- Likely succession is omitted unless a game-authored heir API probe succeeds.
|
||||
- Pack / kin lenses fill remaining Cast slots with live family peers (`Packmate` / `Kin`) after lover/friend/parents/children/heir/rival.
|
||||
|
||||
## Cross-MC moments
|
||||
|
||||
When subject and other are both roster MCs, facts are flagged `McCrossover` (named stake/legacy prose + heat on both).
|
||||
Both rail glyphs light (Active + Involved) when the current tip touches multiple MCs.
|
||||
|
||||
## Soft-fill quiet (AFK hygiene)
|
||||
|
||||
After hard story/crisis closers, soft-fill quiet suppresses soft-life crumbs and short interrupt FX (`stunned` / `invincible`).
|
||||
`FamilyPack` does not break quiet (ambient cluster fill).
|
||||
Combat / war / plot / outbreak / earthquake / story beats / epic strength still cut in.
|
||||
Combat / war / plot / outbreak / earthquake / story beats / epic strength / Prefer/MC still cut in.
|
||||
Quiet can refresh briefly when a soft crumb ends (bounded).
|
||||
|
||||
## Session
|
||||
|
|
@ -93,6 +112,14 @@ Quiet can refresh briefly when a soft crumb ends (bounded).
|
|||
- `saga_roster_diversity`
|
||||
- `saga_camera_prefers_mc`
|
||||
- `saga_prefer_soft_bias`
|
||||
- `saga_wins_moderate_gap`
|
||||
- `saga_combat_follow_mc`
|
||||
- `saga_cast_soft_bias`
|
||||
- `saga_cross_mc_light`
|
||||
- `saga_cross_mc_kill`
|
||||
- `saga_rival_not_single_kill`
|
||||
- `saga_rival_rematch`
|
||||
- `saga_prefer_cuts_ambient`
|
||||
- `saga_overview_has_mc`
|
||||
- `saga_adaptive_dossier`
|
||||
- `saga_memory_survives`
|
||||
|
|
|
|||
|
|
@ -35,4 +35,12 @@ Live combat holds the camera until the scrap goes cold (see who wins); variety p
|
|||
|
||||
Edit the `weights` block in scoring-model.json to change live scoring formula knobs.
|
||||
|
||||
Edit the `story` block for StoryPlanner / life-saga soft bias:
|
||||
|
||||
- `nearTieEpsilon` / `sagaNearTieEpsilon` - ordinary vs Prefer/MC/cast pick bands
|
||||
- `sagaMcWeight` / `sagaPreferWeight` / `sagaCastWeight` / `sagaCrossMcBonus` - tip score soft bias
|
||||
- `sagaRivalEncounterThreshold` - living rematches before EarnRival
|
||||
|
||||
Edit event-catalog.json to change what each event is worth and what it says.
|
||||
|
||||
See also: [life-saga.md](life-saga.md), [story-planner.md](story-planner.md).
|
||||
|
|
|
|||
|
|
@ -174,8 +174,10 @@ short interrupt FX (`stunned` / `invincible`), and character-fill ambient briefl
|
|||
## Selection discipline
|
||||
|
||||
- `InterestVariety.Pick` takes `#1` when score gap ≥ `story.nearTieEpsilon` (default 8).
|
||||
- Near-ties still roll among top-3.
|
||||
- Causal heat + life-saga Prefer/MC soft bias add into `RecalcTotal`.
|
||||
- Prefer / MC / MC-cast tips may still win when the gap is below `story.sagaNearTieEpsilon` (default 28).
|
||||
- Near-ties among peers still Prefer > MC > cast, else roll among top-3.
|
||||
- Causal heat + life-saga Prefer/MC/cast soft bias + cross-MC bonus add into `RecalcTotal`.
|
||||
- See [life-saga.md](life-saga.md) for camera / rival / dual-rail contracts.
|
||||
|
||||
## Knobs
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue