Compare commits

...

23 Commits

Author SHA1 Message Date
VinPropane f265983922
Merge pull request #73 from ViPro-Technologies/NEO-39-data-driven-skill-level-curve-ci-validation
NEO-39: data-driven skill level curve and startup schema validation
2026-05-06 23:11:38 -04:00
VinPropane 236040a5c2 NEO-39: add data-driven level curve with startup schema validation
Replace placeholder XP level math with content-backed thresholds validated in CI and at server boot, and update docs/manual QA/review artifacts to reflect NEO-39 delivery and follow-up fixes.
2026-05-06 23:10:06 -04:00
VinPropane 386bfbc1d4
Merge pull request #72 from ViPro-Technologies/NEO-38-apply-skill-xp-grant-allowlist-level-up
NEO-38: Apply skill XP grant, allowlist validation, and level-up response
2026-05-06 22:40:43 -04:00
VinPropane 310d2b637b NEO-38: reset player_position before progression persistence test 2026-05-06 22:39:18 -04:00
VinPropane ad9259c308 NEO-38: set integration test host to Testing for deterministic JSON errors 2026-05-06 22:34:26 -04:00
VinPropane b2999f3ed9 NEO-38: address review — docs alignment, Bruno deny, README levelUps trim 2026-05-06 22:26:23 -04:00
VinPropane 9ed4034140 NEO-38: add code review (2026-05-06) 2026-05-06 22:24:17 -04:00
VinPropane bab7336e5b NEO-38: Bruno GET skill progression tolerant of persisted XP 2026-05-06 22:23:11 -04:00
VinPropane 2bcde45891 NEO-38: add curl examples to manual QA 2026-05-06 22:20:14 -04:00
VinPropane 33b2e917e4 NEO-38: skill XP POST grant, persistence, level-up response 2026-05-06 22:17:02 -04:00
VinPropane 58b9016d40 NEO-38: add implementation plan for skill XP grant and level-up 2026-05-06 22:11:16 -04:00
VinPropane 9e8914708f
Merge pull request #71 from ViPro-Technologies/chore/ci-skip-smoke-test
chore: CI skip smoke test (docs-only whitespace)
2026-05-06 22:07:31 -04:00
VinPropane c58e716669 chore: smoke-test path-filtered CI skip (docs-only whitespace) 2026-05-06 22:04:39 -04:00
VinPropane dc2376a768
Merge pull request #70 from ViPro-Technologies/chore/gdscript-ci-pr-paths-filter
chore: GDScript CI runs on every PR with path-aware skip
2026-05-06 22:02:45 -04:00
VinPropane 248600db4e chore: run GDScript CI on every PR; skip lint/test when unchanged 2026-05-06 22:01:49 -04:00
VinPropane 3e6cda6383
Merge pull request #69 from ViPro-Technologies/NEO-37-skill-progression-snapshot-read-model
NEO-37: skill progression snapshot read model
2026-05-06 21:58:42 -04:00
VinPropane a89aadbb43 NEO-37: re-review notes for unordered skills contract update 2026-05-06 21:54:13 -04:00
VinPropane a3cbef063c NEO-37: treat skill progression JSON array order as non-contractual 2026-05-06 21:53:24 -04:00
VinPropane 295eb34c68 NEO-37: reconcile decomposition docs + review feedback 2026-05-06 21:51:44 -04:00
VinPropane 5e180f3aa0 NEO-37: code review snapshot for skill progression API branch 2026-05-06 21:50:37 -04:00
VinPropane 9cd96a72c3 NEO-37: add skill progression snapshot GET API, tests, Bruno, docs 2026-05-06 21:48:17 -04:00
VinPropane 413484000f NEO-37: add implementation plan for skill progression snapshot API 2026-05-06 21:46:41 -04:00
VinPropane 15ab06f3e7 docs: note canonical GitHub org for Linear and doc links 2026-05-04 21:24:25 -04:00
47 changed files with 2198 additions and 43 deletions

View File

@ -1,5 +1,10 @@
# Lint, format-check, and unit-test GDScript (Godot 4.6). gdtoolkit: https://github.com/Scony/godot-gdscript-toolkit # Lint, format-check, and unit-test GDScript (Godot 4.6). gdtoolkit: https://github.com/Scony/godot-gdscript-toolkit
# GDUnit4 v6.1.2 under client/addons/gdUnit4; tests in client/test/ # GDUnit4 v6.1.2 under client/addons/gdUnit4; tests in client/test/
#
# PRs: workflow always runs so "GDScript / lint_and_test" can be a required check. Path-sensitive
# work uses step-level `if` — skipped steps still leave the job successful (unlike a workflow
# skipped via `on.paths`, which leaves the required check missing/pending).
# push: path filter on `on` avoids redundant runs on unrelated commits to main.
name: GDScript name: GDScript
on: on:
@ -13,12 +18,11 @@ on:
- ".github/workflows/gdscript.yml" - ".github/workflows/gdscript.yml"
pull_request: pull_request:
branches: [main] branches: [main]
paths:
- "client/scripts/**" # dorny/paths-filter reads the PR file list from the GitHub API; needs pull-requests:read.
- "client/test/**" permissions:
- "client/addons/**" contents: read
- "client/project.godot" pull-requests: read
- ".github/workflows/gdscript.yml"
jobs: jobs:
lint_and_test: lint_and_test:
@ -29,20 +33,42 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Detect GDScript-relevant changes (PR only)
id: paths
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@v3
with:
filters: |
gdscript:
- "client/scripts/**"
- "client/test/**"
- "client/addons/**"
- "client/project.godot"
- ".github/workflows/gdscript.yml"
- name: Skip GDScript lint and tests (no client GDScript paths in this PR)
if: github.event_name == 'pull_request' && steps.paths.outputs.gdscript != 'true'
run: echo "Skipping gdlint, gdformat, and GDUnit4."
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
with: with:
python-version: "3.x" python-version: "3.x"
- name: Install gdtoolkit - name: Install gdtoolkit
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
run: pip install "gdtoolkit==4.5.0" run: pip install "gdtoolkit==4.5.0"
- name: gdlint - name: gdlint
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
run: gdlint client/scripts client/test run: gdlint client/scripts client/test
- name: gdformat (check only) - name: gdformat (check only)
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
run: gdformat --check client/scripts client/test run: gdformat --check client/scripts client/test
- name: Cache Godot 4.6 - name: Cache Godot 4.6
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
id: godot-cache id: godot-cache
with: with:
@ -50,7 +76,7 @@ jobs:
key: godot-4.6-stable-linux-x86_64 key: godot-4.6-stable-linux-x86_64
- name: Download Godot 4.6 - name: Download Godot 4.6
if: steps.godot-cache.outputs.cache-hit != 'true' if: (github.event_name == 'push' || steps.paths.outputs.gdscript == 'true') && steps.godot-cache.outputs.cache-hit != 'true'
run: | run: |
mkdir -p ~/godot mkdir -p ~/godot
wget -q "https://github.com/godotengine/godot/releases/download/4.6-stable/Godot_v4.6-stable_linux.x86_64.zip" -O /tmp/godot.zip wget -q "https://github.com/godotengine/godot/releases/download/4.6-stable/Godot_v4.6-stable_linux.x86_64.zip" -O /tmp/godot.zip
@ -58,10 +84,12 @@ jobs:
chmod +x ~/godot/Godot_v4.6-stable_linux.x86_64 chmod +x ~/godot/Godot_v4.6-stable_linux.x86_64
- name: Import project (.godot) - name: Import project (.godot)
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
working-directory: client working-directory: client
run: ~/godot/Godot_v4.6-stable_linux.x86_64 --headless --import --path . --quit-after 10 run: ~/godot/Godot_v4.6-stable_linux.x86_64 --headless --import --path . --quit-after 10
- name: Verify GDUnit path (Linux res:// is case-sensitive) - name: Verify GDUnit path (Linux res:// is case-sensitive)
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
working-directory: client working-directory: client
run: | run: |
test -f addons/gdUnit4/bin/GdUnitCmdTool.gd || { test -f addons/gdUnit4/bin/GdUnitCmdTool.gd || {
@ -73,6 +101,7 @@ jobs:
# GdUnit returns 0 if *executed* tests pass, even when Godot logs SCRIPT ERROR during suite # GdUnit returns 0 if *executed* tests pass, even when Godot logs SCRIPT ERROR during suite
# discovery (broken test files are skipped). Fail the job if those errors appear in output. # discovery (broken test files are skipped). Fail the job if those errors appear in output.
- name: GDUnit4 (headless) - name: GDUnit4 (headless)
if: github.event_name == 'push' || steps.paths.outputs.gdscript == 'true'
working-directory: client working-directory: client
shell: bash shell: bash
run: | run: |

View File

@ -0,0 +1,66 @@
meta {
name: GET skill progression
type: http
seq: 1
}
docs {
NEO-37: per-player read model; join with GET /game/world/skill-definitions for display names.
NEO-39: XP persists (in-memory/Postgres) and levels are derived from content/skills/prototype_level_curve.json. These checks do not assume a fresh server. Restart server (or truncate DB in Postgres mode) to see all-zero defaults again.
}
get {
url: {{baseUrl}}/game/players/dev-local-1/skill-progression
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and skills array", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal("dev-local-1");
expect(body.skills).to.be.an("array");
expect(body.skills.length).to.equal(3);
});
test("frozen prototype trio with levels consistent with content-backed curve", function () {
const body = res.getBody();
const ids = new Set(body.skills.map((x) => x.id));
expect(ids.has("salvage")).to.equal(true);
expect(ids.has("refine")).to.equal(true);
expect(ids.has("intrusion")).to.equal(true);
/** Matches prototype_level_curve.json (NEO-39). */
const thresholds = [
{ level: 1, requiredXp: 0 },
{ level: 2, requiredXp: 100 },
{ level: 3, requiredXp: 250 },
{ level: 4, requiredXp: 450 },
{ level: 5, requiredXp: 700 },
];
const expectedLevel = (xp) => {
const clamped = Math.max(0, xp);
let level = 1;
for (const row of thresholds) {
if (clamped < row.requiredXp) {
break;
}
level = row.level;
}
return level;
};
for (const id of ["salvage", "refine", "intrusion"]) {
const row = body.skills.find((x) => x.id === id);
expect(row).to.be.an("object");
expect(row.xp).to.be.a("number");
expect(row.level).to.be.a("number");
expect(row.xp).to.be.at.least(0);
expect(row.level).to.be.at.least(1);
expect(row.level).to.equal(expectedLevel(row.xp));
}
});
}

View File

@ -0,0 +1,36 @@
meta {
name: POST skill progression grant deny source kind
type: http
seq: 3
}
docs {
NEO-38: salvage does not allow trainer in prototype_skills.json; expects granted false + source_kind_not_allowed.
}
post {
url: {{baseUrl}}/game/players/dev-local-1/skill-progression
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"amount": 10,
"sourceKind": "trainer"
}
}
tests {
test("denies with source_kind_not_allowed and echoes progression", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.granted).to.equal(false);
expect(body.reasonCode).to.equal("source_kind_not_allowed");
expect(body.progression).to.be.an("object");
expect(body.progression.skills.length).to.equal(3);
});
}

View File

@ -0,0 +1,40 @@
meta {
name: POST skill progression grant
type: http
seq: 2
}
docs {
NEO-38: single skill XP grant + allowlist + level-up metadata; see server README.
}
post {
url: {{baseUrl}}/game/players/dev-local-1/skill-progression
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"amount": 10,
"sourceKind": "activity"
}
}
tests {
test("grant returns 200 with granted true and progression", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.granted).to.equal(true);
expect(body.progression).to.be.an("object");
expect(body.progression.schemaVersion).to.equal(1);
expect(body.progression.playerId).to.equal("dev-local-1");
expect(body.levelUps).to.be.an("array");
const salvage = body.progression.skills.find((x) => x.id === "salvage");
expect(salvage).to.be.an("object");
expect(salvage.xp).to.be.a("number");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: skill-progression
}

View File

@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/level-curve.schema.json",
"title": "LevelCurve",
"description": "Prototype level thresholds for E2.M2. Levels and requiredXp values must increase strictly row-to-row.",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "levels"],
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"levels": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["level", "requiredXp"],
"properties": {
"level": {
"type": "integer",
"minimum": 1
},
"requiredXp": {
"type": "integer",
"minimum": 0
}
}
}
}
}
}

View File

@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"levels": [
{ "level": 1, "requiredXp": 0 },
{ "level": 2, "requiredXp": 100 },
{ "level": 3, "requiredXp": 250 },
{ "level": 4, "requiredXp": 450 },
{ "level": 5, "requiredXp": 700 }
]
}

View File

