diff --git a/content/abilities/prototype_abilities.json b/content/abilities/prototype_abilities.json index 95aac81..3ad79d1 100644 --- a/content/abilities/prototype_abilities.json +++ b/content/abilities/prototype_abilities.json @@ -5,6 +5,7 @@ "id": "prototype_pulse", "displayName": "Prototype Pulse", "baseDamage": 25, + "maxRange": 6.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, @@ -12,6 +13,7 @@ "id": "prototype_guard", "displayName": "Prototype Guard", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 6.0, "abilityKind": "utility" }, @@ -19,6 +21,7 @@ "id": "prototype_dash", "displayName": "Prototype Dash", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 4.0, "abilityKind": "movement" }, @@ -26,6 +29,7 @@ "id": "prototype_burst", "displayName": "Prototype Burst", "baseDamage": 40, + "maxRange": 6.0, "cooldownSeconds": 5.0, "abilityKind": "attack" } diff --git a/content/abilities/prototype_npc_abilities.json b/content/abilities/prototype_npc_abilities.json new file mode 100644 index 0000000..787c6fc --- /dev/null +++ b/content/abilities/prototype_npc_abilities.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_npc_melee_strike", + "displayName": "NPC Melee Strike", + "baseDamage": 15, + "maxRange": 6.0, + "cooldownSeconds": 3.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_ranged_shot", + "displayName": "NPC Ranged Shot", + "baseDamage": 12, + "maxRange": 10.0, + "cooldownSeconds": 4.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_elite_slam", + "displayName": "NPC Elite Slam", + "baseDamage": 25, + "maxRange": 8.0, + "cooldownSeconds": 5.0, + "abilityKind": "attack" + } + ] +} diff --git a/content/npc-behaviors/prototype_npc_behaviors.json b/content/npc-behaviors/prototype_npc_behaviors.json index aa2b1df..4afd2b1 100644 --- a/content/npc-behaviors/prototype_npc_behaviors.json +++ b/content/npc-behaviors/prototype_npc_behaviors.json @@ -10,7 +10,8 @@ "leashRadius": 16.0, "telegraphWindupSeconds": 1.5, "attackDamage": 15, - "attackCooldownSeconds": 3.0 + "attackCooldownSeconds": 3.0, + "attackAbilityId": "prototype_npc_melee_strike" }, { "id": "prototype_ranged_control", @@ -21,7 +22,8 @@ "leashRadius": 20.0, "telegraphWindupSeconds": 2.0, "attackDamage": 12, - "attackCooldownSeconds": 4.0 + "attackCooldownSeconds": 4.0, + "attackAbilityId": "prototype_npc_ranged_shot" }, { "id": "prototype_elite_mini_boss", @@ -32,7 +34,8 @@ "leashRadius": 18.0, "telegraphWindupSeconds": 2.5, "attackDamage": 25, - "attackCooldownSeconds": 5.0 + "attackCooldownSeconds": 5.0, + "attackAbilityId": "prototype_npc_elite_slam" } ] } diff --git a/content/schemas/ability-def.schema.json b/content/schemas/ability-def.schema.json index 41b77b2..f404ca7 100644 --- a/content/schemas/ability-def.schema.json +++ b/content/schemas/ability-def.schema.json @@ -5,7 +5,7 @@ "description": "Single combat ability row for catalogs (e.g. content/abilities/*_abilities.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M1_CombatRulesEngine.md.", "type": "object", "additionalProperties": false, - "required": ["id", "displayName", "baseDamage", "cooldownSeconds"], + "required": ["id", "displayName", "baseDamage", "maxRange", "cooldownSeconds"], "properties": { "id": { "type": "string", @@ -22,6 +22,11 @@ "minimum": 0, "description": "Deterministic damage dealt on successful combat resolve in prototype Slice 1 (no miss/crit RNG)." }, + "maxRange": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Horizontal maximum reach for this ability at combat resolve (XZ-only at runtime)." + }, "cooldownSeconds": { "type": "number", "exclusiveMinimum": 0, diff --git a/content/schemas/npc-behavior-def.schema.json b/content/schemas/npc-behavior-def.schema.json index f03d55a..53015cf 100644 --- a/content/schemas/npc-behavior-def.schema.json +++ b/content/schemas/npc-behavior-def.schema.json @@ -14,7 +14,8 @@ "leashRadius", "telegraphWindupSeconds", "attackDamage", - "attackCooldownSeconds" + "attackCooldownSeconds", + "attackAbilityId" ], "properties": { "id": { @@ -40,7 +41,7 @@ "aggroRadius": { "type": "number", "exclusiveMinimum": 0, - "description": "Horizontal proximity radius for re-aggro after leash clear (XZ-only at runtime)." + "description": "Horizontal proximity radius for future re-aggro (XZ-only at runtime; must be less than leashRadius). Distinct from ability maxRange." }, "leashRadius": { "type": "number", @@ -61,6 +62,11 @@ "type": "number", "exclusiveMinimum": 0, "description": "Seconds after last attack before the NPC may enter telegraph again." + }, + "attackAbilityId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Immutable ability id used at telegraph resolve for maxRange and baseDamage (must exist in ability catalog)." } } } diff --git a/scripts/validate_content.py b/scripts/validate_content.py index b56254b..edeb641 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -105,6 +105,17 @@ PROTOTYPE_E5M1_ABILITY_IDS = frozenset( } ) +# Epic 5 Slice 2 NPC attack abilities (NEO-98): exact ids after schema passes. +PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS = frozenset( + { + "prototype_npc_melee_strike", + "prototype_npc_ranged_shot", + "prototype_npc_elite_slam", + } +) + +PROTOTYPE_ABILITY_CATALOG_IDS = PROTOTYPE_E5M1_ABILITY_IDS | PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS + # Epic 5 Slice 2 prototype lock (NEO-87): exact npc behavior ids after schema passes. # Keep in sync with E5.M2 freeze table and future NEO-88 server loader. PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset( @@ -428,10 +439,11 @@ def _validate_ability_catalogs( *, ability_files: list[Path], ability_validator: Draft202012Validator, -) -> tuple[int, dict[str, str]]: - """Validate ability JSON files. Returns (error_count, seen_ids).""" +) -> tuple[int, dict[str, str], dict[str, dict]]: + """Validate ability JSON files. Returns (error_count, seen_ids, id_to_row).""" errors = 0 seen_ids: dict[str, str] = {} + id_to_row: dict[str, dict] = {} for path in ability_files: rel = str(path.relative_to(REPO_ROOT)) @@ -467,19 +479,25 @@ def _validate_ability_catalogs( errors += 1 else: seen_ids[aid] = rel + id_to_row[aid] = row - return errors, seen_ids + return errors, seen_ids, id_to_row + + +def _prototype_ability_catalog_gate(seen_ids: dict[str, str]) -> str | None: + """Return a human-readable error if prototype ability catalog contract fails, else None.""" + ids = frozenset(seen_ids.keys()) + if ids != PROTOTYPE_ABILITY_CATALOG_IDS: + return ( + "error: prototype ability catalog expects exactly ability ids " + f"{sorted(PROTOTYPE_ABILITY_CATALOG_IDS)!r}, got {sorted(ids)!r}" + ) + return None def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None: - """Return a human-readable error if E5M1 ability contract fails, else None.""" - ids = frozenset(seen_ids.keys()) - if ids != PROTOTYPE_E5M1_ABILITY_IDS: - return ( - "error: prototype E5M1 expects exactly ability ids " - f"{sorted(PROTOTYPE_E5M1_ABILITY_IDS)!r}, got {sorted(ids)!r}" - ) - return None + """Backward-compatible alias for the combined prototype ability catalog gate.""" + return _prototype_ability_catalog_gate(seen_ids) def _validate_npc_behavior_catalogs( @@ -555,6 +573,30 @@ def _prototype_e5m2_npc_behavior_numeric_gate(id_to_row: dict[str, dict]) -> str return None +def _prototype_e5m2_npc_behavior_attack_ability_gate( + id_to_row: dict[str, dict], + ability_id_to_row: dict[str, dict], +) -> str | None: + """Return a human-readable error when attackAbilityId is missing or attackDamage mismatches.""" + for bid, row in id_to_row.items(): + aid = row.get("attackAbilityId") + if not isinstance(aid, str): + continue + ability = ability_id_to_row.get(aid) + if ability is None: + return ( + f"error: npc behavior {bid!r}: attackAbilityId {aid!r} missing from ability catalog" + ) + attack_damage = row.get("attackDamage") + base_damage = ability.get("baseDamage") + if isinstance(attack_damage, int) and isinstance(base_damage, int) and attack_damage != base_damage: + return ( + f"error: npc behavior {bid!r}: attackDamage {attack_damage} must match " + f"ability {aid!r} baseDamage {base_damage}" + ) + return None + + def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None: """Return a human-readable error if Slice 4 contract fails, else None.""" if len(track_skill_ids) != 1: @@ -1161,7 +1203,7 @@ def main() -> int: print(slice3_recipe_err, file=sys.stderr) return 1 - ability_errors, ability_seen_ids = _validate_ability_catalogs( + ability_errors, ability_seen_ids, ability_id_to_row = _validate_ability_catalogs( ability_files=ability_files, ability_validator=ability_validator, ) @@ -1169,9 +1211,9 @@ def main() -> int: print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr) return 1 - e5m1_ability_err = _prototype_e5m1_ability_gate(ability_seen_ids) - if e5m1_ability_err: - print(e5m1_ability_err, file=sys.stderr) + ability_catalog_err = _prototype_ability_catalog_gate(ability_seen_ids) + if ability_catalog_err: + print(ability_catalog_err, file=sys.stderr) return 1 npc_behavior_errors, npc_behavior_seen_ids, npc_behavior_id_to_row = _validate_npc_behavior_catalogs( @@ -1192,6 +1234,14 @@ def main() -> int: print(e5m2_npc_behavior_numeric_err, file=sys.stderr) return 1 + e5m2_attack_ability_err = _prototype_e5m2_npc_behavior_attack_ability_gate( + npc_behavior_id_to_row, + ability_id_to_row, + ) + if e5m2_attack_ability_err: + print(e5m2_attack_ability_err, file=sys.stderr) + return 1 + print( "content OK: " f"{len(skill_files)} skill catalog file(s), " diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs index 74041ee..b7964eb 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs @@ -21,6 +21,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_pulse", "displayName": "Prototype Pulse", "baseDamage": 25, + "maxRange": 6.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, @@ -28,6 +29,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_guard", "displayName": "Prototype Guard", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 6.0, "abilityKind": "utility" }, @@ -35,6 +37,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_dash", "displayName": "Prototype Dash", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 4.0, "abilityKind": "movement" }, @@ -42,6 +45,40 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_burst", "displayName": "Prototype Burst", "baseDamage": 40, + "maxRange": 6.0, + "cooldownSeconds": 5.0, + "abilityKind": "attack" + } + ] + } + """; + + private const string ValidNpcAttackAbilitiesJson = + """ + { + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_npc_melee_strike", + "displayName": "NPC Melee Strike", + "baseDamage": 15, + "maxRange": 6.0, + "cooldownSeconds": 3.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_ranged_shot", + "displayName": "NPC Ranged Shot", + "baseDamage": 12, + "maxRange": 10.0, + "cooldownSeconds": 4.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_elite_slam", + "displayName": "NPC Elite Slam", + "baseDamage": 25, + "maxRange": 8.0, "cooldownSeconds": 5.0, "abilityKind": "attack" } @@ -64,6 +101,15 @@ public class AbilityDefinitionCatalogLoaderTests private static void WriteCatalog(string abilitiesDir, string catalogJson) => File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8); + private static void WriteFullPrototypeCatalog(string abilitiesDir) + { + WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + File.WriteAllText( + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + ValidNpcAttackAbilitiesJson, + Encoding.UTF8); + } + private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId) { var abilities = catalogRoot["abilities"] as JsonArray @@ -85,12 +131,12 @@ public class AbilityDefinitionCatalogLoaderTests { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); - WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + WriteFullPrototypeCatalog(abilitiesDir); // Act var catalog = LoadCatalog(abilitiesDir, schemaPath); // Assert - Assert.Equal(4, catalog.DistinctAbilityCount); - Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.Equal(7, catalog.DistinctAbilityCount); + Assert.Equal(2, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse)); Assert.NotNull(pulse); Assert.Equal(25, pulse!.BaseDamage); @@ -136,7 +182,7 @@ public class AbilityDefinitionCatalogLoaderTests { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); - WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + WriteFullPrototypeCatalog(abilitiesDir); File.WriteAllText( Path.Combine(abilitiesDir, "extra_abilities.json"), """ @@ -147,6 +193,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_pulse", "displayName": "Duplicate Pulse", "baseDamage": 1, + "maxRange": 6.0, "cooldownSeconds": 1.0 } ] @@ -171,11 +218,15 @@ public class AbilityDefinitionCatalogLoaderTests ?? throw new InvalidOperationException("expected abilities array"); abilities.RemoveAt(abilities.Count - 1); WriteCatalog(abilitiesDir, root.ToJsonString()); + File.WriteAllText( + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + ValidNpcAttackAbilitiesJson, + Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); - Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal); } [Fact] @@ -187,11 +238,15 @@ public class AbilityDefinitionCatalogLoaderTests ?? throw new InvalidOperationException("expected object root"); GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra"; WriteCatalog(abilitiesDir, root.ToJsonString()); + File.WriteAllText( + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + ValidNpcAttackAbilitiesJson, + Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); - Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal); } @@ -316,7 +371,7 @@ public class AbilityDefinitionCatalogLoaderTests // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var catalog = factory.Services.GetRequiredService(); - Assert.Equal(4, catalog.DistinctAbilityCount); + Assert.Equal(7, catalog.DistinctAbilityCount); Assert.True(catalog.TryGetAbility("prototype_burst", out var burst)); Assert.Equal(40, burst!.BaseDamage); Assert.Equal(5.0, burst.CooldownSeconds); diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs index 40a3a60..dcb6123 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs @@ -27,6 +27,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -52,6 +53,7 @@ public class AbilityDefinitionRegistryTests "Prototype Burst", 40, 5.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -75,6 +77,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -96,6 +99,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -117,6 +121,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -138,6 +143,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -159,6 +165,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -180,6 +187,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -201,24 +209,28 @@ public class AbilityDefinitionRegistryTests "Prototype Burst", 40, 5.0, + 6.0, "attack"), [PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeDash, "Prototype Dash", 0, 4.0, + 6.0, "movement"), [PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeGuard, "Prototype Guard", 0, 6.0, + 6.0, "utility"), [PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypePulse, "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -249,6 +261,10 @@ public class AbilityDefinitionRegistryTests Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"), Path.Combine(abilitiesDir, "prototype_abilities.json"), overwrite: true); + File.Copy( + Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_npc_abilities.json"), + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + overwrite: true); var loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance); var registry = new AbilityDefinitionRegistry(loaded); // Act @@ -274,7 +290,7 @@ public class AbilityDefinitionRegistryTests Assert.NotNull(burst); Assert.Equal(40, burst!.BaseDamage); Assert.Equal(5.0, burst.CooldownSeconds); - Assert.Equal(4, list.Count); + Assert.Equal(7, list.Count); Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id); Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id); } @@ -313,7 +329,7 @@ public class AbilityDefinitionRegistryTests Assert.Null(missing); Assert.True(normalized); Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId); - Assert.Equal(4, list.Count); + Assert.Equal(7, list.Count); Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs index 6c27309..238accd 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs @@ -8,17 +8,20 @@ namespace NeonSprawl.Server.Tests.Game.Combat; public class AbilityDefinitionsWorldApiTests { - /// Frozen prototype four in registry id order (ordinal). Keep in sync with Bruno. - public static readonly string[] FrozenFourInIdOrder = + /// Frozen prototype seven in registry id order (ordinal). Keep in sync with Bruno. + public static readonly string[] FrozenSevenInIdOrder = [ "prototype_burst", "prototype_dash", "prototype_guard", + "prototype_npc_elite_slam", + "prototype_npc_melee_strike", + "prototype_npc_ranged_shot", "prototype_pulse", ]; [Fact] - public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenFourInIdOrder() + public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenSevenInIdOrder() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); @@ -31,30 +34,38 @@ public class AbilityDefinitionsWorldApiTests Assert.NotNull(body); Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.NotNull(body.Abilities); - Assert.Equal(4, body.Abilities.Count); + Assert.Equal(7, body.Abilities.Count); var ids = body.Abilities.Select(static a => a.Id).ToList(); - Assert.Equal(FrozenFourInIdOrder, ids); + Assert.Equal(FrozenSevenInIdOrder, ids); var pulse = body.Abilities.Single(a => a.Id == "prototype_pulse"); Assert.Equal("Prototype Pulse", pulse.DisplayName); Assert.Equal(25, pulse.BaseDamage); Assert.Equal(3.0, pulse.CooldownSeconds); + Assert.Equal(6.0, pulse.MaxRange); Assert.Equal("attack", pulse.AbilityKind); var burst = body.Abilities.Single(a => a.Id == "prototype_burst"); Assert.Equal("Prototype Burst", burst.DisplayName); Assert.Equal(40, burst.BaseDamage); Assert.Equal(5.0, burst.CooldownSeconds); + Assert.Equal(6.0, burst.MaxRange); Assert.Equal("attack", burst.AbilityKind); var guard = body.Abilities.Single(a => a.Id == "prototype_guard"); Assert.Equal("utility", guard.AbilityKind); Assert.Equal(0, guard.BaseDamage); Assert.Equal(6.0, guard.CooldownSeconds); + Assert.Equal(6.0, guard.MaxRange); var dash = body.Abilities.Single(a => a.Id == "prototype_dash"); Assert.Equal("movement", dash.AbilityKind); Assert.Equal(0, dash.BaseDamage); Assert.Equal(4.0, dash.CooldownSeconds); + Assert.Equal(6.0, dash.MaxRange); + + var meleeStrike = body.Abilities.Single(a => a.Id == "prototype_npc_melee_strike"); + Assert.Equal(15, meleeStrike.BaseDamage); + Assert.Equal(6.0, meleeStrike.MaxRange); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs index 3be602d..081318c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs @@ -19,24 +19,28 @@ public sealed class CombatOperationsTests "Prototype Burst", 40, 5.0, + 6.0, "attack"), [PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeDash, "Prototype Dash", 0, 4.0, + 6.0, "movement"), [PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeGuard, "Prototype Guard", 0, 6.0, + 6.0, "utility"), [PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypePulse, "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1); diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs index f8c2084..a6c934f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs @@ -1,23 +1,38 @@ +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests.Game.Npc; using Xunit; namespace NeonSprawl.Server.Tests.Game.Combat; public sealed class NpcAttackOperationsTests { + private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) => + new(Options.Create(new GamePositionOptions + { + DevPlayerId = "dev-local-1", + DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z }, + })); + [Fact] - public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches() + public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatchesAndInRange() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 15, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert @@ -26,19 +41,71 @@ public sealed class NpcAttackOperationsTests Assert.Equal(85, snapshot.CurrentHp); } + [Fact] + public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderOutOfAbilityMaxRange() + { + // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, + playerHealthStore, + threatStore); + // Assert + Assert.False(ok); + playerHealthStore.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + + [Fact] + public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderBeyondAbilityMaxRangeButInsideAggroRadius() + { + // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-10.0, 0.5, -3.0); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, + playerHealthStore, + threatStore); + // Assert + Assert.False(ok); + playerHealthStore.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + [Fact] public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); _ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 15, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert @@ -51,14 +118,18 @@ public sealed class NpcAttackOperationsTests public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player"); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 15, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert @@ -68,17 +139,21 @@ public sealed class NpcAttackOperationsTests } [Fact] - public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero() + public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAbilityBaseDamageZero() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 0, + PrototypeAbilityRegistry.PrototypeGuard, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs index b595075..5790a6e 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs @@ -26,7 +26,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 16.0, "telegraphWindupSeconds": 1.5, "attackDamage": 15, - "attackCooldownSeconds": 3.0 + "attackCooldownSeconds": 3.0, + "attackAbilityId": "prototype_npc_melee_strike" }, { "id": "prototype_ranged_control", @@ -37,7 +38,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 20.0, "telegraphWindupSeconds": 2.0, "attackDamage": 12, - "attackCooldownSeconds": 4.0 + "attackCooldownSeconds": 4.0, + "attackAbilityId": "prototype_npc_ranged_shot" }, { "id": "prototype_elite_mini_boss", @@ -48,7 +50,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 18.0, "telegraphWindupSeconds": 2.5, "attackDamage": 25, - "attackCooldownSeconds": 5.0 + "attackCooldownSeconds": 5.0, + "attackAbilityId": "prototype_npc_elite_slam" } ] } @@ -159,7 +162,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 2.0, "telegraphWindupSeconds": 1.0, "attackDamage": 1, - "attackCooldownSeconds": 1.0 + "attackCooldownSeconds": 1.0, + "attackAbilityId": "prototype_npc_melee_strike" } ] } diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs index 692a5be..e0c50ac 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs @@ -1,6 +1,7 @@ using System.IO; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Tests; using Xunit; @@ -25,7 +26,8 @@ public class NpcBehaviorDefinitionRegistryTests 16.0, 1.5, 15, - 3.0); + 3.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike); private static NpcBehaviorDefRow RangedRow() => new( @@ -37,7 +39,8 @@ public class NpcBehaviorDefinitionRegistryTests 20.0, 2.0, 12, - 4.0); + 4.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot); private static NpcBehaviorDefRow EliteRow() => new( @@ -49,7 +52,8 @@ public class NpcBehaviorDefinitionRegistryTests 18.0, 2.5, 25, - 5.0); + 5.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam); [Fact] public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists() diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 1d8101b..df437ac 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; using Xunit; namespace NeonSprawl.Server.Tests.Game.Npc; @@ -9,14 +11,23 @@ public sealed class NpcRuntimeOperationsTests private static readonly DateTimeOffset T0 = new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); - private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, InMemoryCombatEntityHealthStore CombatHealth, IPlayerCombatHealthStore PlayerHealth) - CreateFixture() + private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) => + new(Options.Create(new GamePositionOptions + { + DevPlayerId = "dev-local-1", + DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z }, + })); + + private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IAbilityDefinitionRegistry Abilities, InMemoryCombatEntityHealthStore CombatHealth, InMemoryPositionStateStore Positions, IPlayerCombatHealthStore PlayerHealth) + CreateFixture(double playerX = -3.0, double playerY = 0.5, double playerZ = -3.0) { return ( new InMemoryNpcRuntimeStateStore(), new InMemoryThreatStateStore(), PrototypeNpcTestFixtures.CreateBehaviorRegistry(), + PrototypeNpcTestFixtures.CreateAbilityRegistry(), PrototypeNpcTestFixtures.CreateHealthStore(), + CreatePositionStoreAt(playerX, playerY, playerZ), new InMemoryPlayerCombatHealthStore()); } @@ -28,7 +39,9 @@ public sealed class NpcRuntimeOperationsTests INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, InMemoryCombatEntityHealthStore combatHealthStore, + InMemoryPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) => NpcRuntimeOperations.AdvanceAll( @@ -36,7 +49,9 @@ public sealed class NpcRuntimeOperationsTests runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore, maxDeltaSeconds); @@ -44,9 +59,9 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); // Act - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -57,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -72,10 +87,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); runtime.LastAdvancedUtc = T0; // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -86,11 +101,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -102,11 +117,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(2.9), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(2.9), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -116,12 +131,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -131,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -149,13 +164,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -167,11 +182,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 10); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 10); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -183,13 +198,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddSeconds(3.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -204,11 +219,11 @@ public sealed class NpcRuntimeOperationsTests double attackCooldownSeconds) { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -224,12 +239,12 @@ public sealed class NpcRuntimeOperationsTests double telegraphWindupSeconds) { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -242,11 +257,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -258,12 +273,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(100), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(100), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Act - Advance(T0.AddSeconds(100.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(100.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); @@ -275,14 +290,14 @@ public sealed class NpcRuntimeOperationsTests public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); runtime.LastAdvancedUtc = T0.AddHours(1); NpcRuntimeOperations.ResetAllPrototypeRows(runtime); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -294,11 +309,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); runtime.LastAdvancedUtc = T0.AddSeconds(10); // Act - Advance(T0.AddSeconds(5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); } @@ -307,13 +322,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(85, snapshot.CurrentHp); @@ -323,13 +338,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); @@ -350,7 +365,8 @@ public sealed class NpcRuntimeOperationsTests 16.0, 1.5, 15, - 0.0); + 0.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike); var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 }; var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 }; // Act @@ -367,10 +383,10 @@ public sealed class NpcRuntimeOperationsTests public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); // Act NpcRuntimeOperations.TryStopOnTargetDefeat( @@ -390,10 +406,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); NpcRuntimeOperations.TryStopOnTargetDefeat( PrototypeNpcRegistry.PrototypeNpcMeleeId, @@ -401,7 +417,37 @@ public sealed class NpcRuntimeOperationsTests threat, runtime); // Act - Advance(T0.AddSeconds(30), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(30), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Assert + playerHealth.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + + [Fact] + public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderBeyondAbilityMaxRangeButInsideAggroRadius() + { + // Arrange + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(-10.0, 0.5, -3.0); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Act + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Assert + playerHealth.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + + [Fact] + public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderOutOfAttackRangeAtWindupComplete() + { + // Arrange + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(10.0, 0.5, 10.0); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Act + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs index 98caf87..da4642b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; @@ -6,6 +7,65 @@ namespace NeonSprawl.Server.Tests.Game.Npc; /// Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91). public static class PrototypeNpcTestFixtures { + public static IAbilityDefinitionRegistry CreateAbilityRegistry() + { + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeAbilityRegistry.PrototypeBurst] = new( + PrototypeAbilityRegistry.PrototypeBurst, + "Prototype Burst", + 40, + 5.0, + 6.0, + "attack"), + [PrototypeAbilityRegistry.PrototypeDash] = new( + PrototypeAbilityRegistry.PrototypeDash, + "Prototype Dash", + 0, + 4.0, + 6.0, + "movement"), + [PrototypeAbilityRegistry.PrototypeGuard] = new( + PrototypeAbilityRegistry.PrototypeGuard, + "Prototype Guard", + 0, + 6.0, + 6.0, + "utility"), + [PrototypeAbilityRegistry.PrototypePulse] = new( + PrototypeAbilityRegistry.PrototypePulse, + "Prototype Pulse", + 25, + 3.0, + 6.0, + "attack"), + [PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike] = new( + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + "NPC Melee Strike", + 15, + 3.0, + 6.0, + "attack"), + [PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot] = new( + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot, + "NPC Ranged Shot", + 12, + 4.0, + 10.0, + "attack"), + [PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam] = new( + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam, + "NPC Elite Slam", + 25, + 5.0, + 8.0, + "attack"), + }; + + var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 2); + return new AbilityDefinitionRegistry(catalog); + } + public static INpcBehaviorDefinitionRegistry CreateBehaviorRegistry() { var byId = new Dictionary(StringComparer.Ordinal) @@ -19,7 +79,8 @@ public static class PrototypeNpcTestFixtures 16.0, 1.5, 15, - 3.0), + 3.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike), [PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new( PrototypeNpcBehaviorRegistry.PrototypeRangedControl, "Ranged Control", @@ -29,7 +90,8 @@ public static class PrototypeNpcTestFixtures 20.0, 2.0, 12, - 4.0), + 4.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot), [PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new( PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, "Elite Mini-Boss", @@ -39,7 +101,8 @@ public static class PrototypeNpcTestFixtures 18.0, 2.5, 25, - 5.0), + 5.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam), }; var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1); diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs index 34ff4cb..b34df19 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs @@ -6,4 +6,5 @@ public sealed record AbilityDefRow( string DisplayName, int BaseDamage, double CooldownSeconds, + double MaxRange, string? AbilityKind); diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs index 5eb5e83..255d8a2 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs @@ -140,11 +140,12 @@ public static class AbilityDefinitionCatalogLoader var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue(); var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue(); + var maxRange = (rowObj["maxRange"] as JsonValue)!.GetValue(); string? abilityKind = null; if (rowObj["abilityKind"] is JsonValue abilityKindValue) abilityKind = abilityKindValue.GetValue(); - return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind); + return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, maxRange, abilityKind); } private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs index 036949c..d42f926 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs @@ -30,6 +30,9 @@ public sealed class AbilityDefinitionJson [JsonPropertyName("cooldownSeconds")] public required double CooldownSeconds { get; init; } + [JsonPropertyName("maxRange")] + public required double MaxRange { get; init; } + [JsonPropertyName("abilityKind")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? AbilityKind { get; init; } diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs index 7d4d28e..c9310a7 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs @@ -20,6 +20,7 @@ public static class AbilityDefinitionsWorldApi DisplayName = d.DisplayName, BaseDamage = d.BaseDamage, CooldownSeconds = d.CooldownSeconds, + MaxRange = d.MaxRange, AbilityKind = d.AbilityKind, }); } diff --git a/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs index c83cba8..a76a7d6 100644 --- a/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs +++ b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs @@ -1,22 +1,32 @@ using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.World; namespace NeonSprawl.Server.Game.Combat; -/// Deterministic NPC telegraph-complete damage resolve (NEO-95). +/// Deterministic NPC telegraph-complete damage resolve (NEO-95 / NEO-98). public static class NpcAttackOperations { /// - /// Applies catalog to when - /// the threat row still names that holder for . + /// Applies catalog ability baseDamage to when the threat row still + /// names that holder for and the holder is within the attack ability's + /// of the NPC anchor. /// public static bool TryResolveTelegraphComplete( string npcInstanceId, string holderPlayerId, - int attackDamage, + string attackAbilityId, + IAbilityDefinitionRegistry abilityRegistry, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore, IThreatStateStore threatStore) { - if (attackDamage <= 0) + if (!abilityRegistry.TryGetDefinition(attackAbilityId, out var ability)) + { + return false; + } + + if (ability.BaseDamage <= 0) { return true; } @@ -29,7 +39,12 @@ public static class NpcAttackOperations return false; } - if (!playerHealthStore.TryApplyDamage(holderPlayerId, attackDamage, out var snapshot)) + if (!IsHolderWithinAbilityMaxRange(npcInstanceId, holderPlayerId, ability.MaxRange, positionStore)) + { + return false; + } + + if (!playerHealthStore.TryApplyDamage(holderPlayerId, ability.BaseDamage, out var snapshot)) { return false; } @@ -41,4 +56,25 @@ public static class NpcAttackOperations return true; } + + internal static bool IsHolderWithinAbilityMaxRange( + string npcInstanceId, + string holderPlayerId, + double maxRange, + IPositionStateStore positionStore) + { + if (maxRange <= 0 || + !PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || + !positionStore.TryGetPosition(holderPlayerId, out var playerSnap)) + { + return false; + } + + return HorizontalReach.IsWithinHorizontalRadius( + playerSnap.X, + playerSnap.Z, + entry.X, + entry.Z, + maxRange); + } } diff --git a/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs index 30f8b89..f72b648 100644 --- a/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs @@ -3,8 +3,9 @@ using System.Collections.Frozen; namespace NeonSprawl.Server.Game.Combat; /// -/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from scripts/validate_content.py -/// PROTOTYPE_E5M1_ABILITY_IDS / _prototype_e5m1_ability_gate. +/// Prototype ability roster gate (NEO-76 / NEO-77 / NEO-98), mirrored from scripts/validate_content.py +/// PROTOTYPE_E5M1_ABILITY_IDS, PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS, and +/// _prototype_ability_catalog_gate. /// public static class PrototypeE5M1AbilityCatalogRules { @@ -18,15 +19,29 @@ public static class PrototypeE5M1AbilityCatalogRules ], StringComparer.Ordinal); - /// Returns a human-readable error if the E5M1 ability contract fails, otherwise . + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS. + public static readonly FrozenSet ExpectedNpcAttackAbilityIds = FrozenSet.ToFrozenSet( + [ + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam, + ], + StringComparer.Ordinal); + + /// Union of player hotbar abilities and NPC attack abilities loaded at startup. + public static readonly FrozenSet ExpectedCatalogAbilityIds = ExpectedAbilityIds + .Union(ExpectedNpcAttackAbilityIds) + .ToFrozenSet(StringComparer.Ordinal); + + /// Returns a human-readable error if the prototype ability catalog contract fails, otherwise . public static string? TryGetE5M1GateError(IReadOnlyDictionary abilityIdToSourceFile) { var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); - if (!ids.SetEquals(ExpectedAbilityIds)) + if (!ids.SetEquals(ExpectedCatalogAbilityIds)) { return - "error: prototype E5M1 expects exactly ability ids " + - $"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + "error: prototype ability catalog expects exactly ability ids " + + $"[{string.Join(", ", ExpectedCatalogAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; } diff --git a/server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs new file mode 100644 index 0000000..4aa3c49 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Stable prototype NPC attack ability id constants (NEO-98). Allowlist validation uses . +public static class PrototypeNpcAttackAbilityRegistry +{ + public const string PrototypeNpcMeleeStrike = "prototype_npc_melee_strike"; + public const string PrototypeNpcRangedShot = "prototype_npc_ranged_shot"; + public const string PrototypeNpcEliteSlam = "prototype_npc_elite_slam"; +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs index 9263535..26de6c8 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Npc; @@ -29,7 +30,17 @@ public static class NpcBehaviorCatalogServiceCollectionExtensions opts.NpcBehaviorDefSchemaPath, hostEnv.ContentRootPath); - return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); + var catalog = NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); + var abilityRegistry = sp.GetRequiredService(); + var crossRefErr = PrototypeE5M2NpcBehaviorCatalogRules.TryGetAttackAbilityCrossRefError( + catalog.ById, + abilityRegistry); + if (crossRefErr is not null) + { + throw new InvalidOperationException(crossRefErr); + } + + return catalog; }); services.AddSingleton(sp => diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs index 1f93d9a..9a23957 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs @@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow( double LeashRadius, double TelegraphWindupSeconds, int AttackDamage, - double AttackCooldownSeconds); + double AttackCooldownSeconds, + string AttackAbilityId); diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs index be7cddd..6c16516 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs @@ -152,6 +152,7 @@ public static class NpcBehaviorDefinitionCatalogLoader var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue(); var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue(); var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue(); + var attackAbilityId = (rowObj["attackAbilityId"] as JsonValue)!.GetValue(); return new NpcBehaviorDefRow( id, @@ -162,7 +163,8 @@ public static class NpcBehaviorDefinitionCatalogLoader leashRadius, telegraphWindupSeconds, attackDamage, - attackCooldownSeconds); + attackCooldownSeconds, + attackAbilityId); } private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs index e6c7e1e..c9bb92c 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs @@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson [JsonPropertyName("attackCooldownSeconds")] public required double AttackCooldownSeconds { get; init; } + + [JsonPropertyName("attackAbilityId")] + public required string AttackAbilityId { get; init; } } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs index 66eb048..1ae23da 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs @@ -25,6 +25,7 @@ public static class NpcBehaviorDefinitionsWorldApi TelegraphWindupSeconds = d.TelegraphWindupSeconds, AttackDamage = d.AttackDamage, AttackCooldownSeconds = d.AttackCooldownSeconds, + AttackAbilityId = d.AttackAbilityId, }); } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index 104cc5f..95f15e2 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Npc; @@ -20,7 +21,9 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = DefaultMaxDeltaSeconds) { @@ -41,7 +44,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); } @@ -61,7 +66,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); } @@ -81,7 +88,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); } @@ -124,7 +133,9 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore) { if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || @@ -251,7 +262,9 @@ public static class NpcRuntimeOperations NpcAttackOperations.TryResolveTelegraphComplete( npcInstanceId, aggroHolderPlayerId, - behavior.AttackDamage, + behavior.AttackAbilityId, + abilityRegistry, + positionStore, playerHealthStore, threatStore); WriteState( @@ -272,7 +285,9 @@ public static class NpcRuntimeOperations NpcAttackOperations.TryResolveTelegraphComplete( npcInstanceId, aggroHolderPlayerId, - behavior.AttackDamage, + behavior.AttackAbilityId, + abilityRegistry, + positionStore, playerHealthStore, threatStore); WriteState( diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs index 5601a4a..fd614d7 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Npc; @@ -14,7 +15,9 @@ public static class NpcRuntimeSnapshotWorldApi INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore) => { var now = clock.GetUtcNow(); @@ -23,7 +26,9 @@ public static class NpcRuntimeSnapshotWorldApi runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry)); }); diff --git a/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs index 3bb2de9..77fb8e8 100644 --- a/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs @@ -1,5 +1,7 @@ using System.Collections.Frozen; +using NeonSprawl.Server.Game.Combat; + namespace NeonSprawl.Server.Game.Npc; /// @@ -47,4 +49,30 @@ public static class PrototypeE5M2NpcBehaviorCatalogRules return null; } + + /// + /// Returns a human-readable error when is missing from the ability catalog + /// or attackDamage does not match ability baseDamage, otherwise . + /// + public static string? TryGetAttackAbilityCrossRefError( + IReadOnlyDictionary rowsById, + IAbilityDefinitionRegistry abilityRegistry) + { + foreach (var (behaviorId, row) in rowsById) + { + if (!abilityRegistry.TryGetDefinition(row.AttackAbilityId, out var ability)) + { + return + $"error: npc behavior '{behaviorId}': attackAbilityId '{row.AttackAbilityId}' missing from ability catalog"; + } + + if (row.AttackDamage != ability.BaseDamage) + { + return + $"error: npc behavior '{behaviorId}': attackDamage {row.AttackDamage} must match ability '{row.AttackAbilityId}' baseDamage {ability.BaseDamage}"; + } + } + + return null; + } } diff --git a/server/README.md b/server/README.md index b0b89ba..b0f5ab9 100644 --- a/server/README.md +++ b/server/README.md @@ -82,7 +82,7 @@ On success, **Information** logs include the resolved recipes directory path, di ## Ability catalog (`content/abilities`, NEO-77) -On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. +On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype ability catalog** seven-id roster gate (four player hotbar abilities plus three NPC attack abilities; same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| @@ -108,7 +108,7 @@ On success, **Information** logs include the resolved npc-behaviors directory pa ## NPC behavior definitions (NEO-90) -**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, and **`attackCooldownSeconds`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`. +**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions" @@ -161,6 +161,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In | Rule | Behavior | |------|----------| | **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. | +| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **6 m**, ranged shot **10 m**, elite slam **8 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). | | **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. | | **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). | | **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. | @@ -177,7 +178,7 @@ Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** + | **`idle`** | No aggro holder; no telegraph. | | **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. | | **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. | -| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies catalog **`attackDamage`** to aggro holder ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). | +| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies the behavior's **`attackAbilityId`** **`baseDamage`** when holder is within that ability's **`maxRange`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)). | | **`recover`** | Post-attack cooldown wait before next windup. | | Rule | Behavior | @@ -209,7 +210,7 @@ Comment-only hook sites in **`NpcRuntimeOperations.AdvanceAll`** for future E9.M curl -sS -i "http://localhost:5253/game/world/npc-runtime-snapshot" ``` -**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, catalog **`attackDamage`** applies to the aggro holder via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). +**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, the behavior's **`attackAbilityId`** **`baseDamage`** applies to the aggro holder when within that ability's **`maxRange`** via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). ## Session player combat HP (NEO-95) @@ -218,7 +219,7 @@ Session player HP for incoming NPC damage lives in **`Game/Combat/`** as **`IPla | Rule | Behavior | |------|----------| | **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. | -| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes and aggro holder still matches. | +| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes, aggro holder still matches, and holder is within the attack ability's **`maxRange`** of the NPC anchor. | | **Holder re-check** | No damage when holder cleared during windup (leash clear). | | **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. |