Compare commits

...
10 Commits
Author SHA1 Message Date
hz c0e60d622c adding delivery
Deploy Territory Autobattler Auth API / deploy (push) Canceled after 0s
Deploy Territory Autobattler Locations API / deploy (push) Canceled after 0s
k8s smoke test / test (push) Canceled after 0s
2026-05-22 03:11:32 -05:00
hz a2fd9a84e3 fixing offline updates 2026-05-21 18:34:56 -05:00
hz 5530a185a0 updates 2026-05-21 02:21:54 -05:00
hz de42f85835 addressing starvation 2026-05-20 23:57:20 -05:00
hz c89c3a0947 territory claiming modification 2026-05-20 21:10:19 -05:00
hz f142440aca allowing territory to be abandoned, units to be dismissed, and buildings to be destroyed 2026-05-20 19:38:33 -05:00
hz 4a3d3af41c fixing food tick 2026-05-20 19:23:20 -05:00
hz a40c8cf50d destination indicator update 2026-05-20 19:21:09 -05:00
hz 94f3b1e258 Updating movement 2026-05-20 19:11:35 -05:00
hz 84136ad534 Movement enhancements 2026-05-20 14:33:12 -05:00
14 changed files with 1725 additions and 218 deletions
@@ -16,9 +16,11 @@ var resources_request: HTTPRequest
var units_request: HTTPRequest
var train_unit_request: HTTPRequest
var move_units_request: HTTPRequest
var dismiss_units_request: HTTPRequest
var worker_request: HTTPRequest
var claim_request: HTTPRequest
var build_request: HTTPRequest
var territory_action_request: HTTPRequest
var refresh_request: HTTPRequest
var resources_timer: Timer
var auth_check_timer: Timer
@@ -27,7 +29,7 @@ var refresh_dialog: ConfirmationDialog
var session: Dictionary = {}
var escape_overlay: Control
var escape_panel: PanelContainer
var gold_label: Label
var gold_label: RichTextLabel
var tick_bar: ProgressBar
var tick_label: Label
var resource_text := "Gold: --\nWood: --\nOre: --\nFood: --"
@@ -44,7 +46,10 @@ var building_box: VBoxContainer
var building_label: Label
var building_bar: ProgressBar
var progress_panel: PanelContainer
var progress_stack: VBoxContainer
var action_buttons: HBoxContainer
var training_items: Array[Dictionary] = []
var training_item_boxes: Array[VBoxContainer] = []
var training_started_unix := 0.0
var training_completes_unix := 0.0
var training_completion_refresh_requested := false
@@ -58,10 +63,14 @@ var build_log_camp_button: Button
var build_mine_button: Button
var build_farm_button: Button
var build_barracks_button: Button
var destroy_building_button: Button
var abandon_territory_button: Button
var unit_action_popup: PopupPanel
var unit_action_move_button: Button
var unit_action_claim_button: Button
var unit_action_assign_button: Button
var unit_action_deassign_button: Button
var unit_action_dismiss_button: Button
var selected_q := 0
var selected_r := 0
var selected_can_claim := false
@@ -69,14 +78,20 @@ var selected_can_build_log_camp := false
var selected_can_build_mine := false
var selected_can_build_farm := false
var selected_can_build_barracks := false
var selected_can_destroy_building := false
var selected_can_abandon_territory := false
var selected_can_train_archer := false
var selected_is_home := false
var completed_scout_count := 0
var completed_archer_count := 0
var completed_peasant_count := 0
var selected_scout_q := 9999
var selected_scout_r := 9999
var selected_scout_count := 0
var selected_peasant_id := ""
var selected_peasant_q := 9999
var selected_peasant_r := 9999
var selected_peasant_count := 0
var selected_archer_q := 9999
var selected_archer_r := 9999
var selected_archer_count := 0
@@ -84,6 +99,8 @@ var pending_move_q := 9999
var pending_move_r := 9999
var refresh_prompt_active := false
var refresh_request_active := false
var units_reload_queued := false
var visible_reload_queued := false
func _ready() -> void:
@@ -262,6 +279,16 @@ func _build_ui() -> void:
build_barracks_button.pressed.connect(_build_barracks)
action_buttons.add_child(build_barracks_button)
destroy_building_button = _small_button("Destroy Building")
destroy_building_button.visible = false
destroy_building_button.pressed.connect(_destroy_selected_building)
action_buttons.add_child(destroy_building_button)
abandon_territory_button = _small_button("Give Up")
abandon_territory_button.visible = false
abandon_territory_button.pressed.connect(_abandon_selected_territory)
action_buttons.add_child(abandon_territory_button)
unit_label = Label.new()
unit_label.text = "Scouts: -- | Archers: -- | Queue: --"
unit_label.custom_minimum_size = Vector2(180, 0)
@@ -293,6 +320,10 @@ func _build_ui() -> void:
move_units_request.request_completed.connect(_on_move_units_request_completed)
add_child(move_units_request)
dismiss_units_request = HTTPRequest.new()
dismiss_units_request.request_completed.connect(_on_dismiss_units_request_completed)
add_child(dismiss_units_request)
worker_request = HTTPRequest.new()
worker_request.request_completed.connect(_on_worker_request_completed)
add_child(worker_request)
@@ -305,6 +336,10 @@ func _build_ui() -> void:
build_request.request_completed.connect(_on_build_request_completed)
add_child(build_request)
territory_action_request = HTTPRequest.new()
territory_action_request.request_completed.connect(_on_territory_action_request_completed)
add_child(territory_action_request)
refresh_request = HTTPRequest.new()
refresh_request.request_completed.connect(_on_refresh_request_completed)
add_child(refresh_request)
@@ -356,10 +391,14 @@ func _build_map_overlays(map_layer: Control) -> void:
resource_stack.add_theme_constant_override("separation", 6)
resource_margin.add_child(resource_stack)
gold_label = Label.new()
gold_label = RichTextLabel.new()
gold_label.text = resource_text
gold_label.bbcode_enabled = true
gold_label.fit_content = true
gold_label.scroll_active = false
gold_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
gold_label.custom_minimum_size = Vector2(190, 74)
gold_label.add_theme_font_size_override("font_size", 14)
gold_label.add_theme_color_override("font_color", Color(0.95, 0.78, 0.32))
resource_stack.add_child(gold_label)
tick_label = Label.new()
@@ -398,12 +437,13 @@ func _build_map_overlays(map_layer: Control) -> void:
progress_margin.add_theme_constant_override("margin_bottom", 10)
progress_panel.add_child(progress_margin)
var progress_stack := VBoxContainer.new()
progress_stack = VBoxContainer.new()
progress_stack.add_theme_constant_override("separation", 8)
progress_margin.add_child(progress_stack)
training_box = _progress_overlay_item("Training")
progress_stack.add_child(training_box)
training_item_boxes.append(training_box)
training_label = training_box.get_child(0) as Label
training_bar = training_box.get_child(1) as ProgressBar
@@ -474,6 +514,11 @@ func _build_unit_action_menu() -> void:
unit_action_move_button.pressed.connect(_confirm_unit_move)
actions.add_child(unit_action_move_button)
unit_action_claim_button = _small_button("Claim")
unit_action_claim_button.custom_minimum_size = Vector2(92, 34)
unit_action_claim_button.pressed.connect(_confirm_scout_claim)
actions.add_child(unit_action_claim_button)
unit_action_assign_button = _small_button("Assign")
unit_action_assign_button.custom_minimum_size = Vector2(92, 34)
unit_action_assign_button.pressed.connect(_confirm_worker_assign)
@@ -484,6 +529,11 @@ func _build_unit_action_menu() -> void:
unit_action_deassign_button.pressed.connect(_confirm_worker_deassign)
actions.add_child(unit_action_deassign_button)
unit_action_dismiss_button = _small_button("Dismiss")
unit_action_dismiss_button.custom_minimum_size = Vector2(92, 34)
unit_action_dismiss_button.pressed.connect(_confirm_unit_dismiss)
actions.add_child(unit_action_dismiss_button)
func _on_tile_hovered(q: int, r: int, terrain: String) -> void:
if coordinate_label:
@@ -502,6 +552,8 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void:
selected_can_build_mine = _can_build_mine_on_selected_tile()
selected_can_build_farm = _can_build_farm_on_selected_tile()
selected_can_build_barracks = _can_build_barracks_on_selected_tile()
selected_can_destroy_building = _can_destroy_building_on_selected_tile()
selected_can_abandon_territory = _can_abandon_selected_tile()
selected_can_train_archer = _selected_tile_has_built_barracks()
_update_action_buttons()
if coordinate_label:
@@ -513,17 +565,35 @@ func _handle_unit_selection_or_move(q: int, r: int) -> bool:
return false
var archer_count: int = grid_view.get_archer_count_at(q, r)
var scout_count: int = grid_view.get_scout_count_at(q, r)
var peasant_id: String = grid_view.get_peasant_id_at(q, r)
if not peasant_id.is_empty():
var peasant_count: int = grid_view.get_peasant_count_at(q, r)
if scout_count > 0:
if unit_action_popup:
unit_action_popup.hide()
selected_scout_q = q
selected_scout_r = r
selected_scout_count = scout_count
selected_archer_count = 0
selected_peasant_count = 0
selected_peasant_id = ""
grid_view.set_selected_unit_tile(q, r)
if coordinate_label:
coordinate_label.text = "Selected Scout squad: %d at q:%d r:%d. Right-click a visible tile for orders." % [scout_count, q, r]
return true
if peasant_count > 0:
if unit_action_popup:
unit_action_popup.hide()
selected_peasant_id = peasant_id
selected_peasant_q = q
selected_peasant_r = r
selected_peasant_count = peasant_count
selected_scout_count = 0
selected_archer_count = 0
grid_view.set_selected_unit_tile(q, r)
if coordinate_label:
coordinate_label.text = "Selected Peasant at q:%d r:%d. Right-click a building to assign work." % [q, r]
coordinate_label.text = "Selected Peasant squad: %d at q:%d r:%d. Right-click controlled territory to move or a building to assign work." % [peasant_count, q, r]
return true
if archer_count > 0:
@@ -532,10 +602,12 @@ func _handle_unit_selection_or_move(q: int, r: int) -> bool:
selected_archer_q = q
selected_archer_r = r
selected_archer_count = archer_count
selected_scout_count = 0
selected_peasant_id = ""
selected_peasant_count = 0
grid_view.set_selected_unit_tile(q, r)
if coordinate_label:
coordinate_label.text = "Selected Archer squad: %d at q:%d r:%d. Right-click an adjacent visible tile for orders." % [archer_count, q, r]
coordinate_label.text = "Selected Archer squad: %d at q:%d r:%d. Right-click a visible tile for orders." % [archer_count, q, r]
return true
return false
@@ -547,23 +619,24 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position
var location: Dictionary = grid_view.get_visible_location(q, r)
var has_building := _location_has_completed_building(location)
var has_worker := not _nullable_string(location.get("workerUnitId", "")).is_empty()
if selected_archer_count <= 0 and selected_peasant_id.is_empty() and not has_worker:
var selected_peasants := selected_peasant_count > 0
var selected_scouts := selected_scout_count > 0
var selected_archers := selected_archer_count > 0
var can_scout_claim := selected_scouts and _can_claim_tile(q, r)
var is_selected_unit_tile := (selected_archers and q == selected_archer_q and r == selected_archer_r) or (selected_scouts and q == selected_scout_q and r == selected_scout_r) or (selected_peasants and q == selected_peasant_q and r == selected_peasant_r)
if not selected_archers and not selected_scouts and not selected_peasants and not has_worker:
if unit_action_popup:
unit_action_popup.hide()
if coordinate_label:
coordinate_label.text = "Select a unit or right-click a staffed building."
return
if selected_archer_count > 0 and q == selected_archer_q and r == selected_archer_r:
if selected_peasants and not is_selected_unit_tile and not grid_view.is_owned_tile(q, r):
if coordinate_label:
coordinate_label.text = "Archer squad is already on that tile."
coordinate_label.text = "Peasants can only move within controlled territory."
return
if selected_archer_count > 0 and not grid_view.is_neighbor(selected_archer_q, selected_archer_r, q, r):
if not is_selected_unit_tile and not grid_view.is_visible_tile(q, r):
if coordinate_label:
coordinate_label.text = "Archers can only move to an adjacent tile."
return
if not grid_view.is_visible_tile(q, r):
if coordinate_label:
coordinate_label.text = "Archers can only move to visible tiles."
coordinate_label.text = "Units can only move to visible tiles."
return
pending_move_q = q
@@ -571,10 +644,19 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position
if coordinate_label:
coordinate_label.text = "Orders for q:%d r:%d." % [q, r]
if unit_action_popup:
unit_action_move_button.visible = selected_archer_count > 0
unit_action_move_button.visible = not is_selected_unit_tile and (selected_archers or selected_scouts or selected_peasants)
unit_action_claim_button.visible = can_scout_claim
unit_action_assign_button.visible = not selected_peasant_id.is_empty() and has_building and not has_worker
unit_action_deassign_button.visible = has_worker
var popup_size := Vector2i(116, 132)
unit_action_dismiss_button.visible = is_selected_unit_tile and (selected_archers or selected_scouts or selected_peasants)
var visible_action_count := 0
for button in [unit_action_move_button, unit_action_claim_button, unit_action_assign_button, unit_action_deassign_button, unit_action_dismiss_button]:
if button.visible:
visible_action_count += 1
if visible_action_count == 0:
unit_action_popup.hide()
return
var popup_size := Vector2i(116, 16 + visible_action_count * 40)
var visible_rect := get_viewport().get_visible_rect()
var max_x := int(max(0.0, visible_rect.size.x - popup_size.x - 8.0))
var max_y := int(max(0.0, visible_rect.size.y - popup_size.y - 8.0))
@@ -587,7 +669,26 @@ func _confirm_unit_move() -> void:
unit_action_popup.hide()
if pending_move_q == 9999:
return
if selected_archer_count > 0:
_move_selected_archers(pending_move_q, pending_move_r)
elif selected_scout_count > 0:
_move_selected_scouts(pending_move_q, pending_move_r)
elif selected_peasant_count > 0:
_move_selected_peasants(pending_move_q, pending_move_r)
func _confirm_scout_claim() -> void:
if unit_action_popup:
unit_action_popup.hide()
if pending_move_q == 9999:
return
if not _can_claim_tile(pending_move_q, pending_move_r):
coordinate_label.text = "Scouts can only claim visible neutral or enemy territory adjacent to controlled territory."
return
selected_q = pending_move_q
selected_r = pending_move_r
selected_can_claim = true
_move_selected_scouts(pending_move_q, pending_move_r, true)
func _confirm_worker_assign() -> void:
@@ -606,12 +707,24 @@ func _confirm_worker_deassign() -> void:
_request_worker_action("deassign-worker", { "q": pending_move_q, "r": pending_move_r })
func _confirm_unit_dismiss() -> void:
if unit_action_popup:
unit_action_popup.hide()
if selected_archer_count > 0:
_request_unit_dismiss("Archer", selected_archer_q, selected_archer_r)
elif selected_scout_count > 0:
_request_unit_dismiss("Scout", selected_scout_q, selected_scout_r)
elif selected_peasant_count > 0:
_request_unit_dismiss("Peasant", selected_peasant_q, selected_peasant_r)
func _location_has_completed_building(location: Dictionary) -> bool:
if location.is_empty():
return false
var building_type: String = _nullable_string(location.get("buildingType", ""))
var building_status: String = _nullable_string(location.get("buildingStatus", ""))
return not building_type.is_empty() and (building_status == "Built" or building_status.is_empty())
var can_have_worker := building_type == "LogCamp" or building_type == "Mine" or building_type == "Farm"
return can_have_worker and (building_status == "Built" or building_status.is_empty())
func _can_build_log_camp_on_selected_tile() -> bool:
@@ -635,6 +748,36 @@ func _can_build_barracks_on_selected_tile() -> bool:
return kind != "Home"
func _can_destroy_building_on_selected_tile() -> bool:
if not grid_view:
return false
var location: Dictionary = grid_view.get_visible_location(selected_q, selected_r)
if location.is_empty():
return false
var username: String = str(session.get("username", ""))
var owner_username: String = str(location.get("ownerUsername", ""))
var kind: String = str(location.get("kind", ""))
var building_type: String = _nullable_string(location.get("buildingType", ""))
return owner_username == username and kind != "Home" and not building_type.is_empty()
func _can_abandon_selected_tile() -> bool:
if not grid_view:
return false
var location: Dictionary = grid_view.get_visible_location(selected_q, selected_r)
if location.is_empty():
return false
var username: String = str(session.get("username", ""))
var owner_username: String = str(location.get("ownerUsername", ""))
var kind: String = str(location.get("kind", ""))
var building_type: String = _nullable_string(location.get("buildingType", ""))
return owner_username == username and kind != "Home" and building_type.is_empty()
func _can_build_on_selected_tile(required_terrain: String) -> bool:
if not grid_view:
return false
@@ -652,7 +795,24 @@ func _can_build_on_selected_tile(required_terrain: String) -> bool:
func _can_claim_selected_tile() -> bool:
return selected_can_claim and completed_scout_count > 0 and grid_view and grid_view.has_owned_neighbor(selected_q, selected_r)
return selected_can_claim and selected_scout_count > 0 and completed_scout_count > 0 and grid_view and grid_view.has_owned_neighbor(selected_q, selected_r)
func _can_claim_tile(q: int, r: int) -> bool:
if not grid_view:
return false
if completed_scout_count <= 0:
return false
if not grid_view.is_visible_tile(q, r):
return false
var location: Dictionary = grid_view.get_visible_location(q, r)
if location.is_empty():
return false
var username: String = str(session.get("username", ""))
var owner_username: String = _nullable_string(location.get("ownerUsername", ""))
return owner_username != username and grid_view.has_owned_neighbor(q, r)
func _update_action_buttons() -> void:
@@ -680,15 +840,25 @@ func _update_action_buttons() -> void:
if build_barracks_button:
build_barracks_button.visible = selected_can_build_barracks
build_barracks_button.disabled = false
if destroy_building_button:
destroy_building_button.visible = selected_can_destroy_building
destroy_building_button.disabled = false
if abandon_territory_button:
abandon_territory_button.visible = selected_can_abandon_territory
abandon_territory_button.disabled = false
func _clear_selected_archers() -> void:
selected_scout_q = 9999
selected_scout_r = 9999
selected_scout_count = 0
selected_archer_q = 9999
selected_archer_r = 9999
selected_archer_count = 0
selected_peasant_id = ""
selected_peasant_q = 9999
selected_peasant_r = 9999
selected_peasant_count = 0
pending_move_q = 9999
pending_move_r = 9999
if unit_action_popup:
@@ -900,6 +1070,8 @@ func _on_home_request_completed(result: int, response_code: int, _headers: Packe
grid_view.set_home_location(q, r)
coordinate_label.text = "Home territory assigned at q:%d r:%d | %s" % [q, r, terrain]
_load_visible_locations()
_refresh_player_state()
resources_timer.start()
func _load_visible_locations() -> void:
@@ -908,6 +1080,10 @@ func _load_visible_locations() -> void:
coordinate_label.text = "No auth token available. Visibility was not loaded."
return
if visible_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
visible_reload_queued = true
return
var error: int = visible_request.request(
LOCATIONS_BASE_URL + "/api/locations/visible",
PackedStringArray([
@@ -922,16 +1098,19 @@ func _load_visible_locations() -> void:
func _on_visible_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
coordinate_label.text = "Visible territory request failed."
_run_queued_visible_reload()
return
var text: String = body.get_string_from_utf8()
if response_code < 200 or response_code >= 300:
coordinate_label.text = "Visible territory failed: %s" % text
_run_queued_visible_reload()
return
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_ARRAY:
coordinate_label.text = "Locations service returned invalid visibility data."
_run_queued_visible_reload()
return
var username: String = str(session.get("username", ""))
@@ -939,8 +1118,14 @@ func _on_visible_request_completed(result: int, response_code: int, _headers: Pa
grid_view.set_visible_locations(visible_locations, username)
_sync_active_building_timer(visible_locations, username)
coordinate_label.text = "Visible territory loaded. Sight extends 3 hexes beyond controlled land."
_refresh_player_state()
resources_timer.start()
_run_queued_visible_reload()
func _run_queued_visible_reload() -> void:
if not visible_reload_queued:
return
visible_reload_queued = false
call_deferred("_load_visible_locations")
func _refresh_player_state() -> void:
@@ -948,6 +1133,13 @@ func _refresh_player_state() -> void:
_load_units()
func _queue_units_reload() -> void:
if units_request.get_http_client_status() == HTTPClient.STATUS_DISCONNECTED:
_load_units()
else:
units_reload_queued = true
func _load_resources() -> void:
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
@@ -995,20 +1187,12 @@ func _on_resources_request_completed(result: int, response_code: int, _headers:
var food: int = int(resources.get("food", 0))
var food_per_minute: int = int(resources.get("foodPerMinute", 0))
var food_capacity: int = int(resources.get("foodCapacity", 100))
resource_text = "Gold: %d/%d (+%d/min)\nWood: %d/%d (+%d/min)\nOre: %d/%d (+%d/min)\nFood: %d/%d (+%d/min)" % [
gold,
gold_capacity,
gold_per_minute,
wood,
wood_capacity,
wood_per_minute,
ore,
ore_capacity,
ore_per_minute,
food,
food_capacity,
food_per_minute
]
resource_text = "\n".join([
_resource_line("Gold", gold, gold_capacity, gold_per_minute),
_resource_line("Wood", wood, wood_capacity, wood_per_minute),
_resource_line("Ore", ore, ore_capacity, ore_per_minute),
_resource_line("Food", food, food_capacity, food_per_minute)
])
gold_label.text = resource_text
var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", ""))
@@ -1016,6 +1200,28 @@ func _on_resources_request_completed(result: int, response_code: int, _headers:
if gold_tick_anchor_unix <= 0.0:
gold_tick_anchor_unix = Time.get_unix_time_from_system()
_update_tick_bar()
_queue_units_reload()
func _resource_line(resource_name: String, amount: int, capacity: int, rate: int) -> String:
var value_color := "#f2c752"
var rate_color := "#d9574f" if rate < 0 else "#f2c752"
return "[color=%s]%s: %d/%d[/color] [color=%s](%s)[/color]" % [
value_color,
resource_name,
amount,
capacity,
rate_color,
_resource_rate_text(rate)
]
func _resource_rate_text(rate: int) -> String:
if rate > 0:
return "+%d/min" % rate
if rate < 0:
return "%d/min" % rate
return "0/min"
func _load_units() -> void:
@@ -1064,10 +1270,16 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack
if typeof(raw_units) == TYPE_ARRAY:
unit_instances = raw_units
grid_view.set_unit_instances(unit_instances)
_clear_removed_selected_unit()
unit_label.text = "Scouts: %d | Peasants: %d | Archers: %d | Queue: %d" % [completed_scout_count, completed_peasant_count, completed_archer_count, training.size()]
_update_action_buttons()
_load_visible_locations()
if units_reload_queued:
units_reload_queued = false
call_deferred("_load_units")
if training.is_empty():
training_items.clear()
training_started_unix = 0.0
training_completes_unix = 0.0
active_training_label = "Training"
@@ -1075,17 +1287,40 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack
_update_training_bar()
return
if typeof(training[0]) != TYPE_DICTIONARY:
return
training_items.clear()
for item in training:
if typeof(item) != TYPE_DICTIONARY:
continue
var training_item: Dictionary = item
training_items.append({
"label": _unit_display_name(str(training_item.get("unitType", "Training"))),
"started": _parse_utc_timestamp(str(training_item.get("startedAtUtc", ""))),
"completes": _parse_utc_timestamp(str(training_item.get("completesAtUtc", "")))
})
var active_training: Dictionary = training[0]
active_training_label = _unit_display_name(str(active_training.get("unitType", "Training")))
training_started_unix = _parse_utc_timestamp(str(active_training.get("startedAtUtc", "")))
training_completes_unix = _parse_utc_timestamp(str(active_training.get("completesAtUtc", "")))
if training_items.is_empty():
training_started_unix = 0.0
training_completes_unix = 0.0
else:
var active_training: Dictionary = training_items[0]
active_training_label = str(active_training.get("label", "Training"))
training_started_unix = float(active_training.get("started", 0.0))
training_completes_unix = float(active_training.get("completes", 0.0))
training_completion_refresh_requested = false
_update_training_bar()
func _clear_removed_selected_unit() -> void:
if not grid_view:
return
if selected_scout_count > 0 and grid_view.get_scout_count_at(selected_scout_q, selected_scout_r) <= 0:
_clear_selected_archers()
elif selected_archer_count > 0 and grid_view.get_archer_count_at(selected_archer_q, selected_archer_r) <= 0:
_clear_selected_archers()
elif selected_peasant_count > 0 and grid_view.get_peasant_count_at(selected_peasant_q, selected_peasant_r) <= 0:
_clear_selected_archers()
func _train_scout() -> void:
_request_unit_training("Scout")
@@ -1170,9 +1405,6 @@ func _move_selected_archers(to_q: int, to_r: int) -> void:
if to_q == selected_archer_q and to_r == selected_archer_r:
coordinate_label.text = "Archer squad is already on that tile."
return
if not grid_view.is_neighbor(selected_archer_q, selected_archer_r, to_q, to_r):
coordinate_label.text = "Archers can only move to an adjacent tile."
return
if not grid_view.is_visible_tile(to_q, to_r):
coordinate_label.text = "Archers can only move to visible tiles."
return
@@ -1205,21 +1437,143 @@ func _move_selected_archers(to_q: int, to_r: int) -> void:
coordinate_label.text = "Could not contact the unit move service."
func _move_selected_scouts(to_q: int, to_r: int, claim_on_arrival: bool = false) -> void:
if selected_scout_count <= 0:
return
if to_q == selected_scout_q and to_r == selected_scout_r:
coordinate_label.text = "Scout squad is already on that tile."
return
if not grid_view.is_visible_tile(to_q, to_r):
coordinate_label.text = "Scouts can only move to visible tiles."
return
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
coordinate_label.text = "No auth token available. Scout squad was not moved."
return
if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
coordinate_label.text = "Ordering Scout to claim q:%d r:%d..." % [to_q, to_r] if claim_on_arrival else "Ordering %d Scouts to move to q:%d r:%d..." % [selected_scout_count, to_q, to_r]
var error: int = move_units_request.request(
LOCATIONS_BASE_URL + "/api/units/move",
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % access_token
]),
HTTPClient.METHOD_POST,
JSON.stringify({
"unitType": "Scout",
"fromQ": selected_scout_q,
"fromR": selected_scout_r,
"toQ": to_q,
"toR": to_r,
"claimOnArrival": claim_on_arrival
})
)
if error != OK:
coordinate_label.text = "Could not contact the unit move service."
func _move_selected_peasants(to_q: int, to_r: int) -> void:
if selected_peasant_count <= 0:
return
if to_q == selected_peasant_q and to_r == selected_peasant_r:
coordinate_label.text = "Peasant squad is already on that tile."
return
if not grid_view.is_owned_tile(to_q, to_r):
coordinate_label.text = "Peasants can only move within controlled territory."
return
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
coordinate_label.text = "No auth token available. Peasant squad was not moved."
return
if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
coordinate_label.text = "Ordering %d Peasants to move to q:%d r:%d..." % [selected_peasant_count, to_q, to_r]
var error: int = move_units_request.request(
LOCATIONS_BASE_URL + "/api/units/move",
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % access_token
]),
HTTPClient.METHOD_POST,
JSON.stringify({
"unitType": "Peasant",
"fromQ": selected_peasant_q,
"fromR": selected_peasant_r,
"toQ": to_q,
"toR": to_r
})
)
if error != OK:
coordinate_label.text = "Could not contact the unit move service."
func _on_move_units_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var text: String = body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
coordinate_label.text = "Archer move request failed."
coordinate_label.text = "Unit move request failed."
return
if response_code < 200 or response_code >= 300:
coordinate_label.text = "Archer move failed: %s" % text
coordinate_label.text = "Unit move failed: %s" % text
_refresh_player_state()
return
var parsed = JSON.parse_string(text)
if typeof(parsed) == TYPE_ARRAY and grid_view:
grid_view.upsert_unit_instances(parsed)
_clear_selected_archers()
coordinate_label.text = "Unit movement ordered."
_load_visible_locations()
_load_units()
func _request_unit_dismiss(unit_type: String, q: int, r: int) -> void:
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
coordinate_label.text = "No auth token available. Unit squad was not dismissed."
return
if dismiss_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
coordinate_label.text = "Dismissing %s squad at q:%d r:%d..." % [unit_type, q, r]
var error: int = dismiss_units_request.request(
LOCATIONS_BASE_URL + "/api/units/dismiss",
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % access_token
]),
HTTPClient.METHOD_POST,
JSON.stringify({ "unitType": unit_type, "q": q, "r": r })
)
if error != OK:
coordinate_label.text = "Could not contact the unit dismiss service."
func _on_dismiss_units_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var text: String = body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
coordinate_label.text = "Unit dismiss request failed."
return
if response_code < 200 or response_code >= 300:
coordinate_label.text = "Unit dismiss failed: %s" % text
_refresh_player_state()
return
_clear_selected_archers()
coordinate_label.text = "Archer squad movement ordered. It will arrive on the next tick."
coordinate_label.text = "Unit squad dismissed."
_load_visible_locations()
_load_units()
_refresh_player_state()
func _request_worker_action(endpoint: String, body: Dictionary) -> void:
@@ -1263,6 +1617,9 @@ func _claim_selected_tile() -> void:
if not selected_can_claim:
coordinate_label.text = "Select a visible unclaimed tile to send a scout."
return
if selected_scout_count <= 0:
coordinate_label.text = "Select a Scout, then choose Claim on the target tile."
return
if completed_scout_count <= 0:
coordinate_label.text = "Train a scout before claiming territory."
return
@@ -1275,23 +1632,8 @@ func _claim_selected_tile() -> void:
coordinate_label.text = "No auth token available. Territory was not claimed."
return
if claim_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
claim_button.disabled = true
coordinate_label.text = "Sending scout to claim q:%d r:%d..." % [selected_q, selected_r]
var error: int = claim_request.request(
LOCATIONS_BASE_URL + "/api/locations/claim",
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % access_token
]),
HTTPClient.METHOD_POST,
JSON.stringify({ "q": selected_q, "r": selected_r })
)
if error != OK:
_update_action_buttons()
coordinate_label.text = "Could not contact the locations service."
_move_selected_scouts(selected_q, selected_r, true)
func _on_claim_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
@@ -1346,6 +1688,70 @@ func _build_barracks() -> void:
_request_building("Barracks")
func _destroy_selected_building() -> void:
if not selected_can_destroy_building:
coordinate_label.text = "Select controlled territory with a non-town-hall building."
return
_request_territory_action("destroy-building", "Destroying building")
func _abandon_selected_territory() -> void:
if not selected_can_abandon_territory:
coordinate_label.text = "Select controlled territory without a building."
return
_request_territory_action("abandon", "Giving up territory")
func _request_territory_action(endpoint: String, message: String) -> void:
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
coordinate_label.text = "No auth token available. Territory action failed."
return
if territory_action_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
destroy_building_button.disabled = true
abandon_territory_button.disabled = true
coordinate_label.text = "%s at q:%d r:%d..." % [message, selected_q, selected_r]
var error: int = territory_action_request.request(
LOCATIONS_BASE_URL + "/api/locations/" + endpoint,
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % access_token
]),
HTTPClient.METHOD_POST,
JSON.stringify({ "q": selected_q, "r": selected_r })
)
if error != OK:
_update_action_buttons()
coordinate_label.text = "Could not contact the locations service."
func _on_territory_action_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var text: String = body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
coordinate_label.text = "Territory action request failed."
_update_action_buttons()
return
if response_code < 200 or response_code >= 300:
coordinate_label.text = "Territory action failed: %s" % text
_update_action_buttons()
_refresh_player_state()
return
selected_can_destroy_building = false
selected_can_abandon_territory = false
_update_action_buttons()
coordinate_label.text = "Territory updated."
_load_visible_locations()
_load_units()
_refresh_player_state()
func _request_building(building_type: String) -> void:
var access_token: String = str(session.get("accessToken", ""))
if access_token.is_empty():
@@ -1395,7 +1801,10 @@ func _on_build_request_completed(result: int, response_code: int, _headers: Pack
selected_can_build_log_camp = false
selected_can_build_mine = false
selected_can_build_farm = false
selected_can_build_barracks = false
selected_can_destroy_building = true
selected_can_abandon_territory = false
_update_action_buttons()
var parsed = JSON.parse_string(text)
if typeof(parsed) == TYPE_DICTIONARY:
@@ -1430,30 +1839,55 @@ func _update_tick_bar() -> void:
func _update_training_bar() -> void:
if not training_bar or not training_label:
if not training_bar or not training_label or not progress_stack:
return
if training_started_unix <= 0.0 or training_completes_unix <= 0.0:
training_box.visible = false
if training_items.is_empty():
for box in training_item_boxes:
box.visible = false
training_bar.value = 0.0
training_label.text = "Training: idle"
_update_progress_panel_visibility()
return
training_box.visible = true
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
while training_item_boxes.size() < training_items.size():
var box := _progress_overlay_item("Training")
progress_stack.add_child(box)
progress_stack.move_child(building_box, progress_stack.get_child_count() - 1)
training_item_boxes.append(box)
var any_ready := false
for index in range(training_item_boxes.size()):
var box: VBoxContainer = training_item_boxes[index]
if index >= training_items.size():
box.visible = false
continue
var item: Dictionary = training_items[index]
var label := box.get_child(0) as Label
var bar := box.get_child(1) as ProgressBar
var started := float(item.get("started", 0.0))
var completes := float(item.get("completes", 0.0))
var item_label := str(item.get("label", "Training"))
if started <= 0.0 or completes <= 0.0:
box.visible = false
continue
box.visible = true
var duration: float = max(1.0, completes - started)
var elapsed: float = clamp(now - started, 0.0, duration)
var remaining: int = int(max(0, int(ceil(completes - now))))
bar.value = elapsed / duration
if remaining <= 0:
training_label.text = "%s: ready" % active_training_label
if not training_completion_refresh_requested:
label.text = "%s: ready" % item_label
any_ready = true
else:
label.text = "%s: %ds" % [item_label, remaining]
if any_ready and not training_completion_refresh_requested:
training_completion_refresh_requested = true
_load_units()
else:
training_label.text = "%s: %ds" % [active_training_label, remaining]
_update_progress_panel_visibility()
@@ -1489,7 +1923,12 @@ func _update_building_timer() -> void:
func _update_progress_panel_visibility() -> void:
if progress_panel:
progress_panel.visible = (training_box and training_box.visible) or (building_box and building_box.visible)
var has_training := false
for box in training_item_boxes:
if box.visible:
has_training = true
break
progress_panel.visible = has_training or (building_box and building_box.visible)
func _sync_active_building_timer(locations: Array, username: String) -> void:
@@ -122,6 +122,9 @@ func _draw() -> void:
for tile in sorted_tiles:
_draw_tile(tile)
_draw_movement_indicators()
func _draw_grid_lines() -> void:
var line_color: Color = Color(0.12, 0.17, 0.17, 0.35)
for x in range(-8, 9):
@@ -178,6 +181,7 @@ func _draw_tile(tile: Dictionary) -> void:
var visible_location: Dictionary = visible_tiles.get(key, {})
var building_type: String = _nullable_string(visible_location.get("buildingType", ""))
var building_status: String = _nullable_string(visible_location.get("buildingStatus", ""))
var worker_unit_id: String = _nullable_string(visible_location.get("workerUnitId", ""))
if coord == home_tile:
_draw_town_hall_marker(center, height)
elif building_type == "LogCamp" and (building_status == "Built" or building_status.is_empty()):
@@ -190,10 +194,15 @@ func _draw_tile(tile: Dictionary) -> void:
_draw_barracks_marker(center, height)
elif not building_type.is_empty():
_draw_building_marker(center, height)
if _building_needs_worker(building_type, building_status, worker_unit_id):
_draw_missing_worker_marker(center, height)
var archer_count := _archer_count_at(q, r)
if archer_count > 0:
_draw_archer_unit_marker(center, height, archer_count)
var scout_count := _scout_count_at(q, r)
if scout_count > 0:
_draw_scout_unit_marker(center, height, scout_count)
var peasant_count := _peasant_count_at(q, r)
if peasant_count > 0:
_draw_peasant_unit_marker(center, height, peasant_count)
@@ -243,6 +252,111 @@ func _draw_control_layer(control_tiles: Array, control: String, layer_scale: flo
)
func _draw_movement_indicators() -> void:
for unit in unit_instances:
var status := str(unit.get("status", ""))
if status != "Moving" and status != "MovingToWork" and status != "DeliveringToTownHall" and status != "ReturningToWork":
continue
if unit.get("destinationQ", null) == null or unit.get("destinationR", null) == null:
continue
var from_q := int(unit.get("q", 0))
var from_r := int(unit.get("r", 0))
var to_q := int(unit.get("destinationQ", 0))
var to_r := int(unit.get("destinationR", 0))
var route := _movement_route(Vector2i(from_q, from_r), Vector2i(to_q, to_r), str(unit.get("unitType", "")))
if route.size() < 2:
continue
var to_center := _tile_center(to_q, to_r)
var target_tile: Dictionary = _tile_data(to_q, to_r)
var target_height := float(target_tile.get("height", _tile_height(to_q, to_r)))
var marker_color := Color(0.98, 0.82, 0.28, 0.92) if str(unit.get("unitType", "")) == "Peasant" else Color(0.38, 0.86, 1.0, 0.92)
for index in range(route.size() - 1):
var from_center := _tile_center(route[index].x, route[index].y)
var next_center := _tile_center(route[index + 1].x, route[index + 1].y)
draw_line(from_center, next_center, Color(marker_color.r, marker_color.g, marker_color.b, 0.72), 3.0)
draw_circle(from_center, 4.0, Color(marker_color.r, marker_color.g, marker_color.b, 0.88))
if index > 0:
draw_circle(from_center, 2.5, Color(marker_color.r, marker_color.g, marker_color.b, 0.95))
draw_polyline(_closed_points(_hex_points(to_center, target_height + 3.0)), marker_color, 4.0)
draw_circle(to_center, 7.0, Color(marker_color.r, marker_color.g, marker_color.b, 0.76))
func _movement_route(start: Vector2i, target: Vector2i, unit_type: String) -> Array[Vector2i]:
if unit_type == "Peasant":
var owned_route := _owned_route(start, target)
if not owned_route.is_empty():
return owned_route
return _direct_hex_route(start, target)
func _owned_route(start: Vector2i, target: Vector2i) -> Array[Vector2i]:
if not owned_tiles.has(_tile_key(target.x, target.y)):
return []
var queue: Array[Vector2i] = [start]
var visited := { start: true }
var previous := {}
var cursor := 0
while cursor < queue.size():
var current: Vector2i = queue[cursor]
cursor += 1
if current == target:
break
var neighbors := _neighbor_coordinates(current.x, current.y)
neighbors.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
var distance_a := _hex_distance(a.x, a.y, target.x, target.y)
var distance_b := _hex_distance(b.x, b.y, target.x, target.y)
if distance_a == distance_b:
return a.x < b.x if a.y == b.y else a.y < b.y
return distance_a < distance_b
)
for neighbor in neighbors:
if visited.has(neighbor) or not owned_tiles.has(_tile_key(neighbor.x, neighbor.y)):
continue
visited[neighbor] = true
previous[neighbor] = current
queue.append(neighbor)
if not visited.has(target):
return []
var route: Array[Vector2i] = [target]
var step := target
while previous.has(step):
step = previous[step]
route.push_front(step)
return route
func _direct_hex_route(start: Vector2i, target: Vector2i) -> Array[Vector2i]:
var route: Array[Vector2i] = [start]
var current := start
var guard := 0
while current != target and guard < 128:
var neighbors := _neighbor_coordinates(current.x, current.y)
neighbors.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
var distance_a := _hex_distance(a.x, a.y, target.x, target.y)
var distance_b := _hex_distance(b.x, b.y, target.x, target.y)
if distance_a == distance_b:
return a.x < b.x if a.y == b.y else a.y < b.y
return distance_a < distance_b
)
current = neighbors[0]
route.append(current)
guard += 1
return route
func _hex_distance(q1: int, r1: int, q2: int, r2: int) -> int:
var s1 := -q1 - r1
var s2 := -q2 - r2
return max(max(abs(q1 - q2), abs(r1 - r2)), abs(s1 - s2))
func _update_hover(mouse_position: Vector2) -> void:
var closest := _closest_tile_at(mouse_position)
@@ -474,6 +588,30 @@ func _draw_building_marker(center: Vector2, height: float) -> void:
draw_line(left, right.lerp(top_right, 0.2), post_color.darkened(0.12), 1.5 * zoom)
func _building_needs_worker(building_type: String, building_status: String, worker_unit_id: String) -> bool:
if not worker_unit_id.is_empty():
return false
if building_type != "LogCamp" and building_type != "Mine" and building_type != "Farm":
return false
return building_status == "Built" or building_status.is_empty()
func _draw_missing_worker_marker(center: Vector2, height: float) -> void:
var marker_center: Vector2 = center + Vector2(20.0 * zoom, -height * zoom - 27.0 * zoom)
var marker_scale: float = zoom
var badge_radius: float = 7.0 * marker_scale
var fill_color := Color(0.93, 0.66, 0.18, 0.96)
var edge_color := Color(0.18, 0.10, 0.02, 0.92)
var icon_color := Color(0.10, 0.07, 0.03, 0.96)
draw_circle(marker_center + Vector2(1, 1) * marker_scale, badge_radius + 1.0 * marker_scale, Color(0.0, 0.0, 0.0, 0.35))
draw_circle(marker_center, badge_radius, fill_color)
draw_arc(marker_center, badge_radius, 0.0, TAU, 24, edge_color, 1.4 * marker_scale)
draw_circle(marker_center + Vector2(0, -2.3) * marker_scale, 2.0 * marker_scale, icon_color)
draw_rect(Rect2(marker_center + Vector2(-2.2, 0.8) * marker_scale, Vector2(4.4, 4.8) * marker_scale), icon_color, true)
draw_line(marker_center + Vector2(-5.0, 5.8) * marker_scale, marker_center + Vector2(5.0, -5.8) * marker_scale, edge_color, 1.7 * marker_scale)
func _draw_archer_unit_marker(center: Vector2, height: float, count: int) -> void:
var marker_center: Vector2 = _archer_marker_center(center, height)
var marker_scale: float = zoom
@@ -595,6 +733,29 @@ func _draw_peasant_unit_marker(center: Vector2, height: float, count: int) -> vo
draw_string(ThemeDB.fallback_font, marker_center + Vector2(7, 10) * marker_scale, str(count), HORIZONTAL_ALIGNMENT_LEFT, -1, int(9 * marker_scale), Color(0.94, 0.86, 0.6))
func _draw_scout_unit_marker(center: Vector2, height: float, count: int) -> void:
var marker_center: Vector2 = center + Vector2(-18, -height * zoom - 12.0) * zoom
var marker_scale: float = zoom
var cloak_color := Color(0.46, 0.38, 0.20)
var hood_color := Color(0.68, 0.52, 0.24)
var outline_color := Color(0.08, 0.06, 0.03)
var glass_color := Color(0.92, 0.78, 0.34)
draw_circle(marker_center + Vector2(1, 2) * marker_scale, 8.5 * marker_scale, Color(0.01, 0.02, 0.02, 0.45))
var body := PackedVector2Array([
marker_center + Vector2(-6, 8) * marker_scale,
marker_center + Vector2(0, -8) * marker_scale,
marker_center + Vector2(6, 8) * marker_scale
])
draw_colored_polygon(body, cloak_color)
draw_polyline(_closed_points(body), outline_color, 1.1 * marker_scale)
draw_circle(marker_center + Vector2(0, -6) * marker_scale, 4.0 * marker_scale, hood_color)
draw_line(marker_center + Vector2(5, -5) * marker_scale, marker_center + Vector2(12, -11) * marker_scale, glass_color, 2.0 * marker_scale)
draw_circle(marker_center + Vector2(13, -12) * marker_scale, 3.0 * marker_scale, glass_color)
if count > 1:
draw_string(ThemeDB.fallback_font, marker_center + Vector2(7, 10) * marker_scale, str(count), HORIZONTAL_ALIGNMENT_LEFT, -1, int(9 * marker_scale), Color(0.94, 0.86, 0.6))
func _tile_data(q: int, r: int) -> Dictionary:
for tile in tiles:
if int(tile["q"]) == q and int(tile["r"]) == r:
@@ -659,6 +820,10 @@ func is_neighbor(q1: int, r1: int, q2: int, r2: int) -> bool:
return false
func get_scout_count_at(q: int, r: int) -> int:
return _scout_count_at(q, r)
func set_completed_archers(count: int) -> void:
completed_archer_count = max(0, count)
queue_redraw()
@@ -672,6 +837,23 @@ func set_unit_instances(units: Array) -> void:
queue_redraw()
func upsert_unit_instances(units: Array) -> void:
for item in units:
if typeof(item) != TYPE_DICTIONARY:
continue
var unit: Dictionary = item
var unit_id := str(unit.get("id", ""))
var replaced := false
for index in range(unit_instances.size()):
if str(unit_instances[index].get("id", "")) == unit_id:
unit_instances[index] = unit
replaced = true
break
if not replaced:
unit_instances.append(unit)
queue_redraw()
func set_selected_unit_tile(q: int, r: int) -> void:
selected_unit_tile = Vector2i(q, r)
queue_redraw()
@@ -695,6 +877,14 @@ func get_peasant_id_at(q: int, r: int) -> String:
return ""
func get_peasant_count_at(q: int, r: int) -> int:
return _idle_peasant_count_at(q, r)
func is_owned_tile(q: int, r: int) -> bool:
return owned_tiles.has(_tile_key(q, r))
func is_visible_non_rival_tile(q: int, r: int) -> bool:
var key := _tile_key(q, r)
return visible_tiles.has(key) and not rival_tiles.has(key)
@@ -818,11 +1008,31 @@ func _archer_count_at(q: int, r: int) -> int:
return count
func _scout_count_at(q: int, r: int) -> int:
var count := 0
for unit in unit_instances:
if str(unit.get("unitType", "")) != "Scout":
continue
if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r:
count += 1
return count
func _peasant_count_at(q: int, r: int) -> int:
var count := 0
for unit in unit_instances:
var status := str(unit.get("status", ""))
if str(unit.get("unitType", "")) != "Peasant" or (status != "Idle" and status != "MovingToWork"):
if str(unit.get("unitType", "")) != "Peasant" or (status != "Idle" and status != "Moving" and status != "MovingToWork" and status != "DeliveringToTownHall" and status != "ReturningToWork"):
continue
if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r:
count += 1
return count
func _idle_peasant_count_at(q: int, r: int) -> int:
var count := 0
for unit in unit_instances:
if str(unit.get("unitType", "")) != "Peasant" or str(unit.get("status", "")) != "Idle":
continue
if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r:
count += 1
@@ -87,6 +87,10 @@ public class LocationsController : ControllerBase
{
return NotFound();
}
if (!User.IsInRole("SUPER"))
{
return BadRequest("Use a Scout move order to claim territory on arrival.");
}
if (!User.IsInRole("SUPER") && !await _locations.IsAdjacentToOwnedAsync(username, request.Q, request.R))
{
@@ -100,19 +104,22 @@ public class LocationsController : ControllerBase
{
return Ok(LocationResponse.FromLocation(existingLocation));
}
}
return Conflict("Location is already controlled by another player.");
if (!User.IsInRole("SUPER") && !await _locations.CanScoutClaimAsync(username, request.Q, request.R))
{
return BadRequest("Scouts can only claim neutral territory or empty enemy territory without units or buildings.");
}
if (!User.IsInRole("SUPER") && !await _units.TryConsumeCompletedScoutAsync(username))
{
return BadRequest("A completed scout is required to claim unclaimed territory.");
return BadRequest("A completed scout is required to claim territory.");
}
var location = await _locations.ClaimAsync(request.Q, request.R, username);
if (location is null)
{
return Conflict("Location is already controlled by another player.");
return Conflict("Location cannot be claimed while occupied or built up.");
}
return Ok(LocationResponse.FromLocation(location));
@@ -180,6 +187,52 @@ public class LocationsController : ControllerBase
: Ok(LocationResponse.FromLocation(location));
}
[Authorize(Roles = "USER,SUPER")]
[HttpPost("destroy-building")]
public async Task<ActionResult<LocationResponse>> DestroyBuilding([FromBody] TerritoryLocationRequest request)
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
try
{
var location = await _locations.DestroyBuildingAsync(username, request.Q, request.R);
return location is null
? BadRequest("Select controlled territory with a non-town-hall building.")
: Ok(LocationResponse.FromLocation(location));
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Roles = "USER,SUPER")]
[HttpPost("abandon")]
public async Task<ActionResult<LocationResponse>> Abandon([FromBody] TerritoryLocationRequest request)
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
try
{
var location = await _locations.AbandonAsync(username, request.Q, request.R);
return location is null
? BadRequest("Select controlled non-home territory to give up.")
: Ok(LocationResponse.FromLocation(location));
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Roles = "USER,SUPER")]
[HttpGet("home")]
public async Task<ActionResult<LocationResponse>> GetHome()
@@ -89,4 +89,34 @@ public class UnitsController : ControllerBase
return BadRequest(new { error = ex.Message });
}
}
[Authorize(Roles = "USER,SUPER")]
[HttpPost("dismiss")]
public async Task<ActionResult<DismissUnitSquadResponse>> Dismiss([FromBody] DismissUnitSquadRequest request)
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
try
{
var dismissedCount = await _units.DismissSquadAsync(username, request);
if (dismissedCount == 0)
{
return BadRequest(new { error = "No matching idle units are available to dismiss." });
}
return Ok(new DismissUnitSquadResponse
{
UnitType = request.UnitType,
DismissedCount = dismissedCount
});
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
+6
View File
@@ -35,6 +35,12 @@ public class DeassignWorkerRequest
public int R { get; set; }
}
public class TerritoryLocationRequest
{
public int Q { get; set; }
public int R { get; set; }
}
public class LocationResponse
{
public string Id { get; set; } = "";
@@ -26,6 +26,7 @@ public class Location
public int StoredResourceAmount { get; set; }
public int StoredResourceCapacity { get; set; } = 10;
public DateTime? LastResourceGeneratedAtUtc { get; set; }
public DateTime? DeliveryCycleStartedAtUtc { get; set; }
public DateTime? HomeAssignedAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
@@ -9,11 +9,26 @@ public class TrainUnitRequest
public class MoveUnitSquadRequest
{
public string? UnitId { get; set; }
public string UnitType { get; set; } = "Archer";
public int FromQ { get; set; }
public int FromR { get; set; }
public int ToQ { get; set; }
public int ToR { get; set; }
public bool ClaimOnArrival { get; set; }
}
public class DismissUnitSquadRequest
{
public string UnitType { get; set; } = "Archer";
public int Q { get; set; }
public int R { get; set; }
}
public class DismissUnitSquadResponse
{
public string UnitType { get; set; } = "";
public int DismissedCount { get; set; }
}
public class UnitTrainingResponse
@@ -61,6 +76,11 @@ public class UnitInstanceResponse
public DateTime LastMovementRefillAtUtc { get; set; }
public int? DestinationQ { get; set; }
public int? DestinationR { get; set; }
public bool ClaimOnArrival { get; set; }
public int? WorksiteQ { get; set; }
public int? WorksiteR { get; set; }
public string? CarryingResourceType { get; set; }
public int CarryingResourceAmount { get; set; }
public DateTime? MovementStartedAtUtc { get; set; }
public DateTime? MovementCompletesAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; }
@@ -79,6 +99,11 @@ public class UnitInstanceResponse
LastMovementRefillAtUtc = unit.LastMovementRefillAtUtc,
DestinationQ = unit.DestinationQ,
DestinationR = unit.DestinationR,
ClaimOnArrival = unit.ClaimOnArrival,
WorksiteQ = unit.WorksiteQ,
WorksiteR = unit.WorksiteR,
CarryingResourceType = unit.CarryingResourceType,
CarryingResourceAmount = unit.CarryingResourceAmount,
MovementStartedAtUtc = unit.MovementStartedAtUtc,
MovementCompletesAtUtc = unit.MovementCompletesAtUtc,
CreatedAtUtc = unit.CreatedAtUtc,
@@ -19,6 +19,11 @@ public class UnitInstance
public DateTime LastMovementRefillAtUtc { get; set; } = DateTime.UtcNow;
public int? DestinationQ { get; set; }
public int? DestinationR { get; set; }
public bool ClaimOnArrival { get; set; }
public int? WorksiteQ { get; set; }
public int? WorksiteR { get; set; }
public string? CarryingResourceType { get; set; }
public int CarryingResourceAmount { get; set; }
public DateTime? MovementStartedAtUtc { get; set; }
public DateTime? MovementCompletesAtUtc { get; set; }
public string? SourceTrainingId { get; set; }
@@ -27,6 +27,7 @@ public class GameSettingsService
public const string ProductionBuildingResourceCapacity = "ProductionBuildingResourceCapacity";
public const string StartingGold = "StartingGold";
public const string HeadquartersGoldPerMinute = "HeadquartersGoldPerMinute";
public const string TownHallFoodPerMinute = "TownHallFoodPerMinute";
public const string TownHallGoldCapacity = "TownHallGoldCapacity";
public const string TownHallWoodCapacity = "TownHallWoodCapacity";
public const string TownHallOreCapacity = "TownHallOreCapacity";
@@ -145,8 +145,12 @@ public class LocationService
var existing = await GetAsync(q, r);
if (existing is not null)
{
if (!string.IsNullOrWhiteSpace(existing.OwnerUsername) &&
!string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
if (string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
{
return existing;
}
if (!await CanScoutClaimAsync(username, q, r))
{
return null;
}
@@ -164,6 +168,26 @@ public class LocationService
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
}
public async Task<bool> CanScoutClaimAsync(string username, int q, int r)
{
var existing = await GetAsync(q, r);
if (existing is not null)
{
if (string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(existing.Kind, "Home", StringComparison.OrdinalIgnoreCase) ||
!string.IsNullOrWhiteSpace(existing.BuildingType))
{
return false;
}
}
return !await _units.Find(x => x.Q == q && x.R == r).AnyAsync();
}
public async Task<Location?> BuildAsync(BuildLocationRequest request, string username)
{
var buildingType = NormalizeBuildingType(request.BuildingType);
@@ -288,7 +312,7 @@ public class LocationService
var location = await GetAsync(request.Q, request.R);
if (location is null ||
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(location.BuildingType) ||
!CanAssignWorkerToBuilding(location.BuildingType) ||
location.BuildingStatus == "Building" ||
!string.IsNullOrWhiteSpace(location.WorkerUnitId))
{
@@ -313,8 +337,10 @@ public class LocationService
.Set(x => x.MovementRemaining, 0)
.Set(x => x.DestinationQ, request.Q)
.Set(x => x.DestinationR, request.R)
.Set(x => x.WorksiteQ, request.Q)
.Set(x => x.WorksiteR, request.R)
.Set(x => x.MovementStartedAtUtc, now)
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(Math.Max(1, HexDistance(unit.Q, unit.R, request.Q, request.R))))
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1))
.Set(x => x.UpdatedAtUtc, now));
if (claimed.ModifiedCount == 0)
{
@@ -336,11 +362,19 @@ public class LocationService
}
await _units.UpdateOneAsync(
x => x.Id == location.WorkerUnitId && x.Username == username && x.Status == "Working",
x => x.Id == location.WorkerUnitId && x.Username == username,
Builders<UnitInstance>.Update
.Set(x => x.Q, request.Q)
.Set(x => x.R, request.R)
.Set(x => x.Status, "Idle")
.Unset(x => x.WorksiteQ)
.Unset(x => x.WorksiteR)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Unset(x => x.CarryingResourceType)
.Set(x => x.CarryingResourceAmount, 0)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
var updated = await _locations.FindOneAndUpdateAsync(
@@ -348,6 +382,7 @@ public class LocationService
Builders<Location>.Update
.Unset(x => x.WorkerUnitId)
.Unset(x => x.WorkerUnitType)
.Unset(x => x.DeliveryCycleStartedAtUtc)
.Set(x => x.UpdatedAtUtc, now),
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
@@ -355,6 +390,102 @@ public class LocationService
return updated;
}
public async Task<Location?> DestroyBuildingAsync(string username, int q, int r)
{
var now = DateTime.UtcNow;
var location = await GetAsync(q, r);
if (location is null ||
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(location.BuildingType))
{
return null;
}
if (string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("The town hall cannot be destroyed.");
}
if (!string.IsNullOrWhiteSpace(location.WorkerUnitId))
{
await _units.UpdateOneAsync(
x => x.Id == location.WorkerUnitId && x.Username == username,
Builders<UnitInstance>.Update
.Set(x => x.Q, q)
.Set(x => x.R, r)
.Set(x => x.Status, "Idle")
.Set(x => x.MovementRemaining, 1)
.Unset(x => x.WorksiteQ)
.Unset(x => x.WorksiteR)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Unset(x => x.CarryingResourceType)
.Set(x => x.CarryingResourceAmount, 0)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
}
var updated = await _locations.FindOneAndUpdateAsync(
x => x.Id == location.Id && x.OwnerUsername == username && x.BuildingType != null && x.BuildingType != "",
Builders<Location>.Update
.Unset(x => x.BuildingType)
.Unset(x => x.BuildingStatus)
.Unset(x => x.BuildingStartedAtUtc)
.Unset(x => x.BuildingCompletesAtUtc)
.Unset(x => x.BuildingBuiltAtUtc)
.Unset(x => x.WorkerUnitId)
.Unset(x => x.WorkerUnitType)
.Set(x => x.StoredResourceAmount, 0)
.Set(x => x.StoredResourceCapacity, 10)
.Unset(x => x.LastResourceGeneratedAtUtc)
.Unset(x => x.DeliveryCycleStartedAtUtc)
.Set(x => x.UpdatedAtUtc, now),
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
await RecalculateWorkerGenerationAsync(username);
return updated;
}
public async Task<Location?> AbandonAsync(string username, int q, int r)
{
var location = await GetAsync(q, r);
if (location is null ||
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("The town hall cannot be given up.");
}
if (!string.IsNullOrWhiteSpace(location.BuildingType) || !string.IsNullOrWhiteSpace(location.WorkerUnitId))
{
throw new InvalidOperationException("Destroy the building before giving up this territory.");
}
var hasUnits = await _units.Find(x =>
x.Username == username &&
x.Q == q &&
x.R == r)
.AnyAsync();
if (hasUnits)
{
throw new InvalidOperationException("Move or dismiss units on this tile before giving up the territory.");
}
return await _locations.FindOneAndUpdateAsync(
x => x.Id == location.Id && x.OwnerUsername == username,
Builders<Location>.Update
.Set(x => x.Kind, "Open")
.Unset(x => x.OwnerUsername)
.Unset(x => x.Label)
.Set(x => x.UpdatedAtUtc, DateTime.UtcNow),
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
}
private async Task RecalculateWorkerGenerationAsync(string username)
{
var built = await _locations.Find(x =>
@@ -537,13 +668,37 @@ public class LocationService
var destinationR = worker.DestinationR.Value;
var currentQ = worker.Q;
var currentR = worker.R;
var nextTick = worker.MovementCompletesAtUtc!.Value;
while (nextTick <= now && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
var elapsedSteps = Math.Max(1, (int)Math.Floor((now - worker.MovementCompletesAtUtc!.Value).TotalMinutes) + 1);
var usedSteps = 0;
var movementBlocked = false;
while (elapsedSteps > 0 && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
(currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR);
nextTick = nextTick.AddMinutes(1);
var nextStep = await NextPeasantStepTowardAsync(username, currentQ, currentR, destinationQ, destinationR);
if (nextStep is null)
{
await _units.UpdateOneAsync(
x => x.Id == worker.Id,
Builders<UnitInstance>.Update
.Set(x => x.Status, "Idle")
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
movementBlocked = true;
break;
}
currentQ = nextStep.Value.q;
currentR = nextStep.Value.r;
elapsedSteps--;
usedSteps++;
}
if (movementBlocked)
continue;
if (HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
await _units.UpdateOneAsync(
@@ -551,22 +706,24 @@ public class LocationService
Builders<UnitInstance>.Update
.Set(x => x.Q, currentQ)
.Set(x => x.R, currentR)
.Set(x => x.MovementCompletesAtUtc, nextTick)
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1))
.Set(x => x.UpdatedAtUtc, now));
continue;
}
var assignedAt = worker.MovementCompletesAtUtc.Value.AddMinutes(Math.Max(0, usedSteps - 1));
var location = await _locations.FindOneAndUpdateAsync(
x => x.OwnerUsername == username &&
x.Q == destinationQ &&
x.R == destinationR &&
!string.IsNullOrWhiteSpace(x.BuildingType) &&
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm") &&
x.BuildingStatus != "Building" &&
(x.WorkerUnitId == null || x.WorkerUnitId == ""),
Builders<Location>.Update
.Set(x => x.WorkerUnitId, worker.Id)
.Set(x => x.WorkerUnitType, "Peasant")
.Set(x => x.LastResourceGeneratedAtUtc, now)
.Set(x => x.LastResourceGeneratedAtUtc, assignedAt)
.Set(x => x.DeliveryCycleStartedAtUtc, assignedAt)
.Set(x => x.UpdatedAtUtc, now),
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
@@ -591,13 +748,63 @@ public class LocationService
}
}
private static (int q, int r) NextStepToward(int fromQ, int fromR, int toQ, int toR)
private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, int fromQ, int fromR, int toQ, int toR)
{
return NeighborCoordinates(fromQ, fromR)
var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet();
if (!ownedCoordinates.Contains((toQ, toR)))
{
return null;
}
var start = (fromQ, fromR);
var target = (toQ, toR);
var queue = new Queue<(int q, int r)>();
var previous = new Dictionary<(int q, int r), (int q, int r)>();
var visited = new HashSet<(int q, int r)> { start };
queue.Enqueue(start);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current == target)
{
break;
}
foreach (var neighbor in NeighborCoordinates(current.q, current.r)
.Select(x => (x.Q, x.R))
.OrderBy(x => HexDistance(x.Q, x.R, toQ, toR))
.ThenBy(x => x.Q)
.ThenBy(x => x.R)
.First();
.ThenBy(x => x.R))
{
if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor))
{
continue;
}
previous[neighbor] = current;
queue.Enqueue(neighbor);
}
}
if (!visited.Contains(target))
{
return null;
}
var step = target;
while (previous.TryGetValue(step, out var before) && before != start)
{
step = before;
}
return step == start ? target : step;
}
private static bool CanAssignWorkerToBuilding(string? buildingType)
{
return buildingType == "LogCamp" || buildingType == "Mine" || buildingType == "Farm";
}
public async Task<bool> CanSeeAsync(string username, int q, int r)
@@ -16,6 +16,7 @@ public class ResourceService
private readonly GameSettingsService _settings;
private readonly int _startingGold;
private readonly int _headquartersGoldPerMinute;
private readonly int _townHallFoodPerMinute;
private readonly int _townHallGoldCapacity;
private readonly int _townHallWoodCapacity;
private readonly int _townHallOreCapacity;
@@ -38,6 +39,9 @@ public class ResourceService
_headquartersGoldPerMinute = int.TryParse(cfg["Resources:HeadquartersGoldPerMinute"], out var goldPerMinute)
? Math.Max(0, goldPerMinute)
: 10;
_townHallFoodPerMinute = int.TryParse(cfg["Resources:TownHallFoodPerMinute"], out var townHallFoodPerMinute)
? Math.Max(0, townHallFoodPerMinute)
: 5;
_townHallGoldCapacity = int.TryParse(cfg["Resources:TownHallGoldCapacity"], out var goldCapacity)
? Math.Max(0, goldCapacity)
: 500;
@@ -105,7 +109,7 @@ public class ResourceService
{
var resources = await EnsureAsync(username);
resources = await ApplyProductionBuildingDeliveryAsync(resources);
return await ApplyFoodUpkeepAsync(resources);
return await ApplyFoodTickAsync(resources);
}
public async Task<bool> TrySpendGoldAsync(string username, int amount)
@@ -231,10 +235,16 @@ public class ResourceService
{
await EnsureAsync(username);
var now = DateTime.UtcNow;
var townHallFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodPerMinute, _townHallFoodPerMinute);
var hasTownHall = await _locations.Find(x =>
x.OwnerUsername == username &&
x.Kind == "Home")
.AnyAsync();
var totalFoodPerMinute = Math.Max(0, foodPerMinute) + (hasTownHall ? townHallFoodPerMinute : 0);
var update = Builders<PlayerResources>.Update
.Set(x => x.WoodPerMinute, Math.Max(0, woodPerMinute))
.Set(x => x.OrePerMinute, Math.Max(0, orePerMinute))
.Set(x => x.FoodPerMinute, Math.Max(0, foodPerMinute))
.Set(x => x.FoodPerMinute, totalFoodPerMinute)
.Set(x => x.UpdatedAtUtc, now);
if (woodPerMinute > 0)
@@ -245,7 +255,7 @@ public class ResourceService
{
update = update.Set(x => x.LastOreGeneratedAtUtc, now);
}
if (foodPerMinute > 0)
if (totalFoodPerMinute > 0)
{
update = update.Set(x => x.LastFoodGeneratedAtUtc, now);
}
@@ -310,7 +320,7 @@ public class ResourceService
});
}
private async Task<PlayerResources> ApplyProductionBuildingDeliveryAsync(PlayerResources resources)
private async Task<PlayerResources> ApplyProductionBuildingDeliveryAsync(PlayerResources resources, bool includeFood = true)
{
var now = DateTime.UtcNow;
await CompletePendingWorkerAssignmentsAsync(resources.Username, now);
@@ -320,14 +330,17 @@ public class ResourceService
var buildingCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.ProductionBuildingResourceCapacity, _productionBuildingResourceCapacity);
var logCampWoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.LogCampWoodPerMinute, 5);
var mineOrePerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.MineOrePerMinute, 5);
var farmFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, 5);
var farmFoodPerMinute = includeFood
? await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, 5)
: 0;
var buildings = await _locations.Find(x =>
x.OwnerUsername == resources.Username &&
!string.IsNullOrWhiteSpace(x.WorkerUnitId) &&
(x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "") &&
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm"))
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || (includeFood && x.BuildingType == "Farm")))
.ToListAsync();
var home = await _locations.Find(x => x.OwnerUsername == resources.Username && x.Kind == "Home").FirstOrDefaultAsync();
var wood = Math.Min(resources.Wood, woodCapacity);
var ore = Math.Min(resources.Ore, oreCapacity);
@@ -335,6 +348,11 @@ public class ResourceService
foreach (var building in buildings)
{
if (home is null)
{
break;
}
var rate = building.BuildingType switch
{
"LogCamp" => logCampWoodPerMinute,
@@ -343,8 +361,22 @@ public class ResourceService
_ => 0
};
var lastGenerated = building.LastResourceGeneratedAtUtc ?? building.BuildingBuiltAtUtc ?? now;
var route = DirectRoute((building.Q, building.R), (home.Q, home.R));
var oneWayMinutes = Math.Max(1, route.Count - 1);
var cycleMinutes = Math.Max(1, oneWayMinutes * 2);
var cycleStarted = building.DeliveryCycleStartedAtUtc ?? lastGenerated;
var elapsedMinutes = rate <= 0 ? 0 : (int)Math.Floor((now - lastGenerated).TotalMinutes);
var stored = Math.Min(buildingCapacity, Math.Max(0, building.StoredResourceAmount) + (elapsedMinutes * rate));
var stored = Math.Min(buildingCapacity, Math.Max(0, building.StoredResourceAmount));
for (var minute = 1; minute <= elapsedMinutes; minute++)
{
var minuteAt = lastGenerated.AddMinutes(minute);
stored = Math.Min(buildingCapacity, stored + rate);
var cycleMinute = (int)Math.Floor((minuteAt - cycleStarted).TotalMinutes);
if (cycleMinute < oneWayMinutes || (cycleMinute - oneWayMinutes) % cycleMinutes != 0)
{
continue;
}
var delivered = 0;
if (building.BuildingType == "LogCamp")
@@ -363,9 +395,13 @@ public class ResourceService
food += delivered;
}
stored -= delivered;
}
var update = Builders<Location>.Update
.Set(x => x.StoredResourceAmount, stored - delivered)
.Set(x => x.StoredResourceAmount, stored)
.Set(x => x.StoredResourceCapacity, buildingCapacity)
.Set(x => x.DeliveryCycleStartedAtUtc, cycleStarted)
.Set(x => x.UpdatedAtUtc, now);
if (elapsedMinutes > 0 || building.LastResourceGeneratedAtUtc is null)
@@ -374,6 +410,7 @@ public class ResourceService
}
await _locations.UpdateOneAsync(x => x.Id == building.Id, update);
await UpdateDeliveryWorkerStateAsync(resources.Username, building, home, route, cycleStarted, oneWayMinutes, cycleMinutes, stored, buildingCapacity, now);
}
return await _resources.FindOneAndUpdateAsync(
@@ -392,23 +429,35 @@ public class ResourceService
});
}
private async Task<PlayerResources> ApplyFoodUpkeepAsync(PlayerResources resources)
private async Task<PlayerResources> ApplyFoodTickAsync(PlayerResources resources)
{
var now = DateTime.UtcNow;
var unitFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.UnitFoodPerMinute, _unitFoodPerMinute);
var directFoodProductionPerMinute = await CalculateDirectFoodProductionPerMinuteAsync(resources.Username);
var foodProductionPerMinute = await CalculateFoodProductionPerMinuteAsync(resources.Username);
var units = await GetFoodConsumingUnitsAsync(resources.Username);
var upkeepPerMinute = units.Count * unitFoodPerMinute;
var netFoodPerMinute = foodProductionPerMinute - upkeepPerMinute;
var elapsedMinutes = upkeepPerMinute <= 0 || resources.LastFoodGeneratedAtUtc.Year < 2000
var elapsedMinutes = resources.LastFoodGeneratedAtUtc.Year < 2000
? 0
: (int)Math.Floor((now - resources.LastFoodGeneratedAtUtc).TotalMinutes);
var food = resources.Food;
var foodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodCapacity, _townHallFoodCapacity);
var food = Math.Min(resources.Food, foodCapacity);
var removedUnits = new List<FoodConsumingUnit>();
for (var minute = 0; minute < elapsedMinutes && units.Count > 0; minute++)
for (var minute = 0; minute < elapsedMinutes; minute++)
{
if (directFoodProductionPerMinute > 0)
{
food = Math.Min(foodCapacity, food + directFoodProductionPerMinute);
}
var neededFood = units.Count * unitFoodPerMinute;
if (neededFood <= 0)
{
continue;
}
if (food >= neededFood)
{
food -= neededFood;
@@ -427,7 +476,7 @@ public class ResourceService
}
netFoodPerMinute = foodProductionPerMinute - (units.Count * unitFoodPerMinute);
if (elapsedMinutes <= 0 && removedUnits.Count == 0 && resources.Food == food && resources.FoodPerMinute == netFoodPerMinute)
if (elapsedMinutes <= 0 && removedUnits.Count == 0 && resources.Food == food && resources.FoodPerMinute == netFoodPerMinute && resources.FoodCapacity == foodCapacity)
{
return resources;
}
@@ -437,6 +486,7 @@ public class ResourceService
var update = Builders<PlayerResources>.Update
.Set(x => x.FoodPerMinute, netFoodPerMinute)
.Set(x => x.Food, food)
.Set(x => x.FoodCapacity, foodCapacity)
.Set(x => x.UpdatedAtUtc, now);
if (elapsedMinutes > 0)
@@ -462,7 +512,7 @@ public class ResourceService
.ToListAsync();
var persistedUnits = await _units.Find(x =>
x.Username == username &&
(x.UnitType == ArcherUnitType || x.UnitType == PeasantUnitType))
(x.UnitType == ScoutUnitType || x.UnitType == ArcherUnitType || x.UnitType == PeasantUnitType))
.ToListAsync();
var units = completedScouts
@@ -473,16 +523,108 @@ public class ResourceService
return units;
}
private async Task UpdateDeliveryWorkerStateAsync(
string username,
Location building,
Location home,
List<(int q, int r)> route,
DateTime cycleStarted,
int oneWayMinutes,
int cycleMinutes,
int remainingStored,
int carryCapacity,
DateTime now)
{
if (string.IsNullOrWhiteSpace(building.WorkerUnitId) || route.Count == 0)
{
return;
}
var resourceType = building.BuildingType switch
{
"LogCamp" => "Wood",
"Mine" => "Ore",
"Farm" => "Food",
_ => ""
};
if (resourceType == "")
{
return;
}
var elapsedCycleMinutes = Math.Max(0, (int)Math.Floor((now - cycleStarted).TotalMinutes));
var phase = cycleMinutes <= 0 ? 0 : elapsedCycleMinutes % cycleMinutes;
var isDelivering = remainingStored > 0 && phase < oneWayMinutes;
var isReturning = phase >= oneWayMinutes;
if (!isDelivering && !isReturning)
{
await _units.UpdateOneAsync(
x => x.Id == building.WorkerUnitId && x.Username == username,
Builders<UnitInstance>.Update
.Set(x => x.Q, building.Q)
.Set(x => x.R, building.R)
.Set(x => x.Status, "Working")
.Set(x => x.WorksiteQ, building.Q)
.Set(x => x.WorksiteR, building.R)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Unset(x => x.CarryingResourceType)
.Set(x => x.CarryingResourceAmount, 0)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
return;
}
var routeIndex = isDelivering
? Math.Min(route.Count - 1, phase)
: Math.Max(0, route.Count - 1 - Math.Min(route.Count - 1, phase - oneWayMinutes));
var position = route[routeIndex];
(int q, int r) destination = isDelivering ? (home.Q, home.R) : (building.Q, building.R);
await _units.UpdateOneAsync(
x => x.Id == building.WorkerUnitId && x.Username == username,
Builders<UnitInstance>.Update
.Set(x => x.Q, position.q)
.Set(x => x.R, position.r)
.Set(x => x.Status, isDelivering ? "DeliveringToTownHall" : "ReturningToWork")
.Set(x => x.WorksiteQ, building.Q)
.Set(x => x.WorksiteR, building.R)
.Set(x => x.DestinationQ, destination.q)
.Set(x => x.DestinationR, destination.r)
.Set(x => x.CarryingResourceType, isDelivering ? resourceType : (string?)null)
.Set(x => x.CarryingResourceAmount, isDelivering ? Math.Min(carryCapacity, remainingStored) : 0)
.Set(x => x.MovementStartedAtUtc, now)
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1))
.Set(x => x.UpdatedAtUtc, now));
}
private async Task<int> CalculateFoodProductionPerMinuteAsync(string username)
{
var townHallFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodPerMinute, _townHallFoodPerMinute);
var farmFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, 5);
var hasTownHall = await _locations.Find(x =>
x.OwnerUsername == username &&
x.Kind == "Home")
.AnyAsync();
var farms = await _locations.CountDocumentsAsync(x =>
x.OwnerUsername == username &&
!string.IsNullOrWhiteSpace(x.WorkerUnitId) &&
(x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "") &&
x.BuildingType == "Farm");
return checked((int)farms) * farmFoodPerMinute;
return (hasTownHall ? townHallFoodPerMinute : 0) + checked((int)farms) * farmFoodPerMinute;
}
private async Task<int> CalculateDirectFoodProductionPerMinuteAsync(string username)
{
var townHallFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodPerMinute, _townHallFoodPerMinute);
var hasTownHall = await _locations.Find(x =>
x.OwnerUsername == username &&
x.Kind == "Home")
.AnyAsync();
return hasTownHall ? townHallFoodPerMinute : 0;
}
private async Task CompletePendingWorkerAssignmentsAsync(string username, DateTime now)
@@ -513,13 +655,37 @@ public class ResourceService
var destinationR = worker.DestinationR.Value;
var currentQ = worker.Q;
var currentR = worker.R;
var nextTick = worker.MovementCompletesAtUtc!.Value;
while (nextTick <= now && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
var elapsedSteps = Math.Max(1, (int)Math.Floor((now - worker.MovementCompletesAtUtc!.Value).TotalMinutes) + 1);
var usedSteps = 0;
var movementBlocked = false;
while (elapsedSteps > 0 && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
(currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR);
nextTick = nextTick.AddMinutes(1);
var nextStep = await NextPeasantStepTowardAsync(username, currentQ, currentR, destinationQ, destinationR);
if (nextStep is null)
{
await _units.UpdateOneAsync(
x => x.Id == worker.Id,
Builders<UnitInstance>.Update
.Set(x => x.Status, "Idle")
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
movementBlocked = true;
break;
}
currentQ = nextStep.Value.q;
currentR = nextStep.Value.r;
elapsedSteps--;
usedSteps++;
}
if (movementBlocked)
continue;
if (HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
await _units.UpdateOneAsync(
@@ -527,22 +693,24 @@ public class ResourceService
Builders<UnitInstance>.Update
.Set(x => x.Q, currentQ)
.Set(x => x.R, currentR)
.Set(x => x.MovementCompletesAtUtc, nextTick)
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1))
.Set(x => x.UpdatedAtUtc, now));
continue;
}
var assignedAt = worker.MovementCompletesAtUtc.Value.AddMinutes(Math.Max(0, usedSteps - 1));
var location = await _locations.FindOneAndUpdateAsync(
x => x.OwnerUsername == username &&
x.Q == destinationQ &&
x.R == destinationR &&
!string.IsNullOrWhiteSpace(x.BuildingType) &&
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm") &&
x.BuildingStatus != "Building" &&
(x.WorkerUnitId == null || x.WorkerUnitId == ""),
Builders<Location>.Update
.Set(x => x.WorkerUnitId, worker.Id)
.Set(x => x.WorkerUnitType, "Peasant")
.Set(x => x.LastResourceGeneratedAtUtc, now)
.Set(x => x.LastResourceGeneratedAtUtc, assignedAt)
.Set(x => x.DeliveryCycleStartedAtUtc, assignedAt)
.Set(x => x.UpdatedAtUtc, now),
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
@@ -561,13 +729,57 @@ public class ResourceService
}
}
private static (int q, int r) NextStepToward(int fromQ, int fromR, int toQ, int toR)
private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, int fromQ, int fromR, int toQ, int toR)
{
return NeighborCoordinates(fromQ, fromR)
var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet();
if (!ownedCoordinates.Contains((toQ, toR)))
{
return null;
}
var start = (fromQ, fromR);
var target = (toQ, toR);
var queue = new Queue<(int q, int r)>();
var previous = new Dictionary<(int q, int r), (int q, int r)>();
var visited = new HashSet<(int q, int r)> { start };
queue.Enqueue(start);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current == target)
{
break;
}
foreach (var neighbor in NeighborCoordinates(current.q, current.r)
.OrderBy(x => HexDistance(x.q, x.r, toQ, toR))
.ThenBy(x => x.q)
.ThenBy(x => x.r)
.First();
.ThenBy(x => x.r))
{
if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor))
{
continue;
}
previous[neighbor] = current;
queue.Enqueue(neighbor);
}
}
if (!visited.Contains(target))
{
return null;
}
var step = target;
while (previous.TryGetValue(step, out var before) && before != start)
{
step = before;
}
return step == start ? target : step;
}
private static IEnumerable<(int q, int r)> NeighborCoordinates(int q, int r)
@@ -580,6 +792,25 @@ public class ResourceService
yield return (q, r + 1);
}
private static List<(int q, int r)> DirectRoute((int q, int r) start, (int q, int r) target)
{
var route = new List<(int q, int r)> { start };
var current = start;
var guard = 0;
while (current != target && guard < 128)
{
current = NeighborCoordinates(current.q, current.r)
.OrderBy(x => HexDistance(x.q, x.r, target.q, target.r))
.ThenBy(x => x.q)
.ThenBy(x => x.r)
.First();
route.Add(current);
guard++;
}
return route;
}
private static int HexDistance(int q1, int r1, int q2, int r2)
{
var s1 = -q1 - r1;
@@ -618,12 +849,34 @@ public class ResourceService
Builders<Location>.Update
.Unset(x => x.WorkerUnitId)
.Unset(x => x.WorkerUnitType)
.Unset(x => x.DeliveryCycleStartedAtUtc)
.Set(x => x.UpdatedAtUtc, now));
await _units.DeleteManyAsync(x =>
x.Username == username &&
instanceIds.Contains(x.Id));
await RecalculateWorkerGenerationAsync(username);
}
}
private async Task RecalculateWorkerGenerationAsync(string username)
{
var logCampWoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.LogCampWoodPerMinute, 5);
var mineOrePerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.MineOrePerMinute, 5);
var farmFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, 5);
var built = await _locations.Find(x =>
x.OwnerUsername == username &&
!string.IsNullOrWhiteSpace(x.WorkerUnitId) &&
(x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "") &&
(x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm"))
.ToListAsync();
await SetBuildingGenerationAsync(
username,
built.Count(x => x.BuildingType == "LogCamp") * logCampWoodPerMinute,
built.Count(x => x.BuildingType == "Mine") * mineOrePerMinute,
built.Count(x => x.BuildingType == "Farm") * farmFoodPerMinute);
}
private sealed record FoodConsumingUnit(string Id, string UnitType, bool IsTrainingRecord);
}
@@ -69,13 +69,14 @@ public class UnitTrainingService
public async Task<PlayerUnitsResponse> GetMineAsync(string username)
{
await CompleteFinishedTrainingAsync(username);
await PromoteCompletedScoutsAsync(username);
await CompletePendingMovementAsync(username);
await RefillMovementAsync(username);
await _training.DeleteManyAsync(x => x.Username == username && x.Status == "Consumed");
var allTraining = await _training.Find(x => x.Username == username).ToListAsync();
var allUnits = await _units.Find(x => x.Username == username).ToListAsync();
var completedScouts = allTraining.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed");
var completedScouts = allUnits.Count(x => x.UnitType == ScoutUnitType && x.Status == "Idle");
var completedArchers = allUnits.Count(x => x.UnitType == ArcherUnitType);
var completedPeasants = allUnits.Count(x => x.UnitType == PeasantUnitType && x.Status == "Idle");
var activeTraining = allTraining
@@ -129,6 +130,14 @@ public class UnitTrainingService
throw new InvalidOperationException("A town hall is required to train Peasants.");
}
}
else if (normalizedUnitType == ScoutUnitType)
{
origin = await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
if (origin is null)
{
throw new InvalidOperationException("A town hall is required to train Scouts.");
}
}
var goldCost = normalizedUnitType == ArcherUnitType
? await _settings.GetNonNegativeIntAsync(GameSettingsService.ArcherGoldCost, _archerGoldCost)
@@ -177,9 +186,13 @@ public class UnitTrainingService
await RefillMovementAsync(username);
var normalizedUnitType = NormalizeUnitType(request.UnitType);
if (normalizedUnitType != ArcherUnitType)
if (normalizedUnitType != ScoutUnitType && normalizedUnitType != ArcherUnitType && normalizedUnitType != PeasantUnitType)
{
throw new ArgumentException("Only Archer squads can be moved right now.", nameof(request.UnitType));
throw new ArgumentException("Only Scouts, Archers, and Peasants can be moved right now.", nameof(request.UnitType));
}
if (request.ClaimOnArrival && normalizedUnitType != ScoutUnitType)
{
throw new ArgumentException("Only Scouts can claim territory.", nameof(request.ClaimOnArrival));
}
if (request.FromQ == request.ToQ && request.FromR == request.ToR)
@@ -187,24 +200,37 @@ public class UnitTrainingService
throw new InvalidOperationException("Select a different destination tile.");
}
if (HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR) != 1)
var movementDistance = HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR);
if ((normalizedUnitType == ArcherUnitType || normalizedUnitType == ScoutUnitType) && !await CanMoveToAsync(username, request.ToQ, request.ToR))
{
throw new InvalidOperationException("Archers can only move to adjacent tiles.");
throw new InvalidOperationException($"{normalizedUnitType}s can only move to visible tiles.");
}
if (!await CanMoveToAsync(username, request.ToQ, request.ToR))
if (normalizedUnitType == PeasantUnitType && !await CanPeasantMoveToAsync(username, request.ToQ, request.ToR))
{
throw new InvalidOperationException("Archers can only move to visible tiles.");
throw new InvalidOperationException("Peasants can only move within your controlled territory.");
}
if (request.ClaimOnArrival && !await IsAdjacentToOwnedAsync(username, request.ToQ, request.ToR))
{
throw new InvalidOperationException("Scout claims must target territory adjacent to controlled territory.");
}
var now = DateTime.UtcNow;
var units = await _units.Find(x =>
x.Username == username &&
x.UnitType == ArcherUnitType &&
x.Status == "Idle" &&
x.Q == request.FromQ &&
x.R == request.FromR)
.ToListAsync();
var unitFilter = Builders<UnitInstance>.Filter.And(
Builders<UnitInstance>.Filter.Eq(x => x.Username, username),
Builders<UnitInstance>.Filter.Eq(x => x.UnitType, normalizedUnitType),
Builders<UnitInstance>.Filter.Eq(x => x.Status, "Idle"),
Builders<UnitInstance>.Filter.Eq(x => x.Q, request.FromQ),
Builders<UnitInstance>.Filter.Eq(x => x.R, request.FromR));
if (normalizedUnitType == PeasantUnitType)
{
if (!string.IsNullOrWhiteSpace(request.UnitId))
{
unitFilter &= Builders<UnitInstance>.Filter.Eq(x => x.Id, request.UnitId);
}
}
var units = await _units.Find(unitFilter).ToListAsync();
if (units.Count == 0)
{
@@ -216,33 +242,47 @@ public class UnitTrainingService
throw new InvalidOperationException("That squad has no movement remaining this tick.");
}
var movingUnitIds = request.ClaimOnArrival
? units.OrderBy(x => x.Id).Take(1).Select(x => x.Id).ToList()
: units.Select(x => x.Id).ToList();
var movementCompletesAt = now.AddMinutes(1);
await _units.UpdateManyAsync(
x => x.Username == username &&
x.UnitType == ArcherUnitType &&
x.Status == "Idle" &&
x.Q == request.FromQ &&
x.R == request.FromR,
Builders<UnitInstance>.Filter.In(x => x.Id, movingUnitIds),
Builders<UnitInstance>.Update
.Set(x => x.Status, "Moving")
.Inc(x => x.MovementRemaining, -1)
.Set(x => x.DestinationQ, request.ToQ)
.Set(x => x.DestinationR, request.ToR)
.Set(x => x.ClaimOnArrival, request.ClaimOnArrival)
.Set(x => x.MovementStartedAtUtc, now)
.Set(x => x.MovementCompletesAtUtc, movementCompletesAt)
.Set(x => x.UpdatedAtUtc, now));
return await _units.Find(x =>
x.Username == username &&
x.UnitType == ArcherUnitType &&
x.Status == "Moving" &&
x.Q == request.FromQ &&
x.R == request.FromR &&
x.DestinationQ == request.ToQ &&
x.DestinationR == request.ToR)
movingUnitIds.Contains(x.Id))
.ToListAsync();
}
public async Task<int> DismissSquadAsync(string username, DismissUnitSquadRequest request)
{
var normalizedUnitType = NormalizeUnitType(request.UnitType);
if (normalizedUnitType != ScoutUnitType && normalizedUnitType != ArcherUnitType && normalizedUnitType != PeasantUnitType)
{
throw new ArgumentException("Only Scouts, Archers, and Peasants can be dismissed right now.", nameof(request.UnitType));
}
await CompletePendingMovementAsync(username);
var result = await _units.DeleteManyAsync(x =>
x.Username == username &&
x.UnitType == normalizedUnitType &&
x.Status == "Idle" &&
x.Q == request.Q &&
x.R == request.R);
return (int)result.DeletedCount;
}
private async Task<Location?> GetBuiltBarracksAsync(string username, int q, int r)
{
await CompleteFinishedBuildingsAsync(username);
@@ -276,31 +316,92 @@ public class UnitTrainingService
.Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
continue;
}
var destinationQ = unit.DestinationQ.Value;
var destinationR = unit.DestinationR.Value;
var currentQ = unit.Q;
var currentR = unit.R;
var elapsedSteps = Math.Max(1, (int)Math.Floor((now - unit.MovementCompletesAtUtc!.Value).TotalMinutes) + 1);
var movementBlocked = false;
while (elapsedSteps > 0 && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
if (unit.UnitType == PeasantUnitType)
{
var nextStep = await NextPeasantStepTowardAsync(username, currentQ, currentR, destinationQ, destinationR);
if (nextStep is null)
{
await _units.UpdateOneAsync(
x => x.Id == unit.Id,
Builders<UnitInstance>.Update
.Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
movementBlocked = true;
break;
}
currentQ = nextStep.Value.q;
currentR = nextStep.Value.r;
}
else
{
var nextStep = NextStepToward(currentQ, currentR, destinationQ, destinationR);
currentQ = nextStep.q;
currentR = nextStep.r;
}
elapsedSteps--;
}
if (movementBlocked)
continue;
if (HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
await _units.UpdateOneAsync(
x => x.Id == unit.Id,
Builders<UnitInstance>.Update
.Set(x => x.Q, currentQ)
.Set(x => x.R, currentR)
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1))
.Set(x => x.UpdatedAtUtc, now));
continue;
}
await _units.UpdateOneAsync(
x => x.Id == unit.Id,
Builders<UnitInstance>.Update
.Set(x => x.Q, unit.DestinationQ.Value)
.Set(x => x.R, unit.DestinationR.Value)
.Set(x => x.Q, destinationQ)
.Set(x => x.R, destinationR)
.Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick)
.Set(x => x.LastMovementRefillAtUtc, now)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
if (unit.UnitType == ArcherUnitType)
if (unit.UnitType == ScoutUnitType && unit.ClaimOnArrival)
{
await ClaimTargetIfEmptyAsync(username, unit.DestinationQ.Value, unit.DestinationR.Value, now);
await TryClaimWithArrivedScoutAsync(unit, destinationQ, destinationR, now);
}
}
}
@@ -335,6 +436,84 @@ public class UnitTrainingService
}
}
private async Task<bool> IsAdjacentToOwnedAsync(string username, int q, int r)
{
var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
return owned.Any(location => HexDistance(location.Q, location.R, q, r) == 1);
}
private async Task TryClaimWithArrivedScoutAsync(UnitInstance scout, int q, int r, DateTime now)
{
var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync();
if (target is not null)
{
if (string.Equals(target.OwnerUsername, scout.Username, StringComparison.OrdinalIgnoreCase) ||
string.Equals(target.Kind, "Home", StringComparison.OrdinalIgnoreCase) ||
!string.IsNullOrWhiteSpace(target.BuildingType))
{
return;
}
}
var hasBlockingUnit = await _units.Find(x =>
x.Id != scout.Id &&
x.Q == q &&
x.R == r)
.AnyAsync();
if (hasBlockingUnit)
{
return;
}
if (!await IsAdjacentToOwnedAsync(scout.Username, q, r))
{
return;
}
var claimed = false;
if (target is null)
{
var location = new Location
{
Q = q,
R = r,
Terrain = "Field",
Kind = "Claimed",
OwnerUsername = scout.Username,
Level = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
try
{
await _locations.InsertOneAsync(location);
claimed = true;
}
catch (MongoWriteException)
{
}
}
else
{
var result = await _locations.UpdateOneAsync(
x => x.Id == target.Id &&
x.Kind != "Home" &&
(x.BuildingType == null || x.BuildingType == "") &&
x.OwnerUsername != scout.Username,
Builders<Location>.Update
.Set(x => x.OwnerUsername, scout.Username)
.Set(x => x.Kind, "Claimed")
.Set(x => x.UpdatedAtUtc, now));
claimed = result.ModifiedCount > 0;
}
if (claimed)
{
await _units.DeleteOneAsync(x => x.Id == scout.Id);
}
}
private async Task CompleteFinishedBuildingsAsync(string username)
{
var now = DateTime.UtcNow;
@@ -363,62 +542,66 @@ public class UnitTrainingService
return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1);
}
private async Task ClaimTargetIfEmptyAsync(string username, int q, int r, DateTime now)
private async Task<bool> CanPeasantMoveToAsync(string username, int q, int r)
{
var hasOtherUnits = await _units.Find(x => x.Username != username && x.Q == q && x.R == r).AnyAsync();
var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync();
var hasBuilding = target is not null &&
(!string.IsNullOrWhiteSpace(target.BuildingType) ||
string.Equals(target.Kind, "Home", StringComparison.OrdinalIgnoreCase));
if (hasOtherUnits || hasBuilding)
{
return;
return await _locations.Find(x =>
x.OwnerUsername == username &&
x.Q == q &&
x.R == r)
.AnyAsync();
}
if (target is null)
private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, int fromQ, int fromR, int toQ, int toR)
{
var location = new Location
{
Q = q,
R = r,
Terrain = "Field",
Kind = "Claimed",
OwnerUsername = username,
Level = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
try
{
await _locations.InsertOneAsync(location);
}
catch (MongoWriteException)
var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet();
if (!ownedCoordinates.Contains((toQ, toR)))
{
return null;
}
return;
}
var start = (fromQ, fromR);
var target = (toQ, toR);
var queue = new Queue<(int q, int r)>();
var previous = new Dictionary<(int q, int r), (int q, int r)>();
var visited = new HashSet<(int q, int r)> { start };
queue.Enqueue(start);
if (string.Equals(target.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
while (queue.Count > 0)
{
return;
var current = queue.Dequeue();
if (current == target)
{
break;
}
var capturableFilter = Builders<Location>.Filter.And(
Builders<Location>.Filter.Eq(x => x.Id, target.Id),
Builders<Location>.Filter.Ne(x => x.Kind, "Home"),
Builders<Location>.Filter.Or(
Builders<Location>.Filter.Eq(x => x.BuildingType, null),
Builders<Location>.Filter.Eq(x => x.BuildingType, "")));
foreach (var neighbor in NeighborCoordinates(current.q, current.r)
.OrderBy(x => HexDistance(x.q, x.r, toQ, toR))
.ThenBy(x => x.q)
.ThenBy(x => x.r))
{
if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor))
{
continue;
}
await _locations.UpdateOneAsync(
capturableFilter,
Builders<Location>.Update
.Set(x => x.OwnerUsername, username)
.Set(x => x.Kind, "Claimed")
.Set(x => x.UpdatedAtUtc, now));
previous[neighbor] = current;
queue.Enqueue(neighbor);
}
}
if (!visited.Contains(target))
{
return null;
}
var step = target;
while (previous.TryGetValue(step, out var before) && before != start)
{
step = before;
}
return step == start ? target : step;
}
private static int HexDistance(int q1, int r1, int q2, int r2)
@@ -428,9 +611,40 @@ public class UnitTrainingService
return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2));
}
private static (int q, int r) NextStepToward(int fromQ, int fromR, int toQ, int toR)
{
return NeighborCoordinates(fromQ, fromR)
.OrderBy(x => HexDistance(x.q, x.r, toQ, toR))
.ThenBy(x => x.q)
.ThenBy(x => x.r)
.First();
}
private static IEnumerable<(int q, int r)> NeighborCoordinates(int q, int r)
{
yield return (q + 1, r);
yield return (q + 1, r - 1);
yield return (q, r - 1);
yield return (q - 1, r);
yield return (q - 1, r + 1);
yield return (q, r + 1);
}
public async Task<bool> TryConsumeCompletedScoutAsync(string username)
{
await CompleteFinishedTrainingAsync(username);
await PromoteCompletedScoutsAsync(username);
var consumedUnit = await _units.FindOneAndDeleteAsync(
x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Idle",
new FindOneAndDeleteOptions<UnitInstance>
{
Sort = Builders<UnitInstance>.Sort.Ascending(x => x.CreatedAtUtc)
});
if (consumedUnit is not null)
{
return true;
}
var consumed = await _training.FindOneAndDeleteAsync(
x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed",
@@ -445,7 +659,8 @@ public class UnitTrainingService
public async Task<bool> HasCompletedScoutAsync(string username)
{
await CompleteFinishedTrainingAsync(username);
return await _training.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed").AnyAsync();
await PromoteCompletedScoutsAsync(username);
return await _units.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Idle").AnyAsync();
}
private async Task CompleteFinishedTrainingAsync(string username)
@@ -472,7 +687,7 @@ public class UnitTrainingService
ReturnDocument = ReturnDocument.After
});
if (completed is null || (completed.UnitType != ArcherUnitType && completed.UnitType != PeasantUnitType))
if (completed is null || (completed.UnitType != ArcherUnitType && completed.UnitType != PeasantUnitType && completed.UnitType != ScoutUnitType))
{
continue;
}
@@ -496,6 +711,14 @@ public class UnitTrainingService
try
{
await _units.InsertOneAsync(unit);
if (completed.UnitType == ScoutUnitType)
{
await _training.UpdateOneAsync(
x => x.Id == completed.Id,
Builders<UnitTraining>.Update
.Set(x => x.Status, "Consumed")
.Set(x => x.UpdatedAtUtc, now));
}
}
catch (MongoWriteException)
{
@@ -503,6 +726,58 @@ public class UnitTrainingService
}
}
private async Task PromoteCompletedScoutsAsync(string username)
{
var now = DateTime.UtcNow;
var completedScouts = await _training.Find(x =>
x.Username == username &&
x.UnitType == ScoutUnitType &&
x.Status == "Completed")
.ToListAsync();
if (completedScouts.Count == 0)
{
return;
}
var home = await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick);
foreach (var scout in completedScouts)
{
var existing = await _units.Find(x => x.SourceTrainingId == scout.Id).AnyAsync();
if (!existing)
{
var unit = new UnitInstance
{
Username = scout.Username,
UnitType = ScoutUnitType,
Q = scout.OriginQ ?? home?.Q ?? 0,
R = scout.OriginR ?? home?.R ?? 0,
Status = "Idle",
MovementPerTick = movementPerTick,
MovementRemaining = movementPerTick,
LastMovementRefillAtUtc = now,
SourceTrainingId = scout.Id,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
try
{
await _units.InsertOneAsync(unit);
}
catch (MongoWriteException)
{
}
}
await _training.UpdateOneAsync(
x => x.Id == scout.Id,
Builders<UnitTraining>.Update
.Set(x => x.Status, "Consumed")
.Set(x => x.UpdatedAtUtc, now));
}
}
private static string? NormalizeUnitType(string unitType)
{
var trimmed = unitType.Trim();
@@ -22,6 +22,7 @@
"Resources": {
"StartingGold": 100,
"HeadquartersGoldPerMinute": 10,
"TownHallFoodPerMinute": 5,
"TownHallGoldCapacity": 500,
"TownHallWoodCapacity": 100,
"TownHallOreCapacity": 100,
@@ -13,6 +13,7 @@ data:
World__VisibilityDepth: "3"
Resources__StartingGold: "100"
Resources__HeadquartersGoldPerMinute: "10"
Resources__TownHallFoodPerMinute: "5"
Resources__TownHallGoldCapacity: "500"
Resources__TownHallWoodCapacity: "100"
Resources__TownHallOreCapacity: "100"