@ -62,7 +62,7 @@ Every `SkillDef` row **must** include a **non-empty** `allowedXpSourceKinds` arr
- **`mission_reward`** — wire **explicit** mission/contract hand-ins or scripted bonuses only; not a stand-in for combat encounter XP ([`docs/game-design/progression.md`](../../game-design/progression.md) — mission vs combat). - **`mission_reward`** — wire **explicit** mission/contract hand-ins or scripted bonuses only; not a stand-in for combat encounter XP ([`docs/game-design/progression.md`](../../game-design/progression.md) — mission vs combat).
- **`trainer`** / **`book_or_item`** — wire NPC teaching beats and consumable/one-shot grants respectively; do not use for combat kills. - **`trainer`** / **`book_or_item`** — wire NPC teaching beats and consumable/one-shot grants respectively; do not use for combat kills.
**Combat encounters award gig XP, not skill XP.** If a grant channel is missing from a skills list, [E2.M2](E2_M2_XpAwardAndLevelEngine.md) must reject the grant once implemented—keep catalogs honest so call sites do not drift. **Combat encounters award gig XP, not skill XP.** If a grant channel is missing from a skills list, [E2.M2](E2_M2_XpAwardAndLevelEngine.md) rejects the grant at apply time (**NEO-38** HTTP grant path)—keep catalogs honest so call sites do not drift.
## Prototype Slice 1 freeze ([NEO-33](https://linear.app/neon-sprawl/issue/NEO-33)) ## Prototype Slice 1 freeze ([NEO-33](https://linear.app/neon-sprawl/issue/NEO-33))

View File

@ -7,11 +7,19 @@
| **Module ID** | E2.M2 | | **Module ID** | E2.M2 |
| **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) | | **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) |
| **Stage target** | Prototype | | **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) | | **Status** | In Progress (see [dependency register](module_dependency_register.md)) |
## Implementation snapshot ## Implementation snapshot
**Linear (label `E2.M2`):** [Epic 2 project](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) — **Slice 2:** [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression read snapshot) → [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (apply grant + allowlist + level-up) → [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39) (data-driven `LevelCurve` + CI); [NEO-40](https://linear.app/neon-sprawl/issue/NEO-40) (telemetry hook sites) after NEO-38, parallel with NEO-39. **Slice 3 integration** (multi-label where noted): [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41), [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42), [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43), [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). No implementation landed yet; update this section and the [alignment](documentation_and_implementation_alignment.md) row when stories merge. **Linear (label `E2.M2`):** [Epic 2 project](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) — **Slice 2:** [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression read snapshot) → [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (apply grant + allowlist + level-up) → [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39) (data-driven `LevelCurve` + CI); [NEO-40](https://linear.app/neon-sprawl/issue/NEO-40) (telemetry hook sites) after NEO-38, parallel with NEO-39. **Slice 3 integration** (multi-label where noted): [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41), [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42), [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43), [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44).
**NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([implementation plan](../../plans/NEO-37-implementation-plan.md)) — `SkillProgressionSnapshotApi` / `SkillProgressionSnapshotDtos` under `server/NeonSprawl.Server/Game/Skills/`; one row per `ISkillDefinitionRegistry` skill; **`404`** when the player has no position (same gate as **`GET …/cooldown-snapshot`**). Manual QA **[`NEO-37.md`](../../manual-qa/NEO-37.md)**; **[server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37)**.
**NEO-38 landed:** versioned **`POST /game/players/{id}/skill-progression`** ([implementation plan](../../plans/NEO-38-implementation-plan.md)) — applies one grant per request with **`ISkillDefinitionRegistry`** + **`allowedXpSourceKinds`** validation, stable deny **`reasonCode`** values, dual persistence (**`IPlayerSkillProgressionStore`**: in-memory / Postgres + [`V003__player_skill_progression.sql`](../../../server/db/migrations/V003__player_skill_progression.sql)), GET merges stored XP with the registry; success payload includes **`levelUps`** (see README for multi-step semantics). Bruno **`bruno/neon-sprawl-server/skill-progression/`** (GET + POST + deny); manual QA **[`NEO-38.md`](../../manual-qa/NEO-38.md)**; **[server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38)**.
**NEO-39 landed:** data-driven **`LevelCurve`** at `content/skills/prototype_level_curve.json` with schema `content/schemas/level-curve.schema.json`; CI validation extended in `scripts/validate_content.py`; server boot now fail-fast loads curve content and uses it for XP→level resolution in skill progression GET/POST paths (no code edit required for threshold tuning).
**Still backlog within this module:** telemetry hook sites (**NEO-40**); callers in Slice 3 (**NEO-41****NEO-43**). Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge.
## Purpose ## Purpose

View File

@ -50,8 +50,8 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) | | E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) |
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) | | E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) |
| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) | | E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) |
| E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **Follow-on (E2.M2):** XP grant validation vs `allowedXpSourceKinds` per [E2_M1](E2_M1_SkillDefinitionRegistry.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) | | E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) |
| E2.M2 | Planned | **Backlog (Linear):** Slice 2 vertical slices — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (player skill progression read snapshot) → [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (apply XP grant + `allowedXpSourceKinds` validation + level-up) → [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39) (data-driven `LevelCurve` + CI); [NEO-40](https://linear.app/neon-sprawl/issue/NEO-40) (telemetry hook sites, parallel with NEO-39 after NEO-38). Slice 3 integration — [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) / [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) / [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) (multi-label with **E3**/**E7**); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) (gig XP from combat, **E5.M1**). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-40, NEO-41NEO-43 | | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **Follow-on:** [NEO-40](https://linear.app/neon-sprawl/issue/NEO-40) (telemetry hook sites). Slice 3 integration — [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) / [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) / [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-40, NEO-41NEO-43 |
--- ---

View File

@ -28,12 +28,14 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status | | Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| E2.M1 | SkillDefinitionRegistry | None | SkillDef, SkillCategory, UnlockRequirement, allowedXpSourceKinds | Prototype | In Progress | | E2.M1 | SkillDefinitionRegistry | None | SkillDef, SkillCategory, UnlockRequirement, allowedXpSourceKinds | Prototype | In Progress |
| E2.M2 | XpAwardAndLevelEngine | E2.M1 | XpGrantEvent, LevelCurve, LevelUpEvent | Prototype | Planned | | E2.M2 | XpAwardAndLevelEngine | E2.M1 | XpGrantEvent, LevelCurve, LevelUpEvent | Prototype | In Progress |
| E2.M3 | MasteryAndPerkUnlocks | E2.M2 | MasteryTrack, PerkUnlockEvent, PerkState | Pre-production | Planned | | E2.M3 | MasteryAndPerkUnlocks | E2.M2 | MasteryTrack, PerkUnlockEvent, PerkState | Pre-production | Planned |
| E2.M4 | ProgressionPacingControls | E2.M2, E9.M2 | XpModifierProfile, CatchUpRule, PacingPolicy | Pre-production | Planned | | E2.M4 | ProgressionPacingControls | E2.M2, E9.M2 | XpModifierProfile, CatchUpRule, PacingPolicy | Pre-production | Planned |
**E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). **NEO-33** (content + CI + docs) moves the register row to **In Progress**; **NEO-34+** cover server registry and HTTP. Update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when slices land. **E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). **NEO-33** (content + CI + docs) moves the register row to **In Progress**; **NEO-34+** cover server registry and HTTP. Update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when slices land.
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
### Epic 3 — Crafting Economy ### Epic 3 — Crafting Economy
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status | | Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |

View File

@ -0,0 +1,27 @@
# NEO-37 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-37 |
| Title | Skill progression snapshot (read model) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-37/skill-progression-snapshot-read-model |
| Plan | `docs/plans/NEO-37-implementation-plan.md` |
| Branch | `NEO-37-skill-progression-snapshot-read-model` |
## Preconditions
- Server running with default dev player seeded (same as other **`/game/players/dev-local-1/...`** flows).
- Skill catalog loaded (prototype trio); **`skills`** is key-by-**`id`** (array order is not contractual).
## Checklist
1. Start **`NeonSprawl.Server`** (e.g. `dotnet run` from `server/NeonSprawl.Server`).
2. **`GET /game/players/dev-local-1/skill-progression`** — expect **200** and **`Content-Type`** containing **`application/json`**.
```bash
curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression"
```
3. Parse JSON — **`schemaVersion`** **1**, **`playerId`** **`dev-local-1`**, **`skills`** length **3** with **`id`**s **`intrusion`**, **`refine`**, **`salvage`** (any order); each row **`xp`** **0**, **`level`** **1**.
4. Unknown player — **`GET /game/players/missing-player/skill-progression`** — expect **404**.
5. Optional: run **`bruno/neon-sprawl-server/skill-progression/Get skill progression.bru`** (see `environments/Local.bru` for **`{{baseUrl}}`**).

View File

