Enemy Codex / Bestiary Implementation Plan
Enemy Codex / Bestiary Implementation Plan
Section titled “Enemy Codex / Bestiary 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: A cross-run enemy encyclopedia — a freeze-to-show card on first-ever encounter (enemy attributes + counters + a live scripted preview), plus a browsable codex from the pause and start menus.
Architecture: Render-only by construction. First-encounter detection scans sim.enemies.type_id (already read every frame); the only persisted state is a seen_enemies set in the meta save. Authored prose lives in a new data/bestiary.json (kept out of the sim’s bible.json); numeric stats are pulled live from ContentDB. The deterministic sim is never modified.
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 (headless tests). Design spec: docs/superpowers/specs/2026-06-29-enemy-codex-design.md.
Global Constraints
Section titled “Global Constraints”- Zero
/simchanges that touch the tick path. The pinned determinism baseline issnapshot_string().hash() = 1405185210,state_checksum() = 3122397125(tests/test_determinism_checksum.gd). Every task must leave it byte-identical — re-run and confirm. /simpurity: the one sim-dir file touched (sim/meta_state.gd) is NOT in the tick path (apply_toruns render-side at run start). It must stayextends RefCountedwith no Node/Engine/Time/File APIs. All other new code is render-side (content/,render/,ui/).- Do NOT bump
MetaState.SCHEMA_VERSION. Addingseen_enemiesis additive (forward+backward compatible). Bumping to 2 would make older builds seeschema 2 > 1infrom_dictand reset the entire save (lose banked gold). Keep it at 1. (This supersedes the spec’s “bump SCHEMA_VERSION” line.) - GUT gotchas: an un-asserted
push_errorFAILS the test — validators expose a non-erroringvalidate()seam. Test typos likeassert_le(real nameassert_lte) silently drop a whole file — trustscripts/check-test-count.sh, not just “all passed”. After adding aclass_namein a new dir, rungodot --headless --path . --importbefore tests or the new class won’t register. - Content copy rules (in-game flavor is exempt from the no-em-dash marketing rule, but keep it tight and human): short, plain, no filler.
- macOS note:
timeoutis not on PATH — it isgtimeout. Atimeout-wrapped smoke check silently no-ops.
Per-task verification ritual (bh-dev-chunk)
Section titled “Per-task verification ritual (bh-dev-chunk)”Every task ends with this sequence (commands assume cwd /Users/chris/Claude/bullet-heaven):
# 1. If a new class_name file was added in a new dir, refresh the class cache FIRST:godot --headless --path . --import 2>&1 | grep -i "error" || echo "import clean"
# 2. Run this task's test file(s):godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit
# 3. Full suite + test-count guard (catches silent-dropped files):scripts/check-test-count.sh
# 4. Boot smoke — boots + ticks the real sim; must show no SCRIPT ERROR:godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR" && echo "BOOT FAIL" || echo "boot clean"
# 5. Determinism — MUST stay 1405185210 / 3122397125 (no /sim tick change):godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
# 6. Commit (see each task's message).Expected at step 5 for every task: both baseline asserts PASS, unchanged.
File structure
Section titled “File structure”| File | Responsibility | Task |
|---|---|---|
sim/meta_state.gd (modify) |
Persist the cross-run seen_enemies set |
1 |
data/bestiary.json (create) |
Authored prose + boss stat blocks, keyed by codex key | 2 |
content/bestiary_loader.gd (create) |
Load + validate bestiary.json, merge with live ContentDB → BestiaryDB |
2 |
content/bestiary_db.gd (create) |
Merged-entry lookup, TYPE_*→key LUT, first_encounters detection |
2,3 |
render/codex_preview.gd (create) |
Scripted “flying + shooting” animation for one entry | 4 |
ui/codex_card.gd (create) |
Read-only detail Control hosting the preview | 5 |
ui/codex_overlay.gd (create) |
Two-mode overlay (first-encounter freeze + browse) | 6 |
main.gd (modify) |
Instantiate, detect, freeze-gate, toggle | 7 |
ui/pause_menu.gd + ui/start_menu.gd (modify) |
“Bestiary” menu entries | 8 |
Task 1: Persist seen_enemies in the meta save
Section titled “Task 1: Persist seen_enemies in the meta save”Files:
- Modify:
sim/meta_state.gd - Test:
tests/test_meta_state.gd(extend if present; else create)
Interfaces:
-
Produces:
MetaState.seen_enemies: Dictionary(key→true set);MetaState.has_seen(key: String) -> bool;MetaState.mark_seen(key: String) -> void. Round-trips throughto_dict/from_dictas a JSON array under key"seen_enemies". -
Step 1: Write the failing test. Append to
tests/test_meta_state.gd(create with the header below if the file does not exist):
extends GutTest
# (only add this header block if creating the file fresh)# func _make() -> MetaState: return MetaState.new()
func test_seen_enemies_defaults_empty() -> void: var m := MetaState.new() assert_false(m.has_seen("ghost"), "fresh save has seen nothing")
func test_mark_and_has_seen() -> void: var m := MetaState.new() m.mark_seen("ghost") assert_true(m.has_seen("ghost")) assert_false(m.has_seen("tank"))
func test_seen_enemies_round_trips() -> void: var m := MetaState.new() m.mark_seen("ghost") m.mark_seen("tank") var d := m.to_dict() var m2 := MetaState.new() m2.from_dict(d) assert_true(m2.has_seen("ghost")) assert_true(m2.has_seen("tank")) assert_false(m2.has_seen("eye"))
func test_old_save_without_field_migrates_empty() -> void: # A save written before this feature has no "seen_enemies" key. var m := MetaState.new() m.from_dict({"schema": 1, "banked_gold": 50, "levels": {}}) assert_false(m.has_seen("ghost"), "missing field → empty seen set, no crash") assert_eq(m.banked_gold, 50, "other fields still load")- Step 2: Run it, verify it fails.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexit
Expected: FAIL — has_seen/mark_seen not defined.
- Step 3: Implement. In
sim/meta_state.gd:
Add the field after var selected_decoy ... (line ~19):
# Codex: set of enemy codex keys the player has ever encountered (key -> true).# Additive save field — absent in older saves (loads as empty). Render-only use.var seen_enemies: Dictionary = {}
func has_seen(key: String) -> bool: return seen_enemies.has(key)
func mark_seen(key: String) -> void: seen_enemies[key] = trueIn to_dict() add "seen_enemies" (store the keys as a JSON array):
func to_dict() -> Dictionary: return {"schema": SCHEMA_VERSION, "banked_gold": banked_gold, "levels": levels.duplicate(), "tutorial_done": tutorial_done, "selected_decoy": selected_decoy, "seen_enemies": seen_enemies.keys()}In from_dict() (after the levels block) add:
seen_enemies = {} var se: Variant = d.get("seen_enemies", []) if se is Array: for k in se: seen_enemies[str(k)] = true- Step 4: Run the test, verify it passes.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexit
Expected: PASS (4 new tests green).
- Step 5: Ritual + commit. Run the full ritual (count guard, boot, determinism — baseline unchanged), then:
git add sim/meta_state.gd tests/test_meta_state.gdgit commit -m "feat(codex): persist cross-run seen_enemies set in the meta save
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Task 2: bestiary.json + loader + DB (data, LUT, merge, lookup)
Section titled “Task 2: bestiary.json + loader + DB (data, LUT, merge, lookup)”Files:
- Create:
data/bestiary.json - Create:
content/bestiary_loader.gd - Create:
content/bestiary_db.gd - Test:
tests/test_bestiary_loader.gd
Interfaces:
-
Consumes:
ContentDB(ContentLoader.load_from_path("res://data/bible.json")),ElementPalette.color_for(content, idx),EnemyPool.TYPE_*constants. -
Produces:
BestiaryLoader.validate(raw: Dictionary) -> Array— problem strings, NOpush_error.BestiaryLoader.load_from_dict(raw: Dictionary, content: ContentDB) -> BestiaryDBBestiaryLoader.load_from_path(path: String, content: ContentDB) -> BestiaryDBBestiaryDB.key_for_type(type_id: int) -> String(""= excluded/custom/unknown)BestiaryDB.entry_for_key(key: String) -> Dictionary(merged entry, see contract)BestiaryDB.entry_for_type(type_id: int) -> DictionaryBestiaryDB.all_keys() -> Array(stable display order)
-
Merged-entry contract (every consumer reads exactly these keys):
{ "key": String, "name": String, "threat": int, "desc": String, "counter": String, "element": String, "element_color": Color, "hp": float, "speed": float, "contact_damage": float, "armor": float, "move": String, "attack": String, "raw": Dictionary } -
Step 1: Create
data/bestiary.jsonwith all 22 entries:
{ "schemaVersion": 1, "entries": { "swarmer": { "name": "Swarmer", "threat": 1, "move": "walk", "attack": "melee", "desc": "A weak fire chaser that always comes in packs and dies in a hit or two.", "counter": "Trivial alone. Keep moving so a swarm can't surround you." }, "tank": { "name": "Tank", "threat": 3, "move": "walk", "attack": "missiles", "desc": "Slow, armored fire wall that launches homing missiles from range.", "counter": "Shoot the missiles down (they're killable) or juke them, then chip the armor with elemental reactions." }, "shooter": { "name": "Shooter", "threat": 2, "move": "walk", "attack": "bolt", "desc": "Hangs back and fires lightning bolts at you.", "counter": "Close the gap or strafe perpendicular to its line of fire." }, "splitter": { "name": "Splitter", "threat": 2, "move": "walk", "attack": "melee", "desc": "A poison blob that bursts into smaller blobs when it dies.", "counter": "Expect the split — have an AoE or a wide weapon ready to clear the children." }, "elite": { "name": "Elite", "threat": 3, "move": "dash", "attack": "melee", "desc": "A void dasher: it charges, telegraphs a line, then lunges fast.", "counter": "Watch the brightening telegraph and sidestep the lunge — it commits to a straight line." }, "spider": { "name": "Spider", "threat": 3, "move": "dash", "attack": "web", "desc": "A fast decay dasher that lays a trail of slowing webs.", "counter": "Don't get pinned in the webs. Kill it quickly and kite off the sticky ground." }, "skirmisher": { "name": "Skirmisher", "threat": 3, "move": "skirmish", "attack": "bolt", "desc": "An aether mini-boss that strafes at mid-range, fires aimed shots, and drops a powerup when killed.", "counter": "Pressure it between shots and close in to break its strafe — the powerup is worth it." }, "brute": { "name": "Brute", "threat": 4, "move": "walk", "attack": "melee", "desc": "A huge, slow blood wall with heavy armor and a deep health pool.", "counter": "Kite it or focus it with your strongest weapon. Reactions bypass armor — lean on them." }, "rusher": { "name": "Rusher", "threat": 3, "move": "rush", "attack": "melee", "desc": "A relentless aether charger that re-aims and bursts at you with no recovery pause.", "counter": "Move perpendicular to its charge — it overshoots and has to re-aim before it can hit." }, "zapper": { "name": "Zapper", "threat": 3, "move": "rush", "attack": "bolt", "desc": "A fast lightning rusher that fires a bolt mid-charge.", "counter": "Dodge sideways from the charge and punish the brief window while it re-aims." }, "scatterer": { "name": "Scatterer", "threat": 3, "move": "walk", "attack": "fan", "desc": "A blood gunner that fires a wide fan of pellets.", "counter": "Never stand in the cone. Flank it from the side where the spread is thin." }, "bomber": { "name": "Bomber", "threat": 3, "move": "walk", "attack": "bomb", "desc": "A slow fire lobber that drops a delayed bomb on your position.", "counter": "Keep moving — step off the telegraphed blast circle before it detonates." }, "orbiter": { "name": "Orbiter", "threat": 3, "move": "walk", "attack": "orbit", "desc": "A cold walker ringed with spinning shards that hurt on contact.", "counter": "Don't touch the ring. Hit it from range and never melee into the shards." }, "lancer": { "name": "Lancer", "threat": 3, "move": "walk", "attack": "beam", "desc": "A light walker that charges up a long sweeping beam.", "counter": "Step out of the telegraphed beam line before it fires — the wind-up gives you time." }, "ghost": { "name": "Ghost", "threat": 4, "move": "ghost", "attack": "melee", "desc": "A void wraith: it drifts, shows a silhouette where it will strike, then teleports and dashes through that line.", "counter": "When the silhouette appears, move PERPENDICULAR to the strike line. Running straight away won't escape it." }, "accumulator": { "name": "Accumulator", "threat": 4, "move": "dash", "attack": "melee", "desc": "A fire dasher that grows bigger, faster and deadlier the longer it stays alive.", "counter": "Kill it early. Every second you ignore it, it becomes a harder problem." }, "tank_missile": { "name": "Tank Missile", "threat": 2, "move": "homing", "attack": "melee", "desc": "A killable homing missile fired by Tanks. Low health, fast, but a limited turn rate.", "counter": "Shoot it down, or juke it — it can't corner tightly enough to follow a sharp turn." }, "warden": { "name": "Warden", "threat": 5, "move": "hover", "attack": "missiles", "stats": { "hp": 1500, "speed": 40, "contact_damage": 40, "armor": 0, "element": "void" }, "desc": "A domain boss. It approaches, telegraphs, then swings, barrages, fires homing missiles, or rotates a spiral of shots — and enrages below 40% health.", "counter": "Read each telegraph and dodge. Drop a decoy to pull its aggro, and try to burst it down before it enrages." }, "sentinel": { "name": "Sentinel", "threat": 5, "move": "hover", "attack": "beam", "stats": { "hp": 1800, "speed": 45, "contact_damage": 44, "armor": 0, "element": "kinetic" }, "desc": "A second-tier boss that randomly cycles five attacks: a sweeping cutter beam, arcing artillery, shockwave rings, a charge slam, and summons.", "counter": "Identify the attack from its telegraph each cycle. Keep distance and dodge through the gaps in the rings." }, "funzo": { "name": "FunZo", "threat": 5, "move": "hover", "attack": "melee", "desc": "A zone-flooding clown boss. It floods the arena with growing damage zones and alternates a slow drift with fast dashes.", "counter": "Never stop moving. Stay clear of the spreading zones and kill it before they cover the floor." }, "graviton": { "name": "Graviton", "threat": 5, "move": "hover", "attack": "fan", "desc": "A gravity-and-darkness boss. It pulls you toward it, fires radial blobs with safe gap-lanes, and spawns orbiting satellites.", "counter": "Steer against the pull — your input still works. Thread the gaps in its blob rings and don't let it drag you into contact." }, "eye": { "name": "The Eye", "threat": 5, "move": "hover", "attack": "beam", "desc": "A predictive-sight boss. It fires beams that LEAD your movement, blinks to the arena edge, and lazily dashes through.", "counter": "Break your pattern — stutter-step so its leading shots miss, and dodge back into the space you just left." } }}- Step 2: Write the failing test
tests/test_bestiary_loader.gd:
extends GutTest
func _content() -> ContentDB: return ContentLoader.load_from_path("res://data/bible.json")
func test_real_file_validates_clean() -> void: var f := FileAccess.open("res://data/bestiary.json", FileAccess.READ) assert_not_null(f, "bestiary.json must exist") var raw: Variant = JSON.parse_string(f.get_as_text()) assert_true(raw is Dictionary, "bestiary.json parses to a dict") var problems := BestiaryLoader.validate(raw) assert_eq(problems.size(), 0, "no validation problems: %s" % str(problems))
func test_every_spawnable_type_has_an_entry() -> void: var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content()) # Every type id from SWARMER..EYE (excluding CUSTOM) resolves to a non-empty merged entry. for tid in range(EnemyPool.TYPE_CUSTOM): # 0..21 var key := db.key_for_type(tid) assert_ne(key, "", "type %d must map to a codex key" % tid) var e := db.entry_for_type(tid) assert_true(e.has("desc") and String(e["desc"]) != "", "type %d entry has desc" % tid) assert_true(e.has("counter") and String(e["counter"]) != "", "type %d entry has counter" % tid)
func test_custom_type_excluded() -> void: var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content()) assert_eq(db.key_for_type(EnemyPool.TYPE_CUSTOM), "", "custom enemies have no codex key")
func test_mob_stats_pulled_live_from_content() -> void: var content := _content() var db := BestiaryLoader.load_from_path("res://data/bestiary.json", content) var e := db.entry_for_key("swarmer") assert_almost_eq(float(e["hp"]), float(content.enemy("swarmer")["hp"]), 0.001, "mob hp is live from bible")
func test_boss_stats_from_block() -> void: var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content()) var e := db.entry_for_key("warden") assert_gt(float(e["hp"]), 0.0, "warden hp comes from its stats block")
func test_missing_entry_falls_back_not_crash() -> void: # A raw bestiary missing 'ghost' should still load a usable DB (placeholder entry). var raw := {"schemaVersion": 1, "entries": {}} var db := BestiaryLoader.load_from_dict(raw, _content()) var e := db.entry_for_key("ghost") assert_true(e.has("name"), "fallback entry still has a name")
func test_validate_flags_bad_schema() -> void: var problems := BestiaryLoader.validate({"schemaVersion": 99, "entries": {}}) assert_gt(problems.size(), 0, "wrong schemaVersion is a problem")
func test_all_keys_stable_order() -> void: var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content()) assert_eq(db.all_keys().size(), 22, "22 codex keys") assert_eq(String(db.all_keys()[0]), "swarmer", "ordered by type id, swarmer first")- Step 3: Run it, verify it fails.
Run: godot --headless --path . --import (register the new classes), then
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_bestiary_loader.gd -gexit
Expected: FAIL — BestiaryLoader/BestiaryDB not found.
- Step 4: Implement
content/bestiary_db.gd:
class_name BestiaryDBextends RefCounted
# Merged enemy-codex data: authored prose (bestiary.json) + live stats (ContentDB).# Render-side (plain RefCounted — no Node/Engine APIs). The TYPE_*→key LUT and the# first-encounter detection helper live here so they're unit-testable headless.
# Indexed by EnemyPool.TYPE_* (0..TYPE_CUSTOM). "" = no codex entry (custom/unknown).const TYPE_KEYS: Array[String] = [ "swarmer", # 0 TYPE_SWARMER "tank", # 1 TYPE_TANK "shooter", # 2 TYPE_SHOOTER "splitter", # 3 TYPE_SPLITTER "elite", # 4 TYPE_ELITE "spider", # 5 TYPE_SPIDER "warden", # 6 TYPE_BOSS "skirmisher", # 7 TYPE_SKIRMISHER "brute", # 8 TYPE_BRUTE "rusher", # 9 TYPE_RUSHER "zapper", # 10 TYPE_ZAPPER "scatterer", # 11 TYPE_SCATTERER "bomber", # 12 TYPE_BOMBER "orbiter", # 13 TYPE_ORBITER "lancer", # 14 TYPE_LANCER "sentinel", # 15 TYPE_BOSS2 "ghost", # 16 TYPE_GHOST "accumulator", # 17 TYPE_ACCUMULATOR "tank_missile", # 18 TYPE_TANK_MISSILE "funzo", # 19 TYPE_FUNZO "graviton", # 20 TYPE_GRAVITON "eye", # 21 TYPE_EYE "", # 22 TYPE_CUSTOM — excluded]
var _by_key: Dictionary = {} # key -> merged entry Dictionary
func _init(merged: Dictionary) -> void: _by_key = merged
func key_for_type(type_id: int) -> String: if type_id < 0 or type_id >= TYPE_KEYS.size(): return "" return TYPE_KEYS[type_id]
func entry_for_key(key: String) -> Dictionary: return _by_key.get(key, {})
func entry_for_type(type_id: int) -> Dictionary: return entry_for_key(key_for_type(type_id))
func all_keys() -> Array: # Stable order = type id order (skip the empty custom slot + any unbuilt key). var out: Array = [] for k in TYPE_KEYS: if k != "" and _by_key.has(k): out.append(k) return out
# Pure first-encounter detection: the codex keys present in the pool that aren't in# `seen`, de-duplicated, in pool order. Excludes custom/unknown (key == "").func first_encounters(type_ids: PackedInt32Array, count: int, seen: Dictionary) -> Array: var out: Array = [] var added: Dictionary = {} for i in range(count): var key := key_for_type(type_ids[i]) if key == "" or seen.has(key) or added.has(key): continue added[key] = true out.append(key) return out(Note: first_encounters body is delivered here but only tested in Task 3 — Task 2’s tests don’t exercise it.)
- Step 5: Implement
content/bestiary_loader.gd:
class_name BestiaryLoaderextends RefCounted
# Loads data/bestiary.json (authored prose), merges with live ContentDB stats,# and produces a BestiaryDB. Render-side (FileAccess/JSON → outside /sim).# Fail-loud (push_error on load) but never crash: a missing/short entry yields a# tolerant placeholder so a newly-added enemy can't break the codex.
const SCHEMA := 1
# Validate WITHOUT push_error (test seam). Returns a list of human-readable problems.static func validate(raw: Variant) -> Array: var problems: Array = [] if not (raw is Dictionary): return ["bestiary.json is not a JSON object"] if int(raw.get("schemaVersion", 0)) != SCHEMA: problems.append("schemaVersion must be %d (got %s)" % [SCHEMA, str(raw.get("schemaVersion"))]) var entries: Variant = raw.get("entries", null) if not (entries is Dictionary): problems.append("entries must be an object") return problems for key in entries: var e: Variant = entries[key] if not (e is Dictionary): problems.append("entry '%s' is not an object" % key); continue for req in ["threat", "desc", "counter"]: if not e.has(req): problems.append("entry '%s' missing required '%s'" % [key, req]) # Every spawnable type id (excluding custom) must have an entry. for tid in range(EnemyPool.TYPE_CUSTOM): var k: String = BestiaryDB.TYPE_KEYS[tid] if k != "" and not entries.has(k): problems.append("no entry for spawnable type '%s' (type id %d)" % [k, tid]) return problems
static func load_from_path(path: String, content: ContentDB) -> BestiaryDB: var f := FileAccess.open(path, FileAccess.READ) if f == null: push_error("BestiaryLoader: cannot open %s" % path) return load_from_dict({"schemaVersion": SCHEMA, "entries": {}}, content) var raw: Variant = JSON.parse_string(f.get_as_text()) return load_from_dict(raw if raw is Dictionary else {}, content)
static func load_from_dict(raw: Variant, content: ContentDB) -> BestiaryDB: var problems := validate(raw) if not problems.is_empty(): push_error("BestiaryLoader problems: %s" % str(problems)) var entries: Dictionary = {} if raw is Dictionary and raw.get("entries", null) is Dictionary: entries = raw["entries"] var merged: Dictionary = {} for tid in range(EnemyPool.TYPE_CUSTOM): var key: String = BestiaryDB.TYPE_KEYS[tid] if key == "": continue merged[key] = _merge_one(key, entries.get(key, {}), content) return BestiaryDB.new(merged)
static func _merge_one(key: String, prose: Dictionary, content: ContentDB) -> Dictionary: # Stats: live from the bible enemy if present, else the entry's own stats block. var stats: Dictionary = prose.get("stats", {}) var has_bible := content != null and content.has_enemy(key) var bible: Dictionary = content.enemy(key) if has_bible else {} var src: Dictionary = bible if has_bible else stats var element: String = String(src.get("element", prose.get("element", ""))) var el_color := ElementPalette.NEUTRAL if content != null and element != "": el_color = ElementPalette.color_for(content, content.element_index(element)) return { "key": key, "name": String(prose.get("name", bible.get("name", key.capitalize()))), "threat": int(prose.get("threat", 1)), "desc": String(prose.get("desc", "An enemy.")), "counter": String(prose.get("counter", "Stay mobile and focus it down.")), "element": element, "element_color": el_color, "hp": float(src.get("hp", 0.0)), "speed": float(src.get("speed", 0.0)), "contact_damage": float(src.get("contact_damage", 0.0)), "armor": float(src.get("armor", 0.0)), "move": String(prose.get("move", "walk")), "attack": String(prose.get("attack", "melee")), "raw": src, }- Step 6: Run the test, verify it passes.
Run: godot --headless --path . --import then
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_bestiary_loader.gd -gexit
Expected: PASS (8 tests green).
- Step 7: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:
git add data/bestiary.json content/bestiary_loader.gd content/bestiary_db.gd tests/test_bestiary_loader.gdgit commit -m "feat(codex): bestiary.json + loader/DB (prose + live stats, TYPE_*→key LUT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Task 3: First-encounter detection helper
Section titled “Task 3: First-encounter detection helper”Files:
- Modify:
content/bestiary_db.gd(thefirst_encountersmethod shipped in Task 2 — now under test) - Test:
tests/test_codex_detection.gd
Interfaces:
-
Consumes:
BestiaryDB.first_encounters(type_ids: PackedInt32Array, count: int, seen: Dictionary) -> Array(already implemented in Task 2). -
Produces: confidence that detection excludes custom/seen, de-dupes, preserves order.
-
Step 1: Write the failing test
tests/test_codex_detection.gd:
extends GutTest
func _db() -> BestiaryDB: return BestiaryLoader.load_from_path("res://data/bestiary.json", ContentLoader.load_from_path("res://data/bible.json"))
func test_returns_new_keys_in_order() -> void: var db := _db() var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_TANK]) var got := db.first_encounters(ids, 2, {}) assert_eq(got, ["swarmer", "tank"], "new keys in pool order")
func test_excludes_already_seen() -> void: var db := _db() var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_GHOST]) var got := db.first_encounters(ids, 2, {"swarmer": true}) assert_eq(got, ["ghost"], "seen key skipped")
func test_dedupes_within_a_frame() -> void: var db := _db() var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SWARMER]) var got := db.first_encounters(ids, 3, {}) assert_eq(got, ["swarmer"], "duplicate type reported once")
func test_excludes_custom() -> void: var db := _db() var ids := PackedInt32Array([EnemyPool.TYPE_CUSTOM, EnemyPool.TYPE_EYE]) var got := db.first_encounters(ids, 2, {}) assert_eq(got, ["eye"], "custom has no codex key")
func test_honors_count_bound() -> void: var db := _db() # Array has a trailing TANK but count=1 → only the first slot is scanned. var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_TANK]) var got := db.first_encounters(ids, 1, {}) assert_eq(got, ["swarmer"], "count bounds the scan")- Step 2: Run it, verify it passes immediately (the method already exists from Task 2 — this task is a dedicated test gate for the determinism-adjacent detection logic):
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_detection.gd -gexit
Expected: PASS (5 tests). If any FAIL, fix first_encounters in content/bestiary_db.gd until green.
- Step 3: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:
git add tests/test_codex_detection.gd content/bestiary_db.gdgit commit -m "test(codex): cover first_encounters detection (dedupe, seen, custom, bound)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Task 4: CodexPreview — scripted flying + shooting animation
Section titled “Task 4: CodexPreview — scripted flying + shooting animation”Files:
- Create:
render/codex_preview.gd - Test:
tests/test_codex_preview.gd
Interfaces:
-
Consumes: a merged entry Dictionary (Task 2 contract) — reads
element_color,move,attack,raw. -
Produces:
CodexPreview(extendsNode2D);CodexPreview.new(entry: Dictionary);advance(dt: float) -> void(steps the canned animation); draws via_draw(). Bounded to aBOXrect around the origin. -
Step 1: Write the failing test
tests/test_codex_preview.gd:
extends GutTest
func _entry(move: String, attack: String) -> Dictionary: return {"element_color": Color(1,0,0), "move": move, "attack": attack, "raw": {}}
func test_constructs_for_every_archetype() -> void: for move in ["walk", "dash", "skirmish", "rush", "ghost", "homing", "hover"]: for attack in ["melee", "bolt", "fan", "bomb", "beam", "orbit", "web", "missiles"]: var p := CodexPreview.new(_entry(move, attack)) add_child_autofree(p) # Advance a few seconds of canned animation; must not error or escape the box. for _i in range(180): p.advance(1.0 / 60.0) assert_lt(p.enemy_pos().length(), CodexPreview.BOX * 2.0, "enemy stays roughly bounded for %s/%s" % [move, attack])
func test_advance_progresses_time() -> void: var p := CodexPreview.new(_entry("walk", "bolt")) add_child_autofree(p) var t0 := p.elapsed() p.advance(0.5) assert_almost_eq(p.elapsed(), t0 + 0.5, 0.001, "advance accumulates time")- Step 2: Run it, verify it fails.
Run: godot --headless --path . --import then
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_preview.gd -gexit
Expected: FAIL — CodexPreview not found.
- Step 3: Implement
render/codex_preview.gd:
class_name CodexPreviewextends Node2D
# A self-contained, scripted "flying + shooting" loop for one codex entry.# Render-only: no sim, no gameplay RNG. The enemy silhouette moves per its `move`# archetype inside BOX and emits scripted shots per its `attack` type, aimed at a# slowly-drifting faux target. Drawn in the entry's element color. advance(dt) is# driven by the hosting overlay each frame (NOT _process — the overlay owns timing).
const BOX := 140.0 # half-extent of the preview play area (px around origin)const ENEMY_R := 16.0 # drawn silhouette radiusconst SHOT_SPEED := 220.0const SHOT_LIFE := 1.4
var _color: Colorvar _move: Stringvar _attack: Stringvar _raw: Dictionaryvar _t := 0.0var _epos := Vector2.ZERO # enemy positionvar _target := Vector2(0.0, 90.0) # faux player markervar _shots: Array = [] # [{pos:Vector2, vel:Vector2, life:float, kind:String}]var _shot_cd := 0.0
func _init(entry: Dictionary) -> void: _color = entry.get("element_color", Color(1, 0.32, 0.46)) _move = String(entry.get("move", "walk")) _attack = String(entry.get("attack", "melee")) _raw = entry.get("raw", {})
func elapsed() -> float: return _t
func enemy_pos() -> Vector2: return _epos
func advance(dt: float) -> void: _t += dt _target = Vector2(sin(_t * 0.7) * BOX * 0.55, 80.0 + cos(_t * 0.5) * 22.0) _step_move(dt) _step_shots(dt) queue_redraw()
func _step_move(dt: float) -> void: var to_target := (_target - _epos) var dir := to_target.normalized() if to_target.length() > 1.0 else Vector2.RIGHT match _move: "walk": _epos += dir * 42.0 * dt "hover": _epos = Vector2(sin(_t * 0.8) * BOX * 0.5, -30.0 + cos(_t * 0.9) * 26.0) "skirmish": # orbit the target at mid-range var ang := _t * 1.4 _epos = _target + Vector2(cos(ang), sin(ang)) * 95.0 "rush", "dash": # charge windows: dwell, then a fast burst toward the target var phase := fmod(_t, 1.6) if phase < 1.1: _epos += dir * 18.0 * dt else: _epos += dir * 300.0 * dt "homing": _epos += dir * 120.0 * dt # curving approach (dir re-evaluated each frame) "ghost": # drift, then teleport across to the far side every ~2s if fmod(_t, 2.0) < 1.7: _epos += dir * 30.0 * dt else: _epos = -_target.normalized() * BOX * 0.6 _: _epos += dir * 42.0 * dt # Keep it in the box (bounce-clamp). _epos.x = clampf(_epos.x, -BOX, BOX) _epos.y = clampf(_epos.y, -BOX, BOX * 0.4)
func _step_shots(dt: float) -> void: _shot_cd -= dt if _shot_cd <= 0.0 and _attack != "melee": _shot_cd = 1.1 _fire() for s in _shots: s["life"] -= dt s["pos"] += s["vel"] * dt _shots = _shots.filter(func(s): return s["life"] > 0.0)
func _fire() -> void: var aim := (_target - _epos).normalized() match _attack: "bolt", "missiles": _shots.append({"pos": _epos, "vel": aim * SHOT_SPEED, "life": SHOT_LIFE, "kind": _attack}) "fan": for k in range(-2, 3): var v := aim.rotated(deg_to_rad(14.0 * k)) * SHOT_SPEED _shots.append({"pos": _epos, "vel": v, "life": SHOT_LIFE, "kind": "fan"}) "bomb": _shots.append({"pos": _target, "vel": Vector2.ZERO, "life": SHOT_LIFE, "kind": "bomb"}) "beam": _shots.append({"pos": _epos, "vel": aim * 0.0, "life": 0.6, "kind": "beam"}) "orbit", "web": pass # rendered as a persistent ring/trail in _draw, no projectiles _: pass
func _draw() -> void: # Faux target marker. draw_circle(_target, 6.0, Color(0.55, 0.85, 1.0, 0.5)) # Shots. for s in _shots: var kind: String = s["kind"] if kind == "bomb": draw_arc(s["pos"], 26.0, 0, TAU, 24, Color(1.0, 0.6, 0.2, 0.7), 2.0) elif kind == "beam": draw_line(_epos, _target, Color(_color.r, _color.g, _color.b, 0.7), 4.0) else: draw_circle(s["pos"], 4.0, Color(_color.r, _color.g, _color.b, 0.9)) # Orbit ring / web trail decoration. if _attack == "orbit": for k in range(6): var a := _t * 3.0 + float(k) / 6.0 * TAU draw_circle(_epos + Vector2(cos(a), sin(a)) * 30.0, 4.0, _color) # Enemy body: a glowing diamond silhouette (the codex preview reads shape via color+size). var pts := PackedVector2Array([ _epos + Vector2(0, -ENEMY_R), _epos + Vector2(ENEMY_R * 0.7, 0), _epos + Vector2(0, ENEMY_R), _epos + Vector2(-ENEMY_R * 0.7, 0)]) draw_colored_polygon(pts, _color) draw_circle(_epos, ENEMY_R * 0.5, _color.lerp(Color.WHITE, 0.6)) # hot coreNOTE for the implementer: the body silhouette above is a generic diamond so the task is self-contained. If you want the per-archetype silhouette, you may instead call into
ArchetypeRenderer’s point-helpers, but that is optional polish — the diamond + element color + scripted motion satisfies “flying around and shooting”. Do not block the task on it.
- Step 4: Run the test, verify it passes.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_preview.gd -gexit
Expected: PASS (2 tests).
- Step 5: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:
git add render/codex_preview.gd tests/test_codex_preview.gdgit commit -m "feat(codex): CodexPreview — scripted flying+shooting animation per archetype
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Task 5: CodexCard — detail Control
Section titled “Task 5: CodexCard — detail Control”Files:
- Create:
ui/codex_card.gd - Test:
tests/test_codex_card.gd
Interfaces:
-
Consumes: a merged entry Dictionary (Task 2);
CodexPreview(Task 4);NeonTheme. -
Produces:
CodexCard.build(entry: Dictionary) -> Control(static) — a read-only VBox with name, threat dots, element, stat rows, desc, counter, and a hostedCodexPreview(inside aSubViewportContaineror a clippedControlso it sits in the card). MirrorsWeaponDetailView.build. -
Step 1: Write the failing test
tests/test_codex_card.gd:
extends GutTest
func _entry() -> Dictionary: return {"key": "ghost", "name": "Ghost", "threat": 4, "desc": "A void wraith.", "counter": "Move perpendicular.", "element": "void", "element_color": Color(0.6,0.2,0.8), "hp": 12.0, "speed": 60.0, "contact_damage": 30.0, "armor": 0.0, "move": "ghost", "attack": "melee", "raw": {}}
func _labels(node: Node, out: Array) -> void: if node is Label: out.append((node as Label).text) for c in node.get_children(): _labels(c, out)
func test_build_returns_control_with_name_and_counter() -> void: var card := CodexCard.build(_entry()) add_child_autofree(card) var texts: Array = [] _labels(card, texts) var joined := " | ".join(texts) assert_string_contains(joined, "Ghost", "shows the name") assert_string_contains(joined, "Move perpendicular", "shows the counter")
func test_build_hosts_a_preview() -> void: var card := CodexCard.build(_entry()) add_child_autofree(card) # A CodexPreview must exist somewhere in the card subtree. assert_true(_has_preview(card), "card hosts a CodexPreview")
func _has_preview(n: Node) -> bool: if n is CodexPreview: return true for c in n.get_children(): if _has_preview(c): return true return false- Step 2: Run it, verify it fails.
Run: godot --headless --path . --import then
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_card.gd -gexit
Expected: FAIL — CodexCard not found.
- Step 3: Implement
ui/codex_card.gd:
class_name CodexCardextends RefCounted
# Builds a read-only detail Control for one merged bestiary entry. Twin of# WeaponDetailView. Render-only. Hosts a live CodexPreview in a fixed-size pane.
const PREVIEW_SIZE := Vector2(300, 220)const SUB_COLOR := Color(0.62, 0.70, 0.82)
static func build(entry: Dictionary) -> Control: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 20)
# Left: the live preview, hosted in a SubViewport so its origin-centred drawing # sits inside a fixed pane. var svc := SubViewportContainer.new() svc.custom_minimum_size = PREVIEW_SIZE svc.stretch = true var sv := SubViewport.new() sv.size = Vector2i(PREVIEW_SIZE) sv.transparent_bg = true sv.disable_3d = true svc.add_child(sv) var preview := CodexPreview.new(entry) preview.position = PREVIEW_SIZE * 0.5 # centre the origin-based drawing sv.add_child(preview) # Drive the preview from a tiny timer node so it animates while the card is shown. var driver := _PreviewDriver.new() driver.preview = preview sv.add_child(driver) row.add_child(svc)
# Right: text details. var box := VBoxContainer.new() box.add_theme_constant_override("separation", 6) box.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(box)
var name_lbl := Label.new() name_lbl.text = String(entry.get("name", "")) name_lbl.add_theme_font_override("font", NeonTheme.title_font()) name_lbl.add_theme_font_size_override("font_size", 26) name_lbl.add_theme_color_override("font_color", entry.get("element_color", NeonTheme.CYAN)) box.add_child(name_lbl)
var threat := Label.new() var t := int(entry.get("threat", 1)) threat.text = "THREAT " + "●".repeat(t) + "○".repeat(maxi(0, 5 - t)) + " " + String(entry.get("element", "")).to_upper() threat.add_theme_font_override("font", NeonTheme.mono_font()) threat.add_theme_font_size_override("font_size", 13) threat.add_theme_color_override("font_color", Color(1.0, 0.7, 0.3)) box.add_child(threat)
var stats := Label.new() stats.text = "HP %d SPD %d CONTACT %d ARMOR %d" % [ int(entry.get("hp", 0)), int(entry.get("speed", 0)), int(entry.get("contact_damage", 0)), int(entry.get("armor", 0))] stats.add_theme_font_override("font", NeonTheme.mono_font()) stats.add_theme_font_size_override("font_size", 13) stats.add_theme_color_override("font_color", SUB_COLOR) box.add_child(stats)
box.add_child(_para("desc", String(entry.get("desc", "")), Color(0.82, 0.88, 0.95))) var counter_hdr := Label.new() counter_hdr.text = "HOW TO COUNTER" counter_hdr.add_theme_font_override("font", NeonTheme.mono_font()) counter_hdr.add_theme_font_size_override("font_size", 11) counter_hdr.add_theme_color_override("font_color", Color(0.45, 1.0, 0.55)) box.add_child(counter_hdr) box.add_child(_para("counter", String(entry.get("counter", "")), Color(0.7, 0.95, 0.78))) return row
static func _para(_name: String, text: String, color: Color) -> Label: var l := Label.new() l.text = text l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART l.custom_minimum_size = Vector2(360, 0) l.add_theme_font_override("font", NeonTheme.mono_font()) l.add_theme_font_size_override("font_size", 14) l.add_theme_color_override("font_color", color) return l
# Tiny node that advances the preview each frame while the card is in the tree.class _PreviewDriver extends Node: var preview: CodexPreview func _process(delta: float) -> void: if preview != null: preview.advance(delta)- Step 4: Run the test, verify it passes.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_card.gd -gexit
Expected: PASS (2 tests).
- Step 5: Ritual + commit.
git add ui/codex_card.gd tests/test_codex_card.gdgit commit -m "feat(codex): CodexCard — detail control with hosted live preview
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Task 6: CodexOverlay — first-encounter + browse modes
Section titled “Task 6: CodexOverlay — first-encounter + browse modes”Files:
- Create:
ui/codex_overlay.gd - Test:
tests/test_codex_overlay.gd
Interfaces:
-
Consumes:
BestiaryDB(Task 2),CodexCard(Task 5),NeonTheme. -
Produces:
CodexOverlay(extendsCanvasLayer):setup(db: BestiaryDB) -> void(call once after instantiation)show_new(keys: Array) -> void— first-encounter queue; freezes viais_open()open_browse(seen: Dictionary) -> voidclose() -> void;is_open() -> bool- signal
closed(main resumes / restores the calling menu on this)
-
Step 1: Write the failing test
tests/test_codex_overlay.gd:
extends GutTest
func _db() -> BestiaryDB: return BestiaryLoader.load_from_path("res://data/bestiary.json", ContentLoader.load_from_path("res://data/bible.json"))
func _overlay() -> CodexOverlay: var o := CodexOverlay.new() add_child_autofree(o) o.setup(_db()) return o
func test_starts_closed() -> void: var o := _overlay() assert_false(o.is_open(), "closed on construction")
func test_show_new_opens_and_queues() -> void: var o := _overlay() o.show_new(["swarmer", "ghost"]) assert_true(o.is_open(), "first-encounter card is open") assert_eq(o.queued_count(), 2, "two new enemies queued")
func test_show_new_empty_does_not_open() -> void: var o := _overlay() o.show_new([]) assert_false(o.is_open(), "no new enemies → stays closed")
func test_open_browse_lists_all_with_lock_state() -> void: var o := _overlay() o.open_browse({"swarmer": true}) assert_true(o.is_open(), "browse open") assert_eq(o.entry_count(), 22, "all 22 entries listed") assert_eq(o.seen_count(), 1, "one unlocked")
func test_close_emits_and_clears() -> void: var o := _overlay() watch_signals(o) o.open_browse({}) o.close() assert_false(o.is_open()) assert_signal_emitted(o, "closed")- Step 2: Run it, verify it fails.
Run: godot --headless --path . --import then
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_overlay.gd -gexit
Expected: FAIL — CodexOverlay not found.
- Step 3: Implement
ui/codex_overlay.gd:
class_name CodexOverlayextends CanvasLayer
# Two-mode enemy codex overlay (render-only). FIRST-ENCOUNTER: a single "NEW ENEMY"# card per queued key, freezes the sim (main gates on is_open()), dismiss advances# then resumes. BROWSE: a left list of all entries (seen=name, unseen=???) + the# selected card. tvOS-safe nav mirrors WeaponInfoOverlay (debounced stick/d-pad/keys).
signal closed
const NAV_DEBOUNCE_MS := 180
enum Mode { NONE, NEW, BROWSE }
var _db: BestiaryDBvar _mode: int = Mode.NONEvar _dim: ColorRectvar _title: Labelvar _hint: Labelvar _list: VBoxContainervar _detail: PanelContainervar _last_nav_ms := 0
# NEW modevar _queue: Array = []# BROWSE modevar _seen: Dictionary = {}var _keys: Array = []var _sel := 0
func _ready() -> void: layer = 24 _dim = ColorRect.new() _dim.set_anchors_preset(Control.PRESET_FULL_RECT) _dim.color = Color(0.0, 0.01, 0.03, 0.9) _dim.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_dim)
var root := MarginContainer.new() root.set_anchors_preset(Control.PRESET_FULL_RECT) for m in ["margin_left", "margin_right", "margin_top", "margin_bottom"]: root.add_theme_constant_override(m, 56) root.theme = NeonTheme.get_theme() add_child(root)
var outer := VBoxContainer.new() outer.add_theme_constant_override("separation", 12) root.add_child(outer)
_title = Label.new() _title.add_theme_font_override("font", NeonTheme.title_font()) _title.add_theme_font_size_override("font_size", 32) _title.add_theme_color_override("font_color", NeonTheme.CYAN) outer.add_child(_title)
var hb := HBoxContainer.new() hb.add_theme_constant_override("separation", 24) hb.size_flags_vertical = Control.SIZE_EXPAND_FILL outer.add_child(hb)
_list = VBoxContainer.new() _list.add_theme_constant_override("separation", 4) _list.custom_minimum_size = Vector2(220, 0) hb.add_child(_list) hb.add_child(VSeparator.new())
_detail = PanelContainer.new() _detail.size_flags_horizontal = Control.SIZE_EXPAND_FILL var sb := StyleBoxFlat.new() sb.bg_color = Color(0.04, 0.06, 0.12, 0.92) sb.set_border_width_all(1) sb.border_color = Color(0.22, 0.32, 0.48, 0.65) sb.set_corner_radius_all(8) sb.set_content_margin_all(16) _detail.add_theme_stylebox_override("panel", sb) hb.add_child(_detail)
_hint = Label.new() _hint.add_theme_font_override("font", NeonTheme.mono_font()) _hint.add_theme_font_size_override("font_size", 12) _hint.add_theme_color_override("font_color", Color(0.5, 0.6, 0.72)) outer.add_child(_hint)
visible = false
func setup(db: BestiaryDB) -> void: _db = db
func is_open() -> bool: return _mode != Mode.NONE
func queued_count() -> int: return _queue.size()
func entry_count() -> int: return _keys.size()
func seen_count() -> int: var n := 0 for k in _keys: if _seen.has(k): n += 1 return n
func show_new(keys: Array) -> void: if keys.is_empty() or _db == null: return _queue = keys.duplicate() _mode = Mode.NEW visible = true _title.text = "NEW ENEMY" _hint.text = "press to continue" _list.visible = false _render_new()
func open_browse(seen: Dictionary) -> void: if _db == null: return _seen = seen.duplicate() _keys = _db.all_keys() _sel = 0 _mode = Mode.BROWSE visible = true _title.text = "BESTIARY" _hint.text = "↑/↓ select · press again / back to close" _list.visible = true _rebuild_browse()
func close() -> void: _mode = Mode.NONE _queue.clear() visible = false closed.emit()
func _render_new() -> void: for c in _detail.get_children(): c.queue_free() if _queue.is_empty(): close(); return _detail.add_child(CodexCard.build(_db.entry_for_key(String(_queue[0]))))
func _rebuild_browse() -> void: for c in _list.get_children(): c.queue_free() for i in range(_keys.size()): var key: String = _keys[i] var unlocked: bool = _seen.has(key) var sel := i == _sel var lbl := Label.new() var name := String(_db.entry_for_key(key).get("name", key)) if unlocked else "???" lbl.text = ("▸ " if sel else " ") + name lbl.add_theme_font_override("font", NeonTheme.title_font()) lbl.add_theme_font_size_override("font_size", 17) lbl.add_theme_color_override("font_color", NeonTheme.CYAN if sel else (Color(0.66, 0.78, 0.92) if unlocked else Color(0.4, 0.44, 0.52))) _list.add_child(lbl) _render_browse_detail()
func _render_browse_detail() -> void: for c in _detail.get_children(): c.queue_free() if _keys.is_empty(): return var key: String = _keys[_sel] if _seen.has(key): _detail.add_child(CodexCard.build(_db.entry_for_key(key))) else: var l := Label.new() l.text = "??? \n\nYou haven't encountered this enemy yet." l.add_theme_font_override("font", NeonTheme.mono_font()) l.add_theme_color_override("font_color", Color(0.5, 0.55, 0.62)) _detail.add_child(l)
func _input(event: InputEvent) -> void: if _mode == Mode.NONE: return # Confirm / dismiss. var confirm := event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel") if not confirm and event is InputEventJoypadButton: confirm = event.pressed and (event.button_index == JOY_BUTTON_A or event.button_index == JOY_BUTTON_B) if _mode == Mode.NEW: if confirm: _queue.pop_front() if _queue.is_empty(): close() else: _render_new() get_viewport().set_input_as_handled() return # BROWSE: ui_cancel / B closes; A also closes (toggle idiom). if confirm: close() get_viewport().set_input_as_handled() return var fwd := event.is_action_pressed("move_down") or event.is_action_pressed("ui_down") var back := event.is_action_pressed("move_up") or event.is_action_pressed("ui_up") if not (fwd or back): return var now := Time.get_ticks_msec() if now - _last_nav_ms < NAV_DEBOUNCE_MS: get_viewport().set_input_as_handled(); return _last_nav_ms = now if not _keys.is_empty(): _sel = wrapi(_sel + (1 if fwd else -1), 0, _keys.size()) _rebuild_browse() get_viewport().set_input_as_handled()NOTE:
Time.get_ticks_msec()is an Engine API — this is a render-side UI file (ui/), so that is fine (the/simban does not apply here;WeaponInfoOverlayuses it the same way).
- Step 4: Run the test, verify it passes.
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_overlay.gd -gexit
Expected: PASS (5 tests).
- Step 5: Ritual + commit.
git add ui/codex_overlay.gd tests/test_codex_overlay.gdgit commit -m "feat(codex): CodexOverlay — first-encounter freeze + browse modes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Task 7: Wire detection + freeze + toggle into main.gd
Section titled “Task 7: Wire detection + freeze + toggle into main.gd”Files:
- Modify:
main.gd - (No new test — covered by boot smoke + determinism + manual play. The detection logic itself is unit-tested in Task 3.)
Interfaces:
-
Consumes:
BestiaryLoader/BestiaryDB(Tasks 2-3),CodexOverlay(Task 6),MetaState.has_seen/mark_seen(Task 1),MetaStore.save_state. -
Produces:
var codex_db: BestiaryDB,var codex: CodexOverlayonmain; first-encounter freeze; an in-run browse toggle. -
Step 1: Declare members near the other panel vars (e.g. after the
weapon_infodeclaration, ~line 55):
var codex_db: BestiaryDBvar codex: CodexOverlay # enemy encyclopedia — first-encounter freeze + browse- Step 2: Instantiate as persistent singletons in
_ready(). After thecontrol_clientblock (~line 105), add:
codex_db = BestiaryLoader.load_from_path("res://data/bestiary.json", ContentLoader.load_from_path("res://data/bible.json")) codex = CodexOverlay.new() add_child(codex) codex.setup(codex_db) codex.closed.connect(_on_codex_closed)- Step 3: Keep
codexacross runs. In_new_run()’s persistent-singleton guard (~line 215), addcodexto the keep list:
for c in get_children(): if c == gameplay_telemetry or c == quality_manager or c == control_client \ or c == results or c == meta_shop or c == codex: continue c.queue_free()- Step 4: Add
codex.is_open()to the freeze gate in_physics_process(~line 564). Change:
if sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu or weapon_info.is_open(): returnto:
if sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu \ or weapon_info.is_open() or codex.is_open(): return- Step 5: Detect first encounters in
_process. In_processafter thequality_manager.tick(...)block (and before the player-render section ~line 670), add:
_check_codex_encounters()Then add the methods (place near _toggle_weapon_info, ~line 911):
func _check_codex_encounters() -> void: # First-EVER sighting of an enemy type → freeze + pop the codex card. Render-only: # reads the live pool, writes only the meta save. Won't fire while another overlay is up. if sim == null or sim.game_over or _paused_for_levelup or _paused_for_menu \ or _story_won or weapon_info.is_open() or codex.is_open() or meta == null: return var fresh := codex_db.first_encounters(sim.enemies.type_id, sim.enemies.count, meta.seen_enemies) if fresh.is_empty(): return for k in fresh: meta.mark_seen(String(k)) MetaStore.save_state(meta) codex.show_new(fresh)
func _on_codex_closed() -> void: # Browse opened from a menu restores that menu via its own callback; the # first-encounter path just resumes (the freeze gate clears as is_open()→false). pass- Step 6: Add an in-run browse toggle. In the existing
_inputkeyboard block (theif event is InputEventKey and event.pressed and not event.echo:block that handlesKEY_V/KEY_ESCAPEviaphysical_keycode, ~line 895), add aKEY_Cbranch alongside theKEY_Vone (match thephysical_keycodeidiom):
if event.physical_keycode == KEY_C: _toggle_codex() returnThen add the helper next to _toggle_weapon_info (~line 911):
func _toggle_codex() -> void: if sim == null or sim.game_over or _paused_for_menu or _paused_for_levelup or _story_won: return if codex.is_open(): codex.close() else: codex.open_browse(meta.seen_enemies if meta != null else {})(Controller access to the in-run codex is provided via the pause menu in Task 8 — this key is the desktop convenience.)
- Step 7: Verify boot + determinism.
godot --headless --path . --import 2>&1 | grep -i error || echo "import clean"godot --headless --path . --quit-after 180 2>&1 | grep "SCRIPT ERROR" && echo "BOOT FAIL" || echo "boot clean"godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitExpected: import clean, boot clean, determinism 1405185210/3122397125 unchanged.
Headless caveat: the boot path auto-starts survival and
MetaStore.load_statereads the realuser://meta.json. The first headless boot may legitimately mark + save newseen_enemies(it writes the editor’s user dir, harmless). This does NOT affect determinism (the determinism test buildsSim.newdirectly and never instantiatesmain/codex/meta).
-
Step 8: Manual play check. Open the editor, F5. Within the first seconds a “NEW ENEMY” card should freeze the game on the first swarmer; dismissing resumes. Press
Cto open the browse codex; it lists seen enemies +???for the rest. -
Step 9: Ritual + commit. Full ritual (count guard, boot, determinism), then:
git add main.gdgit commit -m "feat(codex): wire first-encounter freeze + in-run browse toggle into main
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Task 8: “Bestiary” entries in the pause and start menus
Section titled “Task 8: “Bestiary” entries in the pause and start menus”Files:
- Modify:
ui/pause_menu.gd - Modify:
ui/start_menu.gd - Modify:
main.gd(wire the new signals)
Interfaces:
-
Consumes:
CodexOverlay.open_browse(Task 6),main.codex/main.meta(Task 7). -
Produces:
PauseMenusignalbestiary_requested;StartMenusignalbestiary_requested; main opens the browse codex and restores the calling menu onclosed. -
Step 1: PauseMenu — add the signal + button. In
ui/pause_menu.gd, add to the signals block (~line 14):
signal bestiary_requestedIn _ready(), add a button next to the Shop entry (~line 65):
box.add_child(_make_btn("Bestiary", func() -> void: bestiary_requested.emit()))- Step 2: StartMenu — add the signal + footer button. In
ui/start_menu.gd, add to the signals block (~line 11):
signal bestiary_requestedThe start menu’s footer buttons are hand-built Buttons carrying a set_meta("mode", …) sentinel that its own _input confirm routing reads. Add a Bestiary footer button right after the Shop button is appended (after box.add_child(shop_btn), ~line 79), mirroring the Shop button’s construction exactly:
var codex_btn := Button.new() codex_btn.text = "Bestiary" codex_btn.focus_mode = Control.FOCUS_ALL codex_btn.add_theme_font_override("font", NeonTheme.mono_font()) codex_btn.add_theme_font_size_override("font_size", 14) var codex_color := Color(0.5, 0.85, 1.0) codex_btn.add_theme_stylebox_override("normal", _card_box(codex_color, 0.08)) codex_btn.add_theme_stylebox_override("hover", _card_box(codex_color, 0.22)) codex_btn.add_theme_stylebox_override("pressed",_card_box(codex_color, 0.30)) codex_btn.add_theme_stylebox_override("focus", _card_box(codex_color, 0.22)) codex_btn.custom_minimum_size = Vector2(CARD_W, 44) codex_btn.set_meta("mode", "bestiary") codex_btn.pressed.connect(func() -> void: bestiary_requested.emit()) _cards.append(codex_btn) box.add_child(codex_btn)Then route the "bestiary" sentinel in StartMenu._input’s confirm block (~line 140, where sel_mode == "shop" is handled) — add an elif:
elif sel_mode == "bestiary": bestiary_requested.emit()- Step 3: Wire PauseMenu in main. In the pause-menu setup block (~line 150, where
pause_menu.shop_requested.connect(...)is), add:
pause_menu.bestiary_requested.connect(func() -> void: if pause_menu != null: pause_menu.visible = false codex.open_browse(meta.seen_enemies if meta != null else {}) await codex.closed if pause_menu != null: pause_menu.visible = true pause_menu.refocus())(If PauseMenu has no refocus(), drop that line — check the class; the Shop wiring above shows whether refocus() exists.)
- Step 4: Wire StartMenu in main. In
_show_start_menu()(~line 250, wherestart_menu.shop_requested.connect(...)is), add:
start_menu.bestiary_requested.connect(func() -> void: start_menu.visible = false codex.open_browse(meta.seen_enemies if meta != null else {}) await codex.closed if start_menu != null: start_menu.visible = true start_menu.refocus())(Match the start-menu’s own restore idiom shown in the adjacent shop_requested
connect — use whatever it uses for re-show/refocus.)
- Step 5: Verify boot + import.
godot --headless --path . --import 2>&1 | grep -i error || echo "import clean"godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR" && echo "BOOT FAIL" || echo "boot clean"Expected: clean.
-
Step 6: Manual play check. In the editor: from the start menu, open Bestiary → browse → close → returns to start menu. Start a run, open the pause menu (Esc / Start button) → Bestiary → browse → close → returns to pause menu.
-
Step 7: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:
git add ui/pause_menu.gd ui/start_menu.gd main.gdgit commit -m "feat(codex): Bestiary entries in the pause and start menus
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"Final verification (after Task 8)
Section titled “Final verification (after Task 8)”- Full suite + count guard green:
scripts/check-test-count.sh - Determinism byte-identical:
1405185210/3122397125. - Boot smoke clean (no
SCRIPT ERROR). - Manual: first-encounter card freezes + dismisses; browse from both menus; cross-run persistence (kill the game, relaunch, previously-seen enemies don’t re-pop and show unlocked in the bestiary).
- Open a PR (do not merge to main without Chris): the work is on
feat/enemy-codex. - tvOS / web-demo sync is a SEPARATE step via the
bh-deployritual after merge — out of scope for this plan.
Self-review notes (addressed)
Section titled “Self-review notes (addressed)”- Spec coverage: persistence (T1), data/loader/DB (T2), detection (T3), preview (T4), card (T5), overlay both modes (T6), main wiring + freeze + toggle (T7), pause+start menu entries (T8). All spec sections mapped.
- SCHEMA_VERSION: spec said “bump”; corrected to “do NOT bump” (additive field; bumping resets older saves). Documented in Global Constraints + Task 1.
- Type consistency: merged-entry contract fixed in Task 2 and consumed identically by
CodexPreview(T4),CodexCard(T5),CodexOverlay(T6).first_encounterssignature identical across T2 (impl), T3 (test), T7 (call). - Determinism: no
/simtick-path change in any task; baseline reaffirmed per task.