using NeonSprawl.Server.Game.Mastery; namespace NeonSprawl.Server.Game.Skills; /// Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook). 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, }); } }