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 http_request: HTTPRequest
var visible_request: HTTPRequest
var resources_request: HTTPRequest
var session: Dictionary = {}
var escape_overlay: Control
var escape_panel: PanelContainer
var gold_label: Label
func _ready() -> void:
@@ -18,6 +22,12 @@ func _ready() -> void:
_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:
set_anchors_preset(Control.PRESET_FULL_RECT)
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.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()
user_label.text = _session_name()
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)
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:
if coordinate_label:
@@ -237,6 +259,148 @@ func _on_visible_request_completed(result: int, response_code: int, _headers: Pa
var visible_locations: Array = parsed
grid_view.set_visible_locations(visible_locations, username)
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:
@@ -248,6 +412,15 @@ func _panel_style(color: Color, border_color: Color, border_width: int, radius:
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:
var item := HBoxContainer.new()
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_selected: bool = coord == selected_tile
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_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 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))
if not is_visible:
if not tile_visible:
draw_colored_polygon(points, draw_color)
elif is_selected:
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)
if not is_visible:
if not tile_visible:
draw_polyline(_closed_points(points), Color(0.11, 0.15, 0.15, 0.75), 1.0)
elif is_selected:
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 r: int = int(location.get("r", 0))
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)
visible_tiles[key] = location
_add_or_update_tile(q, r, terrain)
if not owner.is_empty():
if owner == username:
if not owner_username.is_empty():
if owner_username == username:
owned_tiles[key] = location
else:
rival_tiles[key] = location
@@ -263,8 +263,8 @@ func _tile_key(q: int, r: int) -> String:
return "%d,%d" % [q, r]
func _ownership_fill_color(base_color: Color, coord: Vector2i, is_visible: bool, is_owned: bool, is_rival: bool) -> Color:
if not is_visible:
func _ownership_fill_color(base_color: Color, coord: Vector2i, tile_visible: bool, is_owned: bool, is_rival: bool) -> Color:
if not tile_visible:
return Color(0.045, 0.065, 0.065)
if coord == home_tile:
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
if rival_tiles.has(key):
var rival_data: Dictionary = rival_tiles[key]
var owner: String = _owner_username(rival_data)
if owner.is_empty():
var owner_username: String = _owner_username(rival_data)
if owner_username.is_empty():
return "%s | Unclaimed" % terrain
return "%s | Controlled by %s" % [terrain, owner]
return "%s | Controlled by %s" % [terrain, owner_username]
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.AddSingleton<LocationService>();
builder.Services.AddSingleton<ResourceService>();
builder.Services.AddEndpointsApiExplorer();
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?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.
`claim` and `put` require the same JWT issued by AuthApi.
When a player gains visibility over previously unseen coordinates, the service
generates and persists those locations before returning them. Generated visible
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
{
private readonly IMongoCollection<Location> _locations;
private readonly ResourceService _resources;
private readonly int _homeMinDistance;
private readonly int _homeSearchRadius;
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 databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler";
var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName);
_locations = database.GetCollection<Location>("Locations");
_resources = resources;
_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;
@@ -117,6 +119,7 @@ public class LocationService
var existingHome = await GetHomeAsync(username);
if (existingHome is not null)
{
await _resources.EnsureAsync(username);
return existingHome;
}
@@ -138,6 +141,7 @@ public class LocationService
var home = await TryCreateHomeAsync(username, candidate.q, candidate.r);
if (home is not null)
{
await _resources.EnsureAsync(username);
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,
"VisibilityDepth": 3
},
"Resources": {
"StartingGold": 100,
"HeadquartersGoldPerMinute": 10
},
"AllowedHosts": "*"
}
@@ -11,3 +11,5 @@ data:
World__HomeMinDistance: "8"
World__HomeSearchRadius: "80"
World__VisibilityDepth: "3"
Resources__StartingGold: "100"
Resources__HeadquartersGoldPerMinute: "10"