using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Tests.Game.Mastery; public sealed class PerkUnlockEngineTests { private const string DevPlayer = "dev-local-1"; private const string UnknownPlayer = "no-such-player-neo-47"; 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) => ( factory.Services.GetRequiredService(), factory.Services.GetRequiredService(), factory.Services.GetRequiredService()); [Fact] public async Task TrySelectBranch_AtSalvageLevel2_ShouldRecordPick_WithoutUnlockingTier2Perks() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); _ = factory.CreateClient(); var (engine, xpStore, perkStore) = CreateEngine(factory); Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _)); // Act var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch); // Assert Assert.True(outcome.Success); Assert.Null(outcome.ReasonCode); Assert.Empty(outcome.Events); var snap = perkStore.GetSnapshot(DevPlayer); Assert.Equal(ScrapBranch, snap.BranchPicksBySkillId[SalvageSkill][1]); Assert.DoesNotContain(ScrapPerk, snap.UnlockedPerkIds); Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds); } [Fact] public async Task TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); _ = factory.CreateClient(); var (engine, xpStore, perkStore) = CreateEngine(factory); Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _)); // Act var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch); // Assert Assert.True(outcome.Success); 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 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_WhenUnknownTrack_ShouldDenyWithUnknownTrack() { // 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, "refine", tierIndex: 1, ScrapBranch); // Assert Assert.False(outcome.Success); Assert.Equal(PerkUnlockReasonCodes.UnknownTrack, outcome.ReasonCode); } [Fact] public async Task TrySelectBranch_WhenUnknownTier_ShouldDenyWithUnknownTier() { // 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: 99, ScrapBranch); // Assert Assert.False(outcome.Success); Assert.Equal(PerkUnlockReasonCodes.UnknownTier, outcome.ReasonCode); } [Fact] public async Task TrySelectBranch_WhenPlayerNotInStore_ShouldDenyWithPlayerNotFound() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); _ = factory.CreateClient(); var (engine, _, _) = CreateEngine(factory); // Act var outcome = engine.TrySelectBranch(UnknownPlayer, SalvageSkill, tierIndex: 1, ScrapBranch); // Assert Assert.False(outcome.Success); Assert.Equal(PerkUnlockReasonCodes.PlayerNotFound, 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); var snapAfterPick = perkStore.GetSnapshot(DevPlayer); Assert.Contains(ScrapPerk, snapAfterPick.UnlockedPerkIds); // Act var outcome = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4); // Assert Assert.Empty(outcome.Events); } [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(); var (engine, xpStore, perkStore) = CreateEngine(factory); 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); } [Fact] public async Task PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var (engine, xpStore, perkStore) = CreateEngine(factory); Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 440, out _, out _)); Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success); var move = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, Target = new PositionVector { X = 10, Y = 0.9, Z = -6 }, }; await client.PostAsJsonAsync($"/game/players/{DevPlayer}/move", move); // Act var interact = await client.PostAsJsonAsync( $"/game/players/{DevPlayer}/interact", new InteractionRequest { SchemaVersion = InteractionRequest.CurrentSchemaVersion, InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, }); // Assert Assert.True(interact.IsSuccessStatusCode); var snap = perkStore.GetSnapshot(DevPlayer); Assert.Contains(ScrapPerk, snap.UnlockedPerkIds); } }