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).
Global Constraints
Section titled “Global Constraints”/simpurity: every file undersim/extends RefCountedand touches NO Node/render/Input/Engine/Time API.ship_bonuses.gd,sim.gd,meta_state.gd,upgrade_system.gdare all/sim— keep them pure.- Determinism baseline (read the literal pinned assertion, never prose):
tests/test_determinism_checksum.gd+tests/test_determinism_crystals.gdpinsnapshot_string().hash()=2730172591,state_checksum()=4075578713. This plan must leave them byte-identical — the baseline buildsSimwith no hull (defaultmax_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. classis a GDScript reserved word — the hull class field is namedklass(matches the existing drone-loadout code).SimandUpgradeSystemalready form a type cycle (everyUpgradeSystemmethod takessim: Sim). Do NOT referenceUpgradeSystem.MAX_WEAPONSin aSimmember initializer — default the new field to the literal6with a comment.- tvOS is symlinked — gameplay dirs under
platform/tvos/are symlinks to the repo root. NEVERcp/rsyncgameplay into it. New sprite assets are the only files that need explicit attention (they live underrender/ship_sprites/, which is symlinked, so they appear automatically). - Every task ends via the
bh-dev-chunkritual: build → TDD →godot --headless --path . --import→ boot-smoke → test-count guard → determinism re-verify → commit. Commit straight tomain(this project’s convention — chunks + docs commit to main). - Naming/numbers are provisional (per the spec) —
Obsidian,5/2,170/210/24,+10 armor,800gold 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 120then grep stderr forSCRIPT 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).TABLEgains 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
TABLEand add accessors — replace theTABLEconst insim/ship_bonuses.gd(keep the existingis_known/name_for/label_for/apply_to/ORDER/DEFAULT_SHIPbelow 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
git add sim/ship_bonuses.gd tests/test_ship_bonuses.gdgit commit -m "feat(ships): hull class + slot + base-stat data model (frigate/cruiser)"Task 2: Sim.max_weapon_slots per-run cap
Section titled “Task 2: Sim.max_weapon_slots per-run cap”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, bymax_drone_slots) - Modify:
sim/upgrade_system.gd:58and: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_weaponrespect 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 themax_drone_slotsline (~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
git add sim/sim.gd sim/upgrade_system.gd tests/test_ship_class_slots.gdgit 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(addapply_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— insim/ship_bonuses.gd, add above the existingapply_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 theif 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 perkto:
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 perkand 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
git add sim/ship_bonuses.gd main.gd tests/test_ship_bonuses.gdgit 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 asim(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.gdfollow the hull — inopen_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.gdfollow the hull — inupdate_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
git add ui/weapon_panel.gd ui/ship_config_panel.gd tests/test_ship_config_panel.gdgit 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(addowns_ship) - Modify:
sim/ship_bonuses.gd(ORDERgains"obsidian") - Modify:
data/bible.json(addunlock-hull-obsidianto themeta_upgradesarray) - Modify:
ui/shop_categories.gd:6(ORDERgains"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— insim/meta_state.gd, next toowns_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— insim/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 themeta_upgradesarray (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 firstIn _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:
cp render/ship_sprites/ship3d_aurum.png render/ship_sprites/ship3d_obsidian.pnggodot --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
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.gdgit 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), memorybullet-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 (perdocs/architecture/ theship_previewnotes). The bake path isSHIP_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 existingship3d_*native size/format — the others are 512×512), thengodot --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-deploybuild 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
git add render/ship_sprites/ship3d_obsidian.png render/ship_sprites/ship3d_obsidian.png.importgit commit -m "art(ships): real Obsidian cruiser hull sprite (replaces placeholder)"Self-Review
Section titled “Self-Review”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.gdorder (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.