diff --git a/AimedFleetAbilityController.gd b/AimedFleetAbilityController.gd index f8cb2e5..2a004ee 100644 --- a/AimedFleetAbilityController.gd +++ b/AimedFleetAbilityController.gd @@ -81,11 +81,43 @@ func handle_touch(event: InputEventScreenTouch) -> bool: return false if aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE: _fire() + elif _aim_at_nearest_enemy(): + # Tapping the button used to do nothing at all - the press was claimed, + # the aim began, and releasing without dragging simply threw it away. + # A tap now fires at the nearest enemy in range; dragging remains the + # way to place a shot precisely. + _fire() else: + _say("Nothing in range - drag from the button to aim") _cancel_aim() return true +# Points aim_target at the closest enemy within reach, or reports that there +# is none. Both aimed abilities read only the aim_target when they fire - the +# laser takes its DIRECTION and always extends to full range, the gas bomb +# takes the point itself - so setting it here is all auto-aim has to do, and +# each ability keeps its own meaning for it. +func _aim_at_nearest_enemy() -> bool: + var origin: Vector2 = _aim_origin() + var reach: float = _aim_max_range() + var nearest: Node2D = null + var nearest_distance: float = INF + for enemy in get_tree().get_nodes_in_group("enemies"): + if not is_instance_valid(enemy) or not (enemy is Node2D): + continue + var distance: float = origin.distance_to((enemy as Node2D).global_position) + if distance > reach or distance >= nearest_distance: + continue + nearest_distance = distance + nearest = enemy + if nearest == null: + return false + aim_target = nearest.global_position + queue_redraw() + return true + + func handle_drag(event: InputEventScreenDrag) -> bool: if not aiming or event.index != aim_touch: return false @@ -184,6 +216,8 @@ func _component_id() -> String: return "" func _ammo_resource() -> String: return "" func _ammo_cost() -> int: return 0 func _aim_padding() -> float: return 0.0 +# How far this ability can reach, for auto-aim and for the aim guide. +func _aim_max_range() -> float: return 0.0 func _has_effect_visuals() -> bool: return false func _advance_effects(_delta: float) -> void: pass func _consume_shot(_component: Node) -> bool: return false diff --git a/CarrierVesselDefinition.tres b/CarrierVesselDefinition.tres index 91d2743..1d193dc 100644 --- a/CarrierVesselDefinition.tres +++ b/CarrierVesselDefinition.tres @@ -15,7 +15,7 @@ hull_points = PackedVector2Array(-32, -10, -18, -18, 18, -18, 32, -10, 32, 14, - fleet_eligible = true capabilities = PackedStringArray("cargo") component_scenes = Array[PackedScene]([ExtResource("2")]) -base_stats = {"max_speed": 380.0, "acceleration": 360.0, "delivery_speed": 180.0, "cargo_capacity": 120.0} +base_stats = {"max_speed": 380.0, "acceleration": 1080.0, "delivery_speed": 180.0, "cargo_capacity": 120.0} upgrades = Array[Resource]([ExtResource("3")]) unlock_id = "unlock_carrier" unlock_name = "Unlock Carrier" diff --git a/DistorderVesselDefinition.tres b/DistorderVesselDefinition.tres index c9bc3e7..9d8777f 100644 --- a/DistorderVesselDefinition.tres +++ b/DistorderVesselDefinition.tres @@ -15,7 +15,7 @@ hull_points = PackedVector2Array(0, -24, -14, -2, -6, 18, 10, 10, 12, -10) fleet_eligible = true capabilities = PackedStringArray("distorder") component_scenes = Array[PackedScene]([ExtResource("2")]) -base_stats = {"max_speed": 430.0, "acceleration": 410.0, "delivery_speed": 180.0} +base_stats = {"max_speed": 430.0, "acceleration": 1230.0, "delivery_speed": 180.0} unlock_id = "unlock_distorder" unlock_name = "Unlock Distorder" unlock_description = "Research a space-time distortion hull - while one flies with the fleet, a fader lets you push cruise speed beyond its default." diff --git a/GameState.gd b/GameState.gd index fcd1ce7..235808c 100644 --- a/GameState.gd +++ b/GameState.gd @@ -1223,7 +1223,12 @@ func rotate_formation(radians: float) -> void: # hauler: ore becomes spendable only when a hub-stationed transporter picks # up a full load and physically delivers it to the mothership. Entries are # {"position", "planet_id", "planet_name", "resource_type", "inventory", "mining_progress"}. -const EXTRACTOR_RATE: float = 2.0 # ore per second +# Ore per second. Halved alongside the planet-density change in WorldGen: with +# roughly 2.5x as many planets to put extractors on, holding the per-extractor +# rate would have multiplied passive income rather than shifting where it comes +# from. The intent is more sites each worth less, so expanding is what grows +# the economy rather than one lucky early extractor carrying it. +const EXTRACTOR_RATE: float = 1.0 var extractors: Array = [] diff --git a/GasBombController.gd b/GasBombController.gd index 43e784a..f25c3cb 100644 --- a/GasBombController.gd +++ b/GasBombController.gd @@ -32,6 +32,7 @@ func _component_id() -> String: return "gas_bomber" func _ammo_resource() -> String: return GameState.RESOURCE_GAS func _ammo_cost() -> int: return GAS_PER_SHOT func _aim_padding() -> float: return AOE_RADIUS +func _aim_max_range() -> float: return MAX_RANGE func _has_effect_visuals() -> bool: return not projectiles.is_empty() or not explosions.is_empty() diff --git a/GasBomberVesselDefinition.tres b/GasBomberVesselDefinition.tres index aad85ae..04ad771 100644 --- a/GasBomberVesselDefinition.tres +++ b/GasBomberVesselDefinition.tres @@ -17,7 +17,7 @@ hull_points = PackedVector2Array(0, -24, -24, 14, -10, 10, 0, 22, 10, 10, 24, 14 fleet_eligible = true capabilities = PackedStringArray("gas_bomber") component_scenes = Array[PackedScene]([ExtResource("2")]) -base_stats = {"max_speed": 420.0, "acceleration": 400.0, "delivery_speed": 180.0} +base_stats = {"max_speed": 420.0, "acceleration": 1200.0, "delivery_speed": 180.0} unlock_id = "unlock_gas_bomber" unlock_name = "Unlock Gas Bomber" unlock_description = "Research volatile-gas containment and unlock the Gas Bomber for ship crafters." diff --git a/InterceptorVesselDefinition.tres b/InterceptorVesselDefinition.tres index 1db52b7..cac2700 100644 --- a/InterceptorVesselDefinition.tres +++ b/InterceptorVesselDefinition.tres @@ -17,5 +17,5 @@ glow_color = Color(0.25, 0.85, 1, 1) fleet_eligible = true capabilities = PackedStringArray("weapon", "cargo") component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")]) -base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "delivery_speed": 180.0, "cargo_capacity": 20.0} +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 1350.0, "delivery_speed": 180.0, "cargo_capacity": 20.0} upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")]) diff --git a/LaserController.gd b/LaserController.gd index b9c9cfc..fd66e30 100644 --- a/LaserController.gd +++ b/LaserController.gd @@ -3,7 +3,13 @@ extends "res://AimedFleetAbilityController.gd" const BEAM_COLOR := Color(0.3, 0.9, 1.0) const TARGET_COLOR := Color(0.6, 0.95, 1.0) -const BEAM_HALF_WIDTH := 22.0 +const BEAM_HALF_WIDTH := 38.0 +# The DRAWN beam is scaled from the hit width rather than tuned separately. +# The two were independent numbers and had drifted apart: the beam struck +# anything within 22 units of the line while its outer glow spanned about 11, +# so it killed things it visibly missed. NeonDraw's widest layer is 18px at +# scale 1, so this makes the glow span exactly what the beam hits. +const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0 const FLASH_DURATION := 0.22 const ENERGY_PER_SHOT: int = 8 @@ -21,11 +27,17 @@ func _has_effect_visuals() -> bool: return not beams.is_empty() func _aim_max_range() -> float: + # Falls back to the VESSEL DEFINITION's own beam_range, not to a copy of + # the number. This used to hardcode 950.0 in both branches, which silently + # became a lie the moment the definition's range changed - the aim line + # would have been drawn to a different distance than the beam actually + # reaches, and only in the cases where the fallback fires. var ship: Node2D = _next_ready_ship() - if ship == null: - return 950.0 - var component: Node = ship.get_component(_component_id()) - return component.beam_range if component != null else 950.0 + if ship != null: + var component: Node = ship.get_component(_component_id()) + if component != null: + return component.beam_range + return GameState.vessel_stat(GameState.VESSEL_LASER, "beam_range") func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: @@ -82,4 +94,4 @@ func _draw() -> void: var progress: float = 1.0 - float(beam.time) / FLASH_DURATION var alpha: float = 1.0 - progress NeonDraw.draw_glowing_line(self, beam.from, beam.to, - BEAM_COLOR, alpha, 1.2 - progress * 0.6) + BEAM_COLOR, alpha, BEAM_WIDTH_SCALE * (1.0 - progress * 0.45)) diff --git a/LaserVesselDefinition.tres b/LaserVesselDefinition.tres index ea4b7bc..20dbf62 100644 --- a/LaserVesselDefinition.tres +++ b/LaserVesselDefinition.tres @@ -18,7 +18,7 @@ hull_points = PackedVector2Array(0, -26, -9, 14, -4, 10, 4, 10, 9, 14) fleet_eligible = true capabilities = PackedStringArray("laser") component_scenes = Array[PackedScene]([ExtResource("2")]) -base_stats = {"max_speed": 440.0, "acceleration": 420.0, "delivery_speed": 180.0, "damage": 2.0, "beam_range": 950.0, "beam_cooldown": 4.0} +base_stats = {"max_speed": 440.0, "acceleration": 1260.0, "delivery_speed": 180.0, "damage": 2.0, "beam_range": 1450.0, "beam_cooldown": 4.0} minimum_stats = {"beam_cooldown": 1.5} upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")]) unlock_id = "unlock_laser" diff --git a/Main.tscn b/Main.tscn index 9fa7529..fa6be78 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1199,9 +1199,9 @@ horizontal_alignment = 1 mouse_filter = 2 anchor_right = 1.0 offset_left = 24 -offset_top = 180 +offset_top = 172 offset_right = -24 -offset_bottom = 264 +offset_bottom = 308 script = ExtResource("24") [node name="VesselA" type="Control" parent="UI/LabMenu"] @@ -1606,9 +1606,9 @@ horizontal_alignment = 1 [node name="Bar" type="Control" parent="UI/CraftMenu"] mouse_filter = 2 offset_left = 24 -offset_top = 180 +offset_top = 172 offset_right = 520 -offset_bottom = 264 +offset_bottom = 308 script = ExtResource("24") [node name="StatusLabel" type="Label" parent="UI/CraftMenu"] @@ -2051,9 +2051,9 @@ color = Color(0.03, 0.03, 0.08, 0.94) mouse_filter = 2 anchor_right = 1.0 offset_left = 24.0 -offset_top = 36.0 -offset_right = -200.0 -offset_bottom = 166.0 +offset_top = 20.0 +offset_right = -160.0 +offset_bottom = 82.0 text = "Research" horizontal_alignment = 1 @@ -2061,9 +2061,9 @@ horizontal_alignment = 1 mouse_filter = 2 anchor_right = 1.0 offset_left = 24.0 -offset_top = 180.0 +offset_top = 148.0 offset_right = -24.0 -offset_bottom = 264.0 +offset_bottom = 284.0 script = ExtResource("24") [node name="Tree" type="Control" parent="UI/SkillMenu"] @@ -2072,9 +2072,9 @@ clip_contents = true anchor_right = 1.0 anchor_bottom = 1.0 offset_left = 24.0 -offset_top = 274.0 +offset_top = 296.0 offset_right = -24.0 -offset_bottom = -620.0 +offset_bottom = -434.0 script = ExtResource("21") [node name="DetailLabel" type="Label" parent="UI/SkillMenu"] @@ -2083,9 +2083,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32.0 -offset_top = -610.0 +offset_top = -426.0 offset_right = -32.0 -offset_bottom = -462.0 +offset_bottom = -306.0 theme_override_font_sizes/font_size = 34 autowrap_mode = 2 horizontal_alignment = 1 @@ -2098,9 +2098,9 @@ anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -64.0 -offset_top = -400.0 +offset_top = -300.0 offset_right = 76.0 -offset_bottom = -336.0 +offset_bottom = -236.0 script = ExtResource("19") digit_height = 52.0 @@ -2110,9 +2110,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32.0 -offset_top = -326.0 +offset_top = -230.0 offset_right = -32.0 -offset_bottom = -250.0 +offset_bottom = -178.0 theme_override_font_sizes/font_size = 32 autowrap_mode = 2 horizontal_alignment = 1 @@ -2124,9 +2124,9 @@ anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -74.0 -offset_top = -236.0 +offset_top = -170.0 offset_right = 74.0 -offset_bottom = -88.0 +offset_bottom = -22.0 script = ExtResource("10") icon = 5 glow_color = Color(0.45, 0.9, 0.55, 1) @@ -2134,10 +2134,10 @@ glow_color = Color(0.45, 0.9, 0.55, 1) [node name="CloseButton" type="Control" parent="UI/SkillMenu"] anchor_left = 1.0 anchor_right = 1.0 -offset_left = -172.0 -offset_top = 24.0 +offset_left = -140.0 +offset_top = 20.0 offset_right = -24.0 -offset_bottom = 172.0 +offset_bottom = 136.0 script = ExtResource("10") icon = 1 glow_color = Color(0.55, 0.6, 1, 1) diff --git a/PlanetSetupEditor.gd b/PlanetSetupEditor.gd index b4fc731..72d0773 100644 --- a/PlanetSetupEditor.gd +++ b/PlanetSetupEditor.gd @@ -64,6 +64,10 @@ const PALETTE_STEP: float = 92.0 @onready var delete_map_button: IconButton = $UI/LoadPanel/DeleteMapButton var current_setup: Resource +# The file the open setup was loaded from, or "" for one that has never been +# saved. Saving writes HERE rather than to a path derived from setup_id - see +# PlanetSetups.path_for_id for why deriving it forks a duplicate. +var current_setup_path: String = "" var selected_palette: String = "capture_point" var selected_item: Dictionary = {} var _profile_ids: Array = [] @@ -120,6 +124,7 @@ func _ready() -> void: func new_setup(setup_id: String, display_name: String, profile_id: String) -> void: current_setup = PLANET_SETUP_SCRIPT.new() + current_setup_path = "" current_setup.setup_id = setup_id current_setup.display_name = display_name current_setup.surface_profile_id = profile_id @@ -145,6 +150,10 @@ func load_setup(setup_id: String) -> bool: # registry entry (which other code may be reading) until an # explicit save. current_setup = setup.duplicate(true) + # duplicate() deliberately has no resource_path of its own, so the + # source file is remembered separately - saving must go back to + # the file this was opened from. + current_setup_path = String(setup.resource_path) selected_item = {} _refresh_selected_label() _profile_cursor = maxi(_profile_ids.find(current_setup.surface_profile_id), 0) @@ -164,10 +173,12 @@ func save_setup() -> Dictionary: return {"ok": false, "message": "Name the setup before saving."} if not OS.has_feature("editor"): return {"ok": false, "message": "Setups can only be saved when running from the project."} - var path: String = "res://PlanetSetups/%s.tres" % current_setup.setup_id + var path: String = current_setup_path if current_setup_path != "" \ + else "res://PlanetSetups/%s.tres" % current_setup.setup_id var err: int = ResourceSaver.save(current_setup, path) if err != OK: return {"ok": false, "message": "Save failed (error %d)." % err} + current_setup_path = path PlanetSetups.rescan() return {"ok": true, "message": "Saved '%s'." % current_setup.setup_id} @@ -180,7 +191,11 @@ func delete_setup(setup_id: String) -> Dictionary: return {"ok": false, "message": "No setup to delete."} if not OS.has_feature("editor"): return {"ok": false, "message": "Setups can only be deleted when running from the project."} - var path: String = "res://PlanetSetups/%s.tres" % setup_id + # Asked of the registry, not assembled from the id - see + # PlanetSetups.path_for_id. A setup whose filename differs from its id + # could not be deleted at all before this, which is why the first entry in + # the list appeared to be undeletable: it is the one with the mismatch. + var path: String = PlanetSetups.path_for_id(setup_id) if not ResourceLoader.exists(path): return {"ok": false, "message": "No setup file for '%s'." % setup_id} var err: int = DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) @@ -196,6 +211,10 @@ func delete_setup(setup_id: String) -> Dictionary: # the next save silently recreate the file the player just removed. if current_setup != null and String(current_setup.setup_id) == setup_id: new_setup("untitled", "Untitled", _profile_ids[_profile_cursor]) + elif current_setup_path == path: + # Same file under a different id - the open copy has nowhere to go + # back to either. + current_setup_path = "" return {"ok": true, "message": "Deleted '%s'." % setup_id} diff --git a/PlanetSetupEditor.tscn b/PlanetSetupEditor.tscn index de55cd1..8398d45 100644 --- a/PlanetSetupEditor.tscn +++ b/PlanetSetupEditor.tscn @@ -215,6 +215,7 @@ offset_right = 696 offset_bottom = 1080 theme_override_font_sizes/font_size = 34 theme_override_constants/v_separation = 30 +allow_reselect = true [node name="DeleteMapButton" type="Control" parent="UI/LoadPanel"] offset_left = 24 diff --git a/PlanetSetups.gd b/PlanetSetups.gd index a0bab41..03559c2 100644 --- a/PlanetSetups.gd +++ b/PlanetSetups.gd @@ -31,7 +31,19 @@ static func _ensure_scanned() -> void: var file_name: String = dir.get_next() while file_name != "": if not dir.current_is_dir() and file_name.ends_with(".tres"): - var res: Resource = load(SETUPS_DIR + "/" + file_name) + # CACHE_MODE_REPLACE, not a plain load(). Godot caches resources by + # path, so after the map editor saved a setup this handed back the + # CACHED copy - still holding the values from before the save. The + # editor called rescan() and the pool dutifully re-served the stale + # data, which is what "I changed the research points and it was not + # saved" actually was: the file on disk was correct all along. + # + # REPLACE rather than IGNORE deliberately: it refreshes the + # existing instance in place instead of minting a new one, so + # object identity across a rescan survives - which the per-planet + # map assignments depend on (see setup_for_planet). + var res: Resource = ResourceLoader.load(SETUPS_DIR + "/" + file_name, + "", ResourceLoader.CACHE_MODE_REPLACE) if res != null and res.get_script() == PLANET_SETUP_SCRIPT: _all.append(res) file_name = dir.get_next() @@ -62,6 +74,30 @@ static func all() -> Array: return _all +static func find_by_id(setup_id: String) -> Resource: + for setup in all(): + if String(setup.setup_id) == setup_id: + return setup + return null + + +static func path_for_id(setup_id: String) -> String: + # The file a setup actually LIVES in, which is not reliably + # "PlanetSetups/.tres". Authored resources carry whatever + # filename they were created with, and at least one shipped setup has an + # id that differs from its filename outright (ResearchLabSetup.tres holds + # setup_id "research_lab_1"). Deriving the path from the id instead of + # asking the resource broke two things at once: deleting such a setup + # looked for a file that does not exist and silently refused, and saving + # one wrote a SECOND file under the derived name, leaving the original + # behind with the old contents - which reads exactly like "my change was + # not saved", because the stale twin is still in the pool. + var setup: Resource = find_by_id(setup_id) + if setup != null and String(setup.resource_path) != "": + return String(setup.resource_path) + return "%s/%s.tres" % [SETUPS_DIR, setup_id] + + static func setups_for_profile(profile_id: String) -> Array: var result: Array = [] for setup in all(): diff --git a/PlanetSetups/ResearchLabSetup.tres b/PlanetSetups/ResearchLabSetup.tres index 20b4075..7183fd7 100644 --- a/PlanetSetups/ResearchLabSetup.tres +++ b/PlanetSetups/ResearchLabSetup.tres @@ -40,3 +40,4 @@ display_name = "Research Lab Garrison" surface_profile_id = "research_lab" capture_points = PackedVector2Array(-450.0, -60.0, 460.0, -40.0, 0.0, 480.0) enemy_spawns = Array[Resource]([SubResource("Resource_ta1"), SubResource("Resource_ta2"), SubResource("Resource_ta3"), SubResource("Resource_ta4"), SubResource("Resource_ta5"), SubResource("Resource_ta6")]) +research_point_reward = 1 diff --git a/PlanetSetups/gas_default.tres b/PlanetSetups/gas_default.tres index bafb1de..e68901b 100644 --- a/PlanetSetups/gas_default.tres +++ b/PlanetSetups/gas_default.tres @@ -1,27 +1,51 @@ -[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3] +[gd_resource type="Resource" script_class="PlanetSetup" format=3] [ext_resource type="Script" path="res://PlanetSetup.gd" id="1"] [ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"] -[sub_resource type="Resource" id="Resource_g1"] +[sub_resource type="Resource" id="Resource_che8f"] script = ExtResource("2") -enemy_id = "chaser" position = Vector2(500, -300) -[sub_resource type="Resource" id="Resource_g2"] +[sub_resource type="Resource" id="Resource_js1ws"] script = ExtResource("2") -enemy_id = "chaser" position = Vector2(-450, 400) -[sub_resource type="Resource" id="Resource_g3"] +[sub_resource type="Resource" id="Resource_y4at1"] script = ExtResource("2") -enemy_id = "chaser" position = Vector2(200, 700) +[sub_resource type="Resource" id="Resource_rosps"] +script = ExtResource("2") +position = Vector2(-1006.606, -527.4938) + +[sub_resource type="Resource" id="Resource_68rmi"] +script = ExtResource("2") +enemy_id = "sniper" +position = Vector2(-752.03937, 17.556) + +[sub_resource type="Resource" id="Resource_yx160"] +script = ExtResource("2") +enemy_id = "sniper" +position = Vector2(630.17596, 266.05554) + +[sub_resource type="Resource" id="Resource_clcbd"] +script = ExtResource("2") +position = Vector2(818.4444, -421.24655) + +[sub_resource type="Resource" id="Resource_yk26s"] +script = ExtResource("2") +position = Vector2(-386.3381, -669.1641) + +[sub_resource type="Resource" id="Resource_7m420"] +script = ExtResource("2") +position = Vector2(-797.41345, -292.05005) + [resource] script = ExtResource("1") setup_id = "gas_default" display_name = "Gas Default" surface_profile_id = "gas" -capture_points = PackedVector2Array(600, -200, -550, -250, 0, 650, -700, 450) -enemy_spawns = Array[Resource]([SubResource("Resource_g1"), SubResource("Resource_g2"), SubResource("Resource_g3")]) +capture_points = PackedVector2Array(600, -200, -550, -250) +enemy_spawns = Array[Resource]([SubResource("Resource_che8f"), SubResource("Resource_js1ws"), SubResource("Resource_y4at1"), SubResource("Resource_rosps"), SubResource("Resource_68rmi"), SubResource("Resource_yx160"), SubResource("Resource_clcbd"), SubResource("Resource_yk26s"), SubResource("Resource_7m420")]) +research_point_reward = 1 diff --git a/PlanetSetups/map1.tres b/PlanetSetups/map1.tres deleted file mode 100644 index 8e12259..0000000 --- a/PlanetSetups/map1.tres +++ /dev/null @@ -1,89 +0,0 @@ -[gd_resource type="Resource" script_class="PlanetSetup" format=3] - -[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1_krxpw"] -[ext_resource type="Script" path="res://PlanetSetup.gd" id="2_nljfb"] - -[sub_resource type="Resource" id="Resource_atujs"] -script = ExtResource("1_krxpw") -position = Vector2(-2005.3026, -137.49512) - -[sub_resource type="Resource" id="Resource_seaud"] -script = ExtResource("1_krxpw") -position = Vector2(-1749.1129, -147.55289) - -[sub_resource type="Resource" id="Resource_e5tts"] -script = ExtResource("1_krxpw") -position = Vector2(-1489.0216, 26.782429) - -[sub_resource type="Resource" id="Resource_y4e3o"] -script = ExtResource("1_krxpw") -position = Vector2(-1408.3934, 240.23131) - -[sub_resource type="Resource" id="Resource_fve7e"] -script = ExtResource("1_krxpw") -position = Vector2(-1576.1522, 546.43555) - -[sub_resource type="Resource" id="Resource_dunmq"] -script = ExtResource("1_krxpw") -position = Vector2(-2046.9172, 481.61862) - -[sub_resource type="Resource" id="Resource_hgibr"] -script = ExtResource("1_krxpw") -position = Vector2(-563.0971, -1320.9633) - -[sub_resource type="Resource" id="Resource_xl5ap"] -script = ExtResource("1_krxpw") -position = Vector2(-399.2397, -1011.40643) - -[sub_resource type="Resource" id="Resource_f2300"] -script = ExtResource("1_krxpw") -position = Vector2(-110.53853, -848.2465) - -[sub_resource type="Resource" id="Resource_r0ph7"] -script = ExtResource("1_krxpw") -position = Vector2(265.29315, -930.944) - -[sub_resource type="Resource" id="Resource_8kb31"] -script = ExtResource("1_krxpw") -position = Vector2(637.2235, -1261.734) - -[sub_resource type="Resource" id="Resource_pf7co"] -script = ExtResource("1_krxpw") -position = Vector2(1845.347, -205.66466) - -[sub_resource type="Resource" id="Resource_sviw7"] -script = ExtResource("1_krxpw") -position = Vector2(1338.1693, -72.67802) - -[sub_resource type="Resource" id="Resource_elsje"] -script = ExtResource("1_krxpw") -position = Vector2(1270.5457, 227.93843) - -[sub_resource type="Resource" id="Resource_5gck6"] -script = ExtResource("1_krxpw") -position = Vector2(1386.2861, 623.54553) - -[sub_resource type="Resource" id="Resource_pkihc"] -script = ExtResource("1_krxpw") -position = Vector2(1665.8842, 706.24286) - -[sub_resource type="Resource" id="Resource_th38b"] -script = ExtResource("1_krxpw") -position = Vector2(2074.2273, 560.9635) - -[sub_resource type="Resource" id="Resource_bhel2"] -script = ExtResource("1_krxpw") -enemy_id = "sniper" -position = Vector2(-323.81326, 553.14087) - -[sub_resource type="Resource" id="Resource_bupqc"] -script = ExtResource("1_krxpw") -enemy_id = "sniper" -position = Vector2(396.6392, 734.1813) - -[resource] -script = ExtResource("2_nljfb") -setup_id = "map1" -display_name = "map1" -capture_points = PackedVector2Array(-1825.8397, 147.47598, -29.910276, -1294.1425, 1706.1982, 184.35464) -enemy_spawns = Array[Resource]([SubResource("Resource_atujs"), SubResource("Resource_seaud"), SubResource("Resource_e5tts"), SubResource("Resource_y4e3o"), SubResource("Resource_fve7e"), SubResource("Resource_dunmq"), SubResource("Resource_hgibr"), SubResource("Resource_xl5ap"), SubResource("Resource_f2300"), SubResource("Resource_r0ph7"), SubResource("Resource_8kb31"), SubResource("Resource_pf7co"), SubResource("Resource_sviw7"), SubResource("Resource_elsje"), SubResource("Resource_5gck6"), SubResource("Resource_pkihc"), SubResource("Resource_th38b"), SubResource("Resource_bhel2"), SubResource("Resource_bupqc")]) diff --git a/PlanetSetups/research_lab_1.tres b/PlanetSetups/research_lab_1.tres deleted file mode 100644 index 83b5430..0000000 --- a/PlanetSetups/research_lab_1.tres +++ /dev/null @@ -1,56 +0,0 @@ -[gd_resource type="Resource" script_class="PlanetSetup" format=3] - -[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1_1umkq"] -[ext_resource type="Script" path="res://PlanetSetup.gd" id="2_3lt83"] - -[sub_resource type="Resource" id="Resource_4sgq7"] -script = ExtResource("1_1umkq") -enemy_id = "turret_spawner" -position = Vector2(-620, -180) - -[sub_resource type="Resource" id="Resource_ppux6"] -script = ExtResource("1_1umkq") -enemy_id = "turret_spawner" -position = Vector2(640, -160) - -[sub_resource type="Resource" id="Resource_tij1g"] -script = ExtResource("1_1umkq") -enemy_id = "artillery" -position = Vector2(0, -720) - -[sub_resource type="Resource" id="Resource_cvqsq"] -script = ExtResource("1_1umkq") -enemy_id = "sniper" -position = Vector2(-380, 420) - -[sub_resource type="Resource" id="Resource_ec1ix"] -script = ExtResource("1_1umkq") -enemy_id = "sniper" -position = Vector2(400, 440) - -[sub_resource type="Resource" id="Resource_nx4ij"] -script = ExtResource("1_1umkq") -position = Vector2(120, 60) - -[sub_resource type="Resource" id="Resource_sepu5"] -script = ExtResource("1_1umkq") -enemy_id = "enemy_mothership" -position = Vector2(731.2211, 301.56653) - -[sub_resource type="Resource" id="Resource_luar8"] -script = ExtResource("1_1umkq") -enemy_id = "sniper" -position = Vector2(-593.29315, 150.69946) - -[sub_resource type="Resource" id="Resource_ykd6p"] -script = ExtResource("1_1umkq") -enemy_id = "sniper" -position = Vector2(469.82953, -304.24854) - -[resource] -script = ExtResource("2_3lt83") -setup_id = "research_lab_1" -display_name = "Research Lab Garrison" -surface_profile_id = "research_lab" -capture_points = PackedVector2Array(-450, -60, 460, -40, 0, 480) -enemy_spawns = Array[Resource]([SubResource("Resource_4sgq7"), SubResource("Resource_ppux6"), SubResource("Resource_tij1g"), SubResource("Resource_cvqsq"), SubResource("Resource_ec1ix"), SubResource("Resource_nx4ij"), SubResource("Resource_sepu5"), SubResource("Resource_luar8"), SubResource("Resource_ykd6p")]) diff --git a/PlanetSetups/researtch_lab_garrison.tres b/PlanetSetups/researtch_lab_garrison.tres new file mode 100644 index 0000000..c9b9938 --- /dev/null +++ b/PlanetSetups/researtch_lab_garrison.tres @@ -0,0 +1,46 @@ +[gd_resource type="Resource" script_class="PlanetSetup" format=3] + +[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1_p34gm"] +[ext_resource type="Script" path="res://PlanetSetup.gd" id="2_oxc3b"] + +[sub_resource type="Resource" id="Resource_p34gm"] +script = ExtResource("1_p34gm") +enemy_id = "turret_spawner" +position = Vector2(-620, -180) + +[sub_resource type="Resource" id="Resource_oxc3b"] +script = ExtResource("1_p34gm") +enemy_id = "turret_spawner" +position = Vector2(640, -160) + +[sub_resource type="Resource" id="Resource_ujs3j"] +script = ExtResource("1_p34gm") +enemy_id = "artillery" +position = Vector2(0, -720) + +[sub_resource type="Resource" id="Resource_4h5ny"] +script = ExtResource("1_p34gm") +enemy_id = "sniper" +position = Vector2(-380, 420) + +[sub_resource type="Resource" id="Resource_1djs2"] +script = ExtResource("1_p34gm") +enemy_id = "sniper" +position = Vector2(400, 440) + +[sub_resource type="Resource" id="Resource_isvqt"] +script = ExtResource("1_p34gm") +position = Vector2(120, 60) + +[sub_resource type="Resource" id="Resource_ff4yc"] +script = ExtResource("1_p34gm") +position = Vector2(-977.8174, -470.0476) + +[resource] +script = ExtResource("2_oxc3b") +setup_id = "researtch_lab_garrison" +display_name = "Researtch Lab Garrison" +surface_profile_id = "void" +capture_points = PackedVector2Array(-450, -60, 460, -40, 0, 480) +enemy_spawns = Array[Resource]([SubResource("Resource_p34gm"), SubResource("Resource_oxc3b"), SubResource("Resource_ujs3j"), SubResource("Resource_4h5ny"), SubResource("Resource_1djs2"), SubResource("Resource_isvqt"), SubResource("Resource_ff4yc")]) +research_point_reward = 1 diff --git a/PlanetSetups/rocky_default.tres b/PlanetSetups/rocky_default.tres index cd4767b..d053f5b 100644 --- a/PlanetSetups/rocky_default.tres +++ b/PlanetSetups/rocky_default.tres @@ -1,22 +1,36 @@ -[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3] +[gd_resource type="Resource" script_class="PlanetSetup" format=3] [ext_resource type="Script" path="res://PlanetSetup.gd" id="1"] [ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"] -[sub_resource type="Resource" id="Resource_r1"] +[sub_resource type="Resource" id="Resource_kkunv"] script = ExtResource("2") -enemy_id = "chaser" position = Vector2(450, 250) -[sub_resource type="Resource" id="Resource_r2"] +[sub_resource type="Resource" id="Resource_gk6ea"] script = ExtResource("2") -enemy_id = "chaser" position = Vector2(-600, -350) +[sub_resource type="Resource" id="Resource_ob70o"] +script = ExtResource("2") +position = Vector2(-884.6814, 269.35123) + +[sub_resource type="Resource" id="Resource_np188"] +script = ExtResource("2") +position = Vector2(627.78613, 398.3144) + +[sub_resource type="Resource" id="Resource_ifyky"] +script = ExtResource("2") +position = Vector2(937.718, -105.20244) + +[sub_resource type="Resource" id="Resource_e10vg"] +script = ExtResource("2") +position = Vector2(-985.817, -129.31296) + [resource] script = ExtResource("1") setup_id = "rocky_default" display_name = "Rocky Default" -surface_profile_id = "rocky" -capture_points = PackedVector2Array(700, 100, -650, 300, 50, -750) -enemy_spawns = Array[Resource]([SubResource("Resource_r1"), SubResource("Resource_r2")]) +capture_points = PackedVector2Array(700, 100, -593.01917, 1.8398132) +enemy_spawns = Array[Resource]([SubResource("Resource_kkunv"), SubResource("Resource_gk6ea"), SubResource("Resource_ob70o"), SubResource("Resource_np188"), SubResource("Resource_ifyky"), SubResource("Resource_e10vg")]) +research_point_reward = 1 diff --git a/ResearchLabPlanetDefinition.tres b/ResearchLabPlanetDefinition.tres index ac195df..af5f88c 100644 --- a/ResearchLabPlanetDefinition.tres +++ b/ResearchLabPlanetDefinition.tres @@ -12,4 +12,4 @@ visual_scene = ExtResource("2") surface_profile_id = "research_lab" resource_id = "" map_color = Color(0.7, 0.2, 0.15, 1) -conquest_research_points = 1 +conquest_research_points = 0 diff --git a/ResourceBar.gd b/ResourceBar.gd index 6594fc1..f47f1e1 100644 --- a/ResourceBar.gd +++ b/ResourceBar.gd @@ -7,7 +7,17 @@ extends Control var building_id: int = 0 -const HEADER_HEIGHT: float = 22.0 +# Header band and font sizes were all set for a 84px-tall box, which made the +# table read as a dense strip of small numbers. The hosting menus now give it +# 136px, so the rows have somewhere to breathe and the text can be legible at +# arm's length on a phone. +const HEADER_HEIGHT: float = 30.0 +# Vertical gap between rows. Previously rows were flush against each other and +# the only separation was colour. +const ROW_GAP: float = 4.0 +const HEADER_FONT_SIZE: int = 20 +const NAME_FONT_SIZE: int = 23 +const VALUE_FONT_SIZE: int = 24 const RULE_COLOR: Color = Color(0.25, 0.55, 0.7) const FLEET_COLOR: Color = Color(1.0, 0.78, 0.25) const LOCAL_COLOR: Color = Color(0.45, 0.9, 0.55) @@ -19,7 +29,7 @@ var _rows: Dictionary = {} func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE for heading in ["", "BANKED", "FLEET", "LOCAL"]: - var label := _make_label(heading, 16, Color(0.62, 0.78, 0.86)) + var label := _make_label(heading, HEADER_FONT_SIZE, Color(0.62, 0.78, 0.86)) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER add_child(label) _header_labels.append(label) @@ -28,8 +38,8 @@ func _ready() -> void: var glyph := ResourceGlyph.new() glyph.resource_type = resource_type add_child(glyph) - var name_label := _make_label(GameState.resource_name(resource_type).to_upper(), 18, - GameState.resource_color(resource_type)) + var name_label := _make_label(GameState.resource_name(resource_type).to_upper(), + NAME_FONT_SIZE, GameState.resource_color(resource_type)) add_child(name_label) var bank_label := _make_value_label(GameState.resource_color(resource_type)) var fleet_label := _make_value_label(FLEET_COLOR) @@ -59,7 +69,7 @@ func _make_label(text_value: String, font_size: int, colour: Color) -> Label: func _make_value_label(colour: Color) -> Label: - var label := _make_label("0", 19, colour) + var label := _make_label("0", VALUE_FONT_SIZE, colour) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER return label @@ -76,15 +86,20 @@ func _layout() -> void: _header_labels[i].position = Vector2(columns[i], 0.0) _header_labels[i].size = Vector2(widths[i], HEADER_HEIGHT) - var row_height: float = (size.y - HEADER_HEIGHT - 4.0) / maxf(1.0, float(_rows.size())) + var rows: float = maxf(1.0, float(_rows.size())) + # The gap is taken OUT of the available height rather than added on top of + # each row, so the table still ends exactly where its box does however many + # resources the catalog grows to. + var row_height: float = (size.y - HEADER_HEIGHT - ROW_GAP) / rows var row_index: int = 0 for resource_type in GameState.resource_types(): var row: Dictionary = _rows[resource_type] var y: float = HEADER_HEIGHT + float(row_index) * row_height - row.glyph.position = Vector2(2.0, y + 2.0) - row.glyph.size = Vector2(row_height - 4.0, row_height - 4.0) - row.name.position = Vector2(row_height, y) - row.name.size = Vector2(resource_width - row_height, row_height) + var content_height: float = row_height - ROW_GAP + row.glyph.position = Vector2(2.0, y + ROW_GAP * 0.5) + row.glyph.size = Vector2(content_height, content_height) + row.name.position = Vector2(content_height + 10.0, y) + row.name.size = Vector2(resource_width - content_height - 10.0, row_height) for i in range(3): var label: Label = row[["bank", "fleet", "local"][i]] label.position = Vector2(columns[i + 1], y) diff --git a/ShieldVesselDefinition.tres b/ShieldVesselDefinition.tres index 83b4220..0b811b0 100644 --- a/ShieldVesselDefinition.tres +++ b/ShieldVesselDefinition.tres @@ -16,7 +16,7 @@ fleet_eligible = true capabilities = PackedStringArray("shield") component_scenes = Array[PackedScene]([ExtResource("3")]) hull_points = PackedVector2Array(0, -24, 20.7846, -12, 20.7846, 12, 0, 24, -20.7846, 12, -20.7846, -12) -base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "shield_charges": 1.0, "shield_recharge": 6.0, "delivery_speed": 180.0} +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 1350.0, "shield_charges": 1.0, "shield_recharge": 6.0, "delivery_speed": 180.0} minimum_stats = {"shield_recharge": 2.0} upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")]) unlock_id = "unlock_shield" diff --git a/TransporterVesselDefinition.tres b/TransporterVesselDefinition.tres index b20fa98..ef0030b 100644 --- a/TransporterVesselDefinition.tres +++ b/TransporterVesselDefinition.tres @@ -15,5 +15,5 @@ icon = 12 glow_color = Color(0.95, 0.55, 0.85, 1) capabilities = PackedStringArray("cargo", "transport") component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")]) -base_stats = {"max_speed": 460.0, "acceleration": 450.0, "cargo_capacity": 20.0, "transport_speed": 150.0, "delivery_speed": 180.0} +base_stats = {"max_speed": 460.0, "acceleration": 1350.0, "cargo_capacity": 20.0, "transport_speed": 150.0, "delivery_speed": 180.0} upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")]) diff --git a/WorldGen.gd b/WorldGen.gd index 4345ef1..ba56670 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -14,8 +14,17 @@ enum Biome { SPACE, ASTEROID_FIELD } const CHUNK_SIZE: float = 1200.0 const NOISE_SEED: int = 1337 const ASTEROID_FIELD_THRESHOLD: float = 0.15 -const PLANET_THRESHOLD: float = 0.33 -const GAS_GIANT_THRESHOLD: float = 0.55 +# Planets used to sit one per ~29 chunks - 35,000 world units of empty space +# between them, which is a long flight to find anything at all. Lowered to +# about one per 12 chunks (measured over 14,641 chunks, not estimated). +# +# The two thresholds moved by DIFFERENT amounts on purpose. Every planet in a +# SPACE biome is a gas giant, so lowering both equally would have shifted the +# mix as well as the density - dropping these to 0.20/0.40 more than tripled +# the gas-giant share, from 10% of planets to 34%. 0.18/0.50 leaves it at 11%, +# so this is a change in how OFTEN you find a planet and not in what you find. +const PLANET_THRESHOLD: float = 0.18 +const GAS_GIANT_THRESHOLD: float = 0.50 # A flat per-planet-chunk probability rather than another noise field: gas # giants already cover every SPACE-biome planet chunk (see GAS_GIANT_THRESHOLD # above), so making black holes noticeably rarer just needs a small, uniform diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd index 6999bdf..e9ce954 100644 --- a/tests/_skill_menu_preview.gd +++ b/tests/_skill_menu_preview.gd @@ -1,40 +1,26 @@ extends Node2D -# Preview, not a test. Opens the SkillMenu so its layout can be checked on a -# real viewport - the tree lost vertical room to the shared resource header -# (#24), and whether it still fits is a thing only pixels can answer. +# Preview, not a test - the research screen with a node selected, so the tree's +# share of the screen and the resource table's spacing can actually be looked +# at rather than reasoned about from offsets. # -# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/m.png 20 720x1280 -# ... and again at 720x960 to check a shorter screen format. +# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/skills.png 20 720x1280 -var configured: bool = false - - -func _process(_delta: float) -> void: - if configured: - return - configured = true +func _ready() -> void: GameState.buildings.clear() GameState.extractors.clear() GameState.transporters.clear() - GameState.rocks = 180 - GameState.gas = 36 - GameState.fleet_cargo = 24 - GameState.fleet_gas_cargo = 12 - GameState.research_points = 0 - GameState.unlocked = ["shipyards"] + GameState.reset_progression() + GameState.bank_inventory.set_amount(GameState.RESOURCE_ORE, 240) + GameState.bank_inventory.set_amount(GameState.RESOURCE_GAS, 85) + GameState.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 17) + GameState.research_points = 6 var main: Node = load("res://Main.tscn").instantiate() add_child(main) main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) - main.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) - var mothership_id: int = int(GameState.buildings[-1].id) - var bc: Node = main.get_node("BuildController") - for bar in bc.resource_bars: - bar.building_id = mothership_id - main.get_node("UI/SkillMenu").visible = true - # Select a node so the description, price and status all have text - an - # empty description cannot collide with anything. - bc.skill_tree.selected_id = "coordination" - bc._refresh_skill_menu("coordination") + var build: Node = main.get_node("BuildController") + build.skill_menu.visible = true + build.skill_tree.selected_id = "shipyards" + build._refresh_skill_menu("shipyards") diff --git a/tests/test_black_hole.gd b/tests/test_black_hole.gd index 304536f..6bfe495 100644 --- a/tests/test_black_hole.gd +++ b/tests/test_black_hole.gd @@ -113,12 +113,28 @@ func _process(delta: float) -> bool: # --- C. no resource provider, no capture points, yet conquered --- if elapsed < 0.3: return false + # What must never appear is a RESOURCE PROVIDER - dark matter is + # not a surface pickup, which is this test's actual subject and is + # guaranteed above by void's features_scene being null. + # + # This used to demand zero children outright, which quietly assumed + # nobody would ever author a PlanetSetup for the void profile. That + # is a fact about the content folder, not a rule about black holes, + # and it stopped being true the moment someone saved a map under + # the void profile in the editor. An authored setup here is legal; + # a resource provider is not. var feature_host: Node = scene.get_node("SurfaceFeatures") - if feature_host.get_child_count() != 0: - return _fail("black hole surface spawned %d children under SurfaceFeatures - expected none" % feature_host.get_child_count()) - if not game_state.is_planet_conquered(game_state.planet_id): + for child in feature_host.get_children(): + if not child.has_method("apply_setup"): + return _fail("black hole surface spawned %s under SurfaceFeatures - only an authored PlanetSetupHost belongs here" % child.name) + # Through the autoload NODE, not the bare GameState identifier: a + # global class-name lookup inside a -s entry-point script is + # resolved at compile time, before autoloads are registered. Same + # hazard the header of test_planet_setups.gd documents. + if not game_state.is_planet_conquered(game_state.planet_id) \ + and int(game_state.total_capture_points(game_state.planet_id)) == 0: return _fail("a black hole with no capture points required is not marked conquered") - print("no resource provider, no capture points, already conquered") + print("no resource provider; %d authored feature host(s)" % feature_host.get_child_count()) # Snapshot rather than assert > 0: bank_inventory starts with 500 # ore by default (a fresh game's starting stock), unrelated to diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index f72ac4d..6436473 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -31,7 +31,12 @@ func _process(delta: float) -> bool: match stage: 0: # Mining is local even without a mothership or transporter. - if elapsed < 1.2: + # The wait is DERIVED from the rate, not a fixed 1.2s: that was + # long enough only while an extractor produced 2 ore per second, + # so halving the rate made this fail while the extractor was + # working perfectly. What is being tested is where the ore goes, + # not how fast it arrives. + if elapsed < 2.0 / GameState.EXTRACTOR_RATE + 0.4: return false if game_state.extractor_stock(extractor_index) < 2 or game_state.rocks != 0: _fail("extractor did not hold locally: stock=%d bank=%d" % [ diff --git a/tests/test_fleet_agility.gd b/tests/test_fleet_agility.gd new file mode 100644 index 0000000..c29c25e --- /dev/null +++ b/tests/test_fleet_agility.gd @@ -0,0 +1,184 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_fleet_agility.gd +# The fleet handled with too much inertia: flipping the stick left while +# cruising right took most of a second to take effect, and letting go coasted +# the ships several lengths further. +# +# The cause was every vessel's acceleration sitting at roughly its own top +# speed - a time constant of about a second to change velocity at all, while +# the formation anchor the ships chase turns instantly. Acceleration is now +# ~3x top speed, measured rather than guessed: past 3x the reversal time stops +# improving, because max_speed becomes the limit instead of acceleration. +# +# Feel is not directly assertable, but the three things that PRODUCE it are, +# so this measures them: +# A. how long after the stick reverses before a ship is actually travelling +# the new way +# B. how far past the turn it drifts first +# C. how far it coasts after the stick is released +# +# Plus D: the same measurement at the OLD acceleration must BREACH these +# bounds. Without that, quietly restoring the sluggish handling would still +# pass, and so would bounds so loose they mean nothing. +# +# The bounds are roughly double what the tuned values measure. This suite runs +# several Godot processes at once on a phone, and a physics-timing measurement +# under that load strays (see the note in test_space_time_distorder.gd); real +# regression here means going back towards a second, which is far outside this +# margin. + +const MAX_REVERSE_TIME: float = 0.35 +const MAX_OVERSHOOT: float = 45.0 +const MAX_STOP_COAST: float = 60.0 +# What the acceleration was before, as a fraction of what it is now. +const OLD_ACCELERATION_FRACTION: float = 1.0 / 3.0 + +var main: Node +var fleet: Node +var elapsed: float = 0.0 +var stage: int = 0 +var flip_x: float = 0.0 +var reverse_time: float = -1.0 +var overshoot: float = 0.0 +var stop_x: float = 0.0 +# Second pass re-runs the identical measurement with the old acceleration. +var measuring_old: bool = false +var tuned: Dictionary = {} + + +func _initialize() -> void: + var game_state: Node = root.get_node("GameState") + game_state.reset_progression() + main = load("res://Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("AsteroidField").set_process(false) + main.get_node("EnemySpawner").set_process(false) + # A rock or an enemy destroying the ship being measured would + # read as a handling failure. Same clean-slate idiom as + # test_space_time_distorder.gd. + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + _begin_pass() + return false + 1: + # Reach steady state travelling right before disturbing anything. + if elapsed < 2.0: + return false + if fleet.ships.is_empty(): + return _fail("the fleet lost its ships before anything could be measured") + flip_x = fleet.ships[0].position.x + fleet.move_in_direction(Vector2(-1.0, 0.0)) + stage = 2 + elapsed = 0.0 + return false + 2: + var ship: Node2D = fleet.ships[0] + overshoot = maxf(overshoot, ship.position.x - flip_x) + if reverse_time < 0.0 and ship.velocity.x < 0.0: + reverse_time = elapsed + if elapsed < 2.0: + return false + if reverse_time < 0.0: + return _fail("the fleet never reversed at all within 2s of the stick flipping") + # Settle going one way again, so the coast below is measured from + # a steady cruise rather than mid-catch-up. + fleet.move_in_direction(Vector2(1.0, 0.0)) + stage = 3 + elapsed = 0.0 + return false + 3: + if elapsed < 2.0: + return false + stop_x = fleet.ships[0].position.x + fleet.stop() + stage = 4 + elapsed = 0.0 + return false + 4: + if elapsed < 2.5: + return false + var result: Dictionary = { + "reverse_time": reverse_time, + "overshoot": overshoot, + "coast": fleet.ships[0].position.x - stop_x, + } + if not measuring_old: + tuned = result + if not _check_tuned(result): + return true + # --- D: the same measurement, sluggish again ---------------- + measuring_old = true + _begin_pass() + return false + return _check_old_breaches(result) + return false + + +func _begin_pass() -> void: + reverse_time = -1.0 + overshoot = 0.0 + for ship in fleet.ships: + # apply_vessel_stats() has already put the definition's value on the + # ship, so the old handling is reproduced by scaling that field + # directly - no need to edit a resource mid-test. + if measuring_old: + ship.acceleration *= OLD_ACCELERATION_FRACTION + ship.position = Vector2.ZERO + ship.velocity = Vector2.ZERO + fleet.move_to(Vector2.ZERO) + fleet.move_in_direction(Vector2(1.0, 0.0)) + stage = 1 + elapsed = 0.0 + + +func _check_tuned(r: Dictionary) -> bool: + print("tuned (acceleration %.0f): reversed in %.3fs, overshot %.1f, coasted %.1f" \ + % [fleet.ships[0].acceleration, r.reverse_time, r.overshoot, r.coast]) + if r.reverse_time > MAX_REVERSE_TIME: + return _fail("the fleet took %.3fs to obey a reversed stick (allowed %.2f)" \ + % [r.reverse_time, MAX_REVERSE_TIME]) + if r.overshoot > MAX_OVERSHOOT: + return _fail("the fleet drifted %.1f units past the turn (allowed %.1f)" \ + % [r.overshoot, MAX_OVERSHOOT]) + if r.coast > MAX_STOP_COAST: + return _fail("the fleet coasted %.1f units after the stick was released (allowed %.1f)" \ + % [r.coast, MAX_STOP_COAST]) + return true + + +func _check_old_breaches(r: Dictionary) -> bool: + print("old (acceleration %.0f): reversed in %.3fs, overshot %.1f, coasted %.1f" \ + % [fleet.ships[0].acceleration, r.reverse_time, r.overshoot, r.coast]) + var breaches: int = 0 + if r.reverse_time > MAX_REVERSE_TIME: + breaches += 1 + if r.overshoot > MAX_OVERSHOOT: + breaches += 1 + if r.coast > MAX_STOP_COAST: + breaches += 1 + if breaches == 0: + return _fail("the OLD, sluggish acceleration passed every bound too (reversed %.3fs, overshot %.1f, coasted %.1f) - these thresholds do not discriminate and would not catch a regression" \ + % [r.reverse_time, r.overshoot, r.coast]) + print("bounds discriminate: the old acceleration breaches %d of 3" % breaches) + print("TEST_FLEET_AGILITY: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_FLEET_AGILITY: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_fleet_agility.gd.uid b/tests/test_fleet_agility.gd.uid new file mode 100644 index 0000000..c45a12d --- /dev/null +++ b/tests/test_fleet_agility.gd.uid @@ -0,0 +1 @@ +uid://jxy856fpgl43 diff --git a/tests/test_joystick_control.gd b/tests/test_joystick_control.gd index 17dcd26..cc54861 100644 --- a/tests/test_joystick_control.gd +++ b/tests/test_joystick_control.gd @@ -163,15 +163,29 @@ func _process(delta: float) -> bool: var stopped: bool = speed < 1.0 # Not merely "moved forward at all": the fleet cruises at # cruise_speed and brakes at Ship.acceleration, so the coast is - # cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a - # real distance is what makes this a test of coasting - the old + # cruise_speed^2 / (2 * acceleration). Asserting a real distance + # is what makes this a test of coasting - the old # `> moving_centroid.x` was satisfied by any float wobble, and # passed happily while the fleet stopped dead. + # + # DERIVED from that formula rather than a fixed number. It used to + # be a hardcoded 40, which was a hidden dependency on one + # acceleration value: tripling acceleration to make the fleet + # reactive (see test_fleet_agility.gd) shortened the correct coast + # to ~25 and this failed, reporting a broken coast when the coast + # was exactly right. What this test is about is that releasing the + # stick DECELERATES rather than stopping dead, and that is a + # statement about the shape of the stop, not its length. + var expected_coast: float = (fleet.cruise_speed * fleet.cruise_speed) \ + / (2.0 * fleet.ships[0].acceleration) var coast: float = fleet.get_centroid().x - moving_centroid.x - var coasted: bool = coast > 40.0 + # Floor of 5 units so float wobble can never satisfy this however + # high acceleration goes; upper bound so a fleet that sails on for + # ages is not mistaken for a healthy coast either. + var coasted: bool = coast > maxf(expected_coast * 0.5, 5.0) \ + and coast < expected_coast * 3.0 print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [ - speed, stopped, coast, - (fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration) + speed, stopped, coast, expected_coast ]) if stopped and coasted: print("TEST_JOYSTICK_CONTROL: PASS") diff --git a/tests/test_laser_auto_aim.gd b/tests/test_laser_auto_aim.gd new file mode 100644 index 0000000..1d9ad8f --- /dev/null +++ b/tests/test_laser_auto_aim.gd @@ -0,0 +1,181 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_laser_auto_aim.gd +# Tapping the laser button without dragging used to do nothing whatsoever: +# the press was claimed and the aim begun, then releasing inside the aim +# deadzone threw the whole thing away. It reads as a dead button. A tap now +# fires at the nearest enemy in range; dragging is still how a shot is placed +# precisely. +# +# Covers: +# A. a tap picks the NEAREST enemy, not merely any enemy, and fires +# B. it fires at all - the shot lands and the target takes the hit +# C. a tap with nothing in range does not fire, says so, and leaves the +# shot unspent rather than wasting it on empty space +# D. a tap does not consume the drag-aim path: dragging past the deadzone +# still aims where it was dragged, not at the nearest enemy + +var game_state: Node +var main: Node +var fleet: Node +var skill: Node +var shooter: Node2D +var near_enemy: Node +var far_enemy: Node +var near_position: Vector2 = Vector2.ZERO +var far_position: Vector2 = Vector2.ZERO +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.stored_vessels.clear() + game_state.reset_progression() + game_state.vessel_levels["unlock_laser"] = 1 + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) + main = load("res://Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + skill = main.get_node("LaserController") + + +func _tap() -> void: + # Press and release at the same point - inside AIM_DEADZONE, so this is a + # tap and not a drag. + var button: Control = main.get_node("UI/LaserButton") + var at: Vector2 = button.global_position + button.size * 0.5 + for pressed in [true, false]: + var event := InputEventScreenTouch.new() + event.index = 7 + event.position = at + event.pressed = pressed + main._handle_touch(event) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + # Whatever the world spawned would be a second, unintended target. + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_LASER])) + skill._process(0.0) + if not main.get_node("UI/LaserButton").visible: + return _fail("a laser ship in the fleet did not reveal the laser control") + shooter = skill._next_ready_laser_ship() + if shooter == null: + return _fail("no laser ship was ready right after spawning") + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.1: + return false + + # --- C. nothing in range: no shot, and it says why --------------- + _tap() + if not skill.beams.is_empty(): + return _fail("tapping with no enemy in range fired anyway") + if skill.aiming: + return _fail("tapping with no target left the control stuck in aiming") + if main.get_node("UI/LaserStatus").text == "": + return _fail("tapping with no target fired nothing and said nothing") + print("no target in range: no shot fired, status explains why") + + # --- A/B. two targets, the nearer one is picked ------------------ + # Deliberately placed in DIFFERENT directions, so hitting the near + # one cannot be explained by the beam happening to pass through + # both. Both are well inside range. + far_enemy = _spawn_enemy(Vector2(0.0, -900.0)) + near_enemy = _spawn_enemy(Vector2(400.0, 0.0)) + far_position = far_enemy.global_position + near_position = near_enemy.global_position + stage = 2 + elapsed = 0.0 + return false + 2: + if elapsed < 0.1: + return false + _tap() + if skill.beams.is_empty(): + return _fail("tapping with an enemy in range did not fire") + if skill.last_hit_count < 1: + return _fail("the auto-aimed shot hit nothing") + # Asserted on where it AIMED, not on a body count. Enemies have + # hit points and a beam damages rather than always destroys, so + # "did it die" would be measuring the damage numbers instead of + # the targeting this test is about. + if skill.aim_target.distance_to(near_position) > 1.0: + return _fail("the tap aimed at %s, but the nearest enemy is at %s" \ + % [skill.aim_target, near_position]) + print("tap aimed at the nearest enemy (%.0f away), not the one %.0f away in another direction" \ + % [shooter.global_position.distance_to(near_position), + shooter.global_position.distance_to(far_position)]) + + # --- D. dragging still aims where it is dragged ------------------ + stage = 3 + elapsed = 0.0 + return false + 3: + # Wait out the per-ship cooldown so the next shot is available. + if elapsed < 6.0: + return false + if skill._next_ready_laser_ship() == null: + return _fail("the laser ship never came off cooldown") + var button: Control = main.get_node("UI/LaserButton") + var at: Vector2 = button.global_position + button.size * 0.5 + var press := InputEventScreenTouch.new() + press.index = 8 + press.position = at + press.pressed = true + main._handle_touch(press) + # Dragged straight UP, towards the far enemy and away from where + # auto-aim would have pointed. + var drag := InputEventScreenDrag.new() + drag.index = 8 + drag.position = at + Vector2(0.0, -300.0) + main._handle_drag(drag) + var release := InputEventScreenTouch.new() + release.index = 8 + release.position = drag.position + release.pressed = false + main._handle_touch(release) + + # Dragged up, so the shot must go UP - not to the nearest enemy, + # which lies to the right. Checked as a direction because the + # laser always extends to its full range along the aimed heading. + var heading: Vector2 = (skill.aim_target - shooter.global_position).normalized() + if heading.y > -0.9: + return _fail("dragging upward aimed along %s - auto-aim overrode a deliberate aim" % heading) + print("dragging still aims where it is dragged (heading %s), not at the nearest enemy" % heading) + + print("TEST_LASER_AUTO_AIM: PASS") + quit(0) + return true + return false + + +func _spawn_enemy(offset: Vector2) -> Node: + var enemy: Node = load("res://Enemy.tscn").instantiate() + enemy.position = shooter.global_position + offset + main.add_child(enemy) + enemy.speed = 0.0 + return enemy + + +func _fail(message: String) -> bool: + print("TEST_LASER_AUTO_AIM: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_laser_auto_aim.gd.uid b/tests/test_laser_auto_aim.gd.uid new file mode 100644 index 0000000..c853943 --- /dev/null +++ b/tests/test_laser_auto_aim.gd.uid @@ -0,0 +1 @@ +uid://dp5w0dfhf70pb diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd index 03819d4..d18c91c 100644 --- a/tests/test_laser_vessel.gd +++ b/tests/test_laser_vessel.gd @@ -25,7 +25,7 @@ var skill: Node # the class constant directly - a bare ClassName.CONST reference elsewhere # in a test script has been observed to corrupt method resolution for the # WHOLE script. See tests/test_gas_bomber_cooldowns.gd's identical fix (#60). -const BEAM_HALF_WIDTH: float = 22.0 +const BEAM_HALF_WIDTH: float = 38.0 var on_line_enemies: Array = [] var off_line_enemy: Node var beyond_range_enemy: Node diff --git a/tests/test_planet_map_assignment.gd b/tests/test_planet_map_assignment.gd index f566181..4988142 100644 --- a/tests/test_planet_map_assignment.gd +++ b/tests/test_planet_map_assignment.gd @@ -22,21 +22,29 @@ extends SceneTree const SCRATCH_ID: String = "test_assignment_scratch" const SCRATCH_PATH: String = "res://PlanetSetups/%s.tres" % SCRATCH_ID +# This test needs a pool of several maps of one type to draw from. It used to +# use whatever rocky setups the project happened to ship, which made it a test +# of the content folder as much as of the code - deleting a map in the editor +# dropped the pool to one and this failed, reporting a broken draw when +# nothing was broken. It now plants its own. +const POOL_IDS: Array = [ + "test_assignment_pool_a", "test_assignment_pool_b", "test_assignment_pool_c", +] func _initialize() -> void: _remove_scratch() + if not _plant_pool(): + return PlanetSetups.rescan() # --- E. deterministic ordering ------------------------------------------ var profile_id: String = "rocky" var ordered: Array = PlanetSetups.setups_for_profile(profile_id) if ordered.size() < 2: - # Not a failure of the code under test, but this file cannot say - # anything without a pool to draw from - say so rather than passing - # vacuously. - _fail("only %d '%s' setups exist, so nothing here can distinguish a stable draw from a fixed one" \ - % [ordered.size(), profile_id]) + _remove_scratch() + _fail("only %d '%s' setups exist even after planting %d - the pool is not seeing them" \ + % [ordered.size(), profile_id, POOL_IDS.size()]) return var ids: Array = [] for setup in ordered: @@ -125,18 +133,39 @@ func _initialize() -> void: return print("saving a new map left all %d existing assignments alone" % checked) + _remove_scratch() + PlanetSetups.rescan() print("TEST_PLANET_MAP_ASSIGNMENT: PASS") quit(0) +func _plant_pool() -> bool: + for pool_id in POOL_IDS: + var setup: Resource = load("res://PlanetSetup.gd").new() + setup.setup_id = pool_id + setup.display_name = pool_id + setup.surface_profile_id = "rocky" + setup.capture_points = PackedVector2Array([Vector2(100.0, 100.0)]) + if ResourceSaver.save(setup, "res://PlanetSetups/%s.tres" % pool_id) != OK: + _remove_scratch() + _fail("could not plant the scratch map pool") + return false + return true + + func _remove_scratch() -> void: - var path: String = ProjectSettings.globalize_path(SCRATCH_PATH) - if FileAccess.file_exists(path): - DirAccess.remove_absolute(path) - if FileAccess.file_exists(path + ".uid"): - DirAccess.remove_absolute(path + ".uid") + var paths: Array = [SCRATCH_PATH] + for pool_id in POOL_IDS: + paths.append("res://PlanetSetups/%s.tres" % pool_id) + for res_path in paths: + var path: String = ProjectSettings.globalize_path(res_path) + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + if FileAccess.file_exists(path + ".uid"): + DirAccess.remove_absolute(path + ".uid") func _fail(message: String) -> void: + _remove_scratch() print("TEST_PLANET_MAP_ASSIGNMENT: FAIL (%s)" % message) quit(1) diff --git a/tests/test_planet_setups.gd b/tests/test_planet_setups.gd index 723be8b..9bf6b8d 100644 --- a/tests/test_planet_setups.gd +++ b/tests/test_planet_setups.gd @@ -52,7 +52,15 @@ func _initialize() -> void: return _fail("setups_for_profile('rocky') leaked a non-rocky setup") # --- 2. dropping a new .tres is picked up by rescan() --- - var before_count: int = PlanetSetups.setups_for_profile("rocky").size() + # Checked by MEMBERSHIP of this test's own scratch id, not by counting the + # pool before and after. res://PlanetSetups/ is shared, the suite runs + # several tests at once, and two of them write scratch setups there - so a + # count taken before and a count taken after are not measuring the same + # population, and this failed intermittently while the behaviour it tests + # was perfectly fine. Membership of a uniquely-named file cannot be + # perturbed by anyone else. + if _pool_contains("_test_scratch_setup"): + return _fail("a leftover scratch setup was already in the pool before this test wrote one") var scratch: Resource = PLANET_SETUP_SCRIPT.new() scratch.setup_id = "_test_scratch_setup" scratch.display_name = "Scratch" @@ -62,16 +70,16 @@ func _initialize() -> void: if save_err != OK: return _fail("could not save the scratch setup for the pickup check (err %d)" % save_err) PlanetSetups.rescan() - var after_count: int = PlanetSetups.setups_for_profile("rocky").size() + var picked_up: bool = _pool_contains("_test_scratch_setup") DirAccess.remove_absolute(ProjectSettings.globalize_path(SCRATCH_SETUP_PATH)) var import_file: String = SCRATCH_SETUP_PATH + ".import" if FileAccess.file_exists(import_file): DirAccess.remove_absolute(ProjectSettings.globalize_path(import_file)) PlanetSetups.rescan() - if after_count != before_count + 1: - return _fail("registry did not pick up a newly saved setup (before=%d after=%d)" % [ - before_count, after_count - ]) + if not picked_up: + return _fail("registry did not pick up a newly saved setup") + if _pool_contains("_test_scratch_setup"): + return _fail("registry still offers the scratch setup after its file was deleted") # --- 3. landing composes a PlanetSetupHost with the resolved setup --- # Deferred to _process(): a scene instantiated and added to the tree @@ -132,6 +140,13 @@ func _process(delta: float) -> bool: return false +func _pool_contains(setup_id: String) -> bool: + for setup in PlanetSetups.setups_for_profile("rocky"): + if String(setup.setup_id) == setup_id: + return true + return false + + func _fail(message: String) -> bool: print("TEST_PLANET_SETUPS: FAIL (%s)" % message) if FileAccess.file_exists(SCRATCH_SETUP_PATH): diff --git a/tests/test_research_points.gd b/tests/test_research_points.gd index 0a2cf69..3672a8f 100644 --- a/tests/test_research_points.gd +++ b/tests/test_research_points.gd @@ -15,7 +15,11 @@ extends SceneTree # zero research points # D. a research-point-gated skill node is refused while unaffordable, and # explains why -# E. conquering a research_lab awards exactly one point, once (guarded +# E. conquering a research_lab awards what its MAP says it is worth, once. +# The reward used to be a flat 1 on the planet definition; it now comes +# entirely from the authored setup, so a hard lab can pay more than an +# easy one and the value is editable in the map editor. The planet type +# contributing nothing of its own is part of what is checked (guarded # even against a redundant re-mark), emits the signal once, and a # non-research_lab planet's conquest never awards one # D continued: unlocking spends the points and the balance updates @@ -82,7 +86,22 @@ func _initialize() -> void: var totals_seen: Array = [] game_state.research_points_changed.connect(func(total): totals_seen.append(total)) game_state.planet_capture.clear() - game_state.register_capture_points("test_lab_planet", 2, "research_lab") + # The planet type itself must no longer carry a reward - that is the + # delegation. If it crept back, a lab would pay its map's value PLUS a + # hidden one, or pay something even with no map authored. + var lab_definition: Resource = load("res://PlanetDefinitions.gd").get_definition("research_lab") + if int(lab_definition.conquest_research_points) != 0: + return _fail("the research_lab planet type still grants %d point(s) of its own - the reward belongs to the authored map now" \ + % int(lab_definition.conquest_research_points)) + game_state.register_capture_points("test_lab_no_reward", 1, "research_lab") + game_state.mark_point_captured("test_lab_no_reward", 0) + if game_state.research_points != 0: + return _fail("a lab whose map states no reward still awarded %d" % game_state.research_points) + print("the planet type grants nothing by itself; an unpriced map awards nothing") + + # What an authored map is worth is passed through as the override, exactly + # as PlanetSetupHost does from the setup's research_point_reward. + game_state.register_capture_points("test_lab_planet", 2, "research_lab", 1) if game_state.is_planet_conquered("test_lab_planet"): return _fail("a lab with 2 real capture points started conquered") game_state.mark_point_captured("test_lab_planet", 0) @@ -90,10 +109,10 @@ func _initialize() -> void: return _fail("a research point was awarded before every point was captured") game_state.mark_point_captured("test_lab_planet", 1) if game_state.research_points != 1: - return _fail("conquering a research_lab did not award exactly 1 point: %d" % game_state.research_points) + return _fail("conquering a research_lab did not award the 1 point its map is worth: %d" % game_state.research_points) if totals_seen != [1]: return _fail("research_points_changed did not fire exactly once with the new total: %s" % [totals_seen]) - print("conquering a research_lab awarded exactly 1 point and fired the signal once") + print("conquering a research_lab awarded exactly what its map is worth, once") game_state.mark_point_captured("test_lab_planet", 0) # already captured - redundant re-mark if game_state.research_points != 1: diff --git a/tests/test_setup_file_identity.gd b/tests/test_setup_file_identity.gd new file mode 100644 index 0000000..c064e35 --- /dev/null +++ b/tests/test_setup_file_identity.gd @@ -0,0 +1,158 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_setup_file_identity.gd +# A setup's id and its FILENAME are not the same thing. res://PlanetSetups/ +# holds authored resources named however their author named them, and shipped +# content already contains a mismatch: ResearchLabSetup.tres holds setup_id +# "research_lab_1". +# +# The editor's save and delete both rebuilt the path from the id, which +# produced two bugs that looked unrelated: +# - deleting such a setup looked for a file that does not exist, refused, +# and reported nothing useful. It was always the same entry, so it read +# as "the first item in the list cannot be deleted" +# - saving such a setup wrote a SECOND file under the derived name and left +# the original in place, so the pool then held two setups claiming one id +# and the stale one could still be served +# A third bug hid behind those: rescan() re-read through Godot's resource +# CACHE, so even a correctly written file came back with its pre-save values. +# All three present identically to a player - "my change was not saved". +# +# Covers: +# A. shipped content really does contain an id/filename mismatch, so this is +# guarding a live case (checked read-only - see the note on isolation) +# B. saving a mismatched setup writes back to ITS file and creates no twin +# C. the edit is what the registry serves afterwards, cache and all +# D. such a setup can be deleted +# E. a brand-new setup still lands at PlanetSetups/.tres +# +# ISOLATION: every mutation happens on a scratch file this test creates, never +# on shipped content. The suite runs tests in parallel against one project +# directory, and deleting a real setup mid-run - even briefly, even restored +# afterwards - would make some other test fail for reasons of its own. + +const SCRATCH_FILE := "res://PlanetSetups/_identity_scratch_file.tres" +const SCRATCH_ID := "_identity_scratch_id" +const DERIVED_PATH := "res://PlanetSetups/_identity_scratch_id.tres" +const NEW_ID := "test_identity_new" +const NEW_PATH := "res://PlanetSetups/test_identity_new.tres" + +var editor: Node +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + _cleanup() + editor = load("res://PlanetSetupEditor.tscn").instantiate() + root.add_child(editor) + + +func _process(delta: float) -> bool: + elapsed += delta + if stage != 0: + return false + if elapsed < 0.15: + return false + stage = 1 + + # --- A. shipped content has a mismatch (read-only) --------------------- + var shipped_mismatch: String = "" + for setup in PlanetSetups.all(): + var path: String = String(setup.resource_path) + if path != "" and String(setup.setup_id) != path.get_file().get_basename(): + shipped_mismatch = "%s holds id '%s'" % [path.get_file(), setup.setup_id] + break + if shipped_mismatch == "": + print("note: no shipped setup currently has an id/filename mismatch; the scratch case below still covers the behaviour") + else: + print("shipped mismatch present: %s" % shipped_mismatch) + + # A setup whose FILE NAME deliberately differs from its id. + var scratch: Resource = load("res://PlanetSetup.gd").new() + scratch.setup_id = SCRATCH_ID + scratch.display_name = "Identity Scratch" + scratch.surface_profile_id = "rocky" + scratch.capture_points = PackedVector2Array([Vector2(10.0, 20.0)]) + scratch.research_point_reward = 1 + if ResourceSaver.save(scratch, SCRATCH_FILE) != OK: + return _fail("could not write the scratch setup") + PlanetSetups.rescan() + if PlanetSetups.path_for_id(SCRATCH_ID) != SCRATCH_FILE: + return _fail("the registry resolved '%s' to %s, not the file it lives in (%s)" \ + % [SCRATCH_ID, PlanetSetups.path_for_id(SCRATCH_ID), SCRATCH_FILE]) + print("'%s' lives in %s - id and filename disagree" % [SCRATCH_ID, SCRATCH_FILE.get_file()]) + + # --- B/C. edit, save, and see the edit served ------------------------- + if not editor.load_setup(SCRATCH_ID): + return _fail("the editor could not open '%s'" % SCRATCH_ID) + var before: int = int(editor.current_setup.research_point_reward) + editor._on_reward_pressed() + var chosen: int = int(editor.current_setup.research_point_reward) + if chosen == before: + return _fail("cycling the reward did not change it") + var outcome: Dictionary = editor.save_setup() + if not outcome.ok: + return _fail("saving failed: %s" % outcome.message) + if FileAccess.file_exists(ProjectSettings.globalize_path(DERIVED_PATH)): + return _fail("saving wrote a twin at %s instead of back to the file it was loaded from" % DERIVED_PATH) + + PlanetSetups.rescan() + var served: Resource = PlanetSetups.find_by_id(SCRATCH_ID) + if served == null: + return _fail("'%s' vanished from the pool after saving" % SCRATCH_ID) + if int(served.research_point_reward) != chosen: + return _fail("saved reward %d but the pool still serves %d - the rescan read the resource cache rather than the file" \ + % [chosen, int(served.research_point_reward)]) + var duplicates: int = 0 + for setup in PlanetSetups.all(): + if String(setup.setup_id) == SCRATCH_ID: + duplicates += 1 + if duplicates != 1: + return _fail("the pool holds %d setups all claiming id '%s'" % [duplicates, SCRATCH_ID]) + print("reward %d -> %d saved back into %s, no twin, and the pool serves the edit" \ + % [before, chosen, SCRATCH_FILE.get_file()]) + + # --- E. a NEW setup still lands under its id -------------------------- + editor.new_setup(NEW_ID, "Identity New", "rocky") + editor.add_capture_point(Vector2(20.0, 30.0)) + if not editor.save_setup().ok: + return _fail("saving a brand-new setup failed") + if not FileAccess.file_exists(ProjectSettings.globalize_path(NEW_PATH)): + return _fail("a new setup did not land at %s" % NEW_PATH) + PlanetSetups.rescan() + if not editor.delete_setup(NEW_ID).ok: + return _fail("could not delete the new setup") + print("a new setup still saves to PlanetSetups/.tres, and deletes again") + + # --- D. the mismatched setup can be deleted --------------------------- + PlanetSetups.rescan() + var deleted: Dictionary = editor.delete_setup(SCRATCH_ID) + if not deleted.ok: + return _fail("deleting '%s' failed: %s" % [SCRATCH_ID, deleted.message]) + if FileAccess.file_exists(ProjectSettings.globalize_path(SCRATCH_FILE)): + return _fail("delete reported success but %s is still there" % SCRATCH_FILE) + print("a setup whose filename differs from its id can be deleted") + + _cleanup() + print("TEST_SETUP_FILE_IDENTITY: PASS") + quit(0) + return true + + +func _cleanup() -> void: + for path in [SCRATCH_FILE, DERIVED_PATH, NEW_PATH]: + var absolute: String = ProjectSettings.globalize_path(path) + if FileAccess.file_exists(absolute): + DirAccess.remove_absolute(absolute) + if FileAccess.file_exists(absolute + ".uid"): + DirAccess.remove_absolute(absolute + ".uid") + PlanetSetups.rescan() + + +func _fail(message: String) -> bool: + _cleanup() + print("TEST_SETUP_FILE_IDENTITY: FAIL (%s)" % message) + quit(1) + return true