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");
|
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":
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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>
|
/// </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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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