250 lines
8.5 KiB
C#
250 lines
8.5 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.
|
|
/// Keep in sync with <see cref="MasteryCatalogLoader"/> tier shape — see <see cref="TierRequiresExplicitPick"/>.
|
|
/// </summary>
|
|
public sealed class PerkUnlockEngine(
|
|
IMasteryCatalogRegistry catalog,
|
|
IPlayerPerkStateStore perkStore,
|
|
IPlayerSkillProgressionStore xpStore,
|
|
ISkillLevelCurve levelCurve)
|
|
{
|
|
/// <summary>Selects a branch for a pick tier; unlocks tier perks and retroactive path-auto tiers at current level.</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);
|
|
}
|
|
|
|
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<PerkUnlockEvent>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>Unlocks path-auto tier perks after skill level-up (idempotent).</summary>
|
|
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 static 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 List<PerkUnlockEvent> ApplyPathAutoTiersAtOrBelowLevel(
|
|
string playerId,
|
|
MasteryTrackRow track,
|
|
int maxLevel,
|
|
PerkStateSnapshot snapshot)
|
|
{
|
|
var allEvents = new List<PerkUnlockEvent>();
|
|
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<string> perkIds,
|
|
PerkUnlockSource source,
|
|
PerkStateSnapshot snapshot,
|
|
out IReadOnlyList<PerkUnlockEvent> events)
|
|
{
|
|
events = [];
|
|
if (perkIds.Count == 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var newIds = new List<string>();
|
|
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))];
|
|
|
|
// --- Telemetry hook site (NEO-49): future E9.M1 catalog event `perk_unlock` ---
|
|
// TODO(E9.M1): catalog emit — once per entry in `events` (idempotent reevaluation produces none).
|
|
// Planned payload fields: playerId, perkId, skillId, tierIndex, branchId, source (BranchPick | LevelUp).
|
|
// No ingest or ILogger here (comments-only).
|
|
|
|
return true;
|
|
}
|
|
}
|