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