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