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

252 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 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))
.ToList();
// --- 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;
}
}