NEO-47: perk unlock engine, PerkState persistence, and tests

Add V004 perk tables, in-memory/Postgres stores, PerkUnlockEngine (tier-1 branch
pick, path-auto tier-2 unlocks), level-up hook in skill XP grants, and AAA tests.
Bruno docs note internal perk reevaluation on level-up (HTTP in NEO-48).
pull/81/head
VinPropane 2026-05-17 18:38:41 -04:00
parent e297b3a023
commit 38774a5b39
28 changed files with 1072 additions and 20 deletions

View File

@ -7,6 +7,7 @@ meta {
docs { docs {
NEO-38: single skill XP grant + allowlist + level-up metadata; see server README. NEO-38: single skill XP grant + allowlist + level-up metadata; see server README.
NEO-40: same contract; server adds comment-only telemetry hook markers (no request/response change). NEO-40: same contract; server adds comment-only telemetry hook markers (no request/response change).
NEO-47: on level-up, server re-evaluates mastery perk unlocks internally (no JSON change until NEO-48 perk-state HTTP).
E2.M2 tracking row in docs/decomposition/modules/documentation_and_implementation_alignment.md lists NEO-40 landed with plan + manual QA links. E2.M2 tracking row in docs/decomposition/modules/documentation_and_implementation_alignment.md lists NEO-40 landed with plan + manual QA links.
} }

View File

@ -52,11 +52,11 @@ So “mutually exclusive per tier” applies to **tier 1s pick**; tier 2 is
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] `IPlayerPerkStateStore` with in-memory + Postgres path (migration) mirroring skill progression pattern. - [x] `IPlayerPerkStateStore` with in-memory + Postgres path (migration) mirroring skill progression pattern.
- [ ] Unlock engine reads catalog + skill level; emits internal `PerkUnlockEvent` when perks unlock. - [x] Unlock engine reads catalog + skill level; emits internal `PerkUnlockEvent` when perks unlock.
- [ ] Branch pick per tier is **mutually exclusive** where a tier requires a pick; stable deny `reasonCode` on invalid picks. - [x] Branch pick per tier is **mutually exclusive** where a tier requires a pick; stable deny `reasonCode` on invalid picks.
- [ ] Level-up path (after `SkillProgressionGrantOperations`) triggers re-evaluation for affected skill. - [x] Level-up path (after `SkillProgressionGrantOperations`) triggers re-evaluation for affected skill.
- [ ] Unit/integration tests (AAA): eligibility, branch select, deny paths. - [x] Unit/integration tests (AAA): eligibility, branch select, deny paths.
## Technical approach ## Technical approach

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using Npgsql; using Npgsql;
@ -25,7 +26,11 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
@ -35,6 +40,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
if (d.ServiceType == typeof(IPositionStateStore) || if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) || d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -52,6 +58,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>(); services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
}); });

View File

@ -0,0 +1,85 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
[Collection("Postgres integration")]
public sealed class PerkStatePersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
[RequirePostgresFact]
public async Task BranchPickAndUnlock_ShouldPersistAcrossNewFactory()
{
// Arrange
await ResetPerkTablesAsync();
const string playerId = "dev-local-1";
using (var scope = Factory.Services.CreateScope())
{
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var engine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
Assert.True(xpStore.TryApplyXpDelta(playerId, "salvage", 450, out _, out _));
Assert.True(engine.TrySelectBranch(playerId, "salvage", 1, "scrap_efficiency").Success);
_ = engine.ReevaluateAfterLevelUp(playerId, "salvage", newLevel: 4);
}
// Act
PerkStateSnapshot snapshot;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var scope = secondFactory.Services.CreateScope();
var perkStore = scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>();
snapshot = perkStore.GetSnapshot(playerId);
}
// Assert
Assert.Equal("scrap_efficiency", snapshot.BranchPicksBySkillId["salvage"][1]);
Assert.Contains("salvage_scrap_efficiency_1", snapshot.UnlockedPerkIds);
}
private async Task ResetPerkTablesAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var perkDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V004__player_perk_state.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(perkDdlPath))
{
throw new FileNotFoundException("Test DDL for perk persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var perkDdl = await File.ReadAllTextAsync(perkDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyPerk = new NpgsqlCommand(perkDdl, conn))
{
await applyPerk.ExecuteNonQueryAsync();
}
}
}

