142 lines
5.6 KiB
C#
142 lines
5.6 KiB
C#
using NeonSprawl.Server.Game.Mastery;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
|
|
namespace NeonSprawl.Server.Game.Skills;
|
|
|
|
/// <summary>Shared NEO-38 skill XP grant apply (HTTP POST, gather engine via NEO-63 interact, and other callers).</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,
|
|
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<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,
|
|
});
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
/// <summary>Collects perk ids unlocked between two snapshots (this batch only).</summary>
|
|
public static void CollectPerksUnlockedSince(
|
|
PerkStateSnapshot before,
|
|
PerkStateSnapshot after,
|
|
ISet<string> sink)
|
|
{
|
|
foreach (var perkId in after.UnlockedPerkIds)
|
|
{
|
|
if (!before.UnlockedPerkIds.Contains(perkId))
|
|
{
|
|
sink.Add(perkId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Reverts skill XP grants and perks unlocked during this failed batch commit.</summary>
|
|
public static bool TryRevertAppliedSkillGrants(
|
|
string playerId,
|
|
IReadOnlyList<RewardSkillXpGrantApplied> appliedGrants,
|
|
IReadOnlyCollection<string> 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);
|
|
}
|
|
}
|