adding resource limits and archers

This commit is contained in:
hz
2026-05-19 05:11:57 -05:00
parent a5ecb7f585
commit e78eb24543
12 changed files with 241 additions and 39 deletions
@@ -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);
+10 -7
View File
@@ -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
};
}
}
+8 -2
View File
@@ -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"