View File

@ -0,0 +1,189 @@
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public sealed class PerkUnlockEngineTests
{
private const string DevPlayer = "dev-local-1";
private const string SalvageSkill = "salvage";
private const string ScrapBranch = "scrap_efficiency";
private const string BulkBranch = "bulk_haul";
private const string ScrapPerk = "salvage_scrap_efficiency_1";
private const string BulkPerk = "salvage_bulk_haul_1";
private static (PerkUnlockEngine Engine, IPlayerSkillProgressionStore XpStore, IPlayerPerkStateStore PerkStore) CreateEngine(
InMemoryWebApplicationFactory factory)
{
var scope = factory.Services.CreateScope();
return (
scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>(),
scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(),
scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>());
}
[Fact]
public async Task TrySelectBranch_AtSalvageLevel2_ShouldRecordPick_WithoutUnlockingTier2Perks()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Empty(outcome.Events);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Equal(ScrapBranch, snap.BranchPicksBySkillId[SalvageSkill][1]);
Assert.DoesNotContain(ScrapPerk, snap.UnlockedPerkIds);
Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds);
}
[Fact]
public async Task TrySelectBranch_WhenLevelTooLow_ShouldDenyWithLevelTooLow()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, _, _) = CreateEngine(factory);
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_WhenBranchAlreadyChosen_ShouldDenyWithBranchAlreadyChosen()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, BulkBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.BranchAlreadyChosen, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_WhenBranchUnknown_ShouldDenyWithUnknownBranch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, "not_a_branch");
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.UnknownBranch, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_OnPathAutoTier2_ShouldDenyWithTierBranchNotSelectable()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 2, ScrapBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.TierBranchNotSelectable, outcome.ReasonCode);
}
[Fact]
public async Task ReevaluateAfterLevelUp_AtLevel4WithTier1Pick_ShouldUnlockScrapPerkOnly()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
// Act
var outcome = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
// Assert
var perk = Assert.Single(outcome.Events);
Assert.Equal(ScrapPerk, perk.PerkId);
Assert.Equal(PerkUnlockSource.LevelUp, perk.Source);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds);
}
[Fact]
public async Task ReevaluateAfterLevelUp_WhenCalledTwice_ShouldBeIdempotent()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
_ = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
// Act
var second = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
// Assert
Assert.Empty(second.Events);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Single(snap.UnlockedPerkIds);
}
[Fact]
public async Task PostSkillProgressionGrant_AfterTier1PickAndLevel4Xp_ShouldUnlockPerkViaHook()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var engine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var perkStore = scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>();
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
var grant = new SkillProgressionGrantRequest
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = SalvageSkill,
Amount = 350,
SourceKind = "activity",
};
// Act
var response = await client.PostAsJsonAsync($"/game/players/{DevPlayer}/skill-progression", grant);
// Assert
Assert.True(response.IsSuccessStatusCode);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
}
}

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using Npgsql; using Npgsql;
@ -25,7 +26,11 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
@ -35,6 +40,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
if (d.ServiceType == typeof(IPositionStateStore) || if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) || d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -52,6 +58,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, MissionRewardDeniedSkillRegistry>(); services.AddSingleton<ISkillDefinitionRegistry, MissionRewardDeniedSkillRegistry>();
}); });

View File

