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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-05 22:36:21 +02:00
commit 2bb46cba94
9 changed files with 369 additions and 5 deletions

View file

@ -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)]

View file

@ -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