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