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
parent
e297b3a023
commit
38774a5b39
|
|
@ -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.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,11 @@ So “mutually exclusive per tier” applies to **tier 1’s 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
Loading…
Reference in New Issue