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.
pull/133/head
VinPropane 2026-05-28 23:42:18 -04:00
parent ac4e1dff10
commit cb1692ce85
12 changed files with 308 additions and 11 deletions

View File

@ -1,4 +1,6 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Combat; namespace NeonSprawl.Server.Game.Combat;
@ -9,7 +11,13 @@ public static class CombatTargetFixtureApi
{ {
app.MapPost( app.MapPost(
"/game/__dev/combat-targets-fixture", "/game/__dev/combat-targets-fixture",
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) => (
CombatTargetFixtureRequest? body,
ICombatEntityHealthStore healthStore,
IThreatStateStore threatStore,
INpcRuntimeStateStore runtimeStore,
IPlayerCombatHealthStore playerHealthStore,
IOptions<GamePositionOptions> gameOptions) =>
{ {
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
{ {
@ -27,6 +35,12 @@ public static class CombatTargetFixtureApi
AggroOperations.ClearAllPrototypeHolders(threatStore); AggroOperations.ClearAllPrototypeHolders(threatStore);
NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore); NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore);
var devPlayerId = gameOptions.Value.DevPlayerId;
if (!playerHealthStore.TryResetToFull(devPlayerId, out _))
{
return Results.NotFound();
}
return Results.Json( return Results.Json(
new CombatTargetFixtureResponse new CombatTargetFixtureResponse
{ {

View File

@ -0,0 +1,25 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Session in-memory player combat HP for incoming NPC damage (NEO-95).
/// No Postgres persistence in Epic 5 Slice 2.
/// </summary>
public interface IPlayerCombatHealthStore
{
/// <summary>
/// Reads the current snapshot for a player. Lazy-initializes HP to
/// <see cref="PlayerCombatHealthDefaults.MaxHp"/> on first access.
/// </summary>
bool TryGet(string? playerId, out PlayerCombatHealthSnapshot snapshot);
/// <summary>
/// Applies non-negative NPC attack damage. Lazy-initializes on first access.
/// Floors <see cref="PlayerCombatHealthSnapshot.CurrentHp"/> at zero.
/// </summary>
bool TryApplyDamage(string? playerId, int damage, out PlayerCombatHealthSnapshot snapshot);
/// <summary>
/// Restores a player to full HP. Creates the row when absent (dev fixture + tests).
/// </summary>
bool TryResetToFull(string? playerId, out PlayerCombatHealthSnapshot snapshot);
}

View File

@ -0,0 +1,91 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Thread-safe in-memory session player combat HP; empty at startup — lazy rows only (NEO-95).</summary>
public sealed class InMemoryPlayerCombatHealthStore : IPlayerCombatHealthStore
{
private readonly ConcurrentDictionary<string, int> currentHpById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
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;
}

View File

@ -0,0 +1,44 @@
using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95).</summary>
public static class NpcAttackOperations
{
/// <summary>
/// Applies catalog <paramref name="attackDamage"/> to <paramref name="holderPlayerId"/> when
/// the threat row still names that holder for <paramref name="npcInstanceId"/>.
/// </summary>
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;
}
}

View File

@ -0,0 +1,38 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Maps <c>GET /game/players/{id}/combat-health</c> (NEO-95).</summary>
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,
};
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Prototype session player combat HP defaults (E5M2-09 / NEO-95).</summary>
public static class PlayerCombatHealthDefaults
{
/// <summary>Lazy-init max HP for session player combat rows.</summary>
public const int MaxHp = 100;
}

View File

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>JSON body for <c>GET /game/players/{id}/combat-health</c> (NEO-95).</summary>
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; }
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Registers session player combat health store (NEO-95).</summary>
public static class PlayerCombatHealthServiceCollectionExtensions
{
/// <summary>Registers <see cref="IPlayerCombatHealthStore"/> as an in-memory singleton.</summary>
public static IServiceCollection AddPlayerCombatHealthStore(this IServiceCollection services)
{
services.AddSingleton<IPlayerCombatHealthStore, InMemoryPlayerCombatHealthStore>();
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Authoritative HP snapshot for a session player (NEO-95).</summary>
/// <param name="PlayerId">Normalized lowercase player id.</param>
/// <param name="MaxHp">Prototype session max HP.</param>
/// <param name="CurrentHp">Current HP after NPC damage; floored at zero.</param>
/// <param name="Defeated"><c>true</c> when <paramref name="CurrentHp"/> is zero.</param>
public readonly record struct PlayerCombatHealthSnapshot(
string PlayerId,
int MaxHp,
int CurrentHp,
bool Defeated);

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Npc;
/// <summary>Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).</summary> /// <summary>Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).</summary>
@ -14,6 +16,7 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = DefaultMaxDeltaSeconds) double maxDeltaSeconds = DefaultMaxDeltaSeconds)
{ {
if (maxDeltaSeconds <= 0) if (maxDeltaSeconds <= 0)
@ -32,7 +35,8 @@ public static class NpcRuntimeOperations
nowUtc, nowUtc,
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry); behaviorRegistry,
playerHealthStore);
} }
runtimeStore.LastAdvancedUtc = nowUtc; runtimeStore.LastAdvancedUtc = nowUtc;
@ -50,7 +54,8 @@ public static class NpcRuntimeOperations
nowUtc, nowUtc,
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry); behaviorRegistry,
playerHealthStore);
} }
return; return;
@ -68,7 +73,8 @@ public static class NpcRuntimeOperations
windowEnd, windowEnd,
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry); behaviorRegistry,
playerHealthStore);
} }
runtimeStore.LastAdvancedUtc = windowEnd; runtimeStore.LastAdvancedUtc = windowEnd;
@ -84,7 +90,8 @@ public static class NpcRuntimeOperations
DateTimeOffset windowEnd, DateTimeOffset windowEnd,
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry) INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore)
{ {
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior)) !behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
@ -102,7 +109,7 @@ public static class NpcRuntimeOperations
return; return;
} }
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _)) if (!TryHasAggroHolder(npcInstanceId, threatStore, out var aggroHolderPlayerId))
{ {
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null) if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
{ {
@ -130,7 +137,7 @@ public static class NpcRuntimeOperations
var cursor = windowStart; var cursor = windowStart;
while (cursor < windowEnd) while (cursor < windowEnd)
{ {
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _)) if (!TryHasAggroHolder(npcInstanceId, threatStore, out aggroHolderPlayerId))
{ {
WriteIdle(runtimeStore, npcInstanceId); WriteIdle(runtimeStore, npcInstanceId);
return; return;
@ -176,24 +183,36 @@ public static class NpcRuntimeOperations
} }
cursor = phaseEnd; cursor = phaseEnd;
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
playerHealthStore,
threatStore);
WriteState( WriteState(
runtimeStore, runtimeStore,
npcInstanceId, npcInstanceId,
NpcBehaviorState.Recover, NpcBehaviorState.Recover,
cursor, cursor,
activeTelegraph: null); 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; break;
} }
case NpcBehaviorState.AttackExecute: case NpcBehaviorState.AttackExecute:
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
playerHealthStore,
threatStore);
WriteState( WriteState(
runtimeStore, runtimeStore,
npcInstanceId, npcInstanceId,
NpcBehaviorState.Recover, NpcBehaviorState.Recover,
cursor, cursor,
activeTelegraph: null); activeTelegraph: null);
// telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here // telemetry: npc_state_transition (NEO-96)
break; break;
case NpcBehaviorState.Recover: case NpcBehaviorState.Recover:

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Npc;
/// <summary>Maps <c>GET /game/world/npc-runtime-snapshot</c> (NEO-94).</summary> /// <summary>Maps <c>GET /game/world/npc-runtime-snapshot</c> (NEO-94).</summary>
@ -11,10 +13,16 @@ public static class NpcRuntimeSnapshotWorldApi
TimeProvider clock, TimeProvider clock,
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry) => INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore) =>
{ {
var now = clock.GetUtcNow(); 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)); return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
}); });

View File

@ -27,6 +27,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddThreatStateStore(); builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore();
builder.Services.AddMasteryCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build(); var app = builder.Build();
@ -59,6 +60,7 @@ app.MapAbilityDefinitionsWorldApi();
app.MapNpcBehaviorDefinitionsWorldApi(); app.MapNpcBehaviorDefinitionsWorldApi();
app.MapCombatTargetsWorldApi(); app.MapCombatTargetsWorldApi();
app.MapNpcRuntimeSnapshotWorldApi(); app.MapNpcRuntimeSnapshotWorldApi();
app.MapPlayerCombatHealthApi();
app.MapResourceNodeDefinitionsWorldApi(); app.MapResourceNodeDefinitionsWorldApi();
app.MapPlayerInventoryApi(); app.MapPlayerInventoryApi();
app.MapPlayerCraftApi(); app.MapPlayerCraftApi();