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.pull/73/head
parent
386bfbc1d4
commit
236040a5c2
|
|
@ -6,7 +6,7 @@ meta {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-37: per-player read model; join with GET /game/world/skill-definitions for display names.
|
NEO-37: per-player read model; join with GET /game/world/skill-definitions for display names.
|
||||||
NEO-38: XP persists (in-memory/Postgres). These checks do not assume a fresh server—level must match placeholder curve level = 1 + floor(max(0,xp)/100). Restart server (or truncate DB in Postgres mode) to see all-zero defaults again.
|
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 {
|
get {
|
||||||
|
|
@ -26,15 +26,32 @@ tests {
|
||||||
expect(body.skills.length).to.equal(3);
|
expect(body.skills.length).to.equal(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("frozen prototype trio with levels consistent with server placeholder curve", function () {
|
test("frozen prototype trio with levels consistent with content-backed curve", function () {
|
||||||
const body = res.getBody();
|
const body = res.getBody();
|
||||||
const ids = new Set(body.skills.map((x) => x.id));
|
const ids = new Set(body.skills.map((x) => x.id));
|
||||||
expect(ids.has("salvage")).to.equal(true);
|
expect(ids.has("salvage")).to.equal(true);
|
||||||
expect(ids.has("refine")).to.equal(true);
|
expect(ids.has("refine")).to.equal(true);
|
||||||
expect(ids.has("intrusion")).to.equal(true);
|
expect(ids.has("intrusion")).to.equal(true);
|
||||||
|
|
||||||
/** Matches SkillLevelCurvePlaceholder (NEO-38): level = 1 + floor(max(0,xp)/100). */
|
/** Matches prototype_level_curve.json (NEO-39). */
|
||||||
const expectedLevel = (xp) => 1 + Math.floor(Math.max(0, xp) / 100);
|
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"]) {
|
for (const id of ["salvage", "refine", "intrusion"]) {
|
||||||
const row = body.skills.find((x) => x.id === id);
|
const row = body.skills.find((x) => x.id === id);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -15,9 +15,11 @@
|
||||||
|
|
||||||
**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-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; inline **`SkillLevelCurvePlaceholder`** until [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39); 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-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)**.
|
||||||
|
|
||||||
**Still backlog within this module:** data-driven **`LevelCurve`** + CI (**NEO-39**); 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.
|
**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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
||||||
| 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). **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.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 | 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), **`SkillLevelCurvePlaceholder`**, 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). **Follow-on:** [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). 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), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-40, NEO-41–NEO-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-37–NEO-40, NEO-41–NEO-43 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 story’s 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).
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ public sealed class SkillProgressionGrantApiTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostSkillProgression_ShouldReportLevelUps_WhenThresholdCrossed()
|
public async Task PostSkillProgression_ShouldReportLevelUps_WhenThresholdCrossed()
|
||||||
{
|
{
|
||||||
// Arrange — placeholder curve: +100 xp from 0 → level 2
|
// Arrange — content curve (prototype_level_curve.json): +100 xp from 0 → level 2
|
||||||
await using var factory = new InMemoryWebApplicationFactory();
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (>= 1) for a total XP amount.</summary>
|
||||||
|
int LevelFromTotalXp(int totalXp);
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
namespace NeonSprawl.Server.Game.Skills;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Inline level curve replaced by content-driven thresholds in NEO-39. Rule (v1): <c>level = 1 + floor(xp / 100)</c> for non-negative XP (uncapped prototype).
|
|
||||||
/// </summary>
|
|
||||||
public static class SkillLevelCurvePlaceholder
|
|
||||||
{
|
|
||||||
internal const int XpPerLevelStep = 100;
|
|
||||||
|
|
||||||
/// <summary>Returns skill level (>= 1) for the given total accumulated XP.</summary>
|
|
||||||
public static int LevelFromTotalXp(int totalXp)
|
|
||||||
{
|
|
||||||
var xp = Math.Max(0, totalXp);
|
|
||||||
return 1 + (xp / XpPerLevelStep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Skills;
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
|
@ -7,6 +9,9 @@ public static class SkillProgressionServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddSkillProgressionStore(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddSkillProgressionStore(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
services.AddOptions<ContentPathsOptions>()
|
||||||
|
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||||
|
|
||||||
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||||
if (!string.IsNullOrWhiteSpace(cs))
|
if (!string.IsNullOrWhiteSpace(cs))
|
||||||
{
|
{
|
||||||
|
|
@ -17,6 +22,19 @@ public static class SkillProgressionServiceCollectionExtensions
|
||||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
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;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ public static class SkillProgressionSnapshotApi
|
||||||
{
|
{
|
||||||
app.MapGet(
|
app.MapGet(
|
||||||
"/game/players/{id}/skill-progression",
|
"/game/players/{id}/skill-progression",
|
||||||
(string id, IPositionStateStore positions, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore) =>
|
(string id, IPositionStateStore positions, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
|
||||||
{
|
{
|
||||||
var trimmedId = id.Trim();
|
var trimmedId = id.Trim();
|
||||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||||
|
|
@ -25,13 +25,13 @@ public static class SkillProgressionSnapshotApi
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Json(BuildSnapshot(trimmedId, registry, xpStore));
|
return Results.Json(BuildSnapshot(trimmedId, registry, xpStore, levelCurve));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/game/players/{id}/skill-progression",
|
"/game/players/{id}/skill-progression",
|
||||||
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
|
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
|
||||||
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore) =>
|
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
|
||||||
{
|
{
|
||||||
static SkillProgressionGrantResponse Deny(
|
static SkillProgressionGrantResponse Deny(
|
||||||
SkillProgressionSnapshotResponse snapshot,
|
SkillProgressionSnapshotResponse snapshot,
|
||||||
|
|
@ -55,7 +55,7 @@ public static class SkillProgressionSnapshotApi
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore);
|
var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
|
||||||
|
|
||||||
if (body.Amount <= 0)
|
if (body.Amount <= 0)
|
||||||
{
|
{
|
||||||
|
|
@ -81,8 +81,8 @@ public static class SkillProgressionSnapshotApi
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var prevLevel = SkillLevelCurvePlaceholder.LevelFromTotalXp(prevXp);
|
var prevLevel = levelCurve.LevelFromTotalXp(prevXp);
|
||||||
var nextLevel = SkillLevelCurvePlaceholder.LevelFromTotalXp(newXp);
|
var nextLevel = levelCurve.LevelFromTotalXp(newXp);
|
||||||
var levelUps = new List<SkillLevelUpJson>();
|
var levelUps = new List<SkillLevelUpJson>();
|
||||||
if (nextLevel > prevLevel)
|
if (nextLevel > prevLevel)
|
||||||
{
|
{
|
||||||
|
|
@ -95,7 +95,7 @@ public static class SkillProgressionSnapshotApi
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var afterSnapshot = BuildSnapshot(trimmedId, registry, xpStore);
|
var afterSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
new SkillProgressionGrantResponse
|
new SkillProgressionGrantResponse
|
||||||
{
|
{
|
||||||
|
|
@ -111,7 +111,8 @@ public static class SkillProgressionSnapshotApi
|
||||||
internal static SkillProgressionSnapshotResponse BuildSnapshot(
|
internal static SkillProgressionSnapshotResponse BuildSnapshot(
|
||||||
string playerId,
|
string playerId,
|
||||||
ISkillDefinitionRegistry registry,
|
ISkillDefinitionRegistry registry,
|
||||||
IPlayerSkillProgressionStore store)
|
IPlayerSkillProgressionStore store,
|
||||||
|
ISkillLevelCurve levelCurve)
|
||||||
{
|
{
|
||||||
var xpBySkill = store.GetXpTotals(playerId);
|
var xpBySkill = store.GetXpTotals(playerId);
|
||||||
var defs = registry.GetDefinitionsInIdOrder();
|
var defs = registry.GetDefinitionsInIdOrder();
|
||||||
|
|
@ -119,7 +120,7 @@ public static class SkillProgressionSnapshotApi
|
||||||
foreach (var d in defs)
|
foreach (var d in defs)
|
||||||
{
|
{
|
||||||
xpBySkill.TryGetValue(d.Id, out var xp);
|
xpBySkill.TryGetValue(d.Id, out var xp);
|
||||||
var level = SkillLevelCurvePlaceholder.LevelFromTotalXp(xp);
|
var level = levelCurve.LevelFromTotalXp(xp);
|
||||||
skills.Add(
|
skills.Add(
|
||||||
new SkillProgressionRowJson
|
new SkillProgressionRowJson
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ 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.",
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
|
|
@ -47,7 +47,7 @@ curl -sS -i "http://localhost:5253/game/world/skill-definitions"
|
||||||
|
|
||||||
## Skill progression snapshot (NEO-37)
|
## 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). Before any stored XP, each row reflects **`xp`** **0** and **`level`** **1** (levels are derived from total XP via an inline placeholder curve until [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39): **`level = 1 + floor(xp / 100)`** for non-negative XP). 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`.
|
**`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
|
```bash
|
||||||
curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression"
|
curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression"
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@ CREATE TABLE IF NOT EXISTS player_skill_progression (
|
||||||
PRIMARY KEY (player_id, skill_id)
|
PRIMARY KEY (player_id, skill_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE player_skill_progression IS 'Persisted skill XP per player (NEO-38); level from SkillLevelCurvePlaceholder until NEO-39.';
|
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.';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue