neon-sprawl/server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs

202 lines
6.7 KiB
C#

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();
}
}