adding log camps
This commit is contained in:
@@ -14,6 +14,7 @@ var resources_request: HTTPRequest
|
|||||||
var units_request: HTTPRequest
|
var units_request: HTTPRequest
|
||||||
var train_unit_request: HTTPRequest
|
var train_unit_request: HTTPRequest
|
||||||
var claim_request: HTTPRequest
|
var claim_request: HTTPRequest
|
||||||
|
var build_request: HTTPRequest
|
||||||
var resources_timer: Timer
|
var resources_timer: Timer
|
||||||
var session: Dictionary = {}
|
var session: Dictionary = {}
|
||||||
var escape_overlay: Control
|
var escape_overlay: Control
|
||||||
@@ -31,9 +32,11 @@ var training_started_unix := 0.0
|
|||||||
var training_completes_unix := 0.0
|
var training_completes_unix := 0.0
|
||||||
var training_completion_refresh_requested := false
|
var training_completion_refresh_requested := false
|
||||||
var claim_button: Button
|
var claim_button: Button
|
||||||
|
var build_log_camp_button: Button
|
||||||
var selected_q := 0
|
var selected_q := 0
|
||||||
var selected_r := 0
|
var selected_r := 0
|
||||||
var selected_can_claim := false
|
var selected_can_claim := false
|
||||||
|
var selected_can_build_log_camp := false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -104,9 +107,9 @@ func _build_ui() -> void:
|
|||||||
legend.add_theme_constant_override("separation", 12)
|
legend.add_theme_constant_override("separation", 12)
|
||||||
top_content.add_child(legend)
|
top_content.add_child(legend)
|
||||||
|
|
||||||
legend.add_child(_legend_item(Color(0.78, 0.62, 0.19), "Home"))
|
legend.add_child(_legend_item(Color(1.0, 0.76, 0.16), "Home"))
|
||||||
legend.add_child(_legend_item(Color(0.26, 0.62, 0.32), "Yours"))
|
legend.add_child(_legend_item(Color(0.16, 0.86, 0.92), "Yours"))
|
||||||
legend.add_child(_legend_item(Color(0.58, 0.28, 0.24), "Rival"))
|
legend.add_child(_legend_item(Color(0.95, 0.22, 0.18), "Rival"))
|
||||||
legend.add_child(_legend_item(Color(0.38, 0.56, 0.36), "Open"))
|
legend.add_child(_legend_item(Color(0.38, 0.56, 0.36), "Open"))
|
||||||
legend.add_child(_legend_item(Color(0.045, 0.065, 0.065), "Unknown"))
|
legend.add_child(_legend_item(Color(0.045, 0.065, 0.065), "Unknown"))
|
||||||
|
|
||||||
@@ -181,8 +184,8 @@ func _build_ui() -> void:
|
|||||||
bottom_content.add_child(coordinate_label)
|
bottom_content.add_child(coordinate_label)
|
||||||
|
|
||||||
unit_label = Label.new()
|
unit_label = Label.new()
|
||||||
unit_label.text = "Scouts: --"
|
unit_label.text = "Scouts: -- | Queue: --"
|
||||||
unit_label.custom_minimum_size = Vector2(88, 0)
|
unit_label.custom_minimum_size = Vector2(142, 0)
|
||||||
unit_label.add_theme_font_size_override("font_size", 14)
|
unit_label.add_theme_font_size_override("font_size", 14)
|
||||||
unit_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78))
|
unit_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78))
|
||||||
bottom_content.add_child(unit_label)
|
bottom_content.add_child(unit_label)
|
||||||
@@ -217,6 +220,11 @@ func _build_ui() -> void:
|
|||||||
claim_button.pressed.connect(_claim_selected_tile)
|
claim_button.pressed.connect(_claim_selected_tile)
|
||||||
bottom_content.add_child(claim_button)
|
bottom_content.add_child(claim_button)
|
||||||
|
|
||||||
|
build_log_camp_button = _small_button("Build Log Camp")
|
||||||
|
build_log_camp_button.disabled = true
|
||||||
|
build_log_camp_button.pressed.connect(_build_log_camp)
|
||||||
|
bottom_content.add_child(build_log_camp_button)
|
||||||
|
|
||||||
http_request = HTTPRequest.new()
|
http_request = HTTPRequest.new()
|
||||||
http_request.request_completed.connect(_on_home_request_completed)
|
http_request.request_completed.connect(_on_home_request_completed)
|
||||||
add_child(http_request)
|
add_child(http_request)
|
||||||
@@ -241,6 +249,10 @@ func _build_ui() -> void:
|
|||||||
claim_request.request_completed.connect(_on_claim_request_completed)
|
claim_request.request_completed.connect(_on_claim_request_completed)
|
||||||
add_child(claim_request)
|
add_child(claim_request)
|
||||||
|
|
||||||
|
build_request = HTTPRequest.new()
|
||||||
|
build_request.request_completed.connect(_on_build_request_completed)
|
||||||
|
add_child(build_request)
|
||||||
|
|
||||||
resources_timer = Timer.new()
|
resources_timer = Timer.new()
|
||||||
resources_timer.wait_time = RESOURCE_REFRESH_SECONDS
|
resources_timer.wait_time = RESOURCE_REFRESH_SECONDS
|
||||||
resources_timer.autostart = false
|
resources_timer.autostart = false
|
||||||
@@ -259,8 +271,11 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void:
|
|||||||
selected_q = q
|
selected_q = q
|
||||||
selected_r = r
|
selected_r = r
|
||||||
selected_can_claim = terrain.contains("Unclaimed")
|
selected_can_claim = terrain.contains("Unclaimed")
|
||||||
|
selected_can_build_log_camp = terrain.contains("Forest") and terrain.contains("Your territory") and not terrain.contains("Log Camp")
|
||||||
if claim_button:
|
if claim_button:
|
||||||
claim_button.disabled = not selected_can_claim
|
claim_button.disabled = not selected_can_claim
|
||||||
|
if build_log_camp_button:
|
||||||
|
build_log_camp_button.disabled = not selected_can_build_log_camp
|
||||||
if coordinate_label:
|
if coordinate_label:
|
||||||
coordinate_label.text = "Selected q:%d r:%d | %s territory" % [q, r, terrain]
|
coordinate_label.text = "Selected q:%d r:%d | %s territory" % [q, r, terrain]
|
||||||
|
|
||||||
@@ -412,7 +427,9 @@ func _on_resources_request_completed(result: int, response_code: int, _headers:
|
|||||||
var resources: Dictionary = parsed
|
var resources: Dictionary = parsed
|
||||||
var gold: int = int(resources.get("gold", 0))
|
var gold: int = int(resources.get("gold", 0))
|
||||||
var gold_per_minute: int = int(resources.get("goldPerMinute", 0))
|
var gold_per_minute: int = int(resources.get("goldPerMinute", 0))
|
||||||
gold_label.text = "Gold: %d (+%d/min)" % [gold, gold_per_minute]
|
var wood: int = int(resources.get("wood", 0))
|
||||||
|
var wood_per_minute: int = int(resources.get("woodPerMinute", 0))
|
||||||
|
gold_label.text = "Gold: %d (+%d/min) | Wood: %d (+%d/min)" % [gold, gold_per_minute, wood, wood_per_minute]
|
||||||
|
|
||||||
var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", ""))
|
var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", ""))
|
||||||
gold_tick_anchor_unix = _parse_utc_timestamp(anchor_text)
|
gold_tick_anchor_unix = _parse_utc_timestamp(anchor_text)
|
||||||
@@ -452,12 +469,13 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack
|
|||||||
return
|
return
|
||||||
|
|
||||||
var units: Dictionary = parsed
|
var units: Dictionary = parsed
|
||||||
unit_label.text = "Scouts: %d" % int(units.get("completedScouts", 0))
|
|
||||||
|
|
||||||
var training: Array = []
|
var training: Array = []
|
||||||
var raw_training = units.get("training", [])
|
var raw_training = units.get("training", [])
|
||||||
if typeof(raw_training) == TYPE_ARRAY:
|
if typeof(raw_training) == TYPE_ARRAY:
|
||||||
training = raw_training
|
training = raw_training
|
||||||
|
|
||||||
|
unit_label.text = "Scouts: %d | Queue: %d" % [int(units.get("completedScouts", 0)), training.size()]
|
||||||
|
|
||||||
if training.is_empty():
|
if training.is_empty():
|
||||||
training_started_unix = 0.0
|
training_started_unix = 0.0
|
||||||
training_completes_unix = 0.0
|
training_completes_unix = 0.0
|
||||||
@@ -572,6 +590,55 @@ func _on_claim_request_completed(result: int, response_code: int, _headers: Pack
|
|||||||
_refresh_player_state()
|
_refresh_player_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_log_camp() -> void:
|
||||||
|
if not selected_can_build_log_camp:
|
||||||
|
coordinate_label.text = "Select controlled Forest territory without a building."
|
||||||
|
return
|
||||||
|
|
||||||
|
var access_token: String = str(session.get("accessToken", ""))
|
||||||
|
if access_token.is_empty():
|
||||||
|
coordinate_label.text = "No auth token available. Log Camp was not built."
|
||||||
|
return
|
||||||
|
|
||||||
|
if build_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
return
|
||||||
|
|
||||||
|
build_log_camp_button.disabled = true
|
||||||
|
coordinate_label.text = "Building Log Camp at q:%d r:%d..." % [selected_q, selected_r]
|
||||||
|
var error: int = build_request.request(
|
||||||
|
LOCATIONS_BASE_URL + "/api/locations/build",
|
||||||
|
PackedStringArray([
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"Authorization: Bearer %s" % access_token
|
||||||
|
]),
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
JSON.stringify({ "q": selected_q, "r": selected_r, "buildingType": "LogCamp" })
|
||||||
|
)
|
||||||
|
if error != OK:
|
||||||
|
build_log_camp_button.disabled = false
|
||||||
|
coordinate_label.text = "Could not contact the locations service."
|
||||||
|
|
||||||
|
|
||||||
|
func _on_build_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 = "Log Camp build request failed."
|
||||||
|
build_log_camp_button.disabled = not selected_can_build_log_camp
|
||||||
|
return
|
||||||
|
|
||||||
|
if response_code < 200 or response_code >= 300:
|
||||||
|
coordinate_label.text = "Log Camp build failed: %s" % text
|
||||||
|
build_log_camp_button.disabled = not selected_can_build_log_camp
|
||||||
|
_refresh_player_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_can_build_log_camp = false
|
||||||
|
build_log_camp_button.disabled = true
|
||||||
|
coordinate_label.text = "Log Camp built. Wood production increased."
|
||||||
|
_load_visible_locations()
|
||||||
|
_refresh_player_state()
|
||||||
|
|
||||||
|
|
||||||
func _update_tick_bar() -> void:
|
func _update_tick_bar() -> void:
|
||||||
if not tick_bar or not tick_label:
|
if not tick_bar or not tick_label:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ signal tile_selected(q: int, r: int, terrain: String)
|
|||||||
const HEX_SIZE := 42.0
|
const HEX_SIZE := 42.0
|
||||||
const ISO_Y_SCALE := 0.56
|
const ISO_Y_SCALE := 0.56
|
||||||
const SQRT_3 := 1.7320508075688772
|
const SQRT_3 := 1.7320508075688772
|
||||||
|
const INFLUENCE_RADIUS_X := 54.0
|
||||||
|
const INFLUENCE_RADIUS_Y := 30.0
|
||||||
|
const INFLUENCE_LINK_WIDTH := 54.0
|
||||||
|
|
||||||
var camera_offset: Vector2 = Vector2.ZERO
|
var camera_offset: Vector2 = Vector2.ZERO
|
||||||
var zoom: float = 1.0
|
var zoom: float = 1.0
|
||||||
@@ -101,10 +104,11 @@ func _draw() -> void:
|
|||||||
return _tile_center(int(a["q"]), int(a["r"])).y < _tile_center(int(b["q"]), int(b["r"])).y
|
return _tile_center(int(a["q"]), int(a["r"])).y < _tile_center(int(b["q"]), int(b["r"])).y
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_draw_control_regions(sorted_tiles)
|
||||||
|
|
||||||
for tile in sorted_tiles:
|
for tile in sorted_tiles:
|
||||||
_draw_tile(tile)
|
_draw_tile(tile)
|
||||||
|
|
||||||
|
|
||||||
func _draw_grid_lines() -> void:
|
func _draw_grid_lines() -> void:
|
||||||
var line_color: Color = Color(0.12, 0.17, 0.17, 0.35)
|
var line_color: Color = Color(0.12, 0.17, 0.17, 0.35)
|
||||||
for x in range(-8, 9):
|
for x in range(-8, 9):
|
||||||
@@ -155,10 +159,55 @@ func _draw_tile(tile: Dictionary) -> void:
|
|||||||
draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0)
|
draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0)
|
||||||
elif coord == home_tile:
|
elif coord == home_tile:
|
||||||
draw_polyline(_closed_points(points), Color(1.0, 0.88, 0.38), 3.0)
|
draw_polyline(_closed_points(points), Color(1.0, 0.88, 0.38), 3.0)
|
||||||
elif is_owned:
|
|
||||||
draw_polyline(_closed_points(points), Color(0.48, 0.95, 0.55), 3.0)
|
var visible_location: Dictionary = visible_tiles.get(key, {})
|
||||||
elif is_rival:
|
var building_type: String = str(visible_location.get("buildingType", ""))
|
||||||
draw_polyline(_closed_points(points), Color(0.94, 0.36, 0.29), 3.0)
|
if building_type == "LogCamp":
|
||||||
|
_draw_log_camp_marker(center, height)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_control_regions(sorted_tiles: Array) -> void:
|
||||||
|
for control in ["owned", "rival"]:
|
||||||
|
var control_tiles: Array = sorted_tiles.filter(func(tile: Dictionary) -> bool:
|
||||||
|
return _tile_control(_tile_key(int(tile["q"]), int(tile["r"]))) == control
|
||||||
|
)
|
||||||
|
if control_tiles.is_empty():
|
||||||
|
continue
|
||||||
|
|
||||||
|
_draw_control_layer(control_tiles, control, 1.34, 0.16, INFLUENCE_LINK_WIDTH + 18.0)
|
||||||
|
_draw_control_layer(control_tiles, control, 1.08, 0.28, INFLUENCE_LINK_WIDTH)
|
||||||
|
_draw_control_layer(control_tiles, control, 0.82, 0.20, INFLUENCE_LINK_WIDTH - 16.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_control_layer(control_tiles: Array, control: String, layer_scale: float, alpha: float, link_width: float) -> void:
|
||||||
|
var control_color: Color = _control_color(control)
|
||||||
|
var layer_color := Color(control_color.r, control_color.g, control_color.b, alpha)
|
||||||
|
var tile_lookup := {}
|
||||||
|
|
||||||
|
for tile in control_tiles:
|
||||||
|
var q: int = int(tile["q"])
|
||||||
|
var r: int = int(tile["r"])
|
||||||
|
tile_lookup[_tile_key(q, r)] = true
|
||||||
|
|
||||||
|
for tile in control_tiles:
|
||||||
|
var q: int = int(tile["q"])
|
||||||
|
var r: int = int(tile["r"])
|
||||||
|
var center: Vector2 = _tile_center(q, r)
|
||||||
|
|
||||||
|
for neighbor in _neighbor_coordinates(q, r):
|
||||||
|
var neighbor_key: String = _tile_key(neighbor.x, neighbor.y)
|
||||||
|
if not tile_lookup.has(neighbor_key):
|
||||||
|
continue
|
||||||
|
if _tile_key(q, r) > neighbor_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
var neighbor_center: Vector2 = _tile_center(neighbor.x, neighbor.y)
|
||||||
|
draw_line(center, neighbor_center, layer_color, link_width * zoom * layer_scale)
|
||||||
|
|
||||||
|
draw_colored_polygon(
|
||||||
|
_ellipse_points(center, INFLUENCE_RADIUS_X * zoom * layer_scale, INFLUENCE_RADIUS_Y * zoom * layer_scale),
|
||||||
|
layer_color
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _update_hover(mouse_position: Vector2) -> void:
|
func _update_hover(mouse_position: Vector2) -> void:
|
||||||
@@ -202,11 +251,15 @@ func _screen_point(world_point: Vector2) -> Vector2:
|
|||||||
|
|
||||||
|
|
||||||
func _hex_points(center: Vector2, height: float) -> PackedVector2Array:
|
func _hex_points(center: Vector2, height: float) -> PackedVector2Array:
|
||||||
|
return _hex_points_scaled(center, height, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _hex_points_scaled(center: Vector2, height: float, point_scale: float) -> PackedVector2Array:
|
||||||
var points: PackedVector2Array = PackedVector2Array()
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
var top_lift: Vector2 = Vector2(0, -height * zoom)
|
var top_lift: Vector2 = Vector2(0, -height * zoom)
|
||||||
for i in range(6):
|
for i in range(6):
|
||||||
var angle: float = deg_to_rad(60.0 * i + 30.0)
|
var angle: float = deg_to_rad(60.0 * i + 30.0)
|
||||||
var local: Vector2 = Vector2(cos(angle) * HEX_SIZE, sin(angle) * HEX_SIZE * ISO_Y_SCALE) * zoom
|
var local: Vector2 = Vector2(cos(angle) * HEX_SIZE * point_scale, sin(angle) * HEX_SIZE * ISO_Y_SCALE * point_scale) * zoom
|
||||||
points.append(center + local + top_lift)
|
points.append(center + local + top_lift)
|
||||||
return points
|
return points
|
||||||
|
|
||||||
@@ -218,6 +271,33 @@ func _closed_points(points: PackedVector2Array) -> PackedVector2Array:
|
|||||||
return closed
|
return closed
|
||||||
|
|
||||||
|
|
||||||
|
func _ellipse_points(center: Vector2, radius_x: float, radius_y: float) -> PackedVector2Array:
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for i in range(32):
|
||||||
|
var angle: float = TAU * float(i) / 32.0
|
||||||
|
points.append(center + Vector2(cos(angle) * radius_x, sin(angle) * radius_y))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_log_camp_marker(center: Vector2, height: float) -> void:
|
||||||
|
var marker_center: Vector2 = center + Vector2(0, -height * zoom - 2.0)
|
||||||
|
var width: float = 18.0 * zoom
|
||||||
|
var marker_height: float = 10.0 * zoom
|
||||||
|
var hut_color := Color(0.46, 0.29, 0.14)
|
||||||
|
var roof_color := Color(0.28, 0.17, 0.09)
|
||||||
|
var log_color := Color(0.72, 0.52, 0.28)
|
||||||
|
|
||||||
|
var roof := PackedVector2Array([
|
||||||
|
marker_center + Vector2(-width * 0.55, -marker_height * 0.12),
|
||||||
|
marker_center + Vector2(0, -marker_height * 0.75),
|
||||||
|
marker_center + Vector2(width * 0.55, -marker_height * 0.12)
|
||||||
|
])
|
||||||
|
draw_colored_polygon(roof, roof_color)
|
||||||
|
draw_rect(Rect2(marker_center + Vector2(-width * 0.38, -marker_height * 0.08), Vector2(width * 0.76, marker_height * 0.62)), hut_color, true)
|
||||||
|
draw_line(marker_center + Vector2(-width * 0.48, marker_height * 0.62), marker_center + Vector2(width * 0.48, marker_height * 0.62), log_color, 3.0 * zoom)
|
||||||
|
draw_line(marker_center + Vector2(-width * 0.38, marker_height * 0.86), marker_center + Vector2(width * 0.38, marker_height * 0.86), log_color.darkened(0.12), 3.0 * zoom)
|
||||||
|
|
||||||
|
|
||||||
func _tile_data(q: int, r: int) -> Dictionary:
|
func _tile_data(q: int, r: int) -> Dictionary:
|
||||||
for tile in tiles:
|
for tile in tiles:
|
||||||
if int(tile["q"]) == q and int(tile["r"]) == r:
|
if int(tile["q"]) == q and int(tile["r"]) == r:
|
||||||
@@ -269,27 +349,61 @@ func _ownership_fill_color(base_color: Color, coord: Vector2i, tile_visible: boo
|
|||||||
if not tile_visible:
|
if not tile_visible:
|
||||||
return Color(0.045, 0.065, 0.065)
|
return Color(0.045, 0.065, 0.065)
|
||||||
if coord == home_tile:
|
if coord == home_tile:
|
||||||
return Color(0.78, 0.62, 0.19)
|
return base_color.lerp(Color(0.95, 0.72, 0.18), 0.38)
|
||||||
if is_owned:
|
if is_owned:
|
||||||
return Color(0.26, 0.62, 0.32)
|
return base_color.lerp(Color(0.20, 0.68, 0.72), 0.32)
|
||||||
if is_rival:
|
if is_rival:
|
||||||
return Color(0.58, 0.28, 0.24)
|
return base_color.lerp(Color(0.72, 0.22, 0.18), 0.35)
|
||||||
return base_color
|
return base_color
|
||||||
|
|
||||||
|
|
||||||
|
func _tile_control(key: String) -> String:
|
||||||
|
if owned_tiles.has(key):
|
||||||
|
return "owned"
|
||||||
|
if rival_tiles.has(key):
|
||||||
|
return "rival"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _control_color(control: String) -> Color:
|
||||||
|
match control:
|
||||||
|
"owned":
|
||||||
|
return Color(0.16, 0.86, 0.92)
|
||||||
|
"rival":
|
||||||
|
return Color(0.95, 0.22, 0.18)
|
||||||
|
_:
|
||||||
|
return Color(0.4, 0.5, 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
func _neighbor_coordinates(q: int, r: int) -> Array[Vector2i]:
|
||||||
|
return [
|
||||||
|
Vector2i(q + 1, r),
|
||||||
|
Vector2i(q + 1, r - 1),
|
||||||
|
Vector2i(q, r - 1),
|
||||||
|
Vector2i(q - 1, r),
|
||||||
|
Vector2i(q - 1, r + 1),
|
||||||
|
Vector2i(q, r + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
func _tile_display_label(key: String, data: Dictionary) -> String:
|
func _tile_display_label(key: String, data: Dictionary) -> String:
|
||||||
var terrain: String = str(data.get("terrain", "Open"))
|
var terrain: String = str(data.get("terrain", "Open"))
|
||||||
|
var building_type: String = str(data.get("buildingType", ""))
|
||||||
|
var building_label: String = ""
|
||||||
|
if building_type == "LogCamp":
|
||||||
|
building_label = " | Log Camp"
|
||||||
|
|
||||||
if home_tile == Vector2i(int(data.get("q", 0)), int(data.get("r", 0))):
|
if home_tile == Vector2i(int(data.get("q", 0)), int(data.get("r", 0))):
|
||||||
return "%s | Your home" % terrain
|
return "%s | Your home%s" % [terrain, building_label]
|
||||||
if owned_tiles.has(key):
|
if owned_tiles.has(key):
|
||||||
return "%s | Your territory" % terrain
|
return "%s | Your territory%s" % [terrain, building_label]
|
||||||
if rival_tiles.has(key):
|
if rival_tiles.has(key):
|
||||||
var rival_data: Dictionary = rival_tiles[key]
|
var rival_data: Dictionary = rival_tiles[key]
|
||||||
var owner_username: String = _owner_username(rival_data)
|
var owner_username: String = _owner_username(rival_data)
|
||||||
if owner_username.is_empty():
|
if owner_username.is_empty():
|
||||||
return "%s | Unclaimed" % terrain
|
return "%s | Unclaimed%s" % [terrain, building_label]
|
||||||
return "%s | Controlled by %s" % [terrain, owner_username]
|
return "%s | Controlled by %s%s" % [terrain, owner_username, building_label]
|
||||||
return "%s | Unclaimed" % terrain
|
return "%s | Unclaimed%s" % [terrain, building_label]
|
||||||
|
|
||||||
|
|
||||||
func _owner_username(location: Dictionary) -> String:
|
func _owner_username(location: Dictionary) -> String:
|
||||||
|
|||||||
@@ -113,6 +113,36 @@ public class LocationsController : ControllerBase
|
|||||||
return Ok(LocationResponse.FromLocation(location));
|
return Ok(LocationResponse.FromLocation(location));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
|
[HttpPost("build")]
|
||||||
|
public async Task<ActionResult<LocationResponse>> Build([FromBody] BuildLocationRequest request)
|
||||||
|
{
|
||||||
|
var username = User.FindFirstValue(ClaimTypes.Name);
|
||||||
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var location = await _locations.BuildAsync(request, username);
|
||||||
|
if (location is null)
|
||||||
|
{
|
||||||
|
return NotFound("Build target must be controlled by the player.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(LocationResponse.FromLocation(location));
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
[HttpGet("home")]
|
[HttpGet("home")]
|
||||||
public async Task<ActionResult<LocationResponse>> GetHome()
|
public async Task<ActionResult<LocationResponse>> GetHome()
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ public class ClaimLocationRequest
|
|||||||
public int R { get; set; }
|
public int R { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class BuildLocationRequest
|
||||||
|
{
|
||||||
|
public int Q { get; set; }
|
||||||
|
public int R { get; set; }
|
||||||
|
public string BuildingType { get; set; } = "LogCamp";
|
||||||
|
}
|
||||||
|
|
||||||
public class LocationResponse
|
public class LocationResponse
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = "";
|
public string Id { get; set; } = "";
|
||||||
@@ -25,6 +32,8 @@ public class LocationResponse
|
|||||||
public string? OwnerUsername { get; set; }
|
public string? OwnerUsername { get; set; }
|
||||||
public string? Label { get; set; }
|
public string? Label { get; set; }
|
||||||
public int Level { get; set; }
|
public int Level { get; set; }
|
||||||
|
public string? BuildingType { get; set; }
|
||||||
|
public DateTime? BuildingBuiltAtUtc { get; set; }
|
||||||
public DateTime? HomeAssignedAtUtc { get; set; }
|
public DateTime? HomeAssignedAtUtc { get; set; }
|
||||||
public DateTime UpdatedAtUtc { get; set; }
|
public DateTime UpdatedAtUtc { get; set; }
|
||||||
public bool IsVisible { get; set; } = true;
|
public bool IsVisible { get; set; } = true;
|
||||||
@@ -39,6 +48,8 @@ public class LocationResponse
|
|||||||
OwnerUsername = location.OwnerUsername,
|
OwnerUsername = location.OwnerUsername,
|
||||||
Label = location.Label,
|
Label = location.Label,
|
||||||
Level = location.Level,
|
Level = location.Level,
|
||||||
|
BuildingType = location.BuildingType,
|
||||||
|
BuildingBuiltAtUtc = location.BuildingBuiltAtUtc,
|
||||||
HomeAssignedAtUtc = location.HomeAssignedAtUtc,
|
HomeAssignedAtUtc = location.HomeAssignedAtUtc,
|
||||||
UpdatedAtUtc = location.UpdatedAtUtc,
|
UpdatedAtUtc = location.UpdatedAtUtc,
|
||||||
IsVisible = true
|
IsVisible = true
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class Location
|
|||||||
public string? OwnerUsername { get; set; }
|
public string? OwnerUsername { get; set; }
|
||||||
public string? Label { get; set; }
|
public string? Label { get; set; }
|
||||||
public int Level { get; set; } = 1;
|
public int Level { get; set; } = 1;
|
||||||
|
public string? BuildingType { get; set; }
|
||||||
|
public DateTime? BuildingBuiltAtUtc { get; set; }
|
||||||
public DateTime? HomeAssignedAtUtc { get; set; }
|
public DateTime? HomeAssignedAtUtc { get; set; }
|
||||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ public class PlayerResources
|
|||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
public int Gold { get; set; } = 100;
|
public int Gold { get; set; } = 100;
|
||||||
public int GoldPerMinute { get; set; } = 10;
|
public int GoldPerMinute { get; set; } = 10;
|
||||||
|
public int Wood { get; set; }
|
||||||
|
public int WoodPerMinute { get; set; }
|
||||||
public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime LastWoodGeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ public class PlayerResourcesResponse
|
|||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
public int Gold { get; set; }
|
public int Gold { get; set; }
|
||||||
public int GoldPerMinute { get; set; }
|
public int GoldPerMinute { get; set; }
|
||||||
|
public int Wood { get; set; }
|
||||||
|
public int WoodPerMinute { get; set; }
|
||||||
public DateTime LastGoldGeneratedAtUtc { get; set; }
|
public DateTime LastGoldGeneratedAtUtc { get; set; }
|
||||||
|
public DateTime LastWoodGeneratedAtUtc { get; set; }
|
||||||
public DateTime UpdatedAtUtc { get; set; }
|
public DateTime UpdatedAtUtc { get; set; }
|
||||||
|
|
||||||
public static PlayerResourcesResponse FromResources(PlayerResources resources) => new()
|
public static PlayerResourcesResponse FromResources(PlayerResources resources) => new()
|
||||||
@@ -13,7 +16,10 @@ public class PlayerResourcesResponse
|
|||||||
Username = resources.Username,
|
Username = resources.Username,
|
||||||
Gold = resources.Gold,
|
Gold = resources.Gold,
|
||||||
GoldPerMinute = resources.GoldPerMinute,
|
GoldPerMinute = resources.GoldPerMinute,
|
||||||
|
Wood = resources.Wood,
|
||||||
|
WoodPerMinute = resources.WoodPerMinute,
|
||||||
LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc,
|
LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc,
|
||||||
|
LastWoodGeneratedAtUtc = resources.LastWoodGeneratedAtUtc,
|
||||||
UpdatedAtUtc = resources.UpdatedAtUtc
|
UpdatedAtUtc = resources.UpdatedAtUtc
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Tracks hex locations for the territory map.
|
|||||||
- `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range, constrained by visibility for USER.
|
- `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range, constrained by visibility for USER.
|
||||||
- `GET /api/locations/{q}/{r}` Fetch one visible location for USER, any known location for SUPER.
|
- `GET /api/locations/{q}/{r}` Fetch one visible location for USER, any known location for SUPER.
|
||||||
- `POST /api/locations/claim` Claim visible unclaimed territory for the authenticated user. Normal players spend one completed Scout.
|
- `POST /api/locations/claim` Claim visible unclaimed territory for the authenticated user. Normal players spend one completed Scout.
|
||||||
|
- `POST /api/locations/build` Build on controlled territory.
|
||||||
- `GET /api/locations/home` Get the authenticated user's home location.
|
- `GET /api/locations/home` Get the authenticated user's home location.
|
||||||
- `POST /api/locations/home` Assign or return the authenticated user's home location.
|
- `POST /api/locations/home` Assign or return the authenticated user's home location.
|
||||||
- `GET /api/locations/visible` Get locations visible to the authenticated user.
|
- `GET /api/locations/visible` Get locations visible to the authenticated user.
|
||||||
@@ -27,7 +28,8 @@ locations are unowned `Open` records until claimed by a player.
|
|||||||
|
|
||||||
Player resource state is stored server-side in the `PlayerResources` collection.
|
Player resource state is stored server-side in the `PlayerResources` collection.
|
||||||
New players start with 100 gold. A player's headquarters generates 10 gold per
|
New players start with 100 gold. A player's headquarters generates 10 gold per
|
||||||
minute. Generation is applied lazily when the resource record is read.
|
minute. Log Camps generate wood. Generation is applied lazily when the resource
|
||||||
|
record is read.
|
||||||
|
|
||||||
## Units
|
## Units
|
||||||
|
|
||||||
@@ -35,3 +37,9 @@ Unit training is stored server-side in the `UnitTraining` collection. The first
|
|||||||
unit type is `Scout`, which costs 50 gold and takes 60 seconds to train.
|
unit type is `Scout`, which costs 50 gold and takes 60 seconds to train.
|
||||||
Finished training is completed lazily when unit state is read. Claiming an
|
Finished training is completed lazily when unit state is read. Claiming an
|
||||||
unclaimed visible location consumes one completed Scout.
|
unclaimed visible location consumes one completed Scout.
|
||||||
|
|
||||||
|
## Buildings
|
||||||
|
|
||||||
|
Buildings are stored on location records. The first building type is `LogCamp`,
|
||||||
|
which can be built on controlled Forest territory and adds 5 wood per minute to
|
||||||
|
the owning player's resource generation.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class LocationService
|
|||||||
private readonly int _homeMinDistance;
|
private readonly int _homeMinDistance;
|
||||||
private readonly int _homeSearchRadius;
|
private readonly int _homeSearchRadius;
|
||||||
private readonly int _visibilityDepth;
|
private readonly int _visibilityDepth;
|
||||||
|
private readonly int _logCampWoodPerMinute;
|
||||||
|
|
||||||
public LocationService(IConfiguration cfg, ResourceService resources)
|
public LocationService(IConfiguration cfg, ResourceService resources)
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,9 @@ public class LocationService
|
|||||||
_homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8;
|
_homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8;
|
||||||
_homeSearchRadius = int.TryParse(cfg["World:HomeSearchRadius"], out var searchRadius) ? Math.Max(_homeMinDistance, searchRadius) : 80;
|
_homeSearchRadius = int.TryParse(cfg["World:HomeSearchRadius"], out var searchRadius) ? Math.Max(_homeMinDistance, searchRadius) : 80;
|
||||||
_visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3;
|
_visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3;
|
||||||
|
_logCampWoodPerMinute = int.TryParse(cfg["Buildings:LogCampWoodPerMinute"], out var logCampWoodPerMinute)
|
||||||
|
? Math.Max(1, logCampWoodPerMinute)
|
||||||
|
: 5;
|
||||||
|
|
||||||
var coordinateIndex = Builders<Location>.IndexKeys
|
var coordinateIndex = Builders<Location>.IndexKeys
|
||||||
.Ascending(x => x.Q)
|
.Ascending(x => x.Q)
|
||||||
@@ -111,6 +115,53 @@ public class LocationService
|
|||||||
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
|
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Location?> BuildAsync(BuildLocationRequest request, string username)
|
||||||
|
{
|
||||||
|
if (!string.Equals(request.BuildingType, "LogCamp", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Unsupported building type.", nameof(request.BuildingType));
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = await GetAsync(request.Q, request.R);
|
||||||
|
if (location is null ||
|
||||||
|
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(location.BuildingType))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("This location already has a building.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(location.Terrain, "Forest", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Log Camps can only be built on Forest territory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var update = Builders<Location>.Update
|
||||||
|
.Set(x => x.BuildingType, "LogCamp")
|
||||||
|
.Set(x => x.BuildingBuiltAtUtc, now)
|
||||||
|
.Set(x => x.UpdatedAtUtc, now);
|
||||||
|
|
||||||
|
var updated = await _locations.FindOneAndUpdateAsync(
|
||||||
|
x => x.Id == location.Id && x.OwnerUsername == username && x.BuildingType == null,
|
||||||
|
update,
|
||||||
|
new FindOneAndUpdateOptions<Location>
|
||||||
|
{
|
||||||
|
ReturnDocument = ReturnDocument.After
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("This location already has a building.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _resources.AddWoodGenerationAsync(username, _logCampWoodPerMinute);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Location?> GetHomeAsync(string username) =>
|
public async Task<Location?> GetHomeAsync(string username) =>
|
||||||
await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
|
await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ public class ResourceService
|
|||||||
var update = Builders<PlayerResources>.Update
|
var update = Builders<PlayerResources>.Update
|
||||||
.SetOnInsert(x => x.Username, username)
|
.SetOnInsert(x => x.Username, username)
|
||||||
.SetOnInsert(x => x.Gold, _startingGold)
|
.SetOnInsert(x => x.Gold, _startingGold)
|
||||||
|
.SetOnInsert(x => x.Wood, 0)
|
||||||
.SetOnInsert(x => x.CreatedAtUtc, now)
|
.SetOnInsert(x => x.CreatedAtUtc, now)
|
||||||
.SetOnInsert(x => x.LastGoldGeneratedAtUtc, now)
|
.SetOnInsert(x => x.LastGoldGeneratedAtUtc, now)
|
||||||
|
.SetOnInsert(x => x.LastWoodGeneratedAtUtc, now)
|
||||||
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
|
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
|
||||||
.Set(x => x.UpdatedAtUtc, now);
|
.Set(x => x.UpdatedAtUtc, now);
|
||||||
|
|
||||||
@@ -78,29 +80,61 @@ public class ResourceService
|
|||||||
return updated is not null;
|
return updated is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task AddWoodGenerationAsync(string username, int woodPerMinute)
|
||||||
|
{
|
||||||
|
if (woodPerMinute <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resources = await EnsureAsync(username);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var update = Builders<PlayerResources>.Update
|
||||||
|
.Inc(x => x.WoodPerMinute, woodPerMinute)
|
||||||
|
.Set(x => x.UpdatedAtUtc, now);
|
||||||
|
|
||||||
|
if (resources.WoodPerMinute <= 0 || resources.LastWoodGeneratedAtUtc.Year < 2000)
|
||||||
|
{
|
||||||
|
update = update.Set(x => x.LastWoodGeneratedAtUtc, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _resources.UpdateOneAsync(x => x.Username == username, update);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<PlayerResources> ApplyGoldGenerationAsync(PlayerResources resources)
|
private async Task<PlayerResources> ApplyGoldGenerationAsync(PlayerResources resources)
|
||||||
{
|
{
|
||||||
if (resources.GoldPerMinute <= 0)
|
|
||||||
{
|
|
||||||
return resources;
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var elapsed = now - resources.LastGoldGeneratedAtUtc;
|
var goldElapsedMinutes = resources.GoldPerMinute <= 0
|
||||||
var elapsedMinutes = (int)Math.Floor(elapsed.TotalMinutes);
|
? 0
|
||||||
if (elapsedMinutes <= 0)
|
: (int)Math.Floor((now - resources.LastGoldGeneratedAtUtc).TotalMinutes);
|
||||||
|
var woodElapsedMinutes = resources.WoodPerMinute <= 0 || resources.LastWoodGeneratedAtUtc.Year < 2000
|
||||||
|
? 0
|
||||||
|
: (int)Math.Floor((now - resources.LastWoodGeneratedAtUtc).TotalMinutes);
|
||||||
|
|
||||||
|
if (goldElapsedMinutes <= 0 && woodElapsedMinutes <= 0)
|
||||||
{
|
{
|
||||||
return resources;
|
return resources;
|
||||||
}
|
}
|
||||||
|
|
||||||
var generatedGold = elapsedMinutes * resources.GoldPerMinute;
|
|
||||||
var generationTimestamp = resources.LastGoldGeneratedAtUtc.AddMinutes(elapsedMinutes);
|
|
||||||
var update = Builders<PlayerResources>.Update
|
var update = Builders<PlayerResources>.Update
|
||||||
.Inc(x => x.Gold, generatedGold)
|
|
||||||
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
|
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
|
||||||
.Set(x => x.LastGoldGeneratedAtUtc, generationTimestamp)
|
|
||||||
.Set(x => x.UpdatedAtUtc, now);
|
.Set(x => x.UpdatedAtUtc, now);
|
||||||
|
|
||||||
|
if (goldElapsedMinutes > 0)
|
||||||
|
{
|
||||||
|
update = update
|
||||||
|
.Inc(x => x.Gold, goldElapsedMinutes * resources.GoldPerMinute)
|
||||||
|
.Set(x => x.LastGoldGeneratedAtUtc, resources.LastGoldGeneratedAtUtc.AddMinutes(goldElapsedMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (woodElapsedMinutes > 0)
|
||||||
|
{
|
||||||
|
update = update
|
||||||
|
.Inc(x => x.Wood, woodElapsedMinutes * resources.WoodPerMinute)
|
||||||
|
.Set(x => x.LastWoodGeneratedAtUtc, resources.LastWoodGeneratedAtUtc.AddMinutes(woodElapsedMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
return await _resources.FindOneAndUpdateAsync(
|
return await _resources.FindOneAndUpdateAsync(
|
||||||
x => x.Username == resources.Username,
|
x => x.Username == resources.Username,
|
||||||
update,
|
update,
|
||||||
|
|||||||
@@ -27,5 +27,8 @@
|
|||||||
"ScoutGoldCost": 50,
|
"ScoutGoldCost": 50,
|
||||||
"ScoutTrainingSeconds": 60
|
"ScoutTrainingSeconds": 60
|
||||||
},
|
},
|
||||||
|
"Buildings": {
|
||||||
|
"LogCampWoodPerMinute": 5
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ data:
|
|||||||
Resources__HeadquartersGoldPerMinute: "10"
|
Resources__HeadquartersGoldPerMinute: "10"
|
||||||
Units__ScoutGoldCost: "50"
|
Units__ScoutGoldCost: "50"
|
||||||
Units__ScoutTrainingSeconds: "60"
|
Units__ScoutTrainingSeconds: "60"
|
||||||
|
Buildings__LogCampWoodPerMinute: "5"
|
||||||
|
|||||||
Reference in New Issue
Block a user