1201 lines
46 KiB
C#
1201 lines
46 KiB
C#
using LocationsApi.Models;
|
|
using MongoDB.Driver;
|
|
|
|
namespace LocationsApi.Services;
|
|
|
|
public class LocationService
|
|
{
|
|
private readonly IMongoCollection<Location> _locations;
|
|
private readonly IMongoCollection<UnitInstance> _units;
|
|
private readonly IMongoCollection<LocationIntel> _intel;
|
|
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 _farmFoodPerMinute;
|
|
private readonly int _defaultLogCampBuildSeconds;
|
|
private readonly int _defaultMineBuildSeconds;
|
|
private readonly int _defaultBarracksBuildSeconds;
|
|
private readonly int _defaultFarmBuildSeconds;
|
|
private readonly int _barracksGoldCost;
|
|
private readonly int _barracksWoodCost;
|
|
private readonly int _barracksOreCost;
|
|
|
|
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");
|
|
_units = database.GetCollection<UnitInstance>("Units");
|
|
_intel = database.GetCollection<LocationIntel>("LocationIntel");
|
|
_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;
|
|
_farmFoodPerMinute = int.TryParse(cfg["Buildings:FarmFoodPerMinute"], out var farmFoodPerMinute)
|
|
? Math.Max(1, farmFoodPerMinute)
|
|
: 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;
|
|
_defaultBarracksBuildSeconds = int.TryParse(cfg["Buildings:BarracksBuildSeconds"], out var barracksBuildSeconds)
|
|
? Math.Max(1, barracksBuildSeconds)
|
|
: 60;
|
|
_defaultFarmBuildSeconds = int.TryParse(cfg["Buildings:FarmBuildSeconds"], out var farmBuildSeconds)
|
|
? Math.Max(1, farmBuildSeconds)
|
|
: 60;
|
|
_barracksGoldCost = int.TryParse(cfg["Buildings:BarracksGoldCost"], out var barracksGoldCost)
|
|
? Math.Max(0, barracksGoldCost)
|
|
: 500;
|
|
_barracksWoodCost = int.TryParse(cfg["Buildings:BarracksWoodCost"], out var barracksWoodCost)
|
|
? Math.Max(0, barracksWoodCost)
|
|
: 100;
|
|
_barracksOreCost = int.TryParse(cfg["Buildings:BarracksOreCost"], out var barracksOreCost)
|
|
? Math.Max(0, barracksOreCost)
|
|
: 100;
|
|
|
|
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")
|
|
}));
|
|
|
|
_intel.Indexes.CreateOne(new CreateIndexModel<LocationIntel>(
|
|
Builders<LocationIntel>.IndexKeys
|
|
.Ascending(x => x.Username)
|
|
.Ascending(x => x.Q)
|
|
.Ascending(x => x.R),
|
|
new CreateIndexOptions { Unique = true }));
|
|
}
|
|
|
|
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.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
if (!await CanScoutClaimAsync(username, q, r))
|
|
{
|
|
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<bool> CanScoutClaimAsync(string username, int q, int r)
|
|
{
|
|
var existing = await GetAsync(q, r);
|
|
if (existing is not null)
|
|
{
|
|
if (string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (string.Equals(existing.Kind, "Home", StringComparison.OrdinalIgnoreCase) ||
|
|
!string.IsNullOrWhiteSpace(existing.BuildingType))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return !await _units.Find(x => x.Q == q && x.R == r).AnyAsync();
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
if (buildingType == "Barracks" && string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidOperationException("Barracks cannot be built on the town hall.");
|
|
}
|
|
|
|
var requiredTerrain = RequiredTerrainForBuilding(buildingType);
|
|
if (requiredTerrain is not null && !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 = BuildSecondsSettingKey(buildingType);
|
|
var defaultBuildSeconds = DefaultBuildSeconds(buildingType);
|
|
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 || x.BuildingType == ""),
|
|
update,
|
|
new FindOneAndUpdateOptions<Location>
|
|
{
|
|
ReturnDocument = ReturnDocument.After
|
|
});
|
|
|
|
if (updated is null)
|
|
{
|
|
throw new InvalidOperationException("This location already has a building or construction in progress.");
|
|
}
|
|
|
|
if (buildingType == "Barracks")
|
|
{
|
|
var barracksGoldCost = await _settings.GetNonNegativeIntAsync(GameSettingsService.BarracksGoldCost, _barracksGoldCost);
|
|
var barracksWoodCost = await _settings.GetNonNegativeIntAsync(GameSettingsService.BarracksWoodCost, _barracksWoodCost);
|
|
var barracksOreCost = await _settings.GetNonNegativeIntAsync(GameSettingsService.BarracksOreCost, _barracksOreCost);
|
|
var spent = await _resources.TrySpendResourcesAsync(username, barracksGoldCost, barracksWoodCost, barracksOreCost);
|
|
if (!spent)
|
|
{
|
|
var rollback = Builders<Location>.Update
|
|
.Unset(x => x.BuildingType)
|
|
.Unset(x => x.BuildingStatus)
|
|
.Unset(x => x.BuildingStartedAtUtc)
|
|
.Unset(x => x.BuildingCompletesAtUtc)
|
|
.Unset(x => x.BuildingBuiltAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, DateTime.UtcNow);
|
|
|
|
await _locations.UpdateOneAsync(
|
|
x => x.Id == updated.Id && x.BuildingType == "Barracks" && x.BuildingStatus == "Building" && x.BuildingStartedAtUtc == now,
|
|
rollback);
|
|
|
|
throw new InvalidOperationException($"Not enough resources to build Barracks. Required: {barracksGoldCost} gold, {barracksWoodCost} wood, {barracksOreCost} ore.");
|
|
}
|
|
}
|
|
|
|
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.BuildingType == "Barracks" || x.BuildingType == "Farm") &&
|
|
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.StoredResourceCapacity, 10)
|
|
.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)
|
|
{
|
|
await RecalculateWorkerGenerationAsync(username);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<Location?> AssignWorkerAsync(string username, AssignWorkerRequest request)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
await CompletePendingWorkerAssignmentsAsync(username);
|
|
var location = await GetAsync(request.Q, request.R);
|
|
if (location is null ||
|
|
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) ||
|
|
!CanAssignWorkerToBuilding(location.BuildingType) ||
|
|
location.BuildingStatus == "Building" ||
|
|
!string.IsNullOrWhiteSpace(location.WorkerUnitId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var unit = await _units.Find(x =>
|
|
x.Id == request.UnitId &&
|
|
x.Username == username &&
|
|
x.UnitType == "Peasant" &&
|
|
x.Status == "Idle")
|
|
.FirstOrDefaultAsync();
|
|
if (unit is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var claimed = await _units.UpdateOneAsync(
|
|
x => x.Id == unit.Id && x.Status == "Idle",
|
|
Builders<UnitInstance>.Update
|
|
.Set(x => x.Status, "MovingToWork")
|
|
.Set(x => x.MovementRemaining, 0)
|
|
.Set(x => x.DestinationQ, request.Q)
|
|
.Set(x => x.DestinationR, request.R)
|
|
.Set(x => x.WorksiteQ, request.Q)
|
|
.Set(x => x.WorksiteR, request.R)
|
|
.Set(x => x.MovementStartedAtUtc, now)
|
|
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1))
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
if (claimed.ModifiedCount == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return location;
|
|
}
|
|
|
|
public async Task<Location?> DeassignWorkerAsync(string username, DeassignWorkerRequest request)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var location = await GetAsync(request.Q, request.R);
|
|
if (location is null ||
|
|
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) ||
|
|
string.IsNullOrWhiteSpace(location.WorkerUnitId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await _units.UpdateOneAsync(
|
|
x => x.Id == location.WorkerUnitId && x.Username == username,
|
|
Builders<UnitInstance>.Update
|
|
.Set(x => x.Q, request.Q)
|
|
.Set(x => x.R, request.R)
|
|
.Set(x => x.Status, "Idle")
|
|
.Unset(x => x.WorksiteQ)
|
|
.Unset(x => x.WorksiteR)
|
|
.Unset(x => x.DestinationQ)
|
|
.Unset(x => x.DestinationR)
|
|
.Unset(x => x.CarryingResourceType)
|
|
.Set(x => x.CarryingResourceAmount, 0)
|
|
.Unset(x => x.MovementStartedAtUtc)
|
|
.Unset(x => x.MovementCompletesAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
|
|
var updated = await _locations.FindOneAndUpdateAsync(
|
|
x => x.Id == location.Id,
|
|
Builders<Location>.Update
|
|
.Unset(x => x.WorkerUnitId)
|
|
.Unset(x => x.WorkerUnitType)
|
|
.Unset(x => x.DeliveryCycleStartedAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, now),
|
|
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
|
|
|
|
await RecalculateWorkerGenerationAsync(username);
|
|
return updated;
|
|
}
|
|
|
|
public async Task<Location?> DestroyBuildingAsync(string username, int q, int r)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var location = await GetAsync(q, r);
|
|
if (location is null ||
|
|
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) ||
|
|
string.IsNullOrWhiteSpace(location.BuildingType))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidOperationException("The town hall cannot be destroyed.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(location.WorkerUnitId))
|
|
{
|
|
await _units.UpdateOneAsync(
|
|
x => x.Id == location.WorkerUnitId && x.Username == username,
|
|
Builders<UnitInstance>.Update
|
|
.Set(x => x.Q, q)
|
|
.Set(x => x.R, r)
|
|
.Set(x => x.Status, "Idle")
|
|
.Set(x => x.MovementRemaining, 1)
|
|
.Unset(x => x.WorksiteQ)
|
|
.Unset(x => x.WorksiteR)
|
|
.Unset(x => x.DestinationQ)
|
|
.Unset(x => x.DestinationR)
|
|
.Unset(x => x.CarryingResourceType)
|
|
.Set(x => x.CarryingResourceAmount, 0)
|
|
.Unset(x => x.MovementStartedAtUtc)
|
|
.Unset(x => x.MovementCompletesAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
}
|
|
|
|
var updated = await _locations.FindOneAndUpdateAsync(
|
|
x => x.Id == location.Id && x.OwnerUsername == username && x.BuildingType != null && x.BuildingType != "",
|
|
Builders<Location>.Update
|
|
.Unset(x => x.BuildingType)
|
|
.Unset(x => x.BuildingStatus)
|
|
.Unset(x => x.BuildingStartedAtUtc)
|
|
.Unset(x => x.BuildingCompletesAtUtc)
|
|
.Unset(x => x.BuildingBuiltAtUtc)
|
|
.Unset(x => x.WorkerUnitId)
|
|
.Unset(x => x.WorkerUnitType)
|
|
.Set(x => x.StoredResourceAmount, 0)
|
|
.Set(x => x.StoredResourceCapacity, 10)
|
|
.Unset(x => x.LastResourceGeneratedAtUtc)
|
|
.Unset(x => x.DeliveryCycleStartedAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, now),
|
|
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
|
|
|
|
await RecalculateWorkerGenerationAsync(username);
|
|
return updated;
|
|
}
|
|
|
|
public async Task<Location?> AbandonAsync(string username, int q, int r)
|
|
{
|
|
var location = await GetAsync(q, r);
|
|
if (location is null ||
|
|
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidOperationException("The town hall cannot be given up.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(location.BuildingType) || !string.IsNullOrWhiteSpace(location.WorkerUnitId))
|
|
{
|
|
throw new InvalidOperationException("Destroy the building before giving up this territory.");
|
|
}
|
|
|
|
var hasUnits = await _units.Find(x =>
|
|
x.Username == username &&
|
|
x.Q == q &&
|
|
x.R == r)
|
|
.AnyAsync();
|
|
if (hasUnits)
|
|
{
|
|
throw new InvalidOperationException("Move or dismiss units on this tile before giving up the territory.");
|
|
}
|
|
|
|
return await _locations.FindOneAndUpdateAsync(
|
|
x => x.Id == location.Id && x.OwnerUsername == username,
|
|
Builders<Location>.Update
|
|
.Set(x => x.Kind, "Open")
|
|
.Unset(x => x.OwnerUsername)
|
|
.Unset(x => x.Label)
|
|
.Set(x => x.UpdatedAtUtc, DateTime.UtcNow),
|
|
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
|
|
}
|
|
|
|
private async Task RecalculateWorkerGenerationAsync(string username)
|
|
{
|
|
var built = await _locations.Find(x =>
|
|
x.OwnerUsername == username &&
|
|
!string.IsNullOrWhiteSpace(x.WorkerUnitId) &&
|
|
(x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "") &&
|
|
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm"))
|
|
.ToListAsync();
|
|
|
|
var woodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.LogCampWoodPerMinute, _logCampWoodPerMinute);
|
|
var orePerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.MineOrePerMinute, _mineOrePerMinute);
|
|
var foodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, _farmFoodPerMinute);
|
|
await _resources.SetBuildingGenerationAsync(
|
|
username,
|
|
built.Count(x => x.BuildingType == "LogCamp") * woodPerMinute,
|
|
built.Count(x => x.BuildingType == "Mine") * orePerMinute,
|
|
built.Count(x => x.BuildingType == "Farm") * foodPerMinute);
|
|
}
|
|
|
|
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);
|
|
await CompletePendingWorkerAssignmentsAsync(username);
|
|
await RecalculateWorkerGenerationAsync(username);
|
|
|
|
var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
|
|
if (ownedLocations.Count == 0)
|
|
{
|
|
ownedLocations.Add(await AssignHomeAsync(username));
|
|
}
|
|
|
|
var playerUnits = await _units.Find(x => x.Username == username).ToListAsync();
|
|
var depth = Math.Max(0, visibilityDepth ?? _visibilityDepth);
|
|
var visibleCoordinates = new HashSet<(int q, int r)>();
|
|
var occupiedCoordinates = playerUnits.Select(x => (x.Q, x.R)).ToHashSet();
|
|
|
|
foreach (var owned in ownedLocations)
|
|
{
|
|
foreach (var coordinate in HexRange(owned.Q, owned.R, depth))
|
|
{
|
|
visibleCoordinates.Add(coordinate);
|
|
}
|
|
}
|
|
|
|
foreach (var unit in playerUnits)
|
|
{
|
|
foreach (var coordinate in HexRange(unit.Q, unit.R, 1))
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
await RefreshIntelAsync(username, occupiedCoordinates, existingByCoordinate);
|
|
var intelByCoordinate = await GetIntelByCoordinateAsync(username, visibleCoordinates);
|
|
|
|
return visibleCoordinates
|
|
.OrderBy(x => x.q)
|
|
.ThenBy(x => x.r)
|
|
.Select(coordinate =>
|
|
existingByCoordinate.TryGetValue(coordinate, out var existing)
|
|
? VisibleResponseFor(username, existing, occupiedCoordinates, intelByCoordinate)
|
|
: throw new InvalidOperationException($"Visible location {coordinate.q},{coordinate.r} was not generated."))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<LocationResponse?> GetVisibleLocationAsync(string username, int q, int r)
|
|
{
|
|
await CompletePendingWorkerAssignmentsAsync(username);
|
|
if (!await CanSeeAsync(username, q, r))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var existing = await GetAsync(q, r);
|
|
if (existing is not null)
|
|
{
|
|
var occupiedCoordinates = await GetOccupiedCoordinatesAsync(username);
|
|
await RefreshIntelAsync(username, occupiedCoordinates, new Dictionary<(int Q, int R), Location> { [(existing.Q, existing.R)] = existing });
|
|
var intelByCoordinate = await GetIntelByCoordinateAsync(username, new[] { (q, r) });
|
|
return VisibleResponseFor(username, existing, occupiedCoordinates, intelByCoordinate);
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
private async Task CompletePendingWorkerAssignmentsAsync(string username)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var movingWorkers = await _units.Find(x =>
|
|
x.Username == username &&
|
|
x.UnitType == "Peasant" &&
|
|
x.Status == "MovingToWork" &&
|
|
x.MovementCompletesAtUtc != null &&
|
|
x.MovementCompletesAtUtc <= now)
|
|
.ToListAsync();
|
|
|
|
foreach (var worker in movingWorkers)
|
|
{
|
|
if (worker.DestinationQ is null || worker.DestinationR is null)
|
|
{
|
|
await _units.UpdateOneAsync(
|
|
x => x.Id == worker.Id,
|
|
Builders<UnitInstance>.Update
|
|
.Set(x => x.Status, "Idle")
|
|
.Unset(x => x.MovementStartedAtUtc)
|
|
.Unset(x => x.MovementCompletesAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
continue;
|
|
}
|
|
|
|
var destinationQ = worker.DestinationQ.Value;
|
|
var destinationR = worker.DestinationR.Value;
|
|
var currentQ = worker.Q;
|
|
var currentR = worker.R;
|
|
var elapsedSteps = Math.Max(1, (int)Math.Floor((now - worker.MovementCompletesAtUtc!.Value).TotalMinutes) + 1);
|
|
var usedSteps = 0;
|
|
var movementBlocked = false;
|
|
|
|
while (elapsedSteps > 0 && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
|
|
{
|
|
var nextStep = await NextPeasantStepTowardAsync(username, currentQ, currentR, destinationQ, destinationR);
|
|
if (nextStep is null)
|
|
{
|
|
await _units.UpdateOneAsync(
|
|
x => x.Id == worker.Id,
|
|
Builders<UnitInstance>.Update
|
|
.Set(x => x.Status, "Idle")
|
|
.Unset(x => x.DestinationQ)
|
|
.Unset(x => x.DestinationR)
|
|
.Unset(x => x.MovementStartedAtUtc)
|
|
.Unset(x => x.MovementCompletesAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
movementBlocked = true;
|
|
break;
|
|
}
|
|
|
|
currentQ = nextStep.Value.q;
|
|
currentR = nextStep.Value.r;
|
|
elapsedSteps--;
|
|
usedSteps++;
|
|
}
|
|
|
|
if (movementBlocked)
|
|
continue;
|
|
|
|
if (HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
|
|
{
|
|
await _units.UpdateOneAsync(
|
|
x => x.Id == worker.Id,
|
|
Builders<UnitInstance>.Update
|
|
.Set(x => x.Q, currentQ)
|
|
.Set(x => x.R, currentR)
|
|
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1))
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
continue;
|
|
}
|
|
|
|
var assignedAt = worker.MovementCompletesAtUtc.Value.AddMinutes(Math.Max(0, usedSteps - 1));
|
|
var location = await _locations.FindOneAndUpdateAsync(
|
|
x => x.OwnerUsername == username &&
|
|
x.Q == destinationQ &&
|
|
x.R == destinationR &&
|
|
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm") &&
|
|
x.BuildingStatus != "Building" &&
|
|
(x.WorkerUnitId == null || x.WorkerUnitId == ""),
|
|
Builders<Location>.Update
|
|
.Set(x => x.WorkerUnitId, worker.Id)
|
|
.Set(x => x.WorkerUnitType, "Peasant")
|
|
.Set(x => x.LastResourceGeneratedAtUtc, assignedAt)
|
|
.Set(x => x.DeliveryCycleStartedAtUtc, assignedAt)
|
|
.Set(x => x.UpdatedAtUtc, now),
|
|
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
|
|
|
|
var status = location is null ? "Idle" : "Working";
|
|
await _units.UpdateOneAsync(
|
|
x => x.Id == worker.Id,
|
|
Builders<UnitInstance>.Update
|
|
.Set(x => x.Q, destinationQ)
|
|
.Set(x => x.R, destinationR)
|
|
.Set(x => x.Status, status)
|
|
.Set(x => x.LastMovementRefillAtUtc, now)
|
|
.Unset(x => x.DestinationQ)
|
|
.Unset(x => x.DestinationR)
|
|
.Unset(x => x.MovementStartedAtUtc)
|
|
.Unset(x => x.MovementCompletesAtUtc)
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
}
|
|
|
|
if (movingWorkers.Count > 0)
|
|
{
|
|
await RecalculateWorkerGenerationAsync(username);
|
|
}
|
|
}
|
|
|
|
private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, int fromQ, int fromR, int toQ, int toR)
|
|
{
|
|
var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
|
|
var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet();
|
|
if (!ownedCoordinates.Contains((toQ, toR)))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var start = (fromQ, fromR);
|
|
var target = (toQ, toR);
|
|
var queue = new Queue<(int q, int r)>();
|
|
var previous = new Dictionary<(int q, int r), (int q, int r)>();
|
|
var visited = new HashSet<(int q, int r)> { start };
|
|
queue.Enqueue(start);
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var current = queue.Dequeue();
|
|
if (current == target)
|
|
{
|
|
break;
|
|
}
|
|
|
|
foreach (var neighbor in NeighborCoordinates(current.q, current.r)
|
|
.Select(x => (x.Q, x.R))
|
|
.OrderBy(x => HexDistance(x.Q, x.R, toQ, toR))
|
|
.ThenBy(x => x.Q)
|
|
.ThenBy(x => x.R))
|
|
{
|
|
if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
previous[neighbor] = current;
|
|
queue.Enqueue(neighbor);
|
|
}
|
|
}
|
|
|
|
if (!visited.Contains(target))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var step = target;
|
|
while (previous.TryGetValue(step, out var before) && before != start)
|
|
{
|
|
step = before;
|
|
}
|
|
|
|
return step == start ? target : step;
|
|
}
|
|
|
|
private static bool CanAssignWorkerToBuilding(string? buildingType)
|
|
{
|
|
return buildingType == "LogCamp" || buildingType == "Mine" || buildingType == "Farm";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var playerUnits = await _units.Find(x => x.Username == username).ToListAsync();
|
|
return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1);
|
|
}
|
|
|
|
public async Task<bool> IsAdjacentToOwnedAsync(string username, int q, int r)
|
|
{
|
|
var neighbors = NeighborCoordinates(q, r).ToList();
|
|
return await _locations.Find(x =>
|
|
x.OwnerUsername == username &&
|
|
neighbors.Any(n => n.Q == x.Q && n.R == x.R))
|
|
.AnyAsync();
|
|
}
|
|
|
|
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<HashSet<(int Q, int R)>> GetOccupiedCoordinatesAsync(string username)
|
|
{
|
|
var units = await _units.Find(x => x.Username == username).ToListAsync();
|
|
return units.Select(x => (x.Q, x.R)).ToHashSet();
|
|
}
|
|
|
|
private async Task<Dictionary<(int Q, int R), LocationIntel>> GetIntelByCoordinateAsync(
|
|
string username,
|
|
IEnumerable<(int q, int r)> coordinates)
|
|
{
|
|
var coordinateList = coordinates.Select(x => (x.q, x.r)).ToList();
|
|
if (coordinateList.Count == 0)
|
|
{
|
|
return new Dictionary<(int Q, int R), LocationIntel>();
|
|
}
|
|
|
|
var minQ = coordinateList.Min(x => x.q);
|
|
var maxQ = coordinateList.Max(x => x.q);
|
|
var minR = coordinateList.Min(x => x.r);
|
|
var maxR = coordinateList.Max(x => x.r);
|
|
var coordinateSet = coordinateList.ToHashSet();
|
|
var records = await _intel.Find(x =>
|
|
x.Username == username &&
|
|
x.Q >= minQ && x.Q <= maxQ &&
|
|
x.R >= minR && x.R <= maxR)
|
|
.ToListAsync();
|
|
|
|
return records
|
|
.Where(x => coordinateSet.Contains((x.Q, x.R)))
|
|
.ToDictionary(x => (x.Q, x.R), x => x);
|
|
}
|
|
|
|
private async Task RefreshIntelAsync(
|
|
string username,
|
|
HashSet<(int Q, int R)> occupiedCoordinates,
|
|
Dictionary<(int Q, int R), Location> existingByCoordinate)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
foreach (var coordinate in occupiedCoordinates)
|
|
{
|
|
if (!existingByCoordinate.TryGetValue(coordinate, out var location))
|
|
{
|
|
location = await GetAsync(coordinate.Q, coordinate.R);
|
|
if (location is null)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var update = Builders<LocationIntel>.Update
|
|
.SetOnInsert(x => x.CreatedAtUtc, now)
|
|
.Set(x => x.Terrain, location.Terrain)
|
|
.Set(x => x.Kind, location.Kind)
|
|
.Set(x => x.OwnerUsername, location.OwnerUsername)
|
|
.Set(x => x.Label, location.Label)
|
|
.Set(x => x.Level, location.Level)
|
|
.Set(x => x.BuildingType, location.BuildingType)
|
|
.Set(x => x.BuildingStatus, location.BuildingStatus)
|
|
.Set(x => x.BuildingStartedAtUtc, location.BuildingStartedAtUtc)
|
|
.Set(x => x.BuildingCompletesAtUtc, location.BuildingCompletesAtUtc)
|
|
.Set(x => x.BuildingBuiltAtUtc, location.BuildingBuiltAtUtc)
|
|
.Set(x => x.WorkerUnitId, location.WorkerUnitId)
|
|
.Set(x => x.WorkerUnitType, location.WorkerUnitType)
|
|
.Set(x => x.StoredResourceAmount, location.StoredResourceAmount)
|
|
.Set(x => x.StoredResourceCapacity, location.StoredResourceCapacity)
|
|
.Set(x => x.LastResourceGeneratedAtUtc, location.LastResourceGeneratedAtUtc)
|
|
.Set(x => x.HomeAssignedAtUtc, location.HomeAssignedAtUtc)
|
|
.Set(x => x.ObservedAtUtc, now)
|
|
.Set(x => x.UpdatedAtUtc, now);
|
|
|
|
await _intel.UpdateOneAsync(
|
|
x => x.Username == username && x.Q == coordinate.Q && x.R == coordinate.R,
|
|
update,
|
|
new UpdateOptions { IsUpsert = true });
|
|
}
|
|
}
|
|
|
|
private static LocationResponse VisibleResponseFor(
|
|
string username,
|
|
Location location,
|
|
HashSet<(int Q, int R)> occupiedCoordinates,
|
|
Dictionary<(int Q, int R), LocationIntel> intelByCoordinate)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(location.OwnerUsername) ||
|
|
string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) ||
|
|
occupiedCoordinates.Contains((location.Q, location.R)))
|
|
{
|
|
return LocationResponse.FromLocation(location);
|
|
}
|
|
|
|
return intelByCoordinate.TryGetValue((location.Q, location.R), out var intel)
|
|
? LocationResponse.FromIntel(intel)
|
|
: LocationResponse.FromLocation(location, revealDetails: false);
|
|
}
|
|
|
|
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",
|
|
"barracks" => "Barracks",
|
|
"farm" => "Farm",
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static string? RequiredTerrainForBuilding(string buildingType) =>
|
|
buildingType switch
|
|
{
|
|
"LogCamp" => "Forest",
|
|
"Mine" => "Ore",
|
|
"Farm" => "Field",
|
|
"Barracks" => null,
|
|
_ => null
|
|
};
|
|
|
|
private static string BuildSecondsSettingKey(string buildingType) =>
|
|
buildingType switch
|
|
{
|
|
"LogCamp" => GameSettingsService.LogCampBuildSeconds,
|
|
"Mine" => GameSettingsService.MineBuildSeconds,
|
|
"Barracks" => GameSettingsService.BarracksBuildSeconds,
|
|
"Farm" => GameSettingsService.FarmBuildSeconds,
|
|
_ => GameSettingsService.LogCampBuildSeconds
|
|
};
|
|
|
|
private int DefaultBuildSeconds(string buildingType) =>
|
|
buildingType switch
|
|
{
|
|
"LogCamp" => _defaultLogCampBuildSeconds,
|
|
"Mine" => _defaultMineBuildSeconds,
|
|
"Barracks" => _defaultBarracksBuildSeconds,
|
|
"Farm" => _defaultFarmBuildSeconds,
|
|
_ => 60
|
|
};
|
|
}
|