spaceship/tests/test_progressive_costs.gd

247 lines
11 KiB
GDScript3
Raw Normal View History

#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.
2026-08-28 15:30:30 +02:00
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.<instance member> 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