using System; using System.IO; using System.Text; using NeoModLoader.services; using UnityEngine; namespace IdleSpectator; /// /// Runtime scoring knobs loaded from scoring-model.json next to mod.json. /// Edit that file to tune; code falls back to built-in defaults if missing/invalid. /// [Serializable] public class ScoringModelDocument { public string modVersion; public ScoringWeights weights; public StoryWeights story; } /// StoryPlanner knobs from scoring-model.json story block. [Serializable] public class StoryWeights { public float nearTieEpsilon = 8f; /// /// Prefer / MC / MC-cast tips may beat a hotter stranger when the gap is below this /// (wider than ). /// public float sagaNearTieEpsilon = 28f; 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; /// Obsolete ledger bleed (CharacterLedger removed); kept for json compat. public float ledgerBleedLifeChapter = 0.4f; /// Obsolete ledger bleed (CharacterLedger removed); kept for json compat. public float ledgerBleedVillage = 0.82f; /// Life-saga roster soft MC tip bonus. public float sagaMcWeight = 12f; /// Extra soft bonus when the saga slot is Prefer'd. public float sagaPreferWeight = 16f; /// Extra soft bonus when the unit is a WorldBox favorite. public float sagaGameFavoriteWeight = 8f; /// Soft bonus when tip touches an MC's story cast (lover/friend/kin/rival). public float sagaCastWeight = 5f; /// Extra tip bonus when subject/related/follow touch two or more roster MCs. public float sagaCrossMcBonus = 11f; /// Living rematch count before EarnRival (kill/death do not count). public int sagaRivalEncounterThreshold = 3; /// Chapter starvation window before dull non-favorite MCs lose rank. public float sagaDullSeconds = 300f; /// Extra FixedDwell beat cool for family chapters (seconds, stacks with base). public float familyLifeBeatCooldownSeconds = 90f; // --- Crisis chapters (Phase 4) --- /// WarFront needs at least this many participants to open a crisis chapter. public int crisisWarParticipantMin = 8; /// Disaster / outbreak EventStrength floor to open a crisis chapter. public float crisisDisasterStrengthMin = 95f; /// Score boost for tips that belong to the live crisis. public float crisisOwnershipBoost = 22f; /// Extra cut-in margin while watching a crisis tip against unrelated peers. public float crisisHoldMargin = 40f; /// Keep the chapter after the last crisis signal for this many seconds. public float crisisLingerSeconds = 10f; /// /// Disaster / outbreak linger after the last WorldLog pulse (tornado tips are short FixedDwell). /// public float crisisDisasterLingerSeconds = 28f; /// Hard cap on crisis Active phase length (seconds). public float crisisMaxSeconds = 180f; /// Do not reopen a crisis for this many seconds after Done. public float crisisCooldownSeconds = 45f; /// /// Longer cool after disaster/outbreak closers so stacked storm tips cannot double-close. /// public float crisisDisasterCooldownSeconds = 120f; /// Forced closer strength when a crisis chapter ends. public float crisisEpilogueStrength = 52f; /// Forced closer max watch (seconds). public float crisisEpilogueMaxWatch = 16f; /// /// After a hard story/crisis closer, suppress soft-life crumbs and character-fill /// for this many seconds so AFK does not immediately crumb-surf. /// public float softFillQuietSeconds = 18f; } /// Serializable weight block — field names must match scoring-model.json "weights". [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; /// Extra margin required to interrupt sticky A scenes (combat/grief/status/hatch). public float stickyCutInMargin = 50f; /// /// 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. /// public float combatUrgentEventStrengthMin = 95f; /// /// Score margin over the live fight required when EventStrength clears /// (smaller than stickyCutInMargin). /// public float combatUrgentCutInMargin = 20f; /// After this many seconds on sticky combat/war, variety-valve peers may cut in. public float stickyVarietyValveAfterSeconds = 16f; /// /// Soft pair-combat valve open time (anonymous / single-notable Duels). /// Multi-fighter and notable-pair scraps still use stickyVarietyValveAfterSeconds. /// public float stickyVarietyValveAfterSecondsPair = 8f; /// Also open the valve after this fraction of MaxWatch (0.4 → 24s of a 60s combat). public float stickyVarietyValveMaxWatchFrac = 0.4f; /// Cut-in margin while the variety valve is open (between rotateSlack and stickyCutInMargin). public float stickyVarietyValveMargin = 20f; /// Lopsided fight scale multiplier floor (38v1 → near this; 1v1 balanced → 1). public float fightBalanceLopsidedFloor = 0.28f; /// Balance ratio (min/max sides) at which the fight gets full scale bonus. public float fightBalanceFullAt = 0.55f; /// Small bonus when WarFront has two material kingdom sides. 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; /// Minimum seconds on an EventLed shot before peer rotate / soft cut (interrupts bypass). Ambient exempt. 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; /// Cap for soft pair Duels (anonymous / single-notable); multi scrap uses maxWatchCombat. public float maxWatchDuel = 24f; public float maxWatchEpic = 50f; public int massFighterThreshold = 4; public float massBaseBonus = 28f; public float massPerExtraFighter = 4f; public float massExtraCap = 24f; /// Fighter count at which combat ensembles use Mass scale framing. 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; /// Bonus when the follow unit is the only living member of its species. public float speciesSingletonBonus = 14f; /// Bonus when species population is between 2 and speciesRareMaxPop inclusive. 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; /// /// Idle spectacles (evil mage pacing, dragon roosting) keep at least this action score /// so warm civic chores cannot own ambient forever. /// public float scannerSpectacleIdleFloor = 48f; /// /// Prefer a live spectacle over a non-spectacle ambient pick when within this score gap. /// 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; /// /// 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. /// public float fixedDwellBeatCooldownSeconds = 45f; /// /// Score subtracted per consecutive camera show of the same variety arc /// (e.g. duel → duel). Resets when a different arc is shown. /// public float repeatArcPenaltyPer = 24f; /// Cap on stacked repeat-arc penalty. public float repeatArcPenaltyCap = 72f; /// Rolling tip window for rarity/commonality score adjust. public int arcFrequencyWindow = 16; /// Soft per-arc share target inside the window (common arcs above this lose score). public float arcFairShare = 0.12f; /// Max TotalScore penalty when an arc saturates the window. public float arcOverSharePenaltyMax = 48f; /// Max TotalScore boost when an arc is absent from the window (rare). public float arcUnderShareBoostMax = 22f; /// Soft share cap for all combat arcs combined (duel + multi). public float arcCombatShareTarget = 0.35f; /// Extra penalty when combat share exceeds . public float arcCombatOverSharePenaltyMax = 36f; public int enrichTopK = 8; public float metaCacheTtl = 2f; } /// Loads and exposes from scoring-model.json. 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"); } } /// /// Unity JsonUtility often returns null nested objects on mixed doc/schema files. /// Parse the weights object directly (extracted by brace matching). /// 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(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(weightsJson); if (parsed != null) { weights = parsed; return true; } } // Fallback: whole-document parse (works if file is weights-only wrapped). ScoringModelDocument doc = JsonUtility.FromJson(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; } /// Harness / debug: reload from disk without restarting the game. public static bool Reload() { string folder = ModClass.ModFolder; if (string.IsNullOrEmpty(folder)) { return false; } LoadFromModFolder(folder); return _loadedFromFile; } }