From cb1692ce8568e8f38fca2b0541ebca607607a9f7 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Thu, 28 May 2026 23:42:18 -0400 Subject: [PATCH] NEO-95: add player combat HP store and NPC attack resolve Wire telegraph-complete damage into AdvanceAll and expose GET /game/players/{id}/combat-health for session player HP read model. --- .../Game/Combat/CombatTargetFixtureApi.cs | 16 +++- .../Game/Combat/IPlayerCombatHealthStore.cs | 25 +++++ .../Combat/InMemoryPlayerCombatHealthStore.cs | 91 +++++++++++++++++++ .../Game/Combat/NpcAttackOperations.cs | 44 +++++++++ .../Game/Combat/PlayerCombatHealthApi.cs | 38 ++++++++ .../Game/Combat/PlayerCombatHealthDefaults.cs | 8 ++ .../Game/Combat/PlayerCombatHealthDtos.cs | 24 +++++ ...CombatHealthServiceCollectionExtensions.cs | 12 +++ .../Game/Combat/PlayerCombatHealthSnapshot.cs | 12 +++ .../Game/Npc/NpcRuntimeOperations.cs | 35 +++++-- .../Game/Npc/NpcRuntimeSnapshotWorldApi.cs | 12 ++- server/NeonSprawl.Server/Program.cs | 2 + 12 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 2d27d96..7befa5b 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -1,4 +1,6 @@ +using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Combat; @@ -9,7 +11,13 @@ public static class CombatTargetFixtureApi { app.MapPost( "/game/__dev/combat-targets-fixture", - (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) => + ( + CombatTargetFixtureRequest? body, + ICombatEntityHealthStore healthStore, + IThreatStateStore threatStore, + INpcRuntimeStateStore runtimeStore, + IPlayerCombatHealthStore playerHealthStore, + IOptions gameOptions) => { if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) { @@ -27,6 +35,12 @@ public static class CombatTargetFixtureApi AggroOperations.ClearAllPrototypeHolders(threatStore); NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore); + var devPlayerId = gameOptions.Value.DevPlayerId; + if (!playerHealthStore.TryResetToFull(devPlayerId, out _)) + { + return Results.NotFound(); + } + return Results.Json( new CombatTargetFixtureResponse { diff --git a/server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs new file mode 100644 index 0000000..a7da503 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs @@ -0,0 +1,25 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Session in-memory player combat HP for incoming NPC damage (NEO-95). +/// No Postgres persistence in Epic 5 Slice 2. +/// +public interface IPlayerCombatHealthStore +{ + /// + /// Reads the current snapshot for a player. Lazy-initializes HP to + /// on first access. + /// + bool TryGet(string? playerId, out PlayerCombatHealthSnapshot snapshot); + + /// + /// Applies non-negative NPC attack damage. Lazy-initializes on first access. + /// Floors at zero. + /// + bool TryApplyDamage(string? playerId, int damage, out PlayerCombatHealthSnapshot snapshot); + + /// + /// Restores a player to full HP. Creates the row when absent (dev fixture + tests). + /// + bool TryResetToFull(string? playerId, out PlayerCombatHealthSnapshot snapshot); +} diff --git a/server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs new file mode 100644 index 0000000..fe5f0db --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Combat; + +/// Thread-safe in-memory session player combat HP; empty at startup — lazy rows only (NEO-95). +public sealed class InMemoryPlayerCombatHealthStore : IPlayerCombatHealthStore +{ + private readonly ConcurrentDictionary currentHpById = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public bool TryGet(string? playerId, out PlayerCombatHealthSnapshot snapshot) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0) + { + snapshot = default; + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + EnsureRowInitialized(key); + snapshot = CreateSnapshot(key, currentHpById[key]); + return true; + } + } + + /// + public bool TryApplyDamage(string? playerId, int damage, out PlayerCombatHealthSnapshot snapshot) + { + snapshot = default; + if (damage < 0) + { + return false; + } + + var key = NormalizePlayerId(playerId); + if (key.Length == 0) + { + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + EnsureRowInitialized(key); + var current = currentHpById[key]; + var next = Math.Max(0, current - damage); + currentHpById[key] = next; + snapshot = CreateSnapshot(key, next); + return true; + } + } + + /// + public bool TryResetToFull(string? playerId, out PlayerCombatHealthSnapshot snapshot) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0) + { + snapshot = default; + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + currentHpById[key] = PlayerCombatHealthDefaults.MaxHp; + snapshot = CreateSnapshot(key, PlayerCombatHealthDefaults.MaxHp); + return true; + } + } + + private void EnsureRowInitialized(string normalizedId) + { + if (!currentHpById.ContainsKey(normalizedId)) + { + currentHpById[normalizedId] = PlayerCombatHealthDefaults.MaxHp; + } + } + + private static PlayerCombatHealthSnapshot CreateSnapshot(string playerId, int currentHp) => + new( + playerId, + PlayerCombatHealthDefaults.MaxHp, + currentHp, + currentHp <= 0); + + private static string NormalizePlayerId(string? playerId) => + playerId?.Trim().ToLowerInvariant() ?? string.Empty; +} diff --git a/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs new file mode 100644 index 0000000..c83cba8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs @@ -0,0 +1,44 @@ +using NeonSprawl.Server.Game.Npc; + +namespace NeonSprawl.Server.Game.Combat; + +/// Deterministic NPC telegraph-complete damage resolve (NEO-95). +public static class NpcAttackOperations +{ + /// + /// Applies catalog to when + /// the threat row still names that holder for . + /// + public static bool TryResolveTelegraphComplete( + string npcInstanceId, + string holderPlayerId, + int attackDamage, + IPlayerCombatHealthStore playerHealthStore, + IThreatStateStore threatStore) + { + if (attackDamage <= 0) + { + return true; + } + + if (string.IsNullOrWhiteSpace(holderPlayerId) || + !threatStore.TryGet(npcInstanceId, out var threat) || + string.IsNullOrEmpty(threat.AggroHolderPlayerId) || + !string.Equals(threat.AggroHolderPlayerId, holderPlayerId, StringComparison.Ordinal)) + { + return false; + } + + if (!playerHealthStore.TryApplyDamage(holderPlayerId, attackDamage, out var snapshot)) + { + return false; + } + + if (snapshot.Defeated) + { + // TODO(E9.M1): player_death + } + + return true; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs new file mode 100644 index 0000000..0aa7117 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs @@ -0,0 +1,38 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Combat; + +/// Maps GET /game/players/{id}/combat-health (NEO-95). +public static class PlayerCombatHealthApi +{ + public static WebApplication MapPlayerCombatHealthApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/combat-health", + (string id, IPositionStateStore positions, IPlayerCombatHealthStore playerHealthStore) => + { + if (!positions.TryGetPosition(id, out _)) + { + return Results.NotFound(); + } + + if (!playerHealthStore.TryGet(id, out var snapshot)) + { + return Results.NotFound(); + } + + return Results.Json(BuildResponse(snapshot)); + }); + + return app; + } + + internal static PlayerCombatHealthResponse BuildResponse(in PlayerCombatHealthSnapshot snapshot) => + new() + { + PlayerId = snapshot.PlayerId, + MaxHp = snapshot.MaxHp, + CurrentHp = snapshot.CurrentHp, + Defeated = snapshot.Defeated, + }; +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs new file mode 100644 index 0000000..abcdd7f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Prototype session player combat HP defaults (E5M2-09 / NEO-95). +public static class PlayerCombatHealthDefaults +{ + /// Lazy-init max HP for session player combat rows. + public const int MaxHp = 100; +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs new file mode 100644 index 0000000..95cc967 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Combat; + +/// JSON body for GET /game/players/{id}/combat-health (NEO-95). +public sealed class PlayerCombatHealthResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + [JsonPropertyName("maxHp")] + public required int MaxHp { get; init; } + + [JsonPropertyName("currentHp")] + public required int CurrentHp { get; init; } + + [JsonPropertyName("defeated")] + public required bool Defeated { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs new file mode 100644 index 0000000..1aa5ce3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Registers session player combat health store (NEO-95). +public static class PlayerCombatHealthServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddPlayerCombatHealthStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs new file mode 100644 index 0000000..5b32379 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Authoritative HP snapshot for a session player (NEO-95). +/// Normalized lowercase player id. +/// Prototype session max HP. +/// Current HP after NPC damage; floored at zero. +/// true when is zero. +public readonly record struct PlayerCombatHealthSnapshot( + string PlayerId, + int MaxHp, + int CurrentHp, + bool Defeated); diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index ceaad3f..4c7292e 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Combat; + namespace NeonSprawl.Server.Game.Npc; /// Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93). @@ -14,6 +16,7 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = DefaultMaxDeltaSeconds) { if (maxDeltaSeconds <= 0) @@ -32,7 +35,8 @@ public static class NpcRuntimeOperations nowUtc, runtimeStore, threatStore, - behaviorRegistry); + behaviorRegistry, + playerHealthStore); } runtimeStore.LastAdvancedUtc = nowUtc; @@ -50,7 +54,8 @@ public static class NpcRuntimeOperations nowUtc, runtimeStore, threatStore, - behaviorRegistry); + behaviorRegistry, + playerHealthStore); } return; @@ -68,7 +73,8 @@ public static class NpcRuntimeOperations windowEnd, runtimeStore, threatStore, - behaviorRegistry); + behaviorRegistry, + playerHealthStore); } runtimeStore.LastAdvancedUtc = windowEnd; @@ -84,7 +90,8 @@ public static class NpcRuntimeOperations DateTimeOffset windowEnd, INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, - INpcBehaviorDefinitionRegistry behaviorRegistry) + INpcBehaviorDefinitionRegistry behaviorRegistry, + IPlayerCombatHealthStore playerHealthStore) { if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || !behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior)) @@ -102,7 +109,7 @@ public static class NpcRuntimeOperations return; } - if (!TryHasAggroHolder(npcInstanceId, threatStore, out _)) + if (!TryHasAggroHolder(npcInstanceId, threatStore, out var aggroHolderPlayerId)) { if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null) { @@ -130,7 +137,7 @@ public static class NpcRuntimeOperations var cursor = windowStart; while (cursor < windowEnd) { - if (!TryHasAggroHolder(npcInstanceId, threatStore, out _)) + if (!TryHasAggroHolder(npcInstanceId, threatStore, out aggroHolderPlayerId)) { WriteIdle(runtimeStore, npcInstanceId); return; @@ -176,24 +183,36 @@ public static class NpcRuntimeOperations } cursor = phaseEnd; + NpcAttackOperations.TryResolveTelegraphComplete( + npcInstanceId, + aggroHolderPlayerId, + behavior.AttackDamage, + playerHealthStore, + threatStore); WriteState( runtimeStore, npcInstanceId, NpcBehaviorState.Recover, cursor, activeTelegraph: null); - // telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage) + // telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover break; } case NpcBehaviorState.AttackExecute: + NpcAttackOperations.TryResolveTelegraphComplete( + npcInstanceId, + aggroHolderPlayerId, + behavior.AttackDamage, + playerHealthStore, + threatStore); WriteState( runtimeStore, npcInstanceId, NpcBehaviorState.Recover, cursor, activeTelegraph: null); - // telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here + // telemetry: npc_state_transition (NEO-96) break; case NpcBehaviorState.Recover: diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs index 7ef419c..c565a94 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Combat; + namespace NeonSprawl.Server.Game.Npc; /// Maps GET /game/world/npc-runtime-snapshot (NEO-94). @@ -11,10 +13,16 @@ public static class NpcRuntimeSnapshotWorldApi TimeProvider clock, INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, - INpcBehaviorDefinitionRegistry behaviorRegistry) => + INpcBehaviorDefinitionRegistry behaviorRegistry, + IPlayerCombatHealthStore playerHealthStore) => { var now = clock.GetUtcNow(); - NpcRuntimeOperations.AdvanceAll(now, runtimeStore, threatStore, behaviorRegistry); + NpcRuntimeOperations.AdvanceAll( + now, + runtimeStore, + threatStore, + behaviorRegistry, + playerHealthStore); return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry)); }); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index f1b86ba..cf5d5d7 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -27,6 +27,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddThreatStateStore(); builder.Services.AddNpcRuntimeStateStore(); +builder.Services.AddPlayerCombatHealthStore(); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); @@ -59,6 +60,7 @@ app.MapAbilityDefinitionsWorldApi(); app.MapNpcBehaviorDefinitionsWorldApi(); app.MapCombatTargetsWorldApi(); app.MapNpcRuntimeSnapshotWorldApi(); +app.MapPlayerCombatHealthApi(); app.MapResourceNodeDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapPlayerCraftApi();