Warden rebuild (implementation plan)
Warden Rebuild Implementation Plan
Section titled “Warden Rebuild 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: Make the Survival Warden a scarier, smarter boss with a Nova-look body and player-grade attack FX.
Architecture: Sim changes stay in sim/boss_warden.gd + sim/boss_state.gd, all new behaviour gated on
BossWarden.survival(sim). Render: a Nova bucket for the Warden core in ArchetypeRenderer, a new
render/warden_body_renderer.gd for the rotating ring layers, and new WeaponFxLayer looks in fx/boss/
driven by a new fx/boss/warden_fx.gd coordinator that replaces BossRenderer’s _draw() while
VisualLook.warden_v2 is on.
Tech Stack: Godot 4.7 typed GDScript, GUT, canvas_item .gdshader, Python/PIL bake tool.
Spec: docs/superpowers/specs/2026-10-01-warden-rebuild-design.md (read it first).
Global Constraints
Section titled “Global Constraints”/simis pure logic:extends RefCounted, no Node/Engine/Time/Input APIs. Ticks onSim_Const.DT.- All randomness via
sim.rng(SeededRng). Neverupgrade_rng/drone_rng/player_rngfor the boss. - Tutorial/story Warden (
sim.story != null) behaviour must be byte-identical to today: 4-attack cycle(idx+1) % TUTORIAL_ATTACK_COUNT, same speeds/damage/telegraphs.tests/test_warden_survival.gd::test_story_warden_is_unchangedmust stay green unmodified. - Determinism baselines (
tests/test_determinism*.gd) must not change. If one does, STOP and report — do not re-pin. - Tunable numbers are
static var(remote-config convention) and get a row innet/tunables_registry.gdnext to the existingBOSS_*/SV_*rows (same format as those rows). - No
ShaderMaterial.new()/Shader.new()in render/ui/fx/main.gd; every.gdshaderreferenced by a saved_mat.tres(tests/test_baked_shader_materials.gd). - Shader rules (from Weapon FX v2 reviews): no
pow()on a possibly-negative base; fade to 0 before any discard/extent edge (no hard rims); smoothstep edges in the right order; per-instance COLOR/CUSTOM must survive half-float precision (web); multiply final rgb byBASE_DIMuniform like the player looks. - Every
WeaponFxLayerlook:extends WeaponFxLayer,MAT_PATH/CAPconsts,_init(): super(preload(MAT_PATH).duplicate() as ShaderMaterial, CAP)— copyfx/weapons/blaster_flash_look.gd’s shape. - Hostile palette: body
#ff2e57, core white-hot, rim violet#8a2be2. Never a player element accent hue. - Work in your own worktree
.claude/worktrees/<task-slug>branched off the integration branchwarden-rebuild; never in the main checkout. - Tests: run
scripts/check-test-count.sh(never pull a raw GUT run into context);scripts/gate.shbefore reporting done. - After any windowed Godot run:
git checkout -- project.godot audio/bus_layout.tres. - Commit messages via
git commit -F <file>, ending with the Co-Authored-By / Claude-Session lines.
File Structure
Section titled “File Structure”| File | Responsibility |
|---|---|
sim/boss_state.gd |
+ ATTACK_FAN, ATTACK_LUNGE, deck state (last_attack, since_summon, strafe_sign, lunge_dir, lunge_t, volley_left, volley_t, second_ring_t, second_swing_t, fan_dir) |
sim/boss_warden.gd |
deck pick, new attacks, strafe/reposition, phase 2, missile soak rule, telemetry tags, extended render_info |
sim/warden_deck.gd (new) |
pure weighted pick by distance band (static funcs, testable alone) |
sim/sim.gd |
_hurt_player gets boss_attack tag → boss_dmg_by_attack; boss_log carries it |
net/run_timeline.gd |
boss entry gains the dmg map |
tools/nova_units/nova_unit_designs.py |
warden core + two ring designs, 512 px export |
render/archetype_renderer.gd |
Warden Nova bucket |
render/warden_body_renderer.gd (new) |
rotating ring sprites, state-driven |
render/visual_look.gd, ui/debug_settings_panel.gd |
warden_v2 A/B toggle |
fx/boss/*.gd/.gdshader/_mat.tres (new) |
warden_bolt, warden_drone, warden_missile, warden_burst, warden_swing, warden_charge, warden_lane looks |
fx/boss/warden_fx.gd (new) |
Node2D coordinator owning the 7 layers; fed by main each frame |
render/boss_renderer.gd |
early-return in _draw() when VisualLook.warden_v2 |
main.gd |
wiring: hide boss-sourced dots, feed WardenFx + WardenBodyRenderer |
render/prewarm_pass.gd |
warm the 7 new pipelines |
tools/vfx_library/library_manifest.gd, data/bestiary.json, docs |
housekeeping |
render_info contract (produced by Tasks 1-3, consumed by Tasks 5-7)
Section titled “render_info contract (produced by Tasks 1-3, consumed by Tasks 5-7)”BossWarden.render_info(sim) keeps every existing key and adds:
"lane": {} or {"kind": "fan"|"lunge", "origin": Vector2, "dir": Vector2, "length": float, "half_angle": float (radians; 0 for lunge), "progress": float 0..1}"double_swing_t": float # seconds until the enraged second swing lands, -1 if none pending"strafing": bool"telegraph_kind": int # BossState.ATTACK_* being telegraphed, -1 if not in TELEGRAPHTask 1: Range-aware attack deck (Survival)
Section titled “Task 1: Range-aware attack deck (Survival)”Files:
- Create:
sim/warden_deck.gd - Modify:
sim/boss_state.gd,sim/boss_warden.gd(REST → next-attack transition, ~line 134-138),net/tunables_registry.gd - Test:
tests/test_warden_deck.gd(new); updatetests/test_boss.gd::test_boss_cycles_attacksandtest_boss_cycles_through_every_attackto pin the TUTORIAL cycle (setsim.story = StoryState.new()before spawn) — their intent was the cycle, which is now tutorial-only.
Interfaces:
- Produces:
BossState.ATTACK_FAN := 6,BossState.ATTACK_LUNGE := 7,BossState.ATTACK_SLAM := 8(enraged double swing, reuses swing hit),ATTACK_COUNT := 9;TUTORIAL_ATTACK_COUNTstays 4.BossStatevarslast_attack: int = -1,since_summon: int = 99. - Produces:
WardenDeck.band(dist: float) -> int(0 close, 1 mid, 2 far),WardenDeck.pick(sim: Sim, dist: float, enraged: bool, summon_ok: bool) -> int. - Produces statics on BossWarden:
DECK_CLOSE := 300.0,DECK_FAR := 600.0(static var).
Weights (static var Dictionaries on WardenDeck, attack_id → weight):
- close: SWING 4, SLAM 3 (enraged only), FAN 2, BARRAGE 1
- mid: FAN 4, SPIRAL 3, BARRAGE 2, MISSILES 2, SWARM 1
- far: LUNGE 4, MISSILES 3, SWARM 3, SUMMON 1
Rules: drop last_attack; drop SLAM unless enraged; drop SUMMON unless summon_ok (= since_summon >= 3 and summoned alive < SUMMON_MAX_ADDS). Weighted pick: r = sim.rng.randf() * total, walk weights in ascending attack-id order (fixed order = deterministic). If every weight is dropped, return SWING.
- Step 1: Write failing tests in
tests/test_warden_deck.gd:
extends GutTest
func _sim() -> Sim: var s := Sim.new(7, SimContentFixture.db()) s.enable_survival() s.enemies.count = 0 s.enemy_proj.count = 0 return s
func _counts(sim: Sim, dist: float, enraged: bool, summon_ok: bool, n: int) -> Dictionary: var c := {} for _i in range(n): var a := WardenDeck.pick(sim, dist, enraged, summon_ok) c[a] = int(c.get(a, 0)) + 1 sim.boss.last_attack = -1 return c
func test_bands() -> void: assert_eq(WardenDeck.band(100.0), 0) assert_eq(WardenDeck.band(450.0), 1) assert_eq(WardenDeck.band(900.0), 2)
func test_far_never_swings() -> void: var c := _counts(_sim(), 900.0, false, true, 400) assert_false(c.has(BossState.ATTACK_SWING), "no swing from 900px") assert_true(c.has(BossState.ATTACK_LUNGE))
func test_close_prefers_swing_and_no_lunge() -> void: var c := _counts(_sim(), 150.0, false, true, 400) assert_gt(int(c.get(BossState.ATTACK_SWING, 0)), int(c.get(BossState.ATTACK_BARRAGE, 0))) assert_false(c.has(BossState.ATTACK_LUNGE)) assert_false(c.has(BossState.ATTACK_SLAM), "slam is enraged-only")
func test_slam_only_when_enraged() -> void: var c := _counts(_sim(), 150.0, true, true, 400) assert_true(c.has(BossState.ATTACK_SLAM))
func test_never_repeats_last_attack() -> void: var sim := _sim() for _i in range(200): var a := WardenDeck.pick(sim, 450.0, false, true) assert_ne(a, sim.boss.last_attack) sim.boss.last_attack = a
func test_summon_gated() -> void: var c := _counts(_sim(), 900.0, false, false, 400) assert_false(c.has(BossState.ATTACK_SUMMON))
func test_same_seed_same_sequence() -> void: var a := _sim() var b := _sim() for _i in range(50): assert_eq(WardenDeck.pick(a, 450.0, false, true), WardenDeck.pick(b, 450.0, false, true))
func test_survival_rest_picks_from_deck_by_range() -> void: var sim := _sim() sim.boss_warden.spawn(sim) var bi := sim.boss_rotation.boss_index(sim) sim.enemies.pos[bi] = sim.player.pos + Vector2(900, 0) sim.boss.phase = BossState.PHASE_REST sim.boss.timer = 10.0 sim.boss_warden.update(sim, Sim_Const.DT) assert_ne(sim.boss.attack_idx, BossState.ATTACK_SWING) assert_eq(sim.boss.last_attack, BossState.ATTACK_SWING, "last_attack records the one just finished")
func test_story_rest_still_cycles() -> void: var sim := _sim() sim.story = StoryState.new() sim.boss_warden.spawn(sim) sim.boss.phase = BossState.PHASE_REST sim.boss.timer = 10.0 sim.boss_warden.update(sim, Sim_Const.DT) assert_eq(sim.boss.attack_idx, BossState.ATTACK_BARRAGE)- Step 2: Run
scripts/check-test-count.sh— expect the new file’s tests to FAIL (WardenDeck undefined). - Step 3: Implement
sim/warden_deck.gd(class_name WardenDeck extends RefCounted, static funcs + static var weight dicts per the rules above). InBossStateadd the constants/vars and reset them inreset(). Inboss_warden.gdPHASE_REST: ifsurvival(sim):sim.boss.last_attack = sim.boss.attack_idx;since_summon+= 1 (reset to 0 when SUMMON picked); computesummon_ok;attack_idx = WardenDeck.pick(...)with dist to_nearest_pilot. Else keep(idx+1) % attack_count(sim). Until Tasks 2-3 land,fire()treats FAN/LUNGE/SLAM as no-ops (Task 2/3 fill them) — addelifarms with apassand a comment naming the task.attack_count()for survival stays used only by story; leave it returningATTACK_COUNT. - Step 4: Update
tests/test_boss.gdcycle tests to set story first (see Files). Runscripts/check-test-count.sh→ all green. - Step 5: Determinism check —
tests/test_determinism*.gdpass unchanged. - Step 6: Commit
feat(warden): range-aware attack deck for the Survival Warden.
Task 2: New attacks — aimed fan, closing barrage, lunge, slam
Section titled “Task 2: New attacks — aimed fan, closing barrage, lunge, slam”Files:
- Modify:
sim/boss_warden.gd(fire(), PHASE_ACTIVE,render_info),sim/boss_state.gd,net/tunables_registry.gd - Test:
tests/test_warden_attacks.gd(new)
Interfaces:
- Consumes: Task 1 constants.
- Produces statics:
FAN_VOLLEYS := 3,FAN_VOLLEYS_ENRAGED := 5,FAN_SHOTS := 7,FAN_HALF_ANGLE := deg_to_rad(25.0),FAN_VOLLEY_GAP := 0.18,FAN_SPEED := 380.0,FAN_LEAD_MAX := 0.9(max seconds of lead),BARRAGE_RING2_DELAY := 0.12,LUNGE_SPEED := 760.0,LUNGE_LENGTH := 620.0,LUNGE_DMG := 40.0,LUNGE_BURST := 12,SLAM_SECOND_DELAY := 0.45. - Produces:
func predict_aim(sim: Sim, from: Vector2, speed: float) -> Vector2(unit dir to the pilot’s predicted position usingpilot.velocity, lead timemin(dist/speed, FAN_LEAD_MAX)). - Produces the
render_infokeyslane,double_swing_t,telegraph_kind(see contract).
Behaviour (Survival only; story never selects these ids):
-
FAN: on fire, latch
fan_dir = predict_aim;volley_left = volleys, fire volley 0 immediately, subsequent volleys everyFAN_VOLLEY_GAPduring ACTIVE (extend ACTIVE untilvolley_left == 0). Each volley re-aims withpredict_aim. 7 shots evenly across[-HALF, +HALF]viasim._boss_proj(bpos, dir*FAN_SPEED, sim.SHOOTER_PROJ_RADIUS, sim.SHOOTER_PROJ_LIFETIME, sim.SHOOTER_PROJ_DAMAGE, EnemyPool.TYPE_BOSS). Telegraphlane= fan cone along the predicted dir (re-predicted each tick during TELEGRAPH, latched at fire). -
BARRAGE (Survival): ring 1 as today; ring 2 offset by
PI / count, firedBARRAGE_RING2_DELAYlater (second_ring_ttimer in ACTIVE). Story barrage unchanged (single ring). -
LUNGE: telegraph latches
lunge_dir = predict_aim(sim, bpos, LUNGE_SPEED), lane{kind:"lunge", length: LUNGE_LENGTH}. ACTIVE: move atLUNGE_SPEEDalonglunge_diruntilLUNGE_LENGTHtravelled (extend ACTIVE as needed); one hit max (firedguard) if pilot withinBOSS_RADIUS + pilot.radiusof boss →_hurt_player(LUNGE_DMG, "boss", ...); at end, radial burst ofLUNGE_BURSTboss shots. Respect arena clamp the same way approach movement does (check howsim.enemies.posis clamped for other movers; if none, clamp tosim.arenabounds if present — read sim.gd first). -
SLAM: identical to SWING for the first hit, then
second_swing_t = SLAM_SECOND_DELAY; when it elapses a secondswing_hit(with its ownfiredguard,second_fired) at the boss’s current position. Emitsboss_swing_hitfx_event each landed hit, and a new{"kind":"boss_swing_start","pos":bpos}fx_event at each swing’s ACTIVE start (for the FX). -
Step 1: Failing tests
tests/test_warden_attacks.gd(reuse_sim()/_fire()fromtest_warden_survival.gd, copied in):
func test_fan_aims_ahead_of_a_moving_pilot() -> void: var sim := _sim() sim.boss_warden.spawn(sim) var bi := sim.boss_rotation.boss_index(sim) sim.enemies.pos[bi] = sim.player.pos + Vector2(-500, 0) sim.player.velocity = Vector2(0, 260) _fire(sim, BossState.ATTACK_FAN) assert_eq(sim.enemy_proj.count, BossWarden.FAN_SHOTS) var mean := Vector2.ZERO for j in range(sim.enemy_proj.count): mean += sim.enemy_proj.vel[j].normalized() assert_gt(mean.normalized().y, 0.15, "the fan leads a pilot moving down")
func test_fan_fires_all_volleys() -> void: var sim := _sim() sim.boss_warden.spawn(sim) _fire(sim, BossState.ATTACK_FAN) for _k in range(60): sim.boss_warden.update(sim, Sim_Const.DT) assert_eq(sim.enemy_proj.count, BossWarden.FAN_SHOTS * BossWarden.FAN_VOLLEYS)
func test_survival_barrage_fires_offset_second_ring() -> void: var sim := _sim() sim.boss_warden.spawn(sim) _fire(sim, BossState.ATTACK_BARRAGE) for _k in range(12): sim.boss_warden.update(sim, Sim_Const.DT) assert_eq(sim.enemy_proj.count, BossWarden.SV_BARRAGE_COUNT * 2) var a0 := sim.enemy_proj.vel[0].angle() var a1 := sim.enemy_proj.vel[BossWarden.SV_BARRAGE_COUNT].angle() assert_almost_eq(absf(angle_difference(a0, a1)), PI / BossWarden.SV_BARRAGE_COUNT, 0.01)
func test_lunge_travels_and_hits_pilot_in_path() -> void: var sim := _sim() sim.boss_warden.spawn(sim) var bi := sim.boss_rotation.boss_index(sim) sim.enemies.pos[bi] = sim.player.pos + Vector2(-400, 0) sim.player.velocity = Vector2.ZERO var hp0 := sim.player.hp _fire(sim, BossState.ATTACK_LUNGE) for _k in range(60): sim.boss_warden.update(sim, Sim_Const.DT) assert_lt(sim.player.hp, hp0, "standing in the lane gets hit") assert_gt(sim.enemy_proj.count, 0, "lunge ends in a burst")
func test_slam_hits_twice_with_a_gap() -> void: var sim := _sim() sim.boss_warden.spawn(sim) var bi := sim.boss_rotation.boss_index(sim) sim.enemies.pos[bi] = sim.player.pos + Vector2(100, 0) sim.boss.enraged = true _fire(sim, BossState.ATTACK_SLAM) var hits := 0 for _k in range(60): sim.player.inv = 0.0 sim.player.iframe_timer = 0.0 var hp := sim.player.hp sim.boss_warden.update(sim, Sim_Const.DT) if sim.player.hp < hp: hits += 1 assert_eq(hits, 2)
func test_story_barrage_single_ring() -> void: var sim := _sim() sim.story = StoryState.new() sim.boss_warden.spawn(sim) _fire(sim, BossState.ATTACK_BARRAGE) for _k in range(12): sim.boss_warden.update(sim, Sim_Const.DT) assert_eq(sim.enemy_proj.count, BossWarden.BOSS_BARRAGE_COUNT)
func test_render_info_lane_during_fan_telegraph() -> void: var sim := _sim() sim.boss_warden.spawn(sim) sim.boss.attack_idx = BossState.ATTACK_FAN sim.boss.phase = BossState.PHASE_TELEGRAPH sim.boss.timer = 0.1 sim.boss_warden.update(sim, Sim_Const.DT) var info := sim.boss_warden.render_info(sim) assert_eq(info["lane"]["kind"], "fan") assert_eq(info["telegraph_kind"], BossState.ATTACK_FAN)(If sim.player.velocity isn’t written by the sim outside tick, the test sets it directly — that is the field predict_aim reads. Verify enemy_proj.vel is the field name in sim/enemy_proj_pool.gd and adapt the test only if it is named differently.)
- Step 2: Run → FAIL.
- Step 3: Implement per Behaviour. Telegraph time for FAN/LUNGE/SLAM =
BOSS_TELEGRAPH_TIME * cadence(same as other non-barrage attacks). - Step 4: Run
scripts/check-test-count.sh→ green; determinism unchanged. - Step 5: Commit
feat(warden): aimed fan, gap-closing barrage, lunge and enraged slam.
Task 3: Movement while attacking, phase 2, no missile soak
Section titled “Task 3: Movement while attacking, phase 2, no missile soak”Files:
- Modify:
sim/boss_warden.gd,sim/boss_state.gd,data/bible.json(wardenspeed88 → 120),net/tunables_registry.gd - Test:
tests/test_warden_movement.gd(new); updatetests/test_warden_survival.gd::test_warden_dashes_to_close_a_gaponly if the approach dash is removed — KEEP the approach dash (the lunge is an attack; the dash is gap-closing), so that test should stay green.
Interfaces:
- Produces statics:
STRAFE_MULT := 0.6,PREFERRED_RANGE := {0: 200.0, 1: 450.0, 2: 700.0}keyed by the band of the NEXT attack’s home band (FAN/SPIRAL/BARRAGE/MISSILES/SWARM → mid, SWING/SLAM → close, LUNGE/SUMMON → far);render_info["strafing"]. BossState.strafe_sign: float = 1.0flips each attack.
Behaviour (Survival only):
-
TELEGRAPH of ranged attacks (FAN, SPIRAL, BARRAGE, MISSILES, SWARM): move tangentially around the pilot at
SV_SPEED * STRAFE_MULT * strafe_sign(keep current distance). SWING/SLAM/LUNGE telegraphs stay planted (they’re the readable ones). -
APPROACH: move toward
PREFERRED_RANGE[home band of attack_idx]instead of straight onto the pilot (approach away if too close for a ranged pick, closer for swing). Approach time staysBOSS_APPROACH_TIME. -
Enraged: barrage telegraph =
BOSS_FIRE_TELEGRAPH_TIME * cadence(Survival only — story keeps 2.0 s). Pin a guard: survival enraged ≥ 0.45 s. -
Enraged paired attack: when MISSILES fires while enraged, also fire one SPIRAL cast.
-
Survival missiles:
update_missilesignores drones whensurvival(sim)(home on pilot, never soaked). Story unchanged. -
Step 1: Failing tests
tests/test_warden_movement.gd:
func test_strafes_during_ranged_telegraph() -> void: var sim := _sim() sim.boss_warden.spawn(sim) var bi := sim.boss_rotation.boss_index(sim) sim.enemies.pos[bi] = sim.player.pos + Vector2(450, 0) sim.boss.attack_idx = BossState.ATTACK_FAN sim.boss.phase = BossState.PHASE_TELEGRAPH sim.boss.timer = 0.0 var p0 := sim.enemies.pos[bi] for _k in range(20): sim.boss_warden.update(sim, Sim_Const.DT) var p1 := sim.enemies.pos[bi] assert_gt(p0.distance_to(p1), 20.0, "moves while winding up") assert_almost_eq(p1.distance_to(sim.player.pos), 450.0, 25.0, "keeps its range") assert_true(sim.boss_warden.render_info(sim)["strafing"])
func test_swing_telegraph_is_planted() -> void: var sim := _sim() sim.boss_warden.spawn(sim) var bi := sim.boss_rotation.boss_index(sim) sim.boss.attack_idx = BossState.ATTACK_SWING sim.boss.phase = BossState.PHASE_TELEGRAPH sim.boss.timer = 0.0 var p0 := sim.enemies.pos[bi] for _k in range(20): sim.boss_warden.update(sim, Sim_Const.DT) assert_eq(sim.enemies.pos[bi], p0)
func test_approach_backs_off_for_a_ranged_attack() -> void: var sim := _sim() sim.boss_warden.spawn(sim) var bi := sim.boss_rotation.boss_index(sim) sim.enemies.pos[bi] = sim.player.pos + Vector2(120, 0) sim.boss.attack_idx = BossState.ATTACK_FAN sim.boss.phase = BossState.PHASE_APPROACH sim.boss.timer = 0.1 for _k in range(60): sim.boss_warden.update(sim, Sim_Const.DT) assert_gt(sim.enemies.pos[bi].distance_to(sim.player.pos), 200.0)
func test_enraged_survival_barrage_telegraph_shortens() -> void: var sim := _sim() sim.boss_warden.spawn(sim) sim.boss.enraged = true sim.boss.attack_idx = BossState.ATTACK_BARRAGE sim.boss.phase = BossState.PHASE_TELEGRAPH sim.boss.timer = BossWarden.BOSS_FIRE_TELEGRAPH_TIME * BossWarden.BOSS_ENRAGE_TIME_MULT + 0.02 sim.boss_warden.update(sim, Sim_Const.DT) assert_eq(sim.boss.phase, BossState.PHASE_ACTIVE)
func test_story_barrage_telegraph_unchanged_when_enraged() -> void: var sim := _sim() sim.story = StoryState.new() sim.boss_warden.spawn(sim) sim.boss.enraged = true sim.boss.attack_idx = BossState.ATTACK_BARRAGE sim.boss.phase = BossState.PHASE_TELEGRAPH sim.boss.timer = 1.2 sim.boss_warden.update(sim, Sim_Const.DT) assert_eq(sim.boss.phase, BossState.PHASE_TELEGRAPH)
func test_enraged_missiles_pair_with_spiral() -> void: var sim := _sim() sim.boss_warden.spawn(sim) sim.boss.enraged = true _fire(sim, BossState.ATTACK_MISSILES) assert_eq(sim.boss_missiles.size(), BossWarden.SV_MISSILE_COUNT) assert_gt(sim.enemy_proj.count, 0, "a spiral cast rides along")
func test_survival_missiles_ignore_drones() -> void: var sim := _sim() sim.boss_warden.spawn(sim) # Put a missile right on a fake drone position and confirm it is NOT consumed by it. # Read sim/drone_director.gd for how to add a drone in a test (see tests/test_drones*.gd for a helper); # place the drone 30px from the missile and the pilot 600px away; tick update_missiles 5 times; # assert boss_missiles.size() == 1. Then the same setup with sim.story = StoryState.new(): # assert boss_missiles.size() == 0 (story keeps the soak).The last test’s drone setup comes from the existing drone test helper (find it with /usr/bin/grep -rln "drones.append\|add_drone" tests/); it is the only test in this plan whose setup lines are left to the implementer, because the drone fixture API must be read from the real tests.
- Step 2: Run → FAIL.
- Step 3: Implement. Update
data/bible.jsonwardenspeedto 120 andBossWarden.SV_SPEEDto 120.0 (the bible value wins at spawn; the const is the test reference). Runtools/balancesync if the bible has a generated mirror — checkdocs/architecture/content-and-elemental.mdfor the bible→game rule before editing. - Step 4: Run
scripts/check-test-count.sh→ green; determinism unchanged. - Step 5: Commit
feat(warden): strafes and repositions, real phase 2, missiles no longer soaked.
Task 4: Per-attack damage telemetry
Section titled “Task 4: Per-attack damage telemetry”Files:
- Modify:
sim/sim.gd(_hurt_playersignature + enemy-proj hit site ~line 3086, contact-damage site for TYPE_BOSS,boss_log),sim/boss_warden.gd(pass tags),net/run_timeline.gd - Test:
tests/test_warden_telemetry.gd(new), extendtests/test_run_timeline_sim.gd
Interfaces:
-
_hurt_player(amount, source="", is_dot=false, ignore_armor=false, pilot=player, boss_attack: String = "")— whenboss_attack != "", add the TAKEN amount (post gate + armor, pre min-hull clamp) toboss_dmg_by_attack[boss_attack]andboss_hits_by_attack[boss_attack] += 1(only if taken > 0). Both areDictionaryon Sim, reset on each Warden spawn, and copied into the boss’sboss_logrow as a 5th element{"dmg": {...}, "hits": {...}}at kill/despawn/run end. -
Tags:
swing,slam,lunge,missile,shot(any enemy_proj withsource_type == TYPE_BOSSandis_homing != 2),swarm(is_homing == 2from the boss),contact. -
RunTimeline.to_dict()boss entries become[name, spawn, kill, dmg_map]wheredmg_mapis the dmg dict rounded to ints (omit hits to keep payload small? NO — include both:{"d": {...}, "h": {...}}). -
death_causestays"boss". -
Step 1: Failing tests: swing hit records
boss_dmg_by_attack["swing"] > 0; a boss enemy_proj hit recordsshot; a swarm drone hit recordsswarm; story-mode hits also record (cheap, harmless);RunTimeline.to_dict()emits the 4th element. Write them in the style oftests/test_run_timeline_sim.gd:106. -
Step 2-4: run → fail → implement → green; determinism unchanged (telemetry dicts are not in
state_checksum— confirm by greppingstate_checksum). -
Step 5: Check
telemetry/src/worker.jsstorestimeline_jsonverbatim (no schema change needed) and that~/Claude/bullet-heaven-site/privacy/already covers “per-run gameplay summaries including boss encounters”. If the wording does not cover damage taken, add one bullet to the plan’s final report (do NOT edit the site repo in this task). -
Step 6: Commit
feat(telemetry): per-attack Warden damage in the run timeline.
Task 5: Nova body — design, bake, renderer, A/B toggle
Section titled “Task 5: Nova body — design, bake, renderer, A/B toggle”Files:
- Modify:
tools/nova_units/nova_unit_designs.py(3 designs +--exportsize override for them),render/archetype_renderer.gd,render/visual_look.gd,ui/debug_settings_panel.gd,main.gd - Create:
render/warden_body_renderer.gd,render/enemy_sprites/nova/warden_{body,glow}.png,warden_ring_inner_{body,glow}.png,warden_ring_outer_{body,glow}.png(+.importvia a headless import) - Test:
tests/test_warden_body_renderer.gd(new), extendtests/test_archetype_renderer.gd,tests/test_main.gdparity tests stay green
Design recipe (nova_unit_designs.py, same helpers P, ngon, ring_segment, blade_halves, inset, facet_shade):
warden()core: outer hub ringring_segment(0.20, 0.27, ...)as 8 plates; irisngon(16, 0.17); pupil = vertical almond (two arcs) radius 0.07; 4 small rivet plates at 45°. Colour#ff2e57.warden_ring_inner(): 8 locking platesring_segment(0.29, 0.36, a, a+tau/8-0.06)each with a notch; nothing inside 0.28 (transparent so the core shows).warden_ring_outer(): 6 heavy bladesblade_halves(a, 0.38, 0.49, 0.16)+ a thin broken ringring_segment(0.375, 0.39, ...)in 12 dashes.- Export at 512 px for these three (the rings must share the core’s canvas size + BODY_R so they align when drawn at the same scale). Register in
DESIGNSwith colour#ff2e57. - Preview sheet: run the tool without
--exportinto the scratchpad and include the PNG path in the task report for the controller to show Chris.
Renderer:
-
ArchetypeRenderer: add[EnemyPool.TYPE_BOSS, "warden"]to_NOVA_UNITS(updateSHAPE_COUNT+ its comment).shape_foralready routes Nova whenVisualLook.nova; additionally gate the Warden row onVisualLook.warden_v2(classic sprite when off). Boss tint: keep_base_color_for_type— but calm Warden returnsColor.WHITEtoday; underwarden_v2returnElementPalette.vivid(BOSS_COLOR)calm and a hottervivid(BOSS_COLOR).lightened(0.15)enraged, so the pale Nova body reads crimson like the other units. -
WardenBodyRenderer extends Node2D(z just above the swarm layer; read how main ordersswarm_renderer): twoSprite2Dpairs (body + additive glow) for inner/outer rings, scale so outer radius = boss radius × 1.15 (matchNOVA_SIZE).func update_from(info: Dictionary, dt: float) -> void— sets position, rotations (idle: inner +0.6 rad/s, outer −0.35 rad/s; ×1.6 enraged), and state:telegraph_kindSWING/SLAM: outer spin lerps to ×4 withtelegraph, outer scale 1.0→1.15.- BARRAGE/FAN/SPIRAL: inner ring plates “part” = inner scale 1.0→1.12 with charge.
- LUNGE: both rings stop and lean (scale.x 1.1 along lane dir — use a parent Node2D rotated to the lane).
- tint = same colour main passes for the boss body.
- hidden when
not info.aliveornot VisualLook.warden_v2ornot VisualLook.nova.
-
VisualLook.warden_v2 := true+ Debug Settings row["warden_v2", "Warden Look (A/B)"], desc “New Nova-look Warden body and attack FX. Off = the old sprite and primitive FX.”, match arm, seed — per the toggle recipe (GRAPHICS_TOGGLES/GRAPHICS_DESCS/apply_graphics_toggle/_seed_graphics_state inui/debug_settings_panel.gd). Console parity is automatic viabuild_caps. -
Step 1: Failing tests:
ArchetypeRenderer.shape_for(TYPE_BOSS)is the Warden Nova shape whennova && warden_v2,SHAPE_WARDEN_SPRITEwhenwarden_v2 = false(restore statics inafter_each);WardenBodyRenderer.update_fromwithtelegraph_kind = SWING, telegraph = 1.0gives outer scale ≈ 1.15 and hides itself whenalive = false; textures exist at the 6 paths. -
Step 2: Run → FAIL. Step 3: design +
--export+ headless import (godot --headless --path . --importperdocs/reference/running-and-testing.md) + renderer + toggle + main wiring (warden_body_renderer.update_from(binfo, delta)next toboss_renderer.update_bossat main.gd ~1982). -
Step 4:
scripts/check-test-count.shgreen. Windowed capture: runtools/glow_bake/live_check_warden.tscn(and_enraged) under--rendering-method mobile, save PNGs to the scratchpad, list them in the report. Restoreproject.godot/audio/bus_layout.tres. -
Step 5: Commit
feat(warden): Nova-look body with state-driven rotating rings (A/B toggle).
Task 6: Projectile looks — bolt, drone, missile
Section titled “Task 6: Projectile looks — bolt, drone, missile”Files:
- Create:
fx/boss/warden_bolt_look.gd+warden_bolt.gdshader+warden_bolt_mat.tres; same triple forwarden_drone,warden_missile;fx/boss/warden_fx.gd - Modify:
main.gd(_enemy_proj_colors: boss-sourced shots andis_homing == 2swarm drones geta = 0whileVisualLook.warden_v2; createWardenFxbesideboss_renderer; feed it each frame),render/boss_renderer.gd(skip missile drawing whenwarden_v2),render/prewarm_pass.gd(warm the 3 materials in the weapon-looks block, gated onwarden_v2) - Test:
tests/test_warden_fx.gd(new);tests/test_baked_shader_materials.gdmust pass
Interfaces:
WardenFx extends Node2D:func sync_projectiles(sim: Sim) -> void(persistent looks: begin → put per boss-sourced enemy_proj / boss missile → commit),func on_events(events: Array) -> void(Task 7 fills),func update_boss(info: Dictionary) -> void(Task 7 fills). Owns layers as membersbolt,drone,missile(+ Task 7’s).- Persistent
put()encoding (document in each look’s header, half-float safe):- bolt: rot = velocity angle, half_size = Vector2(14, 5), tint = hostile body colour (enraged: lightened 0.2), custom = (seed in [0,1), speed/600, 0, 0).
- drone: rot = velocity angle, half_size = Vector2(9, 3.5), custom.x = seed.
- missile: rot = velocity angle, half_size = Vector2(26, 10) (quad covers body + exhaust; body at +x end), custom.x = seed, custom.y = remaining life fraction (flicker as it expires).
- Caps: bolt 256, drone 32, missile 24.
Shader looks (canvas_item, unshaded, additive blend like the player looks, uniform float base_dim):
-
bolt: capsule along x, white core (|y| < 0.25) → crimson body → violet rim, tail fading toward −x; subtle noise flicker from
fx/weapons/fx_noise.gdshaderinc. -
drone: thin dart + comet trail toward −x, magenta-crimson, bright tip.
-
missile: arrow-head body at +x (white core, crimson plates), exhaust plume toward −x using noise, flicker with
TIME. -
Step 1: Failing tests:
WardenFx.sync_projectileson a sim with 3 boss shots + 2 swarm drones + 1 missile puts exactly 3/2/1 instances into bolt/drone/missile (read the layer’s instance count); withwarden_v2 = falseit puts 0;main._enemy_proj_colors()alpha is 0 for boss shots when v2 on and unchanged when off (use the existingtests/test_main.gdharness pattern). -
Step 2-4: FAIL → implement → green. Windowed shader compile check under
--rendering-method mobileANDgl_compatibility(opentools/glow_bake/live_check_warden.tscnor a run withapply_dev_commandspawning the Warden; any shader error in the log = fail). Restore the two files. -
Step 5: Commit
feat(warden-fx): plasma bolt, drone and missile looks on the FX v2 layers.
Task 7: Attack looks — swing, charge, burst, lane (telegraphs + impacts)
Section titled “Task 7: Attack looks — swing, charge, burst, lane (telegraphs + impacts)”Files:
- Create: triples for
warden_swing,warden_charge,warden_burst,warden_laneinfx/boss/ - Modify:
fx/boss/warden_fx.gd(update_boss,on_events),render/boss_renderer.gd(_draw()returns early after nothing whenwarden_v2— all its drawing moves to WardenFx),main.gd(forwardsim.render_fx_eventstowarden_fx.on_eventsthe same way it forwards to telemetry; feedupdate_boss(binfo)),render/prewarm_pass.gd,fx/fx_manager.gd(“boss_missile_impact” → skip its legacy spark whenwarden_v2, WardenFx drawswarden_burst) - Test: extend
tests/test_warden_fx.gd
Behaviour (all drawn from render_info + events; render-only):
-
swing (persistent, 1 instance while telegraphing SWING/SLAM; event on hit): half_size = swing_range + 24. Telegraph: a danger ring at
swing_rangewhose fill sweeps inward-out withtelegraph(custom.x), jagged noise edge. Hit (boss_swing_hitevent, and the second slam hit): 0.45 s event — crescent shear wave rotating a full turn while expanding to the ring, white leading edge. -
charge (persistent during barrage wind-up
winding_fire): converging ring from 2.4r → 1.05r + noise corona on the body, intensity =fire_charge; eruption (fire_activerising edge): 0.7 s event — plasma ring burst (reuse the Plasma Nova technique: readfx/weapons/plasma_nova.gdshaderand adapt to the hostile palette, NOT include it). -
burst (event): shock ring + 10 sparks, 0.4 s; spawned on
boss_missile_impact,boss_missile_launch(smaller),reactionevents namedSUMMON/ENRAGEDat the boss (bigger, 0.8 s), and the lunge’s end burst (add a{"kind":"boss_lunge_end","pos":...}fx_event inboss_warden.gd— fx_events are not in the checksum; confirm with grep). -
lane (persistent while
info.lanenon-empty): fan = a wedge from origin withhalf_angle, flickering edge lines + faint fill rising withprogress; lunge = a thin rectanglelength× (boss radius × 2) with chevrons scrolling alongdir. -
Classic look (
warden_v2 = false): BossRenderer draws exactly as today; WardenFx draws nothing. -
Step 1: Failing tests: with a fake
infoin each telegraph state,update_bossputs 1 instance on the expected layer and 0 on the others;on_events([{"kind":"boss_swing_hit","pos":...}])spawns 1 swing event;boss_missile_impactspawns a burst; with v2 off nothing spawns. -
Step 2-4: FAIL → implement → green. Windowed compile check both renderers. Capture a short sequence (one PNG per attack telegraph + impact) with the live-check harness into the scratchpad; list paths.
-
Step 5: Commit
feat(warden-fx): telegraph, swing, charge, burst and lane looks.
Task 8: Housekeeping, roster, docs
Section titled “Task 8: Housekeeping, roster, docs”Files:
- Modify:
tools/vfx_library/library_manifest.gd(+tests/test_vfx_library_manifest.gdEXPECTED_EFFECTS): updateboss_warden+boss_attack_cuessources/descriptions; add entrieswarden_bolt,warden_drone,warden_missile,warden_swing,warden_charge,warden_burst,warden_lane(status REVIEW, rating 3 until Chris scores). data/bestiary.jsonwarden: stats hp 3600 / speed 120 / contact 34 / armor 6, desc lists the Survival attacks (swing, slam, aimed fan, barrage, missiles, spiral, swarm, lunge, summon), counter text mentions range (“it picks its attack by how far away you are”).docs/architecture/enemies-bosses-survival.md: Warden section — deck, bands, new attacks, phase 2, FX files.docs/RELEASE-1.0.1.md: one entryWarden rebuild (Nova look, FX, smarter Survival AI) ✅ DONE (<sha>).- Run
scripts/gate.sh→ green. Commitdocs(warden): roster, bestiary and architecture for the rebuild.
Execution notes for the controller
Section titled “Execution notes for the controller”- Integration branch
warden-rebuildofforigin/main; each task in its own worktree off the branch tip, merged back after review. Tasks 1→2→3→4 are sequential (same files). Task 5 can run in parallel with 1-4. Tasks 6-7 after 3 (needrender_info). Task 8 last. - Concurrent branches in flight touch
fx/fx_manager.gd,render/prewarm_pass.gd,main.gd(fx-v2-promote-railgun, fx-perf-roster, perf-measure-fx). Keep edits to those files minimal and additive; re-checkgit log origin/mainbefore the final merge. - BUILD numbers: do not bump in tasks. The controller bumps once at the end (
scripts/bump-build.sh, past any unmerged branch’s number) for a device build.
Execution handoff
Section titled “Execution handoff”- Chris’s decisions (2026-10-01): asked to “rebuild [the Warden] with the nova look rather than the gemini graphic and make all its weapons have effects more like we have for the player … brief review on its actual weapons, dmg and behaviour because it needs to be more scary and increase tension for the player, it is a bit dumb feeling”. After the review: “yes lets plan and do all of that” = approval of the whole spec AND of the recommendation “Survival-only AI changes; new look + FX everywhere (tutorial included)”.
- The current sprite is Recraft-sourced (commit b64f402a), not Gemini — told Chris; irrelevant to the work.
- Rejected: raising damage numbers (data shows hit RATE is the problem); blind multi-model shootout for these looks (not requested; single author per task, A/B vs the old look via
VisualLook.warden_v2). - Evidence used: D1 telemetry builds 208-235 (human Survival 7 fights, 0 Warden kills; autopilot 56 fights, 8 Warden kills). Scratch scripts were in the old session’s scratchpad (not kept).
- Authorized: implement all 8 tasks, merge to main + push after gate (repo rule: push after every merge). Needs Chris same-day OK: device install / BUILD bump for an ATV test, any remote-config publish, editing the public privacy page.
- Open for Chris after build: eyeball the Nova body preview + attack captures; score the new VFX-library entries; feel-check aggression (all weights/speeds are remote tunables).
- Environment: many concurrent worktrees/branches in flight (fx-v2-promote-railgun, fx-perf-roster, perf-measure-fx, console-weapons, android-export) touching fx_manager/prewarm/main.gd — keep edits additive; read skill
bh-sdd-concurrentbefore Task 1. - Next action: create branch
warden-rebuildoff origin/main, then dispatch Task 1 (and Task 5 in parallel) tobh-implementeragents (model opus) in their own worktrees.