202 lines
6.7 KiB
C#
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();
|
|
}
|
|
}
|