neon-sprawl/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs

119 lines
5.4 KiB
C#

using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Maps <c>GET/POST …/target</c> selection APIs (NEO-23).</summary>
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;
}
}