Skip to content

Ship Classes (EVE-style) Implementation Plan

Ship Classes (EVE-style) Implementation Plan

Section titled “Ship Classes (EVE-style) Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give hulls an EVE-style class: the 6 existing ships become frigate-class (3 weapon / 1 drone slot at today’s stats); add one gold-unlocked cruiser hull (Obsidian) with a full stat block (5 weapon / 2 drone, tankier, slower, bigger).

Architecture: A hull’s slot counts + base stats + headline bonus live as data in sim/ship_bonuses.gd (TABLE). Weapon-slot count becomes per-run sim state (Sim.max_weapon_slots, mirroring the existing Sim.max_drone_slots). At run start (render-side, main.gd) ShipBonuses.apply_base() sets the hull’s base stats + slot counts before meta-shop upgrades and the headline bonus stack on top. The cruiser gates on an unlock-hull-obsidian meta-shop purchase via MetaState.owns_ship(), mirroring the existing drone-class/decoy unlock pattern.

Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 (headless tests).

  • /sim purity: every file under sim/ extends RefCounted and touches NO Node/render/Input/Engine/Time API. ship_bonuses.gd, sim.gd, meta_state.gd, upgrade_system.gd are all /sim — keep them pure.
  • Determinism baseline (read the literal pinned assertion, never prose): tests/test_determinism_checksum.gd + tests/test_determinism_crystals.gd pin snapshot_string().hash()=2730172591, state_checksum()=4075578713. This plan must leave them byte-identical — the baseline builds Sim with no hull (default max_weapon_slots=6, frigate stats = today’s). If a task moves the baseline, treat it as a real break and investigate; do NOT re-pin.
  • class is a GDScript reserved word — the hull class field is named klass (matches the existing drone-loadout code).
  • Sim and UpgradeSystem already form a type cycle (every UpgradeSystem method takes sim: Sim). Do NOT reference UpgradeSystem.MAX_WEAPONS in a Sim member initializer — default the new field to the literal 6 with a comment.
  • tvOS is symlinked — gameplay dirs under platform/tvos/ are symlinks to the repo root. NEVER cp/rsync gameplay into it. New sprite assets are the only files that need explicit attention (they live under render/ship_sprites/, which is symlinked, so they appear automatically).
  • Every task ends via the bh-dev-chunk ritual: build → TDD → godot --headless --path . --import → boot-smoke → test-count guard → determinism re-verify → commit. Commit straight to main (this project’s convention — chunks + docs commit to main).
  • Naming/numbers are provisional (per the spec) — Obsidian, 5/2, 170/210/24, +10 armor, 800 gold are tunable placeholders.

Test commands:

  • Full suite: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
  • Single file: append -gtest=res://tests/<file>.gd (repeatable) instead of -gdir=...
  • Import (after adding a class/asset): godot --headless --path . --import
  • Boot smoke: godot --headless --path . --quit-after 120 then grep stderr for SCRIPT ERROR

Task 1: Enrich ShipBonuses with class, slots & base stats

Section titled “Task 1: Enrich ShipBonuses with class, slots & base stats”

Add the hull data + accessors. The cruiser goes in TABLE (so accessors are testable) but NOT yet in ORDER (it becomes selectable in Task 5) — keeping this task a pure additive-data change with no UI/behaviour shift.

Files:

  • Modify: sim/ship_bonuses.gd
  • Test: tests/test_ship_bonuses.gd

Interfaces:

  • Produces: ShipBonuses.class_of(id) -> String, weapon_slots_for(id) -> int, drone_slots_for(id) -> int, base_stats_for(id) -> Dictionary (keys "hp","speed","radius", all float). Unknown id → frigate defaults ("frigate", 3, 1, 100/260/16). TABLE gains key "obsidian".

  • Step 1: Write the failing tests — append to tests/test_ship_bonuses.gd:

