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"); Emit(cmd, ok: true, detail: "variety_cleared");
break; 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": case "interest_variety_seed":
{ {
// value = "events:chars" e.g. "2:10" // value = "events:chars" e.g. "2:10"
@ -2896,6 +2917,15 @@ public static class AgentHarness
detail = $"interest_pending={have} want={want}"; detail = $"interest_pending={have} want={want}";
break; 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 "session_key":
case "interest_session_key": case "interest_session_key":
{ {

View file

@ -871,6 +871,8 @@ internal static class HarnessScenarios
{ {
Step("ap0", "dismiss_windows"), Step("ap0", "dismiss_windows"),
Step("ap1", "wait_world"), 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("ap2", "set_setting", expect: "enabled", value: "true"),
Step("ap3", "fast_timing", value: "true"), Step("ap3", "fast_timing", value: "true"),
Step("ap4", "spawn", asset: "sheep", count: 2), Step("ap4", "spawn", asset: "sheep", count: 2),
@ -914,6 +916,8 @@ internal static class HarnessScenarios
Step("ap45", "assert", expect: "tip_contains", value: "RealFight"), Step("ap45", "assert", expect: "tip_contains", value: "RealFight"),
Step("ap46", "assert", expect: "current_tier", value: "Action"), 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("ap90", "assert", expect: "no_bad"),
Step("ap91", "fast_timing", value: "false"), Step("ap91", "fast_timing", value: "false"),
Step("ap99", "snapshot"), Step("ap99", "snapshot"),

View file

@ -29,8 +29,8 @@ public static class InterestScoring
private static readonly Dictionary<long, InterestMetadataSnapshot> MetaCache = private static readonly Dictionary<long, InterestMetadataSnapshot> MetaCache =
new Dictionary<long, InterestMetadataSnapshot>(64); new Dictionary<long, InterestMetadataSnapshot>(64);
private const float MetaCacheTtl = 2f; private static float MetaCacheTtl => InterestScoringConfig.W.metaCacheTtl;
private const int EnrichTopKLimit = 8; private static int EnrichTopKLimit => InterestScoringConfig.W.enrichTopK;
public static void ClearCache() public static void ClearCache()
{ {
@ -142,42 +142,47 @@ public static class InterestScoring
// Action-primary: event strength dominates. Character is a tie-break / small amplifier. // Action-primary: event strength dominates. Character is a tie-break / small amplifier.
// Scale / notables on clusters (battles) add to event strength before this runs. // Scale / notables on clusters (battles) add to event strength before this runs.
float charWeight = c.LeadKind == InterestLeadKind.EventLed ? 0.18f : 0.55f; // Knobs: IdleSpectator/scoring-model.json → weights (InterestScoringConfig.W).
float urgencyWeight = 1f + (int)c.Urgency * 0.08f; 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; float scaleBonus = 0f;
int fighters = c.ParticipantCount; int fighters = c.ParticipantCount;
int notables = c.NotableParticipantCount; int notables = c.NotableParticipantCount;
if (fighters >= 4) if (fighters >= w.massFighterThreshold)
{ {
// Multi-person melees outrank thin anonymous 1v1s. // 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) if (notables >= 2)
{ {
// Extremely important duel (kings/favorites) can beat nameless melees. // Extremely important duel (kings/favorites) can beat nameless melees.
scaleBonus += 55f; scaleBonus += w.duelNotablePairBonus;
} }
else if (notables == 1) else if (notables == 1)
{ {
scaleBonus += 15f; scaleBonus += w.duelSingleNotableBonus;
} }
else 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.TotalScore = (c.EventStrength + scaleBonus) * urgencyWeight
+ c.CharacterSignificance * charWeight + c.CharacterSignificance * charWeight
+ c.VisualConfidence * 12f + c.VisualConfidence * w.visualMultiplier
+ c.Novelty * 4f; + c.Novelty * w.noveltyMultiplier;
c.ScoreDetail = c.ScoreDetail =
$"evt={c.EventStrength:0.#} char={c.CharacterSignificance:0.#} n={fighters}/{notables} vis={c.VisualConfidence:0.##}"; $"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) Band = ActivityInterestTable.Classify(actor)
}; };
ScoringWeights w = InterestScoringConfig.W;
float sig = 0f; float sig = 0f;
if (snap.Favorite) if (snap.Favorite)
{ {
sig += 22f; sig += w.metaFavorite;
} }
if (snap.King) if (snap.King)
{ {
sig += 18f; sig += w.metaKing;
} }
else if (snap.Leader) else if (snap.Leader)
{ {
sig += 14f; sig += w.metaLeader;
} }
sig += Mathf.Min(8f, snap.Renown / 120f); float renownDiv = w.metaRenownDivisor > 0f ? w.metaRenownDivisor : 120f;
sig += Mathf.Min(12f, snap.Kills * 0.35f); sig += Mathf.Min(w.metaRenownCap, snap.Renown / renownDiv);
sig += Mathf.Min(10f, snap.Level * 0.4f); sig += Mathf.Min(w.metaKillsCap, snap.Kills * w.metaKillsFactor);
sig += Mathf.Min(w.metaLevelCap, snap.Level * w.metaLevelFactor);
snap.CharacterSignificance = sig; snap.CharacterSignificance = sig;
MetaCache[id] = snap; MetaCache[id] = snap;
return snap; return snap;
@ -332,7 +339,8 @@ public static class InterestScoring
try try
{ {
int kills = actor.data != null ? actor.data.kills : 0; 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; return true;
} }
@ -368,7 +376,8 @@ public static class InterestScoring
try try
{ {
int kills = actor.data != null ? actor.data.kills : 0; 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 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> /// </summary>
public static class InterestVariety public static class InterestVariety
{ {
public const float EventLedTarget = 0.70f; public static float EventLedTarget => InterestScoringConfig.W.eventLedTarget;
public const int MixWindow = 20; public static int MixWindow => Mathf.Max(1, InterestScoringConfig.W.mixWindow);
public const float HardCooldownSeconds = 12f; 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 Dictionary<string, float> CooldownUntil = new Dictionary<string, float>(64);
private static readonly System.Random Rng = new System.Random(); private static readonly System.Random Rng = new System.Random();

View file

@ -53,6 +53,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
_gameObject = pGameObject; _gameObject = pGameObject;
ModFolder = pModDecl.FolderPath; ModFolder = pModDecl.FolderPath;
_config = ModSettings.Load(pModDecl); _config = ModSettings.Load(pModDecl);
InterestScoringConfig.LoadFromModFolder(ModFolder);
_harmony = new Harmony(pModDecl.UID); _harmony = new Harmony(pModDecl.UID);
_harmony.PatchAll(typeof(ModClass).Assembly); _harmony.PatchAll(typeof(ModClass).Assembly);

View file

@ -150,7 +150,9 @@ public static class WorldActivityScanner
? "Combat" ? "Combat"
: (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"), : (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"),
Source = "scanner", Source = "scanner",
EventStrength = lead == InterestLeadKind.EventLed ? actionScore : actionScore * 0.35f, EventStrength = lead == InterestLeadKind.EventLed
? actionScore
: actionScore * InterestScoringConfig.W.characterLedEventMul,
CharacterSignificance = charScore, CharacterSignificance = charScore,
VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f, VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
Position = actor.current_position, Position = actor.current_position,
@ -216,12 +218,13 @@ public static class WorldActivityScanner
bool unitDuel = unit.FollowUnit != null bool unitDuel = unit.FollowUnit != null
&& unit.FollowUnit.has_attack_target && unit.FollowUnit.has_attack_target
&& unit.ParticipantCount > 0 && unit.ParticipantCount > 0
&& unit.ParticipantCount <= 2 && unit.ParticipantCount <= InterestScoringConfig.W.duelMaxFighters
&& unit.NotableParticipantCount >= 2; && unit.NotableParticipantCount >= 2;
bool battleAnonymousMelee = battle.ParticipantCount >= 4 bool battleAnonymousMelee = battle.ParticipantCount >= InterestScoringConfig.W.massFighterThreshold
&& battle.NotableParticipantCount == 0; && battle.NotableParticipantCount == 0;
if (unitDuel && battleAnonymousMelee && unit.Score + 15f >= battle.Score) if (unitDuel && battleAnonymousMelee
&& unit.Score + InterestScoringConfig.W.preferNotableDuelMargin >= battle.Score)
{ {
return unit; return unit;
} }
@ -276,7 +279,7 @@ public static class WorldActivityScanner
AssetId = "live_battle", AssetId = "live_battle",
ParticipantCount = fighters, ParticipantCount = fighters,
NotableParticipantCount = notables, NotableParticipantCount = notables,
CharacterSignificance = notables * 18f CharacterSignificance = notables * InterestScoringConfig.W.battleCharPerNotable
}; };
} }
} }
@ -336,7 +339,7 @@ public static class WorldActivityScanner
AssetId = "live_battle", AssetId = "live_battle",
ParticipantCount = fighters, ParticipantCount = fighters,
NotableParticipantCount = notables, 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) 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. // 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. // Notable duels recover - king vs king / favorites.
if (notables >= 2) if (notables >= 2)
{ {
score += 45f; score += w.clusterNotableDuelBonus;
} }
else if (notables == 1) else if (notables == 1)
{ {
score += 12f; score += w.clusterSingleNotableBonus;
} }
} }
@ -537,13 +543,14 @@ public static class WorldActivityScanner
} }
float finalScore = ScoreFightCluster(0, fighters, notables); float finalScore = ScoreFightCluster(0, fighters, notables);
float rankChar = InterestScoringConfig.W.scannerRankCharWeight;
if (fighters <= 0) if (fighters <= 0)
{ {
finalScore = actionScore + charScore * 0.18f; finalScore = actionScore + charScore * rankChar;
} }
else else
{ {
finalScore = Mathf.Max(finalScore, actionScore + charScore * 0.18f); finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar);
} }
return new InterestEvent return new InterestEvent
@ -642,7 +649,7 @@ public static class WorldActivityScanner
{ {
SplitActorScore(actor, speciesCounts, out float action, out float character); SplitActorScore(actor, speciesCounts, out float action, out float character);
// Action-primary total used for ranking; character is a light tie-break. // Action-primary total used for ranking; character is a light tie-break.
return action + character * 0.18f; return action + character * InterestScoringConfig.W.scannerRankCharWeight;
} }
/// <summary> /// <summary>
@ -662,51 +669,52 @@ public static class WorldActivityScanner
return; return;
} }
ScoringWeights w = InterestScoringConfig.W;
float activity = ActivityInterestTable.ScoreActivity(actor); float activity = ActivityInterestTable.ScoreActivity(actor);
ActivityBand band = ActivityInterestTable.Classify(actor); ActivityBand band = ActivityInterestTable.Classify(actor);
actionScore = activity; actionScore = activity;
if (actor.has_attack_target) if (actor.has_attack_target)
{ {
actionScore += 40f; actionScore += w.scannerAttackBonus;
} }
if (band == ActivityBand.Hot) if (band == ActivityBand.Hot)
{ {
actionScore += 18f; actionScore += w.scannerHotBonus;
} }
else if (band == ActivityBand.Warm) else if (band == ActivityBand.Warm)
{ {
actionScore += 8f; actionScore += w.scannerWarmBonus;
} }
if (IsSpectacle(actor)) if (IsSpectacle(actor))
{ {
actionScore += 12f; actionScore += w.scannerSpectacleBonus;
} }
int kills = actor.data != null ? actor.data.kills : 0; int kills = actor.data != null ? actor.data.kills : 0;
if (band >= ActivityBand.Warm || actor.has_attack_target) 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. // Character significance - tie-break / amplifier only.
if (actor.isFavorite()) if (actor.isFavorite())
{ {
characterScore += 22f; characterScore += w.metaFavorite;
} }
if (actor.isKing()) if (actor.isKing())
{ {
characterScore += 18f; characterScore += w.metaKing;
} }
else if (actor.isCityLeader()) else if (actor.isCityLeader())
{ {
characterScore += 14f; characterScore += w.metaLeader;
} }
else if (actor.is_profession_warrior) else if (actor.is_profession_warrior)
{ {
characterScore += 6f; characterScore += w.scannerWarriorBonus;
} }
string speciesId = actor.asset != null ? actor.asset.id : "unknown"; string speciesId = actor.asset != null ? actor.asset.id : "unknown";
@ -714,17 +722,18 @@ public static class WorldActivityScanner
&& speciesCounts.TryGetValue(speciesId, out int pop) && speciesCounts.TryGetValue(speciesId, out int pop)
&& pop == 1) && pop == 1)
{ {
characterScore += 8f; characterScore += w.scannerSingletonBonus;
} }
characterScore += Mathf.Min(8f, actor.renown / 120f); float renownDiv = w.scannerCharRenownDivisor > 0f ? w.scannerCharRenownDivisor : 120f;
characterScore += Mathf.Min(8f, kills * 0.15f); 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. // Cold kings barely score - do not let identity monopolize the camera.
if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor)) if (band == ActivityBand.Cold && !actor.has_attack_target && !IsSpectacle(actor))
{ {
actionScore *= 0.25f; actionScore *= w.scannerColdActionMul;
characterScore *= 0.35f; characterScore *= w.scannerColdCharMul;
} }
actionScore += (actor.getID() % 7) * 0.01f; actionScore += (actor.getID() % 7) * 0.01f;

View file

@ -1,7 +1,7 @@
{ {
"name": "IdleSpectator", "name": "IdleSpectator",
"author": "dazed", "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.", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
"GUID": "com.dazed.idlespectator" "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.