using System.Collections.Concurrent; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Skills; /// Thread-safe in-memory progression; seeds the configured dev player with an empty XP map (NEO-38). public sealed class InMemoryPlayerSkillProgressionStore(IOptions options) : IPlayerSkillProgressionStore { private readonly ConcurrentDictionary> byPlayer = CreateInitialMap(options.Value); /// Locks per normalized player id for coherent read/compare/write on inner dictionaries. private readonly ConcurrentDictionary playerLocks = new(StringComparer.OrdinalIgnoreCase); private static ConcurrentDictionary> CreateInitialMap(GamePositionOptions o) { var id = NormalizePlayerId(o.DevPlayerId); var map = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); map[id] = new ConcurrentDictionary(StringComparer.Ordinal); return map; } /// public IReadOnlyDictionary GetXpTotals(string playerId) { var key = NormalizePlayerId(playerId); if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) { return ReadOnlyDic.Empty; } lock (playerLocks.GetOrAdd(key, _ => new object())) { return new Dictionary(inner, StringComparer.Ordinal); } } /// public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp) { previousXp = 0; newXp = 0; var key = NormalizePlayerId(playerId); if (key.Length == 0 || skillId.Trim().Length == 0 || !byPlayer.TryGetValue(key, out var inner)) { return false; } var sid = skillId.Trim(); lock (playerLocks.GetOrAdd(key, _ => new object())) { inner.TryGetValue(sid, out var prev); previousXp = prev; newXp = prev + amount; if (newXp < 0) { return false; } inner[sid] = newXp; return true; } } /// public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) { if (xp < 0) { return false; } var key = NormalizePlayerId(playerId); var sid = skillId.Trim(); if (key.Length == 0 || sid.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) { return false; } lock (playerLocks.GetOrAdd(key, _ => new object())) { if (xp == 0) { inner.TryRemove(sid, out _); return true; } inner[sid] = xp; return true; } } /// public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary skillXpTotals) { var key = NormalizePlayerId(playerId); if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) { return false; } foreach (var (skillId, xp) in skillXpTotals) { if (skillId.Trim().Length == 0 || xp < 0) { return false; } } lock (playerLocks.GetOrAdd(key, _ => new object())) { foreach (var (skillId, xp) in skillXpTotals) { var sid = skillId.Trim(); if (xp == 0) { inner.TryRemove(sid, out _); } else { inner[sid] = xp; } } return true; } } private static string NormalizePlayerId(string? playerId) { var t = playerId?.Trim(); if (string.IsNullOrEmpty(t)) { return string.Empty; } return t.ToLowerInvariant(); } private static class ReadOnlyDic { internal static readonly IReadOnlyDictionary Empty = new Dictionary(StringComparer.Ordinal); } }