func test_existing_ships_are_frigates() -> void:
for id in ["manta", "cobalt", "aurum", "prism", "amethyst", "fuchsia"]:
assert_eq(ShipBonuses.class_of(id), "frigate", "%s is a frigate" % id)
assert_eq(ShipBonuses.weapon_slots_for(id), 3, "%s has 3 weapon slots" % id)
assert_eq(ShipBonuses.drone_slots_for(id), 1, "%s has 1 drone slot" % id)
func test_frigate_base_stats_match_todays_defaults() -> void:
var b := ShipBonuses.base_stats_for("cobalt")
assert_eq(b["hp"], 100.0)
assert_eq(b["speed"], 260.0)
assert_eq(b["radius"], 16.0)
func test_obsidian_is_a_cruiser_with_more_slots() -> void:
assert_eq(ShipBonuses.class_of("obsidian"), "cruiser")
assert_eq(ShipBonuses.weapon_slots_for("obsidian"), 5)
assert_eq(ShipBonuses.drone_slots_for("obsidian"), 2)
var b := ShipBonuses.base_stats_for("obsidian")
assert_eq(b["hp"], 170.0)
assert_eq(b["speed"], 210.0)
assert_eq(b["radius"], 24.0)
func test_obsidian_bonus_is_armor() -> void:
var player := PlayerState.new()
var before := player.armor
ShipBonuses.apply_to("obsidian", player, ModState.new())
assert_gt(player.armor, before, "Obsidian grants armor")
func test_unknown_ship_defaults_to_frigate() -> void:
assert_eq(ShipBonuses.class_of("not-a-real-ship"), "frigate")
assert_eq(ShipBonuses.weapon_slots_for("not-a-real-ship"), 3)
assert_eq(ShipBonuses.drone_slots_for("not-a-real-ship"), 1)
var b := ShipBonuses.base_stats_for("not-a-real-ship")
assert_eq(b["hp"], 100.0)
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_bonuses.gd -gexit Expected: FAIL — class_of/weapon_slots_for/etc. do not exist; "obsidian" unknown.

  • Step 3: Enrich TABLE and add accessors — replace the TABLE const in sim/ship_bonuses.gd (keep the existing is_known/name_for/label_for/apply_to/ORDER/DEFAULT_SHIP below it) with:
# Each entry is a full hull spec: class tier + slot counts + base stats + one headline bonus
# (reusing the StatEffects/SimMods vocabulary). NOTE: the class_name stays "ShipBonuses" for
# back-compat (referenced in 5 files); it is now really a hull registry. Field is "klass"
# because `class` is a GDScript reserved word.
const TABLE := {
"manta": {"klass": "frigate", "weapon_slots": 3, "drone_slots": 1, "base_hp": 100.0, "base_speed": 260.0, "base_radius": 16.0, "stat_effect": "damage_mult", "magnitude": 1.20, "name": "Manta", "label": "+20% damage"},
"cobalt": {"klass": "frigate", "weapon_slots": 3, "drone_slots": 1, "base_hp": 100.0, "base_speed": 260.0, "base_radius": 16.0, "stat_effect": "move_speed", "magnitude": 1.15, "name": "Cobalt", "label": "+15% speed"},
"aurum": {"klass": "frigate", "weapon_slots": 3, "drone_slots": 1, "base_hp": 100.0, "base_speed": 260.0, "base_radius": 16.0, "stat_effect": "armor", "magnitude": 5.0, "name": "Aurum", "label": "+5 armor"},
"prism": {"klass": "frigate", "weapon_slots": 3, "drone_slots": 1, "base_hp": 100.0, "base_speed": 260.0, "base_radius": 16.0, "mod_effect": "lifesteal_per_kill", "magnitude": 3.0, "name": "Prism", "label": "+3 HP per kill"},
"amethyst": {"klass": "frigate", "weapon_slots": 3, "drone_slots": 1, "base_hp": 100.0, "base_speed": 260.0, "base_radius": 16.0, "stat_effect": "fire_rate_mult", "magnitude": 1.15, "name": "Amethyst", "label": "+15% fire rate"},
"fuchsia": {"klass": "frigate", "weapon_slots": 3, "drone_slots": 1, "base_hp": 100.0, "base_speed": 260.0, "base_radius": 16.0, "stat_effect": "pickup_radius", "magnitude": 1.20, "name": "Fuchsia", "label": "+20% pickup radius"},
"obsidian": {"klass": "cruiser", "weapon_slots": 5, "drone_slots": 2, "base_hp": 170.0, "base_speed": 210.0, "base_radius": 24.0, "stat_effect": "armor", "magnitude": 10.0, "name": "Obsidian", "label": "Cruiser · +10 armor"},
}

