neon-sprawl/server/README.md

127 KiB
Raw Blame History

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 TargetFramework in NeonSprawl.Server.csproj). Check with dotnet --list-sdks.

Run

cd server/NeonSprawl.Server
dotnet run
  • Default URL is printed by Kestrel (see Properties/launchSettings.json, typically http://localhost:5253).
  • Check GET /health for 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).

Faction catalog (content/factions, NEO-134)

On startup the host loads every *_factions.json under the factions directory after the skill catalog and before the quest catalog, validates each row against content/schemas/faction-def.schema.json, requires schemaVersion 1 per file, rejects duplicate faction id values across files, enforces the prototype E7M3 two-faction roster, freeze table (display names + standing band), and standing-band ordering (minStanding <= neutralStanding <= maxStanding), then the quest loader cross-checks factionGateRules and completionRewardBundle.reputationGrants against loaded faction ids and enforces prototype E7M3 completion bundle freeze (item + skill + reputation grants) and grid-contract quest shape (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:FactionsDirectory Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/factions.
Content:FactionDefSchemaPath Optional override for faction-def.schema.json. When unset, {parent of factions directory}/schemas/faction-def.schema.json.

Docker / CI: include content/factions and content/schemas/faction-def.schema.json in the mounted content/ tree; set Content__FactionsDirectory when layout differs.

On success, Information logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use IFactionDefinitionRegistry for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject FactionDefinitionCatalog in new game code.

Faction standing store (NEO-135)

Per-player faction reputation snapshots live in IFactionStandingStore, keyed by (playerId, factionId). A missing row reads as neutral 0, clamped to the faction definition minStandingmaxStanding. Unknown faction ids return structured deny with reason code unknown_faction at the store boundary. Game code should call ReputationOperations (NEO-136) for auditable apply — not the standing store alone — so source attribution and audit rows stay consistent.

Standing store methods:

  • CanWritePlayer — whether mutations are allowed for the player (dev bucket / Postgres player_position).
  • TryGetStanding — read one faction; missing row ⇒ 0 (clamped).
  • TryApplyStandingDelta — internal primitive: add signed delta, clamp result, persist. Does not append audit rows.

Append-only ReputationDelta audit rows live in IReputationDeltaStore:

  • TryAppend — first row id returns true; duplicate ids return false.
  • GetDeltasForPlayer — ordered by recordedAt then id (prototype test/audit read).

Postgres orchestration: audit rows FK to player_position — append after standing apply succeeds (NEO-136 ReputationOperations); a missing player row causes insert failure rather than a structured false from TryAppend.

Storage: in-memory singletons when ConnectionStrings:NeonSprawl is unset (standing store seeds configured dev player only). When Postgres is configured, PostgresFactionStandingStore persists to player_faction_standing (V009__player_faction_standing.sql) and PostgresReputationDeltaStore appends to reputation_delta_audit (V010__reputation_delta_audit.sql). Plan: NEO-135 implementation plan.

Faction standing read (NEO-139)

GET /game/players/{id}/faction-standing returns a versioned snapshot of current standing for every faction in the loaded catalog. Path {id} is trimmed; unknown players (no position row) return 404 before building a body — same gate as skill/gig progression reads.

Example (dev player defaults):

curl -s http://localhost:5253/game/players/dev-local-1/faction-standing

Sample response (neutral standing on fresh dev player):

{"schemaVersion":1,"playerId":"dev-local-1","factions":[{"id":"prototype_faction_grid_operators","standing":0},{"id":"prototype_faction_rust_collective","standing":0}]}

After prototype_quest_operator_chain completion (organic reward delivery), Grid Operators standing is 15:

{"schemaVersion":1,"playerId":"dev-local-1","factions":[{"id":"prototype_faction_grid_operators","standing":15},{"id":"prototype_faction_rust_collective","standing":0}]}

Consumers: key rows by id — array order is not part of the public contract. Display names are not included; join from client content until a world faction-definitions route exists. Godot HUD: NEO-142.

Bruno smoke: bruno/neon-sprawl-server/faction-standing/. Plan: NEO-139 implementation plan.

ReputationOperations (NEO-136)

Game code applies standing changes through ReputationOperations.TryApplyDelta — not IFactionStandingStore.TryApplyStandingDelta alone — so every mutation records source attribution and an append-only audit row.

TryApplyDelta parameters: playerId, factionId, signed deltaAmount, caller-supplied deltaId (UUID string), sourceKind, sourceId, plus injected IFactionStandingStore, IReputationDeltaStore, and TimeProvider.

Commit order: standing apply first, audit append second. Audit row deltaAmount records the applied change (newStanding - previousStanding), not the requested delta when clamping occurs. On audit append failure (duplicate id or invalid row), the operation compensating-rollback standing via the inverse applied delta and returns audit_append_failed.

Pre-flight denies (no store mutation): deltaAmount == 0invalid_delta; empty deltaIdinvalid_delta_id; empty sourceKind or sourceIdinvalid_source. Store boundary denies (unknown_faction, player_not_writable) pass through unchanged.

Prototype source kind: quest_completion (ReputationDeltaSourceKinds.QuestCompletion); quest id as sourceId. Quest bundle wiring: RewardRouterOperations.TryDeliverQuestCompletion (NEO-138).

Plan: NEO-136 implementation plan.

FactionGateOperations (NEO-137)

Quest accept evaluates FactionGateRule rows through FactionGateOperations.TryEvaluate before activation. Wired from QuestStateOperations.TryAccept after prerequisite checks and before TryActivate.

Semantics: all rules must pass (AND). Empty rule list succeeds immediately. Each rule compares player standing from IFactionStandingStore.TryGetStanding against minStanding with standing >= minStanding. Unknown faction ids fail closed via the standing store boundary (startup catalog cross-ref prevents bad content at load).

Deny reason code: faction_gate_blocked on QuestStateReasonCodes when any gate fails. Accept HTTP response returns accepted: false with that reasonCode (HTTP 200, same as other structured accept denies).

Prototype quest: prototype_quest_grid_contract requires prototype_faction_grid_operators standing ≥ 15 after prototype_quest_operator_chain prerequisite is complete. Deny Bruno (Accept grid contract faction gate deny.bru, seq 10) pre-requests POST …/__dev/quest-fixture with resetQuestIds for grid contract, resetFactionIds for both prototype factions (clears stale standing from prior seq 11 runs), plus completedQuestIds for operator chain — no reward delivery; standing 0. Success Bruno (seq 11, NEO-138): organic operator-chain flow then accept.

Plan: NEO-137 implementation plan.

Faction telemetry hooks (NEO-141)

Comment-only hook sites for future E9.M1 catalog events (Epic 7 Slice 3). TODO(E9.M1) — no production ingest. Quest accept and reward router delegate to these ops layers only (no duplicate API-layer or router-layer hooks for these names). Quest rep grants emit reputation_delta via ReputationOperations — see Reward telemetry hooks (NEO-130) cross-link.

Event Anchor When
reputation_delta ReputationOperations.TryApplyDelta After successful audit append (auditable apply committed).
faction_gate_blocked FactionGateOperations.TryEvaluate On any rule deny (threshold or standing read failure).

Plan: NEO-141 implementation plan.

Dev quest fixture (NEO-137, Bruno/manual QA): When Game:EnableQuestFixtureApi is true (default in Development via appsettings.Development.json, Testing, or CI Bruno step) or the host environment is Development / Testing, POST /game/players/{id}/__dev/quest-fixture accepts schemaVersion 1 with optional (resets run before completedQuestIds when combined):

  • resetQuestIds — deletes each quest's progress row for the player and clears matching IRewardDeliveryStore + quest-completion IReputationDeltaStore audit rows (idempotent when absent).
  • resetFactionIds — clears each faction's standing row for the player (missing row reads as 0; idempotent when absent).
  • completedQuestIds — marks each quest completed via store activate + mark-complete without reward delivery (standing unchanged unless resetFactionIds also sent). Idempotent when a quest is already completed.

400 for bad schema; 404 when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: quest-progress/Accept grid contract faction gate deny.bru uses resetQuestIds, resetFactionIds, and completedQuestIds; order-independent accept/gather tests use resetQuestIds via scripts/bruno-dev-fixture-helper.js.

Bruno self-reset helpers: bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (resetAllPrototypeQuestProgress, resetGatherIntroSpine, resetPrototypeResourceNodes, clearInventory). Combat HP/encounter reset remains scripts/combat-targets-reset-helper.js.

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, encounter, skill, and faction catalogs, validates each row against content/schemas/quest-def.schema.json (with quest-step-def.schema.json, quest-objective-def.schema.json, quest-reward-bundle.schema.json, quest-skill-xp-grant.schema.json, reward-grant-row.schema.json, faction-gate-rule.schema.json, and reputation-grant-row.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, enforces the prototype E7M3 five-quest roster, acyclic prerequisiteQuestIds, and operator-chain terminal inventory_has_item contract_handoff_token ×1 gate, enforces prototype E7M2 completionRewardBundle presence, freeze-table contents (item + skill grants), item/skill cross-refs, and mission_reward allowlist on skill XP targets, and enforces prototype E7M3 faction cross-refs on gates and reputation grants, E7M3 completion bundle freeze (including reputationGrants), and grid-contract quest shape (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.

Bundle-related schemas (quest-reward-bundle.schema.json, quest-skill-xp-grant.schema.json, reward-grant-row.schema.json) resolve as siblings under {parent of quests directory}/schemas/ only — there are no separate Content:* override keys yet; custom layouts must keep those three files next to the quest schemas (NEO-125).

Docker / CI: include content/quests, upstream catalogs (content/items, content/recipes, content/encounters, content/skills, content/factions), and quest/bundle/faction 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.

Contract template catalog (content/contracts, NEO-145)

On startup the host loads every *_contract_templates.json under the contracts directory after the item, recipe, encounter, skill, faction, and quest catalogs, validates each row against content/schemas/contract-template.schema.json (with quest-reward-bundle.schema.json, quest-skill-xp-grant.schema.json, reward-grant-row.schema.json, faction-gate-rule.schema.json, and reputation-grant-row.schema.json for nested $refs), requires schemaVersion 1 per file, rejects duplicate template id values across files, cross-checks encounterTemplateId against the loaded encounter catalog and bundle item/skill/faction refs against frozen prototype catalogs, enforces the prototype E7M4 one-template roster and freeze table (including empty reputationGrants), and enforces band-1 economy caps (item qty ≤ 10, skill XP ≤ 25, rep ≤ 10 per grant 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:ContractsDirectory Optional. Absolute path, or path relative to the server content root. When unset, walks ancestors of AppContext.BaseDirectory until it finds content/contracts.
Content:ContractTemplateSchemaPath Optional override for contract-template.schema.json. When unset, {parent of contracts directory}/schemas/contract-template.schema.json.

Bundle- and gate-related schemas resolve as siblings under {parent of contracts directory}/schemas/ only — there are no separate Content:* override keys yet; custom layouts must keep those files next to the contract template schema (NEO-145).

Docker / CI: include content/contracts, upstream catalogs (content/encounters, content/skills, content/factions, content/items), and contract/bundle/faction schemas in the mounted content/ tree; set Content__ContractsDirectory when layout differs.

On success, Information logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use IContractTemplateRegistry for lookups (TryGetDefinition, GetDefinitionsInIdOrder; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject ContractTemplateCatalog in new game code.

Contract instance store (NEO-146)

Per-player contract runtime state lives in IContractInstanceStore, keyed by contractInstanceId with at most one active row per player (prototype policy). Missing row ⇒ no instance; TryCreateActive creates an active row with template id, seed bucket, and issue timestamp. ContractGeneratorOperations (NEO-147) and ContractCompletionOperations (NEO-149) should call the store through orchestrators — not HTTP directly.

Store interface methods:

  • CanWritePlayer — whether mutations are allowed for the player (dev bucket / Postgres player_position).
  • TryGet — read one instance by player + instance id; false when absent.
  • TryGetActiveForPlayer — read the player's single active instance, if any.
  • TryCreateActive — first active for player returns true; denies when another active exists, duplicate instance id, or player not writable.
  • TryMarkComplete — first completion returns true; replay returns false without changing completedAt.
  • TryClearInstance — dev fixture only; removes one instance row.

Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation and selection run in ContractGeneratorOperations (NEO-147).

Storage: in-memory singleton when ConnectionStrings:NeonSprawl is unset (seeds configured dev player only). When Postgres is configured, PostgresContractInstanceStore persists to contract_instance (V011__contract_instance.sql) with a partial unique index enforcing one active row per player. Plan: NEO-146 implementation plan.

Contract generator operations (NEO-147)

ContractGeneratorOperations.TryIssue issues an active contract instance from seed inputs + player context. Call this orchestrator (or future HTTP wrapper) — not IContractInstanceStore.TryCreateActive directly — so template selection, faction gates, and deterministic ids stay centralized.

Inputs: playerId, optional templateId, seedBucket, optional zoneDifficultyBand (defaults to 1).

Template selection:

  • Explicit templateId: registry lookup; deny unknown_template when missing; deny no_eligible_template when band or faction standing floor fails.
  • Omitted/empty templateId: first template from GetDefinitionsInIdOrder() matching band + standing.
  • Faction floor uses FactionGateOperations on the template minFactionStanding row.

Deterministic instance id: ContractInstanceIds.MakeDeterministicInstanceId — SHA-256 of {playerId}|{templateId}|{seedBucket} (normalized) → ci_{32 lowercase hex}.

Reason codes:

Code When
invalid_ids Empty player or seed bucket after normalize
player_not_writable Player not in dev bucket / Postgres player_position
active_contract_exists Player already has an active instance
unknown_template Explicit template id not in registry
no_eligible_template No template matches band + standing filters

Result: ContractIssueOperationResultsuccess, optional reasonCode, optional ContractInstanceState snapshot, encounterTemplateId from the selected template.

Economy band-cap lint at issue time lands in NEO-150 (E7M4-07). HTTP POST …/contracts/issue lands in NEO-151 (E7M4-08). Plan: NEO-147 implementation plan.

Contract outcome store (NEO-146)

Append-only contract completion audit rows live in IContractOutcomeStore. Each ContractOutcomeRow captures instance id, delivery idempotency key {playerId}:contract_complete:{contractInstanceId}, grant snapshot summary (items, skill XP, reputation — same shapes as RewardDeliveryEvent), and recordedAt.

Store interface methods:

  • TryAppend — first row id or idempotency key returns true; duplicates return false. Missing contract_instance row, or a player_id that does not match the instance owner, returns false (Postgres SQL guard + FK/unique catch; in-memory checks IContractInstanceStore.TryGet) — orchestrators (NEO-149) must create the instance before appending an outcome.
  • TryGetByIdempotencyKey — read one outcome for delivery replay / audit tests.
  • GetOutcomesForInstance — list outcomes for one contract instance id (ordered by recorded time).
  • TryClearForInstance — dev fixture only.

Storage: in-memory when Postgres is unset (InMemoryContractOutcomeStore requires a matching instance row in IContractInstanceStore); PostgresContractOutcomeStore appends to contract_outcome (V012__contract_outcome.sql) with JSONB grant snapshots when configured. Plan: NEO-146 implementation plan.

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, optional factionGateRules (factionId, minStanding — omitted when the quest has no gates), and nested steps (id, displayName, objectives with flat objective fields per kind — unused keys omitted). Plan: NEO-115 implementation plan; gate projection: NEO-140 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: POST …/quests/{questId}/accept (NEO-120).

Store interface methods:

  • CanWritePlayer — whether mutations are allowed for the player (dev bucket / Postgres player_position).
  • TryGetProgress — read one row; false when not started.
  • TryActivate — first accept (not_startedactive).
  • 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 returns true; store replay returns false without changing completedAt.

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 NEO-128: delivers completionRewardBundle via RewardRouterOperations (deliver-then-mark), then marks active quest complete. Idempotent: when already completed, returns success: true without calling the router; replay after first-time complete also skips re-delivery. On delivery deny, quest stays active and reasonCode passthroughs router codes (e.g. inventory_full).

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), optional completedAt when completed, and optional completionRewardSummary when completed and a delivery event exists in IRewardDeliveryStore. Quests are ordered by catalog id (ordinal).

Field When present
completedAt status: completed
completionRewardSummary status: completed and IRewardDeliveryStore.TryGet hits — maps GrantedItemsitemGrants (itemId, quantity), GrantedSkillXpskillXpGrants (skillId, amount), and GrantedReputationreputationGrants (factionId, amount). Omitted when not completed or when no delivery record exists.

Prototype: complete prototype_quest_gather_intro via objective wiring → row completed with completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }], empty itemGrants, and empty reputationGrants. Operator chain completion adds reputationGrants: [{ factionId: prototype_faction_grid_operators, amount: 15 }]. Plan: NEO-129 implementation plan; rep projection: NEO-140 implementation plan; Bruno: bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru, Get quest progress after operator chain complete.bru.

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":{}}]}

Quest accept POST (NEO-120)

POST /game/players/{id}/quests/{questId}/accept transitions not_startedactive via QuestStateOperations.TryAccept. Unknown or blank player ids return 404 (position gate). Request body is optional v1 — omit body, send {}, or { "schemaVersion": 1 }; 400 when body is present with schemaVersion other than 1 (unset 0 is treated as omitted). Faction-gated quests (NEO-137) deny with faction_gate_blocked when standing is below any FactionGateRule.minStanding after prerequisites pass.

Response (schemaVersion 1): accepted (true/false), optional reasonCode on deny, optional quest row (same shape as GET list entries). Structured denies return HTTP 200 with accepted: false (inventory/craft precedent). Accept-time inventory_has_item wiring runs inside TryAccept (NEO-118).

Outcome accepted reasonCode quest row
Success true omitted active snapshot
Deny (no row) false e.g. unknown_quest, prerequisite_incomplete, faction_gate_blocked omitted
Deny (existing row) false e.g. already_active, already_completed current snapshot

Plan: NEO-120 implementation plan; Bruno bruno/neon-sprawl-server/quest-progress/ (accept spine).

curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/quests/prototype_quest_gather_intro/accept" \
  -H "Content-Type: application/json" \
  -d '{"schemaVersion":1}'

Sample success:

{"schemaVersion":1,"accepted":true,"quest":{"questId":"prototype_quest_gather_intro","status":"active","currentStepIndex":0,"objectiveCounters":{}}}

Quest telemetry hooks (NEO-121)

Comment-only hook sites in QuestStateOperations for future E9.M1 catalog events (Epic 7 Slice 1). TODO(E9.M1) — no production ingest. HTTP POST …/accept and QuestObjectiveWiring delegate to the engine only (no duplicate API-layer or wiring-layer hooks).

Event Anchor When
quest_start TryAccept After successful TryActivate (first activation only).
quest_step_complete TryAdvanceStep After successful store commit (one emit per advance; chain quests may emit multiple per wiring pass).
quest_complete TryMarkComplete After first-time completion commit (not idempotent replay when already completed).
quest_accept_denied TryAccept deny paths Every structured accept deny with stable reasonCode.

Manual QA: docs/manual-qa/NEO-121.md; plan: NEO-121 implementation plan.

Reward delivery store (NEO-126)

IRewardDeliveryStore persists RewardDeliveryEvent exactly once per player+source when completion bundle grants commit successfully via RewardRouterOperations (NEO-127, NEO-148). Prototype uses InMemoryRewardDeliveryStore (registered via AddRewardDeliveryStore in Program.cs).

Field Role
PlayerId Normalized key (trim + lowercase).
SourceKind quest_completion (NEO-126) or contract_completion (NEO-148).
QuestId Set for quest deliveries; empty for contract deliveries.
ContractInstanceId Set for contract deliveries (NEO-148); empty for quest deliveries.
DeliveredAt UTC timestamp at delivery record commit.
GrantedItems Snapshot of applied item rows (itemId, quantity) at commit time.
GrantedSkillXp Snapshot of applied skill XP rows (skillId, amount) at commit time.
GrantedReputation Snapshot of applied reputation rows (factionId, amount) at commit time (NEO-138).
IdempotencyKey Stable dedupe key — quest: {playerId}:quest_complete:{questId}; contract: {playerId}:contract_complete:{contractInstanceId}.

Store interface:

  • TryRecord — first delivery returns true; replays for the same player+source return false without overwriting the snapshot.
  • TryGet(playerId, questId) — quest completion lookup for HTTP completionRewardSummary projection (NEO-129).
  • TryGet(playerId, sourceKind, sourceId) — source-kind-aware lookup (contract completion in NEO-148).

Encounter loot remains on IEncounterCompleteEventStore (E5.M3); quest and contract completion rewards use this store. Contract outcome audit rows live separately on IContractOutcomeStore (NEO-146). Postgres persistence deferred. Plans: NEO-126, NEO-148.

Reward router (NEO-127)

RewardRouterOperations.TryDeliverQuestCompletion applies a quest completionRewardBundle via PlayerInventoryOperations (item rows), SkillProgressionGrantOperations.TryApplyGrant with fixed mission_reward source kind (skill XP rows), and ReputationOperations.TryApplyDelta with ReputationDeltaSourceKinds.QuestCompletion (reputation rows — NEO-138), then records RewardDeliveryEvent in IRewardDeliveryStore.

RewardRouterOperations.TryDeliverContractCompletion (NEO-148) mirrors the quest path for contract template completionRewardBundle rows with idempotency key {playerId}:contract_complete:{contractInstanceId}, ReputationDeltaSourceKinds.ContractCompletion, and SourceKind = contract_completion on the delivery event.

Reason code When
already_delivered Success replay — store already has a delivery for this player+source (no re-grant).
invalid_player_id, invalid_quest_id, invalid_contract_instance_id Normalized id empty after trim.
inventory_store_missing Player inventory store could not read/write.
inventory_full, invalid_item, invalid_quantity Item grant pre-flight or apply failed.
unknown_skill, invalid_amount, source_kind_not_allowed Skill XP grant denied.
progression_store_missing Skill progression store could not apply XP delta.
unknown_faction, invalid_delta, audit_append_failed, invalid_delta_id, invalid_source Reputation grant denied via ReputationOperations (NEO-138).

Apply order: items first, then skill XP, then reputation. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation deltaId: {idempotencyKey}:rep:{factionId}; race/compensating revert uses :rollback suffix on inverse delta. Idempotent replay checks TryGet before apply. If grants succeed but TryRecord returns false (concurrent race), this call rolls back item grants, reverts skill XP via SkillProgressionGrantOperations.TryRevertAppliedSkillGrants, and reverts reputation via inverse TryApplyDelta — then returns the existing store event (mirror EncounterCompletionOperations compensating rollback when TryMarkCompleted loses). Quest-state wiring (NEO-128): QuestStateOperations.TryMarkComplete and QuestObjectiveWiring terminal-step completion call TryDeliverQuestCompletion before IPlayerQuestStateStore.TryMarkComplete. Contract wiring (NEO-149): ContractCompletionOperations will call TryDeliverContractCompletion. Bruno quest-flow smoke: bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru (NEO-138). Plans: NEO-127, NEO-128, NEO-138, NEO-148.

Reward telemetry hooks (NEO-130)

Comment-only hook sites in RewardRouterOperations.TryDeliverQuestCompletion for future E9.M1 catalog events (Epic 7 Slice 2). TODO(E9.M1) — no production ingest. QuestStateOperations and HTTP quest routes delegate to the router only (no duplicate API-layer or quest-state hooks for these names). quest_complete remains in QuestStateOperations (NEO-121); reward_delivery fires earlier on the deliver-then-mark path (NEO-128).

Event Anchor When
reward_delivery TryDeliverQuestCompletion After TryRecord returns true on first-time delivery (not idempotent TryGet replay or race-loser paths).
unlock_granted TryDeliverQuestCompletion (stub) Future UnlockGrant apply from bundle rows — prototype bundles have item + skill XP only; no runtime unlock apply in Slice 2.

reputation_delta from quest bundle rep rows emits via ReputationOperations.TryApplyDelta (NEO-141) — not duplicated in the router.

perk_unlock side effects from skill XP grants use PerkUnlockEngine (NEO-49) — distinct from content UnlockGrant rows. Manual QA: docs/manual-qa/NEO-130.md; plan: NEO-130 implementation plan.

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 via IEncounterDefinitionRegistry, starts progress when not completed.
  • TryMarkTargetDefeated(…) — requires prior activation; accumulates defeats in any order; idempotent per NPC.
  • IsAllRequiredTargetsDefeated(playerId, encounterId, …) — set equality vs catalog requiredNpcInstanceIds.

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 tables 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 Encounters 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), clears IPlayerAbilityCooldownStore slot cooldowns for the configured dev player, 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-snapshotaggroHolderPlayerId 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 idleaggro. 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 drives after.Defeated to true (re-hit on defeated targets denies at target_defeated before damage).
  • Reserved (comments only): encounter_start — E5.M3 anchor: Encounter telemetry hooks (NEO-109).
  • player_death — only when lethal NPC damage drives player currentHp to 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 stable reasonCode on every structured deny via private Deny (all CraftReasonCodes, 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.

Dev resource-node fixture (Bruno/manual QA): When Game:EnableResourceNodeFixtureApi is true (default in Development via appsettings.Development.json, Testing, or CI Bruno step) or the host environment is Development / Testing, POST /game/__dev/resource-node-fixture with schemaVersion 1 upserts every catalog node to maxGathers via TrySetRemainingGathers. 404 when disabled. Bruno: scripts/bruno-dev-fixture-helper.js (resetPrototypeResourceNodes).

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 023) 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 skills 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 CombatDefeatGigXpGrantnot 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_xsequence, 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 stores 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 projects 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:DistrictMinXDistrictMaxZ District extents when enabled ±12 / Y 2…24

Manual QA (Godot main.tscn): Green bumps are two random short cylinders each run (~814 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 as GET/position).
  • 400 — malformed command (missing/invalid body or wrong schemaVersion) or move rejected by NEO-10 rules. Rejection responses are JSON MoveCommandRejectedResponse: 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 interactables 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 interactables 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 targets 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 → returns HotbarLoadoutResponse v1 with fixed slotCount 8 and slots array entries (slotIndex, nullable abilityId) for every slot.
  • POST /game/players/{id}/hotbar-loadout with HotbarLoadoutUpdateRequest v1 (schemaVersion, slots[]) upserts one or more slot bindings and returns HotbarLoadoutUpdateResponse v1.

Prototype policy (kickoff decision):

  • Scope is per player id (same keying strategy as PositionState / TargetState).
  • Persistence mirrors NEO-8/NS-17: Postgres when ConnectionStrings:NeonSprawl exists, 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-cast with AbilityCastRequest v1 (schemaVersion, slotIndex 0..7, abilityId, targetId) validates the player exists, the slot is bound in persisted hotbar loadout, and abilityId matches that binding and the prototype allowlist. NEO-28: targetId must be non-empty, exist in PrototypeNpcRegistry, 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 with on_cooldown when the slot is still cooling. NEO-82: after gates pass, invokes CombatOperations.TryResolve; on success commits per-ability cooldownSeconds from the ability catalog and returns AbilityCastResponse v1 with nested combatResolution.

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 3named 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-snapshotCooldownSnapshotResponse v1 (schemaVersion, playerId, serverTimeUtc, slots[] with slotIndex and optional cooldownEndsAtUtc per 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.json environmentVariables (e.g. DOTNET_ROOT) often do not apply to the native apphost Rider launches first; the host probes /usr/share/dotnet before your app starts.
  • This repo sets <UseAppHost>false</UseAppHost> for Debug builds so the IDE runs dotnet …/NeonSprawl.Server.dll using 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/dotnet has 10.x, or add DOTNET_ROOT=/home/don/.dotnet in Run → Edit Configurations → Environment variables (IDE-level, not only launchSettings.json).