neon-sprawl/docs/plans/NEO-82-implementation-plan.md

12 KiB
Raw Permalink Blame History

NEO-82 — Implementation plan

Story reference

Field Value
Key NEO-82
Title E5M1-07: Wire ability-cast into combat engine + cast response
Linear https://linear.app/neon-sprawl/issue/NEO-82/e5m1-07-wire-ability-cast-into-combat-engine-cast-response
Module E5.M1 — CombatRulesEngine · Epic 5 Slice 1 · backlog E5M1-07
Branch NEO-82-wire-ability-cast-combat-resolution
Precursor NEO-81CombatOperations.TryResolve + CombatResult (Done on main); E1.M4 cast funnel (NEO-28/31/32)
Pattern NEO-70 — promote engine envelope to wire JSON; extend existing POST rather than new route
Blocks NEO-44 gig XP on defeat, NEO-85 client HUD, E5M1-09 telemetry
Client counterpart NEO-85 — Godot parses extended cast response + HP HUD (out of scope here; no client-facing change in this story)

Kickoff clarifications

Topic Question Agent recommendation Answer
Resolution wire shape Nested block vs flat fields on AbilityCastResponse? Nested combatResolution object on accept only — E5M1 backlog “optional resolution block”; keeps E1.M4 accepted/reasonCode envelope stable. User: nested combatResolution.
Cooldown on target_defeated Commit catalog cooldown when combat denies re-hit on defeated dummy? No — backlog: per-ability cooldown on successful resolve only. User: no cooldown on defeated deny.
Manual QA doc Add docs/manual-qa/NEO-82.md? Skip — server-only; no Godot change; Bruno + README satisfy AC (NEO-70 pattern). User: skip (no client-facing change).

Goal, scope, and out-of-scope

Goal: After E1.M4 cast gates pass, invoke CombatOperations.TryResolve, return wire combatResolution on accept, map combat denies (especially target_defeated) to JSON cast denies, and replace the global prototype cooldown with per-ability cooldownSeconds from the ability catalog on successful resolve.

In scope (from Linear + E5M1-07):

  • Wire AbilityCastApi to CombatOperations.TryResolve + ICombatEntityHealthStore after existing NEO-28/32 gates.
  • Extend AbilityCastResponse v1 with optional nested combatResolution on accept.
  • Per-ability cooldown from AbilityDefRow.CooldownSeconds on successful combat resolve only.
  • JSON deny target_defeated when firing at a defeated dummy (combat engine handoff from NEO-81).
  • Integration tests (AAA) + Bruno: lock target → cast → damage payload; repeated casts until defeat → deny.
  • server/README.md cast + cooldown sections updated; cross-link E1.M4 / E5.M1 deny vocabulary.

Out of scope (from Linear + backlog):

  • Godot presentation — NEO-85.
  • Gig XP on defeat — NEO-44.
  • Telemetry hook emit — E5M1-09 (comment-only hooks may get placement notes; no E9.M1 ingest).
  • GET /game/world/combat-targetsE5M1-08.
  • Rich ThreatState, player damage, PvP eligibility.
  • docs/manual-qa/NEO-82.md (kickoff decision).

Acceptance criteria checklist

  • Accept path returns non-empty combatResolution payload with deterministic catalog damage.
  • Cooldown duration matches ability catalog (cooldownSeconds), not AbilityPrototypeCooldown.GlobalDuration.
  • Bruno documents lock → cast → damage → defeat deny spine.

Technical approach

  1. Handler flow (AbilityCastApi) — after existing loadout / target / range gates and on_cooldown pre-check:

    • Inject ICombatEntityHealthStore alongside IAbilityDefinitionRegistry.
    • Call CombatOperations.TryResolve(normalizedRequest, lookupKey, abilities, healthStore).
    • Combat deny (!result.Success): return accepted: false, reasonCode = result.ReasonCode (primarily target_defeated; defensive unknown_target / unknown_ability should not occur post-gates). Do not call StartCooldown (kickoff decision).
    • Combat success: load definition via TryGetDefinition, StartCooldown(id, slotIndex, now, TimeSpan.FromSeconds(definition.CooldownSeconds)), return accepted: true with combatResolution mapped from CombatResult.
  2. Wire DTOs (AbilityCastDtos.cs) — add CombatResolutionJson:

    • abilityId — normalized ability id (same as bound/request).
    • targetId — normalized target id (lowercase registry key).
    • damageDealt, targetRemainingHp, targetDefeated — promoted from CombatResult (non-null HP on success).
    • On AbilityCastResponse: optional combatResolutionJsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull); present only when accepted: true.
  3. Mapping helper — internal MapCombatResolution / MapAcceptResponse (mirror PlayerCraftApi.MapResponse) to keep route handler thin.

  4. Cooldown migration — remove AbilityPrototypeCooldown.GlobalDuration usage from accept path. Keep class with updated remark (“superseded by catalog per E5M1-07”) or delete constant if unused after test updates. Snapshot GET unchanged (still reads store ends); duration source is now catalog on commit.

  5. Deny vocabulary alignment — add target_defeated to README cast deny table; cross-link CombatReasonCodes. E1 gate denies (invalid_target, etc.) unchanged and evaluated before combat.

  6. Bruno (bruno/neon-sprawl-server/ability-cast/):

    • Update Post cast happy.bru — assert combatResolution.damageDealt === 25, targetRemainingHp === 75, targetDefeated === false for first pulse on alpha.
    • Add Post cast lock pulse defeat spine.bru — pre-request: move + target select alpha; seq of 4 pulse casts asserting HP stepping (75 → 50 → 25 → 0, targetDefeated true on 4th); 5th cast → accepted: false, reasonCode: target_defeated.
    • Update folder.bru docs with run order / NEO-82 spine note.
  7. server/README.md — rewrite Ability cast section: combat resolution block field table, target_defeated deny, per-ability cooldown from catalog; update Cooldown snapshot section to reference catalog durations.

  8. Module / backlog docs — during implementation reconcile E5M1-prototype-backlog.md E5M1-07 checkboxes and E5_M1_CombatRulesEngine.md prototype handoff paragraph (cast now returns real resolution).

