extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_fleet_growth.gd # A new game opens with a SINGLE vessel and a single berth, and the fleet # grows by building Fleet Offices - one berth each, each of which has to be # kept supplied. # # Forgejo #219 replaced the fleet-size doctrine chain with this. Researching # a permanent +1 asked nothing of the player afterwards; a berth that has to # be fed makes fleet size a standing cost against their supply lines rather # than a purchase they make once. # # BASE_FLEET_SIZE and STARTING_SHIP_COUNT remain separate constants - the # first is capacity, the second is how many hulls are actually flying - and # this test still pins the distinction. # # Covers: # A. a new game starts with one vessel and one berth, and no research # anywhere in the tree can raise that # B. each supplied office adds exactly one berth, and the cap bites # C. an office that cannot pay loses its vessel, and says so # D. a restocked office goes back to work without being rebuilt # E. the wedge has a place for every slot, however far out it is # F. and nothing caps the fleet at the twenty positions the wedge authors var game_state: Node var scene: Node var fleet: Node var stage: int = 0 var elapsed: float = 0.0 var office_ids: Array = [] func _initialize() -> void: game_state = root.get_node("GameState") game_state.reset_new_game() # #108: reset_new_game() arms the prologue, and the prologue opens with a # convoy rather than the game's own starting fleet. This test is about # what the GAME starts from, which is where the prologue hands over - # see test_intro_scene.gd for the prologue itself. The quest chain goes # with it: its briefings are dialogs, and a dialog stops the tree (#128). game_state.intro_active = false game_state.reset_quests() func _stock(office_id: int, amount: int) -> void: var inventory: RefCounted = game_state.building_inventory(office_id) for resource_type in game_state.resource_types(): inventory.set_amount(String(resource_type), amount) func _process(delta: float) -> bool: elapsed += delta match stage: 0: # --- A. the opening position --------------------------------- if GameState.STARTING_SHIP_COUNT != 1: return _fail("a new game starts with %d vessels, expected 1" % GameState.STARTING_SHIP_COUNT) if GameState.BASE_FLEET_SIZE != 1: return _fail("a new game opens with %d berths, expected the one that needs no office" \ % GameState.BASE_FLEET_SIZE) if game_state.max_fleet_size() != 1: return _fail("an unbuilt fleet caps at %d, expected 1" % game_state.max_fleet_size()) # --- the gate on the office ---------------------------------- # Fleet Offices are researched (Berth Authority), but the research # only unlocks the STRUCTURE - it grants no capacity by itself. if game_state.is_building_unlocked(GameState.BUILDING_FLEET_OFFICE): return _fail("Fleet Offices are buildable with nothing researched") var gate: Dictionary = game_state.skill_node("berth_authority") if gate.is_empty(): return _fail("no skill node unlocks the Fleet Office") # Ore only, deliberately: research points come solely from # conquering a planet, and conquering one needs a fleet. Pricing # the fleet's own gate in them would lock the player at their # opening hull behind a fight that hull has to win first. if int(gate.get("research_points", 0)) != 0: return _fail("Berth Authority costs %d research points - unreachable with one vessel" \ % int(gate.get("research_points", 0))) for cost_resource in game_state.skill_costs("berth_authority"): if String(cost_resource) != GameState.RESOURCE_ORE: return _fail("Berth Authority asks for %s, which needs a built economy first" \ % cost_resource) # The whole tree, researched, must still not add a single berth: # #219 moved fleet SIZE out of research and onto the structure. for node in GameState.SKILL_TREE: if int(node.get("fleet_bonus", 0)) != 0: return _fail("skill node '%s' still grants fleet capacity" % node.id) game_state.unlocked.append(String(node.id)) if not game_state.is_building_unlocked(GameState.BUILDING_FLEET_OFFICE): return _fail("researching Berth Authority did not unlock the Fleet Office") if game_state.max_fleet_size() != 1: return _fail("researching the entire tree raised the fleet cap to %d" \ % game_state.max_fleet_size()) print("a new game: 1 vessel, 1 berth; research unlocks the office, not the berth") # --- B. offices are the berths ------------------------------- for i in range(3): var id: int = game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(400.0 * float(i + 1), 0.0)) office_ids.append(id) _stock(id, 500) if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE + i + 1: return _fail("%d offices gave %d berths, expected %d" % [ i + 1, game_state.max_fleet_size(), GameState.BASE_FLEET_SIZE + i + 1]) print("3 supplied offices -> %d berths" % game_state.max_fleet_size()) # --- E. the wedge has a place for every slot ------------------ # The formation used to be the fleet's real ceiling: it defined # twenty positions and capacity clamped itself to them. It now # continues by rule instead, so what is pinned here is that the # rule keeps the authored shape - the far slots are distinct, # and each pair still steps 30 out and 40 back from the last. var authored: int = GameState.DEFAULT_FORMATION.size() var probe: int = authored * 3 var seen: Dictionary = {} for i in range(probe): var slot: Vector2 = game_state.formation_slot(i) var key: String = "%.1f,%.1f" % [slot.x, slot.y] if seen.has(key): return _fail("slot %d sits on top of slot %d at %s" % [i, int(seen[key]), key]) seen[key] = i for i in range(authored): if not game_state.formation_slot(i).is_equal_approx(GameState.DEFAULT_FORMATION[i]): return _fail("growing the wedge moved authored slot %d to %s" \ % [i, game_state.formation_slot(i)]) for i in range(authored, probe - 2): var here: Vector2 = game_state.formation_slot(i) var behind: Vector2 = game_state.formation_slot(i + 2) var step: Vector2 = Vector2(GameState.FORMATION_PAIR_STEP.x * signf(here.x), GameState.FORMATION_PAIR_STEP.y) if not (behind - here).is_equal_approx(step): return _fail("slot %d steps %s from slot %d, expected %s" \ % [i + 2, behind - here, i, step]) print("the wedge continues past its %d authored slots, %d checked and all distinct" \ % [authored, probe]) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) current_scene = scene fleet = scene.get_node("Fleet") elapsed = 0.0 stage = 1 return false 1: if elapsed < 0.3: return false if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: return _fail("a fresh scene spawned %d vessels, expected %d" \ % [fleet.ships.size(), GameState.STARTING_SHIP_COUNT]) # The berths the offices bought are fillable, and the cap bites # exactly one past them. for i in range(3): if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR): return _fail("could not crew the berth bought by office %d" % (i + 1)) if fleet.ships.size() != 4: return _fail("the fleet holds %d after filling its berths, expected 4" \ % fleet.ships.size()) if fleet.add_ship(GameState.VESSEL_INTERCEPTOR): return _fail("a 5th vessel joined a fleet with 4 berths") print("the fleet fills to %d and refuses a 5th" % fleet.ships.size()) # --- C. an office that cannot pay loses its vessel ------------ # Emptied and its clock wound past due, so the next tick bills it. _stock(office_ids[2], 0) var starved: Dictionary = game_state.find_building(int(office_ids[2])) starved.upkeep_progress = GameState.FLEET_OFFICE_UPKEEP_SECONDS game_state.take_quest_messages() var before: int = fleet.ships.size() game_state._advance_fleet_office_upkeep(0.1) if not bool(starved.get("starved", false)): return _fail("an office with an empty store kept paying") if game_state.max_fleet_size() != 3: return _fail("a starved office still counts: %d berths" % game_state.max_fleet_size()) if game_state.take_quest_messages().is_empty(): return _fail("the fleet lost a vessel to supply and nobody was told") elapsed = 0.0 _lost_before = before stage = 2 return false 2: if elapsed < 0.3: return false if fleet.ships.size() != _lost_before - 1: return _fail("a starved office left %d vessels flying, expected %d" \ % [fleet.ships.size(), _lost_before - 1]) print("a dry office destroyed its vessel and warned the player") # --- D. the berth comes back, and a supplied office keeps it - # The office is idle now that its vessel is gone, so the berth is # offered again on the next tick - an office that could never be # crewed again could never pay again either. var revived: Dictionary = game_state.find_building(int(office_ids[2])) game_state._advance_fleet_office_upkeep(0.1) if bool(revived.get("starved", false)): return _fail("an office with an empty berth stayed disabled") if game_state.max_fleet_size() != 4: return _fail("an emptied office did not offer its berth again") # Restocked and re-crewed, it pays and the vessel stays. _stock(office_ids[2], 500) if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR): return _fail("could not re-crew the berth after restocking") revived.upkeep_progress = GameState.FLEET_OFFICE_UPKEEP_SECONDS game_state._advance_fleet_office_upkeep(0.1) if bool(revived.get("starved", false)): return _fail("a restocked office starved with a full store") if fleet.ships.size() != 4: return _fail("a supplied office lost its vessel anyway") print("restocked and re-crewed: the office pays and the vessel stays") # --- F. and nothing caps the fleet at twenty ------------------ # Offices past the wedge's authored positions buy real berths, # and the fleet really flies them: every hull gets a place of # its own rather than piling onto the last defined slot. var target: int = GameState.DEFAULT_FORMATION.size() + 4 while game_state.max_fleet_size() < target: var extra: int = game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(600.0 * float(game_state.max_fleet_size()), 900.0)) _stock(extra, 500) if game_state.max_fleet_size() != target: return _fail("%d supplied offices gave %d berths, expected %d" \ % [game_state.fleet_offices().size(), game_state.max_fleet_size(), target]) while fleet.ships.size() < target: if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR): return _fail("berth %d could not be crewed" % (fleet.ships.size() + 1)) var places: Dictionary = {} for i in range(fleet.ships.size()): var offset: Vector2 = fleet.formation_offsets[i] var place: String = "%.1f,%.1f" % [offset.x, offset.y] if places.has(place): return _fail("vessel %d flies the same place as vessel %d" \ % [i, int(places[place])]) places[place] = i print("%d berths, %d vessels, %d distinct places - no ceiling at %d" \ % [target, fleet.ships.size(), places.size(), GameState.DEFAULT_FORMATION.size()]) print("TEST_FLEET_GROWTH: PASS") quit(0) return true return false var _lost_before: int = 0 func _fail(message: String) -> bool: print("TEST_FLEET_GROWTH: FAIL (%s)" % message) quit(1) return true