@ -0,0 +1,68 @@
# Manual QA — NEO-38 (skill XP grant)
Reference: [implementation plan](../plans/NEO-38-implementation-plan.md), [server README](../../server/README.md#skill-progression-grant-neo-38).
## Preconditions
- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`).
- Use a **known** player id (e.g. `dev-local-1`, present in position state).
Use a shell variable so you only change the base URL once:
```bash
BASE=http://localhost:5253
ID=dev-local-1
```
## Happy path — grant + read back
1. **Baseline read** (optional): note `salvage.xp` before the grant (usually **0** on a fresh store).
```bash
curl -sS "${BASE}/game/players/${ID}/skill-progression"
```
2. **Grant**`schemaVersion` **1**, `skillId` **`salvage`**, `amount` **10**, `sourceKind` **`activity`**.
```bash
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":10,"sourceKind":"activity"}'
```
Expect **HTTP 200**, JSON `granted` **true**, `levelUps` may be empty if no level boundary crossed (10 XP stays at level 1 with the prototype curve).
3. **Read back**: `salvage.xp` in the snapshot should reflect the persisted total (baseline + **10**, or cumulative if you repeated the POST).
```bash
curl -sS "${BASE}/game/players/${ID}/skill-progression"
```
## Deny — source not allowed
`skillId` **`salvage`** does **not** allow **`trainer`** in the prototype catalog; the server still returns **200** with structured deny.
```bash
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":10,"sourceKind":"trainer"}'
```
Expect **HTTP 200**, `granted` **false**, **`reasonCode`** **`source_kind_not_allowed`**, and `progression` present (snapshot unchanged except what other requests already applied).
## Bonus — observe a level-up in one response
With the prototype curve `level = 1 + floor(xp / 100)`, a grant of **100** XP from **0** on `intrusion` should set `granted` **true** and include **`levelUps`** (`previousLevel` **1**, `newLevel` **2**).
```bash
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"intrusion","amount":100,"sourceKind":"activity"}'
```
## Bruno
- Folder: `bruno/neon-sprawl-server/skill-progression/`
- `Post skill progression grant.bru` — happy grant.
- `Post skill progression grant deny source kind.bru``source_kind_not_allowed` on salvage + `trainer`.
- `Get skill progression.bru` — confirms read model after grants.

View File

@ -0,0 +1,51 @@
# NEO-39 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-39 |
| Title | Data-driven skill level curve + CI validation |
| Linear | https://linear.app/neon-sprawl/issue/NEO-39/data-driven-skill-level-curve-ci-validation |
| Plan | `docs/plans/NEO-39-implementation-plan.md` |
| Branch | `NEO-39-data-driven-skill-level-curve-ci-validation` |
## 1) Setup sanity
- [ ] From repo root, run `python3 scripts/validate_content.py` and confirm output reports both skill catalogs and level curve catalogs as valid.
- [ ] Confirm `content/skills/prototype_level_curve.json` exists and includes ascending thresholds (`level` and `requiredXp` increase row-to-row).
- [ ] Start server from `server/NeonSprawl.Server` with `dotnet run` and verify startup succeeds (no level-curve load errors).
## 2) Happy path (acceptance criteria coverage)
- [ ] `GET /game/players/dev-local-1/skill-progression` returns `200` and JSON with `schemaVersion: 1`, `playerId: "dev-local-1"`, and skill rows.
- [ ] Post a grant that does not cross the next threshold and confirm `level` remains unchanged.
```bash
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":99,"sourceKind":"activity"}'
```
- [ ] Post an additional grant that crosses a threshold and confirm the response contains `levelUps` with expected `previousLevel` and `newLevel`.
```bash
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":1,"sourceKind":"activity"}'
```
- [ ] Read back progression and verify the skill `xp`/`level` matches the level-curve file thresholds (data-driven behavior, no code edits needed for tuning).
```bash
curl -sS "http://localhost:5253/game/players/dev-local-1/skill-progression"
```
## 3) Failure modes
- [ ] Temporarily break `content/skills/prototype_level_curve.json` (for example: set first row to `{ "level": 1, "requiredXp": 5 }`) and re-run `python3 scripts/validate_content.py`; confirm non-zero exit with a curve validation error.
- [ ] With the same invalid curve file, start server and confirm boot fails fast with a level-curve validation/load error.
- [ ] Restore valid curve file content and confirm validator + server startup both return to green.
## 4) No regressions around skill catalogs
- [ ] Confirm skill catalog validation still works with `*_skills.json` naming (existing prototype skill catalog validates successfully).
- [ ] Confirm progression GET/POST endpoints still behave as before for deny paths (`unknown_skill`, `source_kind_not_allowed`, `invalid_amount`) while level computation follows curve content.

View File

@ -0,0 +1,85 @@
# NEO-37 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-37 |
| **Title** | Skill progression snapshot (read model) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-37/skill-progression-snapshot-read-model |
| **Module** | [E2.M2 — XpAwardAndLevelEngine](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
## Kickoff clarifications
**Asked (Agent `AskQuestion`):** Lock plan defaults for route, 404 rule, JSON shape (`schemaVersion` + `playerId` + `skills` with `id` / `xp` / `level`), Bruno folder style, and `docs/manual-qa/NEO-37.md`.
**Answer:** User chose **Proceed — use these defaults in the plan**.
## Goal, scope, and out-of-scope
**Goal:** Ship a versioned **GET** read model for per-player skill XP and level for every `SkillDef` id from **`ISkillDefinitionRegistry`**, bootstrapping **xp = 0** and **level = 1** before any grants exist. Match **NEO-36** envelope patterns (`schemaVersion`, `System.Text.Json` property names). **`skills`** is semantically **unordered** — clients **key by `id`** (unlike the world skill catalog projection where id order is pinned for discovery).
**In scope (from Linear):**
- Versioned JSON for the agreed player-scoped path.
- One row per registered skill id; coherent defaults (0 XP, level 1).
- Unit/integration tests (AAA) and Bruno collection folder mirroring NEO-36 style.
- Server README section for the endpoint.
**Out of scope (from Linear):**
- Applying XP grants ([NEO-38](https://linear.app/neon-sprawl/issue/NEO-38/apply-skill-xp-grant-allowlist-level-up)).
- Data-driven level curves (separate issue; this slice uses fixed baseline level **1** for all rows).
- Gather/craft/combat integration (Epic 2 Slice 3).
## Acceptance criteria checklist
- [x] Versioned JSON response for **`GET /game/players/{id}/skill-progression`** (`schemaVersion` **1**).
- [x] One row per registered skill id; **`xp`** = **0**, **`level`** = **1** for all rows in this story; consumers treat **`skills`** as unordered and key by **`id`** (wire order not contractual).
- [x] Automated tests (AAA); Bruno folder mirroring NEO-36 (`skill-definitions` conventions).
- [x] **`server/README.md`** section documenting the endpoint (curl + pointers to plan / manual QA / Bruno).
## Technical approach
1. **Route:** **`GET /game/players/{id}/skill-progression`** alongside existing **`/game/players/{id}/…`** reads (`cooldown-snapshot`, `hotbar-loadout`, etc.).
2. **Player gate:** **`404`** when **`!IPositionStateStore.TryGetPosition(id, …)`**, matching **`CooldownSnapshotApi`** (known player/session only).
3. **Response:** Top-level **`schemaVersion`** (**1**, const on a response type like NEO-36s **`SkillDefinitionsListResponse.CurrentSchemaVersion`**), **`playerId`** (**`id.Trim()`**, consistent with **`HotbarLoadoutResponse`**), and **`skills`**: array of objects **`id`** (skill def id), **`xp`** (int, **0**), **`level`** (int, **1**). **Array order is not part of the public contract** — clients index by **`id`**. Do **not** duplicate catalog fields (`displayName`, etc.); clients join with **`GET /game/world/skill-definitions`** if needed.
4. **Data source:** For NEO-37, build rows from **`ISkillDefinitionRegistry`** (no persistence). **Sort by `id` (ordinal) before serialization** for stable diffs only — semantics remain keyed by **`id`**, not by array index. **NEO-38** will introduce stored progression and merge/override defaults; until then there is **no separate store interface** unless implementation discovers a need for testing seams—prefer a small private/static builder method next to the route to keep diff small.
5. **Bruno:** **`bruno/neon-sprawl-server/skill-progression/`** with **`Get skill progression.bru`**. Tests assert **200**, **`schemaVersion`**, **`skills`** length **3**, frozen ids present + **`xp`/`level`** defaults — **do not assert array order**.
6. **README:** Add a subsection after the NEO-36 skill definitions block — curl example for **`dev-local-1`** (or generic `{id}`) and links to this plan, **`docs/manual-qa/NEO-37.md`**, and Bruno path.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | `Map*` extension registering the GET route; position check + registry → JSON. |
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotDtos.cs` | Versioned response + per-skill row DTOs (`schemaVersion`, `playerId`, `skills`). |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionSnapshotApiTests.cs` | **`InMemoryWebApplicationFactory`**: success for **`dev-local-1`**; **404** for unknown player; **ReadFromJsonAsync** asserts schema, trio by **`id`** (map), defaults (**order-independent**). |
| `bruno/neon-sprawl-server/skill-progression/Get skill progression.bru` | Manual / Bruno verification (mirror **`skill-definitions/Get skill definitions.bru`** style). |
| `bruno/neon-sprawl-server/skill-progression/folder.bru` | Optional; align with Bruno folder metadata used beside **`skill-definitions`**. |
| `docs/manual-qa/NEO-37.md` | Short checklist: run server, GET with known vs unknown player id. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Call **`app.MapSkillProgressionSnapshotApi()`** (exact name may match class) next to other `Map*Api` registrations. |
| `server/README.md` | Document **`GET /game/players/{id}/skill-progression`**, curl, and links (plan / manual QA / Bruno). |
## Tests
| Test file | What it covers |
|-----------|------------------|
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionSnapshotApiTests.cs` | **`GET`** returns **404** for **`missing-player`**. **`GET`** for **`dev-local-1`** returns **200**; body has those three **`id`**s with **`xp`** **0**, **`level`** **1**, **without asserting JSON array order**. **AAA** comments per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert). |
Bruno scripts complement automated coverage; they do not replace it.
## Open questions / risks
- **NEO-38 merge point:** When grants land, prefer **replacing** the pure “zeros builder” with a store-backed projection without changing the **public JSON contract** (only values change). If **`schemaVersion`** must bump later, coordinate with clients.
- **None** otherwise.

View File

