NEO-38: skill XP POST grant, persistence, level-up response
parent
58b9016d40
commit
33b2e917e4
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Skills;
|
||||
|
||||
public sealed class SkillProgressionGrantApiTests
|
||||
{
|
||||
private static SkillProgressionGrantRequest Grant(
|
||||
string skillId,
|
||||
int schemaVersion,
|
||||
int amount = 50,
|
||||
string sourceKind = "activity") =>
|
||||
new SkillProgressionGrantRequest
|
||||
{
|
||||
SchemaVersion = schemaVersion,
|
||||
SkillId = skillId,
|
||||
Amount = amount,
|
||||
SourceKind = sourceKind,
|
||||
};
|
||||
|
||||
private static SkillProgressionGrantRequest ValidGrant(string skillId, int amount = 50, string sourceKind = "activity") =>
|
||||
Grant(skillId, SkillProgressionGrantRequest.CurrentSchemaVersion, amount, sourceKind);
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var request = Grant("salvage", SkillProgressionGrantRequest.CurrentSchemaVersion + 42, amount: 1);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/skill-progression", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/missing-player/skill-progression",
|
||||
ValidGrant("salvage"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldDenyUnknownSkill_WithReasonUnknownSkill_AndEchoProgression()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/skill-progression",
|
||||
ValidGrant("not-a-prototype-skill"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var envelope = await response.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.False(envelope!.Granted);
|
||||
Assert.Equal(SkillProgressionSnapshotApi.ReasonUnknownSkill, envelope.ReasonCode);
|
||||
Assert.NotNull(envelope.Progression);
|
||||
var salvage = envelope.Progression!.Skills!.Single(static s => s.Id == "salvage");
|
||||
Assert.Equal(0, salvage.Xp);
|
||||
Assert.Equal(1, salvage.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldDenyTrainerOnSalvage_WithReasonSourceKindNotAllowed()
|
||||
{
|
||||
// Arrange — prototype_skills: salvage allowedXpSourceKinds = activity, mission_reward only
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/skill-progression",
|
||||
ValidGrant("salvage", amount: 10, sourceKind: "trainer"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var envelope = await response.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.False(envelope!.Granted);
|
||||
Assert.Equal(SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed, envelope.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldDenyNonPositiveAmount_WithReasonInvalidAmount()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/skill-progression",
|
||||
ValidGrant("salvage", amount: 0));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var envelope = await response.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.False(envelope!.Granted);
|
||||
Assert.Equal(SkillProgressionSnapshotApi.ReasonInvalidAmount, envelope.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldApplyGrant_AndGetMatches_WhenTrainerAllowedForRefine()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/skill-progression",
|
||||
ValidGrant("refine", amount: 25, sourceKind: "trainer"));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||
var envelope = await post.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.True(envelope!.Granted);
|
||||
Assert.Empty(envelope.LevelUps);
|
||||
Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "refine" && s.Xp == 25 && s.Level == 1);
|
||||
|
||||
var get = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/skill-progression");
|
||||
Assert.NotNull(get);
|
||||
var refine = Assert.Single(get!.Skills!, static s => s.Id == "refine");
|
||||
Assert.Equal(25, refine.Xp);
|
||||
Assert.Equal(1, refine.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldReportLevelUps_WhenThresholdCrossed()
|
||||
{
|
||||
// Arrange — 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<SkillProgressionGrantResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.True(envelope!.Granted);
|
||||
var up = Assert.Single(envelope.LevelUps!);
|
||||
Assert.Equal("intrusion", up.SkillId);
|
||||
Assert.Equal(1, up.PreviousLevel);
|
||||
Assert.Equal(2, up.NewLevel);
|
||||
Assert.Single(envelope.Progression!.Skills!, static s => s.Id == "intrusion" && s.Xp == 100 && s.Level == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSkillProgression_ShouldAcceptSourceKind_WithIgnoreCase_OnAllowedList()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var post = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/skill-progression",
|
||||
ValidGrant("salvage", amount: 5, sourceKind: "ACTIVITY"));
|
||||
|
||||
// Assert
|
||||
var envelope = await post.Content.ReadFromJsonAsync<SkillProgressionGrantResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.True(envelope!.Granted);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/skill-progression");
|
||||
|
||||
// Assert
|
||||
Assert.True(postResponse.IsSuccessStatusCode);
|
||||
Assert.NotNull(snapshot);
|
||||
var salvage = Assert.Single(snapshot!.Skills!, static s => s.Id == "salvage");
|
||||
Assert.Equal(50, salvage.Xp);
|
||||
Assert.Equal(1, salvage.Level);
|
||||
}
|
||||
|
||||
private 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
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(NpgsqlDataSource) ||
|
||||
|
|
@ -47,6 +48,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<TimeProvider>(fakeTime);
|
||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\db\migrations\V001__player_position.sql" Link="db\migrations\V001__player_position.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="..\db\migrations\*.sql" Link="db\migrations\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Persisted totals of skill XP per player (NEO-38); level is derived, not stored.</summary>
|
||||
public interface IPlayerSkillProgressionStore
|
||||
{
|
||||
/// <summary>Keyed by catalog <c>SkillDef.id</c>; omitted skills imply XP 0.</summary>
|
||||
IReadOnlyDictionary<string, int> GetXpTotals(string playerId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="amount"/> to <paramref name="skillId"/> for <paramref name="playerId"/>.
|
||||
/// Returns <c>false</c> when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from <c>player_position</c>).
|
||||
/// </summary>
|
||||
bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Thread-safe in-memory progression; seeds the configured dev player with an empty XP map (NEO-38).</summary>
|
||||
public sealed class InMemoryPlayerSkillProgressionStore(IOptions<GamePositionOptions> options) : IPlayerSkillProgressionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, int>> byPlayer = CreateInitialMap(options.Value);
|
||||
|
||||
/// <remarks>Locks per normalized player id for coherent read/compare/write on inner dictionaries.</remarks>
|
||||
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static ConcurrentDictionary<string, ConcurrentDictionary<string, int>> CreateInitialMap(GamePositionOptions o)
|
||||
{
|
||||
var id = NormalizePlayerId(o.DevPlayerId);
|
||||
var map = new ConcurrentDictionary<string, ConcurrentDictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
|
||||
map[id] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||
{
|
||||
return ReadOnlyDic.Empty;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
return new Dictionary<string, int>(inner, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp)
|
||||
{
|
||||
previousXp = 0;
|
||||
newXp = 0;
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || skillId.Trim().Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sid = skillId.Trim();
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
inner.TryGetValue(sid, out var prev);
|
||||
previousXp = prev;
|
||||
newXp = prev + amount;
|
||||
if (newXp < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
inner[sid] = newXp;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static class ReadOnlyDic
|
||||
{
|
||||
internal static readonly IReadOnlyDictionary<string, int> Empty = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>PostgreSQL-backed skill XP totals keyed by normalized player id (NEO-38).</summary>
|
||||
public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerSkillProgressionStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
return new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
PostgresSkillProgressionBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT skill_id, xp
|
||||
FROM player_skill_progression
|
||||
WHERE player_id = @pid;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", norm);
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
map[reader.GetString(0)] = reader.GetInt32(1);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp)
|
||||
{
|
||||
previousXp = 0;
|
||||
newXp = 0;
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
var sid = skillId.Trim();
|
||||
if (norm.Length == 0 || sid.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresSkillProgressionBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, norm, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
using (var sel = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT xp
|
||||
FROM player_skill_progression
|
||||
WHERE player_id = @pid AND skill_id = @sid
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
sel.Parameters.AddWithValue("pid", norm);
|
||||
sel.Parameters.AddWithValue("sid", sid);
|
||||
var scalar = sel.ExecuteScalar();
|
||||
previousXp = scalar is null || Equals(scalar, DBNull.Value)
|
||||
? 0
|
||||
: scalar is int xi
|
||||
? xi
|
||||
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
newXp = previousXp + amount;
|
||||
if (newXp < 0)
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
using var upsert = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_skill_progression (player_id, skill_id, xp, updated_at)
|
||||
VALUES (@pid, @sid, @xp, now())
|
||||
ON CONFLICT (player_id, skill_id)
|
||||
DO UPDATE SET xp = EXCLUDED.xp, updated_at = now();
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
upsert.Parameters.AddWithValue("pid", norm);
|
||||
upsert.Parameters.AddWithValue("sid", sid);
|
||||
upsert.Parameters.AddWithValue("xp", newXp);
|
||||
upsert.ExecuteNonQuery();
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Applies NEO-38 skill progression table DDL once per process.</summary>
|
||||
public static class PostgresSkillProgressionBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V003__player_skill_progression.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SchemaGate)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"NEO-38 DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||
cmd.ExecuteNonQuery();
|
||||
System.Threading.Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>POST body for applying one skill XP grant (NEO-38).</summary>
|
||||
public sealed class SkillProgressionGrantRequest
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("skillId")]
|
||||
public required string SkillId { get; init; }
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public int Amount { get; init; }
|
||||
|
||||
/// <remarks>Validated against target skill's <c>allowedXpSourceKinds</c> (ordinal ignore-case).</remarks>
|
||||
[JsonPropertyName("sourceKind")]
|
||||
public required string SourceKind { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST responses for progression grants — success (<see cref="Granted"/>) or structured deny (<see cref="ReasonCode"/>).</summary>
|
||||
public sealed class SkillProgressionGrantResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("granted")]
|
||||
public bool Granted { get; init; }
|
||||
|
||||
/// <remarks>Set when <see cref="Granted"/> is <c>false</c>; stable snake_case values (see README).</remarks>
|
||||
[JsonPropertyName("reasonCode")]
|
||||
public string? ReasonCode { get; init; }
|
||||
|
||||
[JsonPropertyName("progression")]
|
||||
public required SkillProgressionSnapshotResponse Progression { get; init; }
|
||||
|
||||
/// <remarks>Present on success only; empty array when XP changed but level did not.</remarks>
|
||||
[JsonPropertyName("levelUps")]
|
||||
public required IReadOnlyList<SkillLevelUpJson> LevelUps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One skill crossed a level boundary on this grant (<c>previousLevel</c> < <c>newLevel</c>).</summary>
|
||||
public sealed class SkillLevelUpJson
|
||||
{
|
||||
[JsonPropertyName("skillId")]
|
||||
public required string SkillId { get; init; }
|
||||
|
||||
[JsonPropertyName("previousLevel")]
|
||||
public int PreviousLevel { get; init; }
|
||||
|
||||
[JsonPropertyName("newLevel")]
|
||||
public int NewLevel { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Registers skill XP persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-38).</summary>
|
||||
public static class SkillProgressionServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSkillProgressionStore(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, PostgresPlayerSkillProgressionStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,22 @@ using NeonSprawl.Server.Game.PositionState;
|
|||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Maps <c>GET /game/players/{{id}}/skill-progression</c> (NEO-37).</summary>
|
||||
/// <summary>
|
||||
/// Maps <c>GET</c>/<c>POST /game/players/{{id}}/skill-progression</c> — read snapshot (NEO-37) and XP grant apply (NEO-38).
|
||||
/// </summary>
|
||||
public static class SkillProgressionSnapshotApi
|
||||
{
|
||||
/// <seealso cref="SkillProgressionGrantResponse.ReasonCode" />
|
||||
public const string ReasonUnknownSkill = "unknown_skill";
|
||||
|
||||
public const string ReasonSourceKindNotAllowed = "source_kind_not_allowed";
|
||||
public const string ReasonInvalidAmount = "invalid_amount";
|
||||
|
||||
public static WebApplication MapSkillProgressionSnapshotApi(this WebApplication app)
|
||||
{
|
||||
app.MapGet(
|
||||
"/game/players/{id}/skill-progression",
|
||||
(string id, IPositionStateStore positions, ISkillDefinitionRegistry registry) =>
|
||||
(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<SkillLevelUpJson>();
|
||||
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<SkillProgressionRowJson>(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
|
||||
|
|
|
|||
|
|
@ -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<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
Loading…
Reference in New Issue