Add these static accessors (anywhere below TABLE, e.g. after label_for):

# Class tier of a hull. Unknown/stale id → "frigate" (safe default).
static func class_of(ship_id: String) -> String:
return String(TABLE.get(ship_id, {}).get("klass", "frigate"))
# Weapon slots this hull fields. Unknown → 3 (frigate).
static func weapon_slots_for(ship_id: String) -> int:
return int(TABLE.get(ship_id, {}).get("weapon_slots", 3))
# Base drone slots this hull fields (the meta-shop "drone-slots" upgrade adds on top). Unknown → 1.
static func drone_slots_for(ship_id: String) -> int:
return int(TABLE.get(ship_id, {}).get("drone_slots", 1))
# The hull's base hp/speed/radius (before meta upgrades + the headline bonus). Unknown → today's defaults.
static func base_stats_for(ship_id: String) -> Dictionary:
var s: Dictionary = TABLE.get(ship_id, {})
return {"hp": float(s.get("base_hp", 100.0)), "speed": float(s.get("base_speed", 260.0)), "radius": float(s.get("base_radius", 16.0))}
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_bonuses.gd -gexit Expected: PASS (all ship_bonuses tests, including the pre-existing ones — the 6 frigate bonuses are unchanged).

  • Step 5: Commit
Terminal window
git add sim/ship_bonuses.gd tests/test_ship_bonuses.gd
git commit -m "feat(ships): hull class + slot + base-stat data model (frigate/cruiser)"

Turn the weapon-slot ceiling into per-run state the hull can lower. UpgradeSystem.MAX_WEAPONS (6) stays as the absolute ceiling; the two grant-gates read sim.max_weapon_slots instead.

Files:

  • Modify: sim/sim.gd (add field near line 479, by max_drone_slots)
  • Modify: sim/upgrade_system.gd:58 and :274
  • Test: tests/test_ship_class_slots.gd (new)

Interfaces:

  • Consumes: nothing new.

  • Produces: Sim.max_weapon_slots: int (default 6). roll_upgrade_choices/grant_weapon respect it.

  • Step 1: Write the failing test — create tests/test_ship_class_slots.gd:

extends GutTest
# Sim.max_weapon_slots caps how many weapons a run can hold (set per-hull at run start). Default
# equals the ceiling (6) so hull-less test Sims + the determinism baseline are unaffected.
func test_default_is_the_ceiling() -> void:
var sim := Sim.new(1, SimContentFixture.db())
assert_eq(sim.max_weapon_slots, 6, "default max_weapon_slots is the ceiling")
func test_grant_weapon_stops_at_the_cap() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.max_weapon_slots = 3
# Grant three extra weapons beyond the starting one (pulse) up to the cap of 3.
sim.upgrade_system.grant_weapon(sim, "nova")
sim.upgrade_system.grant_weapon(sim, "orbit") # now at 3 (pulse, nova, orbit)
assert_eq(sim.active_weapon_ids.size(), 3, "filled to the frigate cap of 3")
sim.upgrade_system.grant_weapon(sim, "beam") # over cap — must be refused
assert_eq(sim.active_weapon_ids.size(), 3, "grant refused once at the cap")
assert_false(sim.active_weapon_ids.has("beam"), "beam was not granted over the cap")
func test_roll_offers_no_weapon_grant_when_full() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.max_weapon_slots = 1 # the starting weapon (pulse) already fills it
sim.player.level = 9 # well past any level gate
var offered := sim.upgrade_system.roll_upgrade_choices(sim, 3)
for id in offered:
assert_false(String(id).begins_with("weapon:"), "no weapon grant offered at the cap: %s" % id)
  • Step 2: Run the test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_class_slots.gd -gexit Expected: FAIL — sim.max_weapon_slots does not exist (parse/runtime error), or the cap is ignored.

  • Step 3: Add the field — in sim/sim.gd, immediately after the max_drone_slots line (~479):
