From 33b2e917e46537cf4083b390be0d4562cd3e8bf3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 6 May 2026 22:17:02 -0400 Subject: [PATCH] NEO-38: skill XP POST grant, persistence, level-up response --- .../Post skill progression grant.bru | 40 ++++ docs/manual-qa/NEO-38.md | 29 +++ docs/plans/NEO-38-implementation-plan.md | 8 +- .../Skills/SkillProgressionGrantApiTests.cs | 190 ++++++++++++++++++ ...ressionGrantPersistenceIntegrationTests.cs | 77 +++++++ .../InMemoryWebApplicationFactory.cs | 2 + .../NeonSprawl.Server.Tests.csproj | 2 +- .../Skills/IPlayerSkillProgressionStore.cs | 14 ++ .../InMemoryPlayerSkillProgressionStore.cs | 81 ++++++++ .../PostgresPlayerSkillProgressionStore.cs | 121 +++++++++++ .../PostgresSkillProgressionBootstrap.cs | 39 ++++ .../Game/Skills/SkillLevelCurvePlaceholder.cs | 16 ++ .../Game/Skills/SkillProgressionGrantDtos.cs | 58 ++++++ ...lProgressionServiceCollectionExtensions.cs | 22 ++ .../Skills/SkillProgressionSnapshotApi.cs | 103 +++++++++- server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 18 +- .../V003__player_skill_progression.sql | 10 + 18 files changed, 817 insertions(+), 14 deletions(-) create mode 100644 bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru create mode 100644 docs/manual-qa/NEO-38.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantPersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/PostgresSkillProgressionBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillLevelCurvePlaceholder.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillProgressionServiceCollectionExtensions.cs create mode 100644 server/db/migrations/V003__player_skill_progression.sql diff --git a/bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru b/bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru new file mode 100644 index 0000000..8bfa23d --- /dev/null +++ b/bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru @@ -0,0 +1,40 @@ +meta { + name: POST skill progression grant + type: http + seq: 2 +} + +docs { + NEO-38: single skill XP grant + allowlist + level-up metadata; see server README. +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/skill-progression + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "skillId": "salvage", + "amount": 10, + "sourceKind": "activity" + } +} + +tests { + test("grant returns 200 with granted true and progression", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.granted).to.equal(true); + expect(body.progression).to.be.an("object"); + expect(body.progression.schemaVersion).to.equal(1); + expect(body.progression.playerId).to.equal("dev-local-1"); + expect(body.levelUps).to.be.an("array"); + const salvage = body.progression.skills.find((x) => x.id === "salvage"); + expect(salvage).to.be.an("object"); + expect(salvage.xp).to.be.a("number"); + }); +} diff --git a/docs/manual-qa/NEO-38.md b/docs/manual-qa/NEO-38.md new file mode 100644 index 0000000..72e4a80 --- /dev/null +++ b/docs/manual-qa/NEO-38.md @@ -0,0 +1,29 @@ +# Manual QA — NEO-38 (skill XP grant) + +Reference: [implementation plan](../plans/NEO-38-implementation-plan.md), [server README](../../server/README.md#skill-progression-grant-neo-38). + +## Preconditions + +- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`). +- Use a **known** player id (e.g. `dev-local-1`, present in position state). + +## Happy path — grant + read back + +1. `POST /game/players/dev-local-1/skill-progression` with JSON: + - `schemaVersion` **1** + - `skillId` **`salvage`** + - `amount` **10** + - `sourceKind` **`activity`** +2. Expect **HTTP 200**, body `granted` **true**, `progression.skills` contains `salvage` with increased `xp` vs a prior `GET`. +3. `GET /game/players/dev-local-1/skill-progression` — **`salvage.xp`** matches the POST `progression`. + +## Deny — source not allowed + +1. `POST` same path with `skillId` **`salvage`**, `sourceKind` **`trainer`** (not in prototype catalog for salvage). +2. Expect **HTTP 200**, `granted` **false**, **`reasonCode`** **`source_kind_not_allowed`**, and `progression` still present. + +## Bruno + +- Folder: `bruno/neon-sprawl-server/skill-progression/` +- `Post skill progression grant.bru` — happy grant. +- `Get skill progression.bru` — confirms read model after grants. diff --git a/docs/plans/NEO-38-implementation-plan.md b/docs/plans/NEO-38-implementation-plan.md index e8d5c7f..54fc607 100644 --- a/docs/plans/NEO-38-implementation-plan.md +++ b/docs/plans/NEO-38-implementation-plan.md @@ -36,10 +36,10 @@ ## Acceptance criteria checklist -- [ ] Unknown `skillId` rejected; `sourceKind` not in allowlist rejected (**stable reason codes**). -- [ ] Valid grant updates stored progression; level increases when thresholds crossed (**level-up** payload on success). -- [ ] Versioned **POST** + tests; Bruno; README. -- [ ] Persistence: **NEO-29-style** dual backend (in-memory + Postgres when configured). +- [x] Unknown `skillId` rejected; `sourceKind` not in allowlist rejected (**stable reason codes**). +- [x] Valid grant updates stored progression; level increases when thresholds crossed (**level-up** payload on success). +- [x] Versioned **POST** + tests; Bruno; README. +- [x] Persistence: **NEO-29-style** dual backend (in-memory + Postgres when configured). ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs new file mode 100644 index 0000000..8688b41 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs @@ -0,0 +1,190 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Skills; + +public sealed class SkillProgressionGrantApiTests +{ + private static SkillProgressionGrantRequest Grant( + string skillId, + int schemaVersion, + int amount = 50, + string sourceKind = "activity") => + new SkillProgressionGrantRequest + { + SchemaVersion = schemaVersion, + SkillId = skillId, + Amount = amount, + SourceKind = sourceKind, + }; + + private static SkillProgressionGrantRequest ValidGrant(string skillId, int amount = 50, string sourceKind = "activity") => + Grant(skillId, SkillProgressionGrantRequest.CurrentSchemaVersion, amount, sourceKind); + + [Fact] + public async Task PostSkillProgression_ShouldReturnBadRequest_WhenSchemaVersionMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var request = Grant("salvage", SkillProgressionGrantRequest.CurrentSchemaVersion + 42, amount: 1); + + // Act + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/skill-progression", request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostSkillProgression_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/missing-player/skill-progression", + ValidGrant("salvage")); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostSkillProgression_ShouldDenyUnknownSkill_WithReasonUnknownSkill_AndEchoProgression() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/skill-progression", + ValidGrant("not-a-prototype-skill")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var envelope = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.False(envelope!.Granted); + Assert.Equal(SkillProgressionSnapshotApi.ReasonUnknownSkill, envelope.ReasonCode); + Assert.NotNull(envelope.Progression); + var salvage = envelope.Progression!.Skills!.Single(static s => s.Id == "salvage"); + Assert.Equal(0, salvage.Xp); + Assert.Equal(1, salvage.Level); + } + + [Fact] + public async Task PostSkillProgression_ShouldDenyTrainerOnSalvage_WithReasonSourceKindNotAllowed() + { + // Arrange — prototype_skills: salvage allowedXpSourceKinds = activity, mission_reward only + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/skill-progression", + ValidGrant("salvage", amount: 10, sourceKind: "trainer")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var envelope = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.False(envelope!.Granted); + Assert.Equal(SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed, envelope.ReasonCode); + } + + [Fact] + public async Task PostSkillProgression_ShouldDenyNonPositiveAmount_WithReasonInvalidAmount() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/skill-progression", + ValidGrant("salvage", amount: 0)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var envelope = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.False(envelope!.Granted); + Assert.Equal(SkillProgressionSnapshotApi.ReasonInvalidAmount, envelope.ReasonCode); + } + + [Fact] + public async Task PostSkillProgression_ShouldApplyGrant_AndGetMatches_WhenTrainerAllowedForRefine() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/skill-progression", + ValidGrant("refine", amount: 25, sourceKind: "trainer")); + + // Assert + Assert.Equal(HttpStatusCode.OK, post.StatusCode); + var envelope = await post.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.True(envelope!.Granted); + Assert.Empty(envelope.LevelUps); + Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "refine" && s.Xp == 25 && s.Level == 1); + + var get = await client.GetFromJsonAsync( + "/game/players/dev-local-1/skill-progression"); + Assert.NotNull(get); + var refine = Assert.Single(get!.Skills!, static s => s.Id == "refine"); + Assert.Equal(25, refine.Xp); + Assert.Equal(1, refine.Level); + } + + [Fact] + public async Task PostSkillProgression_ShouldReportLevelUps_WhenThresholdCrossed() + { + // Arrange — placeholder curve: +100 xp from 0 → level 2 + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/skill-progression", + ValidGrant("intrusion", amount: 100, sourceKind: "activity")); + + // Assert + var envelope = await post.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.True(envelope!.Granted); + var up = Assert.Single(envelope.LevelUps!); + Assert.Equal("intrusion", up.SkillId); + Assert.Equal(1, up.PreviousLevel); + Assert.Equal(2, up.NewLevel); + Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "intrusion" && s.Xp == 100 && s.Level == 2); + } + + [Fact] + public async Task PostSkillProgression_ShouldAcceptSourceKind_WithIgnoreCase_OnAllowedList() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/skill-progression", + ValidGrant("salvage", amount: 5, sourceKind: "ACTIVITY")); + + // Assert + var envelope = await post.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.True(envelope!.Granted); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantPersistenceIntegrationTests.cs new file mode 100644 index 0000000..43293dc --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantPersistenceIntegrationTests.cs @@ -0,0 +1,77 @@ +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Skills; + +[Collection("Postgres integration")] +public sealed class SkillProgressionGrantPersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + [RequirePostgresFact] + public async Task PostGrantThenGetAcrossNewFactory_ShouldPersistSkillXp() + { + // Arrange + await ResetProgressionTableAsync(); + var grant = new SkillProgressionGrantRequest + { + SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion, + SkillId = "salvage", + Amount = 50, + SourceKind = "activity", + }; + HttpResponseMessage postResponse; + + // Act + using (var firstClient = Factory.CreateClient()) + { + postResponse = await firstClient.PostAsJsonAsync( + "/game/players/dev-local-1/skill-progression", + grant); + } + + await using var secondFactory = new PostgresWebApplicationFactory(); + using var secondClient = secondFactory.CreateClient(); + var snapshot = + await secondClient.GetFromJsonAsync( + "/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.True(postResponse.IsSuccessStatusCode); + Assert.NotNull(snapshot); + var salvage = Assert.Single(snapshot!.Skills!, static s => s.Id == "salvage"); + Assert.Equal(50, salvage.Xp); + Assert.Equal(1, salvage.Level); + } + + private static async Task ResetProgressionTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V003__player_skill_progression.sql"); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = await File.ReadAllTextAsync(ddlPath); + await using var conn = new NpgsqlConnection(cs); + await conn.OpenAsync(); + await using (var apply = new NpgsqlCommand(ddl, conn)) + { + await apply.ExecuteNonQueryAsync(); + } + + await using (var truncate = new NpgsqlCommand("TRUNCATE player_skill_progression;", conn)) + { + await truncate.ExecuteNonQueryAsync(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 7395778..69e4047 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -31,6 +31,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(fakeTime); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); }); } diff --git a/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj b/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj index 22d6cbb..cd49d16 100644 --- a/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +++ b/server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj @@ -27,7 +27,7 @@ - + diff --git a/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs new file mode 100644 index 0000000..bd183ed --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// Persisted totals of skill XP per player (NEO-38); level is derived, not stored. +public interface IPlayerSkillProgressionStore +{ + /// Keyed by catalog SkillDef.id; omitted skills imply XP 0. + IReadOnlyDictionary GetXpTotals(string playerId); + + /// + /// Adds to for . + /// Returns false when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from player_position). + /// + bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp); +} diff --git a/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs new file mode 100644 index 0000000..cadd39f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Skills; + +/// Thread-safe in-memory progression; seeds the configured dev player with an empty XP map (NEO-38). +public sealed class InMemoryPlayerSkillProgressionStore(IOptions options) : IPlayerSkillProgressionStore +{ + private readonly ConcurrentDictionary> byPlayer = CreateInitialMap(options.Value); + + /// Locks per normalized player id for coherent read/compare/write on inner dictionaries. + private readonly ConcurrentDictionary playerLocks = new(StringComparer.OrdinalIgnoreCase); + + private static ConcurrentDictionary> CreateInitialMap(GamePositionOptions o) + { + var id = NormalizePlayerId(o.DevPlayerId); + var map = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + map[id] = new ConcurrentDictionary(StringComparer.Ordinal); + return map; + } + + /// + public IReadOnlyDictionary GetXpTotals(string playerId) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return ReadOnlyDic.Empty; + } + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + return new Dictionary(inner, StringComparer.Ordinal); + } + } + + /// + public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp) + { + previousXp = 0; + newXp = 0; + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || skillId.Trim().Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return false; + } + + var sid = skillId.Trim(); + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + inner.TryGetValue(sid, out var prev); + previousXp = prev; + newXp = prev + amount; + if (newXp < 0) + { + return false; + } + + inner[sid] = newXp; + return true; + } + } + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + return string.Empty; + } + + return t.ToLowerInvariant(); + } + + private static class ReadOnlyDic + { + internal static readonly IReadOnlyDictionary Empty = new Dictionary(StringComparer.Ordinal); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs new file mode 100644 index 0000000..0a0f221 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs @@ -0,0 +1,121 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// PostgreSQL-backed skill XP totals keyed by normalized player id (NEO-38). +public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerSkillProgressionStore +{ + /// + public IReadOnlyDictionary GetXpTotals(string playerId) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + PostgresSkillProgressionBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT skill_id, xp + FROM player_skill_progression + WHERE player_id = @pid; + """, + conn); + cmd.Parameters.AddWithValue("pid", norm); + var map = new Dictionary(StringComparer.Ordinal); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + map[reader.GetString(0)] = reader.GetInt32(1); + } + + return map; + } + + /// + public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp) + { + previousXp = 0; + newXp = 0; + var norm = NormalizePlayerId(playerId); + var sid = skillId.Trim(); + if (norm.Length == 0 || sid.Length == 0) + { + return false; + } + + PostgresSkillProgressionBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, norm, tx)) + { + tx.Rollback(); + return false; + } + + using (var sel = new Npgsql.NpgsqlCommand( + """ + SELECT xp + FROM player_skill_progression + WHERE player_id = @pid AND skill_id = @sid + LIMIT 1 + FOR UPDATE; + """, + conn, + tx)) + { + sel.Parameters.AddWithValue("pid", norm); + sel.Parameters.AddWithValue("sid", sid); + var scalar = sel.ExecuteScalar(); + previousXp = scalar is null || Equals(scalar, DBNull.Value) + ? 0 + : scalar is int xi + ? xi + : Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); + } + + newXp = previousXp + amount; + if (newXp < 0) + { + tx.Rollback(); + return false; + } + + using var upsert = new Npgsql.NpgsqlCommand( + """ + INSERT INTO player_skill_progression (player_id, skill_id, xp, updated_at) + VALUES (@pid, @sid, @xp, now()) + ON CONFLICT (player_id, skill_id) + DO UPDATE SET xp = EXCLUDED.xp, updated_at = now(); + """, + conn, + tx); + upsert.Parameters.AddWithValue("pid", norm); + upsert.Parameters.AddWithValue("sid", sid); + upsert.Parameters.AddWithValue("xp", newXp); + upsert.ExecuteNonQuery(); + tx.Commit(); + return true; + } + + private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx) + { + using var cmd = new Npgsql.NpgsqlCommand( + "SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;", + conn, + tx); + cmd.Parameters.AddWithValue("pid", playerIdNormalized); + return cmd.ExecuteScalar() is not null; + } + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + return string.Empty; + } + + return t.ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/PostgresSkillProgressionBootstrap.cs b/server/NeonSprawl.Server/Game/Skills/PostgresSkillProgressionBootstrap.cs new file mode 100644 index 0000000..2eb3844 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/PostgresSkillProgressionBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// Applies NEO-38 skill progression table DDL once per process. +public static class PostgresSkillProgressionBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V003__player_skill_progression.sql"); + + private static readonly object SchemaGate = new(); + + private static int _schemaReady; + + public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + lock (SchemaGate) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"NEO-38 DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand(ddl, conn); + cmd.ExecuteNonQuery(); + System.Threading.Volatile.Write(ref _schemaReady, 1); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillLevelCurvePlaceholder.cs b/server/NeonSprawl.Server/Game/Skills/SkillLevelCurvePlaceholder.cs new file mode 100644 index 0000000..055ebca --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillLevelCurvePlaceholder.cs @@ -0,0 +1,16 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// +/// Inline level curve replaced by content-driven thresholds in NEO-39. Rule (v1): level = 1 + floor(xp / 100) for non-negative XP (uncapped prototype). +/// +public static class SkillLevelCurvePlaceholder +{ + internal const int XpPerLevelStep = 100; + + /// Returns skill level (>= 1) for the given total accumulated XP. + public static int LevelFromTotalXp(int totalXp) + { + var xp = Math.Max(0, totalXp); + return 1 + (xp / XpPerLevelStep); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantDtos.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantDtos.cs new file mode 100644 index 0000000..b853314 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantDtos.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Skills; + +/// POST body for applying one skill XP grant (NEO-38). +public sealed class SkillProgressionGrantRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("skillId")] + public required string SkillId { get; init; } + + [JsonPropertyName("amount")] + public int Amount { get; init; } + + /// Validated against target skill's allowedXpSourceKinds (ordinal ignore-case). + [JsonPropertyName("sourceKind")] + public required string SourceKind { get; init; } +} + +/// POST responses for progression grants — success () or structured deny (). +public sealed class SkillProgressionGrantResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("granted")] + public bool Granted { get; init; } + + /// Set when is false; stable snake_case values (see README). + [JsonPropertyName("reasonCode")] + public string? ReasonCode { get; init; } + + [JsonPropertyName("progression")] + public required SkillProgressionSnapshotResponse Progression { get; init; } + + /// Present on success only; empty array when XP changed but level did not. + [JsonPropertyName("levelUps")] + public required IReadOnlyList LevelUps { get; init; } +} + +/// One skill crossed a level boundary on this grant (previousLevel < newLevel). +public sealed class SkillLevelUpJson +{ + [JsonPropertyName("skillId")] + public required string SkillId { get; init; } + + [JsonPropertyName("previousLevel")] + public int PreviousLevel { get; init; } + + [JsonPropertyName("newLevel")] + public int NewLevel { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionServiceCollectionExtensions.cs new file mode 100644 index 0000000..883061c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Skills; + +/// Registers skill XP persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-38). +public static class SkillProgressionServiceCollectionExtensions +{ + public static IServiceCollection AddSkillProgressionStore(this IServiceCollection services, IConfiguration configuration) + { + var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName); + if (!string.IsNullOrWhiteSpace(cs)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs index 7631190..390eb55 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs @@ -2,14 +2,22 @@ using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Skills; -/// Maps GET /game/players/{{id}}/skill-progression (NEO-37). +/// +/// Maps GET/POST /game/players/{{id}}/skill-progression — read snapshot (NEO-37) and XP grant apply (NEO-38). +/// public static class SkillProgressionSnapshotApi { + /// + public const string ReasonUnknownSkill = "unknown_skill"; + + public const string ReasonSourceKindNotAllowed = "source_kind_not_allowed"; + public const string ReasonInvalidAmount = "invalid_amount"; + public static WebApplication MapSkillProgressionSnapshotApi(this WebApplication app) { app.MapGet( "/game/players/{id}/skill-progression", - (string id, IPositionStateStore positions, ISkillDefinitionRegistry registry) => + (string id, IPositionStateStore positions, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore) => { if (!positions.TryGetPosition(id, out _)) { @@ -17,7 +25,85 @@ public static class SkillProgressionSnapshotApi } var trimmedId = id.Trim(); - return Results.Json(BuildSnapshot(trimmedId, registry)); + return Results.Json(BuildSnapshot(trimmedId, registry, xpStore)); + }); + + app.MapPost( + "/game/players/{id}/skill-progression", + (string id, SkillProgressionGrantRequest? body, IPositionStateStore positions, + ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore) => + { + var trimmedId = id.Trim(); + + static SkillProgressionGrantResponse Deny( + SkillProgressionSnapshotResponse snapshot, + string reason) => + new SkillProgressionGrantResponse + { + Granted = false, + ReasonCode = reason, + Progression = snapshot, + LevelUps = [], + }; + + if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + if (!positions.TryGetPosition(id, out _)) + { + return Results.NotFound(); + } + + var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore); + + if (body.Amount <= 0) + { + return Results.Json(Deny(beforeSnapshot, ReasonInvalidAmount)); + } + + var skillLookup = body.SkillId.Trim(); + if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def)) + { + return Results.Json(Deny(beforeSnapshot, ReasonUnknownSkill)); + } + + var source = body.SourceKind.Trim(); + var allowed = def.AllowedXpSourceKinds.Any(k => + string.Equals(k, source, StringComparison.OrdinalIgnoreCase)); + if (!allowed) + { + return Results.Json(Deny(beforeSnapshot, ReasonSourceKindNotAllowed)); + } + + if (!xpStore.TryApplyXpDelta(trimmedId, def.Id, body.Amount, out var prevXp, out var newXp)) + { + return Results.NotFound(); + } + + var prevLevel = SkillLevelCurvePlaceholder.LevelFromTotalXp(prevXp); + var nextLevel = SkillLevelCurvePlaceholder.LevelFromTotalXp(newXp); + var levelUps = new List(); + if (nextLevel > prevLevel) + { + levelUps.Add( + new SkillLevelUpJson + { + SkillId = def.Id, + PreviousLevel = prevLevel, + NewLevel = nextLevel, + }); + } + + var afterSnapshot = BuildSnapshot(trimmedId, registry, xpStore); + return Results.Json( + new SkillProgressionGrantResponse + { + Granted = true, + Progression = afterSnapshot, + LevelUps = levelUps, + }); }); return app; @@ -25,22 +111,25 @@ public static class SkillProgressionSnapshotApi internal static SkillProgressionSnapshotResponse BuildSnapshot( string playerId, - ISkillDefinitionRegistry registry) + ISkillDefinitionRegistry registry, + IPlayerSkillProgressionStore store) { + var xpBySkill = store.GetXpTotals(playerId); var defs = registry.GetDefinitionsInIdOrder(); var skills = new List(defs.Count); foreach (var d in defs) { + xpBySkill.TryGetValue(d.Id, out var xp); + var level = SkillLevelCurvePlaceholder.LevelFromTotalXp(xp); skills.Add( new SkillProgressionRowJson { Id = d.Id, - Xp = 0, - Level = 1, + Xp = xp, + Level = level, }); } - // Ordinal sort for stable serialization; consumers must index rows by skill id (NEO-37). skills.Sort(static (a, b) => string.CompareOrdinal(a.Id, b.Id)); return new SkillProgressionSnapshotResponse diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index b9241ec..5a5c4a9 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -7,6 +7,7 @@ using NeonSprawl.Server.Game.Targeting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration); +builder.Services.AddSkillProgressionStore(builder.Configuration); builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); diff --git a/server/README.md b/server/README.md index 2f19752..4931cce 100644 --- a/server/README.md +++ b/server/README.md @@ -47,12 +47,26 @@ curl -sS -i "http://localhost:5253/game/world/skill-definitions" ## Skill progression snapshot (NEO-37) -**`GET /game/players/{id}/skill-progression`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`skills`**) with one row per registered skill (**`id`**, **`xp`**, **`level`**). Treat **`skills`** as **unordered** and index rows by **`id`** — array order is not part of the contract (the server may sort for stable serialization). Before XP grants (**NEO-38**) each row boots with **`xp`** **0** and **`level`** **1**. 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 /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`. ```bash curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression" ``` +## Skill progression grant (NEO-38) + +**`POST /game/players/{id}/skill-progression`** accepts a versioned body (`schemaVersion` **1**, **`skillId`**, **`amount`** positive int, **`sourceKind`**) and validates the skill against **`ISkillDefinitionRegistry`**, then validates **`sourceKind`** against that skill’s **`allowedXpSourceKinds`** (case-insensitive). **404** when the player is unknown to position state; **400** when **`schemaVersion`** is missing or not **1**. Persisted XP totals use the same **[NEO-29-style](#position-persistence-neo-8) split** as hotbar: **`player_skill_progression`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V003__player_skill_progression.sql`](../db/migrations/V003__player_skill_progression.sql)), otherwise the in-memory fallback (dev bucket only, matching hotbar semantics). + +**Structured responses (HTTP 200):** **`granted`** (`true`/`false`), optional **`reasonCode`** on deny, plus **`progression`** mirroring the GET snapshot. Stable deny codes: **`unknown_skill`**, **`source_kind_not_allowed`**, **`invalid_amount`**. On **`granted: true`**, **`levelUps`** lists skills whose level increased on this request (`skillId`, `previousLevel`, `newLevel`). + +Plan: [NEO-38 implementation plan](../../docs/plans/NEO-38-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-38.md`](../../docs/manual-qa/NEO-38.md); Bruno: `bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru`. + +```bash +curl -sS -i -X POST "http://localhost:5253/game/players/dev-local-1/skill-progression" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"skillId":"salvage","amount":10,"sourceKind":"activity"}' +``` + ## Position persistence (NEO-8) When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request). @@ -72,7 +86,7 @@ Match credentials with [root `docker-compose.yml`](../../docker-compose.yml) for **Postgres integration tests** require `ConnectionStrings__NeonSprawl` (set automatically in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml)). The test project sets this via **`NeonSprawl.Server.Tests/postgres.runsettings`** (`RunSettingsFilePath` in the `.csproj`) so **`dotnet test`** and **Rider** pick it up without shell `export`. **`launchSettings.json`** in the same project is for IDE launch profiles and is **not** the xUnit “config file” in Rider settings. -**Contributors / PRs:** With **`postgres.runsettings`** checked in, **`dotnet test`** always injects that connection string, so the **three Postgres integration tests run** (they are **not** skipped). If nothing is listening on Postgres and **Docker** cannot start Compose (or you have no DB), those tests **fail**—that is expected, not a random flake. **Mitigations:** `docker compose up -d` from the repo root (tests may auto-start Compose locally when not in CI), or temporarily remove **`RunSettingsFilePath`** from `NeonSprawl.Server.Tests.csproj` while hacking. **GitHub Actions** provides Postgres in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml), so merges stay green when the workflow runs. +**Contributors / PRs:** With **`postgres.runsettings`** checked in, **`dotnet test`** always injects that connection string, so the **four Postgres integration tests run** (they are **not** skipped). If nothing is listening on Postgres and **Docker** cannot start Compose (or you have no DB), those tests **fail**—that is expected, not a random flake. **Mitigations:** `docker compose up -d` from the repo root (tests may auto-start Compose locally when not in CI), or temporarily remove **`RunSettingsFilePath`** from `NeonSprawl.Server.Tests.csproj` while hacking. **GitHub Actions** provides Postgres in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml), so merges stay green when the workflow runs. **Rider:** Under **Settings → … → Unit Testing → xUnit.net**, leave **“Use specific configuration file”** off unless you add a real **`xunit.runner.json`**. Do **not** point that field at `launchSettings.json`. diff --git a/server/db/migrations/V003__player_skill_progression.sql b/server/db/migrations/V003__player_skill_progression.sql new file mode 100644 index 0000000..4f04997 --- /dev/null +++ b/server/db/migrations/V003__player_skill_progression.sql @@ -0,0 +1,10 @@ +-- NEO-38: per-player non-combat skill XP totals (level derived server-side until data-driven curves). +CREATE TABLE IF NOT EXISTS player_skill_progression ( + player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE, + skill_id TEXT NOT NULL, + xp INTEGER NOT NULL CHECK (xp >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (player_id, skill_id) +); + +COMMENT ON TABLE player_skill_progression IS 'Persisted skill XP per player (NEO-38); level from SkillLevelCurvePlaceholder until NEO-39.';