Files
territory-autobattler/microservices/LocationsApi/Services/LocationService.cs
T
2026-05-18 21:37:48 -05:00

589 lines
21 KiB
C#

using LocationsApi.Models;
using MongoDB.Driver;
namespace LocationsApi.Services;
public class LocationService
{
private readonly IMongoCollection<Location> _locations;
private readonly ResourceService _resources;
private readonly GameSettingsService _settings;
private readonly int _homeMinDistance;
private readonly int _homeSearchRadius;
private readonly int _visibilityDepth;
private readonly int _logCampWoodPerMinute;
private readonly int _mineOrePerMinute;
private readonly int _defaultLogCampBuildSeconds;
private readonly int _defaultMineBuildSeconds;
public LocationService(IConfiguration cfg, ResourceService resources, GameSettingsService settings)
{
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler";
var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName);
_locations = database.GetCollection<Location>("Locations");
_resources = resources;
_settings = settings;
_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;
_logCampWoodPerMinute = int.TryParse(cfg["Buildings:LogCampWoodPerMinute"], out var logCampWoodPerMinute)
? Math.Max(1, logCampWoodPerMinute)
: 5;
_mineOrePerMinute = int.TryParse(cfg["Buildings:MineOrePerMinute"], out var mineOrePerMinute)
? Math.Max(1, mineOrePerMinute)
: 5;
_defaultLogCampBuildSeconds = int.TryParse(cfg["Buildings:LogCampBuildSeconds"], out var logCampBuildSeconds)
? Math.Max(1, logCampBuildSeconds)
: 60;
_defaultMineBuildSeconds = int.TryParse(cfg["Buildings:MineBuildSeconds"], out var mineBuildSeconds)
? Math.Max(1, mineBuildSeconds)
: 60;
var coordinateIndex = Builders<Location>.IndexKeys
.Ascending(x => x.Q)
.Ascending(x => x.R);
_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() =>
_locations.Find(FilterDefinition<Location>.Empty)
.SortBy(x => x.Q)
.ThenBy(x => x.R)
.ToListAsync();
public Task<List<Location>> GetRangeAsync(int minQ, int maxQ, int minR, int maxR) =>
_locations.Find(x => x.Q >= minQ && x.Q <= maxQ && x.R >= minR && x.R <= maxR)
.SortBy(x => x.Q)
.ThenBy(x => x.R)
.ToListAsync();
public async Task<Location?> GetAsync(int q, int r) =>
await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync();
public async Task<Location> UpsertAsync(UpsertLocationRequest request, string? ownerUsername = null)
{
var now = DateTime.UtcNow;
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)
.Set(x => x.Kind, "Claimed");
}
var options = new FindOneAndUpdateOptions<Location>
{
IsUpsert = true,
ReturnDocument = ReturnDocument.After
};
return await _locations.FindOneAndUpdateAsync(
x => x.Q == request.Q && x.R == request.R,
update,
options);
}
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;
}
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
}
public async Task<Location?> BuildAsync(BuildLocationRequest request, string username)
{
var buildingType = NormalizeBuildingType(request.BuildingType);
if (buildingType is null)
{
throw new ArgumentException("Unsupported building type.", nameof(request.BuildingType));
}
var location = await GetAsync(request.Q, request.R);
if (location is null ||
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!string.IsNullOrWhiteSpace(location.BuildingType))
{
throw new InvalidOperationException("This location already has a building or construction in progress.");
}
var requiredTerrain = buildingType == "LogCamp" ? "Forest" : "Ore";
if (!string.Equals(location.Terrain, requiredTerrain, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"{buildingType} can only be built on {requiredTerrain} territory.");
}
await CompleteFinishedBuildingsAsync(username);
var now = DateTime.UtcNow;
var settingKey = buildingType == "LogCamp"
? GameSettingsService.LogCampBuildSeconds
: GameSettingsService.MineBuildSeconds;
var defaultBuildSeconds = buildingType == "LogCamp"
? _defaultLogCampBuildSeconds
: _defaultMineBuildSeconds;
var buildSeconds = await _settings.GetIntAsync(settingKey, defaultBuildSeconds);
var update = Builders<Location>.Update
.Set(x => x.BuildingType, buildingType)
.Set(x => x.BuildingStatus, "Building")
.Set(x => x.BuildingStartedAtUtc, now)
.Set(x => x.BuildingCompletesAtUtc, now.AddSeconds(buildSeconds))
.Set(x => x.UpdatedAtUtc, now);
var updated = await _locations.FindOneAndUpdateAsync(
x => x.Id == location.Id && x.OwnerUsername == username && x.BuildingType == null,
update,
new FindOneAndUpdateOptions<Location>
{
ReturnDocument = ReturnDocument.After
});
if (updated is null)
{
throw new InvalidOperationException("This location already has a building or construction in progress.");
}
return updated;
}
public async Task CompleteFinishedBuildingsAsync(string username)
{
var now = DateTime.UtcNow;
var readyBuildings = await _locations.Find(x =>
x.OwnerUsername == username &&
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine") &&
x.BuildingStatus == "Building" &&
x.BuildingCompletesAtUtc <= now)
.ToListAsync();
foreach (var building in readyBuildings)
{
var update = Builders<Location>.Update
.Set(x => x.BuildingStatus, "Built")
.Set(x => x.BuildingBuiltAtUtc, now)
.Set(x => x.UpdatedAtUtc, now);
var updated = await _locations.FindOneAndUpdateAsync(
x => x.Id == building.Id && x.BuildingStatus == "Building",
update,
new FindOneAndUpdateOptions<Location>
{
ReturnDocument = ReturnDocument.After
});
if (updated is not null)
{
if (updated.BuildingType == "LogCamp")
{
await _resources.AddWoodGenerationAsync(username, _logCampWoodPerMinute);
}
else if (updated.BuildingType == "Mine")
{
await _resources.AddOreGenerationAsync(username, _mineOrePerMinute);
}
}
}
}
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)
{
await _resources.EnsureAsync(username);
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)
{
await _resources.EnsureAsync(username);
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)
{
await CompleteFinishedBuildingsAsync(username);
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);
var missingCoordinates = visibleCoordinates
.Where(coordinate => !existingByCoordinate.ContainsKey(coordinate))
.OrderBy(coordinate => HexDistance(0, 0, coordinate.q, coordinate.r))
.ThenBy(coordinate => coordinate.q)
.ThenBy(coordinate => coordinate.r)
.ToList();
if (missingCoordinates.Count > 0)
{
var generatedLocations = await GenerateVisibleLocationsAsync(missingCoordinates, existingByCoordinate);
foreach (var generated in generatedLocations)
{
existingByCoordinate[(generated.Q, generated.R)] = generated;
}
}
return visibleCoordinates
.OrderBy(x => x.q)
.ThenBy(x => x.r)
.Select(coordinate =>
existingByCoordinate.TryGetValue(coordinate, out var existing)
? LocationResponse.FromLocation(existing)
: throw new InvalidOperationException($"Visible location {coordinate.q},{coordinate.r} was not generated."))
.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);
if (existing is not null)
{
return LocationResponse.FromLocation(existing);
}
var nearbyLocations = await GetRangeAsync(q - 1, q + 1, r - 1, r + 1);
var nearbyByCoordinate = nearbyLocations.ToDictionary(x => (x.Q, x.R), x => x);
var generated = await GenerateVisibleLocationsAsync(
new List<(int q, int r)> { (q, r) },
nearbyByCoordinate);
return generated.Count == 0 ? null : LocationResponse.FromLocation(generated[0]);
}
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 async Task<List<Location>> GenerateVisibleLocationsAsync(
List<(int q, int r)> missingCoordinates,
Dictionary<(int Q, int R), Location> existingByCoordinate)
{
var generated = new List<Location>();
var now = DateTime.UtcNow;
foreach (var coordinate in missingCoordinates)
{
var terrain = GenerateTerrain(coordinate.q, coordinate.r, existingByCoordinate);
var location = new Location
{
Q = coordinate.q,
R = coordinate.r,
Terrain = terrain,
Kind = "Open",
Level = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
try
{
await _locations.InsertOneAsync(location);
generated.Add(location);
existingByCoordinate[(location.Q, location.R)] = location;
}
catch (MongoWriteException)
{
var existing = await GetAsync(coordinate.q, coordinate.r);
if (existing is not null)
{
generated.Add(existing);
existingByCoordinate[(existing.Q, existing.R)] = existing;
}
}
}
return generated;
}
private static string GenerateTerrain(
int q,
int r,
Dictionary<(int Q, int R), Location> existingByCoordinate)
{
var neighborTerrains = NeighborCoordinates(q, r)
.Select(coordinate => existingByCoordinate.TryGetValue(coordinate, out var location) ? location.Terrain : "")
.Where(terrain => !string.IsNullOrWhiteSpace(terrain) && terrain != "Command")
.ToList();
var weights = new Dictionary<string, int>
{
["Field"] = 48,
["Forest"] = 24,
["Highland"] = 16,
["Ore"] = 12
};
foreach (var terrain in neighborTerrains)
{
if (!weights.ContainsKey(terrain))
{
continue;
}
weights[terrain] += terrain switch
{
"Forest" => 18,
"Highland" => 14,
"Ore" => 8,
_ => 10
};
}
var hash = StableCoordinateHash(q, r);
var totalWeight = weights.Values.Sum();
var roll = hash % totalWeight;
foreach (var option in weights)
{
if (roll < option.Value)
{
return option.Key;
}
roll -= option.Value;
}
return "Field";
}
private static IEnumerable<(int Q, int R)> NeighborCoordinates(int q, int r)
{
yield return (q + 1, r);
yield return (q + 1, r - 1);
yield return (q, r - 1);
yield return (q - 1, r);
yield return (q - 1, r + 1);
yield return (q, r + 1);
}
private static int StableCoordinateHash(int q, int r)
{
unchecked
{
var hash = 17;
hash = hash * 31 + q;
hash = hash * 31 + r;
hash = hash * 31 + 7919;
return Math.Abs(hash);
}
}
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();
return trimmed switch
{
"Command" or "Field" or "Forest" or "Ore" or "Highland" => trimmed,
_ => "Field"
};
}
private static string? NormalizeBuildingType(string buildingType)
{
var trimmed = buildingType.Trim();
return trimmed.ToLowerInvariant() switch
{
"logcamp" or "log camp" => "LogCamp",
"mine" => "Mine",
_ => null
};
}
}