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
parent
332cd4445f
commit
3169106f28
|
|
@ -5,6 +5,7 @@
|
||||||
"id": "prototype_pulse",
|
"id": "prototype_pulse",
|
||||||
"displayName": "Prototype Pulse",
|
"displayName": "Prototype Pulse",
|
||||||
"baseDamage": 25,
|
"baseDamage": 25,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 3.0,
|
"cooldownSeconds": 3.0,
|
||||||
"abilityKind": "attack"
|
"abilityKind": "attack"
|
||||||
},
|
},
|
||||||
|
|
@ -12,6 +13,7 @@
|
||||||
"id": "prototype_guard",
|
"id": "prototype_guard",
|
||||||
"displayName": "Prototype Guard",
|
"displayName": "Prototype Guard",
|
||||||
"baseDamage": 0,
|
"baseDamage": 0,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 6.0,
|
"cooldownSeconds": 6.0,
|
||||||
"abilityKind": "utility"
|
"abilityKind": "utility"
|
||||||
},
|
},
|
||||||
|
|
@ -19,6 +21,7 @@
|
||||||
"id": "prototype_dash",
|
"id": "prototype_dash",
|
||||||
"displayName": "Prototype Dash",
|
"displayName": "Prototype Dash",
|
||||||
"baseDamage": 0,
|
"baseDamage": 0,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 4.0,
|
"cooldownSeconds": 4.0,
|
||||||
"abilityKind": "movement"
|
"abilityKind": "movement"
|
||||||
},
|
},
|
||||||
|
|
@ -26,6 +29,7 @@
|
||||||
"id": "prototype_burst",
|
"id": "prototype_burst",
|
||||||
"displayName": "Prototype Burst",
|
"displayName": "Prototype Burst",
|
||||||
"baseDamage": 40,
|
"baseDamage": 40,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 5.0,
|
"cooldownSeconds": 5.0,
|
||||||
"abilityKind": "attack"
|
"abilityKind": "attack"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
"leashRadius": 16.0,
|
"leashRadius": 16.0,
|
||||||
"telegraphWindupSeconds": 1.5,
|
"telegraphWindupSeconds": 1.5,
|
||||||
"attackDamage": 15,
|
"attackDamage": 15,
|
||||||
"attackCooldownSeconds": 3.0
|
"attackCooldownSeconds": 3.0,
|
||||||
|
"attackAbilityId": "prototype_npc_melee_strike"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "prototype_ranged_control",
|
"id": "prototype_ranged_control",
|
||||||
|
|
@ -21,7 +22,8 @@
|
||||||
"leashRadius": 20.0,
|
"leashRadius": 20.0,
|
||||||
"telegraphWindupSeconds": 2.0,
|
"telegraphWindupSeconds": 2.0,
|
||||||
"attackDamage": 12,
|
"attackDamage": 12,
|
||||||
"attackCooldownSeconds": 4.0
|
"attackCooldownSeconds": 4.0,
|
||||||
|
"attackAbilityId": "prototype_npc_ranged_shot"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "prototype_elite_mini_boss",
|
"id": "prototype_elite_mini_boss",
|
||||||
|
|
@ -32,7 +34,8 @@
|
||||||
"leashRadius": 18.0,
|
"leashRadius": 18.0,
|
||||||
"telegraphWindupSeconds": 2.5,
|
"telegraphWindupSeconds": 2.5,
|
||||||
"attackDamage": 25,
|
"attackDamage": 25,
|
||||||
"attackCooldownSeconds": 5.0
|
"attackCooldownSeconds": 5.0,
|
||||||
|
"attackAbilityId": "prototype_npc_elite_slam"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["id", "displayName", "baseDamage", "cooldownSeconds"],
|
"required": ["id", "displayName", "baseDamage", "maxRange", "cooldownSeconds"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"description": "Deterministic damage dealt on successful combat resolve in prototype Slice 1 (no miss/crit RNG)."
|
"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": {
|
"cooldownSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"exclusiveMinimum": 0,
|
"exclusiveMinimum": 0,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
"leashRadius",
|
"leashRadius",
|
||||||
"telegraphWindupSeconds",
|
"telegraphWindupSeconds",
|
||||||
"attackDamage",
|
"attackDamage",
|
||||||
"attackCooldownSeconds"
|
"attackCooldownSeconds",
|
||||||
|
"attackAbilityId"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
|
|
@ -40,7 +41,7 @@
|
||||||
"aggroRadius": {
|
"aggroRadius": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"exclusiveMinimum": 0,
|
"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": {
|
"leashRadius": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|
@ -61,6 +62,11 @@
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"exclusiveMinimum": 0,
|
"exclusiveMinimum": 0,
|
||||||
"description": "Seconds after last attack before the NPC may enter telegraph again."
|
"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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
# 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.
|
# Keep in sync with E5.M2 freeze table and future NEO-88 server loader.
|
||||||
PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
|
PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
|
||||||
|
|
@ -428,10 +439,11 @@ def _validate_ability_catalogs(
|
||||||
*,
|
*,
|
||||||
ability_files: list[Path],
|
ability_files: list[Path],
|
||||||
ability_validator: Draft202012Validator,
|
ability_validator: Draft202012Validator,
|
||||||
) -> tuple[int, dict[str, str]]:
|
) -> tuple[int, dict[str, str], dict[str, dict]]:
|
||||||
"""Validate ability JSON files. Returns (error_count, seen_ids)."""
|
"""Validate ability JSON files. Returns (error_count, seen_ids, id_to_row)."""
|
||||||
errors = 0
|
errors = 0
|
||||||
seen_ids: dict[str, str] = {}
|
seen_ids: dict[str, str] = {}
|
||||||
|
id_to_row: dict[str, dict] = {}
|
||||||
|
|
||||||
for path in ability_files:
|
for path in ability_files:
|
||||||
rel = str(path.relative_to(REPO_ROOT))
|
rel = str(path.relative_to(REPO_ROOT))
|
||||||
|
|
@ -467,19 +479,25 @@ def _validate_ability_catalogs(
|
||||||
errors += 1
|
errors += 1
|
||||||
else:
|
else:
|
||||||
seen_ids[aid] = rel
|
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:
|
def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None:
|
||||||
"""Return a human-readable error if E5M1 ability contract fails, else None."""
|
"""Backward-compatible alias for the combined prototype ability catalog gate."""
|
||||||
ids = frozenset(seen_ids.keys())
|
return _prototype_ability_catalog_gate(seen_ids)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_npc_behavior_catalogs(
|
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
|
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:
|
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
|
||||||
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
||||||
if len(track_skill_ids) != 1:
|
if len(track_skill_ids) != 1:
|
||||||
|
|
@ -1161,7 +1203,7 @@ def main() -> int:
|
||||||
print(slice3_recipe_err, file=sys.stderr)
|
print(slice3_recipe_err, file=sys.stderr)
|
||||||
return 1
|
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_files=ability_files,
|
||||||
ability_validator=ability_validator,
|
ability_validator=ability_validator,
|
||||||
)
|
)
|
||||||
|
|
@ -1169,9 +1211,9 @@ def main() -> int:
|
||||||
print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr)
|
print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
e5m1_ability_err = _prototype_e5m1_ability_gate(ability_seen_ids)
|
ability_catalog_err = _prototype_ability_catalog_gate(ability_seen_ids)
|
||||||
if e5m1_ability_err:
|
if ability_catalog_err:
|
||||||
print(e5m1_ability_err, file=sys.stderr)
|
print(ability_catalog_err, file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
npc_behavior_errors, npc_behavior_seen_ids, npc_behavior_id_to_row = _validate_npc_behavior_catalogs(
|
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)
|
print(e5m2_npc_behavior_numeric_err, file=sys.stderr)
|
||||||
return 1
|
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(
|
print(
|
||||||
"content OK: "
|
"content OK: "
|
||||||
f"{len(skill_files)} skill catalog file(s), "
|
f"{len(skill_files)} skill catalog file(s), "
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_pulse",
|
"id": "prototype_pulse",
|
||||||
"displayName": "Prototype Pulse",
|
"displayName": "Prototype Pulse",
|
||||||
"baseDamage": 25,
|
"baseDamage": 25,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 3.0,
|
"cooldownSeconds": 3.0,
|
||||||
"abilityKind": "attack"
|
"abilityKind": "attack"
|
||||||
},
|
},
|
||||||
|
|
@ -28,6 +29,7 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_guard",
|
"id": "prototype_guard",
|
||||||
"displayName": "Prototype Guard",
|
"displayName": "Prototype Guard",
|
||||||
"baseDamage": 0,
|
"baseDamage": 0,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 6.0,
|
"cooldownSeconds": 6.0,
|
||||||
"abilityKind": "utility"
|
"abilityKind": "utility"
|
||||||
},
|
},
|
||||||
|
|
@ -35,6 +37,7 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_dash",
|
"id": "prototype_dash",
|
||||||
"displayName": "Prototype Dash",
|
"displayName": "Prototype Dash",
|
||||||
"baseDamage": 0,
|
"baseDamage": 0,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 4.0,
|
"cooldownSeconds": 4.0,
|
||||||
"abilityKind": "movement"
|
"abilityKind": "movement"
|
||||||
},
|
},
|
||||||
|
|
@ -42,6 +45,40 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_burst",
|
"id": "prototype_burst",
|
||||||
"displayName": "Prototype Burst",
|
"displayName": "Prototype Burst",
|
||||||
"baseDamage": 40,
|
"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,
|
"cooldownSeconds": 5.0,
|
||||||
"abilityKind": "attack"
|
"abilityKind": "attack"
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +101,15 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
private static void WriteCatalog(string abilitiesDir, string catalogJson) =>
|
private static void WriteCatalog(string abilitiesDir, string catalogJson) =>
|
||||||
File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8);
|
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)
|
private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId)
|
||||||
{
|
{
|
||||||
var abilities = catalogRoot["abilities"] as JsonArray
|
var abilities = catalogRoot["abilities"] as JsonArray
|
||||||
|
|
@ -85,12 +131,12 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
|
WriteFullPrototypeCatalog(abilitiesDir);
|
||||||
// Act
|
// Act
|
||||||
var catalog = LoadCatalog(abilitiesDir, schemaPath);
|
var catalog = LoadCatalog(abilitiesDir, schemaPath);
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(4, catalog.DistinctAbilityCount);
|
Assert.Equal(7, catalog.DistinctAbilityCount);
|
||||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
Assert.Equal(2, catalog.CatalogJsonFileCount);
|
||||||
Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse));
|
Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse));
|
||||||
Assert.NotNull(pulse);
|
Assert.NotNull(pulse);
|
||||||
Assert.Equal(25, pulse!.BaseDamage);
|
Assert.Equal(25, pulse!.BaseDamage);
|
||||||
|
|
@ -136,7 +182,7 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
|
WriteFullPrototypeCatalog(abilitiesDir);
|
||||||
File.WriteAllText(
|
File.WriteAllText(
|
||||||
Path.Combine(abilitiesDir, "extra_abilities.json"),
|
Path.Combine(abilitiesDir, "extra_abilities.json"),
|
||||||
"""
|
"""
|
||||||
|
|
@ -147,6 +193,7 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_pulse",
|
"id": "prototype_pulse",
|
||||||
"displayName": "Duplicate Pulse",
|
"displayName": "Duplicate Pulse",
|
||||||
"baseDamage": 1,
|
"baseDamage": 1,
|
||||||
|
"maxRange": 6.0,
|
||||||
"cooldownSeconds": 1.0
|
"cooldownSeconds": 1.0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -171,11 +218,15 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
?? throw new InvalidOperationException("expected abilities array");
|
?? throw new InvalidOperationException("expected abilities array");
|
||||||
abilities.RemoveAt(abilities.Count - 1);
|
abilities.RemoveAt(abilities.Count - 1);
|
||||||
WriteCatalog(abilitiesDir, root.ToJsonString());
|
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
|
||||||
|
ValidNpcAttackAbilitiesJson,
|
||||||
|
Encoding.UTF8);
|
||||||
// Act
|
// Act
|
||||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
// Assert
|
// Assert
|
||||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
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]
|
[Fact]
|
||||||
|
|
@ -187,11 +238,15 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
?? throw new InvalidOperationException("expected object root");
|
?? throw new InvalidOperationException("expected object root");
|
||||||
GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra";
|
GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra";
|
||||||
WriteCatalog(abilitiesDir, root.ToJsonString());
|
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
|
||||||
|
ValidNpcAttackAbilitiesJson,
|
||||||
|
Encoding.UTF8);
|
||||||
// Act
|
// Act
|
||||||
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
// Assert
|
// Assert
|
||||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
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);
|
Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +371,7 @@ public class AbilityDefinitionCatalogLoaderTests
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
var catalog = factory.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
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.True(catalog.TryGetAbility("prototype_burst", out var burst));
|
||||||
Assert.Equal(40, burst!.BaseDamage);
|
Assert.Equal(40, burst!.BaseDamage);
|
||||||
Assert.Equal(5.0, burst.CooldownSeconds);
|
Assert.Equal(5.0, burst.CooldownSeconds);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -52,6 +53,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Burst",
|
"Prototype Burst",
|
||||||
40,
|
40,
|
||||||
5.0,
|
5.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -75,6 +77,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -96,6 +99,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -117,6 +121,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -138,6 +143,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -159,6 +165,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -180,6 +187,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -201,24 +209,28 @@ public class AbilityDefinitionRegistryTests
|
||||||
"Prototype Burst",
|
"Prototype Burst",
|
||||||
40,
|
40,
|
||||||
5.0,
|
5.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
|
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
|
||||||
PrototypeAbilityRegistry.PrototypeDash,
|
PrototypeAbilityRegistry.PrototypeDash,
|
||||||
"Prototype Dash",
|
"Prototype Dash",
|
||||||
0,
|
0,
|
||||||
4.0,
|
4.0,
|
||||||
|
6.0,
|
||||||
"movement"),
|
"movement"),
|
||||||
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
|
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
|
||||||
PrototypeAbilityRegistry.PrototypeGuard,
|
PrototypeAbilityRegistry.PrototypeGuard,
|
||||||
"Prototype Guard",
|
"Prototype Guard",
|
||||||
0,
|
0,
|
||||||
6.0,
|
6.0,
|
||||||
|
6.0,
|
||||||
"utility"),
|
"utility"),
|
||||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||||
PrototypeAbilityRegistry.PrototypePulse,
|
PrototypeAbilityRegistry.PrototypePulse,
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var registry = CreateRegistryFromRows(rows);
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
|
@ -249,6 +261,10 @@ public class AbilityDefinitionRegistryTests
|
||||||
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"),
|
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"),
|
||||||
Path.Combine(abilitiesDir, "prototype_abilities.json"),
|
Path.Combine(abilitiesDir, "prototype_abilities.json"),
|
||||||
overwrite: true);
|
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 loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
|
||||||
var registry = new AbilityDefinitionRegistry(loaded);
|
var registry = new AbilityDefinitionRegistry(loaded);
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -274,7 +290,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
Assert.NotNull(burst);
|
Assert.NotNull(burst);
|
||||||
Assert.Equal(40, burst!.BaseDamage);
|
Assert.Equal(40, burst!.BaseDamage);
|
||||||
Assert.Equal(5.0, burst.CooldownSeconds);
|
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.PrototypeBurst, list[0].Id);
|
||||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id);
|
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id);
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +329,7 @@ public class AbilityDefinitionRegistryTests
|
||||||
Assert.Null(missing);
|
Assert.Null(missing);
|
||||||
Assert.True(normalized);
|
Assert.True(normalized);
|
||||||
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId);
|
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);
|
Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,20 @@ namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||||
|
|
||||||
public class AbilityDefinitionsWorldApiTests
|
public class AbilityDefinitionsWorldApiTests
|
||||||
{
|
{
|
||||||
/// <summary>Frozen prototype four in registry id order (ordinal). Keep in sync with Bruno.</summary>
|
/// <summary>Frozen prototype seven in registry id order (ordinal). Keep in sync with Bruno.</summary>
|
||||||
public static readonly string[] FrozenFourInIdOrder =
|
public static readonly string[] FrozenSevenInIdOrder =
|
||||||
[
|
[
|
||||||
"prototype_burst",
|
"prototype_burst",
|
||||||
"prototype_dash",
|
"prototype_dash",
|
||||||
"prototype_guard",
|
"prototype_guard",
|
||||||
|
"prototype_npc_elite_slam",
|
||||||
|
"prototype_npc_melee_strike",
|
||||||
|
"prototype_npc_ranged_shot",
|
||||||
"prototype_pulse",
|
"prototype_pulse",
|
||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenFourInIdOrder()
|
public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenSevenInIdOrder()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
await using var factory = new InMemoryWebApplicationFactory();
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
|
@ -31,30 +34,38 @@ public class AbilityDefinitionsWorldApiTests
|
||||||
Assert.NotNull(body);
|
Assert.NotNull(body);
|
||||||
Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||||
Assert.NotNull(body.Abilities);
|
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();
|
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");
|
var pulse = body.Abilities.Single(a => a.Id == "prototype_pulse");
|
||||||
Assert.Equal("Prototype Pulse", pulse.DisplayName);
|
Assert.Equal("Prototype Pulse", pulse.DisplayName);
|
||||||
Assert.Equal(25, pulse.BaseDamage);
|
Assert.Equal(25, pulse.BaseDamage);
|
||||||
Assert.Equal(3.0, pulse.CooldownSeconds);
|
Assert.Equal(3.0, pulse.CooldownSeconds);
|
||||||
|
Assert.Equal(6.0, pulse.MaxRange);
|
||||||
Assert.Equal("attack", pulse.AbilityKind);
|
Assert.Equal("attack", pulse.AbilityKind);
|
||||||
|
|
||||||
var burst = body.Abilities.Single(a => a.Id == "prototype_burst");
|
var burst = body.Abilities.Single(a => a.Id == "prototype_burst");
|
||||||
Assert.Equal("Prototype Burst", burst.DisplayName);
|
Assert.Equal("Prototype Burst", burst.DisplayName);
|
||||||
Assert.Equal(40, burst.BaseDamage);
|
Assert.Equal(40, burst.BaseDamage);
|
||||||
Assert.Equal(5.0, burst.CooldownSeconds);
|
Assert.Equal(5.0, burst.CooldownSeconds);
|
||||||
|
Assert.Equal(6.0, burst.MaxRange);
|
||||||
Assert.Equal("attack", burst.AbilityKind);
|
Assert.Equal("attack", burst.AbilityKind);
|
||||||
|
|
||||||
var guard = body.Abilities.Single(a => a.Id == "prototype_guard");
|
var guard = body.Abilities.Single(a => a.Id == "prototype_guard");
|
||||||
Assert.Equal("utility", guard.AbilityKind);
|
Assert.Equal("utility", guard.AbilityKind);
|
||||||
Assert.Equal(0, guard.BaseDamage);
|
Assert.Equal(0, guard.BaseDamage);
|
||||||
Assert.Equal(6.0, guard.CooldownSeconds);
|
Assert.Equal(6.0, guard.CooldownSeconds);
|
||||||
|
Assert.Equal(6.0, guard.MaxRange);
|
||||||
|
|
||||||
var dash = body.Abilities.Single(a => a.Id == "prototype_dash");
|
var dash = body.Abilities.Single(a => a.Id == "prototype_dash");
|
||||||
Assert.Equal("movement", dash.AbilityKind);
|
Assert.Equal("movement", dash.AbilityKind);
|
||||||
Assert.Equal(0, dash.BaseDamage);
|
Assert.Equal(0, dash.BaseDamage);
|
||||||
Assert.Equal(4.0, dash.CooldownSeconds);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,24 +19,28 @@ public sealed class CombatOperationsTests
|
||||||
"Prototype Burst",
|
"Prototype Burst",
|
||||||
40,
|
40,
|
||||||
5.0,
|
5.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
|
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
|
||||||
PrototypeAbilityRegistry.PrototypeDash,
|
PrototypeAbilityRegistry.PrototypeDash,
|
||||||
"Prototype Dash",
|
"Prototype Dash",
|
||||||
0,
|
0,
|
||||||
4.0,
|
4.0,
|
||||||
|
6.0,
|
||||||
"movement"),
|
"movement"),
|
||||||
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
|
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
|
||||||
PrototypeAbilityRegistry.PrototypeGuard,
|
PrototypeAbilityRegistry.PrototypeGuard,
|
||||||
"Prototype Guard",
|
"Prototype Guard",
|
||||||
0,
|
0,
|
||||||
6.0,
|
6.0,
|
||||||
|
6.0,
|
||||||
"utility"),
|
"utility"),
|
||||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||||
PrototypeAbilityRegistry.PrototypePulse,
|
PrototypeAbilityRegistry.PrototypePulse,
|
||||||
"Prototype Pulse",
|
"Prototype Pulse",
|
||||||
25,
|
25,
|
||||||
3.0,
|
3.0,
|
||||||
|
6.0,
|
||||||
"attack"),
|
"attack"),
|
||||||
};
|
};
|
||||||
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1);
|
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,38 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Tests.Game.Npc;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||||
|
|
||||||
public sealed class NpcAttackOperationsTests
|
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]
|
[Fact]
|
||||||
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches()
|
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatchesAndInRange()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
|
||||||
var threatStore = new InMemoryThreatStateStore();
|
var threatStore = new InMemoryThreatStateStore();
|
||||||
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
||||||
|
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
|
||||||
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
||||||
// Act
|
// Act
|
||||||
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
||||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
"dev-local-1",
|
"dev-local-1",
|
||||||
attackDamage: 15,
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
|
||||||
|
abilityRegistry,
|
||||||
|
positionStore,
|
||||||
playerHealthStore,
|
playerHealthStore,
|
||||||
threatStore);
|
threatStore);
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -26,19 +41,71 @@ public sealed class NpcAttackOperationsTests
|
||||||
Assert.Equal(85, snapshot.CurrentHp);
|
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]
|
[Fact]
|
||||||
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared()
|
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
|
||||||
var threatStore = new InMemoryThreatStateStore();
|
var threatStore = new InMemoryThreatStateStore();
|
||||||
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
||||||
|
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
|
||||||
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
||||||
_ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
_ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// Act
|
||||||
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
||||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
"dev-local-1",
|
"dev-local-1",
|
||||||
attackDamage: 15,
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
|
||||||
|
abilityRegistry,
|
||||||
|
positionStore,
|
||||||
playerHealthStore,
|
playerHealthStore,
|
||||||
threatStore);
|
threatStore);
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -51,14 +118,18 @@ public sealed class NpcAttackOperationsTests
|
||||||
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers()
|
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
|
||||||
var threatStore = new InMemoryThreatStateStore();
|
var threatStore = new InMemoryThreatStateStore();
|
||||||
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
||||||
|
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
|
||||||
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player");
|
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player");
|
||||||
// Act
|
// Act
|
||||||
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
||||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
"dev-local-1",
|
"dev-local-1",
|
||||||
attackDamage: 15,
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
|
||||||
|
abilityRegistry,
|
||||||
|
positionStore,
|
||||||
playerHealthStore,
|
playerHealthStore,
|
||||||
threatStore);
|
threatStore);
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -68,17 +139,21 @@ public sealed class NpcAttackOperationsTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero()
|
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAbilityBaseDamageZero()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
|
||||||
var threatStore = new InMemoryThreatStateStore();
|
var threatStore = new InMemoryThreatStateStore();
|
||||||
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
|
||||||
|
var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0);
|
||||||
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
||||||
// Act
|
// Act
|
||||||
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
|
||||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
"dev-local-1",
|
"dev-local-1",
|
||||||
attackDamage: 0,
|
PrototypeAbilityRegistry.PrototypeGuard,
|
||||||
|
abilityRegistry,
|
||||||
|
positionStore,
|
||||||
playerHealthStore,
|
playerHealthStore,
|
||||||
threatStore);
|
threatStore);
|
||||||
// Assert
|
// Assert
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
|
||||||
"leashRadius": 16.0,
|
"leashRadius": 16.0,
|
||||||
"telegraphWindupSeconds": 1.5,
|
"telegraphWindupSeconds": 1.5,
|
||||||
"attackDamage": 15,
|
"attackDamage": 15,
|
||||||
"attackCooldownSeconds": 3.0
|
"attackCooldownSeconds": 3.0,
|
||||||
|
"attackAbilityId": "prototype_npc_melee_strike"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "prototype_ranged_control",
|
"id": "prototype_ranged_control",
|
||||||
|
|
@ -37,7 +38,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
|
||||||
"leashRadius": 20.0,
|
"leashRadius": 20.0,
|
||||||
"telegraphWindupSeconds": 2.0,
|
"telegraphWindupSeconds": 2.0,
|
||||||
"attackDamage": 12,
|
"attackDamage": 12,
|
||||||
"attackCooldownSeconds": 4.0
|
"attackCooldownSeconds": 4.0,
|
||||||
|
"attackAbilityId": "prototype_npc_ranged_shot"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "prototype_elite_mini_boss",
|
"id": "prototype_elite_mini_boss",
|
||||||
|
|
@ -48,7 +50,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
|
||||||
"leashRadius": 18.0,
|
"leashRadius": 18.0,
|
||||||
"telegraphWindupSeconds": 2.5,
|
"telegraphWindupSeconds": 2.5,
|
||||||
"attackDamage": 25,
|
"attackDamage": 25,
|
||||||
"attackCooldownSeconds": 5.0
|
"attackCooldownSeconds": 5.0,
|
||||||
|
"attackAbilityId": "prototype_npc_elite_slam"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +162,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
|
||||||
"leashRadius": 2.0,
|
"leashRadius": 2.0,
|
||||||
"telegraphWindupSeconds": 1.0,
|
"telegraphWindupSeconds": 1.0,
|
||||||
"attackDamage": 1,
|
"attackDamage": 1,
|
||||||
"attackCooldownSeconds": 1.0
|
"attackCooldownSeconds": 1.0,
|
||||||
|
"attackAbilityId": "prototype_npc_melee_strike"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
using NeonSprawl.Server.Tests;
|
using NeonSprawl.Server.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -25,7 +26,8 @@ public class NpcBehaviorDefinitionRegistryTests
|
||||||
16.0,
|
16.0,
|
||||||
1.5,
|
1.5,
|
||||||
15,
|
15,
|
||||||
3.0);
|
3.0,
|
||||||
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike);
|
||||||
|
|
||||||
private static NpcBehaviorDefRow RangedRow() =>
|
private static NpcBehaviorDefRow RangedRow() =>
|
||||||
new(
|
new(
|
||||||
|
|
@ -37,7 +39,8 @@ public class NpcBehaviorDefinitionRegistryTests
|
||||||
20.0,
|
20.0,
|
||||||
2.0,
|
2.0,
|
||||||
12,
|
12,
|
||||||
4.0);
|
4.0,
|
||||||
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot);
|
||||||
|
|
||||||
private static NpcBehaviorDefRow EliteRow() =>
|
private static NpcBehaviorDefRow EliteRow() =>
|
||||||
new(
|
new(
|
||||||
|
|
@ -49,7 +52,8 @@ public class NpcBehaviorDefinitionRegistryTests
|
||||||
18.0,
|
18.0,
|
||||||
2.5,
|
2.5,
|
||||||
25,
|
25,
|
||||||
5.0);
|
5.0,
|
||||||
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists()
|
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Tests.Game.Npc;
|
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||||
|
|
@ -9,14 +11,23 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
private static readonly DateTimeOffset T0 =
|
private static readonly DateTimeOffset T0 =
|
||||||
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, InMemoryCombatEntityHealthStore CombatHealth, IPlayerCombatHealthStore PlayerHealth)
|
private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) =>
|
||||||
CreateFixture()
|
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 (
|
return (
|
||||||
new InMemoryNpcRuntimeStateStore(),
|
new InMemoryNpcRuntimeStateStore(),
|
||||||
new InMemoryThreatStateStore(),
|
new InMemoryThreatStateStore(),
|
||||||
PrototypeNpcTestFixtures.CreateBehaviorRegistry(),
|
PrototypeNpcTestFixtures.CreateBehaviorRegistry(),
|
||||||
|
PrototypeNpcTestFixtures.CreateAbilityRegistry(),
|
||||||
PrototypeNpcTestFixtures.CreateHealthStore(),
|
PrototypeNpcTestFixtures.CreateHealthStore(),
|
||||||
|
CreatePositionStoreAt(playerX, playerY, playerZ),
|
||||||
new InMemoryPlayerCombatHealthStore());
|
new InMemoryPlayerCombatHealthStore());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +39,9 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
INpcRuntimeStateStore runtimeStore,
|
INpcRuntimeStateStore runtimeStore,
|
||||||
IThreatStateStore threatStore,
|
IThreatStateStore threatStore,
|
||||||
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||||
|
IAbilityDefinitionRegistry abilityRegistry,
|
||||||
InMemoryCombatEntityHealthStore combatHealthStore,
|
InMemoryCombatEntityHealthStore combatHealthStore,
|
||||||
|
InMemoryPositionStateStore positionStore,
|
||||||
IPlayerCombatHealthStore playerHealthStore,
|
IPlayerCombatHealthStore playerHealthStore,
|
||||||
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
|
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
|
||||||
NpcRuntimeOperations.AdvanceAll(
|
NpcRuntimeOperations.AdvanceAll(
|
||||||
|
|
@ -36,7 +49,9 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
runtimeStore,
|
runtimeStore,
|
||||||
threatStore,
|
threatStore,
|
||||||
behaviorRegistry,
|
behaviorRegistry,
|
||||||
|
abilityRegistry,
|
||||||
combatHealthStore,
|
combatHealthStore,
|
||||||
|
positionStore,
|
||||||
playerHealthStore,
|
playerHealthStore,
|
||||||
maxDeltaSeconds);
|
maxDeltaSeconds);
|
||||||
|
|
||||||
|
|
@ -44,9 +59,9 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
|
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
// Act
|
// Act
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
|
@ -57,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
|
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
|
@ -72,10 +87,10 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
|
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
runtime.LastAdvancedUtc = T0;
|
runtime.LastAdvancedUtc = T0;
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
|
@ -86,11 +101,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
|
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -102,11 +117,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
|
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
|
@ -116,12 +131,12 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
|
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -131,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
|
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
||||||
|
|
@ -149,13 +164,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
|
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -167,11 +182,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
|
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// 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
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -183,13 +198,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
|
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
|
@ -204,11 +219,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
double attackCooldownSeconds)
|
double attackCooldownSeconds)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, npcInstanceId);
|
SetHolder(threat, npcInstanceId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(npcInstanceId, out var snapshot);
|
runtime.TryGet(npcInstanceId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
|
@ -224,12 +239,12 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
double telegraphWindupSeconds)
|
double telegraphWindupSeconds)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, npcInstanceId);
|
SetHolder(threat, npcInstanceId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
runtime.TryGet(npcInstanceId, out var snapshot);
|
runtime.TryGet(npcInstanceId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
||||||
|
|
@ -242,11 +257,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
|
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// 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
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
||||||
|
|
@ -258,12 +273,12 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
|
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(100), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5);
|
Advance(T0.AddSeconds(100), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5);
|
||||||
// Act
|
// 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
|
||||||
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
|
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
|
@ -275,14 +290,14 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
|
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
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);
|
runtime.LastAdvancedUtc = T0.AddHours(1);
|
||||||
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
|
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// 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
|
// Assert
|
||||||
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
|
@ -294,11 +309,11 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
|
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
runtime.LastAdvancedUtc = T0.AddSeconds(10);
|
runtime.LastAdvancedUtc = T0.AddSeconds(10);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(5), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
|
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
|
||||||
}
|
}
|
||||||
|
|
@ -307,13 +322,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes()
|
public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
playerHealth.TryGet("dev-local-1", out var snapshot);
|
playerHealth.TryGet("dev-local-1", out var snapshot);
|
||||||
Assert.Equal(85, snapshot.CurrentHp);
|
Assert.Equal(85, snapshot.CurrentHp);
|
||||||
|
|
@ -323,13 +338,13 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes()
|
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
// Act
|
// Act
|
||||||
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
// Assert
|
// Assert
|
||||||
playerHealth.TryGet("dev-local-1", out var snapshot);
|
playerHealth.TryGet("dev-local-1", out var snapshot);
|
||||||
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
|
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
|
||||||
|
|
@ -350,7 +365,8 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
16.0,
|
16.0,
|
||||||
1.5,
|
1.5,
|
||||||
15,
|
15,
|
||||||
0.0);
|
0.0,
|
||||||
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike);
|
||||||
var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 };
|
var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 };
|
||||||
var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 };
|
var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 };
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -367,10 +383,10 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime()
|
public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||||
// Act
|
// Act
|
||||||
NpcRuntimeOperations.TryStopOnTargetDefeat(
|
NpcRuntimeOperations.TryStopOnTargetDefeat(
|
||||||
|
|
@ -390,10 +406,10 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated()
|
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture();
|
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
|
||||||
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
Advance(T0, runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth);
|
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
|
||||||
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||||
NpcRuntimeOperations.TryStopOnTargetDefeat(
|
NpcRuntimeOperations.TryStopOnTargetDefeat(
|
||||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
|
|
@ -401,7 +417,37 @@ public sealed class NpcRuntimeOperationsTests
|
||||||
threat,
|
threat,
|
||||||
runtime);
|
runtime);
|
||||||
// Act
|
// 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
|
// Assert
|
||||||
playerHealth.TryGet("dev-local-1", out var snapshot);
|
playerHealth.TryGet("dev-local-1", out var snapshot);
|
||||||
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
|
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
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>
|
/// <summary>Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91).</summary>
|
||||||
public static class PrototypeNpcTestFixtures
|
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()
|
public static INpcBehaviorDefinitionRegistry CreateBehaviorRegistry()
|
||||||
{
|
{
|
||||||
var byId = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
var byId = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||||
|
|
@ -19,7 +79,8 @@ public static class PrototypeNpcTestFixtures
|
||||||
16.0,
|
16.0,
|
||||||
1.5,
|
1.5,
|
||||||
15,
|
15,
|
||||||
3.0),
|
3.0,
|
||||||
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike),
|
||||||
[PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new(
|
[PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new(
|
||||||
PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
|
PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
|
||||||
"Ranged Control",
|
"Ranged Control",
|
||||||
|
|
@ -29,7 +90,8 @@ public static class PrototypeNpcTestFixtures
|
||||||
20.0,
|
20.0,
|
||||||
2.0,
|
2.0,
|
||||||
12,
|
12,
|
||||||
4.0),
|
4.0,
|
||||||
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot),
|
||||||
[PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new(
|
[PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new(
|
||||||
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
|
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
|
||||||
"Elite Mini-Boss",
|
"Elite Mini-Boss",
|
||||||
|
|
@ -39,7 +101,8 @@ public static class PrototypeNpcTestFixtures
|
||||||
18.0,
|
18.0,
|
||||||
2.5,
|
2.5,
|
||||||
25,
|
25,
|
||||||
5.0),
|
5.0,
|
||||||
|
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam),
|
||||||
};
|
};
|
||||||
|
|
||||||
var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1);
|
var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1);
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ public sealed record AbilityDefRow(
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
int BaseDamage,
|
int BaseDamage,
|
||||||
double CooldownSeconds,
|
double CooldownSeconds,
|
||||||
|
double MaxRange,
|
||||||
string? AbilityKind);
|
string? AbilityKind);
|
||||||
|
|
|
||||||
|
|
@ -140,11 +140,12 @@ public static class AbilityDefinitionCatalogLoader
|
||||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||||
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
|
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
|
||||||
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
|
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
|
||||||
|
var maxRange = (rowObj["maxRange"] as JsonValue)!.GetValue<double>();
|
||||||
string? abilityKind = null;
|
string? abilityKind = null;
|
||||||
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
|
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
|
||||||
abilityKind = abilityKindValue.GetValue<string>();
|
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)
|
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ public sealed class AbilityDefinitionJson
|
||||||
[JsonPropertyName("cooldownSeconds")]
|
[JsonPropertyName("cooldownSeconds")]
|
||||||
public required double CooldownSeconds { get; init; }
|
public required double CooldownSeconds { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("maxRange")]
|
||||||
|
public required double MaxRange { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("abilityKind")]
|
[JsonPropertyName("abilityKind")]
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? AbilityKind { get; init; }
|
public string? AbilityKind { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ public static class AbilityDefinitionsWorldApi
|
||||||
DisplayName = d.DisplayName,
|
DisplayName = d.DisplayName,
|
||||||
BaseDamage = d.BaseDamage,
|
BaseDamage = d.BaseDamage,
|
||||||
CooldownSeconds = d.CooldownSeconds,
|
CooldownSeconds = d.CooldownSeconds,
|
||||||
|
MaxRange = d.MaxRange,
|
||||||
AbilityKind = d.AbilityKind,
|
AbilityKind = d.AbilityKind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.World;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Combat;
|
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
|
public static class NpcAttackOperations
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies catalog <paramref name="attackDamage"/> to <paramref name="holderPlayerId"/> when
|
/// Applies catalog ability <c>baseDamage</c> to <paramref name="holderPlayerId"/> when the threat row still
|
||||||
/// the threat row still names that holder for <paramref name="npcInstanceId"/>.
|
/// 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>
|
/// </summary>
|
||||||
public static bool TryResolveTelegraphComplete(
|
public static bool TryResolveTelegraphComplete(
|
||||||
string npcInstanceId,
|
string npcInstanceId,
|
||||||
string holderPlayerId,
|
string holderPlayerId,
|
||||||
int attackDamage,
|
string attackAbilityId,
|
||||||
|
IAbilityDefinitionRegistry abilityRegistry,
|
||||||
|
IPositionStateStore positionStore,
|
||||||
IPlayerCombatHealthStore playerHealthStore,
|
IPlayerCombatHealthStore playerHealthStore,
|
||||||
IThreatStateStore threatStore)
|
IThreatStateStore threatStore)
|
||||||
{
|
{
|
||||||
if (attackDamage <= 0)
|
if (!abilityRegistry.TryGetDefinition(attackAbilityId, out var ability))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ability.BaseDamage <= 0)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +39,12 @@ public static class NpcAttackOperations
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -41,4 +56,25 @@ public static class NpcAttackOperations
|
||||||
|
|
||||||
return true;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ using System.Collections.Frozen;
|
||||||
namespace NeonSprawl.Server.Game.Combat;
|
namespace NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</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_e5m1_ability_gate</c>.
|
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c>, <c>PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS</c>, and
|
||||||
|
/// <c>_prototype_ability_catalog_gate</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PrototypeE5M1AbilityCatalogRules
|
public static class PrototypeE5M1AbilityCatalogRules
|
||||||
{
|
{
|
||||||
|
|
@ -18,15 +19,29 @@ public static class PrototypeE5M1AbilityCatalogRules
|
||||||
],
|
],
|
||||||
StringComparer.Ordinal);
|
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)
|
public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> abilityIdToSourceFile)
|
||||||
{
|
{
|
||||||
var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||||
if (!ids.SetEquals(ExpectedAbilityIds))
|
if (!ids.SetEquals(ExpectedCatalogAbilityIds))
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
"error: prototype E5M1 expects exactly ability ids " +
|
"error: prototype ability catalog expects exactly ability ids " +
|
||||||
$"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
$"[{string.Join(", ", ExpectedCatalogAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Npc;
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
@ -29,7 +30,17 @@ public static class NpcBehaviorCatalogServiceCollectionExtensions
|
||||||
opts.NpcBehaviorDefSchemaPath,
|
opts.NpcBehaviorDefSchemaPath,
|
||||||
hostEnv.ContentRootPath);
|
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 =>
|
services.AddSingleton<INpcBehaviorDefinitionRegistry>(sp =>
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow(
|
||||||
double LeashRadius,
|
double LeashRadius,
|
||||||
double TelegraphWindupSeconds,
|
double TelegraphWindupSeconds,
|
||||||
int AttackDamage,
|
int AttackDamage,
|
||||||
double AttackCooldownSeconds);
|
double AttackCooldownSeconds,
|
||||||
|
string AttackAbilityId);
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ public static class NpcBehaviorDefinitionCatalogLoader
|
||||||
var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>();
|
var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>();
|
||||||
var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
|
var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
|
||||||
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
|
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
|
||||||
|
var attackAbilityId = (rowObj["attackAbilityId"] as JsonValue)!.GetValue<string>();
|
||||||
|
|
||||||
return new NpcBehaviorDefRow(
|
return new NpcBehaviorDefRow(
|
||||||
id,
|
id,
|
||||||
|
|
@ -162,7 +163,8 @@ public static class NpcBehaviorDefinitionCatalogLoader
|
||||||
leashRadius,
|
leashRadius,
|
||||||
telegraphWindupSeconds,
|
telegraphWindupSeconds,
|
||||||
attackDamage,
|
attackDamage,
|
||||||
attackCooldownSeconds);
|
attackCooldownSeconds,
|
||||||
|
attackAbilityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson
|
||||||
|
|
||||||
[JsonPropertyName("attackCooldownSeconds")]
|
[JsonPropertyName("attackCooldownSeconds")]
|
||||||
public required double AttackCooldownSeconds { get; init; }
|
public required double AttackCooldownSeconds { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("attackAbilityId")]
|
||||||
|
public required string AttackAbilityId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ public static class NpcBehaviorDefinitionsWorldApi
|
||||||
TelegraphWindupSeconds = d.TelegraphWindupSeconds,
|
TelegraphWindupSeconds = d.TelegraphWindupSeconds,
|
||||||
AttackDamage = d.AttackDamage,
|
AttackDamage = d.AttackDamage,
|
||||||
AttackCooldownSeconds = d.AttackCooldownSeconds,
|
AttackCooldownSeconds = d.AttackCooldownSeconds,
|
||||||
|
AttackAbilityId = d.AttackAbilityId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Npc;
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
|
@ -20,7 +21,9 @@ public static class NpcRuntimeOperations
|
||||||
INpcRuntimeStateStore runtimeStore,
|
INpcRuntimeStateStore runtimeStore,
|
||||||
IThreatStateStore threatStore,
|
IThreatStateStore threatStore,
|
||||||
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||||
|
IAbilityDefinitionRegistry abilityRegistry,
|
||||||
ICombatEntityHealthStore combatHealthStore,
|
ICombatEntityHealthStore combatHealthStore,
|
||||||
|
IPositionStateStore positionStore,
|
||||||
IPlayerCombatHealthStore playerHealthStore,
|
IPlayerCombatHealthStore playerHealthStore,
|
||||||
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
|
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
|
||||||
{
|
{
|
||||||
|
|
@ -41,7 +44,9 @@ public static class NpcRuntimeOperations
|
||||||
runtimeStore,
|
runtimeStore,
|
||||||
threatStore,
|
threatStore,
|
||||||
behaviorRegistry,
|
behaviorRegistry,
|
||||||
|
abilityRegistry,
|
||||||
combatHealthStore,
|
combatHealthStore,
|
||||||
|
positionStore,
|
||||||
playerHealthStore);
|
playerHealthStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +66,9 @@ public static class NpcRuntimeOperations
|
||||||
runtimeStore,
|
runtimeStore,
|
||||||
threatStore,
|
threatStore,
|
||||||
behaviorRegistry,
|
behaviorRegistry,
|
||||||
|
abilityRegistry,
|
||||||
combatHealthStore,
|
combatHealthStore,
|
||||||
|
positionStore,
|
||||||
playerHealthStore);
|
playerHealthStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +88,9 @@ public static class NpcRuntimeOperations
|
||||||
runtimeStore,
|
runtimeStore,
|
||||||
threatStore,
|
threatStore,
|
||||||
behaviorRegistry,
|
behaviorRegistry,
|
||||||
|
abilityRegistry,
|
||||||
combatHealthStore,
|
combatHealthStore,
|
||||||
|
positionStore,
|
||||||
playerHealthStore);
|
playerHealthStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +133,9 @@ public static class NpcRuntimeOperations
|
||||||
INpcRuntimeStateStore runtimeStore,
|
INpcRuntimeStateStore runtimeStore,
|
||||||
IThreatStateStore threatStore,
|
IThreatStateStore threatStore,
|
||||||
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||||
|
IAbilityDefinitionRegistry abilityRegistry,
|
||||||
ICombatEntityHealthStore combatHealthStore,
|
ICombatEntityHealthStore combatHealthStore,
|
||||||
|
IPositionStateStore positionStore,
|
||||||
IPlayerCombatHealthStore playerHealthStore)
|
IPlayerCombatHealthStore playerHealthStore)
|
||||||
{
|
{
|
||||||
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
|
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
|
||||||
|
|
@ -251,7 +262,9 @@ public static class NpcRuntimeOperations
|
||||||
NpcAttackOperations.TryResolveTelegraphComplete(
|
NpcAttackOperations.TryResolveTelegraphComplete(
|
||||||
npcInstanceId,
|
npcInstanceId,
|
||||||
aggroHolderPlayerId,
|
aggroHolderPlayerId,
|
||||||
behavior.AttackDamage,
|
behavior.AttackAbilityId,
|
||||||
|
abilityRegistry,
|
||||||
|
positionStore,
|
||||||
playerHealthStore,
|
playerHealthStore,
|
||||||
threatStore);
|
threatStore);
|
||||||
WriteState(
|
WriteState(
|
||||||
|
|
@ -272,7 +285,9 @@ public static class NpcRuntimeOperations
|
||||||
NpcAttackOperations.TryResolveTelegraphComplete(
|
NpcAttackOperations.TryResolveTelegraphComplete(
|
||||||
npcInstanceId,
|
npcInstanceId,
|
||||||
aggroHolderPlayerId,
|
aggroHolderPlayerId,
|
||||||
behavior.AttackDamage,
|
behavior.AttackAbilityId,
|
||||||
|
abilityRegistry,
|
||||||
|
positionStore,
|
||||||
playerHealthStore,
|
playerHealthStore,
|
||||||
threatStore);
|
threatStore);
|
||||||
WriteState(
|
WriteState(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Npc;
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
|
@ -14,7 +15,9 @@ public static class NpcRuntimeSnapshotWorldApi
|
||||||
INpcRuntimeStateStore runtimeStore,
|
INpcRuntimeStateStore runtimeStore,
|
||||||
IThreatStateStore threatStore,
|
IThreatStateStore threatStore,
|
||||||
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||||
|
IAbilityDefinitionRegistry abilityRegistry,
|
||||||
ICombatEntityHealthStore combatHealthStore,
|
ICombatEntityHealthStore combatHealthStore,
|
||||||
|
IPositionStateStore positionStore,
|
||||||
IPlayerCombatHealthStore playerHealthStore) =>
|
IPlayerCombatHealthStore playerHealthStore) =>
|
||||||
{
|
{
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
|
|
@ -23,7 +26,9 @@ public static class NpcRuntimeSnapshotWorldApi
|
||||||
runtimeStore,
|
runtimeStore,
|
||||||
threatStore,
|
threatStore,
|
||||||
behaviorRegistry,
|
behaviorRegistry,
|
||||||
|
abilityRegistry,
|
||||||
combatHealthStore,
|
combatHealthStore,
|
||||||
|
positionStore,
|
||||||
playerHealthStore);
|
playerHealthStore);
|
||||||
return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
|
return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using System.Collections.Frozen;
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Npc;
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -47,4 +49,30 @@ public static class PrototypeE5M2NpcBehaviorCatalogRules
|
||||||
|
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ On success, **Information** logs include the resolved recipes directory path, di
|
||||||
|
|
||||||
## Ability catalog (`content/abilities`, NEO-77)
|
## 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 |
|
| Config | Meaning |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
|
|
@ -108,7 +108,7 @@ On success, **Information** logs include the resolved npc-behaviors directory pa
|
||||||
|
|
||||||
## NPC behavior definitions (NEO-90)
|
## 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
|
```bash
|
||||||
curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions"
|
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 |
|
| 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. |
|
| **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. |
|
| **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). |
|
| **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. |
|
| **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. |
|
| **`idle`** | No aggro holder; no telegraph. |
|
||||||
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
|
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
|
||||||
| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. |
|
| **`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. |
|
| **`recover`** | Post-attack cooldown wait before next windup. |
|
||||||
|
|
||||||
| Rule | Behavior |
|
| 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"
|
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)
|
## 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 |
|
| Rule | Behavior |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. |
|
| **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). |
|
| **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. |
|
| **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. |
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue