adding resource limits and archers
This commit is contained in:
@@ -45,7 +45,7 @@ public class UnitsController : ControllerBase
|
||||
var training = await _units.TrainAsync(username, request.UnitType);
|
||||
if (training is null)
|
||||
{
|
||||
return BadRequest(new { error = "Not enough gold." });
|
||||
return BadRequest(new { error = "Not enough resources." });
|
||||
}
|
||||
|
||||
return Ok(UnitTrainingResponse.FromTraining(training));
|
||||
@@ -54,5 +54,9 @@ public class UnitsController : ControllerBase
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,13 @@ public class PlayerResources
|
||||
public string Username { get; set; } = "";
|
||||
public int Gold { get; set; } = 100;
|
||||
public int GoldPerMinute { get; set; } = 10;
|
||||
public int GoldCapacity { get; set; } = 500;
|
||||
public int Wood { get; set; }
|
||||
public int WoodPerMinute { get; set; }
|
||||
public int WoodCapacity { get; set; } = 100;
|
||||
public int Ore { get; set; }
|
||||
public int OrePerMinute { get; set; }
|
||||
public int OreCapacity { get; set; } = 100;
|
||||
public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastWoodGeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastOreGeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -5,10 +5,13 @@ public class PlayerResourcesResponse
|
||||
public string Username { get; set; } = "";
|
||||
public int Gold { get; set; }
|
||||
public int GoldPerMinute { get; set; }
|
||||
public int GoldCapacity { get; set; }
|
||||
public int Wood { get; set; }
|
||||
public int WoodPerMinute { get; set; }
|
||||
public int WoodCapacity { get; set; }
|
||||
public int Ore { get; set; }
|
||||
public int OrePerMinute { get; set; }
|
||||
public int OreCapacity { get; set; }
|
||||
public DateTime LastGoldGeneratedAtUtc { get; set; }
|
||||
public DateTime LastWoodGeneratedAtUtc { get; set; }
|
||||
public DateTime LastOreGeneratedAtUtc { get; set; }
|
||||
@@ -19,10 +22,13 @@ public class PlayerResourcesResponse
|
||||
Username = resources.Username,
|
||||
Gold = resources.Gold,
|
||||
GoldPerMinute = resources.GoldPerMinute,
|
||||
GoldCapacity = resources.GoldCapacity,
|
||||
Wood = resources.Wood,
|
||||
WoodPerMinute = resources.WoodPerMinute,
|
||||
WoodCapacity = resources.WoodCapacity,
|
||||
Ore = resources.Ore,
|
||||
OrePerMinute = resources.OrePerMinute,
|
||||
OreCapacity = resources.OreCapacity,
|
||||
LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc,
|
||||
LastWoodGeneratedAtUtc = resources.LastWoodGeneratedAtUtc,
|
||||
LastOreGeneratedAtUtc = resources.LastOreGeneratedAtUtc,
|
||||
|
||||
@@ -11,6 +11,8 @@ public class UnitTrainingResponse
|
||||
public string Username { get; set; } = "";
|
||||
public string UnitType { get; set; } = "";
|
||||
public int GoldCost { get; set; }
|
||||
public int WoodCost { get; set; }
|
||||
public int OreCost { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public DateTime StartedAtUtc { get; set; }
|
||||
public DateTime CompletesAtUtc { get; set; }
|
||||
@@ -22,6 +24,8 @@ public class UnitTrainingResponse
|
||||
Username = training.Username,
|
||||
UnitType = training.UnitType,
|
||||
GoldCost = training.GoldCost,
|
||||
WoodCost = training.WoodCost,
|
||||
OreCost = training.OreCost,
|
||||
Status = training.Status,
|
||||
StartedAtUtc = training.StartedAtUtc,
|
||||
CompletesAtUtc = training.CompletesAtUtc,
|
||||
@@ -32,5 +36,6 @@ public class UnitTrainingResponse
|
||||
public class PlayerUnitsResponse
|
||||
{
|
||||
public int CompletedScouts { get; set; }
|
||||
public int CompletedArchers { get; set; }
|
||||
public List<UnitTrainingResponse> Training { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public class UnitTraining
|
||||
public string Username { get; set; } = "";
|
||||
public string UnitType { get; set; } = "Scout";
|
||||
public int GoldCost { get; set; } = 50;
|
||||
public int WoodCost { get; set; }
|
||||
public int OreCost { get; set; }
|
||||
public string Status { get; set; } = "Training";
|
||||
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime CompletesAtUtc { get; set; } = DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
@@ -28,16 +28,18 @@ locations are unowned `Open` records until claimed by a player.
|
||||
|
||||
Player resource state is stored server-side in the `PlayerResources` collection.
|
||||
New players start with 100 gold. A player's headquarters generates 10 gold per
|
||||
minute. Log Camps generate wood and Mines generate ore. Generation is applied
|
||||
lazily when the resource record is read.
|
||||
minute. The starting town hall stores up to 500 gold, 100 wood, and 100 ore.
|
||||
Log Camps generate wood and Mines generate ore. Generation is applied lazily
|
||||
when the resource record is read and clamps at the current storage capacity.
|
||||
|
||||
## Units
|
||||
|
||||
Unit training is stored server-side in the `UnitTraining` collection. The first
|
||||
unit type is `Scout`, which costs 50 gold and takes 60 seconds to train.
|
||||
Finished training is completed lazily when unit state is read. Claiming an
|
||||
unclaimed visible location consumes one completed Scout by deleting that Scout's
|
||||
training record.
|
||||
Unit training is stored server-side in the `UnitTraining` collection. `Scout`
|
||||
costs 50 gold and takes 60 seconds to train. `Archer` requires a completed
|
||||
Barracks, costs 100 gold and 100 wood, and takes 60 seconds to train. Finished
|
||||
training is completed lazily when unit state is read. Claiming an unclaimed
|
||||
visible location consumes one completed Scout by deleting that Scout's training
|
||||
record.
|
||||
|
||||
## Buildings
|
||||
|
||||
@@ -53,6 +55,7 @@ Timer settings are stored in the MongoDB `GameSettings` collection and are
|
||||
seeded on first read:
|
||||
|
||||
- `ScoutTrainingSeconds`
|
||||
- `ArcherTrainingSeconds`
|
||||
- `LogCampBuildSeconds`
|
||||
- `MineBuildSeconds`
|
||||
- `BarracksBuildSeconds`
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace LocationsApi.Services;
|
||||
public class GameSettingsService
|
||||
{
|
||||
public const string ScoutTrainingSeconds = "ScoutTrainingSeconds";
|
||||
public const string ArcherTrainingSeconds = "ArcherTrainingSeconds";
|
||||
public const string LogCampBuildSeconds = "LogCampBuildSeconds";
|
||||
public const string MineBuildSeconds = "MineBuildSeconds";
|
||||
public const string BarracksBuildSeconds = "BarracksBuildSeconds";
|
||||
|
||||
@@ -8,6 +8,9 @@ public class ResourceService
|
||||
private readonly IMongoCollection<PlayerResources> _resources;
|
||||
private readonly int _startingGold;
|
||||
private readonly int _headquartersGoldPerMinute;
|
||||
private readonly int _townHallGoldCapacity;
|
||||
private readonly int _townHallWoodCapacity;
|
||||
private readonly int _townHallOreCapacity;
|
||||
|
||||
public ResourceService(IConfiguration cfg)
|
||||
{
|
||||
@@ -20,6 +23,15 @@ public class ResourceService
|
||||
_headquartersGoldPerMinute = int.TryParse(cfg["Resources:HeadquartersGoldPerMinute"], out var goldPerMinute)
|
||||
? Math.Max(0, goldPerMinute)
|
||||
: 10;
|
||||
_townHallGoldCapacity = int.TryParse(cfg["Resources:TownHallGoldCapacity"], out var goldCapacity)
|
||||
? Math.Max(0, goldCapacity)
|
||||
: 500;
|
||||
_townHallWoodCapacity = int.TryParse(cfg["Resources:TownHallWoodCapacity"], out var woodCapacity)
|
||||
? Math.Max(0, woodCapacity)
|
||||
: 100;
|
||||
_townHallOreCapacity = int.TryParse(cfg["Resources:TownHallOreCapacity"], out var oreCapacity)
|
||||
? Math.Max(0, oreCapacity)
|
||||
: 100;
|
||||
|
||||
_resources.Indexes.CreateOne(new CreateIndexModel<PlayerResources>(
|
||||
Builders<PlayerResources>.IndexKeys.Ascending(x => x.Username),
|
||||
@@ -31,7 +43,7 @@ public class ResourceService
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<PlayerResources>.Update
|
||||
.SetOnInsert(x => x.Username, username)
|
||||
.SetOnInsert(x => x.Gold, _startingGold)
|
||||
.SetOnInsert(x => x.Gold, Math.Min(_startingGold, _townHallGoldCapacity))
|
||||
.SetOnInsert(x => x.Wood, 0)
|
||||
.SetOnInsert(x => x.Ore, 0)
|
||||
.SetOnInsert(x => x.CreatedAtUtc, now)
|
||||
@@ -39,6 +51,9 @@ public class ResourceService
|
||||
.SetOnInsert(x => x.LastWoodGeneratedAtUtc, now)
|
||||
.SetOnInsert(x => x.LastOreGeneratedAtUtc, now)
|
||||
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
|
||||
.Set(x => x.GoldCapacity, _townHallGoldCapacity)
|
||||
.Set(x => x.WoodCapacity, _townHallWoodCapacity)
|
||||
.Set(x => x.OreCapacity, _townHallOreCapacity)
|
||||
.Set(x => x.UpdatedAtUtc, now);
|
||||
|
||||
var resources = await _resources.FindOneAndUpdateAsync(
|
||||
@@ -167,34 +182,52 @@ public class ResourceService
|
||||
var oreElapsedMinutes = resources.OrePerMinute <= 0 || resources.LastOreGeneratedAtUtc.Year < 2000
|
||||
? 0
|
||||
: (int)Math.Floor((now - resources.LastOreGeneratedAtUtc).TotalMinutes);
|
||||
var cappedGold = Math.Min(resources.Gold, _townHallGoldCapacity);
|
||||
var cappedWood = Math.Min(resources.Wood, _townHallWoodCapacity);
|
||||
var cappedOre = Math.Min(resources.Ore, _townHallOreCapacity);
|
||||
var isOverCapacity = resources.Gold != cappedGold || resources.Wood != cappedWood || resources.Ore != cappedOre;
|
||||
|
||||
if (goldElapsedMinutes <= 0 && woodElapsedMinutes <= 0 && oreElapsedMinutes <= 0)
|
||||
if (goldElapsedMinutes <= 0 && woodElapsedMinutes <= 0 && oreElapsedMinutes <= 0 && !isOverCapacity)
|
||||
{
|
||||
return resources;
|
||||
}
|
||||
|
||||
var update = Builders<PlayerResources>.Update
|
||||
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
|
||||
.Set(x => x.GoldCapacity, _townHallGoldCapacity)
|
||||
.Set(x => x.WoodCapacity, _townHallWoodCapacity)
|
||||
.Set(x => x.OreCapacity, _townHallOreCapacity)
|
||||
.Set(x => x.UpdatedAtUtc, now);
|
||||
|
||||
if (isOverCapacity)
|
||||
{
|
||||
update = update
|
||||
.Set(x => x.Gold, cappedGold)
|
||||
.Set(x => x.Wood, cappedWood)
|
||||
.Set(x => x.Ore, cappedOre);
|
||||
}
|
||||
|
||||
if (goldElapsedMinutes > 0)
|
||||
{
|
||||
var generatedGold = Math.Min(_townHallGoldCapacity, cappedGold + (goldElapsedMinutes * resources.GoldPerMinute));
|
||||
update = update
|
||||
.Inc(x => x.Gold, goldElapsedMinutes * resources.GoldPerMinute)
|
||||
.Set(x => x.Gold, generatedGold)
|
||||
.Set(x => x.LastGoldGeneratedAtUtc, resources.LastGoldGeneratedAtUtc.AddMinutes(goldElapsedMinutes));
|
||||
}
|
||||
|
||||
if (woodElapsedMinutes > 0)
|
||||
{
|
||||
var generatedWood = Math.Min(_townHallWoodCapacity, cappedWood + (woodElapsedMinutes * resources.WoodPerMinute));
|
||||
update = update
|
||||
.Inc(x => x.Wood, woodElapsedMinutes * resources.WoodPerMinute)
|
||||
.Set(x => x.Wood, generatedWood)
|
||||
.Set(x => x.LastWoodGeneratedAtUtc, resources.LastWoodGeneratedAtUtc.AddMinutes(woodElapsedMinutes));
|
||||
}
|
||||
|
||||
if (oreElapsedMinutes > 0)
|
||||
{
|
||||
var generatedOre = Math.Min(_townHallOreCapacity, cappedOre + (oreElapsedMinutes * resources.OrePerMinute));
|
||||
update = update
|
||||
.Inc(x => x.Ore, oreElapsedMinutes * resources.OrePerMinute)
|
||||
.Set(x => x.Ore, generatedOre)
|
||||
.Set(x => x.LastOreGeneratedAtUtc, resources.LastOreGeneratedAtUtc.AddMinutes(oreElapsedMinutes));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,17 @@ namespace LocationsApi.Services;
|
||||
public class UnitTrainingService
|
||||
{
|
||||
private const string ScoutUnitType = "Scout";
|
||||
private const string ArcherUnitType = "Archer";
|
||||
|
||||
private readonly IMongoCollection<UnitTraining> _training;
|
||||
private readonly IMongoCollection<Location> _locations;
|
||||
private readonly ResourceService _resources;
|
||||
private readonly GameSettingsService _settings;
|
||||
private readonly int _scoutGoldCost;
|
||||
private readonly int _archerGoldCost;
|
||||
private readonly int _archerWoodCost;
|
||||
private readonly int _defaultScoutTrainingSeconds;
|
||||
private readonly int _defaultArcherTrainingSeconds;
|
||||
|
||||
public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings)
|
||||
{
|
||||
@@ -20,12 +25,18 @@ public class UnitTrainingService
|
||||
var client = new MongoClient(connectionString);
|
||||
var database = client.GetDatabase(databaseName);
|
||||
_training = database.GetCollection<UnitTraining>("UnitTraining");
|
||||
_locations = database.GetCollection<Location>("Locations");
|
||||
_resources = resources;
|
||||
_settings = settings;
|
||||
_scoutGoldCost = int.TryParse(cfg["Units:ScoutGoldCost"], out var scoutGoldCost) ? Math.Max(0, scoutGoldCost) : 50;
|
||||
_archerGoldCost = int.TryParse(cfg["Units:ArcherGoldCost"], out var archerGoldCost) ? Math.Max(0, archerGoldCost) : 100;
|
||||
_archerWoodCost = int.TryParse(cfg["Units:ArcherWoodCost"], out var archerWoodCost) ? Math.Max(0, archerWoodCost) : 100;
|
||||
_defaultScoutTrainingSeconds = int.TryParse(cfg["Units:ScoutTrainingSeconds"], out var scoutTrainingSeconds)
|
||||
? Math.Max(1, scoutTrainingSeconds)
|
||||
: 60;
|
||||
_defaultArcherTrainingSeconds = int.TryParse(cfg["Units:ArcherTrainingSeconds"], out var archerTrainingSeconds)
|
||||
? Math.Max(1, archerTrainingSeconds)
|
||||
: 60;
|
||||
|
||||
_training.Indexes.CreateOne(new CreateIndexModel<UnitTraining>(
|
||||
Builders<UnitTraining>.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status)));
|
||||
@@ -38,6 +49,7 @@ public class UnitTrainingService
|
||||
|
||||
var all = await _training.Find(x => x.Username == username).ToListAsync();
|
||||
var completedScouts = all.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed");
|
||||
var completedArchers = all.Count(x => x.UnitType == ArcherUnitType && x.Status == "Completed");
|
||||
var activeTraining = all
|
||||
.Where(x => x.Status == "Training")
|
||||
.OrderBy(x => x.CompletesAtUtc)
|
||||
@@ -47,33 +59,47 @@ public class UnitTrainingService
|
||||
return new PlayerUnitsResponse
|
||||
{
|
||||
CompletedScouts = completedScouts,
|
||||
CompletedArchers = completedArchers,
|
||||
Training = activeTraining
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<UnitTraining?> TrainAsync(string username, string unitType)
|
||||
{
|
||||
if (!string.Equals(unitType, ScoutUnitType, StringComparison.OrdinalIgnoreCase))
|
||||
var normalizedUnitType = NormalizeUnitType(unitType);
|
||||
if (normalizedUnitType is null)
|
||||
{
|
||||
throw new ArgumentException("Unsupported unit type.", nameof(unitType));
|
||||
}
|
||||
|
||||
var spent = await _resources.TrySpendGoldAsync(username, _scoutGoldCost);
|
||||
if (normalizedUnitType == ArcherUnitType && !await HasBuiltBarracksAsync(username))
|
||||
{
|
||||
throw new InvalidOperationException("A completed Barracks is required to train Archers.");
|
||||
}
|
||||
|
||||
var goldCost = normalizedUnitType == ArcherUnitType ? _archerGoldCost : _scoutGoldCost;
|
||||
var woodCost = normalizedUnitType == ArcherUnitType ? _archerWoodCost : 0;
|
||||
var spent = normalizedUnitType == ArcherUnitType
|
||||
? await _resources.TrySpendResourcesAsync(username, goldCost, woodCost, 0)
|
||||
: await _resources.TrySpendGoldAsync(username, goldCost);
|
||||
if (!spent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var scoutTrainingSeconds = await _settings.GetIntAsync(GameSettingsService.ScoutTrainingSeconds, _defaultScoutTrainingSeconds);
|
||||
var trainingSeconds = normalizedUnitType == ArcherUnitType
|
||||
? await _settings.GetIntAsync(GameSettingsService.ArcherTrainingSeconds, _defaultArcherTrainingSeconds)
|
||||
: await _settings.GetIntAsync(GameSettingsService.ScoutTrainingSeconds, _defaultScoutTrainingSeconds);
|
||||
var training = new UnitTraining
|
||||
{
|
||||
Username = username,
|
||||
UnitType = ScoutUnitType,
|
||||
GoldCost = _scoutGoldCost,
|
||||
UnitType = normalizedUnitType,
|
||||
GoldCost = goldCost,
|
||||
WoodCost = woodCost,
|
||||
Status = "Training",
|
||||
StartedAtUtc = now,
|
||||
CompletesAtUtc = now.AddSeconds(scoutTrainingSeconds),
|
||||
CompletesAtUtc = now.AddSeconds(trainingSeconds),
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
@@ -82,6 +108,32 @@ public class UnitTrainingService
|
||||
return training;
|
||||
}
|
||||
|
||||
private async Task<bool> HasBuiltBarracksAsync(string username)
|
||||
{
|
||||
await CompleteFinishedBuildingsAsync(username);
|
||||
return await _locations.Find(x =>
|
||||
x.OwnerUsername == username &&
|
||||
x.BuildingType == "Barracks" &&
|
||||
(x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == ""))
|
||||
.AnyAsync();
|
||||
}
|
||||
|
||||
private async Task CompleteFinishedBuildingsAsync(string username)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<Location>.Update
|
||||
.Set(x => x.BuildingStatus, "Built")
|
||||
.Set(x => x.BuildingBuiltAtUtc, now)
|
||||
.Set(x => x.UpdatedAtUtc, now);
|
||||
|
||||
await _locations.UpdateManyAsync(
|
||||
x => x.OwnerUsername == username &&
|
||||
x.BuildingType == "Barracks" &&
|
||||
x.BuildingStatus == "Building" &&
|
||||
x.BuildingCompletesAtUtc <= now,
|
||||
update);
|
||||
}
|
||||
|
||||
public async Task<bool> TryConsumeCompletedScoutAsync(string username)
|
||||
{
|
||||
await CompleteFinishedTrainingAsync(username);
|
||||
@@ -114,4 +166,15 @@ public class UnitTrainingService
|
||||
x => x.Username == username && x.Status == "Training" && x.CompletesAtUtc <= now,
|
||||
update);
|
||||
}
|
||||
|
||||
private static string? NormalizeUnitType(string unitType)
|
||||
{
|
||||
var trimmed = unitType.Trim();
|
||||
return trimmed.ToLowerInvariant() switch
|
||||
{
|
||||
"scout" => ScoutUnitType,
|
||||
"archer" => ArcherUnitType,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,17 @@
|
||||
},
|
||||
"Resources": {
|
||||
"StartingGold": 100,
|
||||
"HeadquartersGoldPerMinute": 10
|
||||
"HeadquartersGoldPerMinute": 10,
|
||||
"TownHallGoldCapacity": 500,
|
||||
"TownHallWoodCapacity": 100,
|
||||
"TownHallOreCapacity": 100
|
||||
},
|
||||
"Units": {
|
||||
"ScoutGoldCost": 50,
|
||||
"ScoutTrainingSeconds": 60
|
||||
"ScoutTrainingSeconds": 60,
|
||||
"ArcherGoldCost": 100,
|
||||
"ArcherWoodCost": 100,
|
||||
"ArcherTrainingSeconds": 60
|
||||
},
|
||||
"Buildings": {
|
||||
"LogCampWoodPerMinute": 5,
|
||||
|
||||
@@ -13,8 +13,14 @@ data:
|
||||
World__VisibilityDepth: "3"
|
||||
Resources__StartingGold: "100"
|
||||
Resources__HeadquartersGoldPerMinute: "10"
|
||||
Resources__TownHallGoldCapacity: "500"
|
||||
Resources__TownHallWoodCapacity: "100"
|
||||
Resources__TownHallOreCapacity: "100"
|
||||
Units__ScoutGoldCost: "50"
|
||||
Units__ScoutTrainingSeconds: "60"
|
||||
Units__ArcherGoldCost: "100"
|
||||
Units__ArcherWoodCost: "100"
|
||||
Units__ArcherTrainingSeconds: "60"
|
||||
Buildings__LogCampWoodPerMinute: "5"
|
||||
Buildings__LogCampBuildSeconds: "60"
|
||||
Buildings__MineOrePerMinute: "5"
|
||||
|
||||
Reference in New Issue
Block a user