NEO-81 — Implementation plan
Story reference
Kickoff clarifications
| Topic |
Question |
Agent recommendation |
Answer |
| Defeated-target deny scope |
Deny all abilities on defeated target, including zero-damage utility? |
Deny all — E5M1-07 uses target_defeated on cast at defeated dummy regardless of ability kind. |
User: deny all. |
| Unknown id validation |
Validate ability + target inside TryResolve vs assume E1.M4 pre-validated? |
Validate inside — backlog requires stable deny for unknown ids; mirrors GatherOperations / CraftOperations self-contained validation. |
User: validate inside. |
| Zero-damage success path |
TryGet only vs TryApplyDamage(target, 0)? |
Success with damageDealt = 0; TryGet snapshot only — no HP mutation when catalog damage is 0; still lazy-inits via TryGet. |
User: success, TryGet only. |
Goal, scope, and out-of-scope
Goal: Provide pure server combat resolution: known ability + known prototype target → deterministic catalog damage, remaining HP, and defeated flag. Return structured denies (no silent no-op) when the target is already defeated or ids are unknown.
In scope (from Linear + E5M1-06):
CombatOperations.TryResolve using IAbilityDefinitionRegistry + ICombatEntityHealthStore.
CombatResult readonly record struct with DamageDealt, TargetRemainingHp, TargetDefeated, Success, ReasonCode.
CombatReasonCodes stable deny strings aligned with E1.M4 / E5M1-07 vocabulary.
- Unit tests (AAA): happy path (
prototype_pulse → 25 dmg), zero-damage utility success, defeated-target deny (including guard/dash), unknown ability/target denies.
server/README.md combat engine section.
Out of scope (from Linear + backlog):
- HTTP wiring /
AbilityCastResponse extension (NEO-82).
- Per-ability cooldown commit (NEO-82).
- Gig XP on defeat (NEO-44).
- Telemetry hooks (reserved for follow-up like NEO-64 / NEO-71 pattern).
- Godot / client changes.
- Bruno (no HTTP surface in this story).
Acceptance criteria checklist
Technical approach
-
CombatReasonCodes — static constants in Game/Combat/:
TargetDefeated = "target_defeated" — target HP is already 0 (checked before damage application; no store mutation).
UnknownAbility = "unknown_ability" — ability id fails IAbilityDefinitionRegistry.TryNormalizeKnown (matches cast/hotbar vocabulary).
UnknownTarget = "unknown_target" — target id fails ICombatEntityHealthStore.TryGet (store gates PrototypeTargetRegistry ids; matches E1.M3 targeting vocabulary).
-
CombatResult — readonly record struct (mirror CraftResult):
Success — true when damage resolution committed (including zero-damage success).
ReasonCode — null on success; stable string on deny.
DamageDealt — catalog AbilityDefRow.BaseDamage on success; 0 on deny.
TargetRemainingHp — authoritative HP after resolve on success; null on deny (or current HP on target_defeated deny — see step 4).
TargetDefeated — true when post-resolve HP is 0 on success; false on deny (even when target was already defeated — caller uses ReasonCode).
-
CombatOperations — static class (mirror GatherOperations):
public static CombatResult TryResolve(
string abilityId,
string targetId,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore healthStore)
- Ability gate:
abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility) → deny unknown_ability when false.
- Definition load:
abilityRegistry.TryGetDefinition(normalizedAbility, out var definition) — should succeed after normalize; treat miss as unknown_ability (defensive).
- Target read:
healthStore.TryGet(targetId, out var snapshot) → deny unknown_target when false (lazy-inits live targets).
- Defeated pre-check: when
snapshot.Defeated → deny target_defeated without calling TryApplyDamage (NEO-80 handoff; applies to all abilities including zero-damage).
- Zero-damage path: when
definition.BaseDamage == 0 → success with DamageDealt = 0, TargetRemainingHp = snapshot.CurrentHp, TargetDefeated = snapshot.Defeated (always false for live target after pre-check).
- Damage path:
healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after) → success with DamageDealt = definition.BaseDamage, TargetRemainingHp = after.CurrentHp, TargetDefeated = after.Defeated.
- Private
Deny(string reasonCode) helper returns Success = false, empty damage fields.
-
target_defeated deny payload: On defeated pre-check, set TargetRemainingHp = 0 (from snapshot) so callers/tests can observe current HP without re-querying store; DamageDealt = 0, TargetDefeated = false on envelope (deny is ReasonCode-driven — same pattern as gather depletion deny carrying RemainingGathers).
-
No DI registration — static ops class; tests construct InMemoryCombatEntityHealthStore + in-memory registry helper (same as AbilityDefinitionRegistryTests). Host already registers both dependencies via AddAbilityDefinitionCatalog.
-
server/README.md — new Combat engine (NEO-81) section after combat entity health: document CombatOperations.TryResolve, CombatResult fields, reason-code table, and cross-link NEO-82 cast wiring.
Expected prototype resolve values
| Ability |
Target |
Initial HP |
Result |
prototype_pulse |
prototype_target_alpha |
100 (lazy) |
Success: damageDealt 25, targetRemainingHp 75, targetDefeated false |
prototype_pulse × 4 |
same |
— |
4th success: targetRemainingHp 0, targetDefeated true |
prototype_pulse |
same (defeated) |
0 |
Deny: target_defeated, damageDealt 0 |
prototype_guard |
live alpha |
100 |
Success: damageDealt 0, HP unchanged |
prototype_guard |
defeated alpha |
0 |
Deny: target_defeated |
unknown_ability |
alpha |
— |
Deny: unknown_ability |
prototype_pulse |
not_a_target |
— |
Deny: unknown_target |
Files to add
| Path |
Purpose |
server/NeonSprawl.Server/Game/Combat/CombatReasonCodes.cs |
Stable deny reason strings for combat ops. |
server/NeonSprawl.Server/Game/Combat/CombatResult.cs |
Server-internal combat resolution envelope. |
server/NeonSprawl.Server/Game/Combat/CombatOperations.cs |
Static TryResolve orchestrating registry + health store. |
server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs |
AAA unit tests for resolve happy/deny paths. |
docs/plans/NEO-81-implementation-plan.md |
This plan. |
Files to modify
| Path |
Rationale |
server/README.md |
Document CombatOperations.TryResolve, CombatResult, reason codes, and NEO-82 handoff. |
Tests
| File |
Coverage |
server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs |
Happy path: prototype_pulse on fresh alpha → 25 dmg, 75 HP. Deterministic chain: four pulses → defeated on 4th. Defeated deny: 5th pulse → target_defeated, no HP change. Zero-damage: prototype_guard on live target → success, 0 dmg, HP unchanged. Defeated + utility: prototype_guard on defeated → target_defeated. Unknown ability: deny unknown_ability. Unknown target: deny unknown_target. Burst: prototype_burst → 40 dmg once. Use in-memory registry helper + InMemoryCombatEntityHealthStore; optional host test resolving registries from InMemoryWebApplicationFactory. AAA per csharp-style. |
No Bruno or manual QA for this story (server-only internal ops; no HTTP or client-facing change).
Open questions / risks
| Question / risk |
Agent recommendation |
Status |
| Wire naming: CombatResult vs CombatResolution |
CombatResult server-internal; NEO-82 promotes to wire CombatResolution / cast response block (CraftResult → CraftResponse precedent). |
adopted |
| Defeated deny on zero-damage |
Deny all abilities on defeated target (user kickoff). |
adopted |
| Telemetry hook site |
Defer comment-only hooks to NEO-82 or dedicated telemetry story when cast path wires in. |
deferred |
| Player-as-target / PvP |
Out of scope — prototype registry has NPC stubs only; PvP gates are E6.M1. |
adopted |
Decisions (kickoff)
- Defeated pre-check before damage; no
TryApplyDamage on defeated targets.
- Zero-damage abilities succeed via
TryGet only (no store mutation).
- Unknown ability/target validated inside
TryResolve with stable reason codes.
- No HTTP in NEO-81; cast wiring is NEO-82.
Reconciliation (implementation)
CombatReasonCodes, CombatResult, CombatOperations.TryResolve added under Game/Combat/.
- Defeated pre-check denies before damage; zero-damage path uses
TryGet only.
- 9 AAA tests in
CombatOperationsTests (all green), including host DI resolve.
server/README.md — combat engine section with reason-code table and NEO-82 handoff.