Tracking character movement
This commit is contained in:
parent
d1fade919c
commit
ee0cf0659d
@ -11,10 +11,12 @@ namespace CharacterApi.Controllers;
|
||||
public class CharactersController : ControllerBase
|
||||
{
|
||||
private readonly CharacterStore _characters;
|
||||
private readonly LocationsClient _locations;
|
||||
|
||||
public CharactersController(CharacterStore characters)
|
||||
public CharactersController(CharacterStore characters, LocationsClient locations)
|
||||
{
|
||||
_characters = characters;
|
||||
_locations = locations;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -67,4 +69,39 @@ public class CharactersController : ControllerBase
|
||||
|
||||
return Ok("Deleted");
|
||||
}
|
||||
|
||||
[HttpPut("{id}/move")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Move(string id, [FromBody] MoveCharacterRequest req, CancellationToken ct)
|
||||
{
|
||||
if (req?.Coord is null)
|
||||
return BadRequest("Coord required");
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var allowAnyOwner = User.IsInRole("SUPER");
|
||||
var existing = await _characters.GetForOwnerByIdAsync(id, userId, allowAnyOwner);
|
||||
if (existing is null)
|
||||
return NotFound();
|
||||
|
||||
var presence = await _locations.UpdatePresenceAsync(id, req.Coord, ct);
|
||||
if (!presence.Ok)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(presence.Body)
|
||||
? "Location presence update failed"
|
||||
: presence.Body;
|
||||
return StatusCode((int)presence.Status, message);
|
||||
}
|
||||
|
||||
var updated = await _characters.UpdateCoordAsync(id, userId, allowAnyOwner, req.Coord);
|
||||
if (!updated)
|
||||
{
|
||||
await _locations.UpdatePresenceAsync(id, existing.Coord, ct);
|
||||
return StatusCode(500, "Failed to update character coord");
|
||||
}
|
||||
|
||||
return Ok("Moved");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,15 @@ Inbound JSON documents
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
- MoveCharacterRequest (`PUT /api/characters/{id}/move`)
|
||||
```json
|
||||
{
|
||||
"coord": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stored documents (MongoDB)
|
||||
- Character
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
namespace CharacterApi.Models;
|
||||
|
||||
public class MoveCharacterRequest
|
||||
{
|
||||
public Coord? Coord { get; set; }
|
||||
}
|
||||
@ -9,6 +9,11 @@ builder.Services.AddControllers();
|
||||
|
||||
// DI
|
||||
builder.Services.AddSingleton<CharacterStore>();
|
||||
builder.Services.AddHttpClient<LocationsClient>(client =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["LocationsApi:BaseUrl"] ?? "http://localhost:5002";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
|
||||
// Swagger + JWT auth in Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@ -7,3 +7,4 @@ See `DOCUMENTS.md` for request payloads and stored document shapes.
|
||||
- `POST /api/characters` Create a character.
|
||||
- `GET /api/characters` List characters for the current user.
|
||||
- `DELETE /api/characters/{id}` Delete a character owned by the current user.
|
||||
- `PUT /api/characters/{id}/move` Move a character to a new coord.
|
||||
|
||||
@ -24,6 +24,36 @@ public class CharacterStore
|
||||
public Task<List<Character>> GetForOwnerAsync(string ownerUserId) =>
|
||||
_col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync();
|
||||
|
||||
public async Task<Character?> GetForOwnerByIdAsync(string id, string ownerUserId, bool allowAnyOwner)
|
||||
{
|
||||
var filter = Builders<Character>.Filter.Eq(c => c.Id, id);
|
||||
if (!allowAnyOwner)
|
||||
{
|
||||
filter = Builders<Character>.Filter.And(
|
||||
filter,
|
||||
Builders<Character>.Filter.Eq(c => c.OwnerUserId, ownerUserId)
|
||||
);
|
||||
}
|
||||
|
||||
return await _col.Find(filter).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateCoordAsync(string id, string ownerUserId, bool allowAnyOwner, Coord coord)
|
||||
{
|
||||
var filter = Builders<Character>.Filter.Eq(c => c.Id, id);
|
||||
if (!allowAnyOwner)
|
||||
{
|
||||
filter = Builders<Character>.Filter.And(
|
||||
filter,
|
||||
Builders<Character>.Filter.Eq(c => c.OwnerUserId, ownerUserId)
|
||||
);
|
||||
}
|
||||
|
||||
var update = Builders<Character>.Update.Set(c => c.Coord, coord);
|
||||
var result = await _col.UpdateOneAsync(filter, update);
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner)
|
||||
{
|
||||
var filter = Builders<Character>.Filter.Eq(c => c.Id, id);
|
||||
|
||||
35
microservices/CharacterApi/Services/LocationsClient.cs
Normal file
35
microservices/CharacterApi/Services/LocationsClient.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using CharacterApi.Models;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace CharacterApi.Services;
|
||||
|
||||
public class LocationsClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _internalKey;
|
||||
|
||||
public LocationsClient(HttpClient http, IConfiguration cfg)
|
||||
{
|
||||
_http = http;
|
||||
_internalKey = cfg["LocationsApi:InternalKey"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, HttpStatusCode Status, string? Body)> UpdatePresenceAsync(
|
||||
string characterId,
|
||||
Coord coord,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "api/locations/presence")
|
||||
{
|
||||
Content = JsonContent.Create(new { characterId, coord })
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_internalKey))
|
||||
request.Headers.TryAddWithoutValidation("X-Internal-Key", _internalKey);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return (response.IsSuccessStatusCode, response.StatusCode, body);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } },
|
||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||
"LocationsApi": { "BaseUrl": "http://localhost:5002", "InternalKey": "dev-internal-key" },
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"Logging": { "LogLevel": { "Default": "Information" } }
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } },
|
||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||
"LocationsApi": { "BaseUrl": "http://localhost:5002", "InternalKey": "dev-internal-key" },
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||
"AllowedHosts": "*"
|
||||
|
||||
@ -11,10 +11,12 @@ namespace LocationsApi.Controllers;
|
||||
public class LocationsController : ControllerBase
|
||||
{
|
||||
private readonly LocationStore _locations;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public LocationsController(LocationStore locations)
|
||||
public LocationsController(LocationStore locations, IConfiguration cfg)
|
||||
{
|
||||
_locations = locations;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -31,6 +33,7 @@ public class LocationsController : ControllerBase
|
||||
{
|
||||
Name = req.Name.Trim(),
|
||||
Coord = req.Coord,
|
||||
CharacterIds = new List<string>(),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@ -85,4 +88,28 @@ public class LocationsController : ControllerBase
|
||||
|
||||
return Ok("Updated");
|
||||
}
|
||||
|
||||
[HttpPost("presence")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> UpdatePresence([FromBody] UpdateLocationPresenceRequest req)
|
||||
{
|
||||
var internalKey = _cfg["Internal:Key"];
|
||||
if (!string.IsNullOrWhiteSpace(internalKey))
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-Internal-Key", out var provided) || provided != internalKey)
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(req.CharacterId))
|
||||
return BadRequest("CharacterId required");
|
||||
|
||||
if (req.Coord is null)
|
||||
return BadRequest("Coord required");
|
||||
|
||||
var updated = await _locations.UpdatePresenceAsync(req.CharacterId.Trim(), req.Coord);
|
||||
if (!updated)
|
||||
return NotFound("Location not found");
|
||||
|
||||
return Ok("Updated");
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,16 @@ Inbound JSON documents
|
||||
}
|
||||
```
|
||||
`coord` cannot be updated.
|
||||
- UpdateLocationPresenceRequest (`POST /api/locations/presence`)
|
||||
```json
|
||||
{
|
||||
"characterId": "string",
|
||||
"coord": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stored documents (MongoDB)
|
||||
- Location
|
||||
@ -32,6 +42,7 @@ Stored documents (MongoDB)
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"characterIds": ["string"],
|
||||
"createdUtc": "string (ISO-8601 datetime)"
|
||||
}
|
||||
```
|
||||
|
||||
@ -15,6 +15,9 @@ public class Location
|
||||
[BsonElement("coord")]
|
||||
public required Coord Coord { get; set; }
|
||||
|
||||
[BsonElement("characterIds")]
|
||||
public List<string> CharacterIds { get; set; } = new();
|
||||
|
||||
[BsonElement("createdUtc")]
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace LocationsApi.Models;
|
||||
|
||||
public class UpdateLocationPresenceRequest
|
||||
{
|
||||
public string CharacterId { get; set; } = string.Empty;
|
||||
|
||||
public Coord? Coord { get; set; }
|
||||
}
|
||||
@ -8,3 +8,4 @@ See `DOCUMENTS.md` for request payloads and stored document shapes.
|
||||
- `GET /api/locations` List all locations (SUPER only).
|
||||
- `DELETE /api/locations/{id}` Delete a location (SUPER only).
|
||||
- `PUT /api/locations/{id}` Update a location name (SUPER only).
|
||||
- `POST /api/locations/presence` Update which characters are present at a coord (internal).
|
||||
|
||||
@ -54,6 +54,13 @@ public class LocationStore
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"characterIds", new BsonDocument
|
||||
{
|
||||
{ "bsonType", "array" },
|
||||
{ "items", new BsonDocument { { "bsonType", "string" } } }
|
||||
}
|
||||
},
|
||||
{ "createdUtc", new BsonDocument { { "bsonType", "date" } } }
|
||||
}
|
||||
}
|
||||
@ -103,6 +110,24 @@ public class LocationStore
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdatePresenceAsync(string characterId, Coord coord)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(characterId))
|
||||
return false;
|
||||
|
||||
var pullFilter = Builders<Location>.Filter.AnyEq(l => l.CharacterIds, characterId);
|
||||
var pullUpdate = Builders<Location>.Update.Pull(l => l.CharacterIds, characterId);
|
||||
await _col.UpdateManyAsync(pullFilter, pullUpdate);
|
||||
|
||||
var targetFilter = Builders<Location>.Filter.And(
|
||||
Builders<Location>.Filter.Eq(l => l.Coord.X, coord.X),
|
||||
Builders<Location>.Filter.Eq(l => l.Coord.Y, coord.Y)
|
||||
);
|
||||
var addUpdate = Builders<Location>.Update.AddToSet(l => l.CharacterIds, characterId);
|
||||
var result = await _col.UpdateOneAsync(targetFilter, addUpdate);
|
||||
return result.MatchedCount > 0;
|
||||
}
|
||||
|
||||
private void EnsureOriginLocation()
|
||||
{
|
||||
var filter = Builders<Location>.Filter.And(
|
||||
@ -117,6 +142,7 @@ public class LocationStore
|
||||
{
|
||||
Name = "Origin",
|
||||
Coord = new Coord { X = 0, Y = 0 },
|
||||
CharacterIds = new List<string>(),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
|
||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||
"Internal": { "Key": "dev-internal-key" },
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"Logging": { "LogLevel": { "Default": "Information" } }
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
|
||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||
"Internal": { "Key": "dev-internal-key" },
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||
"AllowedHosts": "*"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user