@ -0,0 +1,107 @@
# NEO-38 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-38 |
| **Title** | Apply skill XP grant + allowlist + level-up |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-38/apply-skill-xp-grant-allowlist-level-up |
| **Module** | [E2.M2 — XpAwardAndLevelEngine](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
| **Blocked by** | [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (read snapshot) — implemented on `main`; this branch builds on that contract. |
## Kickoff clarifications
| Topic | Question / note | Resolution |
|--------|-----------------|------------|
| **Persistence** | NEO-38 AC: follow NEO-29 pattern vs in-memory-only + follow-up | **User:** mirror **Hotbar / NEO-29** — abstract store, **`InMemory*`** fallback, **`Postgres*`** when `ConnectionStrings__NeonSprawl` is set, with schema bootstrap analogous to **`PostgresHotbarLoadoutBootstrap`**. |
| **Successful POST body** | Surface level-up in response vs minimal + GET only | User asked for recommendation → **Agent:** **HTTP 200** with **versioned JSON** that includes **full progression snapshot** (aligned with **`GET …/skill-progression`**) plus an explicit **`levelUps`** (or equivalent) array for thresholds crossed on this grant. **User:** confirmed snapshot + level-ups. |
## Goal, scope, and out-of-scope
**Goal:** Server-authoritative **apply XP grant** path: validate **`skillId`** (`ISkillDefinitionRegistry.TryGetDefinition`) and **`sourceKind`** against **`SkillDefRow.AllowedXpSourceKinds`**; persist per-player XP; recompute level from an **inline placeholder curve** (acceptable until [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39)); return **structured denies** (stable reason codes, hotbar-style **200 + `granted: false`** where that pattern already fits the repo) and **level-up** data on success.
**In scope (from Linear):**
- Reject unknown skill id and disallowed `sourceKind` with **stable reason codes**.
- Valid grant updates persistence; **level** increases when thresholds crossed (**`LevelUpEvent`** semantics surfaced in JSON for this story).
- Versioned **POST** + automated tests; **Bruno** requests; **`server/README.md`**.
- Persistence: **in-memory + Postgres when configured**, per kickoff decision.
**Out of scope (from Linear):**
- Data-driven level curve file + CI (**NEO-39**).
- Telemetry catalog (**NEO-40**).
- Slice 3 activity wiring (**NEO-41****43**).
## Acceptance criteria checklist
- [x] Unknown `skillId` rejected; `sourceKind` not in allowlist rejected (**stable reason codes**).
- [x] Valid grant updates stored progression; level increases when thresholds crossed (**level-up** payload on success).
- [x] Versioned **POST** + tests; Bruno; README.
- [x] Persistence: **NEO-29-style** dual backend (in-memory + Postgres when configured).
## Technical approach
1. **Route:** **`POST /game/players/{id}/skill-progression`** (same resource as NEO-37 **GET**, mirroring **`GET` + `POST` …/hotbar-loadout**). **Reject** missing/invalid **`schemaVersion`** with **400** (same idea as hotbar update).
2. **Player gate:** **`404`** when **`!IPositionStateStore.TryGetPosition`**, unchanged from NEO-37.
3. **Request body (versioned):** e.g. **`schemaVersion`**, **`skillId`**, **`amount`** (positive int), **`sourceKind`** (string matching catalog values: `activity`, `mission_reward`, `trainer`, `book_or_item`). Single grant per request for a small, testable surface; batch grants can be a later issue if needed.
4. **Validation:** Use **`ISkillDefinitionRegistry`**. If unknown skill → deny with **`unknown_skill`** (exact string TBD but stable and documented). If known skill but **`sourceKind`** not in **`AllowedXpSourceKinds`** (case/whitespace normalization consistent with catalog) → **`source_kind_not_allowed`**. Non-positive **`amount`** → **`invalid_amount`** (or reuse a generic **`bad_grant`** only if we want fewer codes — prefer specific codes per AC).
5. **Persistence:** Introduce **`IPlayerSkillProgressionStore`** (name may match existing naming conventions) with operations sufficient to **read XP per skill** and **apply a delta** atomically per request, returning new XP and whether level changed. **In-memory** implementation keyed by normalized player id; **Postgres** implementation with **`EnsureSchema`**, **`player_id` + `skill_id`** uniqueness, XP column (level **derived** from XP + curve helper to avoid drift). **No row** for a skill ⇒ treat as **XP 0** / **level 1** at read time (GET and POST deny paths that return current snapshot).
6. **GET snapshot (NEO-37 evolution):** Replace the hard-coded **0 / 1** builder in **`SkillProgressionSnapshotApi.BuildSnapshot`** with **registry + store merge** so persisted XP appears on **`GET /game/players/{id}/skill-progression`**. Preserve **`schemaVersion` 1** if the JSON shape is unchanged (only values differ). Keep **ordinal sort** for stable serialization; **unordered-by-contract** rule unchanged.
7. **Level curve (placeholder):** Inline C# thresholds or formula (documented in code comments, **subsumed by NEO-39**). Example pattern: simple per-level XP thresholds up to a small cap so tests can cross **at least one** level-up with realistic amounts.
8. **Success response:** **HTTP 200**, versioned envelope: e.g. **`granted: true`**, **`progression`** (same shape as **`SkillProgressionSnapshotResponse`** or a shared type), **`levelUps`**: array of **`{ skillId, previousLevel, newLevel }`** (field names camelCase JSON). Empty **`levelUps`** when no threshold crossed.
9. **Deny response:** **HTTP 200** with **`granted: false`**, **`reasonCode`**, and current **`progression`** snapshot (hotbar **`Updated: false`** pattern) so clients always receive authoritative state for UI sync — unless a case clearly fits **400** (malformed body / wrong schema version).
10. **Wire-up:** **`AddSkillProgressionStore`** (or similar) in **`Program.cs`** next to **`AddHotbarLoadoutStore`**. **`InMemoryWebApplicationFactory`** forces the in-memory implementation for isolated tests (same replacement pattern as hotbar/position).
11. **Bruno / docs:** Add **`Post … skill progression grant.bru`** (exact name follows collection style) under **`bruno/neon-sprawl-server/skill-progression/`**; extend **`server/README.md`**. Add **`docs/manual-qa/NEO-38.md`** during implementation per project convention.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs` | Abstraction: read XP map / apply grant for one player+skill. |
| `server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs` | In-memory persistence; normalized player keys. |
| `server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs` | Postgres implementation when connection string present. |
| `server/NeonSprawl.Server/Game/Skills/PostgresSkillProgressionBootstrap.cs` | Schema ensure (mirror hotbar bootstrap style). |
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionServiceCollectionExtensions.cs` | Chooses Postgres vs in-memory from configuration. |
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantDtos.cs` | Versioned POST request + success/deny response DTOs (`levelUps`, `reasonCode`, etc.). |
| `server/NeonSprawl.Server/Game/Skills/SkillLevelCurvePlaceholder.cs` | Inline threshold/floor helpers until NEO-39 (pure functions, unit-testable). |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs` | In-memory host: unknown skill, bad source kind, success, level-up, GET reflects POST, **404** player gate, bad schema → **400**. |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantPersistenceIntegrationTests.cs` | Postgres harness: grant survives new host/process (mirror **`HotbarLoadoutPersistenceIntegrationTests`**). |
| `bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru` | Manual smoke for happy + one deny path. |
| `docs/manual-qa/NEO-38.md` | Curl/Bruno checklist for grant + GET verification. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | Register **POST**; inject store; change **GET** builder to merge registry + stored XP via placeholder level curve. |
| `server/NeonSprawl.Server/Program.cs` | Register **`AddSkillProgressionStore`** and ensure catalog/store order remains valid. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Swap/replace **`IPlayerSkillProgressionStore`** with in-memory implementation for tests. |
| `server/README.md` | Document **POST** grant, reason codes, persistence note, Bruno path, links to plan + manual QA. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `SkillProgressionGrantApiTests.cs` | **AAA** integration tests: denies (**unknown_skill**, **source_kind_not_allowed**); success increases XP; **levelUps** when threshold crossed; **GET** matches after grant; **404** without position; malformed / wrong **`schemaVersion`** → **400**. |
| `SkillProgressionGrantPersistenceIntegrationTests.cs` | Postgres: write grant, new factory/client, **GET** (or POST read-back) shows persisted XP. |
| `SkillProgressionSnapshotApiTests.cs` (existing) | Update expectations if **GET** implementation changes from hard-coded zeros to store-backed merge (same defaults when store empty). |
Bruno + **`docs/manual-qa/NEO-38.md`** supplement automated coverage.
## Open questions / risks
- **Reason code naming:** Final strings should be listed once in README and reused in tests/Bruno to avoid drift (prefer **`snake_case`** consistent with **`HotbarLoadoutApi`** constants).
- **Placeholder curve vs. content:** Keep thresholds boring and documented; **NEO-39** replaces the helper without changing persistence schema if XP remains the stored value.
- **Concurrency:** Postgres store should use a transaction per grant; in-memory uses concurrent structures or per-player locks if needed for tests under parallel load (prototype: acceptable to document single-threaded assumption if minimal).

View File

@ -0,0 +1,103 @@
# NEO-39 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-39 |
| **Title** | Data-driven skill level curve + CI validation |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-39/data-driven-skill-level-curve-ci-validation |
| **Module** | [E2.M2 — XpAwardAndLevelEngine](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
| **Blocked by** | [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant path + progression store) |
## Kickoff clarifications
| Topic | Question | Resolution |
|--------|----------|------------|
| **Curve shape** | Which JSON structure should this story ship? | **User accepted recommendation:** use a simple ordered thresholds array (`array_thresholds`) for level resolution. |
| **Startup failure behavior** | What if level-curve content is missing/invalid at boot? | **User accepted recommendation:** **fail fast** at startup; no fallback defaults in this slice. |
| **Scope target** | Where should curve loading be wired in this story? | **User accepted recommendation:** **strict ticket scope**; implement only what is required to satisfy NEO-39 acceptance criteria. |
## Goal, scope, and out-of-scope
**Goal:** Replace `SkillLevelCurvePlaceholder` inline threshold logic with data-driven `LevelCurve` content under `content/`, validate that content in CI via `scripts/validate_content.py`, and load the curve at server boot so XP-to-level resolution can be tuned through data without code changes.
**In scope (from Linear + kickoff decisions):**
- Add `LevelCurve` content JSON and JSON Schema using an ordered threshold-array shape.
- Extend `scripts/validate_content.py` so CI fails on invalid curve files/schema mismatches.
- Load and validate level curve content during server startup; fail startup if required curve content is invalid or missing.
- Switch progression level resolution from `SkillLevelCurvePlaceholder` constants to loaded curve data.
- Add tests for curve loading and level boundary behavior.
**Out of scope (from Linear):**
- Hot reload / dev file watcher for content.
- E2.M4 pacing profiles and broader balancing systems.
- Additional progression API shape changes unrelated to curve source replacement.
## Acceptance criteria checklist
- [x] Curve content + schema exist; CI fails on invalid curve files.
- [x] Server loads curves at startup; XP/level resolution uses file data (no code change required to tune thresholds).
- [x] Tests cover curve load and level boundary behavior.
## Technical approach
1. Add a new curve catalog file under `content/skills/` with a top-level level-threshold array (prototype-global curve for this slice).
2. Add a dedicated JSON Schema under `content/schemas/` for level-curve rows and enforce strictly increasing XP thresholds.
3. Extend `scripts/validate_content.py` to validate both existing skill-def catalogs and the new level-curve catalog(s), failing with clear diagnostics and non-zero exit code on any schema error.
4. Introduce a server-side level-curve provider/loader that reads validated curve content at boot and registers the resolved thresholds in DI.
5. Replace `SkillLevelCurvePlaceholder.LevelFromTotalXp` usage with a resolver backed by loaded content data; remove placeholder-only assumptions from runtime path.
6. Keep behavior deterministic at boundaries (exact threshold hit levels up; below threshold does not); encode this in tests.
7. Preserve strict startup behavior: missing or invalid required curve content prevents server startup for prototype safety.
## Files to add
| Path | Purpose |
|------|---------|
| `content/skills/prototype_level_curve.json` | Data source for prototype level thresholds used by runtime level resolution. |
| `content/schemas/level-curve.schema.json` | JSON Schema for curve file structure and row constraints. |
| `server/NeonSprawl.Server/Game/Skills/ISkillLevelCurve.cs` | Interface for XP→level resolution from loaded content-backed thresholds. |
| `server/NeonSprawl.Server/Game/Skills/ContentBackedSkillLevelCurve.cs` | Runtime resolver mapping total XP to level from loaded thresholds. |
| `server/NeonSprawl.Server.Tests/Game/Skills/ContentBackedSkillLevelCurveTests.cs` | Boundary-focused tests for threshold mapping behavior. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `scripts/validate_content.py` | Add level-curve catalog/schema validation and CI-fail diagnostics for invalid curve content. |
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | Ensure progression snapshots resolve `level` via loaded curve provider instead of placeholder constants. |
| `server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalogLoader.cs` | Restrict skill catalog discovery to `*_skills.json` so level-curve JSON can coexist in `content/skills/`. |
| `server/db/migrations/V003__player_skill_progression.sql` | Update comment to reflect data-driven level derivation in runtime. |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionCatalogLoaderTests.cs` | Update fixture file names to `*_skills.json` pattern used by loader. |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionRegistryTests.cs` | Update fixture file name to `prototype_skills.json` for loader parity. |
| `server/NeonSprawl.Server/Program.cs` | Register boot-time curve loading and DI wiring for content-backed level resolver. |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs` | Update/extend assertions to reflect data-driven level thresholds and boundary crossings. |
| `server/README.md` | Document where level curves live, boot-load/fail-fast behavior, and validation command. |
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | Update implementation snapshot after NEO-39 decisions/landing details. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Skills/ContentBackedSkillLevelCurveTests.cs` | Unit tests for XP threshold boundaries (below first threshold, exact threshold, between thresholds, high-XP upper ranges). |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs` | Integration tests asserting grant responses use data-driven level resolution and expected `levelUps` at threshold boundaries. |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionSnapshotApiTests.cs` | Snapshot endpoint reflects data-driven levels (including unchanged level below next threshold). |
| `scripts/validate_content.py` (invoked in CI) | Validation failure behavior for malformed level-curve content/schema mismatch. |
If script-level tests are not present in this repository, verification for validator changes will be covered by running the validation script against good and intentionally bad fixtures during implementation and documenting results in the plan Decisions section.
## Decisions
- Adopted `*_skills.json` and `*_level_curve.json` file naming split under `content/skills/` so both catalogs can coexist without ambiguous loader behavior.
- Kept startup fail-fast for level-curve load: missing/invalid `content/skills/prototype_level_curve.json` or missing schema now throws during boot.
- Verification outcomes:
- `python3 scripts/validate_content.py` passes with skill + level-curve catalogs.
- `dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --no-restore` passes (`132` tests).
## Open questions / risks
- **Schema strictness trade-off:** very strict schema constraints reduce bad data risk but can make iterative tuning slower; keep constraints targeted to structural correctness and monotonic thresholds.
- **Boot coupling:** fail-fast startup improves safety but increases sensitivity to content mistakes; error messages in validator/boot path must be precise.
- **None** otherwise.

View File

@ -9,6 +9,10 @@ A small number of files keep the historical **Jira / Atlassian `NEON-*`** key in
- Each such file includes a short **Historical** banner at the top pointing at the matching **`NEO-*`** plan (same scope) where kickoff and commits should align. - Each such file includes a short **Historical** banner at the top pointing at the matching **`NEO-*`** plan (same scope) where kickoff and commits should align.
- Prefer opening and linking **`NEO-*`** issues and plans for new contributors. - Prefer opening and linking **`NEO-*`** issues and plans for new contributors.
## GitHub links in Linear (and docs)
The canonical public repo path is **`ViPro-Technologies/neon-sprawl`** (see `git remote`). Use that org in **raw GitHub** markdown links (`https://github.com/ViPro-Technologies/neon-sprawl/blob/main/...`). The path **`neon-sprawl/neon-sprawl`** is not the live repo and will **404**.
## Conventions ## Conventions
- New or moved plans: `docs/plans/{LINEAR_KEY}-implementation-plan.md` (see [story kickoff](../../.cursor/rules/story-kickoff.md)). - New or moved plans: `docs/plans/{LINEAR_KEY}-implementation-plan.md` (see [story kickoff](../../.cursor/rules/story-kickoff.md)).

View File

@ -51,3 +51,4 @@ After any edits:
1. ~~Re-open `docs/game-design/abilities.md` and confirm the Seams link reads naturally.~~ **Checked** (2026-05-05 follow-up). 1. ~~Re-open `docs/game-design/abilities.md` and confirm the Seams link reads naturally.~~ **Checked** (2026-05-05 follow-up).
2. ~~`rg 'NEON-' docs/plans` — decide whether each hit is archival or should gain a one-line deprecation pointer.~~ **Checked** — only [`NEON-29-implementation-plan.md`](../plans/NEON-29-implementation-plan.md); archival banner + [`docs/plans/README.md`](../plans/README.md). 2. ~~`rg 'NEON-' docs/plans` — decide whether each hit is archival or should gain a one-line deprecation pointer.~~ **Checked** — only [`NEON-29-implementation-plan.md`](../plans/NEON-29-implementation-plan.md); archival banner + [`docs/plans/README.md`](../plans/README.md).
3. Spot-check any new `docs/game-design/*.md` against `progression.md` vocabulary and `skills.md` Seams once before filing implementation plans. 3. Spot-check any new `docs/game-design/*.md` against `progression.md` vocabulary and `skills.md` Seams once before filing implementation plans.

View File

@ -0,0 +1,59 @@
# Code review — NEO-37 skill progression snapshot
**Date:** 2026-05-06 (initial); **re-reviewed** 2026-05-07.
**Scope:** Branch `NEO-37-skill-progression-snapshot-read-model`; `git` range `f89efdce..a3cbef0` (merge-base `f89efdce` with `origin/main` through current HEAD `a3cbef0`). Issue **NEO-37**.
**Base:** `origin/main` @ `f89efdce`.
**Follow-up commits since first review:** `295eb34` (decomposition docs + review feedback); `a3cbef0` (**`skills`** non-contractual ordering, ordinal sort for stable serialization only, tests keyed by **`id`**, Bruno + README + plan + manual QA aligned).
## Verdict
**Approve.**
## Summary
Adds `GET /game/players/{id}/skill-progression` with server-authoritative defaults (every registered skill row at `xp` 0, `level` 1), gated on `IPositionStateStore.TryGetPosition` like `CooldownSnapshotApi`/`HotbarLoadoutApi`. Response DTOs follow the NEO-36-style versioned envelope (`schemaVersion`, camelCase JSON names); **`skills` array order is not contractual** — clients key by **`id`**. Integration-style tests exercise **404**, frozen trio membership + defaults (**order-independent**), and Bruno no longer asserts sort order on the progression array. README, manual QA, and implementation plan document the same contract. Risk is low: read-only projection with no persistence or grant path yet.
## Documentation checked
- `docs/plans/NEO-37-implementation-plan.md`**matches** route, player gate (`404`), JSON shape, **`skills` unordered-by-contract** (+ optional stable ordinal sort server-side only), Bruno/README/manual QA expectations, and deferral of persistence to NEO-38.
- `docs/manual-qa/NEO-37.md`**matches** curl steps and assertions.
- `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md`**matches** story placement and NEO-37 delivery in **Implementation snapshot**; full engine responsibilities in the module body remain description of the target module (NEO-38+). **Updated** after review (2026-05-07) so tracking does not drift.
- `docs/decomposition/modules/documentation_and_implementation_alignment.md`**matches** (E2.M2 row **In Progress**, NEO-37 landed narrative + follow-on backlog). **Updated** after review.
- `docs/decomposition/modules/client_server_authority.md`, `contracts.md`**N/A** for this additive read-model slice (no new cross-cutting gameplay authority beyond existing “known player via position store” pattern).
## Blocking issues
(none)
## Suggestions
1. ~~**Documentation follow-up after merge:** Update `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` (implementation snapshot — remove or qualify “No implementation landed yet”) and confirm `documentation_and_implementation_alignment.md` E2.M2 row still reads correctly once NEO-37 is merged. Prefer a short PR stacked on this merge or the same sprint so tracking does not drift.~~ **Done.**
2. ~~**`docs/plans/NEO-37-implementation-plan.md` typography:** Insert a missing space after the comma (“When grants land, prefer …”) under Open questions.~~ **Done.**
## Nits
- ~~**Nit:** Bruno `skills are ascending by id` matches `skill-definitions` folder style but is weaker than the C# tests for **exact catalog order**; if regressions swapped registry order without changing ids, lexical sort alone would not catch misuse of a different comparator. Acceptable Bruno scope; parity with sibling collection is coherent.~~ **Superseded** — progression **`skills`** is explicitly **unordered by contract**; Bruno + C# tests key by **`id`** only (**2026-05-07**).
## Re-review notes
Explicit **unordered-by-contract** semantics for **`skills`** are coherent: they differentiate this read snapshot from **`GET …/skill-definitions`**, whose catalog order stays pinned for discovery. Server-side **`string.CompareOrdinal` sort before JSON** preserves stable payloads without inviting clients to depend on index ordering—documented on the response DTO, README, plan, manual QA, and `BuildSnapshot`.
Prior review items (module snapshot, alignment row, plan typo, Bruno-vs-order nit) are **addressed or superseded**; no new blocking findings.
## Verification
- Re-review (2026-05-07):
`cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~SkillProgressionSnapshotApiTests"` — passed (2 tests).
- Recommended before merge: full server test suite:
`cd server && dotnet test`
- Manual: steps in `docs/manual-qa/NEO-37.md`; optional Bruno `bruno/neon-sprawl-server/skill-progression/Get skill progression.bru` with `{{baseUrl}}` from `environments/Local.bru`.

View File

@ -0,0 +1,52 @@
# Code review — NEO-38 (skill XP grant)
**Date:** 2026-05-06
**Scope:** Branch `NEO-38-apply-skill-xp-grant-allowlist-level-up` vs `origin/main` (four commits ending `bab7336`; working tree clean at review time).
**Base:** `origin/main`
---
## Verdict
**Approve**
## Summary
The branch implements `POST /game/players/{id}/skill-progression` with versioned bodies, stable deny reason codes (`unknown_skill`, `source_kind_not_allowed`, `invalid_amount`), hotbar-style **200 + `granted: false`** denies with echoed **`progression`**, dual persistence (**`InMemoryPlayerSkillProgressionStore`** / **`PostgresPlayerSkillProgressionStore`** + **`V003__player_skill_progression.sql`**), and GET snapshot merge of registry + stored XP via **`SkillLevelCurvePlaceholder`**. Integration tests (**`SkillProgressionGrantApiTests`**), Postgres persistence (**`SkillProgressionGrantPersistenceIntegrationTests`**), Bruno, **`server/README.md`**, **`docs/manual-qa/NEO-38.md`**, and **`docs/plans/NEO-38-implementation-plan.md`** are in place. **`dotnet test`** for `NeonSprawl.Server.Tests` passed (123 tests). Overall risk is **low** with normal prototype caveats (placeholder curve, synchronous ADO.NET in stores).
## Documentation checked
| Document | Assessment |
|---------|-------------|
| `docs/plans/NEO-38-implementation-plan.md` | **Matches** — routing, gates, persistence shape, denial pattern, Bruno/README/manual QA covered by implementation. Minor filename drift (`SkillProgressionServiceCollectionExtensions.cs` shipped vs plans wording; benign). |
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | ~~**Partially matches** — responsibilities (validate skill + `sourceKind`, derive level, surface level-ups) align. The pages **Implementation snapshot** paragraph still reads as if **NEO-38** is backlog (“Still backlog … applying XP grants”). **Should update after merge** (or in same PR) so the slice-2 narrative matches landed code — see Suggestions.~~ **Done.** (2026-05-07) — Implementation snapshot refreshed for **NEO-38** landed + remaining backlog (**NEO-39** onward). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches** — E2.M2 tracking row still lists **NEO-38** under “Follow-on” only. Merge should refresh that row (**NEO-38** landed paths, README anchors, Bruno) per this docs own rule (**§ Status column / tracking table**). E2.M1 rows **Follow-on (E2.M2): XP grant validation** line is stale once NEO-38 merges (**should fix** in same sweep).~~ **Done.** (2026-05-07) — E2.M2 row includes **NEO-38**; E2.M1 row references consumer grant validation. |
| `docs/decomposition/modules/module_dependency_register.md` | ~~Referenced implicitly via alignment doc; register row likely needs the same E2.M2 status/snapshot tweak when NEO-38 merges — treat as **post-merge bookkeeping** alongside the alignment table (not reviewed line-by-line in this pass).~~ **Done.** (2026-05-07) — **E2.M2 note** added (NEO-37/NEO-38 pointers). |
**Register / tracking table:** yes — ~~**`documentation_and_implementation_alignment.md`** (and **`E2_M2_…` Implementation snapshot**) should be updated so Status / snapshot bullets do not disagree with merged code.~~ **Done.**
## Blocking issues
_None._
## Suggestions
1. ~~**Decomposition freshness (same PR or immediate follow-up):** Update **`E2_M2_XpAwardAndLevelEngine.md`** implementation snapshot plus **`documentation_and_implementation_alignment.md`** E2.M2 (and tighten E2.M1 “follow-on”) so they describe **NEO-38** as landed with links to **`NEO-38-implementation-plan.md`**, **`docs/manual-qa/NEO-38.md`**, and **`server/README.md`** grant section — matching the checklist in **`planning-implementation-docs`**.~~ **Done.**
2. ~~**`levelUps` semantics for multi-step jumps:** A single grant that crosses more than one level boundary (e.g. +250 XP under `level = 1 + floor(xp/100)`) emits **one** `levelUps` row `{ previousLevel: 1, newLevel: 3 }`. If callers need one entry per crossed level later, note that explicitly in README or backlog; otherwise a one-line clarification that “span reflects final levels after grant” avoids ambiguity.~~ **Done.** (clarified in **`server/README.md`** grant section; optional future backlog called out.)
3. ~~**Bruno coverage:** **`Post skill progression grant.bru`** exercises the happy path only; **`docs/manual-qa/NEO-38.md`** covers deny via curl. Optional: add a second Bruno request for **`source_kind_not_allowed`** to mirror hotbar-style smoke patterns.~~ **Done.**`Post skill progression grant deny source kind.bru`; manual QA updated.
## Nits
- ~~**Nit:** `MapPost` uses `positions.TryGetPosition(id, …)` with the raw route parameter while snapshots use `trimmedId`; `MapGet` does the same. Harmless if ids are never padded with whitespace; using the same trimmed key everywhere would be slightly tidier.~~ **Done.** — GET/POST use **`trimmedId`** (+ empty ⇒ **404**) for **`TryGetPosition`**.
- ~~**Nit:** **`SkillProgressionGrantPersistenceIntegrationTests`** uses a broad `// Act` block that includes opening a second factory and GET — acceptable for an integration test, but strict AAA purists might split “write” vs “read-back” with clearer phase comments.~~ **Done.** — two **`// Act`** comment blocks (**write** / **read-back**).
## Verification
- `cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj`**passed** (123 tests) at review time.
- Optional: run **`docs/manual-qa/NEO-38.md`** curl steps against a local host; run Bruno folder `bruno/neon-sprawl-server/skill-progression/` for smoke.
- With Postgres: confirm **`V003__player_skill_progression.sql`** applied in real migrations flow if something other than bootstrap applies DDL in your environment (app uses bootstrap + test harness re-applies file — OK for tests).

View File

@ -0,0 +1,47 @@
# Code Review — NEO-39
- **Date:** 2026-05-06
- **Scope:** Working tree / unstaged changes for NEO-39 (`Data-driven skill level curve + CI validation`)
- **Base:** `main` (inferred)
## Verdict
Request changes.
## Summary
The change successfully introduces data-driven skill level thresholds, adds CI validation for `*_skills.json` and `*_level_curve.json`, and wires runtime level resolution through `ISkillLevelCurve`. API behavior for progression GET/POST remains stable and test coverage is healthy overall, with full server test suite passing.
The main risk is a startup-validation gap: runtime curve loading currently performs custom checks instead of schema evaluation and therefore does not enforce schema constraints such as `additionalProperties: false`. That allows schema-invalid curve content to pass startup, which conflicts with the storys fail-fast validation intent.
## Documentation checked
- `docs/plans/NEO-39-implementation-plan.md`**partially matches** (most acceptance criteria are implemented; startup validation is not schema-equivalent to declared contract strictness).
- `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md`**matches** (module snapshot updated for NEO-39 landed behavior).
- `docs/decomposition/modules/module_dependency_register.md`**matches** (E2.M2 remains In Progress; dependencies/contract shape unchanged).
- `docs/decomposition/modules/documentation_and_implementation_alignment.md`**partially matches** (E2.M2 tracking row still lists NEO-39 as follow-on; should be updated after merge to avoid stale status narrative).
## Blocking issues
1. ~~Runtime level-curve validation is not schema-equivalent, so schema-invalid files can still boot successfully.~~
- ~~Evidence: `server/NeonSprawl.Server/Game/Skills/ContentBackedSkillLevelCurve.cs` validates only selected fields manually and does not evaluate `content/schemas/level-curve.schema.json` constraints like `additionalProperties: false`.~~
- ~~Impact: Violates fail-fast startup validation intent for invalid curve content and can permit config drift between CI and runtime.~~
- ~~Recommendation: Validate the loaded JSON against `level-curve.schema.json` at startup (or implement strict parity checks for all schema constraints, including additional properties and structural limits), then keep existing domain checks (strictly increasing rows, first row = level 1 / xp 0).~~
Done. Startup load now evaluates `level-curve.schema.json` via `JsonSchema.Net` in `ContentBackedSkillLevelCurve.Load(...)` before domain checks, and `ContentBackedSkillLevelCurveTests.Load_ShouldThrow_WhenCurveHasUnexpectedTopLevelProperty` verifies `additionalProperties: false` enforcement.
## Suggestions
1. ~~Update `docs/decomposition/modules/documentation_and_implementation_alignment.md` E2.M2 snapshot text to reflect that NEO-39 is landed (currently phrased as a follow-on item).~~
Done. E2.M2 tracking row now records NEO-39 as landed (data-driven `LevelCurve` + CI + fail-fast startup checks) and links `docs/plans/NEO-39-implementation-plan.md` plus `docs/manual-qa/NEO-39.md`.
2. ~~Refresh stale placeholder wording in `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs` comments (the threshold behavior is now content-backed, not placeholder-based).~~
Done. Updated the stale arrange comment in `PostSkillProgression_ShouldReportLevelUps_WhenThresholdCrossed` to reference content-backed thresholds from `prototype_level_curve.json`.
## Nits
- ~~Nit: `SkillDefinitionCatalogLoader` XML summary still says `content/skills/*.json`; implementation now intentionally scopes to `*_skills.json`.~~
Done. Updated XML summary in `server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalogLoader.cs` to `content/skills/*_skills.json`.
## Verification
- `python3 scripts/validate_content.py`
- `dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --no-restore`
- Manual negative check: add an extra top-level property to `content/skills/prototype_level_curve.json` and verify server startup fails (expected once runtime schema validation parity is implemented).

View File

@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Validate content catalogs against JSON Schema (CI + local). """Validate content catalogs against JSON Schema (CI + local).
Today: each object in content/skills/*.json top-level ``skills`` array vs Validates:
content/schemas/skill-def.schema.json. Extend this script as new catalogs land. - skill catalogs: content/skills/*_skills.json rows vs content/schemas/skill-def.schema.json
- level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json
""" """
from __future__ import annotations from __future__ import annotations
@ -15,6 +16,7 @@ from jsonschema import Draft202012Validator
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json" SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json"
LEVEL_CURVE_SCHEMA = REPO_ROOT / "content/schemas/level-curve.schema.json"
SKILLS_DIR = REPO_ROOT / "content/skills" SKILLS_DIR = REPO_ROOT / "content/skills"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
@ -47,24 +49,34 @@ def main() -> int:
if not SKILL_SCHEMA.is_file(): if not SKILL_SCHEMA.is_file():
print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr) print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr)
return 1 return 1
if not LEVEL_CURVE_SCHEMA.is_file():
print(f"error: missing schema {LEVEL_CURVE_SCHEMA}", file=sys.stderr)
return 1
schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
validator = Draft202012Validator(schema) skill_validator = Draft202012Validator(skill_schema)
level_curve_schema = json.loads(LEVEL_CURVE_SCHEMA.read_text(encoding="utf-8"))
level_curve_validator = Draft202012Validator(level_curve_schema)
if not SKILLS_DIR.is_dir(): if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
return 1 return 1
json_files = sorted(SKILLS_DIR.glob("*.json")) skill_files = sorted(SKILLS_DIR.glob("*_skills.json"))
if not json_files: if not skill_files:
print(f"error: no JSON files under {SKILLS_DIR}", file=sys.stderr) print(f"error: no *_skills.json files under {SKILLS_DIR}", file=sys.stderr)
return 1
curve_files = sorted(SKILLS_DIR.glob("*_level_curve.json"))
if not curve_files:
print(f"error: no *_level_curve.json files under {SKILLS_DIR}", file=sys.stderr)
return 1 return 1
seen_ids: dict[str, str] = {} seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {} id_to_category: dict[str, str] = {}
errors = 0 errors = 0
for path in json_files: for path in skill_files:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
skills = data.get("skills") skills = data.get("skills")
if not isinstance(skills, list): if not isinstance(skills, list):
@ -77,7 +89,7 @@ def main() -> int:
errors += 1 errors += 1
continue continue
row_schema_errors = 0 row_schema_errors = 0
for err in sorted(validator.iter_errors(row), key=lambda e: e.path): for err in sorted(skill_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)" loc = ".".join(str(p) for p in err.path) or "(root)"
print( print(
f"error: {path.relative_to(REPO_ROOT)} skills[{i}] {loc}: {err.message}", f"error: {path.relative_to(REPO_ROOT)} skills[{i}] {loc}: {err.message}",
@ -102,6 +114,71 @@ def main() -> int:
if isinstance(cat, str): if isinstance(cat, str):
id_to_category[sid] = cat id_to_category[sid] = cat
for path in curve_files:
data = json.loads(path.read_text(encoding="utf-8"))
row_errors = 0
for err in sorted(level_curve_validator.iter_errors(data), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(
f"error: {path.relative_to(REPO_ROOT)} {loc}: {err.message}",
file=sys.stderr,
)
row_errors += 1
errors += 1
if row_errors > 0:
continue
levels = data.get("levels")
if not isinstance(levels, list):
print(f"error: {path.relative_to(REPO_ROOT)}: expected top-level 'levels' array", file=sys.stderr)
errors += 1
continue
if len(levels) == 0:
print(f"error: {path.relative_to(REPO_ROOT)}: levels must contain at least one row", file=sys.stderr)
errors += 1
continue
first = levels[0]
if not isinstance(first, dict) or first.get("level") != 1 or first.get("requiredXp") != 0:
print(
f"error: {path.relative_to(REPO_ROOT)}: first row must be level=1 and requiredXp=0",
file=sys.stderr,
)
errors += 1
prev_level = 0
prev_xp = -1
for i, row in enumerate(levels):
if not isinstance(row, dict):
print(f"error: {path.relative_to(REPO_ROOT)}: levels[{i}] must be an object", file=sys.stderr)
errors += 1
continue
level = row.get("level")
required_xp = row.get("requiredXp")
if not isinstance(level, int) or not isinstance(required_xp, int):
print(
f"error: {path.relative_to(REPO_ROOT)}: levels[{i}] level/requiredXp must be integers",
file=sys.stderr,
)
errors += 1
continue
if level <= prev_level:
print(
f"error: {path.relative_to(REPO_ROOT)}: levels[{i}].level must be strictly increasing",
file=sys.stderr,
)
errors += 1
if required_xp <= prev_xp:
print(
f"error: {path.relative_to(REPO_ROOT)}: levels[{i}].requiredXp must be strictly increasing",
file=sys.stderr,
)
errors += 1
prev_level = level
prev_xp = required_xp
if errors: if errors:
print(f"content validation failed with {errors} error(s)", file=sys.stderr) print(f"content validation failed with {errors} error(s)", file=sys.stderr)
return 1 return 1
@ -111,7 +188,12 @@ def main() -> int:
print(slice1_err, file=sys.stderr) print(slice1_err, file=sys.stderr)
return 1 return 1
print(f"content OK: {len(json_files)} skill catalog file(s), {len(seen_ids)} unique skill id(s)") print(
"content OK: "
f"{len(skill_files)} skill catalog file(s), "
f"{len(curve_files)} level curve file(s), "
f"{len(seen_ids)} unique skill id(s)"
)
return 0 return 0

View File

@ -18,6 +18,8 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
"Set ConnectionStrings__NeonSprawl for Postgres integration tests (see server/README.md)."); "Set ConnectionStrings__NeonSprawl for Postgres integration tests (see server/README.md).");
} }
builder.UseEnvironment("Testing");
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");

View File

@ -0,0 +1,116 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
public sealed class ContentBackedSkillLevelCurveTests
{
private const string LevelCurveSchemaJson =
"""
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "levels"],
"properties": {
"schemaVersion": { "type": "integer", "const": 1 },
"levels": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["level", "requiredXp"],
"properties": {
"level": { "type": "integer", "minimum": 1 },
"requiredXp": { "type": "integer", "minimum": 0 }
}
}
}
}
}
""";
private static ContentBackedSkillLevelCurve BuildCurve() =>
new(
[
new ContentBackedSkillLevelCurve.LevelThresholdRow(1, 0),
new ContentBackedSkillLevelCurve.LevelThresholdRow(2, 100),
new ContentBackedSkillLevelCurve.LevelThresholdRow(3, 250),
new ContentBackedSkillLevelCurve.LevelThresholdRow(4, 450),
]);
[Theory]
[InlineData(-5, 1)]
[InlineData(0, 1)]
[InlineData(99, 1)]
[InlineData(100, 2)]
[InlineData(249, 2)]
[InlineData(250, 3)]
[InlineData(449, 3)]
[InlineData(450, 4)]
[InlineData(9999, 4)]
public void LevelFromTotalXp_ShouldResolveExpectedLevelAcrossBoundaries(int xp, int expectedLevel)
{
// Arrange
var curve = BuildCurve();
// Act
var level = curve.LevelFromTotalXp(xp);
// Assert
Assert.Equal(expectedLevel, level);
}
[Fact]
public void Load_ShouldThrow_WhenCurveHasUnexpectedTopLevelProperty()
{
// Arrange
var root = Directory.CreateTempSubdirectory("neon-sprawl-level-curve-");
try
{
var skillsDir = Path.Combine(root.FullName, "content", "skills");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(skillsDir);
Directory.CreateDirectory(schemaDir);
var curvePath = Path.Combine(skillsDir, "prototype_level_curve.json");
var schemaPath = Path.Combine(schemaDir, "level-curve.schema.json");
File.WriteAllText(schemaPath, LevelCurveSchemaJson, Encoding.UTF8);
File.WriteAllText(
curvePath,
"""
{
"schemaVersion": 1,
"levels": [
{ "level": 1, "requiredXp": 0 },
{ "level": 2, "requiredXp": 100 }
],
"extraField": "should-fail"
}
""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
ContentBackedSkillLevelCurve.Load(skillsDir, curvePath, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("extraField", ioe.Message, StringComparison.Ordinal);
}
finally
{
try
{
Directory.Delete(root.FullName, recursive: true);
}
catch (IOException)
{
// Best-effort cleanup only.
}
}
}
}

