using System.Collections.Concurrent; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Factions; /// Thread-safe in-memory faction standing; seeds the configured dev player (NEO-135). public sealed class InMemoryFactionStandingStore( IOptions options, IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore { private readonly HashSet knownPlayers = CreateKnownPlayers(options.Value); private readonly ConcurrentDictionary standingByKey = new(StringComparer.Ordinal); private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); private static HashSet CreateKnownPlayers(GamePositionOptions o) { var id = FactionStandingIds.NormalizePlayerId(o.DevPlayerId); if (id.Length == 0) { throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); } return new HashSet(StringComparer.OrdinalIgnoreCase) { id }; } /// public bool CanWritePlayer(string playerId) { var player = FactionStandingIds.NormalizePlayerId(playerId); return player.Length > 0 && knownPlayers.Contains(player); } /// public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId) { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); if (player.Length == 0) { return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); } if (faction.Length == 0) { return DenyRead(FactionStandingReasonCodes.UnknownFaction); } if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) { return DenyRead(FactionStandingReasonCodes.UnknownFaction); } if (!CanWritePlayer(player)) { return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); } var key = FactionStandingIds.MakeStandingKey(player, faction); var raw = standingByKey.GetValueOrDefault(key, 0); return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition)); } /// public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount) { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); if (player.Length == 0) { return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); } if (faction.Length == 0) { return DenyMutation(FactionStandingReasonCodes.UnknownFaction); } if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) { return DenyMutation(FactionStandingReasonCodes.UnknownFaction); } if (!CanWritePlayer(player)) { return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); } var key = FactionStandingIds.MakeStandingKey(player, faction); lock (keyLocks.GetOrAdd(key, _ => new object())) { var raw = standingByKey.GetValueOrDefault(key, 0); var previousStanding = FactionStandingIds.ClampStanding(raw, definition); var newStanding = FactionStandingIds.ClampStanding(raw + deltaAmount, definition); standingByKey[key] = newStanding; return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding); } } private static FactionStandingReadOutcome DenyRead(string reasonCode) => new(false, reasonCode, 0); private static FactionStandingMutationOutcome DenyMutation(string reasonCode) => new(false, reasonCode, 0, 0); /// Test-only seed of raw standing before read-clamp (NEO-135 unit tests). internal void SeedRawStandingForTests(string playerId, string factionId, int rawStanding) { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); if (player.Length == 0 || faction.Length == 0 || !CanWritePlayer(player)) { return; } var key = FactionStandingIds.MakeStandingKey(player, faction); lock (keyLocks.GetOrAdd(key, _ => new object())) { standingByKey[key] = rawStanding; } } }