using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.World; namespace NeonSprawl.Server.Game.Targeting; /// Maps GET/POST …/target selection APIs (NEO-23). public static class TargetingApi { // NEO-27 note: these are target-selection deny reasons, not cast telemetry events. // Future ability flow (E1.M4 + E5.M1) will hook `ability_cast_denied` on cast endpoints. // TODO(E9.M1): map deny reason telemetry to cataloged event schema. public const string ReasonUnknownTarget = "unknown_target"; public const string ReasonOutOfRange = "out_of_range"; public static WebApplication MapTargetingApi(this WebApplication app) { app.MapGet( "/game/players/{id}/target", (string id, IPositionStateStore positions, IPlayerTargetLockStore locks) => { if (!positions.TryGetPosition(id, out var snap)) { return Results.NotFound(); } var (lockId, seq) = locks.GetLockState(id); var body = PlayerTargetStateReader.Build(id, lockId, seq, in snap); return Results.Json(body); }); app.MapPost( "/game/players/{id}/target/select", (string id, TargetSelectRequest? body, IPositionStateStore positions, IPlayerTargetLockStore locks) => { if (body is null || body.SchemaVersion != TargetSelectRequest.CurrentSchemaVersion) { return Results.BadRequest(); } if (!positions.TryGetPosition(id, out var snap)) { return Results.NotFound(); } // Clear intent: property omitted or JSON null. if (body.TargetId is null) { var cleared = locks.ApplyClear(id); var stateAfter = PlayerTargetStateReader.Build(id, cleared.LockIdLowercase, cleared.Sequence, in snap); return Results.Json( new TargetSelectResponse { SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, SelectionApplied = true, TargetState = stateAfter, }); } var raw = body.TargetId; var trimmed = raw.Trim(); if (trimmed.Length == 0) { return Results.BadRequest(); } var lookupKey = trimmed.ToLowerInvariant(); if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) { var (lockUnknown, seqUnknown) = locks.GetLockState(id); var stateUnknown = PlayerTargetStateReader.Build(id, lockUnknown, seqUnknown, in snap); return Results.Json( new TargetSelectResponse { SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, SelectionApplied = false, TargetState = stateUnknown, ReasonCode = ReasonUnknownTarget, }); } // Prefer the client-reported `positionHint` when present: the stored `snap` can // trail the visible capsule by several seconds when `move-stream` has been idle, // which caused false `out_of_range` denials (NEO-24 follow-up #5). The hint is // advisory — position writes stay the sole responsibility of `move-stream`. Real // combat (E5.M1) will bound the hint vs. stored snap before trusting it. // The hinted snap is used for the radius check *and* for building the response's // `validity` so a hint-accepted select does not echo a stale `out_of_range`. var effective = body.PositionHint is { } hint ? new PositionSnapshot(hint.X, hint.Y, hint.Z, snap.Sequence) : snap; if (!HorizontalReach.IsWithinHorizontalRadius(effective.X, effective.Z, entry.X, entry.Z, entry.LockRadius)) { var (lockFar, seqFar) = locks.GetLockState(id); var stateFar = PlayerTargetStateReader.Build(id, lockFar, seqFar, in effective); return Results.Json( new TargetSelectResponse { SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, SelectionApplied = false, TargetState = stateFar, ReasonCode = ReasonOutOfRange, }); } var applied = locks.ApplySet(id, lookupKey); var stateOk = PlayerTargetStateReader.Build(id, applied.LockIdLowercase, applied.Sequence, in effective); return Results.Json( new TargetSelectResponse { SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, SelectionApplied = true, TargetState = stateOk, }); }); return app; } }