311 lines
9.4 KiB
C#
311 lines
9.4 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 = 8f;
|
|
public float dwellNormal = 15f;
|
|
public float dwellHot = 15f;
|
|
public float fillRotateSeconds = 8f;
|
|
/// <summary>Minimum seconds on an EventLed shot before peer rotate / soft cut (interrupts bypass). Ambient exempt.</summary>
|
|
public float minCameraDwell = 15f;
|
|
|
|
// MaxWatch caps by completion class (director clamps candidate.MaxWatch).
|
|
public float maxWatchMoment = 15f;
|
|
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;
|
|
}
|
|
}
|