using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Skills; /// Shared NEO-38 skill XP grant apply (HTTP POST, gather engine via NEO-63 interact, and other callers). public static class SkillProgressionGrantOperations { /// /// Applies one grant if validation passes and succeeds. /// Caller must ensure is already authorized for progression (e.g. known to position state). /// public static SkillProgressionGrantApplyOutcome TryApplyGrant( string playerId, string skillIdRaw, int amount, string sourceKindRaw, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine) { static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) => new() { Granted = false, ReasonCode = reason, Progression = snapshot, LevelUps = [], }; var beforeSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve); if (amount <= 0) { return new SkillProgressionGrantApplyOutcome( SkillProgressionGrantApplyKind.Denied, Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonInvalidAmount)); } var skillLookup = skillIdRaw.Trim(); if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def)) { return new SkillProgressionGrantApplyOutcome( SkillProgressionGrantApplyKind.Denied, Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonUnknownSkill)); } var source = sourceKindRaw.Trim(); var allowed = def.AllowedXpSourceKinds.Any(k => string.Equals(k, source, StringComparison.OrdinalIgnoreCase)); if (!allowed) { return new SkillProgressionGrantApplyOutcome( SkillProgressionGrantApplyKind.Denied, Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed)); } if (!xpStore.TryApplyXpDelta(playerId, def.Id, amount, out var prevXp, out var newXp)) { return new SkillProgressionGrantApplyOutcome(SkillProgressionGrantApplyKind.ProgressionStoreMissing, null); } // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `xp_grant` --- // No ingest here until the telemetry catalog exists. Planned payload fields: playerId, // skillId, amount, sourceKind; optional correlation/request ids when Slice 3 wires callers. var prevLevel = levelCurve.LevelFromTotalXp(prevXp); var nextLevel = levelCurve.LevelFromTotalXp(newXp); var levelUps = new List(); if (nextLevel > prevLevel) { // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `level_up` --- // Planned fields: playerId, skillId, previousLevel, newLevel, prevXp, newXp (totals before/after this grant). No logging (comments-only). // Time-to-first-level-up: needs first-seen / milestone persistence not present here; wire // when catalog + storage exist (likely derived from `level_up` or a dedicated milestone). levelUps.Add( new SkillLevelUpJson { SkillId = def.Id, PreviousLevel = prevLevel, NewLevel = nextLevel, }); } if (levelUps.Count > 0) { foreach (var levelUp in levelUps) { _ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, levelUp.SkillId, levelUp.NewLevel); } } var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve); return new SkillProgressionGrantApplyOutcome( SkillProgressionGrantApplyKind.Granted, new SkillProgressionGrantResponse { Granted = true, Progression = afterSnapshot, LevelUps = levelUps, }); } /// Collects perk ids unlocked between two snapshots (this batch only). public static void CollectPerksUnlockedSince( PerkStateSnapshot before, PerkStateSnapshot after, ISet sink) { foreach (var perkId in after.UnlockedPerkIds) { if (!before.UnlockedPerkIds.Contains(perkId)) { sink.Add(perkId); } } } /// Reverts skill XP grants and perks unlocked during this failed batch commit. public static bool TryRevertAppliedSkillGrants( string playerId, IReadOnlyList appliedGrants, IReadOnlyCollection perksUnlockedByBatch, IPlayerSkillProgressionStore xpStore, IPlayerPerkStateStore perkStore) { for (var i = appliedGrants.Count - 1; i >= 0; i--) { var grant = appliedGrants[i]; if (!xpStore.TryApplyXpDelta(playerId, grant.SkillId, -grant.Amount, out _, out _)) { return false; } } return perksUnlockedByBatch.Count == 0 || perkStore.TryRemoveUnlockedPerks(playerId, perksUnlockedByBatch); } }