@ -2,6 +2,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using Xunit; using Xunit;
@ -23,6 +24,7 @@ public sealed class MissionRewardSkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>(); var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(); var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>(); var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act // Act
MissionRewardSkillXpGrant.GrantFromMissionReward( MissionRewardSkillXpGrant.GrantFromMissionReward(
@ -31,7 +33,8 @@ public sealed class MissionRewardSkillXpGrantTests
TestMissionRewardXp, TestMissionRewardXp,
registry, registry,
xpStore, xpStore,
levelCurve); levelCurve,
perkEngine);
// Assert // Assert
var totals = xpStore.GetXpTotals("dev-local-1"); var totals = xpStore.GetXpTotals("dev-local-1");
@ -55,6 +58,7 @@ public sealed class MissionRewardSkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>(); var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(); var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>(); var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act // Act
MissionRewardSkillXpGrant.GrantFromMissionReward( MissionRewardSkillXpGrant.GrantFromMissionReward(
@ -63,7 +67,8 @@ public sealed class MissionRewardSkillXpGrantTests
TestMissionRewardXp, TestMissionRewardXp,
registry, registry,
xpStore, xpStore,
levelCurve); levelCurve,
perkEngine);
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
// Assert // Assert
@ -84,6 +89,7 @@ public sealed class MissionRewardSkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>(); var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(); var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>(); var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act // Act
MissionRewardSkillXpGrant.GrantFromMissionReward( MissionRewardSkillXpGrant.GrantFromMissionReward(
@ -92,7 +98,8 @@ public sealed class MissionRewardSkillXpGrantTests
TestMissionRewardXp, TestMissionRewardXp,
registry, registry,
xpStore, xpStore,
levelCurve); levelCurve,
perkEngine);
// Assert — deny returns before TryApplyXpDelta; salvage row must not appear in stored XP map. // Assert — deny returns before TryApplyXpDelta; salvage row must not appear in stored XP map.
var totals = xpStore.GetXpTotals("dev-local-1"); var totals = xpStore.GetXpTotals("dev-local-1");

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using Npgsql; using Npgsql;
@ -25,7 +26,11 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
@ -35,6 +40,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
if (d.ServiceType == typeof(IPositionStateStore) || if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) || d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -52,6 +58,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>(); services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>();
}); });

View File

@ -2,6 +2,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using Xunit; using Xunit;
@ -21,9 +22,15 @@ public sealed class RefineActivitySkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>(); var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(); var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>(); var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act // Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve); RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
"dev-local-1",
registry,
xpStore,
levelCurve,
perkEngine);
// Assert // Assert
var totals = xpStore.GetXpTotals("dev-local-1"); var totals = xpStore.GetXpTotals("dev-local-1");
@ -47,9 +54,15 @@ public sealed class RefineActivitySkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>(); var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(); var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>(); var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act // Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve); RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
"dev-local-1",
registry,
xpStore,
levelCurve,
perkEngine);
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
// Assert // Assert
@ -70,9 +83,15 @@ public sealed class RefineActivitySkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>(); var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(); var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>(); var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act // Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve); RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
"dev-local-1",
registry,
xpStore,
levelCurve,
perkEngine);
// Assert — deny returns before TryApplyXpDelta; refine row must not appear in stored XP map. // Assert — deny returns before TryApplyXpDelta; refine row must not appear in stored XP map.
var totals = xpStore.GetXpTotals("dev-local-1"); var totals = xpStore.GetXpTotals("dev-local-1");

View File

