Wire QuestStateOperations.TryAccept at POST /game/players/{id}/quests/{questId}/accept
with shared quest row projection, integration tests, and Bruno accept flows.
|
||
|---|---|---|
| .. | ||
| NeonSprawl.Server | ||
| NeonSprawl.Server.Tests | ||
| db/migrations | ||
| README.md | ||
README.md
Game server (NeonSprawl.Server)
ASP.NET Core .NET 10 host for authoritative simulation (prototype: HTTP + health + position state via in-memory or PostgreSQL).
Prerequisites
- .NET 10 SDK (matches
TargetFrameworkinNeonSprawl.Server.csproj). Check withdotnet --list-sdks.
Run
cd server/NeonSprawl.Server
dotnet run
- Default URL is printed by Kestrel (see
Properties/launchSettings.json, typicallyhttp://localhost:5253). - Check
GET /healthfor a JSON heartbeat.
Optional prototype API stdout (manual QA / dev): set NEON_SPRAWL_API_LOG to 1, true, yes, or on (case-insensitive). While enabled, each POST …/ability-cast that returns JSON AbilityCastResponse (HTTP 200) prints one line to the process console: either ability_cast_requested (accept) or ability_cast_denied with reason= (deny). 400 / 404 paths do not emit these lines. Implementation: Diagnostics/PrototypeApiConsoleLog.cs (wired from AbilityCastApi). Example:
NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj
IDE: use launch profile http+apiLog (same URL as http, sets the variable).
Skill catalog (content/skills, NEO-34)
On startup the host loads every *_skills.json under the skills directory, validates each row against content/schemas/skill-def.schema.json, rejects duplicate id values across files, and enforces the prototype Slice 1 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.
| Config | Meaning |
|---|---|
Content:SkillsDirectory |
Optional. Absolute path, or path relative to the server content root (the project directory for dotnet run). When unset, the host walks ancestors of AppContext.BaseDirectory until it finds a content/skills directory (works for dotnet run from server/NeonSprawl.Server without extra config). |
Content:SkillDefSchemaPath |
Optional override for skill-def.schema.json. When unset, resolved as {parent of skills directory}/schemas/skill-def.schema.json (repo layout: content/schemas next to content/skills). |
Docker / CI: mount or copy the repo content/ tree (at least content/skills and content/schemas) and set Content__SkillsDirectory / Content__SkillDefSchemaPath if the layout differs from the default discovery rule.
On success, Information logs include the resolved skills directory path and distinct skill count.
Item catalog (content/items, NEO-51)
On startup the host loads every *_items.json under the items directory, validates each row against content/schemas/item-def.schema.json, requires schemaVersion 1 per file, rejects duplicate id values across files, and enforces the prototype Slice 1 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.
| Config | Meaning |
|---|---|
Content:ItemsDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/items. |
Content:ItemDefSchemaPath |
Optional override for item-def.schema.json. When unset, {parent of items directory}/schemas/item-def.schema.json. |
Docker / CI: include content/items and content/schemas/item-def.schema.json in the mounted content/ tree; set Content__ItemsDirectory when layout differs.
On success, Information logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use IItemDefinitionRegistry for lookups (NEO-52).
Resource-node catalog (content/resource-nodes, NEO-58)
On startup the host loads every *_resource_nodes.json and *_resource_yields.json under the resource-nodes directory, validates each row against content/schemas/resource-node-def.schema.json and resource-yield-row.schema.json, requires schemaVersion 1 per file, rejects duplicate nodeDefId values across files, cross-checks yield itemId values against the item catalog loaded first, and enforces the prototype Slice 2 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.
| Config | Meaning |
|---|---|
Content:ResourceNodesDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/resource-nodes. |
Content:ResourceNodeDefSchemaPath |
Optional override for resource-node-def.schema.json. When unset, {parent of resource-nodes directory}/schemas/resource-node-def.schema.json. |
Content:ResourceYieldRowSchemaPath |
Optional override for resource-yield-row.schema.json. When unset, {parent of resource-nodes directory}/schemas/resource-yield-row.schema.json. |
Docker / CI: include content/resource-nodes, content/items, and the two resource-node schemas in the mounted content/ tree; set Content__ResourceNodesDirectory when layout differs.
On success, Information logs include the resolved resource-nodes directory path, distinct node count, yield row count, and catalog file counts.
IResourceNodeDefinitionRegistry (NEO-59) is the preferred lookup surface for gather engine, depletion, interact, and HTTP callers — resolve node defs and yield rows by nodeDefId, enumerate defs in id order. The catalog singleton remains for fail-fast startup only; do not inject ResourceNodeCatalog in new game code.
Recipe catalog (content/recipes, NEO-66)
On startup the host loads every *_recipes.json under the recipes directory, validates each row against content/schemas/recipe-def.schema.json (with recipe-io-row.schema.json for I/O $refs), requires schemaVersion 1 per file, rejects duplicate recipe id values across files, cross-checks every input/output itemId against the item catalog and every requiredSkillId against the skill catalog (both loaded first), and enforces the prototype Slice 3 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.
| Config | Meaning |
|---|---|
Content:RecipesDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/recipes. |
Content:RecipeDefSchemaPath |
Optional override for recipe-def.schema.json. When unset, {parent of recipes directory}/schemas/recipe-def.schema.json. |
Content:RecipeIoRowSchemaPath |
Optional override for recipe-io-row.schema.json. When unset, {parent of recipes directory}/schemas/recipe-io-row.schema.json. |
Docker / CI: include content/recipes, content/items, content/skills, and the two recipe schemas in the mounted content/ tree; set Content__RecipesDirectory when layout differs.
On success, Information logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use IRecipeDefinitionRegistry for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject RecipeDefinitionCatalog in new game code.
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 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 |
|---|---|
Content:AbilitiesDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/abilities. |
Content:AbilityDefSchemaPath |
Optional override for ability-def.schema.json. When unset, {parent of abilities directory}/schemas/ability-def.schema.json. |
Docker / CI: include content/abilities and content/schemas/ability-def.schema.json in the mounted content/ tree; set Content__AbilitiesDirectory when layout differs.
On success, Information logs include the resolved abilities directory path, distinct ability count, and catalog file count. Game code should use IAbilityDefinitionRegistry for lookups (NEO-79). The catalog singleton remains for fail-fast startup only; do not inject AbilityDefinitionCatalog in new game code. Hotbar loadout and ability cast routes validate ability ids via IAbilityDefinitionRegistry.TryNormalizeKnown (backed by the loaded catalog, not a separate static allowlist).
NPC behavior catalog (content/npc-behaviors, NEO-88)
On startup the host loads every *_npc_behaviors.json under the npc-behaviors directory, validates each row against content/schemas/npc-behavior-def.schema.json, requires schemaVersion 1 per file, rejects duplicate id values across files, enforces the prototype E5M2 three-id roster gate, and requires leashRadius > aggroRadius per row (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 |
|---|---|
Content:NpcBehaviorsDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/npc-behaviors. |
Content:NpcBehaviorDefSchemaPath |
Optional override for npc-behavior-def.schema.json. When unset, {parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json. |
Docker / CI: include content/npc-behaviors and content/schemas/npc-behavior-def.schema.json in the mounted content/ tree; set Content__NpcBehaviorsDirectory when layout differs.
On success, Information logs include the resolved npc-behaviors directory path, distinct behavior count, and catalog file count. Game code should use INpcBehaviorDefinitionRegistry for lookups (TryGetDefinition, TryNormalizeKnown, GetDefinitionsInIdOrder; NEO-89). The catalog singleton remains for fail-fast startup only; do not inject NpcBehaviorDefinitionCatalog in new game code. Stable prototype behavior id constants: PrototypeNpcBehaviorRegistry.
Reward table catalog (content/reward-tables, NEO-101)
On startup the host loads every *_reward_tables.json under the reward-tables directory, validates each row against content/schemas/reward-table.schema.json (with reward-grant-row.schema.json for grant $refs), requires schemaVersion 1 per file, rejects duplicate reward-table id values across files, cross-checks every fixedGrants[].itemId against the item catalog (loaded first), rejects duplicate itemId within a table, and enforces the prototype E5M3 one-table roster + frozen grant quantity 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.
| Config | Meaning |
|---|---|
Content:RewardTablesDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/reward-tables. |
Content:RewardTableDefSchemaPath |
Optional override for reward-table.schema.json. When unset, {parent of reward-tables directory}/schemas/reward-table.schema.json. |
Content:RewardGrantRowSchemaPath |
Optional override for reward-grant-row.schema.json. When unset, {parent of reward-tables directory}/schemas/reward-grant-row.schema.json. |
Docker / CI: include content/reward-tables, content/items, and the two reward schemas in the mounted content/ tree; set Content__RewardTablesDirectory when layout differs.
On success, Information logs include the resolved reward-tables directory path, distinct reward-table count, and catalog file count. Game code should use IRewardTableDefinitionRegistry for lookups (TryGetDefinition, TryNormalizeKnown, GetDefinitionsInIdOrder; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject RewardTableDefinitionCatalog in new game code. TryGetDefinition is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use TryNormalizeKnown (trim + lowercase + fail-closed lookup), same as ability/hotbar routes.
Encounter catalog (content/encounters, NEO-101)
On startup the host loads every *_encounters.json under the encounters directory after the reward-table catalog, validates each row against content/schemas/encounter-def.schema.json, requires schemaVersion 1 per file, rejects duplicate encounter id values across files, cross-checks each rewardTableId against loaded reward-table ids and each requiredNpcInstanceIds set against PrototypeNpcRegistry, and enforces the prototype E5M3 one-encounter 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.
| Config | Meaning |
|---|---|
Content:EncountersDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/encounters. |
Content:EncounterDefSchemaPath |
Optional override for encounter-def.schema.json. When unset, {parent of encounters directory}/schemas/encounter-def.schema.json. |
Docker / CI: include content/encounters, content/reward-tables, and encounter-def.schema.json in the mounted content/ tree; set Content__EncountersDirectory when layout differs.
On success, Information logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use IEncounterDefinitionRegistry for lookups (TryGetDefinition, TryNormalizeKnown, GetDefinitionsInIdOrder; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject EncounterDefinitionCatalog in new game code. TryGetDefinition is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use TryNormalizeKnown (trim + lowercase + fail-closed lookup), same as ability/hotbar routes.
Quest catalog (content/quests, NEO-113)
On startup the host loads every *_quests.json under the quests directory after the item, recipe, and encounter catalogs, validates each row against content/schemas/quest-def.schema.json (with quest-step-def.schema.json and quest-objective-def.schema.json for nested $refs), requires schemaVersion 1 per file, rejects duplicate quest / step / objective id values (step and objective uniqueness is per quest), cross-checks objective itemId / recipeId / encounterId against loaded catalogs, and enforces the prototype E7M1 four-quest roster, acyclic prerequisiteQuestIds, and operator-chain terminal inventory_has_item contract_handoff_token ×1 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.
| Config | Meaning |
|---|---|
Content:QuestsDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/quests. |
Content:QuestDefSchemaPath |
Optional override for quest-def.schema.json. When unset, {parent of quests directory}/schemas/quest-def.schema.json. |
Content:QuestStepDefSchemaPath |
Optional override for quest-step-def.schema.json. When unset, {parent of quests directory}/schemas/quest-step-def.schema.json. |
Content:QuestObjectiveDefSchemaPath |
Optional override for quest-objective-def.schema.json. When unset, {parent of quests directory}/schemas/quest-objective-def.schema.json. |
Docker / CI: include content/quests, upstream catalogs (content/items, content/recipes, content/encounters), and the three quest schemas in the mounted content/ tree; set Content__QuestsDirectory when layout differs.
On success, Information logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use IQuestDefinitionRegistry for lookups (TryGetDefinition, TryNormalizeKnown, GetDefinitionsInIdOrder; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject QuestDefinitionCatalog in new game code. TryGetDefinition is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use TryNormalizeKnown (trim + lowercase + fail-closed lookup), same as encounter/ability routes.
Quest definitions (NEO-115)
GET /game/world/quest-definitions returns a versioned JSON body (schemaVersion 1, quests) backed by IQuestDefinitionRegistry — the same prototype rows loaded at startup (no second source of truth). Each row includes id, displayName, prerequisiteQuestIds, and nested steps (id, displayName, objectives with flat objective fields per kind — unused keys omitted). Plan: NEO-115 implementation plan; Bruno: bruno/neon-sprawl-server/quest-definitions/.
curl -sS -i "http://localhost:5253/game/world/quest-definitions"
Quest progress store (NEO-116)
Per-player quest runtime state lives in IPlayerQuestStateStore, keyed by (playerId, questId). A missing row means not_started; TryActivate creates an active row at step index 0 with empty objective counters. Game code should call QuestStateOperations (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP accept lands in E7M1-09 (NEO-120).
Store interface methods:
CanWritePlayer— whether mutations are allowed for the player (dev bucket / Postgresplayer_position).TryGetProgress— read one row; false when not started.TryActivate— first accept (not_started→active).TryAdvanceStep— bump step index and clear counters for the new step (denies when completed or index does not increase).TryUpdateObjectiveCounter— set one objective counter on the current step (non-negative).TryMarkComplete— first completion returnstrue; store replay returnsfalsewithout changingcompletedAt.
Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids are validated via IQuestDefinitionRegistry.TryNormalizeKnown in QuestStateOperations.
Storage: in-memory singleton when ConnectionStrings:NeonSprawl is unset (seeds configured dev player only). When Postgres is configured, PostgresPlayerQuestStateStore persists to player_quest_progress (V008__player_quest_progress.sql) with objective_counters JSONB for the current step. Plan: NEO-116 implementation plan. Bruno: bruno/neon-sprawl-server/quest-progress/.
Quest state operations (NEO-117)
QuestStateOperations (static) wraps the store with catalog validation and structured reasonCode denials. Returns QuestStateOperationResult (success, optional reasonCode, optional progress snapshot).
| Method | Role |
|---|---|
TryAccept |
Validates quest id + prerequisiteQuestIds (each prerequisite must be completed), then activates at step 0. |
TryAdvanceStep |
Requires active row; newStepIndex must be greater than current and less than step count from QuestDefRow. |
TryMarkComplete |
Marks active quest complete. Idempotent: second call returns success: true with unchanged snapshot (unlike encounter completion). |
Reason codes:
| Code | When |
|---|---|
unknown_quest |
Quest id not in catalog (accept, advance, complete). |
prerequisite_incomplete |
Accept when a listed prerequisite is not completed (implicit not_started fails). |
already_completed |
Accept or advance when row is already completed. |
already_active |
Accept when row is already active. |
unknown_player |
Player id cannot be written (no dev bucket / missing from Postgres player_position). |
not_active |
Advance or complete when quest was never accepted or is not active. |
invalid_step_index |
Advance when index does not increase or is past the last step. |
Quest objective wiring (NEO-118)
QuestObjectiveWiring (static, best-effort) evaluates active quests on server-side gather/craft/encounter success and on inventory_has_item snapshot passes. When every objective on the current step is satisfied, wiring calls QuestStateOperations.TryAdvanceStep or TryMarkComplete (terminal step). Failures are logged at debug and never fail the primary gather/craft/encounter operation.
| Hook site | Objective kind | Trigger |
|---|---|---|
GatherOperations.TryGather success |
gather_item |
Item id + yield quantity credited to matching active quests; counter capped at objective quantity. |
CraftOperations.TryCraft success |
craft_recipe |
Recipe id + batch quantity. |
EncounterCompletionOperations.TryCompleteAndGrant success |
encounter_complete |
Encounter id (counter → 1). |
Inventory mutations above + QuestStateOperations.TryAccept / TryAdvanceStep |
inventory_has_item |
Bag snapshot read; counter set to min(held, required). |
inventory_has_item is not re-evaluated for other objective kinds on accept/step entry (event-driven only for gather/craft/encounter). Plan: NEO-118 implementation plan.
Per-player quest progress (NEO-119)
GET /game/players/{id}/quest-progress returns a versioned JSON body (schemaVersion 1, playerId, quests) backed by IQuestDefinitionRegistry + IPlayerQuestStateStore. Unknown or blank player ids return 404 (position gate, same as encounter-progress). Each row includes questId, status (not_started | active | completed), currentStepIndex, objectiveCounters (objective id → count for the current step only), and optional completedAt when completed. Quests are ordered by catalog id (ordinal).
Before building the snapshot, the handler best-effort runs QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer — refreshes inventory_has_item counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: NEO-119 implementation plan.
curl -sS "http://localhost:5253/game/players/dev-local-1/quest-progress"
Sample default response (no quests accepted):
{"schemaVersion":1,"playerId":"dev-local-1","quests":[{"questId":"prototype_quest_combat_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_gather_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_operator_chain","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_refine_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}}]}
Encounter definitions (NEO-103)
GET /game/world/encounter-definitions returns a versioned JSON body (schemaVersion 1, encounters) backed by IEncounterDefinitionRegistry and IRewardTableDefinitionRegistry — the same prototype rows loaded at startup (no second source of truth). Each row includes id, displayName, nested completionCriteria (kind), requiredNpcInstanceIds, and nested rewardTable (id, displayName, fixedGrants with itemId + quantity). Plan: NEO-103 implementation plan; Bruno: bruno/neon-sprawl-server/encounter-definitions/.
curl -sS -i "http://localhost:5253/game/world/encounter-definitions"
Encounter progress + completion stores (NEO-104)
Per-player encounter state is server-owned in IEncounterProgressStore (activation + defeated required NPC ids) and IEncounterCompletionStore (idempotent completedAt flag after rewards — marking used by NEO-105). Prototype uses in-memory singletons registered in AddEncounterAndRewardCatalogs.
EncounterProgressOperations (static):
TryActivateOnFirstEngagement(playerId, npcInstanceId, …)— resolves encounter from NPC viaIEncounterDefinitionRegistry, starts progress when not completed.TryMarkTargetDefeated(…)— requires prior activation; accumulates defeats in any order; idempotent per NPC.IsAllRequiredTargetsDefeated(playerId, encounterId, …)— set equality vs catalogrequiredNpcInstanceIds.
Per-player HTTP read: see Per-player encounter progress (NEO-108). Plan: NEO-104 implementation plan.
Combat wiring (NEO-106): see Encounter combat wiring (NEO-106) — EncounterCombatWiring on POST …/ability-cast accept path.
Encounter completion + inventory grants (NEO-105)
EncounterCompletionOperations.TryCompleteAndGrant applies a reward table’s fixedGrants via PlayerInventoryOperations when all required NPCs are defeated, then marks IEncounterCompletionStore idempotently. On success, records EncounterCompleteEvent in IEncounterCompleteEventStore and returns the same payload in EncounterCompletionResult.
| Reason code | When |
|---|---|
already_completed |
Encounter already marked complete for this player. |
not_ready |
Not all requiredNpcInstanceIds defeated yet. |
unknown_encounter |
encounterId not in IEncounterDefinitionRegistry. |
unknown_reward_table |
Encounter’s rewardTableId missing or empty grants. |
inventory_full |
Pre-flight or apply could not place full grant quantities. |
inventory_store_missing |
Player inventory store could not read/write. |
Prototype first completion grants scrap_metal_bulk ×10 and contract_handoff_token ×1 from prototype_combat_pocket_clear. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Wired from AbilityCastApi via EncounterCombatWiring (NEO-106). Plan: NEO-105 implementation plan.
Encounter complete event record (NEO-107)
IEncounterCompleteEventStore persists EncounterCompleteEvent exactly once per player+encounter when grants commit successfully. Prototype uses InMemoryEncounterCompleteEventStore (registered in AddEncounterAndRewardCatalogs alongside progress/completion stores).
| Field | Role |
|---|---|
PlayerId, EncounterId |
Normalized keys (same as progress/completion stores). |
RewardTableId |
Catalog reward table applied. |
GrantedItems |
Snapshot of successful fixed grants at commit time. |
CompletedAt |
UTC timestamp from TimeProvider at completion mark. |
IdempotencyKey |
Stable {playerId}:{encounterId} — future RewardDeliveryEvent dedupe input for E7.M2. |
E7.M2 hook (comment-only): after TryRecord, EncounterCompletionOperations documents the future QuestRewardBundle / reward_delivery consumer — prototype quest credit is contract_handoff_token item loot plus this event record; full router deferred to E7.M2. If TryRecord returns false after a fresh completion mark (unexpected race), grants and completion flag are retained; treat as idempotent success.
Plan: NEO-107 implementation plan.
Per-player encounter progress (NEO-108)
GET /game/players/{id}/encounter-progress returns a versioned JSON body (schemaVersion 1, playerId, encounters) projecting in-memory stores — no client-side defeat inference. Unknown players return 404 (same IPositionStateStore gate as gig/skill progression).
Each row (catalog order via IEncounterDefinitionRegistry.GetDefinitionsInIdOrder()):
| Field | Source |
|---|---|
encounterId |
Catalog encounter id. |
state |
inactive (no progress), active (started, not completed), completed. |
defeatedTargetIds |
IEncounterProgressStore defeated NPC ids (ordinal sort); [] when inactive. |
completedAt |
IEncounterCompletionStore when completed; omitted otherwise. |
rewardGrantSummary |
IEncounterCompleteEventStore.GrantedItems when completed; omitted otherwise. |
Prototype: defeat all three E5.M2 NPCs via cast → row prototype_combat_pocket becomes completed with scrap_metal_bulk ×10 and contract_handoff_token ×1 in rewardGrantSummary. Plan: NEO-108 implementation plan; Bruno: bruno/neon-sprawl-server/encounter-progress/.
curl -sS -i "http://localhost:5253/game/players/dev-local-1/encounter-progress"
Encounter telemetry hooks (NEO-109)
Comment-only hook sites for Epic 5 Slice 3 catalog events (epic_05 Slice 3). TODO(E9.M1) — no production ingest. POST …/ability-cast and EncounterCombatWiring delegate to the engines below (no duplicate API-layer hooks).
| Event | Anchor |
|---|---|
encounter_start |
EncounterProgressOperations.TryActivateOnFirstEngagement — hook anchor after successful TryActivate (first engagement only). Activates NEO-84 reserved encounter_start pointer in CombatOperations. |
encounter_complete |
EncounterCompletionOperations.TryCompleteAndGrant — once per successful grant + completion mark commit. |
reward_attribution |
Same success path — once per completion (batch grantedItems); per-stack item_created remains in PlayerInventoryOperations (NEO-56). |
encounter_complete_denied |
EncounterCompletionOperations.Deny — structured deny + EncounterCompletionReasonCodes. |
Plan: NEO-109 implementation plan.
Encounter combat wiring (NEO-106)
On every successful POST …/ability-cast accept, EncounterCombatWiring.TryProcessCastOutcome (best-effort; does not change cast JSON):
| Cast outcome | Encounter effect |
|---|---|
damageDealt > 0 vs a required NPC |
EncounterProgressOperations.TryActivateOnFirstEngagement — starts prototype_combat_pocket progress (encounter_start hook site, NEO-109). |
targetDefeated: true |
TryMarkTargetDefeated; when all three E5.M2 NPC ids defeated, EncounterCompletionOperations.TryCompleteAndGrant. |
| Inventory full on completion | Defeat progress retained; completion/grants fail closed (retry when bag has space). |
Call order on lethal accept: aggro → cooldown → NPC runtime stop → CombatDefeatGigXpGrant (NEO-44) → EncounterCombatWiring. Gig XP per defeat is unchanged (25 to breach each). Verify loot with GET …/inventory or GET …/encounter-progress (NEO-108). Plan: NEO-106 implementation plan.
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, attackCooldownSeconds, and attackAbilityId. Plan: NEO-90 implementation plan; Bruno: bruno/neon-sprawl-server/npc-behavior-definitions/.
curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions"
Ability definitions (NEO-78)
GET /game/world/ability-definitions returns a versioned JSON body (schemaVersion 1, abilities) backed by IAbilityDefinitionRegistry — the same prototype rows loaded at startup (no second source of truth). Each row includes id, displayName, baseDamage, cooldownSeconds, and abilityKind when present in content. Plan: NEO-78 implementation plan; Bruno: bruno/neon-sprawl-server/ability-definitions/.
curl -sS -i "http://localhost:5253/game/world/ability-definitions"
Combat entity health (NEO-80, NEO-91)
Server-owned HP for prototype NPC combat instances lives in Game/Combat/ as ICombatEntityHealthStore + InMemoryCombatEntityHealthStore. Rows are keyed by PrototypeNpcRegistry instance ids (prototype_npc_melee, prototype_npc_ranged, prototype_npc_elite) only. Max HP comes from each instance's bound NpcBehaviorDef.maxHp via INpcBehaviorDefinitionRegistry (melee 100, ranged 80, elite 200 — four prototype_pulse casts defeat melee).
| Policy | Behavior |
|---|---|
| Init | Lazy on first TryGet or TryApplyDamage for a known registry id; currentHp starts at catalog maxHp. |
| Damage | TryApplyDamage floors HP at zero; defeated when currentHp == 0. Re-hit on defeated targets still applies at store layer (HP stays 0); structured deny is NEO-81 (CombatOperations). |
| Reset | Server process restart clears all rows (in-memory only, not persisted). TryResetToFull revives an instance for tests/fixtures — not exposed over HTTP in Slice 1. |
| HTTP read | GET /game/world/combat-targets (NEO-83) — authoritative HP snapshot for client HUD; cast wiring is NEO-82. |
Plan: NEO-80 implementation plan; instance registry migration: NEO-91 implementation plan; HTTP read: NEO-83.
Breaking change (NEO-91): prototype_target_alpha / prototype_target_beta are removed. Godot markers and tab cycle must migrate in NEO-97 (client/scripts/prototype_target_constants.gd).
Combat targets snapshot (NEO-83, NEO-91)
GET /game/world/combat-targets returns a versioned JSON body (schemaVersion 1, targets) backed by ICombatEntityHealthStore — the same authoritative HP rows mutated by cast resolution (no client-side damage math). Each row includes targetId, maxHp, currentHp, and defeated. All three PrototypeNpcRegistry instances are always returned in ascending targetId order; lazy-init on first read matches NEO-80/NEO-91. Plan: NEO-83 implementation plan; NEO-91 implementation plan; Bruno: bruno/neon-sprawl-server/combat-targets/.
| Field | Meaning |
|---|---|
targetId |
Lowercase NPC instance id (prototype_npc_elite, prototype_npc_melee, prototype_npc_ranged). |
maxHp |
Catalog max from bound behavior def (200 / 100 / 80). |
currentHp |
Authoritative HP after cast-applied damage; floored at zero. |
defeated |
true when currentHp is 0. |
curl -sS -i "http://localhost:5253/game/world/combat-targets"
Dev combat-target fixture (Bruno/manual QA): When Game:EnableCombatTargetFixtureApi is true (default in Development via appsettings.Development.json, Testing, or CI Bruno step) or the host environment is Development / Testing, POST /game/__dev/combat-targets-fixture with schemaVersion 1 resets all PrototypeNpcRegistry instances to catalog full HP via TryResetToFull, clears all prototype aggro holders (NEO-92), resets NPC runtime behavior rows to idle (NEO-93), restores dev player combat HP to full (NEO-95), and clears in-memory encounter progress/completion/event rows for the dev player across catalog encounters (NEO-108 Bruno). 404 when disabled. Bruno: scripts/combat-targets-reset-helper.js (request scripts use ./scripts/… relative to the collection).
Threat / aggro state (NEO-92)
Per-NPC aggro holders live in Game/Npc/ as IThreatStateStore + InMemoryThreatStateStore, keyed by PrototypeNpcRegistry instance id. Each row stores an optional AggroHolderPlayerId (lowercase route player id).
| Rule | Behavior |
|---|---|
| Acquire | First damaging cast on an NPC sets holder to the casting player when the row is empty (AggroOperations.TryAcquire from AbilityCastApi). Zero-damage abilities do not acquire. |
| Attack range | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog maxRange of the NPC anchor (horizontal X/Z, inclusive). Melee strike 2 m, ranged shot 10 m, elite slam 4 m — distinct from behavior aggroRadius (re-aggro) and tab-lock 6 m. Out-of-range windups whiff (no damage; state still advances). |
| Leash clear | Holder clears when that player moves beyond the bound behavior def leashRadius from the NPC anchor (horizontal X/Z distance). Wired on POST /move, POST /move-stream, and before acquire on cast. |
| Defeat clear | When ICombatEntityHealthStore marks the NPC defeated, NpcRuntimeOperations.TryStopOnTargetDefeat clears holder and resets runtime to idle (cast accept + lazy advance guard). |
| Re-aggro | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
| HTTP read | GET /game/world/npc-runtime-snapshot — aggroHolderPlayerId per row (NEO-94). |
Plan: NEO-92 implementation plan.
NPC runtime behavior state (NEO-93)
Per-NPC behavior states live in Game/Npc/ as INpcRuntimeStateStore + InMemoryNpcRuntimeStateStore, keyed by PrototypeNpcRegistry instance id. NpcRuntimeOperations.AdvanceAll drives lazy tick advance (monotonic clock, per-call delta cap 5.0 s) using catalog attackCooldownSeconds and telegraphWindupSeconds.
| State | Meaning |
|---|---|
idle |
No aggro holder; no telegraph. |
aggro |
Holder set; waiting attackCooldownSeconds before windup. |
telegraph_windup |
Active telegraph; ActiveTelegraphSnapshot on the runtime row. |
attack_execute |
Logical instant during telegraph complete; NpcAttackOperations.TryResolveTelegraphComplete applies the behavior's attackAbilityId baseDamage when holder is within that ability's maxRange (NEO-95 / NEO-98). |
recover |
Post-attack cooldown wait before next windup. |
| Rule | Behavior |
|---|---|
| Holder sync | Empty holder → idle; holder while idle → aggro. No proximity auto-aggro (first-hit holder from NEO-92 only). |
| Lazy advance | AdvanceAll simulates up to 5.0 s per call; invoked from GET /game/world/npc-runtime-snapshot (NEO-94). |
| Fixture reset | Dev combat-target fixture clears runtime rows alongside HP + threat. |
| HTTP read | GET /game/world/npc-runtime-snapshot — per-NPC state, optional nested activeTelegraph (NEO-94). |
Plan: NEO-93 implementation plan.
NPC runtime telemetry hooks (NEO-96)
Comment-only hook sites in NpcRuntimeOperations.AdvanceAll for future E9.M1 catalog events (Epic 5 Slice 2) — telegraph_fired at each telegraph_windup entry (aggro→telegraph and recover→telegraph); npc_state_transition on every committed state change during AdvanceAll (including holder clear → idle). Dev fixture ResetAllPrototypeRows writes idle directly and bypasses AdvanceOne hooks (optional E9.M1 anchor noted on reset path). TODO(E9.M1) — no production ingest. Snapshot GET invokes AdvanceAll before read (no duplicate API-layer hooks). Plan: NEO-96 implementation plan.
NPC runtime snapshot (NEO-94)
GET /game/world/npc-runtime-snapshot returns a versioned JSON body (schemaVersion 1, serverTimeUtc, npcInstances) backed by INpcRuntimeStateStore + IThreatStateStore after NpcRuntimeOperations.AdvanceAll (lazy tick, 5.0 s delta cap). Each row includes npcInstanceId, behaviorDefId, state (stable snake_case), aggroHolderPlayerId, and optional nested activeTelegraph during telegraph_windup. All three PrototypeNpcRegistry instances are always returned in ascending npcInstanceId order. Plan: NEO-94 implementation plan; Bruno: bruno/neon-sprawl-server/npc-runtime-snapshot/.
| Field | Meaning |
|---|---|
npcInstanceId |
Lowercase prototype NPC instance id. |
behaviorDefId |
Frozen behavior catalog id bound to the instance. |
state |
idle, aggro, telegraph_windup, attack_execute, or recover. |
aggroHolderPlayerId |
Lowercase player id from IThreatStateStore, or null. |
activeTelegraph |
Non-null during telegraph_windup: telegraphId, npcInstanceId, windupRemainingSeconds, archetypeKind. |
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, the behavior's attackAbilityId baseDamage applies to the aggro holder when within that ability's maxRange via IPlayerCombatHealthStore (NEO-95).
Session player combat HP (NEO-95)
Session player HP for incoming NPC damage lives in Game/Combat/ as IPlayerCombatHealthStore + InMemoryPlayerCombatHealthStore. Rows are keyed by lowercase player id; lazy-init currentHp / maxHp 100 on first access. No Postgres in Slice 2.
| Rule | Behavior |
|---|---|
| Init | Lazy on first TryGet or TryApplyDamage; starts at 100 HP. |
| Damage | TryApplyDamage floors HP at zero; defeated when currentHp == 0. Applied from NpcAttackOperations.TryResolveTelegraphComplete when telegraph windup completes, aggro holder still matches, and holder is within the attack ability's maxRange of the NPC anchor. |
| Holder re-check | No damage when holder cleared during windup (leash clear). |
| Fixture reset | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. |
GET /game/players/{id}/combat-health returns a versioned JSON body (schemaVersion 1, playerId, maxHp, currentHp, defeated) backed by IPlayerCombatHealthStore. 404 when the player has no position row (same gate as cooldown snapshot). Plan: NEO-95 implementation plan; Bruno: bruno/neon-sprawl-server/combat-health/.
curl -sS -i "http://localhost:5253/game/players/dev-local-1/combat-health"
Client counterpart: NEO-97 polls this GET alongside npc-runtime-snapshot for player HP + telegraph HUD.
Combat engine (NEO-81)
CombatOperations.TryResolve in Game/Combat/ resolves server-internal CombatResult: ability lookup via IAbilityDefinitionRegistry, target HP read via ICombatEntityHealthStore, defeated pre-check (no damage on re-hit), then catalog baseDamage application. Zero-damage abilities (prototype_guard, prototype_dash) succeed without mutating HP. AbilityCastApi (NEO-82) invokes TryResolve after E1.M4 gates and returns nested wire combatResolution on accept.
| Reason code | When |
|---|---|
unknown_ability |
abilityId failed registry normalization (empty/garbage/off-list). |
unknown_target |
targetId not in PrototypeNpcRegistry (health store gate). |
target_defeated |
Target HP is already 0 before resolve; no damage applied (includes zero-damage utility). |
CombatResult fields: success, reasonCode (null on success), damageDealt, targetRemainingHp, targetDefeated. On target_defeated deny, targetRemainingHp is 0 and targetDefeated on the envelope is false — use reasonCode for deny semantics.
Plan: NEO-81 implementation plan; cast wiring: NEO-82.
Combat telemetry hooks (NEO-84)
Comment-only hook sites in CombatOperations.TryResolve for future E9.M1 catalog events (Epic 5 Slice 1). TODO(E9.M1) — no production ingest. HTTP POST …/ability-cast calls the engine only (no duplicate API-layer hooks for these names).
ability_used— on every successful resolve (zero-damage abilities included).enemy_defeat— only when lethal damage drivesafter.Defeatedtotrue(re-hit on defeated targets denies attarget_defeatedbefore damage).- Reserved (comments only):
encounter_start— E5.M3 anchor: Encounter telemetry hooks (NEO-109). player_death— only when lethal NPC damage drives playercurrentHpto 0 (comment hook in NEO-95; full event deferred).
E1.M4 cast funnel events ability_cast_requested / ability_cast_denied remain in AbilityCastApi (NEO-30). E9.M1 ingest should correlate route playerId with engine payload fields at accept. Plan: NEO-84 implementation plan.
Recipe definitions (NEO-68)
GET /game/world/recipe-definitions returns a versioned JSON body (schemaVersion 1, recipes) backed by IRecipeDefinitionRegistry — the same prototype rows loaded at startup (no second source of truth). Each row includes id, displayName, recipeKind, requiredSkillId, and nested inputs / outputs (itemId, quantity). Plan: NEO-68 implementation plan; manual QA: docs/manual-qa/NEO-68.md; Bruno: bruno/neon-sprawl-server/recipe-definitions/.
curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
Craft engine (NEO-69) and craft HTTP (NEO-70)
CraftOperations.TryCraft in Game/Crafting/ resolves server-internal CraftResult: recipe lookup via IRecipeDefinitionRegistry, read-only pre-flight for inputs and output capacity (simulated adds on a snapshot clone), input removal and output grant via PlayerInventoryOperations, then refine skill XP via SkillProgressionGrantOperations + RefineSkillXpConstants (same path as RefineActivitySkillXpGrant). On output-grant or XP failure after inputs were removed, the engine rolls back inventory before denying. NEO-70 exposes POST /game/players/{id}/craft with wire CraftResponse (promoted CraftResult fields). Client craft UI is NEO-74.
| Reason code | When |
|---|---|
unknown_recipe |
recipeId not in IRecipeDefinitionRegistry. |
insufficient_materials |
Pre-flight input totals below scaled recipe cost. |
inventory_full |
Pre-flight output simulation could not place full scaled yield (NEO-54 all-or-nothing). |
invalid_quantity |
Craft batch quantity is ≤ 0. |
progression_store_missing |
Skill progression store could not apply XP (compensating inventory rollback). |
inventory_store_missing |
Player inventory store could not read or write. |
POST body (schemaVersion 1): recipeId, optional quantity (defaults to 1 when omitted). Wire request type: PlayerCraftRequest. Response: success, reasonCode (null on success), inputsConsumed, outputsGranted, xpGrantSummary on success. 400 on schema mismatch or empty recipeId; 404 on unknown player or store-missing codes; 200 with success: false + stable deny codes on rule failure.
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/craft" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"recipeId":"refine_scrap_standard"}'
Sample 200 success body (after seeding 5× scrap_metal_bulk via gather or inventory POST):
{"schemaVersion":1,"success":true,"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"activity"}}
Plan: NEO-69 implementation plan; NEO-70 implementation plan; NEO-71 implementation plan. Bruno: bruno/neon-sprawl-server/craft/.
Craft telemetry hooks (NEO-71)
Comment-only hook sites in CraftOperations.TryCraft for future E9.M1 catalog events. TODO(E9.M1) — no production ingest. HTTP POST …/craft calls the engine only (no duplicate API-layer hooks).
item_crafted— on every successful craft (inputs removed, outputs granted, refine XP applied).craft_failed— with stablereasonCodeon every structured deny via privateDeny(allCraftReasonCodes, including store-missing and post-mutation rollback paths).
Successful craft output grants also pass through PlayerInventoryOperations (item_created hook, NEO-56). Epic 3 Slice 3 metric time-to-first-craft is derived at E9.M1 from item_crafted + session context (no separate prototype hook). Plan: NEO-71 implementation plan.
Resource node definitions (NEO-60)
GET /game/world/resource-node-definitions returns a versioned JSON body (schemaVersion 1, nodes) backed by IResourceNodeDefinitionRegistry — the same prototype rows loaded at startup (no second source of truth). Each row includes id, displayName, gatherLens, maxGathers, and nested yield (itemId, quantity). Plan: NEO-60 implementation plan; manual QA: docs/manual-qa/NEO-60.md; Bruno: bruno/neon-sprawl-server/resource-node-definitions/.
curl -sS -i "http://localhost:5253/game/world/resource-node-definitions"
Resource node instance depletion (NEO-61)
World-wide remaining gathers per interactableId live in Game/Gathering/ as IResourceNodeInstanceStore + ResourceNodeInstanceOperations. Capacity is lazy-initialized from catalog maxGathers on first successful-gather commit; prototype wiring uses interactableId == nodeDefId. TryCommitSuccessfulGatherDecrement atomically decrements and returns stable deny codes:
| Reason code | When |
|---|---|
node_depleted |
Row exists and remainingGathers is already 0 (decrement rejected). |
unknown_node |
interactableId not found in IResourceNodeDefinitionRegistry. |
Persistence: same NEO-29-style split as inventory — resource_node_instance in PostgreSQL when ConnectionStrings:NeonSprawl is set (DDL V006__resource_node_instance.sql), otherwise in-memory fallback (empty at startup). Interact deny wiring: NEO-63; plan: NEO-61 implementation plan.
Gather engine (NEO-62)
GatherOperations.TryGather in Game/Gathering/ resolves server-internal GatherResult: capacity pre-check (may initialize a depletion row without decrementing), inventory grant via PlayerInventoryOperations, salvage skill XP via SkillProgressionGrantOperations + GatherSkillXpConstants, then depletion commit via ResourceNodeInstanceOperations (decrement last, only when inventory + XP succeed). On XP failure after inventory add, the engine rolls back the item grant before denying.
| Reason code | When |
|---|---|
unknown_node |
interactableId not in IResourceNodeDefinitionRegistry or missing yield row. |
node_depleted |
remainingGathers is 0 (or decrement rejected). |
inventory_full |
Full yield quantity could not be placed (NEO-54 all-or-nothing). |
progression_store_missing |
Skill progression store could not apply XP (compensating inventory remove). |
inventory_store_missing |
Player inventory store could not write. |
Interact wiring (replace NEO-41 inline XP, map denies to InteractionResponse) is NEO-63; plan: NEO-63 implementation plan.
Gather telemetry hooks (NEO-64)
Comment-only hook sites in GatherOperations.TryGather for future E9.M1 catalog events — resource_gathered on every successful gather (inventory + XP + depletion committed); gather_node_depleted when that success drives remainingGathers to 0 (not on pre-gather node_depleted deny). TODO(E9.M1) — no production ingest. Successful gathers also pass through PlayerInventoryOperations (item_created hook, NEO-56). HTTP POST …/interact calls the engine only (no duplicate API-layer hooks). Manual QA: docs/manual-qa/NEO-64.md; plan: NEO-64 implementation plan.
Item definitions (NEO-53)
GET /game/world/item-definitions returns a versioned JSON body (schemaVersion 1, items) backed by IItemDefinitionRegistry — the same prototype rows loaded at startup (no second source of truth). Plan: NEO-53 implementation plan; manual QA: docs/manual-qa/NEO-53.md; Bruno: bruno/neon-sprawl-server/item-definitions/.
curl -sS -i "http://localhost:5253/game/world/item-definitions"
Player inventory (NEO-54 store, NEO-55 HTTP)
Server-authoritative per-player inventory lives in Game/Items/ as IPlayerInventoryStore + PlayerInventoryOperations. NEO-55 exposes versioned GET / POST /game/players/{id}/inventory for manual QA and future client. Plan: NEO-55 implementation plan; Bruno: bruno/neon-sprawl-server/inventory/.
| Container | Fixed slots | Accepts inventorySlotKind |
|---|---|---|
| Bag | 24 (indices 0–23) | bag |
| Equipment | 1 (index 0) | equipment |
GET returns schemaVersion 1, playerId, bagSlots (length 24), equipmentSlots (length 1). Empty slots use quantity: 0 with itemId omitted. 404 when the player is unknown (position gate) or missing from the inventory store.
POST body (schemaVersion 1): mutationKind add or remove, itemId, quantity. Response: applied, reasonCode (null on success), inventory snapshot echo. 400 on schema mismatch or unknown mutationKind; 404 on unknown player; 200 with applied: false + stable deny codes on rule failure.
curl -sS "http://localhost:5253/game/players/dev-local-1/inventory"
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/inventory" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"mutationKind":"add","itemId":"scrap_metal_bulk","quantity":5}'
Engine rules (NEO-54): TryAddStack and TryRemoveStack route items by catalog metadata, merge stacks up to ItemDef.stackMax, and use all-or-nothing adds (no partial silent loss). Stable deny reasonCode values:
| Code | Meaning |
|---|---|
inventory_full |
Full requested quantity could not be placed |
invalid_item |
Unknown itemId (not in catalog) |
insufficient_quantity |
Remove requested more than held |
invalid_quantity |
Non-positive quantity |
Persistence: same NEO-29-style split as skill progression — player_inventory in PostgreSQL when ConnectionStrings:NeonSprawl is set (DDL V005__player_inventory.sql, sparse occupied-slot rows), otherwise in-memory fallback seeding the configured dev player. Store/engine plan: NEO-54 implementation plan.
NEO-56 telemetry hooks: comment-only hook sites in PlayerInventoryOperations for future E9.M1 catalog events — item_created on successful TryAddStack (Applied); inventory_transfer_denied + stable reasonCode on every rule Denied path (early validation via Deny and post-mutation denies such as inventory_full / insufficient_quantity). TODO(E9.M1) — no production ingest. HTTP POST …/inventory calls the engine only (no duplicate API-layer hooks). Manual QA: docs/manual-qa/NEO-56.md; plan: NEO-56 implementation plan.
Mastery catalog (content/mastery, NEO-46)
After the skill catalog loads, the host loads every *_mastery.json under the mastery directory, validates each file against content/schemas/mastery-catalog.schema.json, cross-checks track skillId values against ISkillDefinitionRegistry, and enforces the same post-schema gates as scripts/validate_content.py (unknown perk references, duplicate perk ids, branch-set equality tier-to-tier, unreferenced perks, prototype Slice 4 single salvage track). The server also requires tierIndex values per track to be unique and sequential 1..N (stricter than CI today). Invalid data exits during startup—no silent fallback.
| Config | Meaning |
|---|---|
Content:MasteryDirectory |
Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory for content/mastery. |
Content:MasteryCatalogSchemaPath |
Optional override for mastery-catalog.schema.json. When unset, {parent of mastery directory}/schemas/mastery-catalog.schema.json. |
Docker / CI: include content/mastery and content/schemas/mastery-catalog.schema.json in the mounted content/ tree; set Content__MasteryDirectory when layout differs.
On success, Information logs include the resolved mastery directory, track count, and perk count. Game code should use IMasteryCatalogRegistry for lookups (NEO-47+).
Perk unlock engine and telemetry hooks (NEO-47, NEO-49)
PerkUnlockEngine (Game/Mastery/) is the authoritative server path for branch picks and perk unlocks: TrySelectBranch, ReevaluateAfterLevelUp (called from SkillProgressionGrantOperations on skill level-up). Persistence: IPlayerPerkStateStore + Flyway V004 (NEO-47 plan).
NEO-49: comment-only telemetry hook site for future catalog event perk_unlock in PerkUnlockEngine.TryUnlockPerks when new perk ids are persisted (one emit per unlocked perk; TODO(E9.M1) — no production ingest). Manual QA: docs/manual-qa/NEO-49.md; plan: NEO-49 implementation plan.
Perk state (NEO-48)
GET /game/players/{id}/perk-state returns versioned JSON (schemaVersion 1, playerId, branchPicks, unlockedPerkIds). branchPicks is an array of { skillId, picks: [{ tierIndex, branchId }] } — key by skillId; array order is not part of the contract. unlockedPerkIds lists perk ids currently unlocked for the player.
POST /game/players/{id}/perk-state commits one mastery branch pick (schemaVersion 1, skillId, tierIndex, branchId). Delegates to PerkUnlockEngine.TrySelectBranch (NEO-47). 404 when the player is unknown to position state; 400 when schemaVersion is missing or not 1. Persistence uses IPlayerPerkStateStore + Flyway V004 when Postgres is configured (same NEO-29-style split as skill progression).
Structured responses (HTTP 200): selected (true/false), optional reasonCode on deny, authoritative perkState (same shape as GET), and unlockedEvents on success (empty on deny). Stable deny codes (see PerkUnlockReasonCodes): unknown_track, unknown_tier, unknown_branch, level_too_low, branch_already_chosen, tier_branch_not_selectable, player_not_found. On selected: true, unlockedEvents lists perks newly unlocked on this request (perkId, skillId, tierIndex, branchId, source: branch_pick | level_up). Tier-1 prototype salvage picks may emit zero events; path-auto tier-2 unlocks at level 4+ may emit level_up events on the same POST when level gates are already met.
Dev mastery fixture (NEO-48, Bruno/manual QA): When Game:EnableMasteryFixtureApi is true (default in Development via appsettings.Development.json) or the host environment is Development / Testing, POST /game/players/{id}/__dev/mastery-fixture accepts schemaVersion 1, optional resetPerkState (clear branch picks + unlocked perks), and optional skillXp map (absolute XP per skillId; omitted skills unchanged). Fixture writes set totals directly — they do not run SkillProgressionGrantOperations or perk level-up reevaluation; use normal POST …/skill-progression when testing unlock side effects. Apply order: batch skillXp first, then resetPerkState (XP rolls back if reset fails). 400 for bad schema or invalid skillXp; 404 when disabled, player unknown, or store write fails. Not for production.
Plan: NEO-48 implementation plan; manual QA: docs/manual-qa/NEO-48.md; Bruno: bruno/neon-sprawl-server/perk-state/.
curl -sS -i "http://localhost:5253/game/players/dev-local-1/perk-state"
curl -sS -i -X POST "http://localhost:5253/game/players/dev-local-1/perk-state" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","tierIndex":1,"branchId":"scrap_efficiency"}'
Skill definitions (NEO-36)
GET /game/world/skill-definitions returns a versioned JSON body (schemaVersion 1, skills) backed by ISkillDefinitionRegistry — the same prototype rows loaded at startup (no second source of truth). Plan: NEO-36 implementation plan; manual QA: docs/manual-qa/NEO-36.md; Bruno: bruno/neon-sprawl-server/skill-definitions/.
curl -sS -i "http://localhost:5253/game/world/skill-definitions"
Skill progression snapshot (NEO-37)
GET /game/players/{id}/skill-progression returns a versioned JSON body (schemaVersion 1, playerId, skills) with one row per registered skill (id, xp, level). Treat skills as unordered and index rows by id — array order is not part of the contract (the server may sort for stable serialization). Levels are derived from the startup-loaded content curve at content/skills/prototype_level_curve.json (validated by content/schemas/level-curve.schema.json and CI scripts/validate_content.py). Unknown players (no row in position state) receive 404, same gate as GET …/cooldown-snapshot. Plan: NEO-37 implementation plan; manual QA: docs/manual-qa/NEO-37.md; Bruno: bruno/neon-sprawl-server/skill-progression/Get skill progression.bru.
curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression"
Skill progression grant (NEO-38)
POST /game/players/{id}/skill-progression accepts a versioned body (schemaVersion 1, skillId, amount positive int, sourceKind) and validates the skill against ISkillDefinitionRegistry, then validates sourceKind against that skill’s allowedXpSourceKinds (case-insensitive). 404 when the player is unknown to position state; 400 when schemaVersion is missing or not 1. Persisted XP totals use the same NEO-29-style split as hotbar: player_skill_progression in PostgreSQL when ConnectionStrings:NeonSprawl is set (DDL V003__player_skill_progression.sql), otherwise the in-memory fallback (dev bucket only, matching hotbar semantics).
Combat encounters do not grant skill XP by default — prototype defeat awards gig XP on the main gig via NEO-44 (docs/game-design/progression.md). Gather/craft use activity; mission payouts use mission_reward (NEO-43).
Structured responses (HTTP 200): granted (true/false), optional reasonCode on deny, plus progression mirroring the GET snapshot. Stable deny codes: unknown_skill, source_kind_not_allowed, invalid_amount. On granted: true, levelUps lists skills whose level increased on this request (skillId, previousLevel, newLevel). If a single grant crosses multiple level boundaries (e.g. a large XP jump under the placeholder curve), the server emits one object per skill with previousLevel and newLevel set to the start and end levels after the grant — not one array entry per boundary crossed. A future issue can add per-step events if product needs them.
Plan: NEO-38 implementation plan; manual QA: docs/manual-qa/NEO-38.md; Bruno: bruno/neon-sprawl-server/skill-progression/ (happy POST + deny smoke).
curl -sS -i -X POST "http://localhost:5253/game/players/dev-local-1/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":10,"sourceKind":"activity"}'
Gig progression snapshot (NEO-44)
GET /game/players/{id}/gig-progression returns a versioned JSON body (schemaVersion 1, playerId, mainGigId, gigs) with prototype mainGigId breach and one row (id, xp, level). level is fixed at 1 until gig level-curve content exists; xp accumulates from combat defeat grants only in this slice. Unknown players receive 404 (same gate as skill progression GET).
Combat defeat → gig XP: when POST …/ability-cast accepts with combatResolution.targetDefeated: true, the server best-effort applies 25 gig XP to breach via CombatDefeatGigXpGrant — not SkillProgressionGrantOperations. Re-hits on a defeated target (target_defeated deny) grant no additional gig XP. Persisted totals use the NEO-29-style split: player_gig_progression in PostgreSQL when configured (DDL V007__player_gig_progression.sql), otherwise in-memory.
Plan: NEO-44 implementation plan; manual QA: docs/manual-qa/NEO-44.md; Bruno: bruno/neon-sprawl-server/gig-progression/.
curl -sS -i "http://localhost:5253/game/players/dev-local-1/gig-progression"
Position persistence (NEO-8)
When ConnectionStrings:NeonSprawl is set to a valid PostgreSQL connection string, the server uses the player_position table (relational columns pos_x … sequence, not JSONB). DDL lives in server/db/migrations/V001__player_position.sql; the app applies that script on startup and on first store use (idempotent CREATE TABLE IF NOT EXISTS). The configured dev player (Game:DevPlayerId / Game:DefaultPosition) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request).
If ConnectionStrings:NeonSprawl is missing or blank, behavior matches NEO-6/NEO-7 (in-memory only). The test project’s postgres.runsettings sets ConnectionStrings__NeonSprawl for the test host so Postgres integration tests run in CI/Rider/dotnet test; non-Postgres tests use InMemoryWebApplicationFactory and do not require a live DB. Remove or rename postgres.runsettings (and the RunSettingsFilePath property) if you want those Postgres integration tests to skip again when the variable is unset.
Environment variables (typical — same mapping as other ASP.NET Core config):
| Mechanism | Example |
|---|---|
| Shell / CI | export ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev' |
| User secrets | dotnet user-secrets set ConnectionStrings:NeonSprawl "<connection string>" (run from server/NeonSprawl.Server) |
Match credentials with root docker-compose.yml for local development.
Restart check (acceptance criteria): start Postgres (docker compose up -d from repo root), set the connection string, dotnet run, POST …/move, stop the server, dotnet run again, GET …/position should return the last committed state.
Postgres integration tests require ConnectionStrings__NeonSprawl (set automatically in .github/workflows/dotnet.yml). The test project sets this via NeonSprawl.Server.Tests/postgres.runsettings (RunSettingsFilePath in the .csproj) so dotnet test and Rider pick it up without shell export. launchSettings.json in the same project is for IDE launch profiles and is not the xUnit “config file” in Rider settings.
Contributors / PRs: With postgres.runsettings checked in, dotnet test always injects that connection string, so the four Postgres integration tests run (they are not skipped). If nothing is listening on Postgres and Docker cannot start Compose (or you have no DB), those tests fail—that is expected, not a random flake. Mitigations: docker compose up -d from the repo root (tests may auto-start Compose locally when not in CI), or temporarily remove RunSettingsFilePath from NeonSprawl.Server.Tests.csproj while hacking. GitHub Actions provides Postgres in .github/workflows/dotnet.yml, so merges stay green when the workflow runs.
Rider: Under Settings → … → Unit Testing → xUnit.net, leave “Use specific configuration file” off unless you add a real xunit.runner.json. Do not point that field at launchSettings.json.
Local Docker (optional automation): When not in CI (CI, GITHUB_ACTIONS, GITLAB_CI, TF_BUILD), the Postgres test harness tries to connect first. If the server is unreachable, it runs docker compose up -d from the repo root (same compose file as root docker-compose.yml), waits for the DB, runs tests, then runs docker compose down only if it started Compose itself. If Postgres was already listening (existing container or native install), tests do not stop your database afterward. Initialization is async (no sync-over-async on the xUnit sync context) so Rider / Visual Studio do not leave Postgres tests stuck Pending after Compose starts.
Position state (NEO-6)
Authoritative player position is served over HTTP whether the backing store is in memory or PostgreSQL (NEO-8). The HTTP JSON shape is versioned with top-level schemaVersion (see XML docs on PositionStateResponse in the server project). Path {id} is trimmed and matched case-insensitively against stored players; playerId in the JSON body echoes the path segment from the request. Stored player ids use invariant lowercase + trim in Postgres for lookup parity.
Example (dev player id defaults to dev-local-1 in appsettings.json):
curl -s http://localhost:5253/game/players/dev-local-1/position
Sample response (default spawn matches Godot capsule and NEO-9 walk-in range demo):
{"schemaVersion":1,"playerId":"dev-local-1","position":{"x":-5,"y":0.9,"z":-5},"sequence":0}
Unknown player ids return 404. Full zone sync / replication is still out of scope for these spikes.
Move command (NEO-7, NEO-10)
POST /game/players/{id}/move with JSON body applies a v1 MoveCommand: authoritative position snaps to target immediately; sequence in responses increases by one per successful apply.
POST /game/players/{id}/move-stream (NEO-22): JSON body MoveStreamRequest (schemaVersion 1, targets: ordered array of world positions, max 24 per request). The server validates the entire chain against Game:MovementValidation (same rules as a single move) before applying anything, then applies each target in order; sequence increases by one per applied target. Response body matches PositionStateResponse. On the first failing leg the handler returns 400 with MoveCommandRejectedResponse and does not change stored position.
Client navigation (NEO-11): The Godot prototype uses a baked navigation mesh for local vertical routing / step assist when applicable; WASD sends move-stream samples and the server validates straight-line legs (NEO-10) from the last authoritative position to each requested position. The server does not simulate navmesh. Cheating or divergent paths are out of scope for this slice.
NEO-10 validation (reject-only): Before applying, the server checks the move against Game:MovementValidation. Limits use horizontal distance on X/Z only and |ΔY| separately (see MoveCommandValidation). Unknown players return 404 before validation.
| Config key | Meaning | Default (see appsettings.json) |
|---|---|---|
Game:MovementValidation:MaxHorizontalStep |
Max XZ displacement per command (m); inclusive at limit | 18 |
Game:MovementValidation:MaxVerticalStep |
Max absolute Y delta per command (m); inclusive | 2.2 |
Game:MovementValidation:DistrictBoundsEnabled |
Axis-aligned box on target (inclusive min/max) | false |
Game:MovementValidation:DistrictMinX … DistrictMaxZ |
District extents when enabled | ±12 / Y −2…24 |
Manual QA (Godot main.tscn): Green bumps are two random short cylinders each run (~8–14 cm rise) and stay under default MaxVerticalStep from the dev spawn; orange reject pedestal top is ~2.5 m above floor so move-stream from the floor into the pedestal still yields vertical_step_exceeded (default MaxVerticalStep is 2.2 m); the physics ramp test block top is 2 m so streaming from it toward the floor is accepted. Far pad is beyond default horizontal reach for horizontal_step_exceeded when horizontal limits are enabled.
Request body (example):
curl -s -X POST http://localhost:5253/game/players/dev-local-1/move \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"target":{"x":1.5,"y":0,"z":-2}}'
- 200 — body matches
PositionStateResponse(same shape asGET…/position). - 400 — malformed command (missing/invalid body or wrong
schemaVersion) or move rejected by NEO-10 rules. Rejection responses are JSONMoveCommandRejectedResponse:schemaVersion(1) +reasonCode(horizontal_step_exceeded,vertical_step_exceeded,out_of_bounds). Malformed requests return 400 with no rejection body. - 404 — unknown player id.
See XML on MoveCommandRequest, PositionStateResponse, and MoveCommandRejectedResponse in the server project.
For a PR-ready contract blurb (formatted JSON + field table), see the NEO-6 implementation plan — Pull request description (GET shape) and NEO-7 implementation plan (MoveCommand + sequence semantics). NEO-10: NEO-10 implementation plan.
Interaction (NEO-9)
POST /game/players/{id}/interact with a versioned InteractionRequest body asks whether the given player may use a prototype interactable now. The server reads authoritative PositionState from IPositionStateStore (same id rules as position APIs: trim + ordinal case-insensitive match). Horizontal reach uses X and Z only; Y is ignored for distance (floor-plane prototype). See HorizontalReach in NeonSprawl.Server/Game/World/ and PrototypeInteractableRegistry in Game/Interaction/.
Request (example):
curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"interactableId":"prototype_terminal"}'
HTTP
| Status | Meaning |
|---|---|
| 200 | Body is InteractionResponse v1: allowed: true (optional payload, interactableId echo) or allowed: false with required reasonCode. |
| 400 | Not a valid v1 attempt (bad/missing schemaVersion, malformed JSON, missing interactableId, or empty after trim). |
| 404 | Unknown player only (no row in the position store). |
reasonCode (v1, when allowed is false)
| Code | When |
|---|---|
out_of_range |
Horizontal distance on X/Z is greater than the interactable’s interactionRadius (on the radius is allowed — inclusive <=). |
unknown_interactable |
Id not in the prototype registry (after trim + case-insensitive lookup). |
node_depleted |
resource_node gather: instance capacity exhausted (NEO-63). |
inventory_full |
resource_node gather: yield could not fit in bag (all-or-nothing; NEO-54). |
unknown_node |
resource_node gather: registry miss (defensive; should not occur for registered interactables). |
progression_store_missing |
resource_node gather: skill progression store could not apply XP. |
When allowed is true, reasonCode is omitted (clients must not require it on success). Unknown player never returns this JSON — use 404.
Gather via interact (NEO-63)
When the interactable’s kind is resource_node, POST …/interact delegates to GatherOperations.TryGather (NEO-62): inventory grant, salvage skill XP (activity, 10 XP), and depletion commit. On success allowed: true; gather denies map to allowed: false + stable reasonCode (including node_depleted, inventory_full). Four prototype nodes are registered in PrototypeInteractableRegistry (alpha (12, −6), beta (18, −6), gamma (12, 0), delta (18, 0) on X/Z, radius 3). Verify with GET …/inventory and GET …/skill-progression. Plan: NEO-63 implementation plan; manual QA: docs/manual-qa/NEO-63.md; Bruno: bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru, Post interact gather then get inventory.bru, Post interact depleted node deny.bru.
Historical: NEO-41 previously granted XP only on allowed interact without inventory or depletion; superseded by NEO-63.
Craft / refine hook → skill XP (NEO-42)
E3.M2 craft/refine success paths call SkillProgressionGrantOperations.TryApplyGrant with RefineSkillXpConstants (10 refine XP, sourceKind: activity) — the same stack as RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine and POST …/skill-progression (NEO-38). CraftOperations.TryCraft (NEO-69) owns the live call site; craft HTTP is POST …/craft (NEO-70). Plan: NEO-42 implementation plan; NEO-69 implementation plan; NEO-70 implementation plan; manual QA: docs/manual-qa/NEO-42.md.
Mission / quest reward → skill XP (NEO-43)
E7.M2 quest/mission payout paths should call MissionRewardSkillXpGrant.GrantFromMissionReward with content-driven skillId and amount; the helper always uses sourceKind: mission_reward through SkillProgressionGrantOperations.TryApplyGrant (same validation, allowedXpSourceKinds, and persistence as NEO-38). Combat encounter rewards must not use this path for default clears — gig XP stays separate (docs/game-design/progression.md). Until the router exists, verify mission_reward with POST …/skill-progression. Plan: NEO-43 implementation plan; manual QA: docs/manual-qa/NEO-43.md.
Contract details and PR blurb: NEO-9 implementation plan.
Interactable descriptors (NEO-25)
GET /game/world/interactables returns a versioned projection of the prototype PrototypeInteractableRegistry (no player id; global list). Use this for client discovery of anchors, radii, and kind instead of duplicating registry constants in GDScript.
Example:
curl -s http://localhost:5253/game/world/interactables
HTTP
| Status | Meaning |
|---|---|
| 200 | Body is InteractablesListResponse v1: schemaVersion (1) + interactables array. |
interactables[] fields (v1)
| Field | Meaning |
|---|---|
interactableId |
Lowercase canonical id (matches POST …/interact). |
kind |
Stable machine string (terminal, resource_node, …). |
anchor |
Object with x, y, z (world meters). |
interactionRadius |
Horizontal reach on X/Z; inclusive boundary (same rule as NEO-9). |
Rows are sorted by ascending interactableId. Plan: NEO-25 implementation plan.
Targeting (NEO-23)
Authoritative combat target lock (prototype): GET /game/players/{id}/target returns PlayerTargetStateResponse v1; POST /game/players/{id}/target/select accepts TargetSelectRequest v1 and returns TargetSelectResponse v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). Horizontal lock range uses HorizontalReach on X/Z only (inclusive radius), same floor-plane policy as NEO-9.
Soft lock: the server keeps the persisted lockedTargetId until clear or a successful swap. If the player moves out of stub range, validity becomes out_of_range on GET and on POST echoes until they move back in range, clear, or select another valid target (see NEO-23 implementation plan).
Stub registry: PrototypeNpcRegistry in NeonSprawl.Server/Game/Npc/ — ids prototype_npc_elite, prototype_npc_melee, prototype_npc_ranged (ascending id order for tab cycle; client mirror in NEO-97).
GET /game/players/{id}/target
| Field | Meaning |
|---|---|
schemaVersion |
1 |
playerId |
Echo of route {id} |
lockedTargetId |
Lowercase stub id when locked; JSON null when no lock (key always present) |
validity |
none (no lock), ok, out_of_range, or invalid_target (unknown id in store — should not occur in normal use) |
sequence |
Increments when the persisted lock id changes (clear or swap); unchanged on idempotent re-select of the same id |
POST /game/players/{id}/target/select — body schemaVersion (1), optional targetId. Omit targetId or send JSON null to clear the lock. Non-null targetId is trimmed; whitespace-only after trim → 400.
Examples:
curl -s http://localhost:5253/game/players/dev-local-1/target
curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"targetId":"prototype_npc_melee"}'
HTTP
| Status | Meaning |
|---|---|
| 200 | GET: PlayerTargetStateResponse v1. POST: TargetSelectResponse v1 with targetState always set; selectionApplied true or false; when false, required reasonCode. |
| 400 | Invalid v1 body (wrong schemaVersion, malformed JSON, or targetId whitespace-only when provided). |
| 404 | Unknown player only (same as interact). |
reasonCode (POST v1, when selectionApplied is false)
| Code | When |
|---|---|
unknown_target |
Id not in the prototype target registry (after trim + case-insensitive lookup). |
out_of_range |
Horizontal distance on X/Z is greater than the target’s lockRadius (on the radius is allowed — inclusive <=). |
When selectionApplied is true, reasonCode is omitted (clients must not require it on success).
Hotbar loadout (NEO-29)
Prototype server-owned hotbar bindings are available at:
GET /game/players/{id}/hotbar-loadout→ returnsHotbarLoadoutResponsev1 with fixedslotCount8 andslotsarray entries (slotIndex, nullableabilityId) for every slot.POST /game/players/{id}/hotbar-loadoutwithHotbarLoadoutUpdateRequestv1 (schemaVersion,slots[]) upserts one or more slot bindings and returnsHotbarLoadoutUpdateResponsev1.
Prototype policy (kickoff decision):
- Scope is per player id (same keying strategy as
PositionState/TargetState). - Persistence mirrors NEO-8/NS-17: Postgres when
ConnectionStrings:NeonSprawlexists, otherwise in-memory fallback.
Validation deny reason codes (POST 200 with updated=false):
| Code | Meaning |
|---|---|
slot_out_of_bounds |
slotIndex is outside v1 range 0..7. |
unknown_ability |
abilityId is empty/whitespace or not in the prototype allowlist. |
duplicate_slot |
Request repeats the same slotIndex more than once. |
Current prototype allowlist: prototype_pulse, prototype_guard, prototype_dash, prototype_burst.
Ability cast (NEO-31, NEO-82)
Prototype cast intent with combat resolution on accept (NEO-82). NEO-28 adds server-side target lock + registry + range gates and documents invalid_target / out_of_range denies; the Godot client surfaces accept/deny on CastFeedbackLabel.
POST /game/players/{id}/ability-castwithAbilityCastRequestv1 (schemaVersion,slotIndex0..7,abilityId,targetId) validates the player exists, the slot is bound in persisted hotbar loadout, andabilityIdmatches that binding and the prototype allowlist. NEO-28:targetIdmust be non-empty, exist inPrototypeNpcRegistry, match the server's current combat lock (IPlayerTargetLockStore), and the lock must be within horizontal reach of the authoritative position snapshot (same XZ radius rule as targeting). NEO-32: rejects withon_cooldownwhen the slot is still cooling. NEO-82: after gates pass, invokesCombatOperations.TryResolve; on success commits per-abilitycooldownSecondsfrom the ability catalog and returnsAbilityCastResponsev1 with nestedcombatResolution.
Accept payload (combatResolution, present only when accepted: true):
| Field | Meaning |
|---|---|
abilityId |
Normalized ability id (matches request/binding). |
targetId |
Normalized prototype target id (lowercase registry key). |
damageDealt |
Catalog baseDamage applied (0 for utility abilities). |
targetRemainingHp |
Authoritative HP after resolve. |
targetDefeated |
true when post-resolve HP is zero. |
HTTP status:
| Status | When |
|---|---|
| 200 | Body parses as AbilityCastResponse; accepted is true or false with a stable reasonCode on deny. |
| 400 | Missing body or wrong schemaVersion. |
| 404 | Unknown player id (no position row / unknown player for this prototype). |
Deny reasonCode values (200, accepted=false):
| Code | Meaning |
|---|---|
slot_out_of_bounds |
slotIndex outside 0..7. |
slot_unbound |
No ability bound on that slot in stored loadout. |
loadout_mismatch |
abilityId does not match the ability bound on that slot. |
unknown_ability |
abilityId failed registry normalization (empty/garbage/off-list). |
invalid_target |
targetId missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). |
out_of_range |
Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). |
on_cooldown |
Slot is still inside the server-authoritative cooldown window after a prior successful combat resolve (NEO-32). |
target_defeated |
Target HP is already zero; combat engine deny on re-hit (NEO-82 / NEO-81). No cooldown committed. No gig XP grant (NEO-44). |
NEO-44: on accept when combatResolution.targetDefeated is true, the server grants 25 gig XP to prototype main gig breach via CombatDefeatGigXpGrant (outside E2.M2). Verify with GET …/gig-progression; cast response has no gig block in this slice.
NEO-106: on every successful accept, EncounterCombatWiring.TryProcessCastOutcome activates encounter progress on first damaging hit and applies completion grants when all required NPCs are defeated (Encounter combat wiring (NEO-106)). Verify loot with GET …/inventory.
Implementation: server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs, AbilityCastDtos.cs. Optional Slice 3–named one-line stdout per JSON response when NEON_SPRAWL_API_LOG is set (see Optional prototype API stdout under Run above).
Cooldown snapshot (NEO-32)
Prototype per-slot cooldown ends (in-memory per process; not persisted to Postgres). NEO-82: duration comes from ability catalog cooldownSeconds on each successful combat resolve (e.g. prototype_pulse 3.0s, prototype_guard 6.0s).
GET /game/players/{id}/cooldown-snapshot→CooldownSnapshotResponsev1 (schemaVersion,playerId,serverTimeUtc,slots[]withslotIndexand optionalcooldownEndsAtUtcper slot). 404 when the player id is unknown to position state (same rule as loadout GET).
Implementation: CooldownSnapshotApi.cs, CooldownSnapshotDtos.cs, IPlayerAbilityCooldownStore / InMemoryPlayerAbilityCooldownStore.cs.
NEO-30 (Slice 3 telemetry): authoritative hook-site comments in AbilityCastApi.cs for product names ability_cast_requested (accept) and ability_cast_denied (each JSON deny + reasonCode); cataloged emit deferred to E9.M1 (TODO in source).
Solution
From repo root: dotnet build NeonSprawl.sln and dotnet test NeonSprawl.sln.
CI
Pull requests targeting main run build and tests via .github/workflows/dotnet.yml on GitHub Actions (ubuntu-latest runner, Release configuration).
Linux + Rider: “only 8.x in /usr/share/dotnet”
If the debugger prints .NET location: /usr/share/dotnet and cannot find Microsoft.NETCore.App 10.x while you have .NET 10 under ~/.dotnet:
launchSettings.jsonenvironmentVariables(e.g.DOTNET_ROOT) often do not apply to the native apphost Rider launches first; the host probes/usr/share/dotnetbefore your app starts.- This repo sets
<UseAppHost>false</UseAppHost>for Debug builds so the IDE runsdotnet …/NeonSprawl.Server.dllusing the .NET CLI you configured in the toolchain (/home/don/.dotnet/dotnet), which then loads the 10.x shared runtime from the same install. - Alternatives: install .NET 10 into the default location so
/usr/share/dotnethas 10.x, or addDOTNET_ROOT=/home/don/.dotnetin Run → Edit Configurations → Environment variables (IDE-level, not onlylaunchSettings.json).