View File

@ -55,7 +55,7 @@ public class SkillDefinitionCatalogLoaderTests
{ {
// Arrange // Arrange
var (_, skillsDir, schemaPath) = CreateTempContentLayout(); var (_, skillsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(skillsDir, "prototype.json"), ValidPrototypeCatalogJson, Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "prototype_skills.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act // Act
var catalog = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance); var catalog = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance);
// Assert // Assert
@ -70,12 +70,12 @@ public class SkillDefinitionCatalogLoaderTests
{ {
// Arrange // Arrange
var (_, skillsDir, schemaPath) = CreateTempContentLayout(); var (_, skillsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(skillsDir, "bad.json"), """{"skills": "nope"}""", Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "bad_skills.json"), """{"skills": "nope"}""", Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("bad_skills.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'skills' array", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'skills' array", ioe.Message, StringComparison.Ordinal);
} }
@ -96,12 +96,12 @@ public class SkillDefinitionCatalogLoaderTests
] ]
} }
"""; """;
File.WriteAllText(Path.Combine(skillsDir, "bad.json"), bad, Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "bad_skills.json"), bad, Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("bad_skills.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Skill catalog validation failed", ioe.Message, StringComparison.Ordinal); Assert.Contains("Skill catalog validation failed", ioe.Message, StringComparison.Ordinal);
} }
@ -122,16 +122,16 @@ public class SkillDefinitionCatalogLoaderTests
] ]
} }
"""; """;
File.WriteAllText(Path.Combine(skillsDir, "a.json"), singleSalvage, Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "a_skills.json"), singleSalvage, Encoding.UTF8);
File.WriteAllText(Path.Combine(skillsDir, "b.json"), singleSalvage, Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "b_skills.json"), singleSalvage, Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate skill id", ioe.Message, StringComparison.Ordinal); Assert.Contains("duplicate skill id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal); Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal);
Assert.Contains("a.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("a_skills.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("b.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("b_skills.json", ioe.Message, StringComparison.Ordinal);
} }
[Fact] [Fact]
@ -157,7 +157,7 @@ public class SkillDefinitionCatalogLoaderTests
] ]
} }
"""; """;
File.WriteAllText(Path.Combine(skillsDir, "partial.json"), twoOnly, Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "partial_skills.json"), twoOnly, Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert // Assert

