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.
pull/137/head
VinPropane 2026-05-30 17:49:21 -04:00
parent 332cd4445f
commit 3169106f28
31 changed files with 654 additions and 146 deletions

View File

@ -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"
}

View File

@ -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"
}
]
}

View File

@ -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"
}
]
}

View File

@ -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,

View File

@ -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)."
}
}
}

View File

@ -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), "

View File

@ -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<InvalidOperationException>(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<InvalidOperationException>(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<AbilityDefinitionCatalog>();
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);

View File

@ -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);
}
}

View File

@ -8,17 +8,20 @@ namespace NeonSprawl.Server.Tests.Game.Combat;
public class AbilityDefinitionsWorldApiTests
{
/// <summary>Frozen prototype four in registry id order (ordinal). Keep in sync with Bruno.</summary>
public static readonly string[] FrozenFourInIdOrder =
/// <summary>Frozen prototype seven in registry id order (ordinal). Keep in sync with Bruno.</summary>
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);
}
}

View File

@ -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);

View File

@ -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

View File

@ -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"
}
]
}

View File

@ -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()

View File

@ -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);

View File

@ -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;
/// <summary>Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91).</summary>
public static class PrototypeNpcTestFixtures
{
public static IAbilityDefinitionRegistry CreateAbilityRegistry()
{
var rows = new Dictionary<string, AbilityDefRow>(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<string, NpcBehaviorDefRow>(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);

View File

@ -6,4 +6,5 @@ public sealed record AbilityDefRow(
string DisplayName,
int BaseDamage,
double CooldownSeconds,
double MaxRange,
string? AbilityKind);

View File

@ -140,11 +140,12 @@ public static class AbilityDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
var maxRange = (rowObj["maxRange"] as JsonValue)!.GetValue<double>();
string? abilityKind = null;
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
abilityKind = abilityKindValue.GetValue<string>();
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind);
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, maxRange, abilityKind);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)

View File

@ -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; }

View File

@ -20,6 +20,7 @@ public static class AbilityDefinitionsWorldApi
DisplayName = d.DisplayName,
BaseDamage = d.BaseDamage,
CooldownSeconds = d.CooldownSeconds,
MaxRange = d.MaxRange,
AbilityKind = d.AbilityKind,
});
}

View File

@ -1,22 +1,32 @@
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95).</summary>
/// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95 / NEO-98).</summary>
public static class NpcAttackOperations
{
/// <summary>
/// Applies catalog <paramref name="attackDamage"/> to <paramref name="holderPlayerId"/> when
/// the threat row still names that holder for <paramref name="npcInstanceId"/>.
/// Applies catalog ability <c>baseDamage</c> to <paramref name="holderPlayerId"/> when the threat row still
/// names that holder for <paramref name="npcInstanceId"/> and the holder is within the attack ability's
/// <see cref="AbilityDefRow.MaxRange"/> of the NPC anchor.
/// </summary>
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);
}
}

View File

@ -3,8 +3,9 @@ using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c> / <c>_prototype_e5m1_ability_gate</c>.
/// Prototype ability roster gate (NEO-76 / NEO-77 / NEO-98), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c>, <c>PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS</c>, and
/// <c>_prototype_ability_catalog_gate</c>.
/// </summary>
public static class PrototypeE5M1AbilityCatalogRules
{
@ -18,15 +19,29 @@ public static class PrototypeE5M1AbilityCatalogRules
],
StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the E5M1 ability contract fails, otherwise <see langword="null"/>.</summary>
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedNpcAttackAbilityIds = FrozenSet.ToFrozenSet(
[
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam,
],
StringComparer.Ordinal);
/// <summary>Union of player hotbar abilities and NPC attack abilities loaded at startup.</summary>
public static readonly FrozenSet<string> ExpectedCatalogAbilityIds = ExpectedAbilityIds
.Union(ExpectedNpcAttackAbilityIds)
.ToFrozenSet(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the prototype ability catalog contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> 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 + "'"))}]";
}

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Stable prototype NPC attack ability id constants (NEO-98). Allowlist validation uses <see cref="IAbilityDefinitionRegistry"/>.</summary>
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";
}

View File

@ -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<IAbilityDefinitionRegistry>();
var crossRefErr = PrototypeE5M2NpcBehaviorCatalogRules.TryGetAttackAbilityCrossRefError(
catalog.ById,
abilityRegistry);
if (crossRefErr is not null)
{
throw new InvalidOperationException(crossRefErr);
}
return catalog;
});
services.AddSingleton<INpcBehaviorDefinitionRegistry>(sp =>

View File

@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow(
double LeashRadius,
double TelegraphWindupSeconds,
int AttackDamage,
double AttackCooldownSeconds);
double AttackCooldownSeconds,
string AttackAbilityId);

View File

@ -152,6 +152,7 @@ public static class NpcBehaviorDefinitionCatalogLoader
var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>();
var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
var attackAbilityId = (rowObj["attackAbilityId"] as JsonValue)!.GetValue<string>();
return new NpcBehaviorDefRow(
id,
@ -162,7 +163,8 @@ public static class NpcBehaviorDefinitionCatalogLoader
leashRadius,
telegraphWindupSeconds,
attackDamage,
attackCooldownSeconds);
attackCooldownSeconds,
attackAbilityId);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)

View File

@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson
[JsonPropertyName("attackCooldownSeconds")]
public required double AttackCooldownSeconds { get; init; }
[JsonPropertyName("attackAbilityId")]
public required string AttackAbilityId { get; init; }
}

View File

@ -25,6 +25,7 @@ public static class NpcBehaviorDefinitionsWorldApi
TelegraphWindupSeconds = d.TelegraphWindupSeconds,
AttackDamage = d.AttackDamage,
AttackCooldownSeconds = d.AttackCooldownSeconds,
AttackAbilityId = d.AttackAbilityId,
});
}

View File

@ -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(

View File

@ -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));
});

View File

@ -1,5 +1,7 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
@ -47,4 +49,30 @@ public static class PrototypeE5M2NpcBehaviorCatalogRules
return null;
}
/// <summary>
/// Returns a human-readable error when <paramref name="attackAbilityId"/> is missing from the ability catalog
/// or <c>attackDamage</c> does not match ability <c>baseDamage</c>, otherwise <see langword="null"/>.
/// </summary>
public static string? TryGetAttackAbilityCrossRefError(
IReadOnlyDictionary<string, NpcBehaviorDefRow> 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;
}
}

View File

@ -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. |