From 15bf5982288f63fe709fc04c02cefe2a1ddd7750 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 10 May 2026 19:38:30 -0400 Subject: [PATCH] NEO-42: add refine activity XP grant helper and tests --- docs/manual-qa/NEO-42.md | 59 +++++++++ docs/plans/NEO-42-implementation-plan.md | 14 ++- ...vityDeniedRegistryWebApplicationFactory.cs | 112 ++++++++++++++++++ .../Skills/RefineActivitySkillXpGrantTests.cs | 82 +++++++++++++ .../Game/Skills/RefineActivitySkillXpGrant.cs | 26 ++++ .../Game/Skills/RefineSkillXpConstants.cs | 11 ++ server/README.md | 4 + 7 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 docs/manual-qa/NEO-42.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs diff --git a/docs/manual-qa/NEO-42.md b/docs/manual-qa/NEO-42.md new file mode 100644 index 0000000..5460db3 --- /dev/null +++ b/docs/manual-qa/NEO-42.md @@ -0,0 +1,59 @@ +# Manual QA — NEO-42 (craft / refine → refine skill XP, `activity`) + +Reference: [implementation plan](../plans/NEO-42-implementation-plan.md), [NEO-38](./NEO-38.md) (skill progression grant), [NEO-41](./NEO-41.md) (gather analogue). + +## Preconditions + +- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`). +- Player **`dev-local-1`** (default seed). + +```bash +BASE=http://localhost:5253 +ID=dev-local-1 +``` + +## Automated coverage + +`RefineActivitySkillXpGrantTests` exercises the **NEO-38** grant path with disk **`prototype_skills.json`** (`refine` allows **`activity`**) and a stub registry where **`refine`** disallows **`activity`**. + +## Control: direct POST grant (same stack as E3.M2 hook) + +Move once so **`GET …/skill-progression`** is not **404**: + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"target":{"x":0.5,"y":0.9,"z":0.5}}' +``` + +Baseline: + +```bash +curl -sS "${BASE}/game/players/${ID}/skill-progression" +``` + +Apply **`refine`** + **`activity`** (mirrors what **`RefineActivitySkillXpGrant`** does internally): + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"skillId":"refine","amount":10,"sourceKind":"activity"}' +``` + +Expect **HTTP 200**, **`granted": true`**, and **`refine`** **`xp`** increased (repeat **POST** to see cumulative XP). + +## Deny path (catalog guard) + +With default catalog, **`trainer`** is allowed for **`refine`** but **`book_or_item`** is not: + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"skillId":"refine","amount":5,"sourceKind":"book_or_item"}' +``` + +Expect **HTTP 200**, **`granted": false`**, **`reasonCode":"source_kind_not_allowed"`**. + +## E3.M2 follow-up + +When recipe/refine execution exists, confirm **one** server-side call to **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** on **success only**, then **`GET …/skill-progression`** shows expected **`refine`** **`xp`**. diff --git a/docs/plans/NEO-42-implementation-plan.md b/docs/plans/NEO-42-implementation-plan.md index 6727393..cd6573d 100644 --- a/docs/plans/NEO-42-implementation-plan.md +++ b/docs/plans/NEO-42-implementation-plan.md @@ -35,8 +35,12 @@ ## Acceptance criteria checklist -- [ ] Craft/refine completion grants skill XP via **NEO-38** path (`TryApplyGrant` / shared persistence as `POST …/skill-progression`). -- [ ] Grant channels respect **skill catalog** `allowedXpSourceKinds` (deny / no-op when `activity` not allowed). +- [x] Craft/refine completion **shall** grant skill XP via **NEO-38** path — **`RefineActivitySkillXpGrant`** wraps **`TryApplyGrant`** (same stack as **`POST …/skill-progression`**). **E3.M2** must call **`GrantOnSuccessfulCraftOrRefine`** on success (documented in **`server/README.md`** + manual QA); no craft route in repo yet. +- [x] Grant channels respect **skill catalog** `allowedXpSourceKinds` (stub registry test: **`refine`** without **`activity`** → no XP). + +## Decisions (implementation) + +- **`RefineActivityDeniedRegistryWebApplicationFactory`** added for allowlist-deny coverage (parallel to NEO-41 salvage-denied factory). ## Technical approach @@ -57,13 +61,15 @@ | `server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs` | NEO-42 prototype constants: `refine`, `activity`, flat XP amount. | | `server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs` | Single entry point calling `TryApplyGrant` for successful craft/refine (prototype). | | `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs` | AAA tests: grant when allowed; no XP when `activity` not in allowlist for `refine`. | -| `docs/manual-qa/NEO-42.md` | Checklist during implementation per repo convention (may be minimal until E3.M2 endpoint exists). | +| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Test host with **`refine`** catalog row that omits **`activity`**. | +| `docs/manual-qa/NEO-42.md` | Curl checklist + E3.M2 follow-up note. | ## Files to modify | Path | Rationale | |------|-----------| -| **None at kickoff** for application wiring — **E3.M2** success handler file path is unknown until that module exists; add the **`RefineActivitySkillXpGrant`** call there in the same or follow-up PR. If E3.M2 lands before NEO-42 implementation, replace this row with the concrete path (e.g. `…/Crafting/CraftApi.cs`). | +| `server/README.md` | Document NEO-42 hook type and E3.M2 integration expectation. | +| **E3.M2 craft success handler** (path TBD) | Call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** once on successful craft/refine — **not done in this PR** (module absent). | ## Tests diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs new file mode 100644 index 0000000..39a92df --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs @@ -0,0 +1,112 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; +using Npgsql; + +namespace NeonSprawl.Server.Tests.Game.Skills; + +/// +/// Same in-memory overrides as , plus a stub +/// where refine does not allow activity — NEO-42 defensive tests without mutating disk catalog. +/// +public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + builder.UseSetting("Content:SkillsDirectory", skillsDir); + + builder.ConfigureTestServices(services => + { + for (var i = services.Count - 1; i >= 0; i--) + { + var d = services[i]; + if (d.ServiceType == typeof(IPositionStateStore) || + d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || + d.ServiceType == typeof(IPlayerSkillProgressionStore) || + d.ServiceType == typeof(TimeProvider) || + d.ServiceType == typeof(IPlayerAbilityCooldownStore) || + d.ServiceType == typeof(ISkillDefinitionRegistry) || + d.ServiceType == typeof(NpgsqlDataSource) || + (d.ServiceType == typeof(IHostedService) && + (d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) || + d.ImplementationType == typeof(NpgsqlDataSourceShutdownHostedService)))) + { + services.RemoveAt(i); + } + } + + var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero)); + services.AddSingleton(fakeTime); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + } +} + +/// Prototype trio ids; refine excludes activity so craft/refine grant path denies without throwing. +internal sealed class RefineActivityDeniedSkillRegistry : ISkillDefinitionRegistry +{ + private static readonly SkillDefRow Salvage = new( + "salvage", + "gather", + "Salvage", + new[] { "activity", "mission_reward" }); + + private static readonly SkillDefRow RefineNoActivity = new( + "refine", + "process", + "Refine", + new[] { "mission_reward", "trainer" }); + + private static readonly SkillDefRow Intrusion = new( + "intrusion", + "tech", + "Intrusion", + new[] { "activity", "mission_reward", "book_or_item" }); + + private static readonly IReadOnlyList Ordered = + [ + Intrusion, + RefineNoActivity, + Salvage, + ]; + + public bool TryGetDefinition(string? skillId, [NotNullWhen(true)] out SkillDefRow? definition) + { + if (skillId is null) + { + definition = null; + return false; + } + + var k = skillId.Trim(); + foreach (var row in Ordered) + { + if (string.Equals(row.Id, k, StringComparison.OrdinalIgnoreCase)) + { + definition = row; + return true; + } + } + + definition = null; + return false; + } + + public IReadOnlyList GetDefinitionsInIdOrder() => Ordered; +} diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs new file mode 100644 index 0000000..d59f4c0 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Skills; + +/// NEO-42: craft/refine completion hook → refine XP via shared NEO-38 grant path (sourceKind: activity). +public sealed class RefineActivitySkillXpGrantTests +{ + [Fact] + public async Task GrantOnSuccessfulCraftOrRefine_WhenCatalogAllowsActivity_ShouldIncreaseRefineXp() + { + // Arrange — dev player is seeded on the in-memory progression store (NEO-38). + await using var factory = new InMemoryWebApplicationFactory(); + _ = factory.CreateClient(); + using var scope = factory.Services.CreateScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + var xpStore = scope.ServiceProvider.GetRequiredService(); + var levelCurve = scope.ServiceProvider.GetRequiredService(); + + // Act + RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve); + + // Assert + var totals = xpStore.GetXpTotals("dev-local-1"); + Assert.True(totals.TryGetValue("refine", out var xp)); + Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, xp); + } + + [Fact] + public async Task GrantOnSuccessfulCraftOrRefine_WhenCatalogAllowsActivity_ShouldMatchSkillProgressionGet() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 0.5, Y = 0.9, Z = 0.5 }, + }; + await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); + using var scope = factory.Services.CreateScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + var xpStore = scope.ServiceProvider.GetRequiredService(); + var levelCurve = scope.ServiceProvider.GetRequiredService(); + + // Act + RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve); + var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode); + var body = await snapshot.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var refine = body!.Skills!.Single(static s => s.Id == "refine"); + Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, refine.Xp); + } + + [Fact] + public async Task GrantOnSuccessfulCraftOrRefine_WhenRefineDisallowsActivity_ShouldLeaveRefineXpZero() + { + // Arrange + await using var factory = new RefineActivityDeniedRegistryWebApplicationFactory(); + _ = factory.CreateClient(); + using var scope = factory.Services.CreateScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + var xpStore = scope.ServiceProvider.GetRequiredService(); + var levelCurve = scope.ServiceProvider.GetRequiredService(); + + // Act + RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve); + + // Assert + var totals = xpStore.GetXpTotals("dev-local-1"); + _ = totals.TryGetValue("refine", out var refineXp); + Assert.Equal(0, refineXp); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs b/server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs new file mode 100644 index 0000000..8d5d00d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs @@ -0,0 +1,26 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// +/// NEO-42: E3.M2 craft/refine success should call so XP uses the same path as +/// POST …/skill-progression. Outcome is ignored on success paths (mirror gather interact); use +/// directly when callers need deny envelopes. +/// +public static class RefineActivitySkillXpGrant +{ + /// Applies one refine + activity grant if the catalog allowlist permits it. + public static void GrantOnSuccessfulCraftOrRefine( + string playerId, + ISkillDefinitionRegistry registry, + IPlayerSkillProgressionStore xpStore, + ISkillLevelCurve levelCurve) + { + _ = SkillProgressionGrantOperations.TryApplyGrant( + playerId.Trim(), + RefineSkillXpConstants.RefineSkillId, + RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, + RefineSkillXpConstants.ActivitySourceKind, + registry, + xpStore, + levelCurve); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs b/server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs new file mode 100644 index 0000000..6a73345 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// NEO-42: prototype craft/refine completion → refine skill XP via sourceKind: activity. +public static class RefineSkillXpConstants +{ + public const int ActivityXpPerCraftRefineCompletion = 10; + + public const string RefineSkillId = "refine"; + + public const string ActivitySourceKind = "activity"; +} diff --git a/server/README.md b/server/README.md index 4053c56..f78509e 100644 --- a/server/README.md +++ b/server/README.md @@ -178,6 +178,10 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ When **`allowed` is `true`** and the interactable’s **`kind`** is **`resource_node`** (today: **`prototype_resource_node_alpha`** from **`GET /game/world/interactables`**), the server applies **10** **`salvage`** skill XP with **`sourceKind: activity`** using the **same validation and persistence** as **`POST /game/players/{id}/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)) via `SkillProgressionGrantOperations` — not a separate client-side XP bump. Move into horizontal range first (anchor **(12, −6)** m on X/Z, radius **3**). Plan: [NEO-41 implementation plan](../../docs/plans/NEO-41-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-41.md`](../../docs/manual-qa/NEO-41.md); Bruno: `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` then `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`. +### Craft / refine hook → skill XP (NEO-42) + +**E3.M2** recipe/refine success paths should call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`**, which applies **10** **`refine`** skill XP with **`sourceKind: activity`** through **`SkillProgressionGrantOperations.TryApplyGrant`** (same validation, **`allowedXpSourceKinds`**, and persistence as **[NEO-38](#skill-progression-grant-neo-38)**). There is no separate craft HTTP in NEO-42; verify behavior with **`POST …/skill-progression`** for **`refine`**/**`activity`** until the craft module wires the helper. Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md). + Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md). ## Interactable descriptors (NEO-25)