NEO-23: add targeting HTTP API (TargetState v1, select intent)
parent
8c9db789d0
commit
465c4b14eb
|
|
@ -31,10 +31,10 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] **Server source of truth:** Client cannot persist a lock the server rejects; every **`POST`** response includes the **authoritative** `targetState` after evaluation (success or denial).
|
||||
- [ ] **Denials:** **`selectionApplied`: `false`** ⇒ **required non-empty `reasonCode`**; **HTTP 200** for logical denials; **400** / **404** rules match NEO-9 spirit (unknown player → **404** before body; malformed v1 → **400**).
|
||||
- [ ] **Movement coupling:** After **`POST /move`** or **`POST /move-stream`**, the same **`GET`**/ **`POST`** response reflects updated **`validity`** (e.g. walk out of stub radius ⇒ `out_of_range` on read without requiring a new select).
|
||||
- [ ] **Contract names** in README + XML match the **illustrative** E1.M3 table ([Key contracts](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#key-contracts)); any renames are visible in this plan and in PR text, not only in code.
|
||||
- [x] **Server source of truth:** Client cannot persist a lock the server rejects; every **`POST`** response includes the **authoritative** `targetState` after evaluation (success or denial).
|
||||
- [x] **Denials:** **`selectionApplied`: `false`** ⇒ **required non-empty `reasonCode`**; **HTTP 200** for logical denials; **400** / **404** rules match NEO-9 spirit (unknown player → **404** before body; malformed v1 → **400**).
|
||||
- [x] **Movement coupling:** After **`POST /move`** or **`POST /move-stream`**, the same **`GET`**/ **`POST`** response reflects updated **`validity`** (e.g. walk out of stub radius ⇒ `out_of_range` on read without requiring a new select).
|
||||
- [x] **Contract names** in README + XML match the **illustrative** E1.M3 table ([Key contracts](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#key-contracts)); any renames are visible in this plan and in PR text, not only in code.
|
||||
|
||||
## Technical approach
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Targeting;
|
||||
|
||||
public class PlayerTargetStateReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeValidity_ShouldReturnInvalidTarget_WhenLockIdNotInRegistry()
|
||||
{
|
||||
var snap = new PositionSnapshot(0, 0, 0, 0);
|
||||
Assert.Equal(TargetValidity.InvalidTarget, PlayerTargetStateReader.ComputeValidity("not_in_registry", in snap));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeValidity_ShouldReturnNone_WhenLockNull()
|
||||
{
|
||||
var snap = new PositionSnapshot(0, 0, 0, 0);
|
||||
Assert.Equal(TargetValidity.None, PlayerTargetStateReader.ComputeValidity(null, in snap));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Targeting;
|
||||
|
||||
/// <summary>Integration tests for <see cref="TargetingApi"/> (NEO-23).</summary>
|
||||
public class TargetingApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetTarget_ShouldReturnNone_WhenNoLock()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/game/players/dev-local-1/target");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<PlayerTargetStateResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(PlayerTargetStateResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.Equal("dev-local-1", body.PlayerId);
|
||||
Assert.Null(body.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.None, body.Validity);
|
||||
Assert.Equal(0, body.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldApplyLock_WhenInRange()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.SelectionApplied);
|
||||
Assert.Null(body.ReasonCode);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.Ok, body.TargetState.Validity);
|
||||
Assert.Equal(1, body.TargetState.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyUnknownTarget_WithReasonCode()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = "not_a_real_target",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.SelectionApplied);
|
||||
Assert.Equal(TargetingApi.ReasonUnknownTarget, body.ReasonCode);
|
||||
Assert.Null(body.TargetState.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.None, body.TargetState.Validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyOutOfRange_WhenBetaFarFromSpawn()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.SelectionApplied);
|
||||
Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode);
|
||||
Assert.Null(body.TargetState.LockedTargetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldClearLock_WhenTargetIdOmitted()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
})).StatusCode);
|
||||
|
||||
var clear = await client.PostAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new StringContent(
|
||||
$"{{\"schemaVersion\":{TargetSelectRequest.CurrentSchemaVersion}}}",
|
||||
Encoding.UTF8,
|
||||
"application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, clear.StatusCode);
|
||||
var body = await clear.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.SelectionApplied);
|
||||
Assert.Null(body.TargetState.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.None, body.TargetState.Validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTarget_ShouldShowOutOfRange_AfterMoveWithoutSelect()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
})).StatusCode);
|
||||
|
||||
var move = new MoveCommandRequest
|
||||
{
|
||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 50, Y = 0, Z = 50 },
|
||||
};
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
|
||||
|
||||
var get = await client.GetAsync("/game/players/dev-local-1/target");
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
var body = await get.Content.ReadFromJsonAsync<PlayerTargetStateResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.OutOfRange, body.Validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/missing-player/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldReturnBadRequest_WhenSchemaVersionWrong()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = 999,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldReturnBadRequest_WhenTargetIdWhitespaceOnly()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = " \t ",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldBeCaseInsensitive_ForTargetId()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = " PROTOTYPE_TARGET_ALPHA ",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.SelectionApplied);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldStayIdempotent_WhenSameTargetReselect()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var req = new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
};
|
||||
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
|
||||
var second = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
var b1 = await first.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
var b2 = await second.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.Equal(b1!.TargetState.Sequence, b2!.TargetState.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyOutOfRange_WhenReselectingSameLockWhileOutOfRange()
|
||||
{
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
|
||||
var move = new MoveCommandRequest
|
||||
{
|
||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 50, Y = 0, Z = 50 },
|
||||
};
|
||||
await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.SelectionApplied);
|
||||
Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
/// <summary>In-memory per-player combat target lock for prototype HTTP (NEO-23).</summary>
|
||||
public interface IPlayerTargetLockStore
|
||||
{
|
||||
/// <summary>Current lock id (lowercase registry key) and sequence; missing player ⇒ <c>(null, 0)</c>.</summary>
|
||||
(string? LockIdLowercase, int Sequence) GetLockState(string playerId);
|
||||
|
||||
/// <summary>Clears lock; bumps sequence only if a lock was present.</summary>
|
||||
(string? LockIdLowercase, int Sequence) ApplyClear(string playerId);
|
||||
|
||||
/// <summary>Sets lock to <paramref name="targetIdLowercase"/>; no sequence bump if already equal (idempotent).</summary>
|
||||
(string? LockIdLowercase, int Sequence) ApplySet(string playerId, string targetIdLowercase);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
namespace NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
/// <summary>Thread-safe prototype lock store; player keys are trimmed with ordinal case-insensitive lookup (same spirit as position APIs).</summary>
|
||||
public sealed class InMemoryPlayerTargetLockStore : IPlayerTargetLockStore
|
||||
{
|
||||
private readonly object mutex = new();
|
||||
private readonly Dictionary<string, PlayerLockRow> rows = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly record struct PlayerLockRow(string? LockIdLowercase, int Sequence);
|
||||
|
||||
public (string? LockIdLowercase, int Sequence) GetLockState(string playerId)
|
||||
{
|
||||
var key = playerId.Trim();
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return (null, 0);
|
||||
}
|
||||
|
||||
lock (mutex)
|
||||
{
|
||||
return rows.TryGetValue(key, out var row) ? (row.LockIdLowercase, row.Sequence) : (null, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public (string? LockIdLowercase, int Sequence) ApplyClear(string playerId)
|
||||
{
|
||||
var key = playerId.Trim();
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return (null, 0);
|
||||
}
|
||||
|
||||
lock (mutex)
|
||||
{
|
||||
if (!rows.TryGetValue(key, out var prev))
|
||||
{
|
||||
return (null, 0);
|
||||
}
|
||||
|
||||
if (prev.LockIdLowercase is null)
|
||||
{
|
||||
return (null, prev.Sequence);
|
||||
}
|
||||
|
||||
var next = new PlayerLockRow(null, prev.Sequence + 1);
|
||||
rows[key] = next;
|
||||
return (null, next.Sequence);
|
||||
}
|
||||
}
|
||||
|
||||
public (string? LockIdLowercase, int Sequence) ApplySet(string playerId, string targetIdLowercase)
|
||||
{
|
||||
var key = playerId.Trim();
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return (null, 0);
|
||||
}
|
||||
|
||||
lock (mutex)
|
||||
{
|
||||
var prev = rows.TryGetValue(key, out var r) ? r : new PlayerLockRow(null, 0);
|
||||
|
||||
if (string.Equals(prev.LockIdLowercase, targetIdLowercase, StringComparison.Ordinal))
|
||||
{
|
||||
return (prev.LockIdLowercase, prev.Sequence);
|
||||
}
|
||||
|
||||
var next = new PlayerLockRow(targetIdLowercase, prev.Sequence + 1);
|
||||
rows[key] = next;
|
||||
return (next.LockIdLowercase, next.Sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
/// <summary>Computes <see cref="PlayerTargetStateResponse.Validity"/> from authoritative position + persisted lock (NEO-23).</summary>
|
||||
public static class PlayerTargetStateReader
|
||||
{
|
||||
public static string ComputeValidity(string? lockIdLowercase, in PositionSnapshot snap)
|
||||
{
|
||||
if (string.IsNullOrEmpty(lockIdLowercase))
|
||||
{
|
||||
return TargetValidity.None;
|
||||
}
|
||||
|
||||
if (!PrototypeTargetRegistry.TryGet(lockIdLowercase, out var entry))
|
||||
{
|
||||
return TargetValidity.InvalidTarget;
|
||||
}
|
||||
|
||||
if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius))
|
||||
{
|
||||
return TargetValidity.OutOfRange;
|
||||
}
|
||||
|
||||
return TargetValidity.Ok;
|
||||
}
|
||||
|
||||
public static PlayerTargetStateResponse Build(string routePlayerId, string? lockIdLowercase, int sequence, in PositionSnapshot snap) =>
|
||||
new()
|
||||
{
|
||||
SchemaVersion = PlayerTargetStateResponse.CurrentSchemaVersion,
|
||||
PlayerId = routePlayerId,
|
||||
LockedTargetId = lockIdLowercase,
|
||||
Validity = ComputeValidity(lockIdLowercase, in snap),
|
||||
Sequence = sequence,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
namespace NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
/// <summary>Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase.</summary>
|
||||
public static class PrototypeTargetRegistry
|
||||
{
|
||||
/// <summary>Near default dev spawn <c>(-5, -5)</c> on XZ.</summary>
|
||||
public const string PrototypeTargetAlphaId = "prototype_target_alpha";
|
||||
|
||||
/// <summary>Farther stub for out-of-range tests; ascending id order for NEO-24 tab cycle.</summary>
|
||||
public const string PrototypeTargetBetaId = "prototype_target_beta";
|
||||
|
||||
/// <summary>Alpha anchor aligned with spawn horizontal cell; <see cref="PrototypeTargetEntry.LockRadius"/> on XZ only.</summary>
|
||||
public static readonly PrototypeTargetEntry PrototypeTargetAlpha = new(-5, 0.5, -5, 8.0);
|
||||
|
||||
public static readonly PrototypeTargetEntry PrototypeTargetBeta = new(8, 0, 8, 4.0);
|
||||
|
||||
private static readonly Dictionary<string, PrototypeTargetEntry> ById = new(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeTargetAlphaId] = PrototypeTargetAlpha,
|
||||
[PrototypeTargetBetaId] = PrototypeTargetBeta,
|
||||
};
|
||||
|
||||
public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) =>
|
||||
ById.TryGetValue(targetIdLowercase, out entry);
|
||||
}
|
||||
|
||||
/// <param name="LockRadius">Horizontal reach on X/Z; inclusive boundary (same as NEO-9).</param>
|
||||
public readonly record struct PrototypeTargetEntry(double X, double Y, double Z, double LockRadius);
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/players/{{id}}/target</c> and nested <c>targetState</c> on select (NEO-23).</summary>
|
||||
public sealed class PlayerTargetStateResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("playerId")]
|
||||
public required string PlayerId { get; init; }
|
||||
|
||||
/// <summary>Lowercase registry id when locked; JSON <c>null</c> when no lock (key always serialized).</summary>
|
||||
[JsonPropertyName("lockedTargetId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public string? LockedTargetId { get; init; }
|
||||
|
||||
/// <summary>One of <see cref="TargetValidity"/> string constants.</summary>
|
||||
[JsonPropertyName("validity")]
|
||||
public required string Validity { get; init; }
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public int Sequence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST body for <c>POST /game/players/{{id}}/target/select</c> (NEO-23).</summary>
|
||||
/// <remarks>
|
||||
/// Omit <c>targetId</c> or send JSON <c>null</c> to clear the lock. Whitespace-only after trim → HTTP 400.
|
||||
/// </remarks>
|
||||
public sealed class TargetSelectRequest
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>Registry key after trim + case-insensitive lookup; <c>null</c> or omitted ⇒ clear intent.</summary>
|
||||
[JsonPropertyName("targetId")]
|
||||
public string? TargetId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST response for target selection (NEO-23).</summary>
|
||||
public sealed class TargetSelectResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("selectionApplied")]
|
||||
public bool SelectionApplied { get; init; }
|
||||
|
||||
[JsonPropertyName("targetState")]
|
||||
public required PlayerTargetStateResponse TargetState { get; init; }
|
||||
|
||||
/// <summary>Required when <see cref="SelectionApplied"/> is <c>false</c>; omitted on success.</summary>
|
||||
[JsonPropertyName("reasonCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasonCode { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
namespace NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
/// <summary>Serialized <c>validity</c> strings for <see cref="PlayerTargetStateResponse"/> (NEO-23).</summary>
|
||||
public static class TargetValidity
|
||||
{
|
||||
public const string None = "none";
|
||||
public const string Ok = "ok";
|
||||
public const string OutOfRange = "out_of_range";
|
||||
public const string InvalidTarget = "invalid_target";
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
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
|
||||
{
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius))
|
||||
{
|
||||
var (lockFar, seqFar) = locks.GetLockState(id);
|
||||
var stateFar = PlayerTargetStateReader.Build(id, lockFar, seqFar, in snap);
|
||||
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 snap);
|
||||
return Results.Json(
|
||||
new TargetSelectResponse
|
||||
{
|
||||
SchemaVersion = TargetSelectResponse.CurrentSchemaVersion,
|
||||
SelectionApplied = true,
|
||||
TargetState = stateOk,
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
using NeonSprawl.Server.Game.Interaction;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddPositionStateStore(builder.Configuration);
|
||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
|
@ -18,5 +20,6 @@ app.MapGet("/health", () => Results.Json(new
|
|||
|
||||
app.MapPositionStateApi();
|
||||
app.MapInteractionApi();
|
||||
app.MapTargetingApi();
|
||||
|
||||
app.Run();
|
||||
|
|
|
|||
|
|
@ -125,6 +125,55 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ
|
|||
|
||||
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
|
||||
|
||||
## Targeting (NEO-23)
|
||||
|
||||
Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9.
|
||||
|
||||
**Soft lock:** the server keeps the persisted **`lockedTargetId`** until clear or a successful swap. If the player moves out of stub range, **`validity`** becomes **`out_of_range`** on **`GET`** and on **`POST`** echoes until they move back in range, clear, or select another valid target (see [NEO-23 implementation plan](../../docs/plans/NEO-23-implementation-plan.md)).
|
||||
|
||||
Stub registry: `PrototypeTargetRegistry` in `NeonSprawl.Server/Game/Targeting/` — ids **`prototype_target_alpha`**, **`prototype_target_beta`** (ascending id order for future tab cycle).
|
||||
|
||||
**`GET /game/players/{id}/target`**
|
||||
|
||||
| Field | Meaning |
|
||||
|--------|--------|
|
||||
| `schemaVersion` | `1` |
|
||||
| `playerId` | Echo of route `{id}` |
|
||||
| `lockedTargetId` | Lowercase stub id when locked; JSON **`null`** when no lock (**key always present**) |
|
||||
| `validity` | `none` (no lock), `ok`, `out_of_range`, or `invalid_target` (unknown id in store — should not occur in normal use) |
|
||||
| `sequence` | Increments when the persisted lock **id** changes (clear or swap); unchanged on idempotent re-select of the same id |
|
||||
|
||||
**`POST /game/players/{id}/target/select`** — body `schemaVersion` (`1`), optional **`targetId`**. Omit **`targetId`** or send JSON **`null`** to **clear** the lock. Non-null **`targetId`** is trimmed; **whitespace-only** after trim → **400**.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5253/game/players/dev-local-1/target
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}'
|
||||
```
|
||||
|
||||
**HTTP**
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **200** | **GET:** `PlayerTargetStateResponse` v1. **POST:** `TargetSelectResponse` v1 with **`targetState`** always set; **`selectionApplied`** `true` or `false`; when `false`, required **`reasonCode`**. |
|
||||
| **400** | Invalid v1 body (wrong `schemaVersion`, malformed JSON, or **`targetId`** whitespace-only when provided). |
|
||||
| **404** | Unknown **player** only (same as interact). |
|
||||
|
||||
**`reasonCode` (POST v1, when `selectionApplied` is `false`)**
|
||||
|
||||
| Code | When |
|
||||
|------|------|
|
||||
| `unknown_target` | Id not in the prototype target registry (after trim + case-insensitive lookup). |
|
||||
| `out_of_range` | Horizontal distance on X/Z is **greater than** the target’s `lockRadius` (on the radius is **allowed** — inclusive `<=`). |
|
||||
|
||||
When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success).
|
||||
|
||||
## Solution
|
||||
|
||||
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue