From 8ba2cca6b69c51c0885e259e4dbd7207f0a9190d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Thu, 20 Aug 2026 09:55:11 +0200 Subject: [PATCH] Sharpen fleet handling, widen the laser, and fix the map editor's file identity The fleet had a second of inertia in it: every vessel's acceleration sat at roughly its own top speed, so flipping the stick while cruising took 0.58s to take effect and let go coasted 222 units, while the formation anchor the ships chase turns instantly. Acceleration is now ~3x top speed, chosen by measuring rather than taste - past 3x the reversal time stops improving because max_speed becomes the limit. Measured after: 0.18s and 23 units. test_fleet_agility guards all three numbers and proves the bounds discriminate by re-running the same measurement at the old value. The laser reaches 1450 instead of 950 and strikes 38 units either side instead of 22. Its drawn width is now derived from the hit width: the two were independent numbers that had drifted, so the beam killed things it visibly missed. LaserController's aim range also stopped hardcoding a second copy of 950. Tapping the laser button without dragging used to do nothing at all - the press was claimed, the aim begun, then thrown away on release. It now fires at the nearest enemy in range; dragging still aims precisely. That lives in the shared aimed-ability controller, so the gas bomb gains it too. Planets sat one per ~29 chunks - some 35,000 units of nothing between them. Now one per ~12, measured over 14,641 chunks. The two thresholds moved by different amounts deliberately: every SPACE-biome planet is a gas giant, so lowering both equally would have tripled the gas-giant share as well. This changes how often you find a planet, not what you find. Extractors produce half as fast to match, so expanding is what grows the economy. The research screen gave its tree 386px of 1280 and the text below it 520. The text is now at the bottom, the tree has 550, and the resource table has room and legible type - and its LOCAL column is no longer hidden behind the close button. Two map-editor bugs turned out to be one cause: a setup's id and its FILENAME are not the same thing, and both save and delete rebuilt the path from the id. ResearchLabSetup.tres holds setup_id "research_lab_1", so deleting it looked for a file that does not exist and silently refused - which read as "the first item in the list cannot be deleted" - while saving it wrote a second file under the derived name and left the original behind, so the pool served the stale twin. A third bug hid behind those: rescan() re-read through Godot's resource cache and handed back pre-save values even when the file on disk was correct. All three present to a player as "my change was not saved". Research labs no longer grant a point of their own; what conquering one is worth comes entirely from its authored map, as asked. The shipped lab map carries 1 so the balance is unchanged - only where it is configured moved. Suite 109/109. Four tests changed because the behaviour they pinned changed: the joystick coast is now derived from cruise_speed^2/(2*acceleration) rather than a constant tuned to one acceleration; the extractor wait derives from EXTRACTOR_RATE; the research-point test asserts the delegation; and the black hole test no longer assumes nobody will ever author a void-profile map. test_planet_setups and test_planet_map_assignment stopped depending on which maps happen to be in the content folder, which was making them fail when maps were added or deleted elsewhere. Co-Authored-By: Claude Opus 5 --- AimedFleetAbilityController.gd | 34 +++++ CarrierVesselDefinition.tres | 2 +- DistorderVesselDefinition.tres | 2 +- GameState.gd | 7 +- GasBombController.gd | 1 + GasBomberVesselDefinition.tres | 2 +- InterceptorVesselDefinition.tres | 2 +- LaserController.gd | 24 ++- LaserVesselDefinition.tres | 2 +- Main.tscn | 44 +++--- PlanetSetupEditor.gd | 23 ++- PlanetSetupEditor.tscn | 1 + PlanetSetups.gd | 38 ++++- PlanetSetups/ResearchLabSetup.tres | 1 + PlanetSetups/gas_default.tres | 42 ++++-- PlanetSetups/map1.tres | 89 ----------- PlanetSetups/research_lab_1.tres | 56 ------- PlanetSetups/researtch_lab_garrison.tres | 46 ++++++ PlanetSetups/rocky_default.tres | 30 +++- ResearchLabPlanetDefinition.tres | 2 +- ResourceBar.gd | 35 +++-- ShieldVesselDefinition.tres | 2 +- TransporterVesselDefinition.tres | 2 +- WorldGen.gd | 13 +- tests/_skill_menu_preview.gd | 42 ++---- tests/test_black_hole.gd | 24 ++- tests/test_extractor_income.gd | 7 +- tests/test_fleet_agility.gd | 184 +++++++++++++++++++++++ tests/test_fleet_agility.gd.uid | 1 + tests/test_joystick_control.gd | 24 ++- tests/test_laser_auto_aim.gd | 181 ++++++++++++++++++++++ tests/test_laser_auto_aim.gd.uid | 1 + tests/test_laser_vessel.gd | 2 +- tests/test_planet_map_assignment.gd | 49 ++++-- tests/test_planet_setups.gd | 27 +++- tests/test_research_points.gd | 27 +++- tests/test_setup_file_identity.gd | 158 +++++++++++++++++++ 37 files changed, 954 insertions(+), 273 deletions(-) delete mode 100644 PlanetSetups/map1.tres delete mode 100644 PlanetSetups/research_lab_1.tres create mode 100644 PlanetSetups/researtch_lab_garrison.tres create mode 100644 tests/test_fleet_agility.gd create mode 100644 tests/test_fleet_agility.gd.uid create mode 100644 tests/test_laser_auto_aim.gd create mode 100644 tests/test_laser_auto_aim.gd.uid create mode 100644 tests/test_setup_file_identity.gd 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