Adding units
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
using LocationsApi.Models;
|
||||
using LocationsApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace LocationsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UnitsController : ControllerBase
|
||||
{
|
||||
private readonly UnitTrainingService _units;
|
||||
|
||||
public UnitsController(UnitTrainingService units)
|
||||
{
|
||||
_units = units;
|
||||
}
|
||||
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<PlayerUnitsResponse>> GetMine()
|
||||
{
|
||||
var username = User.FindFirstValue(ClaimTypes.Name);
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(await _units.GetMineAsync(username));
|
||||
}
|
||||
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
[HttpPost("train")]
|
||||
public async Task<ActionResult<UnitTrainingResponse>> Train([FromBody] TrainUnitRequest request)
|
||||
{
|
||||
var username = User.FindFirstValue(ClaimTypes.Name);
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var training = await _units.TrainAsync(username, request.UnitType);
|
||||
if (training is null)
|
||||
{
|
||||
return BadRequest(new { error = "Not enough gold." });
|
||||
}
|
||||
|
||||
return Ok(UnitTrainingResponse.FromTraining(training));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace LocationsApi.Models;
|
||||
|
||||
public class TrainUnitRequest
|
||||
{
|
||||
public string UnitType { get; set; } = "Scout";
|
||||
}
|
||||
|
||||
public class UnitTrainingResponse
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Username { get; set; } = "";
|
||||
public string UnitType { get; set; } = "";
|
||||
public int GoldCost { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public DateTime StartedAtUtc { get; set; }
|
||||
public DateTime CompletesAtUtc { get; set; }
|
||||
public DateTime? CompletedAtUtc { get; set; }
|
||||
|
||||
public static UnitTrainingResponse FromTraining(UnitTraining training) => new()
|
||||
{
|
||||
Id = training.Id,
|
||||
Username = training.Username,
|
||||
UnitType = training.UnitType,
|
||||
GoldCost = training.GoldCost,
|
||||
Status = training.Status,
|
||||
StartedAtUtc = training.StartedAtUtc,
|
||||
CompletesAtUtc = training.CompletesAtUtc,
|
||||
CompletedAtUtc = training.CompletedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public class PlayerUnitsResponse
|
||||
{
|
||||
public int CompletedScouts { get; set; }
|
||||
public List<UnitTrainingResponse> Training { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace LocationsApi.Models;
|
||||
|
||||
public class UnitTraining
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Username { get; set; } = "";
|
||||
public string UnitType { get; set; } = "Scout";
|
||||
public int GoldCost { get; set; } = 50;
|
||||
public string Status { get; set; } = "Training";
|
||||
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime CompletesAtUtc { get; set; } = DateTime.UtcNow.AddMinutes(1);
|
||||
public DateTime? CompletedAtUtc { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSingleton<LocationService>();
|
||||
builder.Services.AddSingleton<ResourceService>();
|
||||
builder.Services.AddSingleton<UnitTrainingService>();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
|
||||
@@ -14,6 +14,8 @@ Tracks hex locations for the territory map.
|
||||
- `GET /api/locations/visible?depth=3` Override visibility depth for a request.
|
||||
- `PUT /api/locations` Upsert location data (SUPER only).
|
||||
- `GET /api/resources/me` Get the authenticated player's resources.
|
||||
- `GET /api/units/me` Get completed units and active training for the authenticated player.
|
||||
- `POST /api/units/train` Spend gold and start training a unit.
|
||||
|
||||
`claim` and `put` require the same JWT issued by AuthApi.
|
||||
|
||||
@@ -26,3 +28,9 @@ 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. Generation is applied lazily when the resource record is read.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -51,8 +51,31 @@ public class ResourceService
|
||||
|
||||
public async Task<PlayerResources> GetAsync(string username)
|
||||
{
|
||||
var resources = await EnsureAsync(username);
|
||||
return await ApplyGoldGenerationAsync(resources);
|
||||
return await EnsureAsync(username);
|
||||
}
|
||||
|
||||
public async Task<bool> TrySpendGoldAsync(string username, int amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
await EnsureAsync(username);
|
||||
|
||||
var update = Builders<PlayerResources>.Update
|
||||
.Inc(x => x.Gold, -amount)
|
||||
.Set(x => x.UpdatedAtUtc, DateTime.UtcNow);
|
||||
|
||||
var updated = await _resources.FindOneAndUpdateAsync(
|
||||
x => x.Username == username && x.Gold >= amount,
|
||||
update,
|
||||
new FindOneAndUpdateOptions<PlayerResources>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
|
||||
return updated is not null;
|
||||
}
|
||||
|
||||
private async Task<PlayerResources> ApplyGoldGenerationAsync(PlayerResources resources)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using LocationsApi.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace LocationsApi.Services;
|
||||
|
||||
public class UnitTrainingService
|
||||
{
|
||||
private const string ScoutUnitType = "Scout";
|
||||
|
||||
private readonly IMongoCollection<UnitTraining> _training;
|
||||
private readonly ResourceService _resources;
|
||||
private readonly int _scoutGoldCost;
|
||||
private readonly int _scoutTrainingSeconds;
|
||||
|
||||
public UnitTrainingService(IConfiguration cfg, ResourceService resources)
|
||||
{
|
||||
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);
|
||||
_training = database.GetCollection<UnitTraining>("UnitTraining");
|
||||
_resources = resources;
|
||||
_scoutGoldCost = int.TryParse(cfg["Units:ScoutGoldCost"], out var scoutGoldCost) ? Math.Max(0, scoutGoldCost) : 50;
|
||||
_scoutTrainingSeconds = int.TryParse(cfg["Units:ScoutTrainingSeconds"], out var scoutTrainingSeconds)
|
||||
? Math.Max(1, scoutTrainingSeconds)
|
||||
: 60;
|
||||
|
||||
_training.Indexes.CreateOne(new CreateIndexModel<UnitTraining>(
|
||||
Builders<UnitTraining>.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status)));
|
||||
}
|
||||
|
||||
public async Task<PlayerUnitsResponse> GetMineAsync(string username)
|
||||
{
|
||||
await CompleteFinishedTrainingAsync(username);
|
||||
|
||||
var all = await _training.Find(x => x.Username == username).ToListAsync();
|
||||
var completedScouts = all.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed");
|
||||
var activeTraining = all
|
||||
.Where(x => x.Status == "Training")
|
||||
.OrderBy(x => x.CompletesAtUtc)
|
||||
.Select(UnitTrainingResponse.FromTraining)
|
||||
.ToList();
|
||||
|
||||
return new PlayerUnitsResponse
|
||||
{
|
||||
CompletedScouts = completedScouts,
|
||||
Training = activeTraining
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<UnitTraining?> TrainAsync(string username, string unitType)
|
||||
{
|
||||
if (!string.Equals(unitType, ScoutUnitType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Unsupported unit type.", nameof(unitType));
|
||||
}
|
||||
|
||||
var spent = await _resources.TrySpendGoldAsync(username, _scoutGoldCost);
|
||||
if (!spent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var training = new UnitTraining
|
||||
{
|
||||
Username = username,
|
||||
UnitType = ScoutUnitType,
|
||||
GoldCost = _scoutGoldCost,
|
||||
Status = "Training",
|
||||
StartedAtUtc = now,
|
||||
CompletesAtUtc = now.AddSeconds(_scoutTrainingSeconds),
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
||||
await _training.InsertOneAsync(training);
|
||||
return training;
|
||||
}
|
||||
|
||||
private async Task CompleteFinishedTrainingAsync(string username)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<UnitTraining>.Update
|
||||
.Set(x => x.Status, "Completed")
|
||||
.Set(x => x.CompletedAtUtc, now)
|
||||
.Set(x => x.UpdatedAtUtc, now);
|
||||
|
||||
await _training.UpdateManyAsync(
|
||||
x => x.Username == username && x.Status == "Training" && x.CompletesAtUtc <= now,
|
||||
update);
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,9 @@
|
||||
"StartingGold": 100,
|
||||
"HeadquartersGoldPerMinute": 10
|
||||
},
|
||||
"Units": {
|
||||
"ScoutGoldCost": 50,
|
||||
"ScoutTrainingSeconds": 60
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -13,3 +13,5 @@ data:
|
||||
World__VisibilityDepth: "3"
|
||||
Resources__StartingGold: "100"
|
||||
Resources__HeadquartersGoldPerMinute: "10"
|
||||
Units__ScoutGoldCost: "50"
|
||||
Units__ScoutTrainingSeconds: "60"
|
||||
|
||||
Reference in New Issue
Block a user