neon-sprawl/server/NeonSprawl.Server/Game/Combat/CombatOperations.cs

85 lines
3.8 KiB
C#

namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Orchestrates deterministic combat resolution: ability catalog damage + prototype target HP (NEO-81).
/// Cast HTTP wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-82).
/// NEO-84 telemetry hook sites: <see cref="TryResolve"/> (<c>ability_used</c>, <c>enemy_defeat</c>).
/// </summary>
public static class CombatOperations
{
// --- Reserved telemetry hook sites (NEO-84): no emit until future modules ---
// encounter_start — E5.M3 anchor: EncounterProgressOperations.TryActivateOnFirstEngagement (NEO-109).
// player_death — future player HP / death resolution (player HP out of scope in Slice 1).
/// <summary>
/// Resolves one ability against one prototype combat target using catalog damage and the health store.
/// Defeated targets deny before damage application; zero-damage abilities succeed without mutating HP.
/// </summary>
public static CombatResult TryResolve(
string abilityId,
string targetId,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore healthStore)
{
if (!abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility) ||
!abilityRegistry.TryGetDefinition(normalizedAbility, out var definition))
{
return Deny(CombatReasonCodes.UnknownAbility);
}
if (!healthStore.TryGet(targetId, out var snapshot))
{
return Deny(CombatReasonCodes.UnknownTarget);
}
if (snapshot.Defeated)
{
return Deny(CombatReasonCodes.TargetDefeated, snapshot.CurrentHp);
}
if (definition.BaseDamage == 0)
{
// --- Telemetry hook site (NEO-84): future E9.M1 catalog event `ability_used` ---
// TODO(E9.M1): catalog emit — on every successful resolve (including zero-damage abilities).
// Planned payload fields: abilityId, targetId, damageDealt (0), targetRemainingHp, targetDefeated.
// playerId is not in engine scope; correlate route id at AbilityCastApi accept (NEO-30 funnel).
return new CombatResult(
Success: true,
ReasonCode: null,
DamageDealt: 0,
TargetRemainingHp: snapshot.CurrentHp,
TargetDefeated: snapshot.Defeated);
}
if (!healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after))
{
return Deny(CombatReasonCodes.UnknownTarget);
}
if (after.Defeated)
{
// --- Telemetry hook site (NEO-84): future E9.M1 catalog event `enemy_defeat` ---
// TODO(E9.M1): catalog emit — once per target defeat transition (lethal hit only; re-hit denied earlier).
// Planned payload fields: targetId, abilityId (killing blow), damageDealt, final HP 0; correlate playerId at cast layer.
}
// --- Telemetry hook site (NEO-84): future E9.M1 catalog event `ability_used` ---
// TODO(E9.M1): catalog emit — on every successful resolve after damage application.
// Planned payload fields: abilityId, targetId, damageDealt, targetRemainingHp, targetDefeated.
// playerId is not in engine scope; correlate route id at AbilityCastApi accept (NEO-30 funnel).
return new CombatResult(
Success: true,
ReasonCode: null,
DamageDealt: definition.BaseDamage,
TargetRemainingHp: after.CurrentHp,
TargetDefeated: after.Defeated);
}
private static CombatResult Deny(string reasonCode, int? targetRemainingHp = null) =>
new(
Success: false,
ReasonCode: reasonCode,
DamageDealt: 0,
TargetRemainingHp: targetRemainingHp,
TargetDefeated: false);
}