var max_weapon_slots: int = 6 # per-run active weapon-slot cap; the hull lowers it at run start.
# = UpgradeSystem.MAX_WEAPONS (the ceiling) — hardcoded 6 to avoid the
# Sim<->UpgradeSystem member-initializer cycle. Default = ceiling so
# hull-less test Sims + the determinism baseline are unaffected.
  • Step 4: Re-point the two grant gates — in sim/upgrade_system.gd:

Line ~58, in roll_upgrade_choices, change:

if sim.story == null and sim.active_weapon_ids.size() < MAX_WEAPONS \
and sim.player.level >= sim.active_weapon_ids.size() * WEAPON_LEVEL_GAP:

to:

if sim.story == null and sim.active_weapon_ids.size() < sim.max_weapon_slots \
and sim.player.level >= sim.active_weapon_ids.size() * WEAPON_LEVEL_GAP:

Line ~274, in grant_weapon, change:

if is_weapon_active(sim, wid) or sim.active_weapon_ids.size() >= MAX_WEAPONS:

to:

if is_weapon_active(sim, wid) or sim.active_weapon_ids.size() >= sim.max_weapon_slots:

(Leave const MAX_WEAPONS := 6 in place — it is still the absolute ceiling used elsewhere.)

  • Step 5: Run the test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_class_slots.gd -gexit Expected: PASS.

  • Step 6: Re-verify determinism (must be unchanged)

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gtest=res://tests/test_determinism_crystals.gd -gexit Expected: PASS at 2730172591 / 4075578713. If it moved, STOP and investigate — do not re-pin.

  • Step 7: Commit
Terminal window
git add sim/sim.gd sim/upgrade_system.gd tests/test_ship_class_slots.gd
git commit -m "feat(ships): per-run Sim.max_weapon_slots cap (replaces the MAX_WEAPONS gate)"

Task 3: ShipBonuses.apply_base + run-start wiring

Section titled “Task 3: ShipBonuses.apply_base + run-start wiring”

Apply the hull’s base stats + slot counts at run start, before meta upgrades + the headline bonus stack on top. Frigates are a byte-identical no-op vs today; the cruiser gets its stat block.

Files:

  • Modify: sim/ship_bonuses.gd (add apply_base)
  • Modify: main.gd (~473-485)
  • Test: tests/test_ship_bonuses.gd

Interfaces:

  • Consumes: Sim.max_weapon_slots (Task 2); ShipBonuses.weapon_slots_for/drone_slots_for/base_stats_for (Task 1); MetaState.level_of.

  • Produces: ShipBonuses.apply_base(ship_id: String, sim: Sim, meta: MetaState) -> void.

  • Step 1: Write the failing tests — append to tests/test_ship_bonuses.gd:

