namespace Cthangover.Mods.Stats { using System; using System.Collections.Generic; using System.Linq; using Cthangover.Core.Characters; using Cthangover.Core.Events; using Cthangover.Core.Games; using Cthangover.Core.Mods.Resolvers; using Cthangover.Core.Scenes; using Cthangover.Core.Settings; /// /// The stats of every character in one place: computes and caches sheets, keeps what /// characters gained during play () and tells everyone when /// stats change. Everything else — scenario actions, conditions, checks, the sheet /// panel, equipment, battle formulas — goes through this facade, so there is one /// answer to "how strong is the hero now" in a novel, a tile RPG or any other game. /// /// Who. Every call takes a "who": a character id, or player. The player /// is the game's setting stats.player, else the first member of the battle set, /// else the free id player — a protagonist with a sheet but without a party /// card, which is what a visual novel needs. /// /// Where numbers live. Party members keep level and exp in the core roster /// (battles award exp there); others keep them in the stats state. Bound stats /// () are written into the roster's battle attributes by /// , so every battle core sees them without knowing this mod. /// public static class StatsService { /// The free id of a protagonist without a party card. public const string FreePlayerId = "player"; private static readonly Dictionary Sheets = new(StringComparer.OrdinalIgnoreCase); private static object snapshot; private static bool notifying; /// Stat definitions by id, sorted for display (order, then id). public static IReadOnlyList Definitions => Entities.Collect().Values.Where(s => !string.IsNullOrWhiteSpace(s.ID)) .OrderBy(s => s.Order).ThenBy(s => s.ID, StringComparer.Ordinal).ToList(); /// The definition of , or null. public static StatInfo Definition(string stat) => Find(Entities.Collect(), stat); /// The archetype , or null. public static ArchetypeInfo Archetype(string id) => string.IsNullOrWhiteSpace(id) ? null : Find(Entities.Collect(), id); /// The character template , or null. public static CharacterInfo Template(string id) => string.IsNullOrWhiteSpace(id) ? null : Find(Entities.Collect(), id); /// /// Resolves a "who": player (or empty) → the player id; anything else is a /// character id taken as written. /// public static string Resolve(string who) { if (!string.IsNullOrWhiteSpace(who) && !string.Equals(who.Trim(), FreePlayerId, StringComparison.OrdinalIgnoreCase)) return who.Trim(); var setting = ActiveGame.GetSetting("stats.player"); if (!string.IsNullOrWhiteSpace(setting)) return setting.Trim(); var roster = Roster; var first = roster?.Characters.Keys.FirstOrDefault(id => roster.BattleSet.Contains(id)); return first ?? FreePlayerId; } /// /// The characters a scenario argument names: party → every roster member /// (the player alone when there is no roster), anything else → . /// public static List Targets(string who) { if (string.Equals(who?.Trim(), "party", StringComparison.OrdinalIgnoreCase)) { var members = Roster?.Characters.Keys.ToList() ?? new List(); return members.Count > 0 ? members : new List { Resolve(null) }; } return new List { Resolve(who) }; } /// The party roster of the running game, or null outside a game. public static CharacterData Roster => GameData.Instance?.Runtime?.CharacterData; /// The roster entry of , or null for a character outside the party. public static CharacterInfoData RosterEntry(string characterId) { var roster = Roster; if (roster == null || characterId == null) return null; return roster.Characters.TryGetValue(characterId, out var entry) ? entry : null; } /// The archetype id in effect: chosen during play, else the template's "Archetype". public static string ArchetypeOf(string who) { var id = Resolve(who); var chosen = StatState.Instance.Find(id)?.Archetype; return !string.IsNullOrWhiteSpace(chosen) ? chosen : ExtraData.Text(Template(id)?.ExtraProperties, "Archetype"); } /// Level: the roster's for party members, else the stats state's, else the template's, at least 1. public static int LevelOf(string who) { var id = Resolve(who); var entry = RosterEntry(id); if (entry != null) return Math.Max(1, entry.Level); var state = StatState.Instance.Find(id); if (state != null && state.Level > 0) return state.Level; return Math.Max(1, Template(id)?.Level ?? 1); } /// Exp: the roster's for party members, else the stats state's. public static int ExpOf(string who) { var id = Resolve(who); var entry = RosterEntry(id); return entry?.Exp ?? StatState.Instance.Find(id)?.Exp ?? 0; } /// /// Whether takes part in the stats: it has an archetype, its /// template carries Stats or Traits, it gained bonuses during play, or a /// trait source gives it something (worn gear). Only such characters get their battle /// attributes from the sheet, so loading a stats pack next to a game whose characters /// never heard of stats changes none of their numbers. /// public static bool Participates(string who) { var id = Resolve(who); if (!string.IsNullOrWhiteSpace(ArchetypeOf(id))) return true; var extra = Template(id)?.ExtraProperties; if (ExtraData.Find(extra, "Stats") != null || ExtraData.Find(extra, "Traits") != null) return true; if (StatState.Instance.Find(id)?.Bonus.Count > 0) return true; return TraitSources.Collect(id).Count > 0; } /// The current sheet of (cached until something changes). public static StatSheet Sheet(string who) { var id = Resolve(who); DropCacheOnNewData(); if (Sheets.TryGetValue(id, out var cached)) return cached; var sheet = StatCalculator.Calculate(id, Inputs(id)); Sheets[id] = sheet; return sheet; } /// The inputs the sheet of is computed from (tools show them). public static StatInputs Inputs(string characterId) { var template = Template(characterId); var archetype = Archetype(ArchetypeOf(characterId)); var traits = new List(); if (archetype?.Traits != null) traits.AddRange(archetype.Traits.Where(t => t != null)); traits.AddRange(ExtraData.Traits(template?.ExtraProperties, characterId)); traits.AddRange(TraitSources.Collect(characterId)); return new StatInputs { Level = LevelOf(characterId), Definitions = Entities.Collect(), Own = ExtraData.Stats(template?.ExtraProperties, characterId), Archetype = archetype, Bonus = StatState.Instance.Find(characterId)?.Bonus ?? new Dictionary(), Traits = traits, }; } /// A permanent change of a stat (a strength potion, a story choice); negative values lower it. public static void AddBonus(string who, string stat, double value) { var id = Resolve(who); if (string.IsNullOrWhiteSpace(stat) || value == 0) return; var bonus = StatState.Instance.Of(id).Bonus; var key = stat.Trim().ToLowerInvariant(); bonus[key] = (bonus.TryGetValue(key, out var old) ? old : 0) + value; Changed(id); } /// Changes the archetype of (null or empty returns to the template's). public static void SetArchetype(string who, string archetype) { var id = Resolve(who); StatState.Instance.Of(id).Archetype = string.IsNullOrWhiteSpace(archetype) ? null : archetype.Trim(); Changed(id); } /// /// Tells everyone that stats of (null: of anyone) may /// have changed: drops cached sheets, writes bound stats into battle attributes, /// publishes and asks conditions to re-evaluate. /// Re-entrant calls (a listener changing stats again) only drop the cache. /// public static void Changed(string characterId) { if (characterId == null) Sheets.Clear(); else Sheets.Remove(characterId); if (notifying) return; notifying = true; try { StatSync.SyncRoster(); ModEventBus.Publish(new StatsChangedEvent { CharacterId = characterId }); ConditionStateMonitor.MarkStateChanged(); } finally { notifying = false; } } /// Drops the cached sheet of without notifying anyone. public static void Forget(string characterId) { if (characterId != null) Sheets.Remove(characterId); } /// Drops every cached sheet (a new game, a load, a mod reload). public static void ClearCache() { Sheets.Clear(); snapshot = null; } private static void DropCacheOnNewData() { // The entity resolver hands out the same dictionary until data changes (a reload, // an editor save), so its identity tells when every sheet is stale. var current = Entities.Collect(); if (ReferenceEquals(current, snapshot)) return; snapshot = current; Sheets.Clear(); } private static T Find(Dictionary all, string id) where T : class { if (all == null || id == null) return null; if (all.TryGetValue(id, out var hit)) return hit; foreach (var pair in all) { if (string.Equals(pair.Key, id, StringComparison.OrdinalIgnoreCase)) return pair.Value; } return null; } } }