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
parent
ac4e1dff10
commit
cb1692ce85
|
|
@ -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<GamePositionOptions> 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).</summary>
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>Maps <c>GET /game/world/npc-runtime-snapshot</c> (NEO-94).</summary>
|
||||
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue