worldbox-observer-mod/IdleSpectator/InterestScoringConfig.cs
DazedAnon c23b2b9c1e feat(spectator): add crisis chapters and harden combat theater truth
- Open war/disaster/outbreak crisis overlays with epilogue_crisis closers
- Keep Duel pairs hot only on owned cast; park sleep/freeze; drop ambient scrap pins
- Require a fallen theater partner for survivor aftermath; let king_killed cut mid-fight
- Gate crisis, combat cold, king cut-in, and living-partner aftermath in harness
2026-07-21 22:05:38 -05:00

462 lines
17 KiB
C#

using System;
using System.IO;
using System.Text;
using NeoModLoader.services;
using UnityEngine;
namespace IdleSpectator;
/// <summary>
/// Runtime scoring knobs loaded from <c>scoring-model.json</c> next to mod.json.
/// Edit that file to tune; code falls back to built-in defaults if missing/invalid.
/// </summary>
[Serializable]
public class ScoringModelDocument
{
public string modVersion;
public ScoringWeights weights;
public StoryWeights story;
}
/// <summary>StoryPlanner knobs from scoring-model.json <c>story</c> block.</summary>
[Serializable]
public class StoryWeights
{
public float nearTieEpsilon = 8f;
public float causalHeatHalfLifeSeconds = 180f;
public float causalHeatWeight = 6f;
public float causalRelatedWeight = 3f;
public float arcOwnershipBoost = 18f;
public float arcHoldMargin = 50f;
public float aftermathStrength = 62f;
public float aftermathMinWatch = 12f;
public float aftermathMaxWatch = 20f;
public float epilogueStrength = 48f;
public float epilogueMaxWatch = 14f;
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>
public float ledgerBleedLifeChapter = 0.4f;
/// <summary>Multiply other ledger entries after a family life chapter (village cool).</summary>
public float ledgerBleedVillage = 0.82f;
/// <summary>Extra FixedDwell beat cool for family chapters (seconds, stacks with base).</summary>
public float familyLifeBeatCooldownSeconds = 90f;
// --- Crisis chapters (Phase 4) ---
/// <summary>WarFront needs at least this many participants to open a crisis chapter.</summary>
public int crisisWarParticipantMin = 8;
/// <summary>Disaster / outbreak EventStrength floor to open a crisis chapter.</summary>
public float crisisDisasterStrengthMin = 95f;
/// <summary>Score boost for tips that belong to the live crisis.</summary>
public float crisisOwnershipBoost = 22f;
/// <summary>Extra cut-in margin while watching a crisis tip against unrelated peers.</summary>
public float crisisHoldMargin = 40f;
/// <summary>Keep the chapter after the last crisis signal for this many seconds.</summary>
public float crisisLingerSeconds = 10f;
/// <summary>
/// Disaster / outbreak linger after the last WorldLog pulse (tornado tips are short FixedDwell).
/// </summary>
public float crisisDisasterLingerSeconds = 28f;
/// <summary>Hard cap on crisis Active phase length (seconds).</summary>
public float crisisMaxSeconds = 180f;
/// <summary>Do not reopen a crisis for this many seconds after Done.</summary>
public float crisisCooldownSeconds = 45f;
/// <summary>
/// Longer cool after disaster/outbreak closers so stacked storm tips cannot double-close.
/// </summary>
public float crisisDisasterCooldownSeconds = 120f;
/// <summary>Forced closer strength when a crisis chapter ends.</summary>
public float crisisEpilogueStrength = 52f;
/// <summary>Forced closer max watch (seconds).</summary>
public float crisisEpilogueMaxWatch = 16f;
}
/// <summary>Serializable weight block — field names must match scoring-model.json "weights".</summary>
[Serializable]
public class ScoringWeights
{
public float charWeightEventLed = 0.18f;
public float charWeightCharacterLed = 0.55f;
public float visualMultiplier = 12f;
public float noveltyMultiplier = 4f;
// Director policy (score-native; replaces InterestTier preemption).
public float cutInMargin = 35f;
/// <summary>Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch).</summary>
public float stickyCutInMargin = 50f;
/// <summary>
/// EventStrength floor for mid-fight urgent cuts (catalog epic-world band, 95-100).
/// Disasters / kingdom fall may interrupt a live scrap; lovers and daily Action may not.
/// </summary>
public float combatUrgentEventStrengthMin = 95f;
/// <summary>
/// Score margin over the live fight required when EventStrength clears
/// <see cref="combatUrgentEventStrengthMin"/> (smaller than stickyCutInMargin).
/// </summary>
public float combatUrgentCutInMargin = 20f;
/// <summary>After this many seconds on sticky combat/war, variety-valve peers may cut in.</summary>
public float stickyVarietyValveAfterSeconds = 16f;
/// <summary>
/// Soft pair-combat valve open time (anonymous / single-notable Duels).
/// Multi-fighter and notable-pair scraps still use stickyVarietyValveAfterSeconds.
/// </summary>
public float stickyVarietyValveAfterSecondsPair = 8f;
/// <summary>Also open the valve after this fraction of MaxWatch (0.4 → 24s of a 60s combat).</summary>
public float stickyVarietyValveMaxWatchFrac = 0.4f;
/// <summary>Cut-in margin while the variety valve is open (between rotateSlack and stickyCutInMargin).</summary>
public float stickyVarietyValveMargin = 20f;
/// <summary>Lopsided fight scale multiplier floor (38v1 → near this; 1v1 balanced → 1).</summary>
public float fightBalanceLopsidedFloor = 0.28f;
/// <summary>Balance ratio (min/max sides) at which the fight gets full scale bonus.</summary>
public float fightBalanceFullAt = 0.55f;
/// <summary>Small bonus when WarFront has two material kingdom sides.</summary>
public float warFrontBalanceBonus = 12f;
public float rotateSlack = 12f;
public float fillScoreMax = 55f;
public float noticeScoreMin = 70f;
public float hotScoreMin = 110f;
public float dwellFill = 8f;
public float dwellNormal = 15f;
public float dwellHot = 15f;
public float fillRotateSeconds = 8f;
/// <summary>Minimum seconds on an EventLed shot before peer rotate / soft cut (interrupts bypass). Ambient exempt.</summary>
public float minCameraDwell = 15f;
// MaxWatch caps by completion class (director clamps candidate.MaxWatch).
public float maxWatchMoment = 15f;
public float maxWatchStatus = 30f;
public float maxWatchGrief = 45f;
public float maxWatchCombat = 60f;
/// <summary>Cap for soft pair Duels (anonymous / single-notable); multi scrap uses maxWatchCombat.</summary>
public float maxWatchDuel = 24f;
public float maxWatchEpic = 50f;
public int massFighterThreshold = 4;
public float massBaseBonus = 28f;
public float massPerExtraFighter = 4f;
public float massExtraCap = 24f;
/// <summary>Fighter count at which combat ensembles use Mass scale framing.</summary>
public int massCrowdThreshold = 8;
public int duelMaxFighters = 2;
public float duelNotablePairBonus = 55f;
public float duelSingleNotableBonus = 15f;
public float duelAnonymousPenalty = -24f;
public int skirmishMinFighters = 3;
public float notableSkirmishPer = 12f;
public float notableSkirmishCap = 35f;
public float clusterDeathWeight = 10f;
public float clusterFighterWeight = 14f;
public float clusterNotableWeight = 22f;
public float clusterMassBonus = 28f;
public float clusterDuelPenalty = -8f;
public float clusterNotableDuelBonus = 45f;
public float clusterSingleNotableBonus = 12f;
public float preferNotableDuelMargin = 15f;
public float battleCharPerNotable = 18f;
public float metaFavorite = 22f;
public float metaKing = 18f;
public float metaLeader = 14f;
public float metaRenownDivisor = 120f;
public float metaRenownCap = 8f;
public float metaKillsFactor = 0.35f;
public float metaKillsCap = 12f;
public float metaLevelFactor = 0.4f;
public float metaLevelCap = 10f;
/// <summary>Bonus when the follow unit is the only living member of its species.</summary>
public float speciesSingletonBonus = 14f;
/// <summary>Bonus when species population is between 2 and speciesRareMaxPop inclusive.</summary>
public float speciesRareBonus = 8f;
public int speciesRareMaxPop = 5;
public int notableKills = 40;
public float notableRenown = 200f;
public int extremeNotableKills = 80;
public float extremeNotableRenown = 400f;
public float scannerAttackBonus = 40f;
public float scannerHotBonus = 18f;
public float scannerWarmBonus = 8f;
public float scannerSpectacleBonus = 12f;
/// <summary>
/// Idle spectacles (evil mage pacing, dragon roosting) keep at least this action score
/// so warm civic chores cannot own ambient forever.
/// </summary>
public float scannerSpectacleIdleFloor = 48f;
/// <summary>
/// Prefer a live spectacle over a non-spectacle ambient pick when within this score gap.
/// </summary>
public float scannerSpectaclePreferMargin = 28f;
public float scannerActionKillsFactor = 0.2f;
public float scannerActionKillsCap = 10f;
public float scannerWarriorBonus = 6f;
public float scannerSingletonBonus = 8f;
public float scannerCharKillsFactor = 0.15f;
public float scannerCharKillsCap = 8f;
public float scannerCharRenownDivisor = 120f;
public float scannerCharRenownCap = 8f;
public float scannerColdActionMul = 0.25f;
public float scannerColdCharMul = 0.35f;
public float scannerRankCharWeight = 0.18f;
public float characterLedEventMul = 0.35f;
public float eventLedTarget = 0.7f;
public int mixWindow = 20;
public float hardCooldownSeconds = 12f;
/// <summary>
/// Hard suppress window for the same FixedDwell beat (happiness effect / status / label)
/// on the same subject so life milestones do not reclaim the camera every few seconds.
/// </summary>
public float fixedDwellBeatCooldownSeconds = 45f;
/// <summary>
/// Score subtracted per consecutive camera show of the same variety arc
/// (e.g. duel → duel). Resets when a different arc is shown.
/// </summary>
public float repeatArcPenaltyPer = 24f;
/// <summary>Cap on stacked repeat-arc penalty.</summary>
public float repeatArcPenaltyCap = 72f;
/// <summary>Rolling tip window for rarity/commonality score adjust.</summary>
public int arcFrequencyWindow = 16;
/// <summary>Soft per-arc share target inside the window (common arcs above this lose score).</summary>
public float arcFairShare = 0.12f;
/// <summary>Max TotalScore penalty when an arc saturates the window.</summary>
public float arcOverSharePenaltyMax = 48f;
/// <summary>Max TotalScore boost when an arc is absent from the window (rare).</summary>
public float arcUnderShareBoostMax = 22f;
/// <summary>Soft share cap for all combat arcs combined (duel + multi).</summary>
public float arcCombatShareTarget = 0.35f;
/// <summary>Extra penalty when combat share exceeds <see cref="arcCombatShareTarget"/>.</summary>
public float arcCombatOverSharePenaltyMax = 36f;
public int enrichTopK = 8;
public float metaCacheTtl = 2f;
}
/// <summary>Loads and exposes <see cref="ScoringWeights"/> from scoring-model.json.</summary>
public static class InterestScoringConfig
{
public const string FileName = "scoring-model.json";
private static ScoringWeights _w = new ScoringWeights();
private static StoryWeights _story = new StoryWeights();
private static string _loadedPath = "";
private static string _loadedVersion = "";
private static bool _loadedFromFile;
public static ScoringWeights W => _w;
public static StoryWeights Story => _story;
public static bool LoadedFromFile => _loadedFromFile;
public static string LoadedPath => _loadedPath;
public static string LoadedVersion => _loadedVersion;
public static void LoadFromModFolder(string modFolder)
{
_w = new ScoringWeights();
_story = new StoryWeights();
_loadedFromFile = false;
_loadedPath = "";
_loadedVersion = "";
if (string.IsNullOrEmpty(modFolder))
{
LogService.LogInfo("[IdleSpectator] scoring-model.json: no mod folder, using defaults");
return;
}
string path = Path.Combine(modFolder, FileName);
if (!File.Exists(path))
{
LogService.LogInfo($"[IdleSpectator] scoring-model.json missing at {path}, using defaults");
return;
}
try
{
string json = File.ReadAllText(path);
if (!TryParseDocument(json, out ScoringWeights weights, out StoryWeights story, out string version))
{
LogService.LogInfo("[IdleSpectator] scoring-model.json parse failed, using defaults");
return;
}
_w = weights;
_story = story ?? new StoryWeights();
_loadedFromFile = true;
_loadedPath = path;
_loadedVersion = version ?? "";
LogService.LogInfo(
$"[IdleSpectator] scoring-model.json loaded v={_loadedVersion} charEvent={_w.charWeightEventLed:0.##} mass={_w.massBaseBonus:0.#} duelPair={_w.duelNotablePairBonus:0.#} storyNearTie={_story.nearTieEpsilon:0.#}");
}
catch (Exception ex)
{
_w = new ScoringWeights();
_story = new StoryWeights();
LogService.LogInfo($"[IdleSpectator] scoring-model.json failed ({ex.Message}), using defaults");
}
}
/// <summary>
/// Unity JsonUtility often returns null nested objects on mixed doc/schema files.
/// Parse the weights object directly (extracted by brace matching).
/// </summary>
internal static bool TryParseDocument(
string json,
out ScoringWeights weights,
out StoryWeights story,
out string version)
{
weights = null;
story = new StoryWeights();
version = "";
if (string.IsNullOrEmpty(json))
{
return false;
}
version = ExtractJsonString(json, "modVersion") ?? "";
if (TryExtractObject(json, "story", out string storyJson))
{
StoryWeights parsedStory = JsonUtility.FromJson<StoryWeights>(storyJson);
if (parsedStory != null)
{
story = parsedStory;
}
}
// Prefer direct FromJson of the weights object — reliable with flat numeric fields.
if (TryExtractObject(json, "weights", out string weightsJson))
{
ScoringWeights parsed = JsonUtility.FromJson<ScoringWeights>(weightsJson);
if (parsed != null)
{
weights = parsed;
return true;
}
}
// Fallback: whole-document parse (works if file is weights-only wrapped).
ScoringModelDocument doc = JsonUtility.FromJson<ScoringModelDocument>(json);
if (doc?.weights != null)
{
weights = doc.weights;
if (doc.story != null)
{
story = doc.story;
}
if (!string.IsNullOrEmpty(doc.modVersion))
{
version = doc.modVersion;
}
return true;
}
return false;
}
private static string ExtractJsonString(string json, string key)
{
string needle = "\"" + key + "\"";
int keyAt = json.IndexOf(needle, StringComparison.Ordinal);
if (keyAt < 0)
{
return null;
}
int colon = json.IndexOf(':', keyAt + needle.Length);
if (colon < 0)
{
return null;
}
int i = colon + 1;
while (i < json.Length && char.IsWhiteSpace(json[i]))
{
i++;
}
if (i >= json.Length || json[i] != '"')
{
return null;
}
i++;
var sb = new StringBuilder();
while (i < json.Length)
{
char c = json[i++];
if (c == '\\' && i < json.Length)
{
sb.Append(json[i++]);
continue;
}
if (c == '"')
{
break;
}
sb.Append(c);
}
return sb.ToString();
}
private static bool TryExtractObject(string json, string key, out string objectJson)
{
objectJson = null;
string needle = "\"" + key + "\"";
int keyAt = json.IndexOf(needle, StringComparison.Ordinal);
if (keyAt < 0)
{
return false;
}
int brace = json.IndexOf('{', keyAt + needle.Length);
if (brace < 0)
{
return false;
}
int depth = 0;
for (int i = brace; i < json.Length; i++)
{
char c = json[i];
if (c == '{')
{
depth++;
}
else if (c == '}')
{
depth--;
if (depth == 0)
{
objectJson = json.Substring(brace, i - brace + 1);
return true;
}
}
}
return false;
}
/// <summary>Harness / debug: reload from disk without restarting the game.</summary>
public static bool Reload()
{
string folder = ModClass.ModFolder;
if (string.IsNullOrEmpty(folder))
{
return false;
}
LoadFromModFolder(folder);
return _loadedFromFile;
}
}