using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Skills; /// /// Maps GET/POST /game/players/{{id}}/skill-progression — read snapshot (NEO-37) and XP grant apply (NEO-38). /// public static class SkillProgressionSnapshotApi { /// public const string ReasonUnknownSkill = "unknown_skill"; public const string ReasonSourceKindNotAllowed = "source_kind_not_allowed"; public const string ReasonInvalidAmount = "invalid_amount"; public static WebApplication MapSkillProgressionSnapshotApi(this WebApplication app) { app.MapGet( "/game/players/{id}/skill-progression", (string id, IPositionStateStore positions, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore) => { if (!positions.TryGetPosition(id, out _)) { return Results.NotFound(); } var trimmedId = id.Trim(); return Results.Json(BuildSnapshot(trimmedId, registry, xpStore)); }); app.MapPost( "/game/players/{id}/skill-progression", (string id, SkillProgressionGrantRequest? body, IPositionStateStore positions, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore) => { var trimmedId = id.Trim(); static SkillProgressionGrantResponse Deny( SkillProgressionSnapshotResponse snapshot, string reason) => new SkillProgressionGrantResponse { Granted = false, ReasonCode = reason, Progression = snapshot, LevelUps = [], }; if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion) { return Results.BadRequest(); } if (!positions.TryGetPosition(id, out _)) { return Results.NotFound(); } var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore); if (body.Amount <= 0) { return Results.Json(Deny(beforeSnapshot, ReasonInvalidAmount)); } var skillLookup = body.SkillId.Trim(); if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def)) { return Results.Json(Deny(beforeSnapshot, ReasonUnknownSkill)); } var source = body.SourceKind.Trim(); var allowed = def.AllowedXpSourceKinds.Any(k => string.Equals(k, source, StringComparison.OrdinalIgnoreCase)); if (!allowed) { return Results.Json(Deny(beforeSnapshot, ReasonSourceKindNotAllowed)); } if (!xpStore.TryApplyXpDelta(trimmedId, def.Id, body.Amount, out var prevXp, out var newXp)) { return Results.NotFound(); } var prevLevel = SkillLevelCurvePlaceholder.LevelFromTotalXp(prevXp); var nextLevel = SkillLevelCurvePlaceholder.LevelFromTotalXp(newXp); var levelUps = new List(); if (nextLevel > prevLevel) { levelUps.Add( new SkillLevelUpJson { SkillId = def.Id, PreviousLevel = prevLevel, NewLevel = nextLevel, }); } var afterSnapshot = BuildSnapshot(trimmedId, registry, xpStore); return Results.Json( new SkillProgressionGrantResponse { Granted = true, Progression = afterSnapshot, LevelUps = levelUps, }); }); return app; } internal static SkillProgressionSnapshotResponse BuildSnapshot( string playerId, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore store) { var xpBySkill = store.GetXpTotals(playerId); var defs = registry.GetDefinitionsInIdOrder(); var skills = new List(defs.Count); foreach (var d in defs) { xpBySkill.TryGetValue(d.Id, out var xp); var level = SkillLevelCurvePlaceholder.LevelFromTotalXp(xp); skills.Add( new SkillProgressionRowJson { Id = d.Id, Xp = xp, Level = level, }); } skills.Sort(static (a, b) => string.CompareOrdinal(a.Id, b.Id)); return new SkillProgressionSnapshotResponse { PlayerId = playerId, Skills = skills, SchemaVersion = SkillProgressionSnapshotResponse.CurrentSchemaVersion, }; } }