func test_apply_base_frigate_is_a_no_op_vs_defaults() -> void:
var sim := Sim.new(1, SimContentFixture.db())
ShipBonuses.apply_base("cobalt", sim, MetaState.new())
assert_eq(sim.max_weapon_slots, 3, "frigate caps weapons at 3")
assert_eq(sim.max_drone_slots, 1, "frigate fields 1 drone (no shop levels)")
assert_eq(sim.player.max_hp, 100.0)
assert_eq(sim.player.speed, 260.0)
assert_eq(sim.player.radius, 16.0)
func test_apply_base_cruiser_sets_the_stat_block() -> void:
var sim := Sim.new(1, SimContentFixture.db())
ShipBonuses.apply_base("obsidian", sim, MetaState.new())
assert_eq(sim.max_weapon_slots, 5)
assert_eq(sim.max_drone_slots, 2)
assert_eq(sim.player.max_hp, 170.0)
assert_eq(sim.player.hp, 170.0, "starts at full")
assert_eq(sim.player.speed, 210.0)
assert_eq(sim.player.radius, 24.0)
func test_apply_base_adds_drone_slots_shop_level_on_top() -> void:
var sim := Sim.new(1, SimContentFixture.db())
var meta := MetaState.new()
meta.levels["drone-slots"] = 2 # two purchased drone-slot levels
ShipBonuses.apply_base("obsidian", sim, meta)
assert_eq(sim.max_drone_slots, 4, "cruiser base 2 + 2 shop levels")
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_bonuses.gd -gexit Expected: FAIL — apply_base does not exist.

  • Step 3: Add apply_base — in sim/ship_bonuses.gd, add above the existing apply_to:
# Set the hull's BASE stats + slot counts on a fresh run, BEFORE meta-shop upgrades and the
# headline bonus stack on top (call order matters — see main.gd). Render-side (like apply_to)
# but pure logic. `meta` supplies the additive drone-slots shop level. Weapon slots are clamped
# to the ceiling (there are only MAX_WEAPONS weapon types).
static func apply_base(ship_id: String, sim: Sim, meta: MetaState) -> void:
sim.max_weapon_slots = mini(weapon_slots_for(ship_id), UpgradeSystem.MAX_WEAPONS)
sim.max_drone_slots = drone_slots_for(ship_id) + meta.level_of("drone-slots")
var base := base_stats_for(ship_id)
sim.player.max_hp = base["hp"]
sim.player.hp = base["hp"]
sim.player.speed = base["speed"]
sim.player.radius = base["radius"]
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_bonuses.gd -gexit Expected: PASS.

  • Step 5: Wire into main.gd — in the if meta != null: block (~473-485), reorder so base comes first and remove the now-redundant standalone drone-slots line. Change:
var defs := sim.content.meta_upgrades()
meta.apply_to(sim.player, defs)
ShipBonuses.apply_to(meta.selected_ship, sim.player, sim.mods) # the chosen hull's fixed perk

to:

var defs := sim.content.meta_upgrades()
ShipBonuses.apply_base(meta.selected_ship, sim, meta) # hull base stats + slot counts FIRST
meta.apply_to(sim.player, defs) # meta upgrades stack on top
ShipBonuses.apply_to(meta.selected_ship, sim.player, sim.mods) # the chosen hull's headline perk

and DELETE the later standalone line (~485), since apply_base now owns it:

sim.max_drone_slots = meta.drone_slots()
  • Step 6: Boot smoke + full suite

Run: godot --headless --path . --import then godot --headless --path . --quit-after 120 (grep stderr — expect no SCRIPT ERROR), then the full suite: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: boots clean; suite green; determinism still 2730172591/4075578713.

  • Step 7: Commit
Terminal window
git add sim/ship_bonuses.gd main.gd tests/test_ship_bonuses.gd
git commit -m "feat(ships): apply hull base stats + slot counts at run start"

Task 4: UI reads the hull’s weapon-slot count

Section titled “Task 4: UI reads the hull’s weapon-slot count”

The in-run weapon dock and the ship-config panel currently draw a fixed UpgradeSystem.MAX_WEAPONS (6) slots. Make them draw the run’s actual sim.max_weapon_slots (3 for a frigate, 5 for a cruiser).

Files:

  • Modify: ui/weapon_panel.gd (~176-179)
  • Modify: ui/ship_config_panel.gd (~95)
  • Test: tests/test_ship_config_panel.gd