Expected cast outcomes (prototype_pulse → alpha, fresh HP)

Cast # accepted reasonCode combatResolution.damageDealt targetRemainingHp targetDefeated Cooldown committed
1 true null 25 75 false 3.0s (pulse catalog)
23 true null 25 50 / 25 false yes
4 true null 25 0 true yes
5 false target_defeated (absent) no

Files to add

Path Purpose
bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru Bruno lock → cast → damage → defeat → deny spine.
docs/plans/NEO-82-implementation-plan.md This plan.

Files to modify

Path Rationale
server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs Invoke CombatOperations.TryResolve, map combat deny/success, per-ability cooldown commit.
server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs Add CombatResolutionJson; extend AbilityCastResponse.
server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs Mark global duration superseded; stop using on accept path.
server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs Assert resolution payload, defeat deny, per-ability cooldown (e.g. guard 6s vs pulse 3s).
server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs Advance fake clock by catalog duration instead of global constant where applicable.
bruno/neon-sprawl-server/ability-cast/Post cast happy.bru Assert combatResolution fields on accept.
bruno/neon-sprawl-server/ability-cast/folder.bru Document NEO-82 spine run order.
server/README.md Cast response combatResolution table, target_defeated, catalog cooldown policy.
docs/plans/E5M1-prototype-backlog.md Mark E5M1-07 landed when complete.
docs/decomposition/modules/E5_M1_CombatRulesEngine.md Update cast handoff — resolution now on wire.

Tests

File Coverage
server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs Happy path: accept + combatResolution (25 dmg, 75 HP). Defeat chain: four pulses → 4th targetDefeated true; 5th → target_defeated deny, no extra HP mutation. Zero-damage: bind prototype_guard, accept with damageDealt 0, HP unchanged. Per-ability cooldown: guard 6s — still on_cooldown at 3.5s after first accept; pulse 3s — accept again after 3.5s. Existing denies unchanged (invalid_target, on_cooldown, etc.). AAA per csharp-style.
server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs Snapshot end time reflects catalog cooldown after successful combat resolve (update clock advance constant).

Bruno spine covers manual verification; no docs/manual-qa/NEO-82.md (server-only, no Godot).

Open questions / risks

Question / risk Agent recommendation Status
Nested vs flat resolution fields Nested combatResolution (user kickoff). adopted
Cooldown on defeated deny No commit (user kickoff). adopted
Combat unknown_target after E1 gates Map to deny as-is if ever returned — defensive; normal path uses invalid_target at gate layer. adopted
Telemetry hook placement Defer catalog emit to E5M1-09; optional TODO(E9.M1) near combat success path only. deferred
Gig XP on 4th pulse defeat Out of scope — NEO-44 hooks defeat transition separately. adopted

Decisions (kickoff)

  • combatResolution nested object on accept only; omitted on all denies.
  • Per-ability cooldownSeconds commit only on successful CombatOperations resolve.
  • target_defeated JSON deny on defeated re-hit; no cooldown.
  • No manual QA doc; Bruno + README sufficient (no client-facing change — NEO-85 owns HUD).

Reconciliation (implementation)

  • CombatResolutionJson + extended AbilityCastResponse in AbilityCastDtos.cs.
  • AbilityCastApi invokes CombatOperations.TryResolve; per-ability catalog cooldown on success; target_defeated deny without cooldown.
  • 4 new AAA tests in AbilityCastApiTests (resolution, zero-damage, defeat chain, catalog cooldown); existing tests updated for resolution payload.
  • Bruno happy + defeat spine; server/README.md, E5M1 backlog, E5_M1 module doc updated.