View File

@ -129,7 +129,7 @@ public class SkillDefinitionRegistryTests
] ]
} }
"""; """;
File.WriteAllText(Path.Combine(skillsDir, "prototype.json"), json, Encoding.UTF8); File.WriteAllText(Path.Combine(skillsDir, "prototype_skills.json"), json, Encoding.UTF8);
var loaded = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance); var loaded = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance);
var registry = new SkillDefinitionRegistry(loaded); var registry = new SkillDefinitionRegistry(loaded);
// Act // Act

View File

@ -0,0 +1,190 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
public sealed class SkillProgressionGrantApiTests
{
private static SkillProgressionGrantRequest Grant(
string skillId,
int schemaVersion,
int amount = 50,
string sourceKind = "activity") =>
new SkillProgressionGrantRequest
{
SchemaVersion = schemaVersion,
SkillId = skillId,
Amount = amount,
SourceKind = sourceKind,
};
private static SkillProgressionGrantRequest ValidGrant(string skillId, int amount = 50, string sourceKind = "activity") =>
Grant(skillId, SkillProgressionGrantRequest.CurrentSchemaVersion, amount, sourceKind);
[Fact]
public async Task PostSkillProgression_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = Grant("salvage", SkillProgressionGrantRequest.CurrentSchemaVersion + 42, amount: 1);
// Act
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/skill-progression", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostSkillProgression_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/skill-progression",
ValidGrant("salvage"));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostSkillProgression_ShouldDenyUnknownSkill_WithReasonUnknownSkill_AndEchoProgression()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("not-a-prototype-skill"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Granted);
Assert.Equal(SkillProgressionSnapshotApi.ReasonUnknownSkill, envelope.ReasonCode);
Assert.NotNull(envelope.Progression);
var salvage = envelope.Progression!.Skills!.Single(static s => s.Id == "salvage");
Assert.Equal(0, salvage.Xp);
Assert.Equal(1, salvage.Level);
}
[Fact]
public async Task PostSkillProgression_ShouldDenyTrainerOnSalvage_WithReasonSourceKindNotAllowed()
{
// Arrange — prototype_skills: salvage allowedXpSourceKinds = activity, mission_reward only
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("salvage", amount: 10, sourceKind: "trainer"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Granted);
Assert.Equal(SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed, envelope.ReasonCode);
}
[Fact]
public async Task PostSkillProgression_ShouldDenyNonPositiveAmount_WithReasonInvalidAmount()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("salvage", amount: 0));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Granted);
Assert.Equal(SkillProgressionSnapshotApi.ReasonInvalidAmount, envelope.ReasonCode);
}
[Fact]
public async Task PostSkillProgression_ShouldApplyGrant_AndGetMatches_WhenTrainerAllowedForRefine()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("refine", amount: 25, sourceKind: "trainer"));
// Assert
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
var envelope = await post.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
Assert.NotNull(envelope);
Assert.True(envelope!.Granted);
Assert.Empty(envelope.LevelUps);
Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "refine" && s.Xp == 25 && s.Level == 1);
var get = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
"/game/players/dev-local-1/skill-progression");
Assert.NotNull(get);
var refine = Assert.Single(get!.Skills!, static s => s.Id == "refine");
Assert.Equal(25, refine.Xp);
Assert.Equal(1, refine.Level);
}
[Fact]
public async Task PostSkillProgression_ShouldReportLevelUps_WhenThresholdCrossed()
{
// Arrange — content curve (prototype_level_curve.json): +100 xp from 0 → level 2
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("intrusion", amount: 100, sourceKind: "activity"));
// Assert
var envelope = await post.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
Assert.NotNull(envelope);
Assert.True(envelope!.Granted);
var up = Assert.Single(envelope.LevelUps!);
Assert.Equal("intrusion", up.SkillId);
Assert.Equal(1, up.PreviousLevel);
Assert.Equal(2, up.NewLevel);
Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "intrusion" && s.Xp == 100 && s.Level == 2);
}
[Fact]
public async Task PostSkillProgression_ShouldAcceptSourceKind_WithIgnoreCase_OnAllowedList()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("salvage", amount: 5, sourceKind: "ACTIVITY"));
// Assert
var envelope = await post.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
Assert.NotNull(envelope);
Assert.True(envelope!.Granted);
}
}