@ -41,6 +41,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
if (d.ServiceType == typeof(IPositionStateStore) || if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(NpgsqlDataSource) || d.ServiceType == typeof(NpgsqlDataSource) ||
@ -58,6 +59,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
}); });
} }

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World; using NeonSprawl.Server.Game.World;
@ -15,7 +16,8 @@ public static class InteractionApi
app.MapPost( app.MapPost(
"/game/players/{id}/interact", "/game/players/{id}/interact",
(string id, InteractionRequest? body, IPositionStateStore store, (string id, InteractionRequest? body, IPositionStateStore store,
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) => ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine) =>
{ {
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{ {
@ -79,7 +81,8 @@ public static class InteractionApi
GatherSkillXpConstants.ActivitySourceKind, GatherSkillXpConstants.ActivitySourceKind,
registry, registry,
xpStore, xpStore,
levelCurve); levelCurve,
perkUnlockEngine);
} }
return Results.Json( return Results.Json(

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Persisted mastery branch picks and unlocked perks per player (NEO-47).</summary>
public interface IPlayerPerkStateStore
{
PerkStateSnapshot GetSnapshot(string playerId);
/// <summary>Fails when pick already exists for <c>(skillId, tierIndex)</c> or player cannot be written.</summary>
bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId);
/// <summary>Adds perks not already unlocked; fails when player cannot be written.</summary>
bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds);
}

View File

@ -0,0 +1,131 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Thread-safe in-memory perk state; seeds the configured dev player (NEO-47).</summary>
public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> options) : IPlayerPerkStateStore
{
private readonly ConcurrentDictionary<string, PlayerPerkState> byPlayer = CreateInitialMap(options.Value);
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentDictionary<string, PlayerPerkState> CreateInitialMap(GamePositionOptions o)
{
var id = NormalizePlayerId(o.DevPlayerId);
var map = new ConcurrentDictionary<string, PlayerPerkState>(StringComparer.OrdinalIgnoreCase);
map[id] = new PlayerPerkState();
return map;
}
/// <inheritdoc />
public PerkStateSnapshot GetSnapshot(string playerId)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return PerkStateSnapshot.Empty;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
return inner.ToSnapshot();
}
}
/// <inheritdoc />
public bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)
{
var key = NormalizePlayerId(playerId);
var sid = skillId.Trim();
var bid = branchId.Trim();
if (key.Length == 0 || sid.Length == 0 || bid.Length == 0 || tierIndex < 1
|| !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
if (inner.HasBranchPick(sid, tierIndex))
{
return false;
}
inner.SetBranchPick(sid, tierIndex, bid);
return true;
}
}
/// <inheritdoc />
public bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
foreach (var raw in perkIds)
{
var pid = raw.Trim();
if (pid.Length == 0)
{
continue;
}
inner.AddUnlockedPerk(pid);
}
return true;
}
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
private sealed class PlayerPerkState
{
private readonly Dictionary<string, Dictionary<int, string>> branchPicks = new(StringComparer.Ordinal);
private readonly HashSet<string> unlockedPerks = new(StringComparer.Ordinal);
public bool HasBranchPick(string skillId, int tierIndex) =>
branchPicks.TryGetValue(skillId, out var tiers) && tiers.ContainsKey(tierIndex);
public void SetBranchPick(string skillId, int tierIndex, string branchId)
{
if (!branchPicks.TryGetValue(skillId, out var tiers))
{
tiers = new Dictionary<int, string>();
branchPicks[skillId] = tiers;
}
tiers[tierIndex] = branchId;
}
public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId);
public PerkStateSnapshot ToSnapshot()
{
var bySkill = new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal);
foreach (var (skillId, tiers) in branchPicks)
{
bySkill[skillId] = new Dictionary<int, string>(tiers);
}
return new PerkStateSnapshot(bySkill, new HashSet<string>(unlockedPerks, StringComparer.Ordinal));
}
}
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Result of <see cref="PerkUnlockEngine.TrySelectBranch"/> (NEO-47).</summary>
public sealed record PerkBranchSelectOutcome(
bool Success,
string? ReasonCode,
IReadOnlyList<PerkUnlockEvent> Events,
PerkStateSnapshot Snapshot);

View File

@ -0,0 +1,6 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Result of <see cref="PerkUnlockEngine.ReevaluateAfterLevelUp"/> (NEO-47).</summary>
public sealed record PerkReevaluationOutcome(
IReadOnlyList<PerkUnlockEvent> Events,
PerkStateSnapshot Snapshot);

View File

@ -0,0 +1,24 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Registers perk state persistence and unlock engine (NEO-47).</summary>
public static class PerkStateServiceCollectionExtensions
{
public static IServiceCollection AddPerkStateStore(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IPlayerPerkStateStore, PostgresPlayerPerkStateStore>();
}
else
{
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
}
services.AddSingleton<PerkUnlockEngine>();
return services;
}
}

View File

