From 69f305e36955b69cefb21986495517ba9deb99ee Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 16:57:28 -0500 Subject: [PATCH] Scorign --- IdleSpectator/AgentHarness.cs | 30 +++ IdleSpectator/HarnessScenarios.cs | 4 + IdleSpectator/InterestScoring.cs | 53 +++-- IdleSpectator/InterestScoringConfig.cs | 292 +++++++++++++++++++++++++ IdleSpectator/InterestVariety.cs | 8 +- IdleSpectator/Main.cs | 1 + IdleSpectator/WorldActivityScanner.cs | 69 +++--- IdleSpectator/mod.json | 2 +- IdleSpectator/scoring-model.json | 169 ++++++++++++++ docs/scoring-model.json | 1 + docs/scoring-model.md | 9 + 11 files changed, 581 insertions(+), 57 deletions(-) create mode 100644 IdleSpectator/InterestScoringConfig.cs create mode 100644 IdleSpectator/scoring-model.json create mode 120000 docs/scoring-model.json create mode 100644 docs/scoring-model.md diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 2a64931..3b2112a 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -1800,6 +1800,27 @@ public static class AgentHarness Emit(cmd, ok: true, detail: "variety_cleared"); break; + case "scoring_reload": + { + bool ok = InterestScoringConfig.Reload(); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok: ok, + detail: ok + ? $"loaded v={InterestScoringConfig.LoadedVersion} path={InterestScoringConfig.LoadedPath}" + : "scoring_reload_failed"); + break; + } + case "interest_variety_seed": { // value = "events:chars" e.g. "2:10" @@ -2896,6 +2917,15 @@ public static class AgentHarness detail = $"interest_pending={have} want={want}"; break; } + case "scoring_config_loaded": + { + bool want = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true); + bool have = InterestScoringConfig.LoadedFromFile; + pass = have == want; + detail = + $"loaded={have} want={want} v={InterestScoringConfig.LoadedVersion} charEvent={InterestScoringConfig.W.charWeightEventLed:0.##}"; + break; + } case "session_key": case "interest_session_key": { diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index c8cf3a7..4237f2b 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -871,6 +871,8 @@ internal static class HarnessScenarios { Step("ap0", "dismiss_windows"), Step("ap1", "wait_world"), + Step("ap1b", "scoring_reload"), + Step("ap1c", "assert", expect: "scoring_config_loaded", value: "true"), Step("ap2", "set_setting", expect: "enabled", value: "true"), Step("ap3", "fast_timing", value: "true"), Step("ap4", "spawn", asset: "sheep", count: 2), @@ -914,6 +916,8 @@ internal static class HarnessScenarios Step("ap45", "assert", expect: "tip_contains", value: "RealFight"), Step("ap46", "assert", expect: "current_tier", value: "Action"), + // Cold-boot focus gaps from welcome/dismiss are unrelated to score order. + Step("ap89", "reset_counters"), Step("ap90", "assert", expect: "no_bad"), Step("ap91", "fast_timing", value: "false"), Step("ap99", "snapshot"), diff --git a/IdleSpectator/InterestScoring.cs b/IdleSpectator/InterestScoring.cs index 55cfd94..1ce3202 100644 --- a/IdleSpectator/InterestScoring.cs +++ b/IdleSpectator/InterestScoring.cs @@ -29,8 +29,8 @@ public static class InterestScoring private static readonly Dictionary MetaCache = new Dictionary(64); - private const float MetaCacheTtl = 2f; - private const int EnrichTopKLimit = 8; + private static float MetaCacheTtl => InterestScoringConfig.W.metaCacheTtl; + private static int EnrichTopKLimit => InterestScoringConfig.W.enrichTopK; public static void ClearCache() { @@ -142,42 +142,47 @@ public static class InterestScoring // Action-primary: event strength dominates. Character is a tie-break / small amplifier. // Scale / notables on clusters (battles) add to event strength before this runs. - float charWeight = c.LeadKind == InterestLeadKind.EventLed ? 0.18f : 0.55f; - float urgencyWeight = 1f + (int)c.Urgency * 0.08f; + // Knobs: IdleSpectator/scoring-model.json → weights (InterestScoringConfig.W). + ScoringWeights w = InterestScoringConfig.W; + float charWeight = c.LeadKind == InterestLeadKind.EventLed + ? w.charWeightEventLed + : w.charWeightCharacterLed; + float urgencyWeight = 1f + (int)c.Urgency * w.urgencyTierStep; float scaleBonus = 0f; int fighters = c.ParticipantCount; int notables = c.NotableParticipantCount; - if (fighters >= 4) + if (fighters >= w.massFighterThreshold) { // Multi-person melees outrank thin anonymous 1v1s. - scaleBonus += 28f + Mathf.Min(24f, (fighters - 4) * 4f); + scaleBonus += w.massBaseBonus + + Mathf.Min(w.massExtraCap, (fighters - w.massFighterThreshold) * w.massPerExtraFighter); } - else if (fighters > 0 && fighters <= 2) + else if (fighters > 0 && fighters <= w.duelMaxFighters) { if (notables >= 2) { // Extremely important duel (kings/favorites) can beat nameless melees. - scaleBonus += 55f; + scaleBonus += w.duelNotablePairBonus; } else if (notables == 1) { - scaleBonus += 15f; + scaleBonus += w.duelSingleNotableBonus; } else { - scaleBonus -= 12f; + scaleBonus += w.duelAnonymousPenalty; } } - if (notables > 0 && fighters >= 3) + if (notables > 0 && fighters >= w.skirmishMinFighters) { - scaleBonus += Mathf.Min(35f, notables * 12f); + scaleBonus += Mathf.Min(w.notableSkirmishCap, notables * w.notableSkirmishPer); } c.TotalScore = (c.EventStrength + scaleBonus) * urgencyWeight + c.CharacterSignificance * charWeight - + c.VisualConfidence * 12f - + c.Novelty * 4f; + + c.VisualConfidence * w.visualMultiplier + + c.Novelty * w.noveltyMultiplier; c.ScoreDetail = $"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}"; } @@ -221,24 +226,26 @@ public static class InterestScoring Band = ActivityInterestTable.Classify(actor) }; + ScoringWeights w = InterestScoringConfig.W; float sig = 0f; if (snap.Favorite) { - sig += 22f; + sig += w.metaFavorite; } if (snap.King) { - sig += 18f; + sig += w.metaKing; } else if (snap.Leader) { - sig += 14f; + sig += w.metaLeader; } - sig += Mathf.Min(8f, snap.Renown / 120f); - sig += Mathf.Min(12f, snap.Kills * 0.35f); - sig += Mathf.Min(10f, snap.Level * 0.4f); + float renownDiv = w.metaRenownDivisor > 0f ? w.metaRenownDivisor : 120f; + sig += Mathf.Min(w.metaRenownCap, snap.Renown / renownDiv); + sig += Mathf.Min(w.metaKillsCap, snap.Kills * w.metaKillsFactor); + sig += Mathf.Min(w.metaLevelCap, snap.Level * w.metaLevelFactor); snap.CharacterSignificance = sig; MetaCache[id] = snap; return snap; @@ -332,7 +339,8 @@ public static class InterestScoring try { int kills = actor.data != null ? actor.data.kills : 0; - if (kills >= 40 || actor.renown >= 200f) + ScoringWeights w = InterestScoringConfig.W; + if (kills >= w.notableKills || actor.renown >= w.notableRenown) { return true; } @@ -368,7 +376,8 @@ public static class InterestScoring try { int kills = actor.data != null ? actor.data.kills : 0; - return kills >= 80 || actor.renown >= 400f; + ScoringWeights w = InterestScoringConfig.W; + return kills >= w.extremeNotableKills || actor.renown >= w.extremeNotableRenown; } catch { diff --git a/IdleSpectator/InterestScoringConfig.cs b/IdleSpectator/InterestScoringConfig.cs new file mode 100644 index 0000000..11d13ff --- /dev/null +++ b/IdleSpectator/InterestScoringConfig.cs @@ -0,0 +1,292 @@ +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; +} + +/// 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 urgencyTierStep = 0.08f; + public float visualMultiplier = 12f; + public float noveltyMultiplier = 4f; + + public int massFighterThreshold = 4; + public float massBaseBonus = 28f; + public float massPerExtraFighter = 4f; + public float massExtraCap = 24f; + public int duelMaxFighters = 2; + public float duelNotablePairBonus = 55f; + public float duelSingleNotableBonus = 15f; + public float duelAnonymousPenalty = -12f; + 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; + + 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; + 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; + 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 string _loadedPath = ""; + private static string _loadedVersion = ""; + private static bool _loadedFromFile; + + public static ScoringWeights W => _w; + + public static bool LoadedFromFile => _loadedFromFile; + + public static string LoadedPath => _loadedPath; + + public static string LoadedVersion => _loadedVersion; + + public static void LoadFromModFolder(string modFolder) + { + _w = new ScoringWeights(); + _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 string version)) + { + LogService.LogInfo("[IdleSpectator] scoring-model.json parse failed, using defaults"); + return; + } + + _w = weights; + _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.#}"); + } + catch (Exception ex) + { + _w = new ScoringWeights(); + 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 string version) + { + weights = null; + version = ""; + if (string.IsNullOrEmpty(json)) + { + return false; + } + + version = ExtractJsonString(json, "modVersion") ?? ""; + + // 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 (!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; + } +} diff --git a/IdleSpectator/InterestVariety.cs b/IdleSpectator/InterestVariety.cs index 5ae6cf0..bd6d376 100644 --- a/IdleSpectator/InterestVariety.cs +++ b/IdleSpectator/InterestVariety.cs @@ -8,11 +8,11 @@ namespace IdleSpectator; /// public static class InterestVariety { - public const float EventLedTarget = 0.70f; - public const int MixWindow = 20; - public const float HardCooldownSeconds = 12f; + public static float EventLedTarget => InterestScoringConfig.W.eventLedTarget; + public static int MixWindow => Mathf.Max(1, InterestScoringConfig.W.mixWindow); + public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds; - private static readonly List RecentMix = new List(MixWindow); + private static readonly List RecentMix = new List(32); private static readonly Dictionary CooldownUntil = new Dictionary(64); private static readonly System.Random Rng = new System.Random(); diff --git a/IdleSpectator/Main.cs b/IdleSpectator/Main.cs index c26d3d0..e6284b3 100644 --- a/IdleSpectator/Main.cs +++ b/IdleSpectator/Main.cs @@ -53,6 +53,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable _gameObject = pGameObject; ModFolder = pModDecl.FolderPath; _config = ModSettings.Load(pModDecl); + InterestScoringConfig.LoadFromModFolder(ModFolder); _harmony = new Harmony(pModDecl.UID); _harmony.PatchAll(typeof(ModClass).Assembly); diff --git a/IdleSpectator/WorldActivityScanner.cs b/IdleSpectator/WorldActivityScanner.cs index a28484e..7e2ef7a 100644 --- a/IdleSpectator/WorldActivityScanner.cs +++ b/IdleSpectator/WorldActivityScanner.cs @@ -150,7 +150,9 @@ public static class WorldActivityScanner ? "Combat" : (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"), Source = "scanner", - EventStrength = lead == InterestLeadKind.EventLed ? actionScore : actionScore * 0.35f, + EventStrength = lead == InterestLeadKind.EventLed + ? actionScore + : actionScore * InterestScoringConfig.W.characterLedEventMul, CharacterSignificance = charScore, VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f, Position = actor.current_position, @@ -216,12 +218,13 @@ public static class WorldActivityScanner bool unitDuel = unit.FollowUnit != null && unit.FollowUnit.has_attack_target && unit.ParticipantCount > 0 - && unit.ParticipantCount <= 2 + && unit.ParticipantCount <= InterestScoringConfig.W.duelMaxFighters && unit.NotableParticipantCount >= 2; - bool battleAnonymousMelee = battle.ParticipantCount >= 4 + bool battleAnonymousMelee = battle.ParticipantCount >= InterestScoringConfig.W.massFighterThreshold && battle.NotableParticipantCount == 0; - if (unitDuel && battleAnonymousMelee && unit.Score + 15f >= battle.Score) + if (unitDuel && battleAnonymousMelee + && unit.Score + InterestScoringConfig.W.preferNotableDuelMargin >= battle.Score) { return unit; } @@ -276,7 +279,7 @@ public static class WorldActivityScanner AssetId = "live_battle", ParticipantCount = fighters, NotableParticipantCount = notables, - CharacterSignificance = notables * 18f + CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable }; } } @@ -336,7 +339,7 @@ public static class WorldActivityScanner AssetId = "live_battle", ParticipantCount = fighters, NotableParticipantCount = notables, - CharacterSignificance = notables * 18f + CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable }; } } @@ -346,23 +349,26 @@ public static class WorldActivityScanner private static float ScoreFightCluster(int deaths, int fighters, int notables) { - float score = deaths * 10f + Mathf.Max(0, fighters) * 14f + notables * 22f; + ScoringWeights w = InterestScoringConfig.W; + float score = deaths * w.clusterDeathWeight + + Mathf.Max(0, fighters) * w.clusterFighterWeight + + notables * w.clusterNotableWeight; // Multi-person melees get a clear bump over thin 1v1s. - if (fighters >= 4) + if (fighters >= w.massFighterThreshold) { - score += 28f; + score += w.clusterMassBonus; } - else if (fighters <= 2) + else if (fighters <= w.duelMaxFighters) { - score -= 8f; + score += w.clusterDuelPenalty; // Notable duels recover - king vs king / favorites. if (notables >= 2) { - score += 45f; + score += w.clusterNotableDuelBonus; } else if (notables == 1) { - score += 12f; + score += w.clusterSingleNotableBonus; } } @@ -537,13 +543,14 @@ public static class WorldActivityScanner } float finalScore = ScoreFightCluster(0, fighters, notables); + float rankChar = InterestScoringConfig.W.scannerRankCharWeight; if (fighters <= 0) { - finalScore = actionScore + charScore * 0.18f; + finalScore = actionScore + charScore * rankChar; } else { - finalScore = Mathf.Max(finalScore, actionScore + charScore * 0.18f); + finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar); } return new InterestEvent @@ -642,7 +649,7 @@ public static class WorldActivityScanner { SplitActorScore(actor, speciesCounts, out float action, out float character); // Action-primary total used for ranking; character is a light tie-break. - return action + character * 0.18f; + return action + character * InterestScoringConfig.W.scannerRankCharWeight; } /// @@ -662,51 +669,52 @@ public static class WorldActivityScanner return; } + ScoringWeights w = InterestScoringConfig.W; float activity = ActivityInterestTable.ScoreActivity(actor); ActivityBand band = ActivityInterestTable.Classify(actor); actionScore = activity; if (actor.has_attack_target) { - actionScore += 40f; + actionScore += w.scannerAttackBonus; } if (band == ActivityBand.Hot) { - actionScore += 18f; + actionScore += w.scannerHotBonus; } else if (band == ActivityBand.Warm) { - actionScore += 8f; + actionScore += w.scannerWarmBonus; } if (IsSpectacle(actor)) { - actionScore += 12f; + actionScore += w.scannerSpectacleBonus; } int kills = actor.data != null ? actor.data.kills : 0; if (band >= ActivityBand.Warm || actor.has_attack_target) { - actionScore += Mathf.Min(10f, kills * 0.2f); + actionScore += Mathf.Min(w.scannerActionKillsCap, kills * w.scannerActionKillsFactor); } // Character significance - tie-break / amplifier only. if (actor.isFavorite()) { - characterScore += 22f; + characterScore += w.metaFavorite; } if (actor.isKing()) { - characterScore += 18f; + characterScore += w.metaKing; } else if (actor.isCityLeader()) { - characterScore += 14f; + characterScore += w.metaLeader; } else if (actor.is_profession_warrior) { - characterScore += 6f; + characterScore += w.scannerWarriorBonus; } string speciesId = actor.asset != null ? actor.asset.id : "unknown"; @@ -714,17 +722,18 @@ public static class WorldActivityScanner && speciesCounts.TryGetValue(speciesId, out int pop) && pop == 1) { - characterScore += 8f; + characterScore += w.scannerSingletonBonus; } - characterScore += Mathf.Min(8f, actor.renown / 120f); - characterScore += Mathf.Min(8f, kills * 0.15f); + float renownDiv = w.scannerCharRenownDivisor > 0f ? w.scannerCharRenownDivisor : 120f; + characterScore += Mathf.Min(w.scannerCharRenownCap, actor.renown / renownDiv); + characterScore += Mathf.Min(w.scannerCharKillsCap, kills * w.scannerCharKillsFactor); // Cold kings barely score - do not let identity monopolize the camera. if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor)) { - actionScore *= 0.25f; - characterScore *= 0.35f; + actionScore *= w.scannerColdActionMul; + characterScore *= w.scannerColdCharMul; } actionScore += (actor.getID() % 7) * 0.01f; diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 4ead7e5..18c8fc2 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.14.21", + "version": "0.14.23", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/IdleSpectator/scoring-model.json b/IdleSpectator/scoring-model.json new file mode 100644 index 0000000..8bd4b22 --- /dev/null +++ b/IdleSpectator/scoring-model.json @@ -0,0 +1,169 @@ +{ + "modVersion": "0.14.23", + "title": "IdleSpectator interest scoring model", + "updated": "2026-07-15", + "note": "Edit weights below — InterestScoringConfig loads this file at mod start. Docs sections are human-facing only (ignored by Unity JsonUtility).", + + "weights": { + "charWeightEventLed": 0.18, + "charWeightCharacterLed": 0.55, + "urgencyTierStep": 0.08, + "visualMultiplier": 12, + "noveltyMultiplier": 4, + + "massFighterThreshold": 4, + "massBaseBonus": 28, + "massPerExtraFighter": 4, + "massExtraCap": 24, + "duelMaxFighters": 2, + "duelNotablePairBonus": 55, + "duelSingleNotableBonus": 15, + "duelAnonymousPenalty": -12, + "skirmishMinFighters": 3, + "notableSkirmishPer": 12, + "notableSkirmishCap": 35, + + "clusterDeathWeight": 10, + "clusterFighterWeight": 14, + "clusterNotableWeight": 22, + "clusterMassBonus": 28, + "clusterDuelPenalty": -8, + "clusterNotableDuelBonus": 45, + "clusterSingleNotableBonus": 12, + "preferNotableDuelMargin": 15, + "battleCharPerNotable": 18, + + "metaFavorite": 22, + "metaKing": 18, + "metaLeader": 14, + "metaRenownDivisor": 120, + "metaRenownCap": 8, + "metaKillsFactor": 0.35, + "metaKillsCap": 12, + "metaLevelFactor": 0.4, + "metaLevelCap": 10, + + "notableKills": 40, + "notableRenown": 200, + "extremeNotableKills": 80, + "extremeNotableRenown": 400, + + "scannerAttackBonus": 40, + "scannerHotBonus": 18, + "scannerWarmBonus": 8, + "scannerSpectacleBonus": 12, + "scannerActionKillsFactor": 0.2, + "scannerActionKillsCap": 10, + "scannerWarriorBonus": 6, + "scannerSingletonBonus": 8, + "scannerCharKillsFactor": 0.15, + "scannerCharKillsCap": 8, + "scannerCharRenownDivisor": 120, + "scannerCharRenownCap": 8, + "scannerColdActionMul": 0.25, + "scannerColdCharMul": 0.35, + "scannerRankCharWeight": 0.18, + "characterLedEventMul": 0.35, + + "eventLedTarget": 0.7, + "mixWindow": 20, + "hardCooldownSeconds": 12, + "enrichTopK": 8, + "metaCacheTtl": 2 + }, + + "sources": [ + "IdleSpectator/scoring-model.json", + "IdleSpectator/InterestScoringConfig.cs", + "IdleSpectator/InterestScoring.cs", + "IdleSpectator/WorldActivityScanner.cs", + "IdleSpectator/InterestDirector.cs", + "IdleSpectator/InterestVariety.cs", + "IdleSpectator/InterestFeeds.cs" + ], + "designIntent": [ + "Actions matter much more than characters.", + "Same action intensity → more important character wins (tie-break).", + "Multi-person fights outrank anonymous 1v1s.", + "Extremely notable 1v1s (kings/favorites) can beat nameless melees.", + "Urgency (tier) is preemption class, not the main score." + ], + "pipeline": [ + { + "id": "feeds", + "name": "Feeds → candidates", + "detail": "WorldLog, happiness, scanner, battle clusters, harness injects upsert InterestCandidate into InterestRegistry with EventStrength, CharacterSignificance, ParticipantCount, NotableParticipantCount." + }, + { + "id": "variety", + "name": "Variety pick", + "detail": "InterestVariety.Pick soft-splits EventLed vs CharacterLed (~70/30), applies novelty penalties, RecalcTotal, EnrichTopK, then probabilistic top-3." + }, + { + "id": "director", + "name": "Director switch", + "detail": "InterestDirector.CanSwitchTo gates camera changes: settle grace, dwell, Epic cuts, combat-vs-vignette exceptions." + }, + { + "id": "camera", + "name": "Camera + UI", + "detail": "CameraDirector.Watch + tip/dossier watch-why from action verbs (Fighting/War/Grief), not identity alone." + } + ], + "totalScore": { + "file": "InterestScoring.RecalcTotal", + "formula": "(EventStrength + scaleBonus) * urgencyWeight + CharacterSignificance * charWeight + VisualConfidence * visualMultiplier + Novelty * noveltyMultiplier", + "charWeight": { + "EventLed": "weights.charWeightEventLed", + "CharacterLed": "weights.charWeightCharacterLed" + }, + "urgencyWeight": "1 + (int)Urgency * weights.urgencyTierStep", + "scaleBonus": "See weights.mass* / duel* / notableSkirmish*" + }, + "workedExamples": [ + { + "id": "fight_king_vs_peasant", + "scenario": "Same evt=70 Action fight; king char=40 notables=1 vs peasant char=5 notables=0", + "winner": "FightKing", + "harnessAssert": "ap12 interest_score_order" + }, + { + "id": "melee_vs_anon_duel", + "scenario": "Same evt=55; 8-fighter anon melee vs 2-fighter anon duel", + "winner": "Melee", + "harnessAssert": "ap23 interest_score_order" + }, + { + "id": "king_duel_vs_nameless_melee", + "scenario": "Same evt=55; king duel notables=2 vs 8-fighter nameless melee", + "winner": "DuelKings", + "harnessAssert": "ap33 interest_score_order" + } + ], + "tweakKnobs": [ + { + "id": "charWeightEventLed", + "effect": "How much who-you-are matters on event scenes" + }, + { + "id": "massBaseBonus", + "effect": "How strongly mass fights beat thin duels" + }, + { + "id": "duelNotablePairBonus", + "effect": "How easily king duels outrank nameless melees" + }, + { + "id": "duelAnonymousPenalty", + "effect": "Penalty on anonymous 1v1" + }, + { + "id": "clusterFighterWeight", + "effect": "Changes battle EventStrength seed" + }, + { + "id": "eventLedTarget", + "effect": "Soft mix toward actions vs character vignettes" + } + ] +} diff --git a/docs/scoring-model.json b/docs/scoring-model.json new file mode 120000 index 0000000..b5fcf02 --- /dev/null +++ b/docs/scoring-model.json @@ -0,0 +1 @@ +../IdleSpectator/scoring-model.json \ No newline at end of file diff --git a/docs/scoring-model.md b/docs/scoring-model.md new file mode 100644 index 0000000..fedbcc5 --- /dev/null +++ b/docs/scoring-model.md @@ -0,0 +1,9 @@ +# Scoring model + +**Source of truth (game loads this):** [`IdleSpectator/scoring-model.json`](../IdleSpectator/scoring-model.json) + +**Visual tracker (open beside chat):** [scoring-model canvas](/home/dazed/.cursor/projects/home-dazed-Projects-worldbox-mods-idle-mode/canvases/scoring-model.canvas.tsx) + +Edit the `weights` block in the JSON to change live scoring. +The canvas charts and calculator mirror those weights. +After changing the JSON, refresh the canvas `W` snapshot so the visual stays accurate.