Interfaces:

  • Consumes: Sim.max_weapon_slots.

  • Produces: no new API — behaviour change only.

  • Step 1: Inspect the existing config-panel test — read tests/test_ship_config_panel.gd. If it asserts a fixed slot/card count of 6, that assertion must change to the hull’s count. Determine whether the panel is opened with a sim (live count) or without (menu → frigate default). Match the existing setup style.

  • Step 2: Write the failing test — append to tests/test_ship_config_panel.gd (adapt the harness to the file’s existing pattern for instancing the panel; the intent is: a cruiser run shows 5 weapon cards):

func test_config_panel_card_count_follows_hull_weapon_slots() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.max_weapon_slots = 5 # cruiser
var panel := ShipConfigPanel.new()
add_child_autofree(panel)
var meta := MetaState.new()
meta.selected_ship = "obsidian"
panel.open_config(meta, sim)
assert_eq(panel._weapon_cards.size(), 5, "cruiser shows 5 weapon-slot cards")
  • Step 3: Run the test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_config_panel.gd -gexit Expected: FAIL — panel still builds MAX_WEAPONS (6) cards.

  • Step 4: Make ship_config_panel.gd follow the hull — in open_config (~95), change:
for i in range(UpgradeSystem.MAX_WEAPONS):

to:

var slot_count := sim.max_weapon_slots if sim != null else ShipBonuses.weapon_slots_for(ship_id)
for i in range(slot_count):

(ship_id is already resolved a few lines above at var ship_id := ....)

  • Step 5: Make weapon_panel.gd follow the hull — in update_panel (~176-179), change:
if _built != UpgradeSystem.MAX_WEAPONS:
_build(UpgradeSystem.MAX_WEAPONS)

to:

if _built != sim.max_weapon_slots:
_build(sim.max_weapon_slots)
  • Step 6: Run tests + boot smoke

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_ship_config_panel.gd -gexit (PASS), then boot smoke (--quit-after 120, no SCRIPT ERROR), then full suite (green).

  • Step 7: Commit
Terminal window
git add ui/weapon_panel.gd ui/ship_config_panel.gd tests/test_ship_config_panel.gd
git commit -m "feat(ships): weapon dock + config panel draw the hull's slot count"

Task 5: Cruiser gold-unlock + selectable in the picker

Section titled “Task 5: Cruiser gold-unlock + selectable in the picker”

MetaState.owns_ship, the bible unlock def, the shop “Ships” category, and the start-menu picker showing the cruiser (locked until bought). Add obsidian to ORDER and drop in a placeholder sprite so a cruiser run is not invisible (Task 6 replaces it with real art).

Files:

  • Modify: sim/meta_state.gd (add owns_ship)
  • Modify: sim/ship_bonuses.gd (ORDER gains "obsidian")
  • Modify: data/bible.json (add unlock-hull-obsidian to the meta_upgrades array)
  • Modify: ui/shop_categories.gd:6 (ORDER gains "Ships")
  • Modify: ui/start_menu.gd (lock unowned hulls in _make_ship_button + _pick_ship)
  • Add (placeholder): render/ship_sprites/ship3d_obsidian.png
  • Test: tests/test_meta_state.gd, tests/test_ship_bonuses.gd

Interfaces:

  • Consumes: ShipBonuses.class_of (Task 1); MetaState.level_of.

  • Produces: MetaState.owns_ship(ship_id: String) -> bool.

  • Step 1: Write the failing tests — append to tests/test_meta_state.gd:

func test_frigates_are_always_owned() -> void:
var meta := MetaState.new()
for id in ["manta", "cobalt", "aurum", "prism", "amethyst", "fuchsia"]:
assert_true(meta.owns_ship(id), "%s (frigate) always owned" % id)
func test_cruiser_gates_on_the_unlock_purchase() -> void:
var meta := MetaState.new()
assert_false(meta.owns_ship("obsidian"), "cruiser locked before purchase")
meta.levels["unlock-hull-obsidian"] = 1
assert_true(meta.owns_ship("obsidian"), "cruiser owned after unlock")

Append to tests/test_ship_bonuses.gd (update the existing count test — ORDER is now 7):

