From 38774a5b3937a78f485f1fe538ea60dea81a15f7 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 18:38:41 -0400 Subject: [PATCH] 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). --- .../Post skill progression grant.bru | 1 + docs/plans/NEO-47-implementation-plan.md | 10 +- ...vityDeniedRegistryWebApplicationFactory.cs | 7 + .../PerkStatePersistenceIntegrationTests.cs | 85 ++++++++ .../Game/Mastery/PerkUnlockEngineTests.cs | 189 ++++++++++++++++ ...wardDeniedRegistryWebApplicationFactory.cs | 7 + .../Skills/MissionRewardSkillXpGrantTests.cs | 13 +- ...vityDeniedRegistryWebApplicationFactory.cs | 7 + .../Skills/RefineActivitySkillXpGrantTests.cs | 25 ++- .../InMemoryWebApplicationFactory.cs | 2 + .../Game/Interaction/InteractionApi.cs | 7 +- .../Game/Mastery/IPlayerPerkStateStore.cs | 13 ++ .../Mastery/InMemoryPlayerPerkStateStore.cs | 131 ++++++++++++ .../Game/Mastery/PerkBranchSelectOutcome.cs | 8 + .../Game/Mastery/PerkReevaluationOutcome.cs | 6 + .../PerkStateServiceCollectionExtensions.cs | 24 +++ .../Game/Mastery/PerkStateSnapshot.cs | 33 +++ .../Game/Mastery/PerkUnlockEngine.cs | 201 ++++++++++++++++++ .../Game/Mastery/PerkUnlockEvent.cs | 17 ++ .../Game/Mastery/PerkUnlockReasonCodes.cs | 13 ++ .../Mastery/PostgresPerkStateBootstrap.cs | 39 ++++ .../Mastery/PostgresPlayerPerkStateStore.cs | 198 +++++++++++++++++ .../Game/Skills/MissionRewardSkillXpGrant.cs | 8 +- .../Game/Skills/RefineActivitySkillXpGrant.cs | 8 +- .../Skills/SkillProgressionGrantOperations.cs | 13 +- .../Skills/SkillProgressionSnapshotApi.cs | 7 +- server/NeonSprawl.Server/Program.cs | 1 + .../db/migrations/V004__player_perk_state.sql | 19 ++ 28 files changed, 1072 insertions(+), 20 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Mastery/PerkStatePersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Mastery/PerkUnlockEngineTests.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkBranchSelectOutcome.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkReevaluationOutcome.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkStateServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkStateSnapshot.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkUnlockReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PostgresPerkStateBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs create mode 100644 server/db/migrations/V004__player_perk_state.sql diff --git a/bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru b/bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru index aa0450f..4ccbead 100644 --- a/bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru +++ b/bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru @@ -7,6 +7,7 @@ meta { docs { 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-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. } diff --git a/docs/plans/NEO-47-implementation-plan.md b/docs/plans/NEO-47-implementation-plan.md index 8c991e7..67f6928 100644 --- a/docs/plans/NEO-47-implementation-plan.md +++ b/docs/plans/NEO-47-implementation-plan.md @@ -52,11 +52,11 @@ So “mutually exclusive per tier” applies to **tier 1’s pick**; tier 2 is ## Acceptance criteria checklist -- [ ] `IPlayerPerkStateStore` with in-memory + Postgres path (migration) mirroring skill progression pattern. -- [ ] 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. -- [ ] Level-up path (after `SkillProgressionGrantOperations`) triggers re-evaluation for affected skill. -- [ ] Unit/integration tests (AAA): eligibility, branch select, deny paths. +- [x] `IPlayerPerkStateStore` with in-memory + Postgres path (migration) mirroring skill progression pattern. +- [x] Unlock engine reads catalog + skill level; emits internal `PerkUnlockEvent` when perks unlock. +- [x] Branch pick per tier is **mutually exclusive** where a tier requires a pick; stable deny `reasonCode` on invalid picks. +- [x] Level-up path (after `SkillProgressionGrantOperations`) triggers re-evaluation for affected skill. +- [x] Unit/integration tests (AAA): eligibility, branch select, deny paths. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index ab4dc01..6e2b43c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -25,7 +26,11 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + 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:MasteryDirectory", masteryDir); builder.ConfigureTestServices(services => { @@ -35,6 +40,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl if (d.ServiceType == typeof(IPositionStateStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) || + d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(ISkillDefinitionRegistry) || @@ -52,6 +58,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStatePersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStatePersistenceIntegrationTests.cs new file mode 100644 index 0000000..776aff6 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStatePersistenceIntegrationTests.cs @@ -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(); + var engine = scope.ServiceProvider.GetRequiredService(); + 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(); + 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>().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(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/PerkUnlockEngineTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkUnlockEngineTests.cs new file mode 100644 index 0000000..9b067de --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkUnlockEngineTests.cs @@ -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(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService()); + } + + [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(); + var xpStore = scope.ServiceProvider.GetRequiredService(); + var perkStore = scope.ServiceProvider.GetRequiredService(); + 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs index 3d6d35d..5055913 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -25,7 +26,11 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + 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:MasteryDirectory", masteryDir); builder.ConfigureTestServices(services => { @@ -35,6 +40,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic if (d.ServiceType == typeof(IPositionStateStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) || + d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(ISkillDefinitionRegistry) || @@ -52,6 +58,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardSkillXpGrantTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardSkillXpGrantTests.cs index 06c6dc5..2c5788d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardSkillXpGrantTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardSkillXpGrantTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Xunit; @@ -23,6 +24,7 @@ public sealed class MissionRewardSkillXpGrantTests var registry = scope.ServiceProvider.GetRequiredService(); var xpStore = scope.ServiceProvider.GetRequiredService(); var levelCurve = scope.ServiceProvider.GetRequiredService(); + var perkEngine = scope.ServiceProvider.GetRequiredService(); // Act MissionRewardSkillXpGrant.GrantFromMissionReward( @@ -31,7 +33,8 @@ public sealed class MissionRewardSkillXpGrantTests TestMissionRewardXp, registry, xpStore, - levelCurve); + levelCurve, + perkEngine); // Assert var totals = xpStore.GetXpTotals("dev-local-1"); @@ -55,6 +58,7 @@ public sealed class MissionRewardSkillXpGrantTests var registry = scope.ServiceProvider.GetRequiredService(); var xpStore = scope.ServiceProvider.GetRequiredService(); var levelCurve = scope.ServiceProvider.GetRequiredService(); + var perkEngine = scope.ServiceProvider.GetRequiredService(); // Act MissionRewardSkillXpGrant.GrantFromMissionReward( @@ -63,7 +67,8 @@ public sealed class MissionRewardSkillXpGrantTests TestMissionRewardXp, registry, xpStore, - levelCurve); + levelCurve, + perkEngine); var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); // Assert @@ -84,6 +89,7 @@ public sealed class MissionRewardSkillXpGrantTests var registry = scope.ServiceProvider.GetRequiredService(); var xpStore = scope.ServiceProvider.GetRequiredService(); var levelCurve = scope.ServiceProvider.GetRequiredService(); + var perkEngine = scope.ServiceProvider.GetRequiredService(); // Act MissionRewardSkillXpGrant.GrantFromMissionReward( @@ -92,7 +98,8 @@ public sealed class MissionRewardSkillXpGrantTests TestMissionRewardXp, registry, xpStore, - levelCurve); + levelCurve, + perkEngine); // Assert — deny returns before TryApplyXpDelta; salvage row must not appear in stored XP map. var totals = xpStore.GetXpTotals("dev-local-1"); diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs index 39a92df..bd18798 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -25,7 +26,11 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + 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:MasteryDirectory", masteryDir); builder.ConfigureTestServices(services => { @@ -35,6 +40,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli if (d.ServiceType == typeof(IPositionStateStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) || + d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(ISkillDefinitionRegistry) || @@ -52,6 +58,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs index 8a1cb65..a2e2e2e 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Xunit; @@ -21,9 +22,15 @@ public sealed class RefineActivitySkillXpGrantTests var registry = scope.ServiceProvider.GetRequiredService(); var xpStore = scope.ServiceProvider.GetRequiredService(); var levelCurve = scope.ServiceProvider.GetRequiredService(); + var perkEngine = scope.ServiceProvider.GetRequiredService(); // Act - RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve); + RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine( + "dev-local-1", + registry, + xpStore, + levelCurve, + perkEngine); // Assert var totals = xpStore.GetXpTotals("dev-local-1"); @@ -47,9 +54,15 @@ public sealed class RefineActivitySkillXpGrantTests var registry = scope.ServiceProvider.GetRequiredService(); var xpStore = scope.ServiceProvider.GetRequiredService(); var levelCurve = scope.ServiceProvider.GetRequiredService(); + var perkEngine = scope.ServiceProvider.GetRequiredService(); // 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"); // Assert @@ -70,9 +83,15 @@ public sealed class RefineActivitySkillXpGrantTests var registry = scope.ServiceProvider.GetRequiredService(); var xpStore = scope.ServiceProvider.GetRequiredService(); var levelCurve = scope.ServiceProvider.GetRequiredService(); + var perkEngine = scope.ServiceProvider.GetRequiredService(); // 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. var totals = xpStore.GetXpTotals("dev-local-1"); diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 4882a9d..a768863 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -41,6 +41,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); }); } diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs index 4ed189f..53d03bc 100644 --- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.World; @@ -15,7 +16,8 @@ public static class InteractionApi app.MapPost( "/game/players/{id}/interact", (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) { @@ -79,7 +81,8 @@ public static class InteractionApi GatherSkillXpConstants.ActivitySourceKind, registry, xpStore, - levelCurve); + levelCurve, + perkUnlockEngine); } return Results.Json( diff --git a/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs new file mode 100644 index 0000000..fe5509a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Persisted mastery branch picks and unlocked perks per player (NEO-47). +public interface IPlayerPerkStateStore +{ + PerkStateSnapshot GetSnapshot(string playerId); + + /// Fails when pick already exists for (skillId, tierIndex) or player cannot be written. + bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId); + + /// Adds perks not already unlocked; fails when player cannot be written. + bool TryAddUnlockedPerks(string playerId, IEnumerable perkIds); +} diff --git a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs new file mode 100644 index 0000000..5d29950 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Mastery; + +/// Thread-safe in-memory perk state; seeds the configured dev player (NEO-47). +public sealed class InMemoryPlayerPerkStateStore(IOptions options) : IPlayerPerkStateStore +{ + private readonly ConcurrentDictionary byPlayer = CreateInitialMap(options.Value); + + private readonly ConcurrentDictionary playerLocks = new(StringComparer.OrdinalIgnoreCase); + + private static ConcurrentDictionary CreateInitialMap(GamePositionOptions o) + { + var id = NormalizePlayerId(o.DevPlayerId); + var map = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + map[id] = new PlayerPerkState(); + return map; + } + + /// + 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(); + } + } + + /// + 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; + } + } + + /// + public bool TryAddUnlockedPerks(string playerId, IEnumerable 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> branchPicks = new(StringComparer.Ordinal); + + private readonly HashSet 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(); + branchPicks[skillId] = tiers; + } + + tiers[tierIndex] = branchId; + } + + public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId); + + public PerkStateSnapshot ToSnapshot() + { + var bySkill = new Dictionary>(StringComparer.Ordinal); + foreach (var (skillId, tiers) in branchPicks) + { + bySkill[skillId] = new Dictionary(tiers); + } + + return new PerkStateSnapshot(bySkill, new HashSet(unlockedPerks, StringComparer.Ordinal)); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkBranchSelectOutcome.cs b/server/NeonSprawl.Server/Game/Mastery/PerkBranchSelectOutcome.cs new file mode 100644 index 0000000..b338266 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkBranchSelectOutcome.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Result of (NEO-47). +public sealed record PerkBranchSelectOutcome( + bool Success, + string? ReasonCode, + IReadOnlyList Events, + PerkStateSnapshot Snapshot); diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkReevaluationOutcome.cs b/server/NeonSprawl.Server/Game/Mastery/PerkReevaluationOutcome.cs new file mode 100644 index 0000000..f0f112d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkReevaluationOutcome.cs @@ -0,0 +1,6 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Result of (NEO-47). +public sealed record PerkReevaluationOutcome( + IReadOnlyList Events, + PerkStateSnapshot Snapshot); diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkStateServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Mastery/PerkStateServiceCollectionExtensions.cs new file mode 100644 index 0000000..1d1aa44 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkStateServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Mastery; + +/// Registers perk state persistence and unlock engine (NEO-47). +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(); + } + else + { + services.AddSingleton(); + } + + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkStateSnapshot.cs b/server/NeonSprawl.Server/Game/Mastery/PerkStateSnapshot.cs new file mode 100644 index 0000000..ecaaf0d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkStateSnapshot.cs @@ -0,0 +1,33 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Read model for one player's perk state (NEO-47). +public sealed class PerkStateSnapshot +{ + public static PerkStateSnapshot Empty { get; } = new( + new Dictionary>(StringComparer.Ordinal), + new HashSet(StringComparer.Ordinal)); + + public PerkStateSnapshot( + IReadOnlyDictionary> branchPicksBySkillId, + IReadOnlySet unlockedPerkIds) + { + BranchPicksBySkillId = branchPicksBySkillId; + UnlockedPerkIds = unlockedPerkIds; + } + + /// skillIdtierIndexbranchId. + public IReadOnlyDictionary> BranchPicksBySkillId { get; } + + public IReadOnlySet 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!); + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs b/server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs new file mode 100644 index 0000000..b2579ab --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs @@ -0,0 +1,201 @@ +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Mastery; + +/// +/// Server-authoritative perk unlock evaluation from skill level + branch picks (NEO-47). +/// Tier rule: all branches have empty perkIds ⇒ explicit pick tier; otherwise path-auto when a prior pick tier exists. +/// +public sealed class PerkUnlockEngine( + IMasteryCatalogRegistry catalog, + IPlayerPerkStateStore perkStore, + IPlayerSkillProgressionStore xpStore, + ISkillLevelCurve levelCurve) +{ + /// Selects a branch for a pick tier; unlocks any perks on that branch for the tier. + 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); + } + + /// Unlocks path-auto tier perks after skill level-up (idempotent). + public PerkReevaluationOutcome ReevaluateAfterLevelUp(string playerId, string skillIdRaw, int newLevel) + { + var allEvents = new List(); + 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); + + /// True when every branch has no perks — player must pick a branch explicitly. + 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)); + + /// Branch id from the highest prior tier that required an explicit pick. + 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 UnlockPerks( + string playerId, + string skillId, + int tierIndex, + string branchId, + IReadOnlyList perkIds, + PerkUnlockSource source) + { + if (perkIds.Count == 0) + { + return []; + } + + var snapshot = perkStore.GetSnapshot(playerId); + var newIds = new List(); + 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(); + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs b/server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs new file mode 100644 index 0000000..63bde65 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs @@ -0,0 +1,17 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Internal event when a perk becomes unlocked (NEO-47; NEO-49 telemetry). +public enum PerkUnlockSource +{ + BranchPick, + LevelUp, +} + +/// Chosen branch when relevant; null for edge cases without a branch context. +public sealed record PerkUnlockEvent( + string PlayerId, + string PerkId, + string SkillId, + int TierIndex, + string? BranchId, + PerkUnlockSource Source); diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkUnlockReasonCodes.cs b/server/NeonSprawl.Server/Game/Mastery/PerkUnlockReasonCodes.cs new file mode 100644 index 0000000..21f4b3e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkUnlockReasonCodes.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Stable deny codes for perk branch selection (NEO-47; NEO-48 HTTP). +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"; +} diff --git a/server/NeonSprawl.Server/Game/Mastery/PostgresPerkStateBootstrap.cs b/server/NeonSprawl.Server/Game/Mastery/PostgresPerkStateBootstrap.cs new file mode 100644 index 0000000..1d8ebfd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PostgresPerkStateBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Applies NEO-47 perk state table DDL once per process. +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); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs new file mode 100644 index 0000000..8c3aea7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs @@ -0,0 +1,198 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// PostgreSQL-backed perk state keyed by normalized player id (NEO-47). +public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerPerkStateStore +{ + /// + 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>(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(); + branchPicks[skillId] = tiers; + } + + tiers[tierIndex] = branchId; + } + } + + var bySkill = new Dictionary>(StringComparer.Ordinal); + foreach (var (skillId, tiers) in branchPicks) + { + bySkill[skillId] = tiers; + } + + var unlocked = new HashSet(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); + } + + /// + 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; + } + + /// + public bool TryAddUnlockedPerks(string playerId, IEnumerable perkIds) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + return false; + } + + var toAdd = new List(); + 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(); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/MissionRewardSkillXpGrant.cs b/server/NeonSprawl.Server/Game/Skills/MissionRewardSkillXpGrant.cs index 31f024c..84f3af4 100644 --- a/server/NeonSprawl.Server/Game/Skills/MissionRewardSkillXpGrant.cs +++ b/server/NeonSprawl.Server/Game/Skills/MissionRewardSkillXpGrant.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Mastery; + namespace NeonSprawl.Server.Game.Skills; /// @@ -15,7 +17,8 @@ public static class MissionRewardSkillXpGrant int amount, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, - ISkillLevelCurve levelCurve) + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine) { _ = SkillProgressionGrantOperations.TryApplyGrant( playerId, @@ -24,6 +27,7 @@ public static class MissionRewardSkillXpGrant MissionRewardSkillXpConstants.MissionRewardSourceKind, registry, xpStore, - levelCurve); + levelCurve, + perkUnlockEngine); } } diff --git a/server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs b/server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs index 6f8aa22..6eefc78 100644 --- a/server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs +++ b/server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Mastery; + namespace NeonSprawl.Server.Game.Skills; /// @@ -13,7 +15,8 @@ public static class RefineActivitySkillXpGrant string playerId, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, - ISkillLevelCurve levelCurve) + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine) { _ = SkillProgressionGrantOperations.TryApplyGrant( playerId, @@ -22,6 +25,7 @@ public static class RefineActivitySkillXpGrant RefineSkillXpConstants.ActivitySourceKind, registry, xpStore, - levelCurve); + levelCurve, + perkUnlockEngine); } } diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs index e905526..f9f781a 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Mastery; + namespace NeonSprawl.Server.Game.Skills; /// Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook). @@ -14,7 +16,8 @@ public static class SkillProgressionGrantOperations string sourceKindRaw, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, - ISkillLevelCurve levelCurve) + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine) { static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) => 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); return new SkillProgressionGrantApplyOutcome( SkillProgressionGrantApplyKind.Granted, diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs index a9d1387..09c607b 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Skills; @@ -31,7 +32,8 @@ public static class SkillProgressionSnapshotApi app.MapPost( "/game/players/{id}/skill-progression", (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) { @@ -51,7 +53,8 @@ public static class SkillProgressionSnapshotApi body.SourceKind, registry, xpStore, - levelCurve); + levelCurve, + perkUnlockEngine); return outcome.Kind switch { diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index df10925..2ec626d 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddSkillProgressionStore(builder.Configuration); +builder.Services.AddPerkStateStore(builder.Configuration); builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); diff --git a/server/db/migrations/V004__player_perk_state.sql b/server/db/migrations/V004__player_perk_state.sql new file mode 100644 index 0000000..373eb70 --- /dev/null +++ b/server/db/migrations/V004__player_perk_state.sql @@ -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.';