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 {
|
||||
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.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
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.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<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
services.AddSingleton<ISkillDefinitionRegistry, MissionRewardDeniedSkillRegistry>();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ISkillDefinitionRegistry>();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
|
||||
// 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<ISkillDefinitionRegistry>();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
|
||||
// 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<ISkillDefinitionRegistry>();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ISkillDefinitionRegistry>();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
|
||||
// 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<ISkillDefinitionRegistry>();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
|
||||
// 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<ISkillDefinitionRegistry>();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
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(NpgsqlDataSource) ||
|
||||
|
|
@ -58,6 +59,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <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,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
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