using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Mastery; /// /// Maps GET/POST /game/players/{{id}}/perk-state — read snapshot and branch selection (NEO-48). /// public static class PerkStateApi { public static WebApplication MapPerkStateApi(this WebApplication app) { app.MapGet( "/game/players/{id}/perk-state", (string id, IPositionStateStore positions, IPlayerPerkStateStore perkStore) => { var trimmedId = id.Trim(); if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) { return Results.NotFound(); } return Results.Json(BuildSnapshot(trimmedId, perkStore)); }); app.MapPost( "/game/players/{id}/perk-state", (string id, PerkBranchSelectRequest? body, IPositionStateStore positions, PerkUnlockEngine perkUnlockEngine) => { if (body is null || body.SchemaVersion != PerkBranchSelectRequest.CurrentSchemaVersion) { return Results.BadRequest(); } var trimmedId = id.Trim(); if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) { return Results.NotFound(); } var outcome = perkUnlockEngine.TrySelectBranch( trimmedId, body.SkillId, body.TierIndex, body.BranchId); var perkState = BuildSnapshot(trimmedId, outcome.Snapshot); if (!outcome.Success) { return Results.Json( new PerkBranchSelectResponse { Selected = false, ReasonCode = outcome.ReasonCode, PerkState = perkState, UnlockedEvents = [], }); } return Results.Json( new PerkBranchSelectResponse { Selected = true, PerkState = perkState, UnlockedEvents = MapEvents(outcome.Events), }); }); return app; } internal static PerkStateSnapshotResponse BuildSnapshot(string playerId, IPlayerPerkStateStore store) => BuildSnapshot(playerId, store.GetSnapshot(playerId)); internal static PerkStateSnapshotResponse BuildSnapshot(string playerId, PerkStateSnapshot snapshot) { var branchPicks = new List(snapshot.BranchPicksBySkillId.Count); foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId.OrderBy(static kv => kv.Key, StringComparer.Ordinal)) { var picks = tiers .OrderBy(static kv => kv.Key) .Select(static kv => new PerkBranchPickRowJson { TierIndex = kv.Key, BranchId = kv.Value }) .ToList(); branchPicks.Add(new PerkBranchPickTrackJson { SkillId = skillId, Picks = picks }); } var unlocked = snapshot.UnlockedPerkIds.OrderBy(static id => id, StringComparer.Ordinal).ToList(); return new PerkStateSnapshotResponse { PlayerId = playerId, BranchPicks = branchPicks, UnlockedPerkIds = unlocked, }; } private static List MapEvents(IReadOnlyList events) { if (events.Count == 0) { return []; } var mapped = new List(events.Count); foreach (var e in events) { mapped.Add( new PerkUnlockedEventJson { PerkId = e.PerkId, SkillId = e.SkillId, TierIndex = e.TierIndex, BranchId = e.BranchId, Source = e.Source switch { PerkUnlockSource.BranchPick => "branch_pick", PerkUnlockSource.LevelUp => "level_up", _ => throw new InvalidOperationException($"Unexpected perk unlock source: {e.Source}"), }, }); } return mapped; } }