152 lines
6.1 KiB
C#
152 lines
6.1 KiB
C#
using NeonSprawl.Server.Game.PositionState;
|
|
|
|
namespace NeonSprawl.Server.Game.Skills;
|
|
|
|
/// <summary>
|
|
/// Maps <c>GET</c>/<c>POST /game/players/{{id}}/skill-progression</c> — read snapshot (NEO-37) and XP grant apply (NEO-38).
|
|
/// </summary>
|
|
public static class SkillProgressionSnapshotApi
|
|
{
|
|
/// <seealso cref="SkillProgressionGrantResponse.ReasonCode" />
|
|
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, ISkillLevelCurve levelCurve) =>
|
|
{
|
|
var trimmedId = id.Trim();
|
|
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return Results.Json(BuildSnapshot(trimmedId, registry, xpStore, levelCurve));
|
|
});
|
|
|
|
app.MapPost(
|
|
"/game/players/{id}/skill-progression",
|
|
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
|
|
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
|
|
{
|
|
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();
|
|
}
|
|
|
|
var trimmedId = id.Trim();
|
|
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
|
|
|
|
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();
|
|
}
|
|
|
|
// --- 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. 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 = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
|
|
return Results.Json(
|
|
new SkillProgressionGrantResponse
|
|
{
|
|
Granted = true,
|
|
Progression = afterSnapshot,
|
|
LevelUps = levelUps,
|
|
});
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
internal static SkillProgressionSnapshotResponse BuildSnapshot(
|
|
string playerId,
|
|
ISkillDefinitionRegistry registry,
|
|
IPlayerSkillProgressionStore store,
|
|
ISkillLevelCurve levelCurve)
|
|
{
|
|
var xpBySkill = store.GetXpTotals(playerId);
|
|
var defs = registry.GetDefinitionsInIdOrder();
|
|
var skills = new List<SkillProgressionRowJson>(defs.Count);
|
|
foreach (var d in defs)
|
|
{
|
|
xpBySkill.TryGetValue(d.Id, out var xp);
|
|
var level = levelCurve.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,
|
|
};
|
|
}
|
|
}
|