@ -0,0 +1,33 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Read model for one player's perk state (NEO-47).</summary>
public sealed class PerkStateSnapshot
{
public static PerkStateSnapshot Empty { get; } = new(
new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal));
public PerkStateSnapshot(
IReadOnlyDictionary<string, IReadOnlyDictionary<int, string>> branchPicksBySkillId,
IReadOnlySet<string> unlockedPerkIds)
{
BranchPicksBySkillId = branchPicksBySkillId;
UnlockedPerkIds = unlockedPerkIds;
}
/// <summary><c>skillId</c> → <c>tierIndex</c> → <c>branchId</c>.</summary>
public IReadOnlyDictionary<string, IReadOnlyDictionary<int, string>> BranchPicksBySkillId { get; }
public IReadOnlySet<string> UnlockedPerkIds { get; }
public bool TryGetBranchPick(string skillId, int tierIndex, out string branchId)
{
branchId = string.Empty;
if (!BranchPicksBySkillId.TryGetValue(skillId, out var tiers))
{
return false;
}
return tiers.TryGetValue(tierIndex, out branchId!);
}
}

View File

@ -0,0 +1,201 @@
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>
/// Server-authoritative perk unlock evaluation from skill level + branch picks (NEO-47).
/// Tier rule: all branches have empty <c>perkIds</c> ⇒ explicit pick tier; otherwise path-auto when a prior pick tier exists.
/// </summary>
public sealed class PerkUnlockEngine(
IMasteryCatalogRegistry catalog,
IPlayerPerkStateStore perkStore,
IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve)
{
/// <summary>Selects a branch for a pick tier; unlocks any perks on that branch for the tier.</summary>
public PerkBranchSelectOutcome TrySelectBranch(
string playerId,
string skillIdRaw,
int tierIndex,
string branchIdRaw)
{
var snapshot = perkStore.GetSnapshot(playerId);
var skillId = skillIdRaw.Trim();
var branchId = branchIdRaw.Trim();
if (skillId.Length == 0 || branchId.Length == 0)
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTrack);
}
if (!catalog.TryGetTrack(skillId, out var track))
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTrack);
}
var tier = FindTier(track, tierIndex);
if (tier is null)
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTier);
}
if (TierIsPathAuto(tier))
{
return Deny(snapshot, PerkUnlockReasonCodes.TierBranchNotSelectable);
}
if (!TierContainsBranch(tier, branchId))
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownBranch);
}
var level = GetSkillLevel(playerId, track.SkillId);
if (level < tier.RequiredLevel)
{
return Deny(snapshot, PerkUnlockReasonCodes.LevelTooLow);
}
if (snapshot.TryGetBranchPick(track.SkillId, tier.TierIndex, out _))
{
return Deny(snapshot, PerkUnlockReasonCodes.BranchAlreadyChosen);
}
if (!perkStore.TrySetBranchPick(playerId, track.SkillId, tier.TierIndex, branchId))
{
return Deny(snapshot, PerkUnlockReasonCodes.PlayerNotFound);
}
var branch = tier.Branches.First(b => string.Equals(b.BranchId, branchId, StringComparison.Ordinal));
var events = UnlockPerks(playerId, track.SkillId, tier.TierIndex, branch.BranchId, branch.PerkIds, PerkUnlockSource.BranchPick);
snapshot = perkStore.GetSnapshot(playerId);
return new PerkBranchSelectOutcome(true, null, events, snapshot);
}
/// <summary>Unlocks path-auto tier perks after skill level-up (idempotent).</summary>
public PerkReevaluationOutcome ReevaluateAfterLevelUp(string playerId, string skillIdRaw, int newLevel)
{
var allEvents = new List<PerkUnlockEvent>();
var skillId = skillIdRaw.Trim();
if (skillId.Length == 0 || !catalog.TryGetTrack(skillId, out var track))
{
return new PerkReevaluationOutcome(allEvents, perkStore.GetSnapshot(playerId));
}
foreach (var tier in track.Tiers.OrderBy(static t => t.TierIndex))
{
if (tier.RequiredLevel > newLevel || !TierIsPathAuto(tier))
{
continue;
}
var pathBranchId = GetPathBranchId(perkStore.GetSnapshot(playerId), track, tier);
if (pathBranchId is null)
{
continue;
}
var branch = tier.Branches.FirstOrDefault(b =>
string.Equals(b.BranchId, pathBranchId, StringComparison.Ordinal));
if (branch is null)
{
continue;
}
var batch = UnlockPerks(
playerId,
track.SkillId,
tier.TierIndex,
branch.BranchId,
branch.PerkIds,
PerkUnlockSource.LevelUp);
allEvents.AddRange(batch);
}
return new PerkReevaluationOutcome(allEvents, perkStore.GetSnapshot(playerId));
}
private PerkBranchSelectOutcome Deny(PerkStateSnapshot snapshot, string reason) =>
new(false, reason, [], snapshot);
private int GetSkillLevel(string playerId, string skillId)
{
var xpBySkill = xpStore.GetXpTotals(playerId);
xpBySkill.TryGetValue(skillId, out var xp);
return levelCurve.LevelFromTotalXp(xp);
}
private static MasteryTierRow? FindTier(MasteryTrackRow track, int tierIndex) =>
track.Tiers.FirstOrDefault(t => t.TierIndex == tierIndex);
/// <summary>True when every branch has no perks — player must pick a branch explicitly.</summary>
internal static bool TierRequiresExplicitPick(MasteryTierRow tier) =>
tier.Branches.All(static b => b.PerkIds.Count == 0);
internal static bool TierIsPathAuto(MasteryTierRow tier) => !TierRequiresExplicitPick(tier);
private static bool TierContainsBranch(MasteryTierRow tier, string branchId) =>
tier.Branches.Any(b => string.Equals(b.BranchId, branchId, StringComparison.Ordinal));
/// <summary>Branch id from the highest prior tier that required an explicit pick.</summary>
internal static string? GetPathBranchId(PerkStateSnapshot snapshot, MasteryTrackRow track, MasteryTierRow tier)
{
MasteryTierRow? anchor = null;
foreach (var prior in track.Tiers.OrderByDescending(static t => t.TierIndex))
{
if (prior.TierIndex >= tier.TierIndex)
{
continue;
}
if (TierRequiresExplicitPick(prior))
{
anchor = prior;
break;
}
}
if (anchor is null)
{
return null;
}
return snapshot.TryGetBranchPick(track.SkillId, anchor.TierIndex, out var bid) ? bid : null;
}
private IReadOnlyList<PerkUnlockEvent> UnlockPerks(
string playerId,
string skillId,
int tierIndex,
string branchId,
IReadOnlyList<string> perkIds,
PerkUnlockSource source)
{
if (perkIds.Count == 0)
{
return [];
}
var snapshot = perkStore.GetSnapshot(playerId);
var newIds = new List<string>();
foreach (var perkId in perkIds)
{
if (!snapshot.UnlockedPerkIds.Contains(perkId))
{
newIds.Add(perkId);
}
}
if (newIds.Count == 0)
{
return [];
}
if (!perkStore.TryAddUnlockedPerks(playerId, newIds))
{
return [];
}
return newIds
.Select(pid => new PerkUnlockEvent(playerId, pid, skillId, tierIndex, branchId, source))
.ToList();
}
}