View File

@ -0,0 +1,103 @@
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
[Collection("Postgres integration")]
public sealed class SkillProgressionGrantPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
[RequirePostgresFact]
public async Task PostGrantThenGetAcrossNewFactory_ShouldPersistSkillXp()
{
// Arrange
await ResetProgressionTableAsync();
var grant = new SkillProgressionGrantRequest
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = "salvage",
Amount = 50,
SourceKind = "activity",
};
HttpResponseMessage postResponse;
// Act — write grant through first host
using (var firstClient = Factory.CreateClient())
{
postResponse = await firstClient.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
grant);
}
// Act — read back through a fresh host (forces DB round-trip / new DI scope)
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondClient = secondFactory.CreateClient();
var snapshot =
await secondClient.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
"/game/players/dev-local-1/skill-progression");
// Assert
Assert.True(postResponse.IsSuccessStatusCode);
Assert.NotNull(snapshot);
var salvage = Assert.Single(snapshot!.Skills!, static s => s.Id == "salvage");
Assert.Equal(50, salvage.Xp);
Assert.Equal(1, salvage.Level);
}
private async Task ResetProgressionTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var progressionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V003__player_skill_progression.sql");
if (!File.Exists(positionDdlPath))
{
throw new FileNotFoundException($"Test DDL not found at '{positionDdlPath}'.", positionDdlPath);
}
if (!File.Exists(progressionDdlPath))
{
throw new FileNotFoundException($"Test DDL not found at '{progressionDdlPath}'.", progressionDdlPath);
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var progressionDdl = await File.ReadAllTextAsync(progressionDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyProgression = new NpgsqlCommand(progressionDdl, conn))
{
await applyProgression.ExecuteNonQueryAsync();
}
await using (var truncateProgression = new NpgsqlCommand("TRUNCATE player_skill_progression;", conn))
{
await truncateProgression.ExecuteNonQueryAsync();
}
}
}

View File

@ -0,0 +1,52 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
public sealed class SkillProgressionSnapshotApiTests
{
[Fact]
public async Task GetSkillProgression_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/skill-progression");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetSkillProgression_ShouldReturnSchemaV1_WithDefaultXpAndLevel_ForFrozenTrio()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/dev-local-1/skill-progression");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
Assert.NotNull(body);
Assert.Equal(SkillProgressionSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal("dev-local-1", body.PlayerId);
Assert.NotNull(body.Skills);
Assert.Equal(3, body.Skills!.Count);
var byId = body.Skills.ToDictionary(static s => s.Id, StringComparer.Ordinal);
Assert.Equal(3, byId.Count);
foreach (var id in new[] { "intrusion", "refine", "salvage" })
{
Assert.True(byId.TryGetValue(id, out var row));
Assert.Equal(0, row!.Xp);
Assert.Equal(1, row.Level);
}
}
}

View File

@ -19,6 +19,10 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
// Avoid Development + DeveloperExceptionPage: invalid JSON / binding failures must stay 400 for API tests,
// and Release CI runs rely on deterministic status codes (e.g. HotbarLoadoutApiTests malformed-body case).
builder.UseEnvironment("Testing");
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
@ -31,6 +35,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var d = services[i]; var d = services[i];
if (d.ServiceType == typeof(IPositionStateStore) || if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(NpgsqlDataSource) || d.ServiceType == typeof(NpgsqlDataSource) ||
@ -47,6 +52,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<TimeProvider>(fakeTime); services.AddSingleton<TimeProvider>(fakeTime);
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
}); });
} }

View File

@ -27,7 +27,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\db\migrations\V001__player_position.sql" Link="db\migrations\V001__player_position.sql" CopyToOutputDirectory="PreserveNewest" /> <Content Include="..\db\migrations\*.sql" Link="db\migrations\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,202 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Level resolver backed by startup-loaded threshold rows from content files (NEO-39).</summary>
public sealed class ContentBackedSkillLevelCurve : ISkillLevelCurve
{
private readonly IReadOnlyList<LevelThresholdRow> levels;
internal ContentBackedSkillLevelCurve(IReadOnlyList<LevelThresholdRow> levels)
{
if (levels.Count == 0)
throw new InvalidOperationException("Level curve must contain at least one level row.");
this.levels = levels;
}
public int LevelFromTotalXp(int totalXp)
{
var xp = Math.Max(0, totalXp);
var level = 1;
foreach (var row in levels)
{
if (xp < row.RequiredXp)
break;
level = row.Level;
}
return level;
}
internal static ContentBackedSkillLevelCurve Load(
string skillsDirectory,
string curveFilePath,
string schemaPath,
ILogger logger)
{
var errors = new List<string>();
var fullSkillsDirectory = Path.GetFullPath(skillsDirectory);
var fullCurvePath = Path.GetFullPath(curveFilePath);
var fullSchemaPath = Path.GetFullPath(schemaPath);
if (!Directory.Exists(fullSkillsDirectory))
errors.Add($"error: missing directory {fullSkillsDirectory}");
if (!File.Exists(fullCurvePath))
errors.Add($"error: missing level curve file {fullCurvePath}");
if (!File.Exists(fullSchemaPath))
errors.Add($"error: missing level curve schema {fullSchemaPath}");
ThrowIfAny(errors);
JsonDocument doc;
JsonNode? rootNode;
try
{
var json = File.ReadAllText(fullCurvePath);
doc = JsonDocument.Parse(json);
rootNode = JsonNode.Parse(json);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Level curve file is invalid JSON: {fullCurvePath}: {ex.Message}");
}
using (doc)
{
var schema = JsonSchema.FromText(File.ReadAllText(fullSchemaPath));
if (rootNode is null)
{
errors.Add($"error: {fullCurvePath}: failed to parse JSON document");
}
else
{
var eval = schema.Evaluate(rootNode, new EvaluationOptions { OutputFormat = OutputFormat.List });
errors.AddRange(CollectSchemaMessages(eval, fullCurvePath));
}
if (doc.RootElement.ValueKind != JsonValueKind.Object)
errors.Add($"error: {fullCurvePath}: expected top-level object");
if (!doc.RootElement.TryGetProperty("schemaVersion", out var schemaVersion) ||
schemaVersion.ValueKind != JsonValueKind.Number ||
!schemaVersion.TryGetInt32(out var sv) ||
sv != 1)
{
errors.Add($"error: {fullCurvePath}: expected schemaVersion = 1");
}
if (!doc.RootElement.TryGetProperty("levels", out var levelsNode) ||
levelsNode.ValueKind != JsonValueKind.Array)
{
errors.Add($"error: {fullCurvePath}: expected top-level 'levels' array");
ThrowIfAny(errors);
}
var parsedRows = new List<LevelThresholdRow>();
var rowIndex = 0;
var prevLevel = 0;
var prevXp = -1;
foreach (var row in levelsNode.EnumerateArray())
{
if (row.ValueKind != JsonValueKind.Object)
{
errors.Add($"error: {fullCurvePath}: levels[{rowIndex}] must be an object");
rowIndex++;
continue;
}
if (!row.TryGetProperty("level", out var levelNode) ||
levelNode.ValueKind != JsonValueKind.Number ||
!levelNode.TryGetInt32(out var level) ||
level < 1)
{
errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].level must be an integer >= 1");
rowIndex++;
continue;
}
if (!row.TryGetProperty("requiredXp", out var xpNode) ||
xpNode.ValueKind != JsonValueKind.Number ||
!xpNode.TryGetInt32(out var requiredXp) ||
requiredXp < 0)
{
errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].requiredXp must be an integer >= 0");
rowIndex++;
continue;
}
if (row.TryGetProperty("level", out _) &&
row.TryGetProperty("requiredXp", out _))
{
if (level <= prevLevel)
errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].level must be strictly increasing");
if (requiredXp <= prevXp)
errors.Add($"error: {fullCurvePath}: levels[{rowIndex}].requiredXp must be strictly increasing");
prevLevel = level;
prevXp = requiredXp;
parsedRows.Add(new LevelThresholdRow(level, requiredXp));
}
rowIndex++;
}
if (parsedRows.Count == 0)
errors.Add($"error: {fullCurvePath}: levels must contain at least one valid row");
else if (parsedRows[0].Level != 1 || parsedRows[0].RequiredXp != 0)
errors.Add($"error: {fullCurvePath}: first level row must be {{\"level\":1,\"requiredXp\":0}}");
ThrowIfAny(errors);
logger.LogInformation(
"Loaded level curve from {CurvePath} with {LevelCount} thresholds.",
fullCurvePath,
parsedRows.Count);
return new ContentBackedSkillLevelCurve(parsedRows);
}
}
internal sealed record LevelThresholdRow(int Level, int RequiredXp);
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, List<string> sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, sink);
}
if (!r.HasErrors)
return;
foreach (var kv in r.Errors!)
{
var loc = r.InstanceLocation?.ToString();
if (string.IsNullOrEmpty(loc) || loc == "#")
loc = "(root)";
sink.Add($"error: {filePath} {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Level curve validation failed:");
foreach (var e in errors.OrderBy(static x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Persisted totals of skill XP per player (NEO-38); level is derived, not stored.</summary>
public interface IPlayerSkillProgressionStore
{
/// <summary>Keyed by catalog <c>SkillDef.id</c>; omitted skills imply XP 0.</summary>
IReadOnlyDictionary<string, int> GetXpTotals(string playerId);
/// <summary>
/// Adds <paramref name="amount"/> to <paramref name="skillId"/> for <paramref name="playerId"/>.
/// Returns <c>false</c> when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from <c>player_position</c>).
/// </summary>
bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp);
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Resolves player skill level from total XP using loaded content data.</summary>
public interface ISkillLevelCurve
{
/// <summary>Returns skill level (&gt;= 1) for a total XP amount.</summary>
int LevelFromTotalXp(int totalXp);
}

View File

@ -0,0 +1,81 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Thread-safe in-memory progression; seeds the configured dev player with an empty XP map (NEO-38).</summary>
public sealed class InMemoryPlayerSkillProgressionStore(IOptions<GamePositionOptions> options) : IPlayerSkillProgressionStore
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, int>> byPlayer = CreateInitialMap(options.Value);
/// <remarks>Locks per normalized player id for coherent read/compare/write on inner dictionaries.</remarks>
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentDictionary<string, ConcurrentDictionary<string, int>> CreateInitialMap(GamePositionOptions o)
{
var id = NormalizePlayerId(o.DevPlayerId);
var map = new ConcurrentDictionary<string, ConcurrentDictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
map[id] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
return map;
}
/// <inheritdoc />
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return ReadOnlyDic.Empty;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
return new Dictionary<string, int>(inner, StringComparer.Ordinal);
}
}
/// <inheritdoc />
public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp)
{
previousXp = 0;
newXp = 0;
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || skillId.Trim().Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
var sid = skillId.Trim();
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
inner.TryGetValue(sid, out var prev);
previousXp = prev;
newXp = prev + amount;
if (newXp < 0)
{
return false;
}
inner[sid] = newXp;
return true;
}
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
private static class ReadOnlyDic
{
internal static readonly IReadOnlyDictionary<string, int> Empty = new Dictionary<string, int>(StringComparer.Ordinal);
}
}

View File

