Add a free-costs sandbox tool

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-04 22:34:36 +02:00
commit 571f01dc31
5 changed files with 136 additions and 18 deletions

View file

@ -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():

View file

@ -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)

View file

@ -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()

View file

@ -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:

View file

@ -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"