From ee0cf0659d1e3d0bf3427fa1a03472492ee76202 Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Wed, 28 Jan 2026 11:55:25 -0600 Subject: [PATCH] Tracking character movement --- .../Controllers/CharactersController.cs | 71 ++++++++++++---- microservices/CharacterApi/DOCUMENTS.md | 21 +++-- .../Models/MoveCharacterRequest.cs | 6 ++ microservices/CharacterApi/Program.cs | 13 ++- microservices/CharacterApi/README.md | 1 + .../CharacterApi/Services/CharacterStore.cs | 46 +++++++++-- .../CharacterApi/Services/LocationsClient.cs | 35 ++++++++ .../CharacterApi/appsettings.Development.json | 7 +- microservices/CharacterApi/appsettings.json | 7 +- .../Controllers/LocationsController.cs | 29 ++++++- microservices/LocationsApi/DOCUMENTS.md | 11 +++ microservices/LocationsApi/Models/Location.cs | 3 + .../Models/UpdateLocationPresenceRequest.cs | 8 ++ microservices/LocationsApi/README.md | 1 + .../LocationsApi/Services/LocationStore.cs | 82 ++++++++++++------- .../LocationsApi/appsettings.Development.json | 1 + microservices/LocationsApi/appsettings.json | 1 + 17 files changed, 273 insertions(+), 70 deletions(-) create mode 100644 microservices/CharacterApi/Models/MoveCharacterRequest.cs create mode 100644 microservices/CharacterApi/Services/LocationsClient.cs create mode 100644 microservices/LocationsApi/Models/UpdateLocationPresenceRequest.cs diff --git a/microservices/CharacterApi/Controllers/CharactersController.cs b/microservices/CharacterApi/Controllers/CharactersController.cs index 060edc4..010878c 100644 --- a/microservices/CharacterApi/Controllers/CharactersController.cs +++ b/microservices/CharacterApi/Controllers/CharactersController.cs @@ -8,14 +8,16 @@ namespace CharacterApi.Controllers; [ApiController] [Route("api/[controller]")] -public class CharactersController : ControllerBase -{ - private readonly CharacterStore _characters; - - public CharactersController(CharacterStore characters) - { - _characters = characters; - } +public class CharactersController : ControllerBase +{ + private readonly CharacterStore _characters; + private readonly LocationsClient _locations; + + public CharactersController(CharacterStore characters, LocationsClient locations) + { + _characters = characters; + _locations = locations; + } [HttpPost] [Authorize(Roles = "USER,SUPER")] @@ -54,17 +56,52 @@ public class CharactersController : ControllerBase [HttpDelete("{id}")] [Authorize(Roles = "USER,SUPER")] - public async Task Delete(string id) - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (string.IsNullOrWhiteSpace(userId)) - return Unauthorized(); + public async Task Delete(string id) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); var allowAnyOwner = User.IsInRole("SUPER"); var deleted = await _characters.DeleteForOwnerAsync(id, userId, allowAnyOwner); if (!deleted) return NotFound(); - - return Ok("Deleted"); - } -} + + return Ok("Deleted"); + } + + [HttpPut("{id}/move")] + [Authorize(Roles = "USER,SUPER")] + public async Task 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"); + } +} diff --git a/microservices/CharacterApi/DOCUMENTS.md b/microservices/CharacterApi/DOCUMENTS.md index 55315cf..cfb8304 100644 --- a/microservices/CharacterApi/DOCUMENTS.md +++ b/microservices/CharacterApi/DOCUMENTS.md @@ -4,12 +4,21 @@ This service expects JSON request bodies for character creation and stores character documents in MongoDB. Inbound JSON documents -- CreateCharacterRequest (`POST /api/characters`) - ```json - { - "name": "string" - } - ``` +- CreateCharacterRequest (`POST /api/characters`) + ```json + { + "name": "string" + } + ``` +- MoveCharacterRequest (`PUT /api/characters/{id}/move`) + ```json + { + "coord": { + "x": 0, + "y": 0 + } + } + ``` Stored documents (MongoDB) - Character diff --git a/microservices/CharacterApi/Models/MoveCharacterRequest.cs b/microservices/CharacterApi/Models/MoveCharacterRequest.cs new file mode 100644 index 0000000..ca6e0e1 --- /dev/null +++ b/microservices/CharacterApi/Models/MoveCharacterRequest.cs @@ -0,0 +1,6 @@ +namespace CharacterApi.Models; + +public class MoveCharacterRequest +{ + public Coord? Coord { get; set; } +} diff --git a/microservices/CharacterApi/Program.cs b/microservices/CharacterApi/Program.cs index bac4df0..59026d4 100644 --- a/microservices/CharacterApi/Program.cs +++ b/microservices/CharacterApi/Program.cs @@ -5,10 +5,15 @@ using Microsoft.OpenApi.Models; using System.Text; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); - -// DI -builder.Services.AddSingleton(); +builder.Services.AddControllers(); + +// DI +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(client => +{ + var baseUrl = builder.Configuration["LocationsApi:BaseUrl"] ?? "http://localhost:5002"; + client.BaseAddress = new Uri(baseUrl); +}); // Swagger + JWT auth in Swagger builder.Services.AddEndpointsApiExplorer(); diff --git a/microservices/CharacterApi/README.md b/microservices/CharacterApi/README.md index 4c9db47..900f496 100644 --- a/microservices/CharacterApi/README.md +++ b/microservices/CharacterApi/README.md @@ -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. diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 433b438..ba346c2 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -21,14 +21,44 @@ public class CharacterStore public Task CreateAsync(Character character) => _col.InsertOneAsync(character); - public Task> GetForOwnerAsync(string ownerUserId) => - _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync(); - - public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner) - { - var filter = Builders.Filter.Eq(c => c.Id, id); - if (!allowAnyOwner) - { + public Task> GetForOwnerAsync(string ownerUserId) => + _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync(); + + public async Task GetForOwnerByIdAsync(string id, string ownerUserId, bool allowAnyOwner) + { + var filter = Builders.Filter.Eq(c => c.Id, id); + if (!allowAnyOwner) + { + filter = Builders.Filter.And( + filter, + Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId) + ); + } + + return await _col.Find(filter).FirstOrDefaultAsync(); + } + + public async Task UpdateCoordAsync(string id, string ownerUserId, bool allowAnyOwner, Coord coord) + { + var filter = Builders.Filter.Eq(c => c.Id, id); + if (!allowAnyOwner) + { + filter = Builders.Filter.And( + filter, + Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId) + ); + } + + var update = Builders.Update.Set(c => c.Coord, coord); + var result = await _col.UpdateOneAsync(filter, update); + return result.ModifiedCount > 0; + } + + public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner) + { + var filter = Builders.Filter.Eq(c => c.Id, id); + if (!allowAnyOwner) + { filter = Builders.Filter.And( filter, Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId) diff --git a/microservices/CharacterApi/Services/LocationsClient.cs b/microservices/CharacterApi/Services/LocationsClient.cs new file mode 100644 index 0000000..46c4484 --- /dev/null +++ b/microservices/CharacterApi/Services/LocationsClient.cs @@ -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); + } +} diff --git a/microservices/CharacterApi/appsettings.Development.json b/microservices/CharacterApi/appsettings.Development.json index d50df68..3b25861 100644 --- a/microservices/CharacterApi/appsettings.Development.json +++ b/microservices/CharacterApi/appsettings.Development.json @@ -1,6 +1,7 @@ { - "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } }, - "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, - "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "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" } } } diff --git a/microservices/CharacterApi/appsettings.json b/microservices/CharacterApi/appsettings.json index 2a44cad..24fbc63 100644 --- a/microservices/CharacterApi/appsettings.json +++ b/microservices/CharacterApi/appsettings.json @@ -1,7 +1,8 @@ { - "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } }, - "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, - "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "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": "*" } diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index 6c9886f..07bcaa9 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -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(), CreatedUtc = DateTime.UtcNow }; @@ -85,4 +88,28 @@ public class LocationsController : ControllerBase return Ok("Updated"); } + + [HttpPost("presence")] + [AllowAnonymous] + public async Task 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"); + } } diff --git a/microservices/LocationsApi/DOCUMENTS.md b/microservices/LocationsApi/DOCUMENTS.md index 0df5336..bfd076a 100644 --- a/microservices/LocationsApi/DOCUMENTS.md +++ b/microservices/LocationsApi/DOCUMENTS.md @@ -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)" } ``` diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs index 462e763..0073341 100644 --- a/microservices/LocationsApi/Models/Location.cs +++ b/microservices/LocationsApi/Models/Location.cs @@ -15,6 +15,9 @@ public class Location [BsonElement("coord")] public required Coord Coord { get; set; } + [BsonElement("characterIds")] + public List CharacterIds { get; set; } = new(); + [BsonElement("createdUtc")] public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/microservices/LocationsApi/Models/UpdateLocationPresenceRequest.cs b/microservices/LocationsApi/Models/UpdateLocationPresenceRequest.cs new file mode 100644 index 0000000..2cc141d --- /dev/null +++ b/microservices/LocationsApi/Models/UpdateLocationPresenceRequest.cs @@ -0,0 +1,8 @@ +namespace LocationsApi.Models; + +public class UpdateLocationPresenceRequest +{ + public string CharacterId { get; set; } = string.Empty; + + public Coord? Coord { get; set; } +} diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index cdbde9a..da53c23 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -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). diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs index 27e753f..786a499 100644 --- a/microservices/LocationsApi/Services/LocationStore.cs +++ b/microservices/LocationsApi/Services/LocationStore.cs @@ -34,13 +34,13 @@ public class LocationStore { "$jsonSchema", new BsonDocument { - { "bsonType", "object" }, - { "required", new BsonArray { "name", "coord", "createdUtc" } }, - { - "properties", new BsonDocument - { - { "name", new BsonDocument { { "bsonType", "string" } } }, - { + { "bsonType", "object" }, + { "required", new BsonArray { "name", "coord", "createdUtc" } }, + { + "properties", new BsonDocument + { + { "name", new BsonDocument { { "bsonType", "string" } } }, + { "coord", new BsonDocument { { "bsonType", "object" }, @@ -51,14 +51,21 @@ public class LocationStore { "x", new BsonDocument { { "bsonType", "int" } } }, { "y", new BsonDocument { { "bsonType", "int" } } } } - } - } - }, - { "createdUtc", new BsonDocument { { "bsonType", "date" } } } - } - } - } - } + } + } + }, + { + "characterIds", new BsonDocument + { + { "bsonType", "array" }, + { "items", new BsonDocument { { "bsonType", "string" } } } + } + }, + { "createdUtc", new BsonDocument { { "bsonType", "date" } } } + } + } + } + } }; var collections = db.ListCollectionNames().ToList(); @@ -95,13 +102,31 @@ public class LocationStore return result.DeletedCount > 0; } - public async Task UpdateNameAsync(string id, string name) - { - var filter = Builders.Filter.Eq(l => l.Id, id); - var update = Builders.Update.Set(l => l.Name, name); - var result = await _col.UpdateOneAsync(filter, update); - return result.ModifiedCount > 0; - } + public async Task UpdateNameAsync(string id, string name) + { + var filter = Builders.Filter.Eq(l => l.Id, id); + var update = Builders.Update.Set(l => l.Name, name); + var result = await _col.UpdateOneAsync(filter, update); + return result.ModifiedCount > 0; + } + + public async Task UpdatePresenceAsync(string characterId, Coord coord) + { + if (string.IsNullOrWhiteSpace(characterId)) + return false; + + var pullFilter = Builders.Filter.AnyEq(l => l.CharacterIds, characterId); + var pullUpdate = Builders.Update.Pull(l => l.CharacterIds, characterId); + await _col.UpdateManyAsync(pullFilter, pullUpdate); + + var targetFilter = Builders.Filter.And( + Builders.Filter.Eq(l => l.Coord.X, coord.X), + Builders.Filter.Eq(l => l.Coord.Y, coord.Y) + ); + var addUpdate = Builders.Update.AddToSet(l => l.CharacterIds, characterId); + var result = await _col.UpdateOneAsync(targetFilter, addUpdate); + return result.MatchedCount > 0; + } private void EnsureOriginLocation() { @@ -113,12 +138,13 @@ public class LocationStore if (existing is not null) return; - var origin = new Location - { - Name = "Origin", - Coord = new Coord { X = 0, Y = 0 }, - CreatedUtc = DateTime.UtcNow - }; + var origin = new Location + { + Name = "Origin", + Coord = new Coord { X = 0, Y = 0 }, + CharacterIds = new List(), + CreatedUtc = DateTime.UtcNow + }; try { diff --git a/microservices/LocationsApi/appsettings.Development.json b/microservices/LocationsApi/appsettings.Development.json index 07f3f94..55da67b 100644 --- a/microservices/LocationsApi/appsettings.Development.json +++ b/microservices/LocationsApi/appsettings.Development.json @@ -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" } } } diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index d67c59f..f784041 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -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": "*"