func test_order_lists_frigates_plus_the_cruiser() -> void:
assert_eq(ShipBonuses.ORDER.size(), 7, "6 frigates + 1 cruiser")
assert_true("obsidian" in ShipBonuses.ORDER, "cruiser is selectable")

Then DELETE the now-stale test_order_lists_exactly_the_six_ships (it asserts size 6).

  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gtest=res://tests/test_ship_bonuses.gd -gexit Expected: FAIL — owns_ship missing; ORDER still 6.

  • Step 3: Add owns_ship — in sim/meta_state.gd, next to owns_class (~66):
# Does the player own this hull? Frigate-class hulls are always owned; heavier classes
# (cruiser+) gate on an "unlock-hull-<id>" purchase. Mirrors owns_class.
func owns_ship(ship_id: String) -> bool:
if ShipBonuses.class_of(ship_id) == "frigate":
return true
return level_of("unlock-hull-" + ship_id) >= 1
  • Step 4: Add the cruiser to ORDER — in sim/ship_bonuses.gd:
const ORDER: Array[String] = ["manta", "cobalt", "aurum", "prism", "amethyst", "fuchsia", "obsidian"]
  • Step 5: Add the bible unlock def — in data/bible.json, add this object to the meta_upgrades array (alongside the other "type": "unlock" entries):
{
"id": "unlock-hull-obsidian",
"name": "Cruiser: Obsidian",
"type": "unlock",
"target": "hull:obsidian",
"max_level": 1,
"base_cost": 800,
"cost_growth": 1,
"desc": "Heavy cruiser hull: 5 weapon / 2 drone slots, +70 HP, slower and bigger. +10 armor.",
"category": "Ships"
}
  • Step 6: Register the shop category — in ui/shop_categories.gd:6:
const ORDER: Array[String] = ["Pilot", "Drones", "Arsenal", "Utility", "Ships"]

(The card itself renders the existing “unlock” lock icon automatically via type:"unlock"; the “Ships” tab falls back to the default star glyph in ShopIcons — acceptable; a bespoke hull glyph is optional polish.)

  • Step 7: Lock unowned hulls in the picker — in ui/start_menu.gd:

In _pick_ship (~289), guard against selecting a locked hull — after the null/same-id check add:

if not meta.owns_ship(ship_id):
return # locked hull — buy it in the shop first

In _make_ship_button (just before return btn, ~284), dim + label locked hulls:

if meta != null and not meta.owns_ship(ship_id):
btn.modulate = Color(0.45, 0.45, 0.5)
var lock_lbl := Label.new()
lock_lbl.text = "LOCKED"
lock_lbl.add_theme_font_override("font", NeonTheme.mono_font())
lock_lbl.add_theme_font_size_override("font_size", 10)
lock_lbl.add_theme_color_override("font_color", Color(1.0, 0.5, 0.5))
lock_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lock_lbl.position = Vector2(0, SHIP_THUMB + 42)
lock_lbl.size = Vector2(SHIP_TILE, 14)
lock_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.add_child(lock_lbl)

(_make_ship_button already has meta in scope via the enclosing panel; confirm the field name when editing — it reads meta.selected_ship elsewhere.)

  • Step 8: Drop in the placeholder sprite — the menu thumb AND the in-game baked hull both load res://render/ship_sprites/ship3d_<id>.png, so one file serves both. Copy an existing hull as a clear placeholder, then import:
Terminal window
cp render/ship_sprites/ship3d_aurum.png render/ship_sprites/ship3d_obsidian.png
godot --headless --path . --import
  • Step 9: Run tests + boot a cruiser run

Run the two updated test files (PASS), then the full suite (green), then verify a cruiser run boots without a missing-resource error: godot --headless --path . --quit-after 120 (grep stderr for SCRIPT ERROR / Failed loading resource — expect none).

  • Step 10: Commit
