119 lines
5.4 KiB
C#
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;
|
|
}
|
|
}
|