This commit is contained in:
DazedAnon 2026-07-15 16:57:28 -05:00
parent 77cb688925
commit 69f305e369
11 changed files with 581 additions and 57 deletions

View file

@ -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":
{

View file

@ -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"),

View file

@ -29,8 +29,8 @@ public static class InterestScoring
private static readonly Dictionary<long, InterestMetadataSnapshot> MetaCache =
new Dictionary<long, InterestMetadataSnapshot>(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
{

View file

@ -0,0 +1,292 @@
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;
}
/// <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 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;
}
/// <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 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");
}
}
/// <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 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<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 (!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;
}
}

View file

@ -8,11 +8,11 @@ namespace IdleSpectator;
/// </summary>
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<InterestLeadKind> RecentMix = new List<InterestLeadKind>(MixWindow);
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
private static readonly Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
private static readonly System.Random Rng = new System.Random();

View file

@ -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);

View file

@ -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;
}
/// <summary>
@ -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;

View file

@ -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"
}

View file

@ -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"
}
]
}

1
docs/scoring-model.json Symbolic link
View file

@ -0,0 +1 @@
../IdleSpectator/scoring-model.json

9
docs/scoring-model.md Normal file
View file

@ -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.