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.
247 lines
11 KiB
GDScript
247 lines
11 KiB
GDScript
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
|