From 571f01dc3133eb6fc567c9a30d8fab102d85174d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 4 Sep 2026 22:34:36 +0200 Subject: [PATCH] Add a free-costs sandbox tool Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- core/GameState.gd | 41 ++++++++++++++++++++++++----- tests/test_dev_menu.gd | 60 +++++++++++++++++++++++++++++++++++++++++- ui/DevMenuButton.gd | 8 ++++++ ui/menus/DevMenu.gd | 10 ++++++- ui/menus/DevMenu.tscn | 35 +++++++++++++++++------- 5 files changed, 136 insertions(+), 18 deletions(-) diff --git a/core/GameState.gd b/core/GameState.gd index d95961b..2e63272 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -190,11 +190,17 @@ var research_points: int = 0 # place to set it - assigning dev_mode anywhere, tests included, reaches # world generation. var dev_unlimited_resources: bool = false +# #202: every research and production price answers zero while this is on. +# A separate switch from the one above rather than a stronger version of it - +# unlimited resources tests whether a price can be PAID, and this tests what +# happens when there is no price at all, which is a different play-test. +var dev_free_costs: bool = false var dev_mode: bool = false: set(value): dev_mode = value if not value: dev_unlimited_resources = false + dev_free_costs = false WORLD_GEN.dev_planets_enabled = value # Explored-chunk history for the world map's fog-of-war reveal. Lives @@ -676,7 +682,7 @@ func prerequisite_met(id: String) -> bool: # ever compared two nodes by price - keeps working on a plain number. func skill_costs(id: String) -> Dictionary: var node: Dictionary = skill_node(id) - if node.is_empty(): + if node.is_empty() or costs_are_free(): return {} var costs: Dictionary = node.get("costs", {}) if not costs.is_empty(): @@ -692,7 +698,7 @@ func can_unlock(id: String, payment_position: Variant = null) -> bool: func skill_research_point_cost(id: String) -> int: - return int(skill_node(id).get("research_points", 0)) + return 0 if costs_are_free() else int(skill_node(id).get("research_points", 0)) func unlock(id: String, payment_position: Variant = null) -> bool: @@ -792,6 +798,7 @@ func reset_new_game() -> void: boost_depleted_notice = false research_points = 0 dev_unlimited_resources = false + dev_free_costs = false dev_mode = false explored_chunks.clear() scanner_target.clear() @@ -954,6 +961,21 @@ func set_dev_unlimited_resources(enabled: bool) -> bool: return true +func set_dev_free_costs(enabled: bool) -> bool: + if enabled and not dev_mode: + return false + dev_free_costs = enabled + return true + + +# Asked by every price below, and by the menus that display them. The dev_mode +# term is not redundant: the setter refuses outside a dev game and the dev_mode +# setter clears the flag, so this is the third guard rather than the first, and +# it is the one that holds if a save or a test sets the field directly. +func costs_are_free() -> bool: + return dev_mode and dev_free_costs + + func _refill_dev_resources() -> void: for resource_type in resource_types(): if bank_inventory.amount(resource_type) < DEV_UNLIMITED_AMOUNT: @@ -1052,7 +1074,7 @@ func find_building(id: int) -> Dictionary: # there is one question with one answer whatever is being bought. func building_costs(type: String) -> Dictionary: var definition: Resource = building_definition(type) - if definition == null: + if definition == null or costs_are_free(): return {} if not definition.build_resource_costs.is_empty(): return definition.build_resource_costs.duplicate() @@ -1494,7 +1516,10 @@ func unlock_vessel(vessel: String, payment_position: Variant = null) -> bool: if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab(): return false var costs: Dictionary = research_costs(definition.unlock_id) - var points_needed: int = int(definition.research_point_cost) + # Through the accessor, not off the definition: this used to be the one + # place a research price was read straight from its file, which #202's + # free-costs switch would have left charging full price here alone. + var points_needed: int = research_point_cost(definition.unlock_id) if research_points < points_needed or not spend_resources(costs, payment_position): return false if points_needed > 0: @@ -1540,7 +1565,7 @@ func upgrade_maxed(id: String) -> bool: func upgrade_cost(id: String) -> int: var d: Dictionary = upgrade_def(id) - if d.is_empty(): + if d.is_empty() or costs_are_free(): return 0 return int(d.cost) * (upgrade_level(id) + 1) @@ -1631,7 +1656,7 @@ func product_cost(product: String) -> int: func product_costs(product: String) -> Dictionary: var definition: Resource = vessel_definition(product) - if definition == null: + if definition == null or costs_are_free(): return {} if not definition.build_resource_costs.is_empty(): return definition.build_resource_costs.duplicate() @@ -2332,6 +2357,8 @@ func research_cost(id: String) -> int: func research_point_cost(id: String) -> int: + if costs_are_free(): + return 0 var unlock_definition: Resource = vessel_for_unlock(id) if unlock_definition != null: return int(unlock_definition.research_point_cost) @@ -2340,6 +2367,8 @@ func research_point_cost(id: String) -> int: func research_costs(id: String) -> Dictionary: + if costs_are_free(): + return {} var unlock_definition: Resource = vessel_for_unlock(id) if unlock_definition != null: if not unlock_definition.unlock_resource_costs.is_empty(): diff --git a/tests/test_dev_menu.gd b/tests/test_dev_menu.gd index ad09482..94a3dec 100644 --- a/tests/test_dev_menu.gd +++ b/tests/test_dev_menu.gd @@ -5,6 +5,7 @@ extends SceneTree # the live fleet, not only GameState bookkeeping. const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd") +const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd") var state: Node var scene: Node @@ -73,6 +74,39 @@ func _process(delta: float) -> bool: return _fail("unlimited mode did not replenish research points") print("skill unlock and unlimited resources operate on persistent GameState") + # --- #202: every research and production price reads zero ------- + # Unlimited resources goes off first: a bottomless bank and a free + # price are indistinguishable while both are on, and it is the + # price this checks. + menu.unlimited_button.pressed.emit() + if state.dev_unlimited_resources: + return _fail("the unlimited-resources toggle did not turn back off") + var charged: int = _priced_things() + if charged == 0: + return _fail("nothing in the game carries a price to make free") + menu.free_costs_button.pressed.emit() + if not state.dev_free_costs: + return _fail("the free-costs toggle did not turn on") + if _priced_things() != 0: + return _fail("%d prices survived the free-costs toggle" % _priced_things()) + # The accessors are what every purchase, queue and price label + # reads, so an empty bank must now afford anything. + for resource_type in state.resource_types(): + state.bank_inventory.set_amount(resource_type, 0) + state.fleet_inventory.set_amount(resource_type, 0) + state.research_points = 0 + for definition in BUILDING_DEFINITIONS.all(): + if not state.can_afford_resources(state.building_costs(String(definition.definition_id))): + return _fail("a broke fleet could not afford the free %s" % definition.definition_id) + print("free costs zeroed every research and production price") + + menu.free_costs_button.pressed.emit() + if state.dev_free_costs: + return _fail("the free-costs toggle did not turn back off") + if _priced_things() != charged: + return _fail("turning free costs off did not restore every price") + print("prices come back exactly as they were when the toggle goes off") + var wave_before: int = state.wave_number menu.wave_button.pressed.emit() if menu.visible: @@ -81,7 +115,7 @@ func _process(delta: float) -> bool: return _fail("the dev menu did not retain the immediate wave tool") state.reset_new_game() - if state.dev_mode or state.dev_unlimited_resources: + if state.dev_mode or state.dev_unlimited_resources or state.dev_free_costs: return _fail("dev cheats leaked into a normal new game") print("TEST_DEV_MENU: PASS") quit(0) @@ -89,6 +123,30 @@ func _process(delta: float) -> bool: return false +# How many prices in the whole game are currently non-zero: skills, vessel +# unlocks and upgrades on the research side, buildings and vessel production +# on the other. Counted rather than sampled so a price added later is covered +# here without anyone remembering to add it. +func _priced_things() -> int: + var total: int = 0 + for node in GameState.SKILL_TREE: + var id: String = String(node.id) + total += state.skill_costs(id).size() + state.skill_research_point_cost(id) + for definition in BUILDING_DEFINITIONS.all(): + total += state.building_costs(String(definition.definition_id)).size() + for definition in VESSEL_DEFINITIONS.all(): + total += state.product_costs(String(definition.definition_id)).size() + var unlock_id: String = String(definition.unlock_id) + if unlock_id != "": + total += state.research_costs(unlock_id).size() \ + + state.research_point_cost(unlock_id) + for upgrade in definition.upgrades: + var upgrade_id: String = String(upgrade.upgrade_id) + total += state.upgrade_cost(upgrade_id) \ + + state.research_point_cost(upgrade_id) + return total + + func _fail(message: String) -> bool: print("TEST_DEV_MENU: FAIL (%s)" % message) quit(1) diff --git a/ui/DevMenuButton.gd b/ui/DevMenuButton.gd index ae77001..22dc7f8 100644 --- a/ui/DevMenuButton.gd +++ b/ui/DevMenuButton.gd @@ -14,6 +14,7 @@ func _ready() -> void: menu.fleet_remove_requested.connect(_on_fleet_remove_requested) menu.unlock_skills_requested.connect(_on_unlock_skills_requested) menu.unlimited_resources_requested.connect(_on_unlimited_resources_requested) + menu.free_costs_requested.connect(_on_free_costs_requested) menu.wave_requested.connect(_on_wave_requested) visible = false @@ -70,6 +71,13 @@ func _on_unlimited_resources_requested(enabled: bool) -> void: menu.set_status("Unlimited resources %s" % ("enabled" if enabled else "disabled")) +func _on_free_costs_requested(enabled: bool) -> void: + if not GameState.set_dev_free_costs(enabled): + return + menu.set_status("Research and production costs %s" \ + % ("are now free" if enabled else "are charged again")) + + func _on_wave_requested() -> void: GameState.force_wave_now() menu.close_menu() diff --git a/ui/menus/DevMenu.gd b/ui/menus/DevMenu.gd index e96b43a..24c412b 100644 --- a/ui/menus/DevMenu.gd +++ b/ui/menus/DevMenu.gd @@ -4,6 +4,7 @@ signal fleet_add_requested(vessel_type: String) signal fleet_remove_requested(vessel_type: String) signal unlock_skills_requested signal unlimited_resources_requested(enabled: bool) +signal free_costs_requested(enabled: bool) signal wave_requested const ICON_BUTTON = preload("res://ui/IconButton.gd") @@ -16,8 +17,10 @@ const DESIGN_SIZE := Vector2(720.0, 1280.0) @onready var fleet_rows: VBoxContainer = $Content/FleetRows @onready var unlock_button: IconButton = $Content/ToolButtons/UnlockSkills @onready var unlimited_button: IconButton = $Content/ToolButtons/UnlimitedResources +@onready var free_costs_button: IconButton = $Content/ToolButtons/FreeCosts @onready var wave_button: IconButton = $Content/ToolButtons/StartWave @onready var unlimited_label: Label = $Content/ToolLabels/UnlimitedLabel +@onready var free_costs_label: Label = $Content/ToolLabels/FreeCostsLabel @onready var status: Label = $Content/StatusLabel var fleet: Node = null @@ -31,6 +34,8 @@ func _ready() -> void: unlock_button.pressed.connect(func(): unlock_skills_requested.emit()) unlimited_button.pressed.connect(func(): unlimited_resources_requested.emit(not GameState.dev_unlimited_resources)) + free_costs_button.pressed.connect(func(): + free_costs_requested.emit(not GameState.dev_free_costs)) wave_button.pressed.connect(func(): wave_requested.emit()) _layout_content() @@ -80,8 +85,11 @@ func refresh() -> void: (controls.remove as IconButton).enabled = count > 0 and total > 1 unlock_button.highlighted = _all_skills_unlocked() unlimited_button.highlighted = GameState.dev_unlimited_resources - unlimited_label.text = "UNLIMITED RESOURCES\n%s" \ + unlimited_label.text = "UNLIMITED\nRESOURCES\n%s" \ % ("ON" if GameState.dev_unlimited_resources else "OFF") + free_costs_button.highlighted = GameState.dev_free_costs + free_costs_label.text = "FREE\nCOSTS\n%s" \ + % ("ON" if GameState.dev_free_costs else "OFF") func _all_skills_unlocked() -> bool: diff --git a/ui/menus/DevMenu.tscn b/ui/menus/DevMenu.tscn index e1e72c5..860b1d9 100644 --- a/ui/menus/DevMenu.tscn +++ b/ui/menus/DevMenu.tscn @@ -105,11 +105,11 @@ horizontal_alignment = 1 [node name="ToolButtons" type="HBoxContainer" parent="Content"] anchor_right = 1.0 -offset_left = 70.0 +offset_left = 24.0 offset_top = 896.0 -offset_right = -70.0 +offset_right = -24.0 offset_bottom = 1012.0 -theme_override_constants/separation = 56 +theme_override_constants/separation = 52 alignment = 1 [node name="UnlockSkills" type="Control" parent="Content/ToolButtons"] @@ -126,6 +126,13 @@ script = ExtResource("icon_button") icon = 29 glow_color = Color(1, 0.78, 0.25, 1) +[node name="FreeCosts" type="Control" parent="Content/ToolButtons"] +custom_minimum_size = Vector2(116, 116) +layout_mode = 2 +script = ExtResource("icon_button") +icon = 28 +glow_color = Color(0.55, 0.75, 1, 1) + [node name="StartWave" type="Control" parent="Content/ToolButtons"] custom_minimum_size = Vector2(116, 116) layout_mode = 2 @@ -135,15 +142,15 @@ glow_color = Color(1, 0.45, 0.2, 1) [node name="ToolLabels" type="HBoxContainer" parent="Content"] anchor_right = 1.0 -offset_left = 28.0 +offset_left = 24.0 offset_top = 1018.0 -offset_right = -28.0 -offset_bottom = 1098.0 +offset_right = -24.0 +offset_bottom = 1114.0 theme_override_constants/separation = 8 alignment = 1 [node name="UnlockLabel" type="Label" parent="Content/ToolLabels"] -custom_minimum_size = Vector2(210, 76) +custom_minimum_size = Vector2(160, 92) layout_mode = 2 theme_override_font_sizes/font_size = 20 text = "UNLOCK ALL\nSKILLS" @@ -151,15 +158,23 @@ horizontal_alignment = 1 vertical_alignment = 1 [node name="UnlimitedLabel" type="Label" parent="Content/ToolLabels"] -custom_minimum_size = Vector2(210, 76) +custom_minimum_size = Vector2(160, 92) layout_mode = 2 theme_override_font_sizes/font_size = 20 -text = "UNLIMITED RESOURCES\nOFF" +text = "UNLIMITED\nRESOURCES\nOFF" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="FreeCostsLabel" type="Label" parent="Content/ToolLabels"] +custom_minimum_size = Vector2(160, 92) +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "FREE\nCOSTS\nOFF" horizontal_alignment = 1 vertical_alignment = 1 [node name="WaveLabel" type="Label" parent="Content/ToolLabels"] -custom_minimum_size = Vector2(210, 76) +custom_minimum_size = Vector2(160, 92) layout_mode = 2 theme_override_font_sizes/font_size = 20 text = "START ENEMY\nWAVE"