Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e60d622c | ||
|
|
a2fd9a84e3 | ||
|
|
5530a185a0 | ||
|
|
de42f85835 | ||
|
|
c89c3a0947 | ||
|
|
f142440aca | ||
|
|
4a3d3af41c | ||
|
|
a40c8cf50d | ||
|
|
94f3b1e258 | ||
|
|
84136ad534 |
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user