From 1ece2027316e0ceadac1d6340335fb4b4eff133b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 28 Aug 2026 15:30:30 +0200 Subject: [PATCH] #138: structures and doctrines cost more than rocks, and more the deeper they are Both halves of the ticket, in the shape vessels have used since gas bombers and laser ships: a definition carries an ore figure and, when it wants more than ore, a dictionary that IS the price. `building_costs()` and `skill_costs()` answer it the way `product_costs()` already did, so there is one question with one answer whatever is being bought, and every payment path now goes through `spend_resources` rather than `spend`. Seven of ten structures and thirteen of seventeen doctrines now want gas, energy or dark matter alongside their rocks, and the totals climb with tree depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530. The constraint that shaped the table is REACHABILITY, and it is the reason some prices stayed ore-only. Energy exists only where a gas burner stands, and a gas burner needs Power Systems - so nothing at or before that depth may be priced in energy, or it is a wall with no way through. Dark matter is rarer still and appears once, on the deepest node in the game. The extractor stays rocks-only despite its depth allowing gas, because the quest chain reaches it before it has sent the player for any. test_progressive_costs checks those as properties rather than transcribing the numbers, so rebalancing a price costs nothing while breaking the shape fails: nothing unreachable at its own depth, prices rising with depth, the ore field agreeing with the dictionary (repairs quote the field alone, since a repair drone carries ore and nothing else), and the quest chain never asking for a structure it has not funded. Two things cleaned up on the way: - The per-building cost constants in GameState are gone. Nothing but tests used them, and they were a second copy of a number the definitions already hold - exactly what would drift once a price gained a resource. - research_for_building() returns a display NAME, and the id-keyed half of the tree wants an id. That mix-up is silent - skill_depth() of a name finds no node and answers 0, so a deep structure reads as an ungated one, which is how the first draft of the test let an unreachable price through. research_node_for_building() is the id, and the name is expressed from it. PriceTag no longer rebuilds its labels when re-told the same price; it refreshes the held figures instead. The craft menu re-states prices every frame and the build menu now does too, so that was a dozen Labels being torn down and rebuilt sixty times a second. --- buildings/BuildingDefinition.gd | 10 + buildings/CommandBuildingDefinition.tres | 1 + buildings/GasBurnerBuildingDefinition.tres | 3 +- buildings/OutpostBuildingDefinition.tres | 1 + buildings/PlanningBuildingDefinition.tres | 3 +- buildings/ShipLabBuildingDefinition.tres | 3 +- buildings/TransportHubBuildingDefinition.tres | 3 +- buildings/WorkshopBuildingDefinition.tres | 3 +- controllers/BuildController.gd | 58 ++-- core/GameState.gd | 75 +++++- tests/test_build_info_popup.gd | 4 + tests/test_extractor_building.gd | 2 +- tests/test_hub_capacity.gd | 2 + tests/test_mothership_replacement.gd | 4 +- tests/test_payments_use_fleet_hold.gd | 6 +- tests/test_planet_conquest_gate.gd | 2 +- tests/test_progressive_costs.gd | 247 ++++++++++++++++++ tests/test_progressive_costs.gd.uid | 1 + tests/test_resources_and_buildings.gd | 6 +- tests/test_spacial_workshop.gd | 3 + tests/test_transport_hub_building.gd | 7 +- ui/PriceTag.gd | 37 +++ 22 files changed, 435 insertions(+), 46 deletions(-) create mode 100644 tests/test_progressive_costs.gd create mode 100644 tests/test_progressive_costs.gd.uid diff --git a/buildings/BuildingDefinition.gd b/buildings/BuildingDefinition.gd index 00fb1ae..ca4f598 100644 --- a/buildings/BuildingDefinition.gd +++ b/buildings/BuildingDefinition.gd @@ -4,7 +4,17 @@ extends Resource @export var definition_id: String @export var display_name: String @export_multiline var description: String +# What it costs in ORE, and the whole price when it wants more than ore. +# +# #138: structures all charged rocks and nothing else, whatever they were. +# The dictionary is the same shape VesselDefinition has used since gas +# bombers and laser ships (build_cost / build_resource_costs): when it is +# empty the price is `cost` in ore, and when it is set it IS the price, ore +# included - and its ore entry must agree with `cost`, which the test pins, +# because repair costs are quoted from `cost` alone (a repair drone carries +# ore and nothing else). @export var cost: int +@export var build_resource_costs: Dictionary = {} @export var action_icon: int @export var menu_icon: int @export var visual_scene: PackedScene diff --git a/buildings/CommandBuildingDefinition.tres b/buildings/CommandBuildingDefinition.tres index e24d24c..956bca0 100644 --- a/buildings/CommandBuildingDefinition.tres +++ b/buildings/CommandBuildingDefinition.tres @@ -7,6 +7,7 @@ definition_id = "command_center" display_name = "Command Center" description = "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it." cost = 15 +build_resource_costs = {"ore": 15, "gas": 6} action_icon = 10 menu_icon = 10 visual_scene = ExtResource("2") diff --git a/buildings/GasBurnerBuildingDefinition.tres b/buildings/GasBurnerBuildingDefinition.tres index 4f2fd22..6cf34ef 100644 --- a/buildings/GasBurnerBuildingDefinition.tres +++ b/buildings/GasBurnerBuildingDefinition.tres @@ -20,7 +20,8 @@ script = ExtResource("1") definition_id = "gas_burner" display_name = "Gas Burner" description = "Burns gas into energy at its own site. Hub transporters keep it stocked with gas and collect the energy it makes." -cost = 30 +cost = 40 +build_resource_costs = {"ore": 40, "gas": 22} action_icon = 33 menu_icon = 33 visual_scene = ExtResource("2") diff --git a/buildings/OutpostBuildingDefinition.tres b/buildings/OutpostBuildingDefinition.tres index 18d29e1..ccde7a9 100644 --- a/buildings/OutpostBuildingDefinition.tres +++ b/buildings/OutpostBuildingDefinition.tres @@ -7,6 +7,7 @@ definition_id = "outpost" display_name = "Outpost" description = "A forward position for the fleet to garrison. Vessels stationed here hold station and fight what comes at them, without you flying back." cost = 30 +build_resource_costs = {"ore": 30, "gas": 16} action_icon = 40 menu_icon = 40 visual_scene = ExtResource("2") diff --git a/buildings/PlanningBuildingDefinition.tres b/buildings/PlanningBuildingDefinition.tres index 77552af..0136217 100644 --- a/buildings/PlanningBuildingDefinition.tres +++ b/buildings/PlanningBuildingDefinition.tres @@ -6,7 +6,8 @@ script = ExtResource("1") definition_id = "planning_center" display_name = "Planning Center" description = "Holds standing orders for the whole game: say how many of each vessel should always exist, and the ship crafters are kept working until they do." -cost = 70 +cost = 50 +build_resource_costs = {"ore": 50, "gas": 30} action_icon = 41 menu_icon = 41 visual_scene = ExtResource("2") diff --git a/buildings/ShipLabBuildingDefinition.tres b/buildings/ShipLabBuildingDefinition.tres index 16e2547..5d5e16a 100644 --- a/buildings/ShipLabBuildingDefinition.tres +++ b/buildings/ShipLabBuildingDefinition.tres @@ -6,7 +6,8 @@ script = ExtResource("1") definition_id = "ship_lab" display_name = "Ship Lab" description = "Fly the fleet onto it to upgrade each vessel type and unlock new vessel designs." -cost = 35 +cost = 30 +build_resource_costs = {"ore": 30, "gas": 18} action_icon = 13 menu_icon = 13 visual_scene = ExtResource("2") diff --git a/buildings/TransportHubBuildingDefinition.tres b/buildings/TransportHubBuildingDefinition.tres index 2f79a86..562b569 100644 --- a/buildings/TransportHubBuildingDefinition.tres +++ b/buildings/TransportHubBuildingDefinition.tres @@ -6,7 +6,8 @@ script = ExtResource("1") definition_id = "transport_hub" display_name = "Transport Hub" description = "Stations transport vessels. They collect extracted resources and supply buildings that are waiting on materials." -cost = 30 +cost = 28 +build_resource_costs = {"ore": 28, "gas": 20} action_icon = 12 menu_icon = 12 visual_scene = ExtResource("2") diff --git a/buildings/WorkshopBuildingDefinition.tres b/buildings/WorkshopBuildingDefinition.tres index 2f781d1..347fc46 100644 --- a/buildings/WorkshopBuildingDefinition.tres +++ b/buildings/WorkshopBuildingDefinition.tres @@ -6,7 +6,8 @@ script = ExtResource("1") definition_id = "spacial_workshop" display_name = "Spacial Workshop" description = "Sends repair drones out to damaged structures. Repairs cost ore in proportion to the damage, delivered here by hub transporters." -cost = 55 +cost = 50 +build_resource_costs = {"ore": 50, "gas": 28, "energy": 18} action_icon = 42 menu_icon = 42 visual_scene = ExtResource("2") diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 539407e..1b36ec5 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -352,7 +352,7 @@ func _configure_build_menu_entries() -> void: button.icon = definition.menu_icon button.pressed.connect(_show_building_info.bind(String(definition.definition_id))) label.text = definition.display_name - cost_tag.show_price({GameState.RESOURCE_ORE: int(definition.cost)}) + cost_tag.show_price(GameState.building_costs(String(definition.definition_id))) build_menu_entries.append([definition.definition_id, button, label, cost_tag, definition]) @@ -820,6 +820,11 @@ func _refresh_build_status() -> void: func _refresh_build_option_states() -> void: for entry in build_menu_entries: var definition: Resource = entry[4] + # #138: a price now names several resources, and what the player + # holds of each is on the tag - so it is re-stated here, live, the + # way the craft menu's already was. Re-stating the same price is + # cheap; PriceTag only rebuilds when the price itself changes. + entry[3].show_price(GameState.building_costs(String(definition.definition_id))) var enabled: bool = GameState.is_building_unlocked(definition.definition_id) if definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() @@ -893,16 +898,21 @@ func _refresh_planning() -> void: planning_status.text = "Short: %s" % ", ".join(short) +# The ORE half of a price, which is all some callers want (the info popup's +# headline, the tests). costs_of() below is the whole of it. func cost_of(type: String) -> int: - var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) - return int(definition.cost) if definition != null else 0 + return int(costs_of(type).get(GameState.RESOURCE_ORE, 0)) + + +func costs_of(type: String) -> Dictionary: + return GameState.building_costs(type) func _show_building_info(type: String) -> void: var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) if definition == null: return - var cost: int = int(definition.cost) + var costs: Dictionary = GameState.building_costs(type) var lines: Array = [definition.description] var can_build: bool = true @@ -933,7 +943,7 @@ func _show_building_info(type: String) -> void: # The same pooled test the build itself uses. Asking the bank here while # charging bank-plus-hold below would hide the build button for something # the player can in fact afford. - if can_build and not GameState.can_afford(cost): + if can_build and not GameState.can_afford_resources(costs): # #137: no sentence about the shortfall. The price tag below carries # what is held over what is wanted, in red - saying it twice, in two # different shapes, is what the ticket is about. @@ -941,7 +951,7 @@ func _show_building_info(type: String) -> void: info_title.text = definition.display_name info_message.text = "\n\n".join(lines) - info_cost.show_price({GameState.RESOURCE_ORE: cost}) + info_cost.show_price(costs) info_build_button.visible = can_build pending_build_type = type if can_build else "" info_popup.visible = true @@ -983,30 +993,44 @@ func _confirm_build() -> void: # glyph/HUD rendering the moment it produced anything. build_status.text = "%s has nothing to extract" % planet.planet_name return - if not GameState.spend(definition.cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.available_ore()] + if not GameState.spend_resources(GameState.building_costs(type)): + build_status.text = _cannot_afford(type) return GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id) build_menu.visible = false return if definition.unique and GameState.has_building_of_type(type): - if not GameState.can_afford(definition.cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [ - definition.cost, GameState.available_ore() - ] + if not GameState.can_afford_resources(GameState.building_costs(type)): + build_status.text = _cannot_afford(type) return # Only now is the old one destroyed - never before the new one is # known to be affordable, or the player would be left with none. GameState.remove_buildings_of_type(type) building_field.rebuild_all() - _try_place(type, cost_of(type)) + _try_place(type) -func _try_place(type: String, cost: int) -> void: - if not GameState.spend(cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.available_ore()] +# #138: a structure can want more than rocks, so the refusal has to name +# which of them is short rather than always saying "rocks". Built from the +# price itself, so a cost gaining a resource needs nothing here. +func _cannot_afford(type: String) -> String: + var missing: Array = [] + for resource_type in GameState.building_costs(type): + var id: String = String(resource_type) + var want: int = int(GameState.building_costs(type)[resource_type]) + var have: int = GameState.available_resource(id) + if have < want: + missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have]) + if missing.is_empty(): + return "" + return "Not enough %s" % " and ".join(missing) + + +func _try_place(type: String) -> void: + if not GameState.spend_resources(GameState.building_costs(type)): + build_status.text = _cannot_afford(type) return building_field.place(type, fleet.get_centroid()) build_menu.visible = false @@ -1050,7 +1074,7 @@ func _refresh_skill_menu(id: String = "") -> void: # now entries on the tag, held-over-needed, in red when short. One place, # and it says WHICH one is missing without a sentence per case. skill_detail.text = "%s\n%s" % [node.name, node.desc] - skill_cost.show_price({GameState.RESOURCE_ORE: int(node.cost)}, points_needed) + skill_cost.show_price(GameState.skill_costs(id), points_needed) if GameState.is_unlocked(id): skill_status.text = "Researched" diff --git a/core/GameState.gd b/core/GameState.gd index 9b86b16..9394e87 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -330,9 +330,12 @@ func transfer_gas_to_building(id: int, amount: int) -> int: func transfer_gas_to_fleet(id: int, amount: int) -> int: return transfer_resource_to_fleet(id, RESOURCE_GAS, amount) -const MOTHERSHIP_COST: int = 20 -const SHIP_CRAFTER_COST: int = 12 const SHIP_COST: int = 6 +# #138: the per-building cost constants that used to sit here are gone. A +# structure's price lives in its own BuildingDefinition and is read through +# building_costs(), which is the only thing that knows about the resources +# beyond ore that some of them now want. Two places holding the same number +# is how they end up disagreeing. # --- Buildings ------------------------------------------------------------- @@ -361,11 +364,6 @@ const BUILDING_SHIP_LAB: String = "ship_lab" # inventory exactly like every other building (#54). const BUILDING_GAS_BURNER: String = "gas_burner" -const COMMAND_CENTER_COST: int = 15 -const EXTRACTOR_COST: int = 25 -const TRANSPORT_HUB_COST: int = 30 -const SHIP_LAB_COST: int = 35 -const GAS_BURNER_COST: int = 30 var buildings: Array = [] @@ -411,6 +409,7 @@ const SKILL_TREE: Array = [ # attacking your structures is available at about the time the waves # start being able to hurt them. "id": "forward_bases", "name": "Forward Bases", "cost": 30, "requires": "coordination", + "costs": {"ore": 30, "gas": 12}, "x": 0.66, "unlocks": BUILDING_OUTPOST, "research_points": 1, "desc": "Build outposts: forward positions vessels can be stationed at, to hold ground you are not standing on.", }, @@ -419,6 +418,7 @@ const SKILL_TREE: Array = [ # applies to outposts already standing, not only to new ones, so # researching it never means demolishing and rebuilding. "id": "garrison_bays", "name": "Garrison Bays", "cost": 110, "requires": "forward_bases", + "costs": {"ore": 110, "gas": 35}, "x": 0.66, "outpost_bonus": 1, "research_points": 2, "icon": 40, "desc": "Permanent: +1 berth at every outpost, including ones already built.", @@ -429,6 +429,7 @@ const SKILL_TREE: Array = [ # hand has become work, and it has shipyards behind it because it # manages what shipyards do. "id": "logistics_command", "name": "Logistics Command", "cost": 140, + "costs": {"ore": 140, "gas": 40}, "requires": "vessel_science", "x": 0.94, "unlocks": BUILDING_PLANNING_CENTER, "research_points": 3, "desc": "Build a planning center: set how many of each vessel should always exist, and the crafters are kept working until they do.", @@ -438,6 +439,7 @@ const SKILL_TREE: Array = [ # is infrastructure that looks after infrastructure, and it should # arrive after the player has some worth looking after. "id": "field_engineering", "name": "Field Engineering", "cost": 95, + "costs": {"ore": 95, "gas": 35, "energy": 20}, "requires": "power_systems", "x": 0.34, "unlocks": BUILDING_WORKSHOP, "research_points": 2, "desc": "Build spacial workshops. Their drones fly out and repair damaged structures, spending ore delivered to the workshop.", @@ -446,28 +448,33 @@ const SKILL_TREE: Array = [ # Same shape as freight_yards and garrison_bays: a permanent bonus # that reaches workshops already standing. "id": "drone_bays", "name": "Drone Bays", "cost": 150, + "costs": {"ore": 150, "gas": 50, "energy": 35}, "requires": "field_engineering", "x": 0.34, "workshop_bonus": 1, "research_points": 2, "icon": 43, "desc": "Permanent: +1 repair drone at every spacial workshop, including ones already built.", }, { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", + "costs": {"ore": 40, "gas": 15}, "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1, "desc": "Build transport hubs. Each hub includes its own automatic transporters.", }, { "id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards", + "costs": {"ore": 35, "gas": 10}, "x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1, "desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.", }, { "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", + "costs": {"ore": 80, "gas": 30}, "x": 0.18, "hub_bonus": 1, "research_points": 2, "icon": 12, "desc": "Permanent: +1 transporter stationed at every hub, including ones already built.", }, { "id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight", + "costs": {"ore": 45, "gas": 35}, "x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2, "desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.", }, @@ -490,12 +497,14 @@ const SKILL_TREE: Array = [ # someone else's lane. # Also cut (70 ore / 2 points), for the same reason as wing. "id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing", + "costs": {"ore": 35, "gas": 12}, "x": 0.82, "fleet_bonus": 1, "research_points": 1, "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { "id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron", + "costs": {"ore": 90, "gas": 30, "energy": 15}, "x": 0.82, "fleet_bonus": 1, "research_points": 2, "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", @@ -506,12 +515,14 @@ const SKILL_TREE: Array = [ # that only ever does one thing; two larger, costlier steps carry the # ceiling from 6 to 10 while keeping the branch a readable length. "id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada", + "costs": {"ore": 180, "gas": 60, "energy": 40}, "x": 0.82, "fleet_bonus": 2, "research_points": 4, "icon": 2, "desc": "Permanent: +2 vessels in the fleet.", }, { "id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup", + "costs": {"ore": 320, "gas": 110, "energy": 80, "dark_matter": 20}, "x": 0.82, "fleet_bonus": 2, "research_points": 6, "icon": 2, "desc": "Permanent: +2 vessels in the fleet.", @@ -570,9 +581,26 @@ func prerequisite_met(id: String) -> bool: return node.requires == "" or is_unlocked(node.requires) +# What one doctrine costs. Same rule as buildings and vessels (#138): the +# node's own dictionary when it has one, its ore figure otherwise. +# +# `cost` stays the ore figure either way rather than being folded into the +# dictionary and deleted, so the tree's own display - and everything that +# 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(): + return {} + var costs: Dictionary = node.get("costs", {}) + if not costs.is_empty(): + return costs.duplicate() + return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {} + + func can_unlock(id: String) -> bool: var node: Dictionary = skill_node(id) - return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \ + return not is_unlocked(id) and prerequisite_met(id) \ + and can_afford_resources(skill_costs(id)) \ and research_points >= skill_research_point_cost(id) @@ -584,7 +612,7 @@ func unlock(id: String) -> bool: if not can_unlock(id): return false var node: Dictionary = skill_node(id) - if not spend(int(node.cost)): + if not spend_resources(skill_costs(id)): return false var points_needed: int = skill_research_point_cost(id) if points_needed > 0: @@ -621,13 +649,24 @@ func is_building_unlocked(type: String) -> bool: return true -func research_for_building(type: String) -> String: +# The doctrine that unlocks a structure, BY ID - the handle everything else +# in the tree is keyed on (skill_depth, skill_costs, is_unlocked). +func research_node_for_building(type: String) -> String: for node in SKILL_TREE: if node.get("unlocks", "") == type: - return node.name + return String(node.id) return "" +# The same thing by NAME, which is what a message shows the player. Kept +# apart from the id above because the two are easy to mix up and the mistake +# is silent: skill_depth() of a display name finds no node and answers 0, so +# a deep structure reads as an ungated one. +func research_for_building(type: String) -> String: + var id: String = research_node_for_building(type) + return "" if id == "" else String(skill_node(id).name) + + func max_fleet_size() -> int: if intro_fleet_size > 0: return mini(intro_fleet_size, DEFAULT_FORMATION.size()) @@ -770,6 +809,20 @@ func find_building(id: int) -> Dictionary: # node would heal itself the moment the player landed - the same reason the # buildings themselves live here. +# What one structure costs, in every resource it wants. +# +# #138: the same shape product_costs() already answers for vessels - the +# dictionary when a definition carries one, the ore figure otherwise - so +# 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: + return {} + if not definition.build_resource_costs.is_empty(): + return definition.build_resource_costs.duplicate() + return {RESOURCE_ORE: int(definition.cost)} if int(definition.cost) > 0 else {} + + func building_definition(type: String) -> Resource: return BUILDING_DEFINITIONS.get_definition(type) diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index f6c7948..43988be 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -39,6 +39,10 @@ const OPTIONS: Array = [ func _initialize() -> void: game_state = root.get_node("GameState") game_state.rocks = 500 + # #138: some structures want gas as well as rocks, and the popup + # only offers a build button for something the player can pay for. + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500) + game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 500) game_state.buildings.clear() game_state.extractors.clear() # Structures are gated behind the mothership's research tree. That gate diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 469aeb2..0cf3223 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -147,7 +147,7 @@ func _process(delta: float) -> bool: controller._confirm_build() var built: bool = game_state.has_extractor_at(planet2.planet_id) - var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST + var charged: bool = game_state.rocks == rocks_before - int(game_state.building_costs(GameState.BUILDING_EXTRACTOR)[GameState.RESOURCE_ORE]) print("over planet '%s': enabled=%s built=%s rocks %d->%d charged=%s" % [ planet2.planet_name, button.enabled, built, rocks_before, game_state.rocks, charged ]) diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index d9005de..0f62d24 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -12,6 +12,8 @@ func _initialize() -> void: state.transporters.clear() state.reset_progression() state.rocks = 900 + # #138: doctrines past the first tier want gas as well as rocks. + state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 900) state.research_points = state.skill_research_point_cost("freight_yards") state.unlocked = ["shipyards", "logistics", "freight"] diff --git a/tests/test_mothership_replacement.gd b/tests/test_mothership_replacement.gd index 2fb9cdd..fd264d7 100644 --- a/tests/test_mothership_replacement.gd +++ b/tests/test_mothership_replacement.gd @@ -111,7 +111,7 @@ func _process(delta: float) -> bool: controller._confirm_build() var only_one: bool = _count_motherships() == 1 var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0 - var charged: bool = game_state.rocks == rocks_before - GameState.MOTHERSHIP_COST + var charged: bool = game_state.rocks == rocks_before - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) print("confirmed: motherships=%d moved=%s rocks %d->%d charged=%s" % [ _count_motherships(), moved, rocks_before, game_state.rocks, charged ]) @@ -121,7 +121,7 @@ func _process(delta: float) -> bool: return true # --- unaffordable replacement must not destroy the old one --- - game_state.rocks = GameState.MOTHERSHIP_COST - 1 + game_state.rocks = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) - 1 _place_fleet(Vector2(2500.0, -800.0)) controller._on_build_pressed() controller._show_building_info(GameState.BUILDING_MOTHERSHIP) diff --git a/tests/test_payments_use_fleet_hold.gd b/tests/test_payments_use_fleet_hold.gd index d3165fb..033987d 100644 --- a/tests/test_payments_use_fleet_hold.gd +++ b/tests/test_payments_use_fleet_hold.gd @@ -38,7 +38,7 @@ func _initialize() -> void: game_state.buildings.clear() game_state.extractors.clear() - var cost: int = GameState.MOTHERSHIP_COST + var cost: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) # --- A. the deadlock, exactly as a fresh game presents it --------------- game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) @@ -261,9 +261,9 @@ func _process(delta: float) -> bool: # cost left the pool - and that it left, since the bank was empty # and could not have paid. var total: int = game_state.available_ore() - if total != HOLD_ORE - GameState.MOTHERSHIP_COST: + if total != HOLD_ORE - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]): return _fail("the mothership was placed but %d ore remains, expected %d" \ - % [total, HOLD_ORE - GameState.MOTHERSHIP_COST]) + % [total, HOLD_ORE - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])]) print("built a mothership with an empty bank, paid out of the fleet hold") print("TEST_PAYMENTS_USE_FLEET_HOLD: PASS") diff --git a/tests/test_planet_conquest_gate.gd b/tests/test_planet_conquest_gate.gd index c26a049..a9f864b 100644 --- a/tests/test_planet_conquest_gate.gd +++ b/tests/test_planet_conquest_gate.gd @@ -146,7 +146,7 @@ func _process(delta: float) -> bool: return _fail("conquered planet did not offer a build") controller._confirm_build() var built: bool = game_state.has_extractor_at(planet.planet_id) - var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST + var charged: bool = game_state.rocks == rocks_before - int(game_state.building_costs(GameState.BUILDING_EXTRACTOR)[GameState.RESOURCE_ORE]) if not built or not charged: return _fail("conquered planet did not build/charge correctly") diff --git a/tests/test_progressive_costs.gd b/tests/test_progressive_costs.gd new file mode 100644 index 0000000..9b19562 --- /dev/null +++ b/tests/test_progressive_costs.gd @@ -0,0 +1,247 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_progressive_costs.gd +# Forgejo #138: "all buildings only cost rocks, you can introduce other +# resources for some buildings, make the cost meaningful depending on how far +# they are in the skill tree. same for skills." +# +# "Meaningful depending on how far they are" is a property of the whole +# table, not of any one entry, so that is what is checked here - and the +# checks are properties rather than transcribed numbers, so rebalancing a +# price does not mean editing this file, while breaking the shape does. +# +# The dangerous half is REACHABILITY. Energy exists only where a gas burner +# has been built, and a gas burner needs Power Systems - so anything at or +# before that depth which asked for energy would be a wall the player has no +# way through. Dark matter is rarer still: black hole worlds. The rule below +# is the one that keeps a price from being a dead end, and it is the reason +# the extractor stays ore-only even though its depth would allow gas. +# +# Covers: +# A. more than one resource is actually in play - the ticket's whole point +# B. no price asks for something unreachable at its own depth +# C. prices grow with depth, for buildings and for doctrines alike +# D. the ore figure and the price dictionary agree, because repairs are +# quoted from the ore figure alone +# E. nothing is free, and nothing charges a resource that does not exist +# F. the quest chain stays payable: no quest asks for a structure whose +# price needs a resource the chain has not reached yet + +# Quests.gd is plain data with no autoload in it, so preloading it here is +# safe where preloading PriceTag or ResourceBar would not be. +const QUESTS = preload("res://core/Quests.gd") + +var game_state: Node + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_PROGRESSIVE_COSTS: FAIL (%s)" % message) + quit(1) + return true + + +# The depth at which each resource first becomes gettable. +# +# ore from the asteroids the game opens on +# gas from a gas giant, which needs no research at all - only flying +# energy from a gas burner, unlocked by Power Systems, so it exists only +# BELOW that node rather than at it +# dark matter from a black hole world: no research either, but the rarest +# thing in the sky, so it is reserved for the deepest tier +func _first_available_depth(resource_type: String) -> int: + match resource_type: + GameState.RESOURCE_ORE, GameState.RESOURCE_GAS: + return 0 + GameState.RESOURCE_ENERGY: + return game_state.skill_depth("power_systems") + 1 + GameState.RESOURCE_DARK_MATTER: + return 4 + return 99 + + +# How deep in the tree a structure sits: the depth of the node that unlocks +# it, or 0 for one that is never gated. +func _building_depth(type: String) -> int: + # The ID, not the display name: skill_depth() of a name silently answers + # 0, which would let an unreachable price through as "ungated". + var gate: String = String(game_state.research_node_for_building(type)) + return 0 if gate == "" else game_state.skill_depth(gate) + + +func _total(costs: Dictionary) -> int: + var sum: int = 0 + for resource_type in costs: + sum += int(costs[resource_type]) + return sum + + +func _check_reachable(what: String, depth: int, costs: Dictionary) -> String: + for resource_type in costs: + var id: String = String(resource_type) + if not (id in game_state.resource_types()): + return "%s is priced in \"%s\", which is not a resource" % [what, id] + var need_depth: int = _first_available_depth(id) + if depth < need_depth: + return "%s sits at depth %d and asks for %s, which nothing produces before depth %d" \ + % [what, depth, game_state.resource_name(id), need_depth] + return "" + + +# Average price per depth, and it must go up. Averaged rather than compared +# entry-by-entry: a tier holds several things of deliberately different +# weight - a doctrine that adds one vessel beside one that unlocks a whole +# branch - and demanding every one of them out-price every shallower one +# would be demanding a flat tree. +func _rising(label: String, totals: Dictionary) -> String: + var depths: Array = totals.keys() + depths.sort() + var previous: float = -1.0 + var trace: PackedStringArray = PackedStringArray() + for depth in depths: + var here: Array = totals[depth] + var mean: float = 0.0 + for value in here: + mean += float(value) + mean /= float(here.size()) + trace.append("d%d=%.0f" % [depth, mean]) + if mean <= previous: + return "%s prices do not rise with depth: %s" % [label, " ".join(trace)] + previous = mean + print(" %s: %s" % [label, " ".join(trace)]) + return "" + + +func _process(_delta: float) -> bool: + # --- buildings ------------------------------------------------------- + var building_totals: Dictionary = {} + var multi_resource: int = 0 + var kinds: Dictionary = {} + for definition in BuildingDefinitions.all(): + var type: String = String(definition.definition_id) + var costs: Dictionary = game_state.building_costs(type) + var depth: int = _building_depth(type) + + # --- E. nothing free ------------------------------------------ + if costs.is_empty() or _total(costs) <= 0: + return _fail("%s costs nothing" % type) + # --- D. the ore figure and the dictionary agree ---------------- + if int(costs.get(GameState.RESOURCE_ORE, 0)) != int(definition.cost): + return _fail("%s is priced at %d ore but its cost field says %d - repairs quote the field" \ + % [type, int(costs.get(GameState.RESOURCE_ORE, 0)), int(definition.cost)]) + # --- B. reachable --------------------------------------------- + var problem: String = _check_reachable(type, depth, costs) + if problem != "": + return _fail(problem) + + if costs.size() > 1: + multi_resource += 1 + for resource_type in costs: + kinds[String(resource_type)] = true + if not building_totals.has(depth): + building_totals[depth] = [] + building_totals[depth].append(_total(costs)) + + # --- A. the ticket's point ------------------------------------------ + if multi_resource < 3: + return _fail("only %d structures cost anything but rocks" % multi_resource) + if kinds.size() < 3: + return _fail("structures between them use only %d kinds of resource" % kinds.size()) + print("%d of %d structures cost more than rocks, across %d resources" \ + % [multi_resource, BuildingDefinitions.all().size(), kinds.size()]) + + # --- C. rising with depth ------------------------------------------- + var rising: String = _rising("structure", building_totals) + if rising != "": + return _fail(rising) + + # --- doctrines ------------------------------------------------------- + var skill_totals: Dictionary = {} + var multi_skills: int = 0 + for node in GameState.SKILL_TREE: + var id: String = String(node.id) + var costs: Dictionary = game_state.skill_costs(id) + var depth: int = game_state.skill_depth(id) + if costs.is_empty() or _total(costs) <= 0: + return _fail("doctrine %s costs nothing" % id) + if int(costs.get(GameState.RESOURCE_ORE, 0)) != int(node.cost): + return _fail("doctrine %s is priced at %d ore but its cost field says %d" \ + % [id, int(costs.get(GameState.RESOURCE_ORE, 0)), int(node.cost)]) + var problem2: String = _check_reachable("doctrine %s" % id, depth, costs) + if problem2 != "": + return _fail(problem2) + if costs.size() > 1: + multi_skills += 1 + if not skill_totals.has(depth): + skill_totals[depth] = [] + skill_totals[depth].append(_total(costs)) + if multi_skills < 5: + return _fail("only %d doctrines cost anything but rocks" % multi_skills) + print("%d of %d doctrines cost more than rocks" % [multi_skills, GameState.SKILL_TREE.size()]) + var rising2: String = _rising("doctrine", skill_totals) + if rising2 != "": + return _fail(rising2) + + # --- the money really changes hands ---------------------------------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500) + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500) + game_state.unlocked = ["shipyards", "logistics", "coordination"] + game_state.research_points = 9 + var gas_before: int = game_state.available_resource(GameState.RESOURCE_GAS) + if not game_state.unlock("freight"): + return _fail("could not research a doctrine with everything in the bank") + var gas_spent: int = gas_before - game_state.available_resource(GameState.RESOURCE_GAS) + if gas_spent != int(game_state.skill_costs("freight")[GameState.RESOURCE_GAS]): + return _fail("researching Freight Handling took %d gas, its price is %d" \ + % [gas_spent, int(game_state.skill_costs("freight")[GameState.RESOURCE_GAS])]) + print("researching Freight Handling really took %d gas as well as its ore" % gas_spent) + + # ...and a doctrine the player cannot pay the gas for is refused, even + # with rocks to spare. This is the case that would have passed silently + # before, when only the ore was ever checked. + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 0) + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5000) + if game_state.can_unlock("power_systems"): + return _fail("Power Systems is buyable with no gas at all") + var ore_before: int = game_state.available_resource(GameState.RESOURCE_ORE) + if game_state.unlock("power_systems"): + return _fail("Power Systems was bought with no gas at all") + if game_state.available_resource(GameState.RESOURCE_ORE) != ore_before: + return _fail("a refused purchase still took %d ore" \ + % (ore_before - game_state.available_resource(GameState.RESOURCE_ORE))) + print("a doctrine short of gas is refused, and nothing is taken for it") + + # --- F. the quest chain stays payable -------------------------------- + # Quests.gd walks the player through the game in its own order, which is + # not the tree's. A quest asking for a structure priced in something the + # chain has not sent them for yet is the same dead end as an unreachable + # depth, in a different coordinate system. + var reached: Dictionary = {GameState.RESOURCE_ORE: true} + for quest in QUESTS.CHAIN: + var goal: Dictionary = quest.goal + if String(goal.kind) == QUESTS.GOAL_BUILDING: + for resource_type in game_state.building_costs(String(goal.building)): + if not reached.has(String(resource_type)): + return _fail("quest \"%s\" asks for a %s, priced in %s, before the chain has sent anyone for any" \ + % [quest.id, goal.building, game_state.resource_name(String(resource_type))]) + if String(goal.kind) == QUESTS.GOAL_RESOURCE: + reached[String(goal.resource)] = true + # Building the gas burner is what puts energy in reach. + if String(goal.kind) == QUESTS.GOAL_BUILDING \ + and String(goal.building) == GameState.BUILDING_GAS_BURNER: + reached[GameState.RESOURCE_ENERGY] = true + print("the quest chain never asks for a structure it has not funded") + + print("TEST_PROGRESSIVE_COSTS: PASS") + quit(0) + return true diff --git a/tests/test_progressive_costs.gd.uid b/tests/test_progressive_costs.gd.uid new file mode 100644 index 0000000..666bb14 --- /dev/null +++ b/tests/test_progressive_costs.gd.uid @@ -0,0 +1 @@ +uid://bq6d3l4hxfmkb diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index 81a9c9b..33324b4 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -84,11 +84,11 @@ func _process(delta: float) -> bool: ship.position = mothership_pos fleet.move_to(mothership_pos) field.place(GameState.BUILDING_MOTHERSHIP, mothership_pos) - game_state.spend(GameState.MOTHERSHIP_COST) + game_state.spend(int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])) field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(1400.0, 300.0)) - game_state.spend(GameState.SHIP_CRAFTER_COST) + game_state.spend(int(game_state.building_costs(GameState.BUILDING_SHIP_CRAFTER)[GameState.RESOURCE_ORE])) - var paid: bool = game_state.rocks == before - GameState.MOTHERSHIP_COST - GameState.SHIP_CRAFTER_COST + var paid: bool = game_state.rocks == before - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) - int(game_state.building_costs(GameState.BUILDING_SHIP_CRAFTER)[GameState.RESOURCE_ORE]) var stored: bool = game_state.buildings.size() == 2 print("buildings=%d rocks %d->%d paid=%s" % [ game_state.buildings.size(), before, game_state.rocks, paid diff --git a/tests/test_spacial_workshop.gd b/tests/test_spacial_workshop.gd index 00eccca..3d173b8 100644 --- a/tests/test_spacial_workshop.gd +++ b/tests/test_spacial_workshop.gd @@ -188,6 +188,9 @@ func _process(_delta: float) -> bool: return _fail("no skill node raises the drone count") var before_count: int = _drones_of(workshop_id).size() game_state.rocks = 5000 + # #138: the deep doctrines want gas and energy too. + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 5000) + game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 5000) game_state.research_points = 20 if not game_state.unlock(bonus_id): return _fail("could not research \"%s\"" % bonus_id) diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index 503f400..740299d 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -84,16 +84,17 @@ func _process(delta: float) -> bool: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate) quit(1) return true + var hub_cost: int = int(game_state.building_costs(GameState.BUILDING_TRANSPORT_HUB)[GameState.RESOURCE_ORE]) var before: int = game_state.rocks field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB)) - if game_state.rocks != before - GameState.TRANSPORT_HUB_COST: + if game_state.rocks != before - hub_cost: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [ - before - game_state.rocks, GameState.TRANSPORT_HUB_COST + before - game_state.rocks, hub_cost ]) quit(1) return true - print("researched and placed for %d ore" % GameState.TRANSPORT_HUB_COST) + print("researched and placed for %d ore" % hub_cost) elapsed = 0.0 stage = 1 return false diff --git a/ui/PriceTag.gd b/ui/PriceTag.gd index 7b4927a..64856be 100644 --- a/ui/PriceTag.gd +++ b/ui/PriceTag.gd @@ -47,6 +47,13 @@ const POINT_MARK: String = "RP" @export var show_available: bool = true var _entries: Array = [] +# The price currently on show. Kept so a caller that re-states the same price +# every frame - the craft menu does, and since #138 the build menu does too - +# refreshes the HELD figures instead of tearing down and rebuilding a dozen +# Labels sixty times a second. +var _shown_costs: Dictionary = {} +var _shown_points: int = 0 +var _built: bool = false func _ready() -> void: @@ -56,6 +63,13 @@ func _ready() -> void: # costs: {resource_type: amount}. points: research points, 0 for none. func show_price(costs: Dictionary, points: int = 0) -> void: + if _built and points == _shown_points and costs == _shown_costs: + # Same price, possibly a different wallet. + _refresh_held() + return + _shown_costs = costs.duplicate() + _shown_points = points + _built = true _clear() for resource_type in GameState.resource_types(): var amount: int = int(costs.get(resource_type, 0)) @@ -75,6 +89,29 @@ func set_costs(costs: Dictionary) -> void: show_price(costs) +# What the player holds, re-read. The price does not move; the wallet does. +func _refresh_held() -> void: + var relayout: bool = false + for entry in _entries: + var have: int = GameState.research_points if String(entry.resource) == "" \ + else GameState.available_resource(String(entry.resource)) + if have == int(entry.have): + continue + entry.have = have + entry.short = have < int(entry.cost) + if entry.have_label == null: + continue + entry.have_label.text = "%d/" % have + entry.have_label.add_theme_color_override("font_color", + SHORT_COLOR if entry.short else HAVE_COLOR) + var width: float = _text_width(entry.have_label, HAVE_FONT_SIZE) + if not is_equal_approx(width, float(entry.have_width)): + entry.have_width = width + relayout = true + if relayout: + _layout() + + func is_free() -> bool: return _entries.is_empty()