adding log camps

This commit is contained in:
hz
2026-05-18 20:42:49 -05:00
parent 3167558e04
commit bf75f02dbb
12 changed files with 365 additions and 35 deletions
@@ -113,6 +113,36 @@ public class LocationsController : ControllerBase
return Ok(LocationResponse.FromLocation(location));
}
[Authorize(Roles = "USER,SUPER")]
[HttpPost("build")]
public async Task<ActionResult<LocationResponse>> Build([FromBody] BuildLocationRequest request)
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
try
{
var location = await _locations.BuildAsync(request, username);
if (location is null)
{
return NotFound("Build target must be controlled by the player.");
}
return Ok(LocationResponse.FromLocation(location));
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Roles = "USER,SUPER")]
[HttpGet("home")]
public async Task<ActionResult<LocationResponse>> GetHome()
+11
View File
@@ -15,6 +15,13 @@ public class ClaimLocationRequest
public int R { get; set; }
}
public class BuildLocationRequest
{
public int Q { get; set; }
public int R { get; set; }
public string BuildingType { get; set; } = "LogCamp";
}
public class LocationResponse
{
public string Id { get; set; } = "";
@@ -25,6 +32,8 @@ public class LocationResponse
public string? OwnerUsername { get; set; }
public string? Label { get; set; }
public int Level { get; set; }
public string? BuildingType { get; set; }
public DateTime? BuildingBuiltAtUtc { get; set; }
public DateTime? HomeAssignedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
public bool IsVisible { get; set; } = true;
@@ -39,6 +48,8 @@ public class LocationResponse
OwnerUsername = location.OwnerUsername,
Label = location.Label,
Level = location.Level,
BuildingType = location.BuildingType,
BuildingBuiltAtUtc = location.BuildingBuiltAtUtc,
HomeAssignedAtUtc = location.HomeAssignedAtUtc,
UpdatedAtUtc = location.UpdatedAtUtc,
IsVisible = true
@@ -16,6 +16,8 @@ public class Location
public string? OwnerUsername { get; set; }
public string? Label { get; set; }
public int Level { get; set; } = 1;
public string? BuildingType { get; set; }
public DateTime? BuildingBuiltAtUtc { get; set; }
public DateTime? HomeAssignedAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
@@ -12,7 +12,10 @@ public class PlayerResources
public string Username { get; set; } = "";
public int Gold { get; set; } = 100;
public int GoldPerMinute { get; set; } = 10;
public int Wood { get; set; }
public int WoodPerMinute { get; set; }
public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime LastWoodGeneratedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
}
@@ -5,7 +5,10 @@ public class PlayerResourcesResponse
public string Username { get; set; } = "";
public int Gold { get; set; }
public int GoldPerMinute { get; set; }
public int Wood { get; set; }
public int WoodPerMinute { get; set; }
public DateTime LastGoldGeneratedAtUtc { get; set; }
public DateTime LastWoodGeneratedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
public static PlayerResourcesResponse FromResources(PlayerResources resources) => new()
@@ -13,7 +16,10 @@ public class PlayerResourcesResponse
Username = resources.Username,
Gold = resources.Gold,
GoldPerMinute = resources.GoldPerMinute,
Wood = resources.Wood,
WoodPerMinute = resources.WoodPerMinute,
LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc,
LastWoodGeneratedAtUtc = resources.LastWoodGeneratedAtUtc,
UpdatedAtUtc = resources.UpdatedAtUtc
};
}
+9 -1
View File
@@ -8,6 +8,7 @@ Tracks hex locations for the territory map.
- `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range, constrained by visibility for USER.
- `GET /api/locations/{q}/{r}` Fetch one visible location for USER, any known location for SUPER.
- `POST /api/locations/claim` Claim visible unclaimed territory for the authenticated user. Normal players spend one completed Scout.
- `POST /api/locations/build` Build on controlled territory.
- `GET /api/locations/home` Get the authenticated user's home location.
- `POST /api/locations/home` Assign or return the authenticated user's home location.
- `GET /api/locations/visible` Get locations visible to the authenticated user.
@@ -27,7 +28,8 @@ 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.
minute. Log Camps generate wood. Generation is applied lazily when the resource
record is read.
## Units
@@ -35,3 +37,9 @@ 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.
## Buildings
Buildings are stored on location records. The first building type is `LogCamp`,
which can be built on controlled Forest territory and adds 5 wood per minute to
the owning player's resource generation.
@@ -10,6 +10,7 @@ public class LocationService
private readonly int _homeMinDistance;
private readonly int _homeSearchRadius;
private readonly int _visibilityDepth;
private readonly int _logCampWoodPerMinute;
public LocationService(IConfiguration cfg, ResourceService resources)
{
@@ -22,6 +23,9 @@ public class LocationService
_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;
var coordinateIndex = Builders<Location>.IndexKeys
.Ascending(x => x.Q)
@@ -111,6 +115,53 @@ public class LocationService
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
}
public async Task<Location?> BuildAsync(BuildLocationRequest request, string username)
{
if (!string.Equals(request.BuildingType, "LogCamp", StringComparison.OrdinalIgnoreCase))
{
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.");
}
if (!string.Equals(location.Terrain, "Forest", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Log Camps can only be built on Forest territory.");
}
var now = DateTime.UtcNow;
var update = Builders<Location>.Update
.Set(x => x.BuildingType, "LogCamp")
.Set(x => x.BuildingBuiltAtUtc, now)
.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.");
}
await _resources.AddWoodGenerationAsync(username, _logCampWoodPerMinute);
return updated;
}
public async Task<Location?> GetHomeAsync(string username) =>
await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
@@ -32,8 +32,10 @@ public class ResourceService
var update = Builders<PlayerResources>.Update
.SetOnInsert(x => x.Username, username)
.SetOnInsert(x => x.Gold, _startingGold)
.SetOnInsert(x => x.Wood, 0)
.SetOnInsert(x => x.CreatedAtUtc, now)
.SetOnInsert(x => x.LastGoldGeneratedAtUtc, now)
.SetOnInsert(x => x.LastWoodGeneratedAtUtc, now)
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
.Set(x => x.UpdatedAtUtc, now);
@@ -78,29 +80,61 @@ public class ResourceService
return updated is not null;
}
public async Task AddWoodGenerationAsync(string username, int woodPerMinute)
{
if (woodPerMinute <= 0)
{
return;
}
var resources = await EnsureAsync(username);
var now = DateTime.UtcNow;
var update = Builders<PlayerResources>.Update
.Inc(x => x.WoodPerMinute, woodPerMinute)
.Set(x => x.UpdatedAtUtc, now);
if (resources.WoodPerMinute <= 0 || resources.LastWoodGeneratedAtUtc.Year < 2000)
{
update = update.Set(x => x.LastWoodGeneratedAtUtc, now);
}
await _resources.UpdateOneAsync(x => x.Username == username, update);
}
private async Task<PlayerResources> ApplyGoldGenerationAsync(PlayerResources resources)
{
if (resources.GoldPerMinute <= 0)
{
return resources;
}
var now = DateTime.UtcNow;
var elapsed = now - resources.LastGoldGeneratedAtUtc;
var elapsedMinutes = (int)Math.Floor(elapsed.TotalMinutes);
if (elapsedMinutes <= 0)
var goldElapsedMinutes = resources.GoldPerMinute <= 0
? 0
: (int)Math.Floor((now - resources.LastGoldGeneratedAtUtc).TotalMinutes);
var woodElapsedMinutes = resources.WoodPerMinute <= 0 || resources.LastWoodGeneratedAtUtc.Year < 2000
? 0
: (int)Math.Floor((now - resources.LastWoodGeneratedAtUtc).TotalMinutes);
if (goldElapsedMinutes <= 0 && woodElapsedMinutes <= 0)
{
return resources;
}
var generatedGold = elapsedMinutes * resources.GoldPerMinute;
var generationTimestamp = resources.LastGoldGeneratedAtUtc.AddMinutes(elapsedMinutes);
var update = Builders<PlayerResources>.Update
.Inc(x => x.Gold, generatedGold)
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
.Set(x => x.LastGoldGeneratedAtUtc, generationTimestamp)
.Set(x => x.UpdatedAtUtc, now);
if (goldElapsedMinutes > 0)
{
update = update
.Inc(x => x.Gold, goldElapsedMinutes * resources.GoldPerMinute)
.Set(x => x.LastGoldGeneratedAtUtc, resources.LastGoldGeneratedAtUtc.AddMinutes(goldElapsedMinutes));
}
if (woodElapsedMinutes > 0)
{
update = update
.Inc(x => x.Wood, woodElapsedMinutes * resources.WoodPerMinute)
.Set(x => x.LastWoodGeneratedAtUtc, resources.LastWoodGeneratedAtUtc.AddMinutes(woodElapsedMinutes));
}
return await _resources.FindOneAndUpdateAsync(
x => x.Username == resources.Username,
update,
@@ -27,5 +27,8 @@
"ScoutGoldCost": 50,
"ScoutTrainingSeconds": 60
},
"Buildings": {
"LogCampWoodPerMinute": 5
},
"AllowedHosts": "*"
}
@@ -15,3 +15,4 @@ data:
Resources__HeadquartersGoldPerMinute: "10"
Units__ScoutGoldCost: "50"
Units__ScoutTrainingSeconds: "60"
Buildings__LogCampWoodPerMinute: "5"