@ -0,0 +1,121 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>PostgreSQL-backed skill XP totals keyed by normalized player id (NEO-38).</summary>
public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerSkillProgressionStore
{
/// <inheritdoc />
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return new Dictionary<string, int>(StringComparer.Ordinal);
}
PostgresSkillProgressionBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT skill_id, xp
FROM player_skill_progression
WHERE player_id = @pid;
""",
conn);
cmd.Parameters.AddWithValue("pid", norm);
var map = new Dictionary<string, int>(StringComparer.Ordinal);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
map[reader.GetString(0)] = reader.GetInt32(1);
}
return map;
}
/// <inheritdoc />
public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp)
{
previousXp = 0;
newXp = 0;
var norm = NormalizePlayerId(playerId);
var sid = skillId.Trim();
if (norm.Length == 0 || sid.Length == 0)
{
return false;
}
PostgresSkillProgressionBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
return false;
}
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT xp
FROM player_skill_progression
WHERE player_id = @pid AND skill_id = @sid
LIMIT 1
FOR UPDATE;
""",
conn,
tx))
{
sel.Parameters.AddWithValue("pid", norm);
sel.Parameters.AddWithValue("sid", sid);
var scalar = sel.ExecuteScalar();
previousXp = scalar is null || Equals(scalar, DBNull.Value)
? 0
: scalar is int xi
? xi
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
}
newXp = previousXp + amount;
if (newXp < 0)
{
tx.Rollback();
return false;
}
using var upsert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_skill_progression (player_id, skill_id, xp, updated_at)
VALUES (@pid, @sid, @xp, now())
ON CONFLICT (player_id, skill_id)
DO UPDATE SET xp = EXCLUDED.xp, updated_at = now();
""",
conn,
tx);
upsert.Parameters.AddWithValue("pid", norm);
upsert.Parameters.AddWithValue("sid", sid);
upsert.Parameters.AddWithValue("xp", newXp);
upsert.ExecuteNonQuery();
tx.Commit();
return true;
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Applies NEO-38 skill progression table DDL once per process.</summary>
public static class PostgresSkillProgressionBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V003__player_skill_progression.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-38 DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
System.Threading.Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -15,13 +15,13 @@ public sealed class SkillDefinitionCatalog
CatalogJsonFileCount = catalogJsonFileCount; CatalogJsonFileCount = catalogJsonFileCount;
} }
/// <summary>Absolute path to the directory that was enumerated for <c>*.json</c> catalogs.</summary> /// <summary>Absolute path to the directory that was enumerated for <c>*_skills.json</c> catalogs.</summary>
public string SkillsDirectory { get; } public string SkillsDirectory { get; }
public IReadOnlyDictionary<string, SkillDefRow> ById { get; } public IReadOnlyDictionary<string, SkillDefRow> ById { get; }
public int DistinctSkillCount => ById.Count; public int DistinctSkillCount => ById.Count;
/// <summary>Number of <c>*.json</c> files under <see cref="SkillsDirectory"/>.</summary> /// <summary>Number of <c>*_skills.json</c> files under <see cref="SkillsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } public int CatalogJsonFileCount { get; }
} }

View File

@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Skills;
/// <summary>Loads and validates <c>content/skills/*.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-34).</summary> /// <summary>Loads and validates <c>content/skills/*_skills.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-34).</summary>
public static class SkillDefinitionCatalogLoader public static class SkillDefinitionCatalogLoader
{ {
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary> /// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
@ -26,11 +26,11 @@ public static class SkillDefinitionCatalogLoader
if (errors.Count > 0) if (errors.Count > 0)
ThrowIfAny(errors); ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(skillsDirectory, "*.json", SearchOption.TopDirectoryOnly) var jsonFiles = Directory.GetFiles(skillsDirectory, "*_skills.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal) .OrderBy(p => p, StringComparer.Ordinal)
.ToArray(); .ToArray();
if (jsonFiles.Length == 0) if (jsonFiles.Length == 0)
errors.Add($"error: no JSON files under {skillsDirectory}"); errors.Add($"error: no *_skills.json files under {skillsDirectory}");
if (errors.Count > 0) if (errors.Count > 0)
ThrowIfAny(errors); ThrowIfAny(errors);

View File

@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>POST body for applying one skill XP grant (NEO-38).</summary>
public sealed class SkillProgressionGrantRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("amount")]
public int Amount { get; init; }
/// <remarks>Validated against target skill&apos;s <c>allowedXpSourceKinds</c> (ordinal ignore-case).</remarks>
[JsonPropertyName("sourceKind")]
public required string SourceKind { get; init; }
}
/// <summary>POST responses for progression grants — success (<see cref="Granted"/>) or structured deny (<see cref="ReasonCode"/>).</summary>
public sealed class SkillProgressionGrantResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("granted")]
public bool Granted { get; init; }
/// <remarks>Set when <see cref="Granted"/> is <c>false</c>; stable snake_case values (see README).</remarks>
[JsonPropertyName("reasonCode")]
public string? ReasonCode { get; init; }
[JsonPropertyName("progression")]
public required SkillProgressionSnapshotResponse Progression { get; init; }
/// <remarks>Present on success only; empty array when XP changed but level did not.</remarks>
[JsonPropertyName("levelUps")]
public required IReadOnlyList<SkillLevelUpJson> LevelUps { get; init; }
}
/// <summary>One skill crossed a level boundary on this grant (<c>previousLevel</c> &lt; <c>newLevel</c>).</summary>
public sealed class SkillLevelUpJson
{
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("previousLevel")]
public int PreviousLevel { get; init; }
[JsonPropertyName("newLevel")]
public int NewLevel { get; init; }
}

View File

@ -0,0 +1,40 @@
using NeonSprawl.Server.Game.PositionState;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Registers skill XP persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-38).</summary>
public static class SkillProgressionServiceCollectionExtensions
{
public static IServiceCollection AddSkillProgressionStore(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IPlayerSkillProgressionStore, PostgresPlayerSkillProgressionStore>();
}
else
{
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
}
services.AddSingleton<ISkillLevelCurve>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Skills.LevelCurve");
var skillsDir = SkillCatalogPathResolution.ResolveSkillsDirectory(opts.SkillsDirectory, hostEnv.ContentRootPath);
var curvePath = Path.Combine(skillsDir, "prototype_level_curve.json");
var schemaPath = Path.GetFullPath(Path.Combine(skillsDir, "..", "schemas", "level-curve.schema.json"));
return ContentBackedSkillLevelCurve.Load(skillsDir, curvePath, schemaPath, logger);
});
return services;
}
}

View File

@ -0,0 +1,142 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>
/// Maps <c>GET</c>/<c>POST /game/players/{{id}}/skill-progression</c> — read snapshot (NEO-37) and XP grant apply (NEO-38).
/// </summary>
public static class SkillProgressionSnapshotApi
{
/// <seealso cref="SkillProgressionGrantResponse.ReasonCode" />
public const string ReasonUnknownSkill = "unknown_skill";
public const string ReasonSourceKindNotAllowed = "source_kind_not_allowed";
public const string ReasonInvalidAmount = "invalid_amount";
public static WebApplication MapSkillProgressionSnapshotApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/skill-progression",
(string id, IPositionStateStore positions, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
{
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
return Results.Json(BuildSnapshot(trimmedId, registry, xpStore, levelCurve));
});
app.MapPost(
"/game/players/{id}/skill-progression",
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
{
static SkillProgressionGrantResponse Deny(
SkillProgressionSnapshotResponse snapshot,
string reason) =>
new SkillProgressionGrantResponse
{
Granted = false,
ReasonCode = reason,
Progression = snapshot,
LevelUps = [],
};
if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
if (body.Amount <= 0)
{
return Results.Json(Deny(beforeSnapshot, ReasonInvalidAmount));
}
var skillLookup = body.SkillId.Trim();
if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def))
{
return Results.Json(Deny(beforeSnapshot, ReasonUnknownSkill));
}
var source = body.SourceKind.Trim();
var allowed = def.AllowedXpSourceKinds.Any(k =>
string.Equals(k, source, StringComparison.OrdinalIgnoreCase));
if (!allowed)
{
return Results.Json(Deny(beforeSnapshot, ReasonSourceKindNotAllowed));
}
if (!xpStore.TryApplyXpDelta(trimmedId, def.Id, body.Amount, out var prevXp, out var newXp))
{
return Results.NotFound();
}
var prevLevel = levelCurve.LevelFromTotalXp(prevXp);
var nextLevel = levelCurve.LevelFromTotalXp(newXp);
var levelUps = new List<SkillLevelUpJson>();
if (nextLevel > prevLevel)
{
levelUps.Add(
new SkillLevelUpJson
{
SkillId = def.Id,
PreviousLevel = prevLevel,
NewLevel = nextLevel,
});
}
var afterSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
return Results.Json(
new SkillProgressionGrantResponse
{
Granted = true,
Progression = afterSnapshot,
LevelUps = levelUps,
});
});
return app;
}
internal static SkillProgressionSnapshotResponse BuildSnapshot(
string playerId,
ISkillDefinitionRegistry registry,
IPlayerSkillProgressionStore store,
ISkillLevelCurve levelCurve)
{
var xpBySkill = store.GetXpTotals(playerId);
var defs = registry.GetDefinitionsInIdOrder();
var skills = new List<SkillProgressionRowJson>(defs.Count);
foreach (var d in defs)
{
xpBySkill.TryGetValue(d.Id, out var xp);
var level = levelCurve.LevelFromTotalXp(xp);
skills.Add(
new SkillProgressionRowJson
{
Id = d.Id,
Xp = xp,
Level = level,
});
}
skills.Sort(static (a, b) => string.CompareOrdinal(a.Id, b.Id));
return new SkillProgressionSnapshotResponse
{
PlayerId = playerId,
Skills = skills,
SchemaVersion = SkillProgressionSnapshotResponse.CurrentSchemaVersion,
};
}
}

View File

@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>JSON body for <c>GET /game/players/{{id}}/skill-progression</c> (NEO-37).</summary>
public sealed class SkillProgressionSnapshotResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>One row per registered skill id. Consumers must treat this as unordered and key rows by <see cref="SkillProgressionRowJson.Id"/> — array order is not part of the public contract.</summary>
[JsonPropertyName("skills")]
public required IReadOnlyList<SkillProgressionRowJson> Skills { get; init; }
}
/// <summary>XP and level for one skill before grants (defaults) or after NEO-38 persistence.</summary>
public sealed class SkillProgressionRowJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("xp")]
public int Xp { get; init; }
[JsonPropertyName("level")]
public int Level { get; init; }
}

View File

@ -7,12 +7,14 @@ using NeonSprawl.Server.Game.Targeting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddPositionStateStore(builder.Configuration);
builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration);
builder.Services.AddSkillProgressionStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore(); builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>(); builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration); builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
var app = builder.Build(); var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>(); _ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
app.MapGet("/", () => Results.Text( app.MapGet("/", () => Results.Text(
"Neon Sprawl game server — GET /health for JSON status.", "Neon Sprawl game server — GET /health for JSON status.",
@ -28,6 +30,7 @@ app.MapPositionStateApi();
app.MapInteractionApi(); app.MapInteractionApi();
app.MapInteractablesWorldApi(); app.MapInteractablesWorldApi();
app.MapSkillDefinitionsWorldApi(); app.MapSkillDefinitionsWorldApi();
app.MapSkillProgressionSnapshotApi();
app.MapTargetingApi(); app.MapTargetingApi();
app.MapHotbarLoadoutApi(); app.MapHotbarLoadoutApi();
app.MapCooldownSnapshotApi(); app.MapCooldownSnapshotApi();

View File

@ -26,7 +26,7 @@ IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the vari
## Skill catalog (`content/skills`, NEO-34) ## Skill catalog (`content/skills`, NEO-34)
On startup the host loads every **`*.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. 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 | | Config | Meaning |
|--------|---------| |--------|---------|
@ -45,11 +45,33 @@ On success, **Information** logs include the resolved skills directory path and
curl -sS -i "http://localhost:5253/game/world/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](../../docs/plans/NEO-37-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-37.md`](../../docs/manual-qa/NEO-37.md); Bruno: `bruno/neon-sprawl-server/skill-progression/Get skill progression.bru`.
```bash
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](#position-persistence-neo-8) split** as hotbar: **`player_skill_progression`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V003__player_skill_progression.sql`](../db/migrations/V003__player_skill_progression.sql)), otherwise the in-memory fallback (dev bucket only, matching hotbar semantics).
**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](../../docs/plans/NEO-38-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-38.md`](../../docs/manual-qa/NEO-38.md); Bruno: `bruno/neon-sprawl-server/skill-progression/` (happy **POST** + deny smoke).
```bash
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"}'
```
## Position persistence (NEO-8) ## Position persistence (NEO-8)
When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x``sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../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). When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x``sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../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 three tests to **skip** again when the variable is unset. 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): **Environment variables** (typical — same mapping as other ASP.NET Core config):
@ -64,7 +86,7 @@ Match credentials with [root `docker-compose.yml`](../../docker-compose.yml) for
**Postgres integration tests** require `ConnectionStrings__NeonSprawl` (set automatically in [`.github/workflows/dotnet.yml`](../../.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. **Postgres integration tests** require `ConnectionStrings__NeonSprawl` (set automatically in [`.github/workflows/dotnet.yml`](../../.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 **three 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`](../../.github/workflows/dotnet.yml), so merges stay green when the workflow runs. **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`](../../.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`. **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`.

View File

@ -0,0 +1,10 @@
-- NEO-38: per-player non-combat skill XP totals (level derived server-side until data-driven curves).
CREATE TABLE IF NOT EXISTS player_skill_progression (
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
skill_id TEXT NOT NULL,
xp INTEGER NOT NULL CHECK (xp >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (player_id, skill_id)
);
COMMENT ON TABLE player_skill_progression IS 'Persisted skill XP per player (NEO-38); level is derived from data-driven LevelCurve content in server runtime.';