adding resources

This commit is contained in:
hz
2026-05-18 15:05:55 -05:00
parent 32b953aa61
commit 6366ca46c6
11 changed files with 363 additions and 13 deletions
@@ -9,7 +9,11 @@ var title_font: Font
var grid_view var grid_view
var http_request: HTTPRequest var http_request: HTTPRequest
var visible_request: HTTPRequest var visible_request: HTTPRequest
var resources_request: HTTPRequest
var session: Dictionary = {} var session: Dictionary = {}
var escape_overlay: Control
var escape_panel: PanelContainer
var gold_label: Label
func _ready() -> void: func _ready() -> void:
@@ -18,6 +22,12 @@ func _ready() -> void:
_assign_home_location() _assign_home_location()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
_toggle_escape_menu()
get_viewport().set_input_as_handled()
func _build_ui() -> void: func _build_ui() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT) set_anchors_preset(Control.PRESET_FULL_RECT)
title_font = load("res://assets/ui/kenney_ui_pack/Font/Kenney Future.ttf") title_font = load("res://assets/ui/kenney_ui_pack/Font/Kenney Future.ttf")
@@ -75,6 +85,12 @@ func _build_ui() -> void:
legend.add_child(_legend_item(Color(0.38, 0.56, 0.36), "Open")) legend.add_child(_legend_item(Color(0.38, 0.56, 0.36), "Open"))
legend.add_child(_legend_item(Color(0.045, 0.065, 0.065), "Unknown")) legend.add_child(_legend_item(Color(0.045, 0.065, 0.065), "Unknown"))
gold_label = Label.new()
gold_label.text = "Gold: --"
gold_label.add_theme_font_size_override("font_size", 15)
gold_label.add_theme_color_override("font_color", Color(0.95, 0.78, 0.32))
top_content.add_child(gold_label)
var user_label := Label.new() var user_label := Label.new()
user_label.text = _session_name() user_label.text = _session_name()
user_label.add_theme_font_size_override("font_size", 15) user_label.add_theme_font_size_override("font_size", 15)
@@ -121,6 +137,12 @@ func _build_ui() -> void:
visible_request.request_completed.connect(_on_visible_request_completed) visible_request.request_completed.connect(_on_visible_request_completed)
add_child(visible_request) add_child(visible_request)
resources_request = HTTPRequest.new()
resources_request.request_completed.connect(_on_resources_request_completed)
add_child(resources_request)
_build_escape_menu()
func _on_tile_hovered(q: int, r: int, terrain: String) -> void: func _on_tile_hovered(q: int, r: int, terrain: String) -> void:
if coordinate_label: if coordinate_label:
@@ -237,6 +259,148 @@ func _on_visible_request_completed(result: int, response_code: int, _headers: Pa
var visible_locations: Array = parsed var visible_locations: Array = parsed
grid_view.set_visible_locations(visible_locations, username) grid_view.set_visible_locations(visible_locations, username)
coordinate_label.text = "Visible territory loaded. Sight extends 3 hexes beyond controlled land." coordinate_label.text = "Visible territory loaded. Sight extends 3 hexes beyond controlled land."
_load_resources()
func _load_resources() -> void:
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
gold_label.text = "Gold: --"
return
var error: int = resources_request.request(
LOCATIONS_BASE_URL + "/api/resources/me",
PackedStringArray([
"Authorization: Bearer %s" % access_token
]),
HTTPClient.METHOD_GET
)
if error != OK:
gold_label.text = "Gold: ?"
func _on_resources_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
gold_label.text = "Gold: ?"
return
var parsed = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
gold_label.text = "Gold: ?"
return
var resources: Dictionary = parsed
var gold: int = int(resources.get("gold", 0))
var gold_per_minute: int = int(resources.get("goldPerMinute", 0))
gold_label.text = "Gold: %d (+%d/min)" % [gold, gold_per_minute]
func _build_escape_menu() -> void:
escape_overlay = Control.new()
escape_overlay.visible = false
escape_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
escape_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(escape_overlay)
var scrim := ColorRect.new()
scrim.color = Color(0.01, 0.015, 0.015, 0.64)
scrim.set_anchors_preset(Control.PRESET_FULL_RECT)
escape_overlay.add_child(scrim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
escape_overlay.add_child(center)
escape_panel = PanelContainer.new()
escape_panel.custom_minimum_size = Vector2(360, 310)
escape_panel.add_theme_stylebox_override("panel", _panel_style(Color(0.09, 0.13, 0.13, 0.98), Color(0.34, 0.49, 0.46), 2, 8))
center.add_child(escape_panel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 30)
margin.add_theme_constant_override("margin_top", 28)
margin.add_theme_constant_override("margin_right", 30)
margin.add_theme_constant_override("margin_bottom", 26)
escape_panel.add_child(margin)
var menu := VBoxContainer.new()
menu.add_theme_constant_override("separation", 14)
margin.add_child(menu)
var title := Label.new()
title.text = "Paused"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 30)
title.add_theme_color_override("font_color", Color(0.94, 0.96, 0.9))
if title_font:
title.add_theme_font_override("font", title_font)
menu.add_child(title)
var account := Label.new()
account.text = _session_name()
account.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
account.add_theme_font_size_override("font_size", 14)
account.add_theme_color_override("font_color", Color(0.64, 0.78, 0.74))
menu.add_child(account)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 10)
menu.add_child(spacer)
var resume_button := _menu_button("Resume")
resume_button.pressed.connect(_toggle_escape_menu)
menu.add_child(resume_button)
var logout_button := _menu_button("Log Out")
logout_button.pressed.connect(_logout)
menu.add_child(logout_button)
var quit_button := _menu_button("Quit")
quit_button.pressed.connect(func() -> void: get_tree().quit())
menu.add_child(quit_button)
func _toggle_escape_menu() -> void:
if not escape_overlay:
return
escape_overlay.visible = not escape_overlay.visible
if escape_overlay.visible and escape_panel:
var first_button := _find_first_button(escape_panel)
if first_button:
first_button.grab_focus()
func _logout() -> void:
if FileAccess.file_exists(SESSION_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_PATH))
get_tree().remove_meta("auth_session")
get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn")
func _menu_button(text_value: String) -> Button:
var button := Button.new()
button.text = text_value
button.custom_minimum_size = Vector2(0, 48)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.add_theme_font_size_override("font_size", 17)
button.add_theme_stylebox_override("normal", _button_style(Color(0.75, 0.78, 0.68)))
button.add_theme_stylebox_override("hover", _button_style(Color(0.88, 0.82, 0.47)))
button.add_theme_stylebox_override("pressed", _button_style(Color(0.58, 0.62, 0.52)))
button.add_theme_color_override("font_color", Color(0.08, 0.12, 0.11))
button.add_theme_color_override("font_hover_color", Color(0.08, 0.12, 0.11))
button.add_theme_color_override("font_pressed_color", Color(0.08, 0.12, 0.11))
return button
func _find_first_button(node: Node) -> Button:
if node is Button:
return node
for child in node.get_children():
var button := _find_first_button(child)
if button:
return button
return null
func _panel_style(color: Color, border_color: Color, border_width: int, radius: int) -> StyleBoxFlat: func _panel_style(color: Color, border_color: Color, border_width: int, radius: int) -> StyleBoxFlat:
@@ -248,6 +412,15 @@ func _panel_style(color: Color, border_color: Color, border_width: int, radius:
return style return style
func _button_style(color: Color) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.border_color = color.darkened(0.28)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
return style
func _legend_item(color: Color, label_text: String) -> HBoxContainer: func _legend_item(color: Color, label_text: String) -> HBoxContainer:
var item := HBoxContainer.new() var item := HBoxContainer.new()
item.add_theme_constant_override("separation", 5) item.add_theme_constant_override("separation", 5)
@@ -126,15 +126,15 @@ func _draw_tile(tile: Dictionary) -> void:
var is_hovered: bool = coord == hovered_tile var is_hovered: bool = coord == hovered_tile
var is_selected: bool = coord == selected_tile var is_selected: bool = coord == selected_tile
var key: String = _tile_key(q, r) var key: String = _tile_key(q, r)
var is_visible: bool = visible_tiles.has(key) var tile_visible: bool = visible_tiles.has(key)
var is_owned: bool = owned_tiles.has(key) var is_owned: bool = owned_tiles.has(key)
var is_rival: bool = rival_tiles.has(key) var is_rival: bool = rival_tiles.has(key)
var side_points: PackedVector2Array = PackedVector2Array([points[2], points[3], points[4], points[4] + Vector2(0, 14), points[3] + Vector2(0, 14), points[2] + Vector2(0, 14)]) var side_points: PackedVector2Array = PackedVector2Array([points[2], points[3], points[4], points[4] + Vector2(0, 14), points[3] + Vector2(0, 14), points[2] + Vector2(0, 14)])
var draw_color: Color = _ownership_fill_color(color, coord, is_visible, is_owned, is_rival) var draw_color: Color = _ownership_fill_color(color, coord, tile_visible, is_owned, is_rival)
draw_colored_polygon(side_points, draw_color.darkened(0.28)) draw_colored_polygon(side_points, draw_color.darkened(0.28))
if not is_visible: if not tile_visible:
draw_colored_polygon(points, draw_color) draw_colored_polygon(points, draw_color)
elif is_selected: elif is_selected:
draw_colored_polygon(points, draw_color.lightened(0.2)) draw_colored_polygon(points, draw_color.lightened(0.2))
@@ -147,7 +147,7 @@ func _draw_tile(tile: Dictionary) -> void:
draw_polyline(_closed_points(points), Color(0.04, 0.065, 0.06, 0.72), 2.0) draw_polyline(_closed_points(points), Color(0.04, 0.065, 0.06, 0.72), 2.0)
if not is_visible: if not tile_visible:
draw_polyline(_closed_points(points), Color(0.11, 0.15, 0.15, 0.75), 1.0) draw_polyline(_closed_points(points), Color(0.11, 0.15, 0.15, 0.75), 1.0)
elif is_selected: elif is_selected:
draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0) draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0)
@@ -244,14 +244,14 @@ func set_visible_locations(locations: Array, username: String) -> void:
var q: int = int(location.get("q", 0)) var q: int = int(location.get("q", 0))
var r: int = int(location.get("r", 0)) var r: int = int(location.get("r", 0))
var terrain: String = str(location.get("terrain", "Field")) var terrain: String = str(location.get("terrain", "Field"))
var owner: String = _owner_username(location) var owner_username: String = _owner_username(location)
var key: String = _tile_key(q, r) var key: String = _tile_key(q, r)
visible_tiles[key] = location visible_tiles[key] = location
_add_or_update_tile(q, r, terrain) _add_or_update_tile(q, r, terrain)
if not owner.is_empty(): if not owner_username.is_empty():
if owner == username: if owner_username == username:
owned_tiles[key] = location owned_tiles[key] = location
else: else:
rival_tiles[key] = location rival_tiles[key] = location
@@ -263,8 +263,8 @@ func _tile_key(q: int, r: int) -> String:
return "%d,%d" % [q, r] return "%d,%d" % [q, r]
func _ownership_fill_color(base_color: Color, coord: Vector2i, is_visible: bool, is_owned: bool, is_rival: bool) -> Color: func _ownership_fill_color(base_color: Color, coord: Vector2i, tile_visible: bool, is_owned: bool, is_rival: bool) -> Color:
if not is_visible: if not tile_visible:
return Color(0.045, 0.065, 0.065) return Color(0.045, 0.065, 0.065)
if coord == home_tile: if coord == home_tile:
return Color(0.78, 0.62, 0.19) return Color(0.78, 0.62, 0.19)
@@ -283,10 +283,10 @@ func _tile_display_label(key: String, data: Dictionary) -> String:
return "%s | Your territory" % terrain return "%s | Your territory" % terrain
if rival_tiles.has(key): if rival_tiles.has(key):
var rival_data: Dictionary = rival_tiles[key] var rival_data: Dictionary = rival_tiles[key]
var owner: String = _owner_username(rival_data) var owner_username: String = _owner_username(rival_data)
if owner.is_empty(): if owner_username.is_empty():
return "%s | Unclaimed" % terrain return "%s | Unclaimed" % terrain
return "%s | Controlled by %s" % [terrain, owner] return "%s | Controlled by %s" % [terrain, owner_username]
return "%s | Unclaimed" % terrain return "%s | Unclaimed" % terrain
@@ -0,0 +1,33 @@
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 ResourcesController : ControllerBase
{
private readonly ResourceService _resources;
public ResourcesController(ResourceService resources)
{
_resources = resources;
}
[Authorize(Roles = "USER,SUPER")]
[HttpGet("me")]
public async Task<ActionResult<PlayerResourcesResponse>> GetMine()
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
var resources = await _resources.GetAsync(username);
return Ok(PlayerResourcesResponse.FromResources(resources));
}
}
@@ -0,0 +1,18 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace LocationsApi.Models;
public class PlayerResources
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = "";
public string Username { get; set; } = "";
public int Gold { get; set; } = 100;
public int GoldPerMinute { get; set; } = 10;
public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,19 @@
namespace LocationsApi.Models;
public class PlayerResourcesResponse
{
public string Username { get; set; } = "";
public int Gold { get; set; }
public int GoldPerMinute { get; set; }
public DateTime LastGoldGeneratedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
public static PlayerResourcesResponse FromResources(PlayerResources resources) => new()
{
Username = resources.Username,
Gold = resources.Gold,
GoldPerMinute = resources.GoldPerMinute,
LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc,
UpdatedAtUtc = resources.UpdatedAtUtc
};
}
+1
View File
@@ -8,6 +8,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSingleton<LocationService>(); builder.Services.AddSingleton<LocationService>();
builder.Services.AddSingleton<ResourceService>();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => builder.Services.AddSwaggerGen(c =>
{ {
+7
View File
@@ -13,9 +13,16 @@ Tracks hex locations for the territory map.
- `GET /api/locations/visible` Get locations visible to the authenticated user. - `GET /api/locations/visible` Get locations visible to the authenticated user.
- `GET /api/locations/visible?depth=3` Override visibility depth for a request. - `GET /api/locations/visible?depth=3` Override visibility depth for a request.
- `PUT /api/locations` Upsert location data (SUPER only). - `PUT /api/locations` Upsert location data (SUPER only).
- `GET /api/resources/me` Get the authenticated player's resources.
`claim` and `put` require the same JWT issued by AuthApi. `claim` and `put` require the same JWT issued by AuthApi.
When a player gains visibility over previously unseen coordinates, the service When a player gains visibility over previously unseen coordinates, the service
generates and persists those locations before returning them. Generated visible generates and persists those locations before returning them. Generated visible
locations are unowned `Open` records until claimed by a player. locations are unowned `Open` records until claimed by a player.
## Resources
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.
@@ -6,17 +6,19 @@ namespace LocationsApi.Services;
public class LocationService public class LocationService
{ {
private readonly IMongoCollection<Location> _locations; private readonly IMongoCollection<Location> _locations;
private readonly ResourceService _resources;
private readonly int _homeMinDistance; private readonly int _homeMinDistance;
private readonly int _homeSearchRadius; private readonly int _homeSearchRadius;
private readonly int _visibilityDepth; private readonly int _visibilityDepth;
public LocationService(IConfiguration cfg) public LocationService(IConfiguration cfg, ResourceService resources)
{ {
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler"; var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler";
var client = new MongoClient(connectionString); var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName); var database = client.GetDatabase(databaseName);
_locations = database.GetCollection<Location>("Locations"); _locations = database.GetCollection<Location>("Locations");
_resources = resources;
_homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8; _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; _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; _visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3;
@@ -117,6 +119,7 @@ public class LocationService
var existingHome = await GetHomeAsync(username); var existingHome = await GetHomeAsync(username);
if (existingHome is not null) if (existingHome is not null)
{ {
await _resources.EnsureAsync(username);
return existingHome; return existingHome;
} }
@@ -138,6 +141,7 @@ public class LocationService
var home = await TryCreateHomeAsync(username, candidate.q, candidate.r); var home = await TryCreateHomeAsync(username, candidate.q, candidate.r);
if (home is not null) if (home is not null)
{ {
await _resources.EnsureAsync(username);
return home; return home;
} }
@@ -0,0 +1,89 @@
using LocationsApi.Models;
using MongoDB.Driver;
namespace LocationsApi.Services;
public class ResourceService
{
private readonly IMongoCollection<PlayerResources> _resources;
private readonly int _startingGold;
private readonly int _headquartersGoldPerMinute;
public ResourceService(IConfiguration cfg)
{
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);
_resources = database.GetCollection<PlayerResources>("PlayerResources");
_startingGold = int.TryParse(cfg["Resources:StartingGold"], out var startingGold) ? Math.Max(0, startingGold) : 100;
_headquartersGoldPerMinute = int.TryParse(cfg["Resources:HeadquartersGoldPerMinute"], out var goldPerMinute)
? Math.Max(0, goldPerMinute)
: 10;
_resources.Indexes.CreateOne(new CreateIndexModel<PlayerResources>(
Builders<PlayerResources>.IndexKeys.Ascending(x => x.Username),
new CreateIndexOptions { Unique = true }));
}
public async Task<PlayerResources> EnsureAsync(string username)
{
var now = DateTime.UtcNow;
var update = Builders<PlayerResources>.Update
.SetOnInsert(x => x.Username, username)
.SetOnInsert(x => x.Gold, _startingGold)
.SetOnInsert(x => x.CreatedAtUtc, now)
.SetOnInsert(x => x.LastGoldGeneratedAtUtc, now)
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
.Set(x => x.UpdatedAtUtc, now);
var resources = await _resources.FindOneAndUpdateAsync(
x => x.Username == username,
update,
new FindOneAndUpdateOptions<PlayerResources>
{
IsUpsert = true,
ReturnDocument = ReturnDocument.After
});
return await ApplyGoldGenerationAsync(resources);
}
public async Task<PlayerResources> GetAsync(string username)
{
var resources = await EnsureAsync(username);
return await ApplyGoldGenerationAsync(resources);
}
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)
{
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);
return await _resources.FindOneAndUpdateAsync(
x => x.Username == resources.Username,
update,
new FindOneAndUpdateOptions<PlayerResources>
{
ReturnDocument = ReturnDocument.After
});
}
}
@@ -19,5 +19,9 @@
"HomeSearchRadius": 80, "HomeSearchRadius": 80,
"VisibilityDepth": 3 "VisibilityDepth": 3
}, },
"Resources": {
"StartingGold": 100,
"HeadquartersGoldPerMinute": 10
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -11,3 +11,5 @@ data:
World__HomeMinDistance: "8" World__HomeMinDistance: "8"
World__HomeSearchRadius: "80" World__HomeSearchRadius: "80"
World__VisibilityDepth: "3" World__VisibilityDepth: "3"
Resources__StartingGold: "100"
Resources__HeadquartersGoldPerMinute: "10"