Terminal window
git add sim/meta_state.gd sim/ship_bonuses.gd data/bible.json ui/shop_categories.gd ui/start_menu.gd render/ship_sprites/ship3d_obsidian.png render/ship_sprites/ship3d_obsidian.png.import tests/test_meta_state.gd tests/test_ship_bonuses.gd
git commit -m "feat(ships): gold-unlock the Obsidian cruiser + show it (locked) in the picker"

Task 6: Bake the real Obsidian cruiser sprite (art)

Section titled “Task 6: Bake the real Obsidian cruiser sprite (art)”

Replace the placeholder with a distinct, chunkier cruiser silhouette. This is an asset task with a visual gate, not TDD.

Files:

  • Replace: render/ship_sprites/ship3d_obsidian.png (+ re-import)

  • Reference: tools/ship_preview/ (bake harness), memory bullet-heaven-ui-look

  • Step 1: Read the bake harness — read tools/ship_preview/preview.tscn + its driver script to learn how a hull form is chosen and baked (per docs/architecture / the ship_preview notes). The bake path is SHIP_VARIANT=bake godot --path . res://tools/ship_preview/preview.tscn --rendering-method forward_plus.

  • Step 2: Author a cruiser form — the cruiser must read as more mass than a frigate (wider hull, heavier prow, more plating) so it is visually distinct at a glance. Iterate in the windowed preview.

  • Step 3: Bake to the asset path — output render/ship_sprites/ship3d_obsidian.png (match the existing ship3d_* native size/format — the others are 512×512), then godot --headless --path . --import.

  • Step 4: Diff against the frigate baseline — per memory bullet-heaven-ui-look: a capture that “looks plausible” is NOT proof. Compare the cruiser side-by-side with a frigate sprite; confirm it is genuinely different (not accidentally identical treatment). Boot a cruiser run and confirm the hull renders.

  • Step 5: On-device review (Chris) — flag for a bh-deploy build so Chris sees the cruiser on the TV/iPhone before the art is called done. Ship visuals have historically needed his eye (two real bugs caught that way).

  • Step 6: Commit

Terminal window
git add render/ship_sprites/ship3d_obsidian.png render/ship_sprites/ship3d_obsidian.png.import
git commit -m "art(ships): real Obsidian cruiser hull sprite (replaces placeholder)"

Spec coverage:

  • §2 data model (class/slots/base) → Task 1. ✅
  • §3 Sim.max_weapon_slots + re-pointed gates → Task 2. ✅
  • §4 run-start apply_base + main.gd order (base → meta → bonus) + drone-slots additive → Task 3. ✅
  • §5 meta-shop unlock (owns_ship, bible def, shop category) → Task 5. ✅
  • §6 UI slot counts (weapon_panel, ship_config_panel; drone_dock unchanged; player_renderer already data-driven) → Task 4 + Task 5 (picker). ✅
  • §7 cruiser sprite (bake, diff, on-device) → Task 6. ✅
  • §8 determinism re-verify (unchanged) → Task 2 Step 6, Task 3 Step 6. ✅
  • §9 balance flags → captured in the spec; numbers are const/data, tunable. ✅

Placeholder scan: No TBD/TODO. Every code step shows the code. Task 6 (art) has no test code because it produces a binary asset — its gate is the visual diff + on-device review, which is the correct verification for an asset.

Type consistency: klass/weapon_slots/drone_slots/base_hp/base_speed/base_radius (TABLE keys) and class_of/weapon_slots_for/drone_slots_for/base_stats_for (accessors, returning String/int/int/Dictionary) are used identically across Tasks 1, 3, 5. Sim.max_weapon_slots: int (Task 2) is consumed with that exact name in Tasks 3, 4. MetaState.owns_ship(ship_id: String) -> bool (Task 5) matches its callers. apply_base(ship_id, sim, meta) signature matches the main.gd call site. base_stats_for dict keys "hp"/"speed"/"radius" match between definition and every consumer.

Ordering note: ORDER gains "obsidian" only in Task 5 (with the placeholder sprite + picker-lock in the same task), so the picker never renders a 7th tile with a missing sprite or a selectable-but-unpurchasable cruiser between tasks.