View File

@ -0,0 +1,17 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Internal event when a perk becomes unlocked (NEO-47; NEO-49 telemetry).</summary>
public enum PerkUnlockSource
{
BranchPick,
LevelUp,
}
/// <param name="BranchId">Chosen branch when relevant; null for edge cases without a branch context.</param>
public sealed record PerkUnlockEvent(
string PlayerId,
string PerkId,
string SkillId,
int TierIndex,
string? BranchId,
PerkUnlockSource Source);

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Stable deny codes for perk branch selection (NEO-47; NEO-48 HTTP).</summary>
public static class PerkUnlockReasonCodes
{
public const string UnknownTrack = "unknown_track";
public const string UnknownTier = "unknown_tier";
public const string UnknownBranch = "unknown_branch";
public const string LevelTooLow = "level_too_low";
public const string BranchAlreadyChosen = "branch_already_chosen";
public const string TierBranchNotSelectable = "tier_branch_not_selectable";
public const string PlayerNotFound = "player_not_found";
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Applies NEO-47 perk state table DDL once per process.</summary>
public static class PostgresPerkStateBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V004__player_perk_state.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-47 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();
Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,198 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>PostgreSQL-backed perk state keyed by normalized player id (NEO-47).</summary>
public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerPerkStateStore
{
/// <inheritdoc />
public PerkStateSnapshot GetSnapshot(string playerId)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return PerkStateSnapshot.Empty;
}
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
var branchPicks = new Dictionary<string, Dictionary<int, string>>(StringComparer.Ordinal);
using (var pickCmd = new Npgsql.NpgsqlCommand(
"""
SELECT skill_id, tier_index, branch_id
FROM player_mastery_branch_pick
WHERE player_id = @pid;
""",
conn))
{
pickCmd.Parameters.AddWithValue("pid", norm);
using var reader = pickCmd.ExecuteReader();
while (reader.Read())
{
var skillId = reader.GetString(0);
var tierIndex = reader.GetInt32(1);
var branchId = reader.GetString(2);
if (!branchPicks.TryGetValue(skillId, out var tiers))
{
tiers = new Dictionary<int, string>();
branchPicks[skillId] = tiers;
}
tiers[tierIndex] = branchId;
}
}
var bySkill = new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal);
foreach (var (skillId, tiers) in branchPicks)
{
bySkill[skillId] = tiers;
}
var unlocked = new HashSet<string>(StringComparer.Ordinal);
using (var perkCmd = new Npgsql.NpgsqlCommand(
"""
SELECT perk_id
FROM player_unlocked_perk
WHERE player_id = @pid;
""",
conn))
{
perkCmd.Parameters.AddWithValue("pid", norm);
using var reader = perkCmd.ExecuteReader();
while (reader.Read())
{
unlocked.Add(reader.GetString(0));
}
}
return new PerkStateSnapshot(bySkill, unlocked);
}
/// <inheritdoc />
public bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)
{
var norm = NormalizePlayerId(playerId);
var sid = skillId.Trim();
var bid = branchId.Trim();
if (norm.Length == 0 || sid.Length == 0 || bid.Length == 0 || tierIndex < 1)
{
return false;
}
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
return false;
}
using (var exists = new Npgsql.NpgsqlCommand(
"""
SELECT 1
FROM player_mastery_branch_pick
WHERE player_id = @pid AND skill_id = @sid AND tier_index = @tier
LIMIT 1;
""",
conn,
tx))
{
exists.Parameters.AddWithValue("pid", norm);
exists.Parameters.AddWithValue("sid", sid);
exists.Parameters.AddWithValue("tier", tierIndex);
if (exists.ExecuteScalar() is not null)
{
tx.Rollback();
return false;
}
}
using var insert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id, updated_at)
VALUES (@pid, @sid, @tier, @bid, now());
""",
conn,
tx);
insert.Parameters.AddWithValue("pid", norm);
insert.Parameters.AddWithValue("sid", sid);
insert.Parameters.AddWithValue("tier", tierIndex);
insert.Parameters.AddWithValue("bid", bid);
insert.ExecuteNonQuery();
tx.Commit();
return true;
}
/// <inheritdoc />
public bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return false;
}
var toAdd = new List<string>();
foreach (var raw in perkIds)
{
var pid = raw.Trim();
if (pid.Length > 0)
{
toAdd.Add(pid);
}
}
if (toAdd.Count == 0)
{
return true;
}
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
return false;
}
foreach (var perkId in toAdd)
{
using var upsert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_unlocked_perk (player_id, perk_id, unlocked_at)
VALUES (@pid, @perk, now())
ON CONFLICT (player_id, perk_id) DO NOTHING;
""",
conn,
tx);
upsert.Parameters.AddWithValue("pid", norm);
upsert.Parameters.AddWithValue("perk", perkId);
upsert.ExecuteNonQuery();
}
tx.Commit();
return true;
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
}

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Mastery;
namespace NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Skills;
/// <summary> /// <summary>
@ -15,7 +17,8 @@ public static class MissionRewardSkillXpGrant
int amount, int amount,
ISkillDefinitionRegistry registry, ISkillDefinitionRegistry registry,
IPlayerSkillProgressionStore xpStore, IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve) ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine)
{ {
_ = SkillProgressionGrantOperations.TryApplyGrant( _ = SkillProgressionGrantOperations.TryApplyGrant(
playerId, playerId,
@ -24,6 +27,7 @@ public static class MissionRewardSkillXpGrant
MissionRewardSkillXpConstants.MissionRewardSourceKind, MissionRewardSkillXpConstants.MissionRewardSourceKind,
registry, registry,
xpStore, xpStore,
levelCurve); levelCurve,
perkUnlockEngine);
} }
} }

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Mastery;
namespace NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Skills;
/// <summary> /// <summary>
@ -13,7 +15,8 @@ public static class RefineActivitySkillXpGrant
string playerId, string playerId,
ISkillDefinitionRegistry registry, ISkillDefinitionRegistry registry,
IPlayerSkillProgressionStore xpStore, IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve) ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine)
{ {
_ = SkillProgressionGrantOperations.TryApplyGrant( _ = SkillProgressionGrantOperations.TryApplyGrant(
playerId, playerId,
@ -22,6 +25,7 @@ public static class RefineActivitySkillXpGrant
RefineSkillXpConstants.ActivitySourceKind, RefineSkillXpConstants.ActivitySourceKind,
registry, registry,
xpStore, xpStore,
levelCurve); levelCurve,
perkUnlockEngine);
} }
} }

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Mastery;
namespace NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Skills;
/// <summary>Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).</summary> /// <summary>Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).</summary>
@ -14,7 +16,8 @@ public static class SkillProgressionGrantOperations
string sourceKindRaw, string sourceKindRaw,
ISkillDefinitionRegistry registry, ISkillDefinitionRegistry registry,
IPlayerSkillProgressionStore xpStore, IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve) ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine)
{ {
static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) => static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) =>
new() new()
@ -80,6 +83,14 @@ public static class SkillProgressionGrantOperations
}); });
} }
if (levelUps.Count > 0)
{
foreach (var levelUp in levelUps)
{
_ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, levelUp.SkillId, levelUp.NewLevel);
}
}
var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve); var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve);
return new SkillProgressionGrantApplyOutcome( return new SkillProgressionGrantApplyOutcome(
SkillProgressionGrantApplyKind.Granted, SkillProgressionGrantApplyKind.Granted,

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Skills;
@ -31,7 +32,8 @@ public static class SkillProgressionSnapshotApi
app.MapPost( app.MapPost(
"/game/players/{id}/skill-progression", "/game/players/{id}/skill-progression",
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions, (string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) => ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine) =>
{ {
if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion)
{ {
@ -51,7 +53,8 @@ public static class SkillProgressionSnapshotApi
body.SourceKind, body.SourceKind,
registry, registry,
xpStore, xpStore,
levelCurve); levelCurve,
perkUnlockEngine);
return outcome.Kind switch return outcome.Kind switch
{ {

View File

@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddPositionStateStore(builder.Configuration);
builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration);
builder.Services.AddSkillProgressionStore(builder.Configuration); builder.Services.AddSkillProgressionStore(builder.Configuration);
builder.Services.AddPerkStateStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore(); builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>(); builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration); builder.Services.AddSkillDefinitionCatalog(builder.Configuration);

View File

@ -0,0 +1,19 @@
-- NEO-47: per-player mastery branch picks and unlocked perk ids.
CREATE TABLE IF NOT EXISTS player_mastery_branch_pick (
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
skill_id TEXT NOT NULL,
tier_index INTEGER NOT NULL CHECK (tier_index >= 1),
branch_id TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (player_id, skill_id, tier_index)
);
CREATE TABLE IF NOT EXISTS player_unlocked_perk (
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
perk_id TEXT NOT NULL,
unlocked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (player_id, perk_id)
);
COMMENT ON TABLE player_mastery_branch_pick IS 'Mutually exclusive branch picks per mastery tier (NEO-47); tier 1 pick drives path-auto tier unlocks.';
COMMENT ON TABLE player_unlocked_perk IS 'Unlocked perk ids for a player (NEO-47); additive, idempotent per perk_id.';