diff --git a/buildings/BuildingDefinitions.gd b/buildings/BuildingDefinitions.gd index 5a29724..b2ebfe8 100644 --- a/buildings/BuildingDefinitions.gd +++ b/buildings/BuildingDefinitions.gd @@ -1,7 +1,7 @@ class_name BuildingDefinitions extends RefCounted -const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "planet_scanner", "extractor", "transport_hub", "ship_lab", "gas_burner"] +const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "planet_scanner", "extractor", "transport_hub", "ship_lab", "gas_burner", "fleet_office"] const DEFINITIONS: Dictionary = { "mothership": preload("res://buildings/MothershipBuildingDefinition.tres"), "ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"), @@ -14,6 +14,7 @@ const DEFINITIONS: Dictionary = { "transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"), "ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"), "gas_burner": preload("res://buildings/GasBurnerBuildingDefinition.tres"), + "fleet_office": preload("res://buildings/FleetOfficeBuildingDefinition.tres"), } static func get_definition(definition_id: String) -> Resource: diff --git a/buildings/BuildingVisualBase.gd b/buildings/BuildingVisualBase.gd index 55b4280..c4954d8 100644 --- a/buildings/BuildingVisualBase.gd +++ b/buildings/BuildingVisualBase.gd @@ -35,6 +35,9 @@ const PLANNING_COLOR: Color = Color(0.62, 0.74, 0.92) # #116. Warm steel: the workshop is the only structure whose whole job is # looking after the others, and it should read as a yard rather than a plant. const WORKSHOP_COLOR: Color = Color(0.5, 0.95, 0.8) +# #219: the fleet office. A cool administrative blue, deliberately unlike +# the crafter's amber - one builds hulls, this one pays for them. +const FLEET_OFFICE_COLOR: Color = Color(0.45, 0.78, 1.0) # Workshop: an open service gantry with a cradle under it and drone berths # either side. Deliberately unenclosed - what it holds is other people's @@ -434,6 +437,37 @@ func _draw_command_center() -> void: NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) +func _draw_fleet_office() -> void: + # A berth, and the ledger that keeps it. The frame is the berth - an open + # bay with a hull sitting in it - and the stack of bars beside it is the + # supply the office is spending. Read together they say "this structure + # holds a place in the fleet, and it costs something". + var c: Color = FLEET_OFFICE_COLOR + var lit: Color = Color(0.88, 0.97, 1.0) + + # The berth: three sides, open at the top, so what sits in it reads as + # parked rather than enclosed. + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + Vector2(-52.0, -46.0), Vector2(-52.0, 34.0), + Vector2(18.0, 34.0), Vector2(18.0, -46.0), + ]), c, 0.95, 0.5) + # The hull in it, small and lit: the vessel this office is paying for. + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(-17.0, -22.0), Vector2(-3.0, 14.0), + Vector2(-17.0, 6.0), Vector2(-31.0, 14.0), + ]), lit, 0.9) + + # The ledger: four bars of decreasing length, the running cost. + for i in range(4): + var y: float = -34.0 + float(i) * 20.0 + var length: float = 44.0 - float(i) * 7.0 + NeonDraw.draw_glowing_line(self, Vector2(34.0, y), + Vector2(34.0 + length, y), c, 0.8 - float(i) * 0.12, 0.32) + # The spine they hang off, tying the ledger to the berth. + NeonDraw.draw_glowing_line(self, Vector2(30.0, -42.0), + Vector2(30.0, 34.0), c, 0.7, 0.3) + + func _draw_workshop() -> void: var c: Color = WORKSHOP_COLOR var lit: Color = Color(0.86, 1.0, 0.95) diff --git a/buildings/FleetOfficeBuildingDefinition.tres b/buildings/FleetOfficeBuildingDefinition.tres new file mode 100644 index 0000000..7d32fad --- /dev/null +++ b/buildings/FleetOfficeBuildingDefinition.tres @@ -0,0 +1,39 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" format=3] + +[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://buildings/FleetOfficeBuildingVisual.tscn" id="2"] +[ext_resource type="Script" path="res://buildings/BuildingLogisticsProfile.gd" id="3"] + +[sub_resource type="Resource" id="Resource_office_logistics"] +script = ExtResource("3") +input_buffers = { +"energy": 30, +"gas": 30, +"ore": 60 +} +collected_outputs = PackedStringArray() +conversion_input = "" +conversion_output = "" +conversion_rate = 0.0 +conversion_yield = 1 + +[resource] +script = ExtResource("1") +definition_id = "fleet_office" +display_name = "Fleet Office" +description = "Holds one berth in the fleet and pays for the vessel in it. Hub transporters keep it stocked; an office that runs dry loses its vessel." +cost = 35 +build_resource_costs = { +"ore": 35 +} +action_icon = 2 +menu_icon = 2 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("fleet_office") +logistics = SubResource("Resource_office_logistics") +map_color = Color(0.45, 0.78, 1, 1) +map_sides = 4 +menu_button_name = "FleetOfficeButton" +menu_label_name = "FleetOfficeLabel" +menu_cost_name = "FleetOfficeCost" +max_hp = 45 diff --git a/buildings/FleetOfficeBuildingVisual.gd b/buildings/FleetOfficeBuildingVisual.gd new file mode 100644 index 0000000..5bfa099 --- /dev/null +++ b/buildings/FleetOfficeBuildingVisual.gd @@ -0,0 +1,12 @@ +extends "res://buildings/BuildingVisualBase.gd" + +# Drawn once, like the command centre and the workshop: nothing on this +# structure moves, so there is nothing for #117's idle gate to stop. + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_fleet_office() diff --git a/buildings/FleetOfficeBuildingVisual.tscn b/buildings/FleetOfficeBuildingVisual.tscn new file mode 100644 index 0000000..8370f6d --- /dev/null +++ b/buildings/FleetOfficeBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://buildings/FleetOfficeBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("transfer") diff --git a/buildings/ScannerBuildingVisual.gd.uid b/buildings/ScannerBuildingVisual.gd.uid new file mode 100644 index 0000000..811d257 --- /dev/null +++ b/buildings/ScannerBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://df2ahttimnr5k diff --git a/core/GameState.gd b/core/GameState.gd index e63092d..fb53f26 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -472,6 +472,8 @@ const BUILDING_SHIP_LAB: String = "ship_lab" # Converts gas into energy, both stored/moved through the building's own # inventory exactly like every other building (#54). const BUILDING_GAS_BURNER: String = "gas_burner" +# #219: the structure that pays for a berth in the fleet. +const BUILDING_FLEET_OFFICE: String = "fleet_office" # A map-revealing structure, not a passive sensor bonus. Its menu searches # the same deterministic WorldGen catalogue that PlanetField spawns from. const BUILDING_PLANET_SCANNER: String = "planet_scanner" @@ -597,89 +599,18 @@ const SKILL_TREE: Array = [ "x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2, "desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.", }, - { - # Cheaper than it was (30 ore / 1 point): the fleet now starts at - # BASE_FLEET_SIZE 3 rather than 5, so the first two doctrines are no - # longer a mid-game luxury but the ordinary way out of the opening. - # Pricing them as a luxury made the early game feel stuck. - "id": "wing", "name": "Wing Doctrine", "cost": 15, "requires": "coordination", - "x": 0.82, "fleet_bonus": 1, "research_points": 1, - "icon": 2, - "desc": "Permanent: +1 vessel in the fleet.", - }, - { - # #70: was x=0.50 - the same lane freight's branch already occupies at - # this same depth (power_systems), so the two nodes were drawn - # directly on top of each other. Kept in line with wing (its own - # prerequisite, also x=0.82) instead, so the whole fleet-size - # doctrine branch now runs straight rather than zigzagging into - # someone else's lane. - # Also cut (70 ore / 2 points), for the same reason as wing. - "id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing", - "costs": {"ore": 35, "gas": 12}, - "x": 0.82, "fleet_bonus": 1, "research_points": 1, - "icon": 2, - "desc": "Permanent: +1 vessel in the fleet.", - }, - { - "id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron", - "costs": {"ore": 90, "gas": 30, "energy": 15}, - "x": 0.82, "fleet_bonus": 1, "research_points": 2, - "icon": 2, - "desc": "Permanent: +1 vessel in the fleet.", - }, - { - # The last two tiers grant +2 rather than +1. Seven single-vessel - # nodes in one lane would be a long, monotonous chain for a branch - # that only ever does one thing; two larger, costlier steps carry the - # ceiling from 6 to 10 while keeping the branch a readable length. - "id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada", - "costs": {"ore": 180, "gas": 60, "energy": 40}, - "x": 0.82, "fleet_bonus": 2, "research_points": 4, - "icon": 2, - "desc": "Permanent: +2 vessels in the fleet.", - }, - { - "id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup", - "costs": {"ore": 320, "gas": 110, "energy": 80, "dark_matter": 20}, - "x": 0.82, "fleet_bonus": 2, "research_points": 6, - "icon": 2, - "desc": "Permanent: +2 vessels in the fleet.", - }, - { - # #206 carries the branch on to twenty. The step size keeps growing - # for the reason the +2 tiers were introduced: ten more single-vessel - # nodes would be a very long chain for a lane that only ever does one - # thing. These three cost late-game money and are meant to be the - # thing a finished economy is finally FOR. - "id": "task_force", "name": "Task Force Doctrine", "cost": 520, "requires": "grand_fleet", - "costs": {"ore": 520, "gas": 180, "energy": 130, "dark_matter": 35}, - "x": 0.82, "fleet_bonus": 3, "research_points": 8, - "icon": 2, - "desc": "Permanent: +3 vessels in the fleet.", - }, - { - "id": "star_legion", "name": "Star Legion Doctrine", "cost": 820, "requires": "task_force", - "costs": {"ore": 820, "gas": 280, "energy": 200, "dark_matter": 60}, - "x": 0.82, "fleet_bonus": 3, "research_points": 10, - "icon": 2, - "desc": "Permanent: +3 vessels in the fleet.", - }, - { - "id": "void_armada", "name": "Void Armada Doctrine", "cost": 1300, "requires": "star_legion", - "costs": {"ore": 1300, "gas": 440, "energy": 320, "dark_matter": 100}, - "x": 0.82, "fleet_bonus": 4, "research_points": 14, - "icon": 2, - "desc": "Permanent: +4 vessels in the fleet. The fleet reaches twenty.", - }, ] -# How many SLOTS the fleet has before any doctrine is researched. Distinct -# from STARTING_SHIP_COUNT below: this is capacity, that is how many vessels -# are actually flying. The two used to be one constant, which forced a new -# game to open with a full fleet and left the first crafter with nothing to -# do - there was no room to put a vessel until a doctrine was researched. -const BASE_FLEET_SIZE: int = 3 +# How many SLOTS the fleet has before a single Fleet Office is built. +# +# #219 replaced the fleet-size doctrines with a structure that has to be +# supplied: every berth past the first is a Fleet Office standing somewhere, +# paying for the vessel in it. So this is 1 - the hull the survivors came out +# of the breach with, which the ticket exempts from needing an office at all. +# +# Distinct from STARTING_SHIP_COUNT below: this is capacity, that is how many +# vessels are actually flying. +const BASE_FLEET_SIZE: int = 1 # A new game opens with a SINGLE vessel. Building the fleet up is the early # game, so the two empty slots are the first thing a ship crafter is for. const STARTING_SHIP_COUNT: int = 1 @@ -812,13 +743,109 @@ func research_for_building(type: String) -> String: return "" if id == "" else String(skill_node(id).name) +# #219: every berth past the first is a Fleet Office standing somewhere, and +# an office only counts while it can still pay for the vessel in it. A starved +# one has already had its vessel destroyed; leaving it counting would let the +# player rebuild straight into a berth nothing is supplying. +signal fleet_upkeep_failed(slot_index: int, vessel_type: String) + +# How often an office is charged, and how much of the vessel's own build price +# it charges. Derived from the build cost rather than authored per vessel: a +# hull that was expensive to make is expensive to keep, which is the rule the +# ticket asks for and needs no second table to fall out of step with. +const FLEET_OFFICE_UPKEEP_SECONDS: float = 25.0 +const FLEET_OFFICE_UPKEEP_DIVISOR: int = 8 + + +# Offices in the order they were built, which is the order they are matched to +# the fleet: the first vessel needs none, the second is kept by the first +# office, and so on. +func fleet_offices() -> Array: + var offices: Array = [] + for building in buildings: + if String(building.get("type", "")) == BUILDING_FLEET_OFFICE: + offices.append(building) + return offices + + +func supplied_fleet_office_count() -> int: + var n: int = 0 + for office in fleet_offices(): + if not bool(office.get("starved", false)): + n += 1 + return n + + +# What one berth costs per charge, for the vessel actually sitting in it. An +# empty answer means this vessel is free to keep - which is true of nothing +# the crafter builds, and is what an unknown type falls back to. +func fleet_office_upkeep(vessel_type: String) -> Dictionary: + var upkeep: Dictionary = {} + for resource_type in product_costs(vessel_type): + var share: int = int(ceil(float(product_costs(vessel_type)[resource_type]) + / float(FLEET_OFFICE_UPKEEP_DIVISOR))) + if share > 0: + upkeep[resource_type] = share + return upkeep + + +# The vessel an office is answerable for, or "" when the fleet is smaller than +# the berths built for it - an office with nothing in it costs nothing. +func fleet_office_vessel(slot_index: int) -> String: + if slot_index < 0 or slot_index >= fleet_vessel_types.size(): + return "" + return String(fleet_vessel_types[slot_index]) + + +func _advance_fleet_office_upkeep(delta: float) -> void: + var offices: Array = fleet_offices() + for i in range(offices.size()): + var office: Dictionary = offices[i] + # Office i keeps the (i + 1)th vessel: the first one needs no office. + var vessel_type: String = fleet_office_vessel(i + BASE_FLEET_SIZE) + if vessel_type == "": + # Nothing to keep. The clock is not advanced either, so a berth + # that stood empty does not present a bill the moment it is used. + # + # An empty berth is IDLE, not starved, including one that just + # lost its vessel: an office is only failing while there is + # something in it to fail. Holding it disabled would leave the + # player unable to use the berth they paid for, with no way back + # - it can never pay for a vessel it is not allowed to hold. + office.upkeep_progress = 0.0 + office.starved = false + continue + var progress: float = float(office.get("upkeep_progress", 0.0)) + delta + if progress < FLEET_OFFICE_UPKEEP_SECONDS: + office.upkeep_progress = progress + continue + office.upkeep_progress = progress - FLEET_OFFICE_UPKEEP_SECONDS + var upkeep: Dictionary = fleet_office_upkeep(vessel_type) + var inventory: RefCounted = _inventory_for_record(office) + var affordable: bool = true + for resource_type in upkeep: + if inventory.amount(String(resource_type)) < int(upkeep[resource_type]): + affordable = false + break + if affordable: + for resource_type in upkeep: + inventory.withdraw(String(resource_type), int(upkeep[resource_type])) + # A restocked office goes back to work on its own: the player + # fixing their supply line should not also have to rebuild it. + office.starved = false + continue + office.starved = true + say_quest_line(("A Fleet Office has run dry. The %s berthed there is gone - " + + "nothing is reaching it, and a berth nobody supplies keeps nobody.") + % vessel_name(vessel_type)) + fleet_upkeep_failed.emit(i + BASE_FLEET_SIZE, vessel_type) + + func max_fleet_size() -> int: if intro_fleet_size > 0: return mini(intro_fleet_size, DEFAULT_FORMATION.size()) - var n: int = BASE_FLEET_SIZE - for id in unlocked: - n += int(skill_node(id).get("fleet_bonus", 0)) - return mini(n, DEFAULT_FORMATION.size()) + return mini(BASE_FLEET_SIZE + supplied_fleet_office_count(), + DEFAULT_FORMATION.size()) func reset_progression() -> void: @@ -3071,6 +3098,7 @@ func _process(delta: float) -> void: _advance_planning() _advance_repairs(delta) _advance_resource_converters(delta) + _advance_fleet_office_upkeep(delta) _advance_distorder_drain(delta, _distorder_boost_in_use()) _advance_quests() for e in extractors: diff --git a/tests/_berths.gd b/tests/_berths.gd new file mode 100644 index 0000000..3601358 --- /dev/null +++ b/tests/_berths.gd @@ -0,0 +1,21 @@ +extends RefCounted + +# #219 made fleet capacity a BUILT thing: one berth free, one more per Fleet +# Office standing. A test that flies four vessels therefore needs three +# offices, where before it unlocked three doctrines. +# +# Loaded with load() at runtime rather than preloaded at the top of a test - +# these are script-mode entry points, and a top-level preload evaluates during +# their own early compilation, which can happen before the autoloads exist. +# +# The offices are put a long way from the origin so they cannot wander into +# whatever the test is actually looking at, and left unstocked: upkeep is not +# charged until FLEET_OFFICE_UPKEEP_SECONDS has passed, and no test here runs +# that long. + + +static func grant(game_state: Node, vessels: int) -> void: + var needed: int = maxi(vessels - int(game_state.BASE_FLEET_SIZE), 0) + for i in range(needed): + game_state.add_building(String(game_state.BUILDING_FLEET_OFFICE), + Vector2(40000.0 + 500.0 * float(i), 40000.0)) diff --git a/tests/_hud_preview.gd b/tests/_hud_preview.gd index fbdd642..761fee5 100644 --- a/tests/_hud_preview.gd +++ b/tests/_hud_preview.gd @@ -12,7 +12,8 @@ func _ready() -> void: GameState.vessel_levels["unlock_laser"] = 1 GameState.vessel_levels["unlock_gas_bomber"] = 1 GameState.vessel_levels["unlock_distorder"] = 1 - GameState.unlocked.append("wing") + # #219: a berth is an office now, not a doctrine. + GameState.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(2400.0, 0.0)) GameState.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 200) GameState.deposit_fleet_resource(GameState.RESOURCE_GAS, 200) var main: Node = load("res://world/Main.tscn").instantiate() diff --git a/tests/_scanner_guidance_preview.gd.uid b/tests/_scanner_guidance_preview.gd.uid new file mode 100644 index 0000000..c5f5956 --- /dev/null +++ b/tests/_scanner_guidance_preview.gd.uid @@ -0,0 +1 @@ +uid://bwusdnid203ma diff --git a/tests/_scanner_menu_preview.gd.uid b/tests/_scanner_menu_preview.gd.uid new file mode 100644 index 0000000..641b811 --- /dev/null +++ b/tests/_scanner_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://cvpu8gyb5757i diff --git a/tests/test_ability_button_layout.gd b/tests/test_ability_button_layout.gd index b285881..f0f0867 100644 --- a/tests/test_ability_button_layout.gd +++ b/tests/test_ability_button_layout.gd @@ -20,6 +20,10 @@ func _initialize() -> void: state.fleet_inventory.total_capacity = -1 state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) state.deposit_fleet_resource(GameState.RESOURCE_GAS, 500) + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main diff --git a/tests/test_ability_button_layout.gd.uid b/tests/test_ability_button_layout.gd.uid new file mode 100644 index 0000000..abe9b38 --- /dev/null +++ b/tests/test_ability_button_layout.gd.uid @@ -0,0 +1 @@ +uid://cwxeg5i0betsn diff --git a/tests/test_aim_converges.gd b/tests/test_aim_converges.gd index 78413cb..64fac35 100644 --- a/tests/test_aim_converges.gd +++ b/tests/test_aim_converges.gd @@ -47,6 +47,10 @@ func _initialize() -> void: game_state.reset_progression() game_state.intro_active = false game_state.reset_quests() + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main diff --git a/tests/test_artillery_enemy.gd b/tests/test_artillery_enemy.gd index 73c123a..d55ed26 100644 --- a/tests/test_artillery_enemy.gd +++ b/tests/test_artillery_enemy.gd @@ -52,6 +52,10 @@ func _initialize() -> void: game_state.build_time_scale = 0.02 game_state.reset_progression() game_state.rocks = 2000 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_build_menu_hides_locked.gd b/tests/test_build_menu_hides_locked.gd index 80e5503..d2f1a5a 100644 --- a/tests/test_build_menu_hides_locked.gd +++ b/tests/test_build_menu_hides_locked.gd @@ -9,7 +9,8 @@ extends SceneTree # holes where they used to be - it is repacked from whatever is researched. # # Verifies: -# 1. with nothing researched only the mothership is offered (it is +# 1. with nothing researched only the mothership and the Fleet Office are +# offered (the mothership is # deliberately never gated - it is where research happens) # 2. researching a structure adds it, and it is placed, not left at # whatever offsets it happened to have in the scene file @@ -79,21 +80,36 @@ func _process(delta: float) -> bool: # --- 1. nothing researched ---------------------------------- controller._refresh_build_status() + # #219 put the Fleet Office beside the mothership at the opening: + # it is the only way the fleet grows now, so gating it behind + # research would gate fleet size behind research again - which is + # the thing that ticket removed. + var opening: Array = [GameState.BUILDING_MOTHERSHIP, + GameState.BUILDING_FLEET_OFFICE] var shown: Array = _shown() - if shown.size() != 1 or shown[0][0] != GameState.BUILDING_MOTHERSHIP: - var names: Array = [] - for e in shown: - names.append(e[0]) - print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (unresearched structures on offer: %s)" % [names]) + var shown_ids: Array = [] + for e in shown: + shown_ids.append(e[0]) + shown_ids.sort() + opening.sort() + if shown_ids != opening: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (opening offer was %s, expected %s)" \ + % [shown_ids, opening]) quit(1) return true - # --- 4. and it is centred, being alone on its row ----------- - var only: Array = _slots()[0] - if absf(only[1]) > 0.01: - print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the only entry sits at x=%.1f instead of centred)" % only[1]) + # --- 4. and the row is centred as a whole ------------------- + # Two entries now, so "centred" is the pair straddling the middle + # rather than one sitting on it. The rule being checked is the + # same one: a short row is not left hanging off to one side. + var slots: Array = _slots() + var spread: float = 0.0 + for slot in slots: + spread += float(slot[1]) + if absf(spread) > 0.01: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the opening row is off-centre by %.1f)" % spread) quit(1) return true - print("nothing researched -> only the mothership, centred") + print("nothing researched -> the mothership and a Fleet Office, centred") # --- 2. research three more --------------------------------- game_state.unlocked = ["shipyards", "logistics", "coordination"] @@ -101,9 +117,11 @@ func _process(delta: float) -> bool: stage = 1 return false 1: + # Three doctrines add three structures to the two the opening + # already offers (#219's Fleet Office is one of those two). var shown2: Array = _shown() - if shown2.size() != 4: - print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched 3, expected 4 entries, got %d)" % shown2.size()) + if shown2.size() != 5: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched 3, expected 5 entries, got %d)" % shown2.size()) quit(1) return true for e in shown2: @@ -117,23 +135,30 @@ func _process(delta: float) -> bool: var columns: int = controller.menu_columns(slots.size()) var rows: int = controller.menu_rows(slots.size()) if columns != 2: - print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (4 entries laid out in %d columns)" % columns) + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%d entries laid out in %d columns)" \ + % [slots.size(), columns]) quit(1) return true for i in range(slots.size()): - var want_row: float = controller.menu_row_top(i / columns, rows, columns) + var row: int = i / columns + var want_row: float = controller.menu_row_top(row, rows, columns) if absf(slots[i][0] - want_row) > 0.01: - print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / columns, want_row]) + print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], row, want_row]) print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)") quit(1) return true + # Each ROW is centred on however many it holds, so a final row + # with one entry sits in the middle rather than in column 0. + # Written the way the layout writes it, so an odd count is not + # a special case here that is ordinary there. + var in_row: int = mini(slots.size() - row * columns, columns) var spacing: float = controller.menu_column_spacing(columns) - var want_x: float = -spacing * 0.5 if i % 2 == 0 else spacing * 0.5 + var want_x: float = (float(i % columns) - (float(in_row) - 1.0) * 0.5) * spacing if absf(slots[i][1] - want_x) > 0.01: print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x]) quit(1) return true - print("4 entries packed into consecutive slots, no gaps") + print("%d entries packed into consecutive slots, no gaps" % slots.size()) # --- 6. #136: nothing overlaps, with EVERY structure on offer - # The bug the ticket reported: the row list ran out and the last diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd index 7283e67..0a28c31 100644 --- a/tests/test_building_stock_queue.gd +++ b/tests/test_building_stock_queue.gd @@ -51,7 +51,7 @@ func _initialize() -> void: # Real build times are 10s+; the timed path is what matters here, not the # wall clock. See GameState.build_time_scale. game_state.build_time_scale = 0.02 - game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] + game_state.unlocked = ["shipyards", "coordination"] scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_carrier_vessel.gd b/tests/test_carrier_vessel.gd index d274f3f..72892b5 100644 --- a/tests/test_carrier_vessel.gd +++ b/tests/test_carrier_vessel.gd @@ -49,6 +49,10 @@ func _initialize() -> void: game_state.build_time_scale = 0.02 game_state.rocks = 500 game_state.research_points = 1 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_crafter_feedback.gd b/tests/test_crafter_feedback.gd index 26c4f47..6025509 100644 --- a/tests/test_crafter_feedback.gd +++ b/tests/test_crafter_feedback.gd @@ -47,7 +47,7 @@ func _initialize() -> void: # real-tap checks below. A full fleet correctly DISABLES it, and a # disabled button not reacting to a tap is not the bug those checks are # looking for. - game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] + game_state.unlocked = ["shipyards", "coordination"] game_state.rocks = 100 game_state.fleet_cargo = 0 game_state.stored_vessels.clear() diff --git a/tests/test_desktop_controls.gd b/tests/test_desktop_controls.gd index 8467ad0..4fad953 100644 --- a/tests/test_desktop_controls.gd +++ b/tests/test_desktop_controls.gd @@ -47,6 +47,10 @@ func _initialize() -> void: game_state.reset_formation() game_state.intro_active = false game_state.vessel_levels["unlock_distorder"] = 1 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) current_scene = scene diff --git a/tests/test_dev_menu.gd b/tests/test_dev_menu.gd index 94a3dec..3dcbe7d 100644 --- a/tests/test_dev_menu.gd +++ b/tests/test_dev_menu.gd @@ -45,8 +45,18 @@ func _process(delta: float) -> bool: for skill in GameState.SKILL_TREE: if not state.is_unlocked(String(skill.id)): return _fail("unlock-all missed %s" % skill.id) + # #219: capacity is Fleet Offices, not research, so unlocking the + # whole tree must NOT move it. The sandbox tool is about doctrines. + if state.max_fleet_size() != GameState.BASE_FLEET_SIZE: + return _fail("unlock-all moved the fleet cap to %d - capacity is not research any more" \ + % state.max_fleet_size()) + # The berths the rest of this test needs come from offices. + for berth in range(GameState.DEFAULT_FORMATION.size() - GameState.BASE_FLEET_SIZE): + state.add_building(GameState.BUILDING_FLEET_OFFICE, + Vector2(3000.0 + 300.0 * float(berth), 3000.0)) if state.max_fleet_size() != GameState.DEFAULT_FORMATION.size(): - return _fail("unlock-all did not expose the complete fleet capacity") + return _fail("offices for every formation position gave %d berths" \ + % state.max_fleet_size()) for definition in VESSEL_DEFINITIONS.fleet(): menu.fleet_add_requested.emit(String(definition.definition_id)) diff --git a/tests/test_distorder_energy.gd b/tests/test_distorder_energy.gd index 0d974dd..98966e7 100644 --- a/tests/test_distorder_energy.gd +++ b/tests/test_distorder_energy.gd @@ -109,6 +109,10 @@ func _process(delta: float) -> bool: # --- C continued: the SpeedFader visibly follows it down ---------- if elapsed < 0.2: return false + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_distorder_stacking.gd b/tests/test_distorder_stacking.gd index 25745cb..51a9e64 100644 --- a/tests/test_distorder_stacking.gd +++ b/tests/test_distorder_stacking.gd @@ -32,6 +32,10 @@ func _initialize() -> void: game_state.reset_progression() game_state.vessel_levels["unlock_distorder"] = 1 game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") @@ -47,8 +51,6 @@ func _process(delta: float) -> bool: main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) # Room for a fleet of distorders. - game_state.unlocked.append_array(["wing", "squadron", "armada"]) - # --- A. one vessel is unchanged ------------------------------ fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER])) diff --git a/tests/test_enemy_collision.gd b/tests/test_enemy_collision.gd index e4dcbee..07bb5d5 100644 --- a/tests/test_enemy_collision.gd +++ b/tests/test_enemy_collision.gd @@ -22,6 +22,10 @@ var started: bool = false func _initialize() -> void: + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) var packed: PackedScene = load("res://world/Main.tscn") main_scene = packed.instantiate() root.add_child(main_scene) @@ -42,7 +46,6 @@ func _physics_process(delta: float) -> bool: # can be undone by the engine's deferred tree-entry finalization. A # new game flies one vessel into three slots now, and this test needs # a full one. - root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) fleet.spawn_ships(5, Vector2.ZERO) target_ship = fleet.ships[0] var enemy_scene: PackedScene = load("res://enemies/Enemy.tscn") diff --git a/tests/test_enemy_combat.gd b/tests/test_enemy_combat.gd index 6e73681..4ad2654 100644 --- a/tests/test_enemy_combat.gd +++ b/tests/test_enemy_combat.gd @@ -23,6 +23,10 @@ var started: bool = false func _initialize() -> void: + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) var packed: PackedScene = load("res://world/Main.tscn") main_scene = packed.instantiate() root.add_child(main_scene) @@ -43,7 +47,6 @@ func _physics_process(delta: float) -> bool: # can be undone by the engine's deferred tree-entry finalization. A # new game flies one vessel into three slots now, and this test needs # a full one. - root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) fleet.spawn_ships(5, Vector2.ZERO) var enemy_scene: PackedScene = load("res://enemies/Enemy.tscn") enemy = enemy_scene.instantiate() diff --git a/tests/test_enemy_mothership.gd b/tests/test_enemy_mothership.gd index d4b92bf..b506e64 100644 --- a/tests/test_enemy_mothership.gd +++ b/tests/test_enemy_mothership.gd @@ -56,7 +56,13 @@ func _initialize() -> void: beam_script = load("res://enemies/EnemyBigLaserBeam.gd") # Four vessels are crewed below, past BASE_FLEET_SIZE's 3 slots, so the # capacity doctrines are granted rather than assumed. - game_state.unlocked.append_array(["wing", "squadron", "armada"]) + # #219: berths come from Fleet Offices now, not from research. Three of + # them buy the same four-vessel fleet the doctrines used to. Left + # unstocked deliberately - upkeep is not charged for the first + # 25 seconds, and nothing here runs that long. + for _berth in range(3): + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, + Vector2(3000.0 + 400.0 * float(_berth), 3000.0)) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_enemy_projectile_damage.gd b/tests/test_enemy_projectile_damage.gd index a6ac454..3a3477a 100644 --- a/tests/test_enemy_projectile_damage.gd +++ b/tests/test_enemy_projectile_damage.gd @@ -24,6 +24,10 @@ func _initialize() -> void: game_state.build_time_scale = 0.02 game_state.reset_progression() game_state.rocks = 2000 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_energy_propulser_dash.gd b/tests/test_energy_propulser_dash.gd index 4cd3bad..26d94f2 100644 --- a/tests/test_energy_propulser_dash.gd +++ b/tests/test_energy_propulser_dash.gd @@ -28,6 +28,10 @@ func _initialize() -> void: state.vessel_levels["unlock_distorder"] = 1 state.fleet_inventory.total_capacity = -1 state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, DASH_COST * 3) + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main diff --git a/tests/test_fleet_cargo_limit.gd b/tests/test_fleet_cargo_limit.gd index c98294f..982e1c3 100644 --- a/tests/test_fleet_cargo_limit.gd +++ b/tests/test_fleet_cargo_limit.gd @@ -33,6 +33,10 @@ func _initialize() -> void: game_state.rocks = 500 game_state.fleet_cargo = 0 mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) current_scene = scene diff --git a/tests/test_fleet_growth.gd b/tests/test_fleet_growth.gd index 4d66987..5a04f32 100644 --- a/tests/test_fleet_growth.gd +++ b/tests/test_fleet_growth.gd @@ -2,31 +2,33 @@ 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 three slots, and the fleet grows -# from there through the doctrine branch of the skill tree. +# 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. # -# BASE_FLEET_SIZE used to be both "how many slots exist" and "how many ships -# you start with", which forced a new game to open with a full fleet - there -# was nowhere to put a vessel until a doctrine was researched, so the first -# ship crafter had nothing to do. The two are now separate constants, and -# this test pins the distinction. +# 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 three slots -# B. the fleet really spawns one ship, and can be grown into its slots -# C. every doctrine raises the ceiling by its own stated bonus -# D. the doctrine branch is a chain, in increasing cost order -# E. the tree cannot promise a slot the formation has no room for - the -# invariant that made extra doctrines silently worthless - -const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup", - "grand_fleet", "task_force", "star_legion", "void_armada"] +# 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. capacity can never outrun the formation's defined positions 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: @@ -41,6 +43,12 @@ func _initialize() -> void: 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: @@ -48,74 +56,43 @@ func _process(delta: float) -> bool: # --- 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 != 3: - return _fail("the fleet has %d slots by default, expected 3" % GameState.BASE_FLEET_SIZE) - if game_state.surviving_ship_count != GameState.STARTING_SHIP_COUNT: - return _fail("reset_new_game left %d surviving ships" % game_state.surviving_ship_count) - if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE: - return _fail("an unresearched fleet caps at %d, expected %d" \ - % [game_state.max_fleet_size(), GameState.BASE_FLEET_SIZE]) - print("new game: %d vessel, %d slots" % [GameState.STARTING_SHIP_COUNT, GameState.BASE_FLEET_SIZE]) + 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()) - # --- C + D. the doctrine chain ------------------------------- - var expected: int = GameState.BASE_FLEET_SIZE - var previous_cost: int = -1 - var previous_points: int = -1 - var previous_id: String = "coordination" - for id in DOCTRINE_CHAIN: - var node: Dictionary = game_state.skill_node(id) - if node.is_empty(): - return _fail("no skill node '%s' - the doctrine chain is broken" % id) - var bonus: int = int(node.get("fleet_bonus", 0)) - if bonus <= 0: - return _fail("'%s' is in the doctrine chain but grants no fleet_bonus" % id) - if String(node.get("requires", "")) != previous_id: - return _fail("'%s' requires '%s', expected it to follow '%s' - the branch is not one chain" \ - % [id, node.get("requires", ""), previous_id]) - # Later tiers must not be cheaper than earlier ones, or the - # order the player meets them stops matching what they cost. - var cost: int = int(node.get("cost", 0)) - var points: int = int(node.get("research_points", 0)) - if cost <= previous_cost: - return _fail("'%s' costs %d ore, not more than the tier before it (%d)" % [id, cost, previous_cost]) - if points < previous_points: - return _fail("'%s' costs %d research points, fewer than the tier before it (%d)" \ - % [id, points, previous_points]) - previous_cost = cost - previous_points = points - previous_id = id + # The whole tree, researched, must not add a single berth: #219 + # moved fleet size out of research entirely. + 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 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, and no research anywhere raises it") - game_state.unlocked.append(id) - expected += bonus - if game_state.max_fleet_size() != expected: - return _fail("after researching '%s' the fleet caps at %d, expected %d" \ - % [id, game_state.max_fleet_size(), expected]) - print(" %-12s +%d -> %2d slots (%d ore, %d research)" % [id, bonus, expected, cost, points]) + # --- 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. no doctrine may be swallowed by the formation --------- - # max_fleet_size() clamps to the formation's length, so a doctrine - # added without extending DEFAULT_FORMATION would cost the player - # real resources and grant nothing. - if expected > GameState.DEFAULT_FORMATION.size(): - return _fail("the doctrines promise %d slots but DEFAULT_FORMATION only defines %d - the last ones buy nothing" \ - % [expected, GameState.DEFAULT_FORMATION.size()]) - # #206 named the destination, so it is pinned rather than left to - # whatever the chain happens to add up to. - if expected != 20: - return _fail("the doctrine chain reaches %d vessels, not the 20 it is meant to" \ - % expected) - print("full doctrine chain reaches %d slots, with %d formation positions defined" \ - % [expected, GameState.DEFAULT_FORMATION.size()]) + # --- E. and never more than the formation can seat ------------ + if game_state.max_fleet_size() > GameState.DEFAULT_FORMATION.size(): + return _fail("capacity %d exceeds the %d positions the formation defines" \ + % [game_state.max_fleet_size(), GameState.DEFAULT_FORMATION.size()]) - # --- B. what actually flies ---------------------------------- - game_state.reset_new_game() - # ... and again here: this is the reset the scene below is built - # from, so the prologue has to be off for THIS one too - and the - # quest chain with it. - game_state.intro_active = false - game_state.reset_quests() scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) + current_scene = scene fleet = scene.get_node("Fleet") elapsed = 0.0 stage = 1 @@ -123,27 +100,70 @@ func _process(delta: float) -> bool: 1: if elapsed < 0.3: return false - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(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]) - print("a fresh game really flies %d vessel" % fleet.ships.size()) - # The two empty slots are what the opening is for, so they have to - # be fillable without researching anything. - for i in range(GameState.BASE_FLEET_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 slot %d of the starting fleet" % (i + 2)) - if fleet.ships.size() != GameState.BASE_FLEET_SIZE: - return _fail("the fleet holds %d after filling its slots, expected %d" \ - % [fleet.ships.size(), GameState.BASE_FLEET_SIZE]) - # And the cap has to actually bite past that. + 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 %dth vessel joined an unresearched fleet of %d slots" \ - % [fleet.ships.size(), GameState.BASE_FLEET_SIZE]) - print("the starting slots fill to %d and refuse a %dth" \ - % [GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 1]) + 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") print("TEST_FLEET_GROWTH: PASS") quit(0) @@ -151,6 +171,9 @@ func _process(delta: float) -> bool: return false +var _lost_before: int = 0 + + func _fail(message: String) -> bool: print("TEST_FLEET_GROWTH: FAIL (%s)" % message) quit(1) diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd index 9451c69..4ab1d01 100644 --- a/tests/test_gas_bomber.gd +++ b/tests/test_gas_bomber.gd @@ -77,6 +77,10 @@ func _initialize() -> void: return game_state.fleet_gas_cargo = 20 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_gas_bomber_ammo.gd b/tests/test_gas_bomber_ammo.gd index 7c4e5a8..f332f1e 100644 --- a/tests/test_gas_bomber_ammo.gd +++ b/tests/test_gas_bomber_ammo.gd @@ -43,6 +43,10 @@ func _initialize() -> void: game_state.rocks = 0 game_state.gas = 0 game_state.fleet_gas_cargo = 0 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_gas_bomber_cooldowns.gd b/tests/test_gas_bomber_cooldowns.gd index ff1d616..69f5696 100644 --- a/tests/test_gas_bomber_cooldowns.gd +++ b/tests/test_gas_bomber_cooldowns.gd @@ -14,6 +14,10 @@ func _initialize() -> void: state.vessel_levels["unlock_gas_bomber"] = 1 # #60: shots draw from the fleet hold now - stock enough for both fires. state.fleet_gas_cargo = 20 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd index 3184cda..4233217 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -19,6 +19,10 @@ func _initialize() -> void: game_state.transporters.clear() game_state.stored_vessels.clear() game_state.rocks = 100 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_input_after_dialog.gd b/tests/test_input_after_dialog.gd index 801c380..fbf736b 100644 --- a/tests/test_input_after_dialog.gd +++ b/tests/test_input_after_dialog.gd @@ -58,6 +58,10 @@ func _process(delta: float) -> bool: elapsed += delta match stage: 0: + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) current_scene = scene @@ -124,9 +128,17 @@ func _process(delta: float) -> bool: if panels.is_empty(): return _fail("no modal panels found to be drawn over") for menu in panels: - if menu.get_index() > banner_at: - return _fail("%s is a later child than the dialog, so it draws on top of it" \ - % menu.name) + # A panel nested inside another panel - the build menu's own + # info popup - indexes against ITS parent, not the UI layer, + # so comparing that number against the banner's is comparing + # two different sibling lists. What actually has to sit under + # the banner is the UI-level ancestor it is drawn inside. + var top_level: Node = menu + while top_level.get_parent() != ui: + top_level = top_level.get_parent() + if top_level.get_index() > banner_at: + return _fail("%s is drawn inside %s, a later child than the dialog" \ + % [menu.name, top_level.name]) if banner_at != ui.get_child_count() - 1: return _fail("the dialog is child %d of %d - something still draws over it" \ % [banner_at, ui.get_child_count()]) diff --git a/tests/test_laser_ammo.gd b/tests/test_laser_ammo.gd index 72f2a83..67cedc7 100644 --- a/tests/test_laser_ammo.gd +++ b/tests/test_laser_ammo.gd @@ -42,6 +42,10 @@ func _initialize() -> void: game_state.rocks = 0 game_state.gas = 0 game_state.fleet_gas_cargo = 0 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd index 253b3e2..42c943f 100644 --- a/tests/test_laser_vessel.gd +++ b/tests/test_laser_vessel.gd @@ -58,6 +58,10 @@ func _initialize() -> void: # fleet composition exists), so this deposit can't be clamped by the # soon-to-exist all-laser fleet having zero "cargo" capability. game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 20) + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd index 3a65e95..fc3d89a 100644 --- a/tests/test_manual_supply_route.gd +++ b/tests/test_manual_supply_route.gd @@ -60,7 +60,7 @@ func _initialize() -> void: game_state.fleet_cargo = 0 game_state.stored_vessels.clear() game_state.build_time_scale = 0.02 - game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] + game_state.unlocked = ["shipyards", "coordination"] scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_menu_polish.gd b/tests/test_menu_polish.gd index 505bf25..6c9f0c9 100644 --- a/tests/test_menu_polish.gd +++ b/tests/test_menu_polish.gd @@ -40,6 +40,10 @@ func _initialize() -> void: game_state.reset_formation() game_state.intro_active = false game_state.rocks = 5000 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) current_scene = scene diff --git a/tests/test_pivot_controls.gd b/tests/test_pivot_controls.gd index 365a0cc..471be9d 100644 --- a/tests/test_pivot_controls.gd +++ b/tests/test_pivot_controls.gd @@ -51,7 +51,13 @@ func _process(delta: float) -> bool: # same reason the set_process(false) calls above sit at this # point. A new game flies one vessel now, and the checks below # index ships[1]. - game_state.unlocked.append_array(["wing", "squadron", "armada"]) + # #219: berths come from Fleet Offices now, not from research. Three of + # them buy the same four-vessel fleet the doctrines used to. Left + # unstocked deliberately - upkeep is not charged for the first + # 25 seconds, and nothing here runs that long. + for _berth in range(3): + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, + Vector2(3000.0 + 400.0 * float(_berth), 3000.0)) fleet.spawn_ships(5, Vector2.ZERO) # Hold the button down without releasing it. angle_at_hold_start = _angle() diff --git a/tests/test_planet_atmosphere_transition.gd b/tests/test_planet_atmosphere_transition.gd index cd1bcdb..f164f61 100644 --- a/tests/test_planet_atmosphere_transition.gd +++ b/tests/test_planet_atmosphere_transition.gd @@ -55,6 +55,10 @@ func _initialize() -> void: return game_state = root.get_node("GameState") + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) var packed: PackedScene = load("res://world/Main.tscn") main_scene = packed.instantiate() root.add_child(main_scene) @@ -68,7 +72,6 @@ func _initialize() -> void: # (3) slots. This test needs a crewed fleet, so it asks for one outright # rather than inheriting whatever a fresh game happens to open with - # which is exactly what changed underneath it. - root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) fleet.spawn_ships(5, Vector2.ZERO) planet_field = main_scene.get_node("PlanetField") landing_controller = main_scene.get_node("LandingController") diff --git a/tests/test_planet_landing_proximity.gd b/tests/test_planet_landing_proximity.gd index f427082..f4c994e 100644 --- a/tests/test_planet_landing_proximity.gd +++ b/tests/test_planet_landing_proximity.gd @@ -38,6 +38,10 @@ func _initialize() -> void: quit(1) return + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) var packed: PackedScene = load("res://world/Main.tscn") main_scene = packed.instantiate() root.add_child(main_scene) @@ -67,7 +71,6 @@ func _process(delta: float) -> bool: # there can be undone by the engine's deferred tree-entry # finalization, the same reason the set_process(false) calls # above live at this point. A new game flies one vessel now. - root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) fleet.spawn_ships(5, Vector2.ZERO) # Silencing the field does not remove what it already placed, and # those keep drifting - a rock taking the fleet out mid-test reads diff --git a/tests/test_planet_scanner.gd.uid b/tests/test_planet_scanner.gd.uid new file mode 100644 index 0000000..f69818b --- /dev/null +++ b/tests/test_planet_scanner.gd.uid @@ -0,0 +1 @@ +uid://bthdk3qrxamks diff --git a/tests/test_resource_transfer.gd b/tests/test_resource_transfer.gd index 908c109..5c8bc7a 100644 --- a/tests/test_resource_transfer.gd +++ b/tests/test_resource_transfer.gd @@ -51,7 +51,13 @@ func _initialize() -> void: game_state.rocks = 100 game_state.fleet_cargo = 0 game_state.stored_vessels.clear() - game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] + game_state.unlocked = ["shipyards", "coordination"] + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + # Eight, which is what every doctrine used to allow: the amounts below are + # hand-picked to sit under a hold that size (8 interceptors x 20 = 160). + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 8) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") @@ -59,7 +65,6 @@ func _initialize() -> void: # (3) slots. This test needs a crewed fleet, so it asks for one outright # rather than inheriting whatever a fresh game happens to open with - # which is exactly what changed underneath it. - root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) fleet.spawn_ships(5, Vector2.ZERO) controller = scene.get_node("BuildController") @@ -96,9 +101,9 @@ func _process(delta: float) -> bool: _clear_hazards() # #56 gave the hold a finite, composition-driven capacity. This # test is about the transfer UI, not that cap - test_fleet_cargo_ - # limit.gd owns the cap itself - so it spawns the largest fleet - # every unlocked doctrine allows (8 interceptors x 20 = 160), - # comfortably above every hand-picked amount below. + # limit.gd owns the cap itself - so it spawns into every berth the + # offices above bought (8 interceptors x 20 = 160), comfortably + # above every hand-picked amount below. fleet.spawn_ships(game_state.max_fleet_size(), MOTHERSHIP_AT) var field: Node = scene.get_node("BuildingField") field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index 33324b4..a9c63f1 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -107,7 +107,13 @@ func _process(delta: float) -> bool: # Crafting adds a FOURTH vessel below, one past the 3 slots a # new game has, so make room first - the subject here is the # crafter, not the fleet cap. - game_state.unlocked.append_array(["wing", "squadron", "armada"]) + # #219: berths come from Fleet Offices now, not from research. Three of + # them buy the same four-vessel fleet the doctrines used to. Left + # unstocked deliberately - upkeep is not charged for the first + # 25 seconds, and nothing here runs that long. + for _berth in range(3): + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, + Vector2(3000.0 + 400.0 * float(_berth), 3000.0)) fleet.spawn_ships(3, Vector2.ZERO) var before_ships: int = fleet.ships.size() var before_rocks: int = game_state.rocks diff --git a/tests/test_return_position_matches_planet.gd b/tests/test_return_position_matches_planet.gd index d660702..53fd32b 100644 --- a/tests/test_return_position_matches_planet.gd +++ b/tests/test_return_position_matches_planet.gd @@ -43,6 +43,10 @@ func _initialize() -> void: quit(1) return + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) var packed: PackedScene = load("res://world/Main.tscn") main_scene = packed.instantiate() root.add_child(main_scene) @@ -63,7 +67,6 @@ func _process(delta: float) -> bool: # there can be undone by the engine's deferred tree-entry # finalization, the same reason the set_process(false) calls # above live at this point. A new game flies one vessel now. - root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) fleet.spawn_ships(5, Vector2.ZERO) # Silencing the field does not remove what it already placed, and # those keep drifting - a rock taking the fleet out mid-test reads diff --git a/tests/test_shield_vessel.gd b/tests/test_shield_vessel.gd index 9f928d5..a5e4a79 100644 --- a/tests/test_shield_vessel.gd +++ b/tests/test_shield_vessel.gd @@ -38,6 +38,10 @@ func _initialize() -> void: game_state.build_time_scale = 0.02 game_state.reset_progression() game_state.rocks = 2000 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_ship_death.gd b/tests/test_ship_death.gd index 6891abc..82596e8 100644 --- a/tests/test_ship_death.gd +++ b/tests/test_ship_death.gd @@ -70,6 +70,10 @@ func _process(delta: float) -> bool: elapsed += delta match stage: 0: + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) current_scene = scene diff --git a/tests/test_ship_lab.gd b/tests/test_ship_lab.gd index 80e5577..00f08c6 100644 --- a/tests/test_ship_lab.gd +++ b/tests/test_ship_lab.gd @@ -41,6 +41,10 @@ func _initialize() -> void: game_state.reset_progression() game_state.rocks = 1200 game_state.fleet_cargo = 0 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") @@ -81,7 +85,7 @@ func _process(delta: float) -> bool: return true print("no lab -> no upgrades") - game_state.unlocked = ["shipyards", "vessel_science", "coordination", "wing", "squadron"] + game_state.unlocked = ["shipyards", "vessel_science", "coordination"] game_state.research_points = 10 field.place(GameState.BUILDING_SHIP_LAB, Vector2(-700.0, 0.0)) lab_id = int(game_state.buildings[-1].id) diff --git a/tests/test_skill_menu.gd b/tests/test_skill_menu.gd index bfd714e..e87076b 100644 --- a/tests/test_skill_menu.gd +++ b/tests/test_skill_menu.gd @@ -126,6 +126,18 @@ func _process(delta: float) -> bool: print("a second click on the same node researches it") # --- C. held drag scrolls, a bare hover does not -------------------- + # #219 removed the eight-deep doctrine lane and the tree now fits its own + # view, so there is nothing left to scroll. What is under test here is the + # INPUT rule - drag only while the button is held, wheel both ways - not + # how tall the tree happens to be, so the view is shortened to give the + # gesture something to move. Scrolled to the bottom first, the way the + # menu opens when the content does overflow. + var authored_height: float = tree_view.size.y + tree_view.size.y = authored_height * 0.4 + tree_view.scroll_y = tree_view._max_scroll() + if tree_view.scroll_y <= 0.0: + return _fail("the tree still has nothing to scroll, so this proves nothing") + var before: float = tree_view.scroll_y _move(Vector2(0.0, 120.0)) if not is_equal_approx(tree_view.scroll_y, before): @@ -153,6 +165,10 @@ func _process(delta: float) -> bool: print("the wheel scrolls both ways") # --- E. the panel has no dead band at the bottom -------------------- + # Back to the authored height: section C shortened the view to have + # something to scroll, and the layout below is about the real one. + tree_view.size.y = authored_height + tree_view.scroll_y = 0.0 var detail: Control = scene.get_node("UI/SkillMenu/DetailLabel") var status: Control = scene.get_node("UI/SkillMenu/StatusLabel") var tree_bottom: float = tree_view.position.y + tree_view.size.y diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd index 77df9e3..76168de 100644 --- a/tests/test_skill_tree.gd +++ b/tests/test_skill_tree.gd @@ -131,19 +131,34 @@ func _process(delta: float) -> bool: print("TEST_SKILL_TREE: FAIL (freight branch does not rise vertically)") quit(1) return true - var before_scroll: float = controller.skill_menu.tree.scroll_y + var tree_view: Control = controller.skill_menu.tree + var before_scroll: float = tree_view.scroll_y var drag: InputEventScreenDrag = InputEventScreenDrag.new() # Start at the root at the bottom; pull the canvas down to reveal # the branches rising above it. drag.relative = Vector2(0.0, 180.0) - controller.skill_menu.tree.handle_drag(drag) - if controller.skill_menu.tree.scroll_y >= before_scroll: - print("TEST_SKILL_TREE: FAIL (tree did not scroll upward)") - quit(1) - return true + tree_view.handle_drag(drag) + # #219 removed the eight-deep doctrine lane, which was what made + # the canvas taller than the view. A tree that FITS has nothing to + # scroll, so what is checked depends on which it is - and the + # clamp is worth pinning either way, since a canvas that drifts + # off its own top edge is the bug this gesture used to expose. + if before_scroll > 0.0: + if tree_view.scroll_y >= before_scroll: + print("TEST_SKILL_TREE: FAIL (tree did not scroll upward)") + quit(1) + return true + print("tree scrolls: %.0f -> %.0f" % [before_scroll, tree_view.scroll_y]) + else: + if tree_view.scroll_y != 0.0: + print("TEST_SKILL_TREE: FAIL (a tree that fits scrolled to %.1f)" \ + % tree_view.scroll_y) + quit(1) + return true + print("tree fits the view; the pan clamps at the top") # --- prerequisites -------------------------------------------- - if game_state.can_unlock("wing"): + if game_state.can_unlock("power_systems"): print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)") quit(1) return true @@ -172,16 +187,21 @@ func _process(delta: float) -> bool: scene.get_node("UI/BuildMenu").visible = false # --- fleet capacity ------------------------------------------- + # #219 took capacity out of the tree entirely: a berth is a Fleet + # Office standing somewhere. What this checks now is that the + # tree does NOT move the cap, and that the structure does. var base_cap: int = game_state.max_fleet_size() game_state.research_points = 2 game_state.unlock("coordination") - var wing_ok: bool = game_state.unlock("wing") + if game_state.max_fleet_size() != base_cap: + print("TEST_SKILL_TREE: FAIL (research still moves the fleet cap)") + quit(1) + return true + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(2400.0, 0.0)) var raised_cap: int = game_state.max_fleet_size() - print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [ - base_cap, raised_cap, wing_ok - ]) - if not (wing_ok and raised_cap == base_cap + 1): - print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)") + print("fleet cap %d -> %d after a Fleet Office" % [base_cap, raised_cap]) + if raised_cap != base_cap + 1: + print("TEST_SKILL_TREE: FAIL (a Fleet Office did not raise the cap)") quit(1) return true diff --git a/tests/test_sniper_enemy.gd b/tests/test_sniper_enemy.gd index 42f2b4f..df6493e 100644 --- a/tests/test_sniper_enemy.gd +++ b/tests/test_sniper_enemy.gd @@ -41,6 +41,10 @@ func _initialize() -> void: game_state.build_time_scale = 0.02 game_state.reset_progression() game_state.rocks = 2000 + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd index cf1654c..3076aaa 100644 --- a/tests/test_space_time_distorder.gd +++ b/tests/test_space_time_distorder.gd @@ -43,6 +43,10 @@ func _initialize() -> void: # hold has no capacity cap yet at this point, so this can't be clamped # by the soon-to-exist fleet's cargo capability. game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) + # #219: fleet capacity is built now - one berth free, one per Fleet + # Office. This test flies more than one vessel, so it needs the offices + # standing before the fleet spawns into them. + load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd index ace2ab7..e006315 100644 --- a/tests/test_wave_system.gd +++ b/tests/test_wave_system.gd @@ -126,20 +126,22 @@ func _state_cases() -> bool: var patient: int = game_state.wave_number for wave in [1, 5, 20, 60]: game_state.wave_number = wave - for size in range(1, GameState.BASE_FLEET_SIZE + 1): + for size in range(1, 4): for id in game_state.wave_composition(size): if id != "chaser": game_state.wave_number = patient return _fail("wave %d against a fleet of %d fields a %s" \ % [wave, size, id]) game_state.wave_number = patient - print("a fleet of %d or fewer meets chasers only, at every wave number" \ - % GameState.BASE_FLEET_SIZE) + print("a fleet of 3 or fewer meets chasers only, at every wave number") # And each vessel past the starting three opens exactly one more kind, so # the ramp is progressive rather than a cliff. + # Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting + # capacity to 1, and this is about the roster's own thresholds (4, 5, 6), + # not about where the fleet happens to begin. var previous_kinds: int = 1 - for size in range(GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 4): + for size in range(3, 8): var kinds_here: Dictionary = {} for id in game_state.wave_composition(size): kinds_here[id] = true diff --git a/ui/AbilityButtonLayout.gd.uid b/ui/AbilityButtonLayout.gd.uid new file mode 100644 index 0000000..35c0496 --- /dev/null +++ b/ui/AbilityButtonLayout.gd.uid @@ -0,0 +1 @@ +uid://dxsx3ilfx442g diff --git a/ui/ScannerGuidance.gd.uid b/ui/ScannerGuidance.gd.uid new file mode 100644 index 0000000..fd416ff --- /dev/null +++ b/ui/ScannerGuidance.gd.uid @@ -0,0 +1 @@ +uid://b8opi6d0vr1ib diff --git a/ui/menus/BuildMenu.tscn b/ui/menus/BuildMenu.tscn index 1a44c88..ea490d7 100644 --- a/ui/menus/BuildMenu.tscn +++ b/ui/menus/BuildMenu.tscn @@ -328,6 +328,46 @@ offset_top = 774 offset_bottom = 826 script = ExtResource("price_tag") +[node name="FleetOfficeButton" type="Control" parent="."] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 856 +offset_bottom = 984 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.45, 0.78, 1, 1) + +[node name="FleetOfficeLabel" type="Label" parent="."] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -150 +offset_right = 150 +offset_top = 990 +offset_bottom = 1056 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Fleet Office" +horizontal_alignment = 1 + +[node name="FleetOfficeCost" type="Control" parent="."] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 1060 +offset_bottom = 1112 +script = ExtResource("price_tag") + [node name="OutpostButton" type="Control" parent="."] anchor_left = 0.5 anchor_right = 0.5 diff --git a/ui/menus/ScannerMenu.gd.uid b/ui/menus/ScannerMenu.gd.uid new file mode 100644 index 0000000..875b088 --- /dev/null +++ b/ui/menus/ScannerMenu.gd.uid @@ -0,0 +1 @@ +uid://b0i5o58y5rycg diff --git a/vessels/Fleet.gd b/vessels/Fleet.gd index d88e970..25422b2 100644 --- a/vessels/Fleet.gd +++ b/vessels/Fleet.gd @@ -51,6 +51,23 @@ func _ready() -> void: # Researching a vessel upgrade improves the ships already flying, not # just the next one built (#5). GameState.vessel_upgraded.connect(_on_vessel_upgraded) + # #219: a Fleet Office that runs dry loses the vessel it was keeping. + # GameState decides THAT it happens - it owns the supply - and the fleet + # owns the hulls, so it is the one that gives this one up. + GameState.fleet_upkeep_failed.connect(_on_fleet_upkeep_failed) + + +# Destroyed rather than stored: the vessel is not being put away, it is being +# lost, and taking it out through the ordinary death path means it explodes, +# is heard, and leaves the formation exactly as a vessel shot down does. +func _on_fleet_upkeep_failed(slot_index: int, _vessel_type: String) -> void: + if slot_index < 0 or slot_index >= ships.size(): + return + var lost: Node = ships[slot_index] + if is_instance_valid(lost) and not lost.is_queued_for_deletion(): + # scuttle(), not take_damage(): a shield charge would ABSORB a hit, + # and there is no hit here - the berth simply stopped being paid for. + lost.scuttle() func _on_vessel_upgraded(_id: String) -> void: diff --git a/vessels/Ship.gd b/vessels/Ship.gd index d63bdfc..19fe49b 100644 --- a/vessels/Ship.gd +++ b/vessels/Ship.gd @@ -171,6 +171,15 @@ func take_damage(_amount: int) -> bool: for child in get_children(): if child.has_method("absorb_hit") and child.absorb_hit(): return true + scuttle() + return false + + +# Lost for a reason no shield can answer - #219's Fleet Office running dry, +# rather than something hitting it. Same ending as a lethal hit, deliberately: +# what the player sees is a vessel they had and no longer have, and giving +# that its own quieter exit would read as the ship simply vanishing. +func scuttle() -> void: # #130: the hull comes off the formation and explodes further out, rather # than going up on the spot. The wreck is its own node - this one is # still freed on this frame, so nothing about who is in the fleet, who @@ -182,7 +191,6 @@ func take_damage(_amount: int) -> bool: Sfx.play(get_tree(), Sfx.SHIP_DEATH) destroyed.emit() queue_free() - return false # Which way is "out of the fleet". Away from the formation's own centre, so a