worldbox-observer-mod/IdleSpectator/InterestScoringConfig.cs

309 lines
9.2 KiB
C#

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 visualMultiplier = 12f;
public float noveltyMultiplier = 4f;
// Director policy (score-native; replaces InterestTier preemption).
public float cutInMargin = 35f;
public float rotateSlack = 12f;
public float fillScoreMax = 55f;
public float noticeScoreMin = 70f;
public float hotScoreMin = 110f;
public float dwellFill = 6f;
public float dwellNormal = 10f;
public float dwellHot = 14f;
public float fillRotateSeconds = 10f;
// MaxWatch caps by completion class (director clamps candidate.MaxWatch).
public float maxWatchMoment = 10f;
public float maxWatchStatus = 30f;
public float maxWatchGrief = 45f;
public float maxWatchCombat = 60f;
public float maxWatchEpic = 50f;
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;
}
}