Adding units
This commit is contained in:
@@ -2,6 +2,7 @@ extends Control
|
|||||||
|
|
||||||
const SESSION_PATH := "user://auth_session.json"
|
const SESSION_PATH := "user://auth_session.json"
|
||||||
const LOCATIONS_BASE_URL := "https://tloc.ranaze.com"
|
const LOCATIONS_BASE_URL := "https://tloc.ranaze.com"
|
||||||
|
const RESOURCE_REFRESH_SECONDS := 15.0
|
||||||
const HexGridView := preload("res://scenes/game/hex_grid_view.gd")
|
const HexGridView := preload("res://scenes/game/hex_grid_view.gd")
|
||||||
|
|
||||||
var coordinate_label: Label
|
var coordinate_label: Label
|
||||||
@@ -10,10 +11,23 @@ var grid_view
|
|||||||
var http_request: HTTPRequest
|
var http_request: HTTPRequest
|
||||||
var visible_request: HTTPRequest
|
var visible_request: HTTPRequest
|
||||||
var resources_request: HTTPRequest
|
var resources_request: HTTPRequest
|
||||||
|
var units_request: HTTPRequest
|
||||||
|
var train_unit_request: HTTPRequest
|
||||||
|
var resources_timer: Timer
|
||||||
var session: Dictionary = {}
|
var session: Dictionary = {}
|
||||||
var escape_overlay: Control
|
var escape_overlay: Control
|
||||||
var escape_panel: PanelContainer
|
var escape_panel: PanelContainer
|
||||||
var gold_label: Label
|
var gold_label: Label
|
||||||
|
var tick_bar: ProgressBar
|
||||||
|
var tick_label: Label
|
||||||
|
var gold_tick_anchor_unix := 0.0
|
||||||
|
var gold_tick_seconds := 60.0
|
||||||
|
var train_button: Button
|
||||||
|
var unit_label: Label
|
||||||
|
var training_label: Label
|
||||||
|
var training_bar: ProgressBar
|
||||||
|
var training_started_unix := 0.0
|
||||||
|
var training_completes_unix := 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -22,6 +36,11 @@ func _ready() -> void:
|
|||||||
_assign_home_location()
|
_assign_home_location()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
_update_tick_bar()
|
||||||
|
_update_training_bar()
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event.is_action_pressed("ui_cancel"):
|
if event.is_action_pressed("ui_cancel"):
|
||||||
_toggle_escape_menu()
|
_toggle_escape_menu()
|
||||||
@@ -91,6 +110,27 @@ func _build_ui() -> void:
|
|||||||
gold_label.add_theme_color_override("font_color", Color(0.95, 0.78, 0.32))
|
gold_label.add_theme_color_override("font_color", Color(0.95, 0.78, 0.32))
|
||||||
top_content.add_child(gold_label)
|
top_content.add_child(gold_label)
|
||||||
|
|
||||||
|
var tick_box := VBoxContainer.new()
|
||||||
|
tick_box.custom_minimum_size = Vector2(130, 0)
|
||||||
|
tick_box.add_theme_constant_override("separation", 4)
|
||||||
|
top_content.add_child(tick_box)
|
||||||
|
|
||||||
|
tick_label = Label.new()
|
||||||
|
tick_label.text = "Next: --"
|
||||||
|
tick_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
tick_label.add_theme_font_size_override("font_size", 12)
|
||||||
|
tick_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78))
|
||||||
|
tick_box.add_child(tick_label)
|
||||||
|
|
||||||
|
tick_bar = ProgressBar.new()
|
||||||
|
tick_bar.custom_minimum_size = Vector2(130, 8)
|
||||||
|
tick_bar.max_value = 1.0
|
||||||
|
tick_bar.value = 0.0
|
||||||
|
tick_bar.show_percentage = false
|
||||||
|
tick_bar.add_theme_stylebox_override("background", _progress_style(Color(0.035, 0.055, 0.055), Color(0.18, 0.28, 0.26)))
|
||||||
|
tick_bar.add_theme_stylebox_override("fill", _progress_style(Color(0.82, 0.65, 0.2), Color(0.9, 0.78, 0.36)))
|
||||||
|
tick_box.add_child(tick_bar)
|
||||||
|
|
||||||
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)
|
||||||
@@ -123,11 +163,48 @@ func _build_ui() -> void:
|
|||||||
bottom_margin.add_theme_constant_override("margin_bottom", 8)
|
bottom_margin.add_theme_constant_override("margin_bottom", 8)
|
||||||
bottom_bar.add_child(bottom_margin)
|
bottom_bar.add_child(bottom_margin)
|
||||||
|
|
||||||
|
var bottom_content := HBoxContainer.new()
|
||||||
|
bottom_content.add_theme_constant_override("separation", 16)
|
||||||
|
bottom_margin.add_child(bottom_content)
|
||||||
|
|
||||||
coordinate_label = Label.new()
|
coordinate_label = Label.new()
|
||||||
coordinate_label.text = "Drag to pan. Mouse wheel zooms. Select a tile to inspect territory."
|
coordinate_label.text = "Drag to pan. Mouse wheel zooms. Select a tile to inspect territory."
|
||||||
|
coordinate_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
coordinate_label.add_theme_font_size_override("font_size", 14)
|
coordinate_label.add_theme_font_size_override("font_size", 14)
|
||||||
coordinate_label.add_theme_color_override("font_color", Color(0.72, 0.8, 0.78))
|
coordinate_label.add_theme_color_override("font_color", Color(0.72, 0.8, 0.78))
|
||||||
bottom_margin.add_child(coordinate_label)
|
bottom_content.add_child(coordinate_label)
|
||||||
|
|
||||||
|
unit_label = Label.new()
|
||||||
|
unit_label.text = "Scouts: --"
|
||||||
|
unit_label.custom_minimum_size = Vector2(88, 0)
|
||||||
|
unit_label.add_theme_font_size_override("font_size", 14)
|
||||||
|
unit_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78))
|
||||||
|
bottom_content.add_child(unit_label)
|
||||||
|
|
||||||
|
var training_box := VBoxContainer.new()
|
||||||
|
training_box.custom_minimum_size = Vector2(150, 0)
|
||||||
|
training_box.add_theme_constant_override("separation", 4)
|
||||||
|
bottom_content.add_child(training_box)
|
||||||
|
|
||||||
|
training_label = Label.new()
|
||||||
|
training_label.text = "Training: idle"
|
||||||
|
training_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
training_label.add_theme_font_size_override("font_size", 12)
|
||||||
|
training_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78))
|
||||||
|
training_box.add_child(training_label)
|
||||||
|
|
||||||
|
training_bar = ProgressBar.new()
|
||||||
|
training_bar.custom_minimum_size = Vector2(150, 8)
|
||||||
|
training_bar.max_value = 1.0
|
||||||
|
training_bar.value = 0.0
|
||||||
|
training_bar.show_percentage = false
|
||||||
|
training_bar.add_theme_stylebox_override("background", _progress_style(Color(0.035, 0.055, 0.055), Color(0.18, 0.28, 0.26)))
|
||||||
|
training_bar.add_theme_stylebox_override("fill", _progress_style(Color(0.28, 0.62, 0.72), Color(0.43, 0.82, 0.92)))
|
||||||
|
training_box.add_child(training_bar)
|
||||||
|
|
||||||
|
train_button = _small_button("Train Scout 50g")
|
||||||
|
train_button.pressed.connect(_train_scout)
|
||||||
|
bottom_content.add_child(train_button)
|
||||||
|
|
||||||
http_request = HTTPRequest.new()
|
http_request = HTTPRequest.new()
|
||||||
http_request.request_completed.connect(_on_home_request_completed)
|
http_request.request_completed.connect(_on_home_request_completed)
|
||||||
@@ -141,6 +218,20 @@ func _build_ui() -> void:
|
|||||||
resources_request.request_completed.connect(_on_resources_request_completed)
|
resources_request.request_completed.connect(_on_resources_request_completed)
|
||||||
add_child(resources_request)
|
add_child(resources_request)
|
||||||
|
|
||||||
|
units_request = HTTPRequest.new()
|
||||||
|
units_request.request_completed.connect(_on_units_request_completed)
|
||||||
|
add_child(units_request)
|
||||||
|
|
||||||
|
train_unit_request = HTTPRequest.new()
|
||||||
|
train_unit_request.request_completed.connect(_on_train_unit_request_completed)
|
||||||
|
add_child(train_unit_request)
|
||||||
|
|
||||||
|
resources_timer = Timer.new()
|
||||||
|
resources_timer.wait_time = RESOURCE_REFRESH_SECONDS
|
||||||
|
resources_timer.autostart = false
|
||||||
|
resources_timer.timeout.connect(_refresh_player_state)
|
||||||
|
add_child(resources_timer)
|
||||||
|
|
||||||
_build_escape_menu()
|
_build_escape_menu()
|
||||||
|
|
||||||
|
|
||||||
@@ -259,7 +350,13 @@ 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."
|
||||||
|
_refresh_player_state()
|
||||||
|
resources_timer.start()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_player_state() -> void:
|
||||||
_load_resources()
|
_load_resources()
|
||||||
|
_load_units()
|
||||||
|
|
||||||
|
|
||||||
func _load_resources() -> void:
|
func _load_resources() -> void:
|
||||||
@@ -268,6 +365,9 @@ func _load_resources() -> void:
|
|||||||
gold_label.text = "Gold: --"
|
gold_label.text = "Gold: --"
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if resources_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
return
|
||||||
|
|
||||||
var error: int = resources_request.request(
|
var error: int = resources_request.request(
|
||||||
LOCATIONS_BASE_URL + "/api/resources/me",
|
LOCATIONS_BASE_URL + "/api/resources/me",
|
||||||
PackedStringArray([
|
PackedStringArray([
|
||||||
@@ -294,6 +394,170 @@ func _on_resources_request_completed(result: int, response_code: int, _headers:
|
|||||||
var gold_per_minute: int = int(resources.get("goldPerMinute", 0))
|
var gold_per_minute: int = int(resources.get("goldPerMinute", 0))
|
||||||
gold_label.text = "Gold: %d (+%d/min)" % [gold, gold_per_minute]
|
gold_label.text = "Gold: %d (+%d/min)" % [gold, gold_per_minute]
|
||||||
|
|
||||||
|
var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", ""))
|
||||||
|
gold_tick_anchor_unix = _parse_utc_timestamp(anchor_text)
|
||||||
|
if gold_tick_anchor_unix <= 0.0:
|
||||||
|
gold_tick_anchor_unix = Time.get_unix_time_from_system()
|
||||||
|
_update_tick_bar()
|
||||||
|
|
||||||
|
|
||||||
|
func _load_units() -> void:
|
||||||
|
var access_token: String = str(session.get("accessToken", ""))
|
||||||
|
if access_token.is_empty():
|
||||||
|
unit_label.text = "Scouts: --"
|
||||||
|
return
|
||||||
|
|
||||||
|
if units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
return
|
||||||
|
|
||||||
|
var error: int = units_request.request(
|
||||||
|
LOCATIONS_BASE_URL + "/api/units/me",
|
||||||
|
PackedStringArray([
|
||||||
|
"Authorization: Bearer %s" % access_token
|
||||||
|
]),
|
||||||
|
HTTPClient.METHOD_GET
|
||||||
|
)
|
||||||
|
if error != OK:
|
||||||
|
unit_label.text = "Scouts: ?"
|
||||||
|
|
||||||
|
|
||||||
|
func _on_units_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:
|
||||||
|
unit_label.text = "Scouts: ?"
|
||||||
|
return
|
||||||
|
|
||||||
|
var parsed = JSON.parse_string(body.get_string_from_utf8())
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
unit_label.text = "Scouts: ?"
|
||||||
|
return
|
||||||
|
|
||||||
|
var units: Dictionary = parsed
|
||||||
|
unit_label.text = "Scouts: %d" % int(units.get("completedScouts", 0))
|
||||||
|
|
||||||
|
var training: Array = []
|
||||||
|
var raw_training = units.get("training", [])
|
||||||
|
if typeof(raw_training) == TYPE_ARRAY:
|
||||||
|
training = raw_training
|
||||||
|
if training.is_empty():
|
||||||
|
training_started_unix = 0.0
|
||||||
|
training_completes_unix = 0.0
|
||||||
|
_update_training_bar()
|
||||||
|
return
|
||||||
|
|
||||||
|
if typeof(training[0]) != TYPE_DICTIONARY:
|
||||||
|
return
|
||||||
|
|
||||||
|
var active_training: Dictionary = training[0]
|
||||||
|
training_started_unix = _parse_utc_timestamp(str(active_training.get("startedAtUtc", "")))
|
||||||
|
training_completes_unix = _parse_utc_timestamp(str(active_training.get("completesAtUtc", "")))
|
||||||
|
_update_training_bar()
|
||||||
|
|
||||||
|
|
||||||
|
func _train_scout() -> void:
|
||||||
|
var access_token: String = str(session.get("accessToken", ""))
|
||||||
|
if access_token.is_empty():
|
||||||
|
coordinate_label.text = "No auth token available. Scout training was not started."
|
||||||
|
return
|
||||||
|
|
||||||
|
if train_unit_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
return
|
||||||
|
|
||||||
|
train_button.disabled = true
|
||||||
|
coordinate_label.text = "Starting scout training..."
|
||||||
|
var error: int = train_unit_request.request(
|
||||||
|
LOCATIONS_BASE_URL + "/api/units/train",
|
||||||
|
PackedStringArray([
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"Authorization: Bearer %s" % access_token
|
||||||
|
]),
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
JSON.stringify({ "unitType": "Scout" })
|
||||||
|
)
|
||||||
|
if error != OK:
|
||||||
|
train_button.disabled = false
|
||||||
|
coordinate_label.text = "Could not contact the unit training service."
|
||||||
|
|
||||||
|
|
||||||
|
func _on_train_unit_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||||
|
train_button.disabled = false
|
||||||
|
var text: String = body.get_string_from_utf8()
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
coordinate_label.text = "Scout training request failed."
|
||||||
|
return
|
||||||
|
|
||||||
|
if response_code < 200 or response_code >= 300:
|
||||||
|
coordinate_label.text = "Scout training failed: %s" % text
|
||||||
|
_refresh_player_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
var parsed = JSON.parse_string(text)
|
||||||
|
if typeof(parsed) == TYPE_DICTIONARY:
|
||||||
|
var training: Dictionary = parsed
|
||||||
|
training_started_unix = _parse_utc_timestamp(str(training.get("startedAtUtc", "")))
|
||||||
|
training_completes_unix = _parse_utc_timestamp(str(training.get("completesAtUtc", "")))
|
||||||
|
coordinate_label.text = "Scout training started. Completion takes 1 minute."
|
||||||
|
|
||||||
|
_refresh_player_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_tick_bar() -> void:
|
||||||
|
if not tick_bar or not tick_label:
|
||||||
|
return
|
||||||
|
|
||||||
|
if gold_tick_anchor_unix <= 0.0:
|
||||||
|
tick_bar.value = 0.0
|
||||||
|
tick_label.text = "Next: --"
|
||||||
|
return
|
||||||
|
|
||||||
|
var elapsed: float = max(0.0, Time.get_unix_time_from_system() - gold_tick_anchor_unix)
|
||||||
|
var tick_progress: float = fposmod(elapsed, gold_tick_seconds) / gold_tick_seconds
|
||||||
|
var seconds_remaining: int = int(ceil(gold_tick_seconds - fposmod(elapsed, gold_tick_seconds)))
|
||||||
|
if seconds_remaining >= int(gold_tick_seconds):
|
||||||
|
seconds_remaining = int(gold_tick_seconds)
|
||||||
|
|
||||||
|
tick_bar.value = tick_progress
|
||||||
|
tick_label.text = "Next: %ds" % seconds_remaining
|
||||||
|
|
||||||
|
|
||||||
|
func _update_training_bar() -> void:
|
||||||
|
if not training_bar or not training_label:
|
||||||
|
return
|
||||||
|
|
||||||
|
if training_started_unix <= 0.0 or training_completes_unix <= 0.0:
|
||||||
|
training_bar.value = 0.0
|
||||||
|
training_label.text = "Training: idle"
|
||||||
|
return
|
||||||
|
|
||||||
|
var now: float = Time.get_unix_time_from_system()
|
||||||
|
var duration: float = max(1.0, training_completes_unix - training_started_unix)
|
||||||
|
var elapsed: float = clamp(now - training_started_unix, 0.0, duration)
|
||||||
|
var remaining: int = int(max(0, int(ceil(training_completes_unix - now))))
|
||||||
|
training_bar.value = elapsed / duration
|
||||||
|
|
||||||
|
if remaining <= 0:
|
||||||
|
training_label.text = "Training: ready"
|
||||||
|
else:
|
||||||
|
training_label.text = "Training: %ds" % remaining
|
||||||
|
|
||||||
|
|
||||||
|
func _parse_utc_timestamp(timestamp: String) -> float:
|
||||||
|
if timestamp.is_empty():
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
var normalized := timestamp.strip_edges()
|
||||||
|
var timezone_index := normalized.find("+")
|
||||||
|
if timezone_index > -1:
|
||||||
|
normalized = normalized.substr(0, timezone_index)
|
||||||
|
if normalized.ends_with("Z"):
|
||||||
|
normalized = normalized.substr(0, normalized.length() - 1)
|
||||||
|
|
||||||
|
var fractional_index := normalized.find(".")
|
||||||
|
if fractional_index > -1:
|
||||||
|
normalized = normalized.substr(0, fractional_index)
|
||||||
|
|
||||||
|
normalized = normalized.replace("T", " ")
|
||||||
|
return Time.get_unix_time_from_datetime_string(normalized)
|
||||||
|
|
||||||
|
|
||||||
func _build_escape_menu() -> void:
|
func _build_escape_menu() -> void:
|
||||||
escape_overlay = Control.new()
|
escape_overlay = Control.new()
|
||||||
@@ -393,6 +657,13 @@ func _menu_button(text_value: String) -> Button:
|
|||||||
return button
|
return button
|
||||||
|
|
||||||
|
|
||||||
|
func _small_button(text_value: String) -> Button:
|
||||||
|
var button := _menu_button(text_value)
|
||||||
|
button.custom_minimum_size = Vector2(128, 32)
|
||||||
|
button.add_theme_font_size_override("font_size", 13)
|
||||||
|
return button
|
||||||
|
|
||||||
|
|
||||||
func _find_first_button(node: Node) -> Button:
|
func _find_first_button(node: Node) -> Button:
|
||||||
if node is Button:
|
if node is Button:
|
||||||
return node
|
return node
|
||||||
@@ -421,6 +692,15 @@ func _button_style(color: Color) -> StyleBoxFlat:
|
|||||||
return style
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
func _progress_style(color: Color, border_color: Color) -> StyleBoxFlat:
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = color
|
||||||
|
style.border_color = border_color
|
||||||
|
style.set_border_width_all(1)
|
||||||
|
style.set_corner_radius_all(3)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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.AddControllers();
|
||||||
builder.Services.AddSingleton<LocationService>();
|
builder.Services.AddSingleton<LocationService>();
|
||||||
builder.Services.AddSingleton<ResourceService>();
|
builder.Services.AddSingleton<ResourceService>();
|
||||||
|
builder.Services.AddSingleton<UnitTrainingService>();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c =>
|
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.
|
- `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.
|
- `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.
|
`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.
|
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
|
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. 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)
|
public async Task<PlayerResources> GetAsync(string username)
|
||||||
{
|
{
|
||||||
var resources = await EnsureAsync(username);
|
return await EnsureAsync(username);
|
||||||
return await ApplyGoldGenerationAsync(resources);
|
}
|
||||||
|
|
||||||
|
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)
|
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,
|
"StartingGold": 100,
|
||||||
"HeadquartersGoldPerMinute": 10
|
"HeadquartersGoldPerMinute": 10
|
||||||
},
|
},
|
||||||
|
"Units": {
|
||||||
|
"ScoutGoldCost": 50,
|
||||||
|
"ScoutTrainingSeconds": 60
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ data:
|
|||||||
World__VisibilityDepth: "3"
|
World__VisibilityDepth: "3"
|
||||||
Resources__StartingGold: "100"
|
Resources__StartingGold: "100"
|
||||||
Resources__HeadquartersGoldPerMinute: "10"
|
Resources__HeadquartersGoldPerMinute: "10"
|
||||||
|
Units__ScoutGoldCost: "50"
|
||||||
|
Units__ScoutTrainingSeconds: "60"
|
||||||
|
|||||||
Reference in New Issue
Block a user