From 2bb46cba94458695e2c432a525072cf787a5a7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 5 Sep 2026 22:36:21 +0200 Subject: [PATCH] Charge more for each berth, and show the bill #220: every berth past the first paid one costs 1.12x the one before it, on top of the flat per-vessel charge. A linear bill makes the tenth hull no more of a decision than the second; compounding weighs each addition against the whole fleet behind it. Shallow on purpose - the thirteenth berth costs about three and a half times the second, the twentieth about seven and a half. Fleet Offices gain a menu naming the whole bill per minute, a line per paying berth with its multiplier, and the arithmetic in words. It renders fleet_upkeep_breakdown(), which is the calculation the charging loop itself runs: a readout that summed independently could quote a bill the game does not take, which is the thing worth preventing here. The test pins that what the menu quotes for a berth is what that office is charged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- buildings/FleetOfficeBuildingVisual.tscn | 2 +- controllers/BuildController.gd | 7 +- core/GameState.gd | 63 ++++++++++++- tests/_fleet_upkeep_preview.gd | 34 +++++++ tests/_fleet_upkeep_preview.tscn | 6 ++ tests/test_fleet_upkeep_scaling.gd | 111 +++++++++++++++++++++++ ui/menus/FleetUpkeepMenu.gd | 71 +++++++++++++++ ui/menus/FleetUpkeepMenu.tscn | 77 ++++++++++++++++ world/Main.tscn | 3 + 9 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 tests/_fleet_upkeep_preview.gd create mode 100644 tests/_fleet_upkeep_preview.tscn create mode 100644 tests/test_fleet_upkeep_scaling.gd create mode 100644 ui/menus/FleetUpkeepMenu.gd create mode 100644 ui/menus/FleetUpkeepMenu.tscn diff --git a/buildings/FleetOfficeBuildingVisual.tscn b/buildings/FleetOfficeBuildingVisual.tscn index 8370f6d..d8e6691 100644 --- a/buildings/FleetOfficeBuildingVisual.tscn +++ b/buildings/FleetOfficeBuildingVisual.tscn @@ -8,4 +8,4 @@ script = ExtResource("1") [node name="ActionProvider" type="Node" parent="."] script = ExtResource("2") -actions = PackedStringArray("transfer") +actions = PackedStringArray("upkeep", "transfer") diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 2db5156..308e93f 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -34,6 +34,8 @@ const HUD_ROW_GAP := 16.0 @onready var garrison_menu: Control = get_node("../UI/GarrisonMenu") # #216: where a hub or a workshop is told how many craft to keep. @onready var station_menu: Control = get_node("../UI/StationMenu") +# #220: what the fleet costs to keep, and the arithmetic behind it. +@onready var upkeep_menu: Control = get_node("../UI/FleetUpkeepMenu") @onready var scanner_menu: Control = get_node("../UI/ScannerMenu") var pending_build_type: String = "" @@ -114,7 +116,7 @@ func _seat_hud_columns() -> void: func _menu_is_open() -> bool: for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_menu, formation_menu, skill_menu, planning_menu, garrison_menu, scanner_menu, - station_menu]: + station_menu, upkeep_menu]: if menu.visible: return true return false @@ -319,6 +321,8 @@ func _on_action_pressed() -> void: scanner_menu.open_menu() elif building_action == "staff": station_menu.open_for(building_id) + elif building_action == "upkeep": + upkeep_menu.open_for(building_id) func _on_scan_requested(definition_id: String) -> void: @@ -797,6 +801,7 @@ func _layout_hangar() -> void: func _refresh_garrison() -> void: garrison_menu.refresh() station_menu.refresh() + upkeep_menu.refresh() func _refresh_planning() -> void: diff --git a/core/GameState.gd b/core/GameState.gd index 6c92972..92f69a4 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -776,6 +776,16 @@ signal fleet_upkeep_failed(slot_index: int, vessel_type: String) # 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 +# #220: and each berth costs more than the one before it. A flat per-vessel +# charge means a big fleet is only linearly harder to keep, so the tenth hull +# is no more of a decision than the second; compounding makes every addition +# weigh against the whole fleet behind it. +# +# Deliberately shallow - the ticket asks for a slow one. At 1.12 the thirteenth +# berth costs about three and a half times the second, and the twentieth about +# seven and a half. A steeper rate reaches "unaffordable" while the player is +# still learning what the fleet is for. +const FLEET_UPKEEP_GROWTH: float = 1.12 # Offices in the order they were built, which is the order they are matched to @@ -800,16 +810,62 @@ func supplied_fleet_office_count() -> int: # 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: +# What one berth costs per charge. `slot_index` is the fleet position it +# keeps, so the first berth past the free one pays the base rate and every +# berth after it pays the compounded one (#220). +func fleet_office_upkeep(vessel_type: String, slot_index: int = BASE_FLEET_SIZE) -> Dictionary: var upkeep: Dictionary = {} + var scale: float = fleet_upkeep_scale(slot_index) 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))) + / float(FLEET_OFFICE_UPKEEP_DIVISOR) * scale)) if share > 0: upkeep[resource_type] = share return upkeep +# The multiplier on a berth at this fleet position. The free hull and the +# first paid berth both sit at 1.0, so the curve starts where the charging +# starts rather than partway up it. +func fleet_upkeep_scale(slot_index: int) -> float: + return pow(FLEET_UPKEEP_GROWTH, maxi(slot_index - BASE_FLEET_SIZE, 0)) + + +# The whole bill, berth by berth, for the readout #220 asks for: what each +# one is keeping, what it is charged, and what that is per minute. Computed +# here rather than in the menu so the numbers shown are the numbers charged - +# a readout that recomputes them separately can disagree with the loop that +# actually takes the resources. +func fleet_upkeep_breakdown() -> Dictionary: + var berths: Array = [] + var per_minute: Dictionary = {} + var charges_per_minute: float = 60.0 / FLEET_OFFICE_UPKEEP_SECONDS + var offices: Array = fleet_offices() + for i in range(offices.size()): + var slot_index: int = i + BASE_FLEET_SIZE + var vessel_type: String = fleet_office_vessel(slot_index) + if vessel_type == "": + continue # a berth standing empty is charged nothing + var costs: Dictionary = fleet_office_upkeep(vessel_type, slot_index) + berths.append({ + "slot": slot_index + 1, + "vessel_type": vessel_type, + "scale": fleet_upkeep_scale(slot_index), + "costs": costs, + "starved": bool(offices[i].get("starved", false)), + }) + for resource_type in costs: + per_minute[resource_type] = float(per_minute.get(resource_type, 0.0)) \ + + float(costs[resource_type]) * charges_per_minute + return { + "berths": berths, + "per_minute": per_minute, + "charge_seconds": FLEET_OFFICE_UPKEEP_SECONDS, + "divisor": FLEET_OFFICE_UPKEEP_DIVISOR, + "growth": FLEET_UPKEEP_GROWTH, + } + + # 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: @@ -841,7 +897,8 @@ func _advance_fleet_office_upkeep(delta: float) -> void: office.upkeep_progress = progress continue office.upkeep_progress = progress - FLEET_OFFICE_UPKEEP_SECONDS - var upkeep: Dictionary = fleet_office_upkeep(vessel_type) + var upkeep: Dictionary = fleet_office_upkeep(vessel_type, + i + BASE_FLEET_SIZE) var inventory: RefCounted = _inventory_for_record(office) var affordable: bool = true for resource_type in upkeep: diff --git a/tests/_fleet_upkeep_preview.gd b/tests/_fleet_upkeep_preview.gd new file mode 100644 index 0000000..4f98314 --- /dev/null +++ b/tests/_fleet_upkeep_preview.gd @@ -0,0 +1,34 @@ +extends Node2D + +# Preview, not a test - #220's upkeep readout with a fleet worth reading: +# several berths of different hulls, so the per-berth lines, the compounding +# multiplier and the per-minute total all have something to show. +# +# bash tests/screenshot.sh res://tests/_fleet_upkeep_preview.tscn shots/fleet_upkeep.png 3 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.transporters.clear() + GameState.reset_progression() + GameState.intro_active = false + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + var office_ids: Array = [] + for i in range(5): + office_ids.append(GameState.add_building(GameState.BUILDING_FLEET_OFFICE, + Vector2(400.0 * float(i + 1), 300.0))) + var main: Node = load("res://world/Main.tscn").instantiate() + add_child(main) + main.get_node("AsteroidField").set_process(false) + main.get_node("EnemySpawner").set_process(false) + # Spawned for real rather than by writing fleet_vessel_types directly: the + # Fleet rewrites that from what is actually flying the moment it composes + # itself, so a value set beforehand is discarded. + main.get_node("Fleet").spawn_ships(6, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, + GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_DISTORDER, + GameState.VESSEL_LASER, GameState.VESSEL_SHIELD])) + # One office left unsupplied, so the unpaid marker is visible too. + GameState.find_building(int(office_ids[3])).starved = true + main.get_node("UI/FleetUpkeepMenu").open_for(int(office_ids[0])) diff --git a/tests/_fleet_upkeep_preview.tscn b/tests/_fleet_upkeep_preview.tscn new file mode 100644 index 0000000..837556c --- /dev/null +++ b/tests/_fleet_upkeep_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_fleet_upkeep_preview.gd" id="1"] + +[node name="FleetUpkeepPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_fleet_upkeep_scaling.gd b/tests/test_fleet_upkeep_scaling.gd new file mode 100644 index 0000000..b9d240f --- /dev/null +++ b/tests/test_fleet_upkeep_scaling.gd @@ -0,0 +1,111 @@ +extends SceneTree + +# Forgejo #220: the fleet costs more per hull as it grows, and the Fleet +# Office says so with the arithmetic shown. +# +# The interesting failure is not "the sum is wrong" - it is the readout and +# the charging loop drifting apart, so the menu quotes a bill the game does +# not take. So what is checked is that the SAME numbers come out of both. +# +# Covers: +# A. the first paid berth pays the base rate, undialled +# B. each berth past it pays more than the one before, compounding +# C. the growth is slow - a mid-sized fleet is dearer, not ruinous +# D. the breakdown's per-minute total matches its own per-berth charges +# E. and the charges it quotes are the ones actually withdrawn +# F. an empty berth is quoted nothing + +var state: Node + + +func _initialize() -> void: + state = root.get_node("GameState") + state.buildings.clear() + state.transporters.clear() + state.reset_progression() + state.reset_new_game() + state.intro_active = false + state.fleet_vessel_types = PackedStringArray() + + +func _process(_delta: float) -> bool: + var vessel: String = GameState.VESSEL_LASER + var base: Dictionary = state.fleet_office_upkeep(vessel, GameState.BASE_FLEET_SIZE) + if base.is_empty(): + return _fail("a laser berth is charged nothing at all") + + # --- A. the first paid berth is the base rate ------------------- + if not is_equal_approx(state.fleet_upkeep_scale(GameState.BASE_FLEET_SIZE), 1.0): + return _fail("the first paid berth is already dialled up (x%.2f)" \ + % state.fleet_upkeep_scale(GameState.BASE_FLEET_SIZE)) + + # --- B. and every berth after it costs more --------------------- + var previous: float = 0.0 + for slot in range(GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 12): + var scale: float = state.fleet_upkeep_scale(slot) + if scale < previous: + return _fail("berth %d is cheaper than the one before it" % (slot + 1)) + previous = scale + var twelfth: float = state.fleet_upkeep_scale(GameState.BASE_FLEET_SIZE + 11) + if twelfth <= 1.0: + return _fail("twelve berths in, upkeep has not risen at all") + + # --- C. slowly, though ------------------------------------------ + if twelfth > 6.0: + return _fail("the twelfth berth costs x%.1f - that is not a slow exponential" % twelfth) + print("berth scaling: 1.00 at the first paid berth, x%.2f twelve in" % twelfth) + + # --- D/E/F. the quoted bill IS the charged one ------------------ + # Three offices, two of them crewed: the third berth stands empty and + # must be quoted nothing. + var office_ids: Array = [] + for i in range(3): + office_ids.append(state.add_building(GameState.BUILDING_FLEET_OFFICE, + Vector2(500.0 * float(i + 1), 0.0))) + state.fleet_vessel_types = PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, vessel, vessel]) + + var breakdown: Dictionary = state.fleet_upkeep_breakdown() + var berths: Array = breakdown.berths + if berths.size() != 2: + return _fail("%d berths quoted for a fleet of 3 with 3 offices, expected 2 paying" \ + % berths.size()) + if int(berths[1].costs.get(GameState.RESOURCE_ORE, 0)) \ + <= int(berths[0].costs.get(GameState.RESOURCE_ORE, 0)) - 1: + return _fail("the second paid berth is quoted less than the first") + + # The per-minute figure has to be the per-berth charges, at the rate they + # are actually taken - not a separate estimate. + var charges: float = 60.0 / float(breakdown.charge_seconds) + for resource_type in breakdown.per_minute: + var summed: float = 0.0 + for berth in berths: + summed += float(berth.costs.get(resource_type, 0)) * charges + if not is_equal_approx(float(breakdown.per_minute[resource_type]), summed): + return _fail("%s quoted at %.2f/min, its own berths add to %.2f" % [ + resource_type, float(breakdown.per_minute[resource_type]), summed]) + print("bill: %s, from %d paying berths" % [breakdown.per_minute, berths.size()]) + + # And a charge actually taken matches what was quoted for that berth. + var quoted: int = int(berths[0].costs.get(GameState.RESOURCE_ORE, 0)) + var office: Dictionary = state.find_building(int(office_ids[0])) + var store: RefCounted = state.building_inventory(int(office_ids[0])) + store.set_amount(GameState.RESOURCE_ORE, 500) + store.set_amount(GameState.RESOURCE_ENERGY, 500) + var before: int = store.amount(GameState.RESOURCE_ORE) + office.upkeep_progress = GameState.FLEET_OFFICE_UPKEEP_SECONDS + state._advance_fleet_office_upkeep(0.1) + var taken: int = before - store.amount(GameState.RESOURCE_ORE) + if taken != quoted: + return _fail("berth 2 was quoted %d ore and charged %d" % [quoted, taken]) + print("what the menu quotes for a berth is what the office pays: %d ore" % taken) + + print("TEST_FLEET_UPKEEP_SCALING: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_FLEET_UPKEEP_SCALING: FAIL (%s)" % message) + quit(1) + return true diff --git a/ui/menus/FleetUpkeepMenu.gd b/ui/menus/FleetUpkeepMenu.gd new file mode 100644 index 0000000..c662d52 --- /dev/null +++ b/ui/menus/FleetUpkeepMenu.gd @@ -0,0 +1,71 @@ +extends "res://ui/menus/ModalMenu.gd" + +# Forgejo #220: what the fleet costs to keep, and WHY it costs that. +# +# A Fleet Office charges for the vessel in its berth every 25 seconds, and +# each berth costs more than the one before it. Neither of those is visible +# from the resource bar - the player watches a number fall and cannot tell +# which of their own decisions is draining it - so this states the bill and +# shows the arithmetic that produced it, line by line. +# +# Every number here comes from GameState.fleet_upkeep_breakdown(), which is +# the same calculation the charging loop runs. A readout that did its own sum +# could disagree with what is actually being taken, which is exactly the kind +# of "the game is lying to me" the ticket is about. + +@onready var total_label: Label = $TotalLabel +@onready var detail_label: Label = $DetailLabel +@onready var formula_label: Label = $FormulaLabel + +var building_id: int = 0 + + +func open_for(next_building_id: int) -> void: + building_id = next_building_id + open_menu() + refresh() + + +func close_menu() -> void: + super.close_menu() + building_id = 0 + + +func refresh() -> void: + if not visible: + return + var breakdown: Dictionary = GameState.fleet_upkeep_breakdown() + var berths: Array = breakdown.berths + var per_minute: Dictionary = breakdown.per_minute + + if per_minute.is_empty(): + total_label.text = "NOTHING TO KEEP" + else: + var parts: PackedStringArray = PackedStringArray() + for resource_type in per_minute: + parts.append("%.1f %s" % [float(per_minute[resource_type]), + GameState.resource_name(String(resource_type)).to_lower()]) + total_label.text = "%s PER MINUTE" % " · ".join(parts) + + # One line per paying berth: which hull, what it is charged, and the + # multiplier that got it there. An empty berth is charged nothing and is + # left out rather than listed at zero. + var lines: PackedStringArray = PackedStringArray() + for berth in berths: + var costs: PackedStringArray = PackedStringArray() + for resource_type in berth.costs: + costs.append("%d %s" % [int(berth.costs[resource_type]), + GameState.resource_name(String(resource_type)).to_lower()]) + lines.append("BERTH %d %-16s %s (x%.2f)%s" % [ + int(berth.slot), GameState.vessel_name(String(berth.vessel_type)), + " ".join(costs), float(berth.scale), + " — UNPAID" if bool(berth.starved) else "", + ]) + detail_label.text = "\n".join(lines) if not lines.is_empty() \ + else "No berth is being charged. Build a Fleet Office and put a vessel in it." + + formula_label.text = ("Each berth is charged every %ds: the vessel's build price " + + "divided by %d, then multiplied by %.2f for every berth past the first " + + "paid one. The first vessel is free.") % [ + int(breakdown.charge_seconds), int(breakdown.divisor), + float(breakdown.growth)] diff --git a/ui/menus/FleetUpkeepMenu.tscn b/ui/menus/FleetUpkeepMenu.tscn new file mode 100644 index 0000000..ad4d649 --- /dev/null +++ b/ui/menus/FleetUpkeepMenu.tscn @@ -0,0 +1,77 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ui/IconButton.gd" id="icon"] +[ext_resource type="Script" path="res://ui/menus/FleetUpkeepMenu.gd" id="menu"] + +[node name="FleetUpkeepMenu" type="Control" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("menu") + +[node name="Backdrop" type="ColorRect" parent="."] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.96) + +[node name="TitleLabel" type="Label" parent="."] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 24.0 +offset_right = -180.0 +offset_bottom = 100.0 +theme_override_font_sizes/font_size = 40 +text = "FLEET UPKEEP" +horizontal_alignment = 1 + +[node name="CloseButton" type="Control" parent="."] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_top = 24.0 +offset_right = -24.0 +offset_bottom = 140.0 +script = ExtResource("icon") +icon = 1 +glow_color = Color(0.55, 0.6, 1, 1) + +[node name="TotalLabel" type="Label" parent="."] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 32.0 +offset_top = 150.0 +offset_right = -32.0 +offset_bottom = 220.0 +theme_override_font_sizes/font_size = 32 +theme_override_colors/font_color = Color(1, 0.85, 0.35, 1) +text = "" +horizontal_alignment = 1 + +[node name="DetailLabel" type="Label" parent="."] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 32.0 +offset_top = 250.0 +offset_right = -32.0 +offset_bottom = 860.0 +theme_override_font_sizes/font_size = 30 +theme_override_colors/font_color = Color(0.72, 0.88, 0.96, 1) +text = "" +autowrap_mode = 2 + +[node name="FormulaLabel" type="Label" parent="."] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 40.0 +offset_top = 880.0 +offset_right = -40.0 +offset_bottom = 1060.0 +theme_override_font_sizes/font_size = 26 +theme_override_colors/font_color = Color(0.55, 0.78, 0.86, 1) +text = "" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 diff --git a/world/Main.tscn b/world/Main.tscn index 3274663..66e6780 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -11,6 +11,7 @@ [ext_resource type="PackedScene" path="res://ui/menus/DevMenu.tscn" id="menu_dev"] [ext_resource type="PackedScene" path="res://ui/menus/ScannerMenu.tscn" id="menu_scanner"] [ext_resource type="PackedScene" path="res://ui/menus/StationMenu.tscn" id="menu_station"] +[ext_resource type="PackedScene" path="res://ui/menus/FleetUpkeepMenu.tscn" id="menu_upkeep"] [ext_resource type="PackedScene" path="res://ui/ScannerGuidance.tscn" id="scanner_guidance"] @@ -530,6 +531,8 @@ icon = 1 [node name="ScannerMenu" parent="UI" instance=ExtResource("menu_scanner")] [node name="StationMenu" parent="UI" instance=ExtResource("menu_station")] + +[node name="FleetUpkeepMenu" parent="UI" instance=ExtResource("menu_upkeep")] [node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2