Scorign
This commit is contained in:
parent
77cb688925
commit
69f305e369
11 changed files with 581 additions and 57 deletions
|
|
@ -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":
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
292
IdleSpectator/InterestScoringConfig.cs
Normal file
292
IdleSpectator/InterestScoringConfig.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
169
IdleSpectator/scoring-model.json
Normal file
169
IdleSpectator/scoring-model.json
Normal 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
1
docs/scoring-model.json
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../IdleSpectator/scoring-model.json
|
||||
9
docs/scoring-model.md
Normal file
9
docs/scoring-model.md
Normal 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.
|
||||
Loading…
Reference in a new issue