neon-sprawl/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperat...

94 lines
4.0 KiB
C#

namespace NeonSprawl.Server.Game.Skills;
/// <summary>Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).</summary>
public static class SkillProgressionGrantOperations
{
/// <summary>
/// Applies one grant if validation passes and <see cref="IPlayerSkillProgressionStore.TryApplyXpDelta"/> succeeds.
/// Caller must ensure <paramref name="playerId"/> is already authorized for progression (e.g. known to position state).
/// </summary>
public static SkillProgressionGrantApplyOutcome TryApplyGrant(
string playerId,
string skillIdRaw,
int amount,
string sourceKindRaw,
ISkillDefinitionRegistry registry,
IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve)
{
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<SkillLevelUpJson>();
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,
});
}
var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve);
return new SkillProgressionGrantApplyOutcome(
SkillProgressionGrantApplyKind.Granted,
new SkillProgressionGrantResponse
{
Granted = true,
Progression = afterSnapshot,
LevelUps = levelUps,
});
}
}