#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.
This commit is contained in:
Loïc Wisniewski 2026-08-28 15:30:30 +02:00
commit 1ece202731
22 changed files with 435 additions and 46 deletions

View file

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