From 3169106f28e21a97b26978f1c1b230f662fdd368 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:49:21 -0400 Subject: [PATCH] NEO-98: Resolve NPC telegraph hits via attack ability maxRange. Telegraph damage now looks up behavior attackAbilityId for baseDamage and maxRange instead of using aggroRadius. Adds three NPC attack abilities, maxRange on AbilityDef, attackAbilityId on NpcBehaviorDef, and cross-ref validation. --- content/abilities/prototype_abilities.json | 4 + .../abilities/prototype_npc_abilities.json | 29 +++ .../prototype_npc_behaviors.json | 9 +- content/schemas/ability-def.schema.json | 7 +- content/schemas/npc-behavior-def.schema.json | 10 +- scripts/validate_content.py | 80 ++++++-- .../AbilityDefinitionCatalogLoaderTests.cs | 69 ++++++- .../Combat/AbilityDefinitionRegistryTests.cs | 20 +- .../Combat/AbilityDefinitionsWorldApiTests.cs | 21 +- .../Game/Combat/CombatOperationsTests.cs | 4 + .../Game/Combat/NpcAttackOperationsTests.cs | 87 +++++++- ...NpcBehaviorDefinitionCatalogLoaderTests.cs | 12 +- .../Npc/NpcBehaviorDefinitionRegistryTests.cs | 10 +- .../Game/Npc/NpcRuntimeOperationsTests.cs | 190 +++++++++++------- .../Game/Npc/PrototypeNpcTestFixtures.cs | 69 ++++++- .../Game/Combat/AbilityDefRow.cs | 1 + .../Combat/AbilityDefinitionCatalogLoader.cs | 3 +- .../Game/Combat/AbilityDefinitionsListDtos.cs | 3 + .../Game/Combat/AbilityDefinitionsWorldApi.cs | 1 + .../Game/Combat/NpcAttackOperations.cs | 48 ++++- .../PrototypeE5M1AbilityCatalogRules.cs | 27 ++- .../PrototypeNpcAttackAbilityRegistry.cs | 9 + ...aviorCatalogServiceCollectionExtensions.cs | 13 +- .../Game/Npc/NpcBehaviorDefRow.cs | 3 +- .../Npc/NpcBehaviorDefinitionCatalogLoader.cs | 4 +- .../Npc/NpcBehaviorDefinitionsListDtos.cs | 3 + .../Npc/NpcBehaviorDefinitionsWorldApi.cs | 1 + .../Game/Npc/NpcRuntimeOperations.cs | 19 +- .../Game/Npc/NpcRuntimeSnapshotWorldApi.cs | 5 + .../PrototypeE5M2NpcBehaviorCatalogRules.cs | 28 +++ server/README.md | 11 +- 31 files changed, 654 insertions(+), 146 deletions(-) create mode 100644 content/abilities/prototype_npc_abilities.json create mode 100644 server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs 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. |