Updating location schema to tracker user territory and lock visibility
This commit is contained in:
@@ -6,6 +6,9 @@ namespace LocationsApi.Services;
|
||||
public class LocationService
|
||||
{
|
||||
private readonly IMongoCollection<Location> _locations;
|
||||
private readonly int _homeMinDistance;
|
||||
private readonly int _homeSearchRadius;
|
||||
private readonly int _visibilityDepth;
|
||||
|
||||
public LocationService(IConfiguration cfg)
|
||||
{
|
||||
@@ -14,6 +17,9 @@ public class LocationService
|
||||
var client = new MongoClient(connectionString);
|
||||
var database = client.GetDatabase(databaseName);
|
||||
_locations = database.GetCollection<Location>("Locations");
|
||||
_homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8;
|
||||
_homeSearchRadius = int.TryParse(cfg["World:HomeSearchRadius"], out var searchRadius) ? Math.Max(_homeMinDistance, searchRadius) : 80;
|
||||
_visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3;
|
||||
|
||||
var coordinateIndex = Builders<Location>.IndexKeys
|
||||
.Ascending(x => x.Q)
|
||||
@@ -21,6 +27,17 @@ public class LocationService
|
||||
_locations.Indexes.CreateOne(new CreateIndexModel<Location>(
|
||||
coordinateIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
|
||||
var ownerHomeIndex = Builders<Location>.IndexKeys
|
||||
.Ascending(x => x.OwnerUsername)
|
||||
.Ascending(x => x.Kind);
|
||||
_locations.Indexes.CreateOne(new CreateIndexModel<Location>(
|
||||
ownerHomeIndex,
|
||||
new CreateIndexOptions<Location>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<Location>.Filter.Eq(x => x.Kind, "Home")
|
||||
}));
|
||||
}
|
||||
|
||||
public Task<List<Location>> GetAllAsync() =>
|
||||
@@ -44,13 +61,16 @@ public class LocationService
|
||||
var update = Builders<Location>.Update
|
||||
.SetOnInsert(x => x.CreatedAtUtc, now)
|
||||
.Set(x => x.Terrain, NormalizeTerrain(request.Terrain))
|
||||
.Set(x => x.Kind, "Open")
|
||||
.Set(x => x.Label, request.Label)
|
||||
.Set(x => x.Level, Math.Max(1, request.Level))
|
||||
.Set(x => x.UpdatedAtUtc, now);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ownerUsername))
|
||||
{
|
||||
update = update.Set(x => x.OwnerUsername, ownerUsername);
|
||||
update = update
|
||||
.Set(x => x.OwnerUsername, ownerUsername)
|
||||
.Set(x => x.Kind, "Claimed");
|
||||
}
|
||||
|
||||
var options = new FindOneAndUpdateOptions<Location>
|
||||
@@ -65,12 +85,22 @@ public class LocationService
|
||||
options);
|
||||
}
|
||||
|
||||
public async Task<Location> ClaimAsync(int q, int r, string username)
|
||||
public async Task<Location?> ClaimAsync(int q, int r, string username)
|
||||
{
|
||||
var existing = await GetAsync(q, r);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(existing.OwnerUsername) &&
|
||||
!string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
existing.OwnerUsername = username;
|
||||
if (string.IsNullOrWhiteSpace(existing.Kind) || existing.Kind == "Open")
|
||||
{
|
||||
existing.Kind = "Claimed";
|
||||
}
|
||||
existing.UpdatedAtUtc = DateTime.UtcNow;
|
||||
await _locations.ReplaceOneAsync(x => x.Id == existing.Id, existing);
|
||||
return existing;
|
||||
@@ -79,6 +109,203 @@ public class LocationService
|
||||
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
|
||||
}
|
||||
|
||||
public async Task<Location?> GetHomeAsync(string username) =>
|
||||
await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Location> AssignHomeAsync(string username)
|
||||
{
|
||||
var existingHome = await GetHomeAsync(username);
|
||||
if (existingHome is not null)
|
||||
{
|
||||
return existingHome;
|
||||
}
|
||||
|
||||
var homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync();
|
||||
|
||||
foreach (var candidate in HomeCandidates(username))
|
||||
{
|
||||
if (!IsFarEnoughFromHomes(candidate.q, candidate.r, homeLocations))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingLocation = await GetAsync(candidate.q, candidate.r);
|
||||
if (existingLocation is not null && !string.IsNullOrWhiteSpace(existingLocation.OwnerUsername))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var home = await TryCreateHomeAsync(username, candidate.q, candidate.r);
|
||||
if (home is not null)
|
||||
{
|
||||
return home;
|
||||
}
|
||||
|
||||
homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No valid home location is available in the configured search radius.");
|
||||
}
|
||||
|
||||
public async Task<List<LocationResponse>> GetVisibleAsync(string username, int? visibilityDepth = null)
|
||||
{
|
||||
var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
|
||||
if (ownedLocations.Count == 0)
|
||||
{
|
||||
ownedLocations.Add(await AssignHomeAsync(username));
|
||||
}
|
||||
|
||||
var depth = Math.Max(0, visibilityDepth ?? _visibilityDepth);
|
||||
var visibleCoordinates = new HashSet<(int q, int r)>();
|
||||
|
||||
foreach (var owned in ownedLocations)
|
||||
{
|
||||
foreach (var coordinate in HexRange(owned.Q, owned.R, depth))
|
||||
{
|
||||
visibleCoordinates.Add(coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
var minQ = visibleCoordinates.Min(x => x.q);
|
||||
var maxQ = visibleCoordinates.Max(x => x.q);
|
||||
var minR = visibleCoordinates.Min(x => x.r);
|
||||
var maxR = visibleCoordinates.Max(x => x.r);
|
||||
|
||||
var existingLocations = await GetRangeAsync(minQ, maxQ, minR, maxR);
|
||||
var existingByCoordinate = existingLocations.ToDictionary(x => (x.Q, x.R), x => x);
|
||||
|
||||
return visibleCoordinates
|
||||
.OrderBy(x => x.q)
|
||||
.ThenBy(x => x.r)
|
||||
.Select(coordinate =>
|
||||
existingByCoordinate.TryGetValue(coordinate, out var existing)
|
||||
? LocationResponse.FromLocation(existing)
|
||||
: LocationResponse.FromCoordinate(coordinate.q, coordinate.r))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<LocationResponse?> GetVisibleLocationAsync(string username, int q, int r)
|
||||
{
|
||||
if (!await CanSeeAsync(username, q, r))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = await GetAsync(q, r);
|
||||
return existing is null
|
||||
? LocationResponse.FromCoordinate(q, r)
|
||||
: LocationResponse.FromLocation(existing);
|
||||
}
|
||||
|
||||
public async Task<bool> CanSeeAsync(string username, int q, int r)
|
||||
{
|
||||
var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
|
||||
if (ownedLocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth);
|
||||
}
|
||||
|
||||
private async Task<Location?> TryCreateHomeAsync(string username, int q, int r)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<Location>.Update
|
||||
.SetOnInsert(x => x.CreatedAtUtc, now)
|
||||
.Set(x => x.Terrain, "Command")
|
||||
.Set(x => x.Kind, "Home")
|
||||
.Set(x => x.OwnerUsername, username)
|
||||
.Set(x => x.Label, $"{username}'s Home")
|
||||
.Set(x => x.Level, 1)
|
||||
.Set(x => x.HomeAssignedAtUtc, now)
|
||||
.Set(x => x.UpdatedAtUtc, now);
|
||||
|
||||
try
|
||||
{
|
||||
return await _locations.FindOneAndUpdateAsync(
|
||||
x => x.Q == q && x.R == r && (x.OwnerUsername == null || x.OwnerUsername == username),
|
||||
update,
|
||||
new FindOneAndUpdateOptions<Location>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
}
|
||||
catch (MongoWriteException)
|
||||
{
|
||||
return await GetHomeAsync(username);
|
||||
}
|
||||
catch (MongoCommandException)
|
||||
{
|
||||
return await GetHomeAsync(username);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<(int q, int r)> HomeCandidates(string username)
|
||||
{
|
||||
var preferredDirection = Math.Abs(username.GetHashCode()) % 6;
|
||||
yield return (0, 0);
|
||||
|
||||
for (var radius = _homeMinDistance; radius <= _homeSearchRadius; radius++)
|
||||
{
|
||||
foreach (var candidate in HexRing(radius, preferredDirection))
|
||||
{
|
||||
yield return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(int q, int r)> HexRing(int radius, int directionOffset)
|
||||
{
|
||||
var directions = new (int q, int r)[]
|
||||
{
|
||||
(1, 0),
|
||||
(1, -1),
|
||||
(0, -1),
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, 1)
|
||||
};
|
||||
|
||||
var q = directions[(4 + directionOffset) % 6].q * radius;
|
||||
var r = directions[(4 + directionOffset) % 6].r * radius;
|
||||
|
||||
for (var side = 0; side < 6; side++)
|
||||
{
|
||||
var direction = directions[(side + directionOffset) % 6];
|
||||
for (var step = 0; step < radius; step++)
|
||||
{
|
||||
yield return (q, r);
|
||||
q += direction.q;
|
||||
r += direction.r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(int q, int r)> HexRange(int centerQ, int centerR, int radius)
|
||||
{
|
||||
for (var dq = -radius; dq <= radius; dq++)
|
||||
{
|
||||
var minDr = Math.Max(-radius, -dq - radius);
|
||||
var maxDr = Math.Min(radius, -dq + radius);
|
||||
for (var dr = minDr; dr <= maxDr; dr++)
|
||||
{
|
||||
yield return (centerQ + dq, centerR + dr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsFarEnoughFromHomes(int q, int r, List<Location> homeLocations) =>
|
||||
homeLocations.All(home => HexDistance(q, r, home.Q, home.R) >= _homeMinDistance);
|
||||
|
||||
private static int HexDistance(int q1, int r1, int q2, int r2)
|
||||
{
|
||||
var s1 = -q1 - r1;
|
||||
var s2 = -q2 - r2;
|
||||
return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2));
|
||||
}
|
||||
|
||||
private static string NormalizeTerrain(string terrain)
|
||||
{
|
||||
var trimmed = terrain.Trim();
|
||||
|
||||
Reference in New Issue
Block a user