feat: salvage-pickup loop — kill-fed charges, scrap drops, salvage-gated card levels

Per canon decisions 2026-08-07 (advisor CONCEPT.md f12cb15, falsifier
response): charges now generate on kills (kills_per_charge, chemistry
chain kills feed the hand; per-hit generation removed). Kills drop
scrap as physical pickups (amber diamond, magnet radius 70, drift to
mech, 20s fade; grunt 1 / burster 3) — collection is the positioning
reward gradient. Cumulative collected scrap gates card levels 1-3
(thresholds 12/30; per-level radius x1.25/x1.5 and power x1.15/x1.5 —
HEAT L3 hits one-cast detonation threshold). HUD: level notches under
slots + scrap counter. Salvage spawn is call_deferred: death can fire
inside a physics signal flush where Area2D setup is blocked.

Smoke gate: charge accrual now kill-fed, salvage drop + collection +
level gating phases added, heat-target selection made retry-tolerant.
4/4 green on nh3-dev headless 4.7.1.
This commit is contained in:
2026-08-07 12:46:59 -07:00
parent fbac09a86a
commit 4ae0876e34
10 changed files with 192 additions and 52 deletions
+25 -17
View File
@@ -16,24 +16,24 @@ greybox falsifier (ROADMAP.md v1) that tests the design's three core bets.
## Current state / in-flight
_As of 2026-08-07:_
_As of 2026-08-07 (post-falsifier):_
- **Greybox build COMPLETE** at `1976149`, pushed to gitea (vh/urm03) —
all five build capabilities live: arena + positioning-only piloting,
auto-fire KINETIC weapon, cards + Freeze-charge meter + thaw/release
ramps, KINETIC+HEAT chemistry row with authorship-loud feedback stack
(hitstop + hues meeting + `_ph` sting), grunt swarm + self-heating
burster. Headless smoke gate green 3/3.
- **Awaiting the falsification playtest** (ROADMAP capability 6):
operator verdicts on the three bets positioning↔build interaction,
card-freeze rhythm, chemistry delight. Early signal on bet 2 is
positive: freeze meter + release ramp "feels very good" / "starting to
feel game-like" (operator, 2026-08-07). Tuning everywhere still open.
- Operator hit a pull conflict on the Mac (untracked `.uid` files vs
`1976149`) — fix being applied this session; an operator-surfaced
issue is expected next.
- After verdicts: draft the CONCEPT.md canon entries in the advisor repo
(freeze-meter ratification + whatever else the playtest settles).
- **Greybox build complete AND falsifier verdicts rendered** (ROADMAP
v1 target met through capability 6). Verdicts: bet 2 (card-freeze
rhythm) CONFIRMED; bet 1 (positioning) shaky → salvage-pickup loop
adopted and built; bet 3 (chemistry) inconclusive → in-card
randomness parked to belt stage.
- **Post-falsifier loop built**: charges generate on kills; kills drop
scrap pickups (magnet radius, fade); collected scrap gates card
levels 1→3 (thresholds + per-level radius/power scaling, HEAT L3
reaches one-cast threshold). Smoke gate green 4/4.
- Canon amendments landed in the advisor repo at `f12cb15`
(**local-only — that repo has NO git remote**): Freeze charge
ratified, kill-charges + salvage-gated levels decided, randomness
parked.
- Everything is knobs; operator says "everything needs tuning" — no
tuning pass done yet. v1.0 cut not yet made (operator sign-off
needed; ROADMAP v1 target arguably met).
## Recent decisions
@@ -51,6 +51,10 @@ _As of 2026-08-07:_
- `[2026-08-06]` Placeholder tag hues: KINETIC pale steel-blue, HEAT
orange — canon assigns no tag colors yet (naming/color pass parked
post-greybox).
- `[2026-08-07]` Falsifier verdicts + resulting design decisions live
in advisor-repo CONCEPT.md (§ loop stack amendments, decisions-log
rows, open-question #0 closure) at `f12cb15` — design rationale is
NOT duplicated here; that log owns it.
## Tried and abandoned
@@ -63,3 +67,7 @@ _As of 2026-08-07:_
the Mac editor generated its own untracked copies and the next
`git pull` refused to merge over them. Import headless before
committing so `.uid` files ride the same commit as their scripts.
- `[2026-08-07]` Spawning physics nodes (Area2D) directly inside a
physics signal flush (projectile `body_entered` → death → spawn) —
Godot blocks collision setup mid-flush. Spawn via
`add_child.call_deferred()` from any death/hit signal path.
+1
View File
@@ -14,6 +14,7 @@ hp = 5.0
contact_damage = 3.0
body_radius = 11.0
base_color = Color(0.6, 0.4, 0.25, 1)
salvage_value = 3
self_heat_rate = 0.5
detonation_damage = 2.5
detonation_mech_damage = 15.0
+16 -1
View File
@@ -6,6 +6,7 @@ extends Node2D
## no proc text, the light and the sting are the noun).
const CHEM_FX := preload("res://scripts/chemistry_fx.gd")
const SALVAGE := preload("res://scripts/salvage.gd")
var kills: int = 0
var chem_procs: int = 0
@@ -30,7 +31,21 @@ func chemistry_proc(at: Vector2, axis: Vector2) -> void:
func _on_grunt_spawned(grunt: Node) -> void:
grunt.died.connect(func() -> void: kills += 1)
grunt.died.connect(func() -> void: _on_grunt_died(grunt))
func _on_grunt_died(grunt: Node2D) -> void:
# died fires before queue_free, so position/value are still valid.
kills += 1
var hand := get_tree().get_first_node_in_group("hand")
if hand != null:
hand.on_kill()
var s: Area2D = SALVAGE.new()
s.position = grunt.global_position
s.value = grunt.salvage_value
# Deferred: death can fire inside a physics signal flush (projectile
# body_entered), where Area2D collision setup is blocked.
add_child.call_deferred(s)
func _on_hull_changed(hull: float, hull_max: float) -> void:
+1
View File
@@ -27,6 +27,7 @@ signal died
@export var knockback_decay := 5.0
@export var body_radius := 8.0
@export var base_color := Color(0.45, 0.47, 0.4)
@export var salvage_value := 1
## > 0 makes this a burster: heat/second it applies to itself.
@export var self_heat_rate := 0.0
+41 -13
View File
@@ -33,9 +33,20 @@ extends CanvasLayer
@export var dilation_scale := 0.0
@export var dilation_step := 0.05
@export var dilation_max := 0.3
@export var hits_per_charge := 4
@export var kills_per_charge := 2
@export var max_charges := 5
@export_group("Salvage / card levels")
## Cumulative collected scrap gates card levels (canon 2026-08-07:
## same-currency rule — this is the run-timescale reading of the same
## scrap the meta will bank; it is NOT a separate XP).
@export var level2_salvage := 12
@export var level3_salvage := 30
## Per-level scaling, index = level - 1. Radius grows reach; power
## scales impulse/heat (HEAT at L3 reaches one-cast threshold).
@export var level_radius_scale: Array[float] = [1.0, 1.25, 1.5]
@export var level_power_scale: Array[float] = [1.0, 1.15, 1.5]
@export_group("Freeze meter")
## Seconds of open-hand time a full meter holds.
@export var meter_max := 3.0
@@ -77,7 +88,8 @@ const FX_RING := preload("res://scripts/fx_ring.gd")
var charges := 0
var hand_open := false
var meter := 0.0
var _hit_progress := 0
var salvage_total := 0
var _kill_progress := 0
var _last_ms := 0
var _release_from := 1.0
var _release_t := -1.0
@@ -170,13 +182,23 @@ func _input(event: InputEvent) -> void:
_apply_time_state()
func on_build_hit() -> void:
_hit_progress += 1
if _hit_progress >= hits_per_charge:
_hit_progress = 0
func on_kill() -> void:
# Charges generate on kills — build-generated (the build does the
# killing), and chemistry-chain kills feed the hand (canon 2026-08-07).
_kill_progress += 1
if _kill_progress >= kills_per_charge:
_kill_progress = 0
charges = mini(charges + 1, max_charges)
func add_salvage(value: int) -> void:
salvage_total += value
func card_level() -> int:
return 1 + int(salvage_total >= level2_salvage) + int(salvage_total >= level3_salvage)
func hitstop(dur := -1.0) -> void:
_hitstop_t = minf(maxf(_hitstop_t, dur if dur > 0.0 else hitstop_s), 0.15)
@@ -261,14 +283,17 @@ func _play_kinetic() -> void:
var mech := _mech()
if mech == null:
return
var lvl := card_level()
var radius: float = kinetic_radius * level_radius_scale[lvl - 1]
var impulse: float = kinetic_impulse * level_power_scale[lvl - 1]
for e in get_tree().get_nodes_in_group("enemies"):
var offset: Vector2 = e.global_position - mech.global_position
var dist := offset.length()
if dist > kinetic_radius:
if dist > radius:
continue
var falloff := 1.0 - dist / kinetic_radius * 0.6
e.hit(kinetic_damage, offset.normalized() * kinetic_impulse * falloff)
_ring(mech.global_position, kinetic_radius, Color(0.75, 0.85, 0.95))
var falloff := 1.0 - dist / radius * 0.6
e.hit(kinetic_damage, offset.normalized() * impulse * falloff)
_ring(mech.global_position, radius, Color(0.75, 0.85, 0.95))
func _play_heat() -> void:
@@ -278,10 +303,13 @@ func _play_heat() -> void:
var mech := _mech()
if mech == null:
return
var lvl := card_level()
var radius: float = heat_radius * level_radius_scale[lvl - 1]
var amount: float = heat_amount * level_power_scale[lvl - 1]
for e in get_tree().get_nodes_in_group("enemies"):
if e.global_position.distance_to(mech.global_position) <= heat_radius:
e.add_heat(heat_amount)
_ring(mech.global_position, heat_radius, Color(0.95, 0.55, 0.2))
if e.global_position.distance_to(mech.global_position) <= radius:
e.add_heat(amount)
_ring(mech.global_position, radius, Color(0.95, 0.55, 0.2))
func _ring(at: Vector2, radius: float, color: Color) -> void:
+12
View File
@@ -14,6 +14,7 @@ func _draw() -> void:
var x0 := (640.0 - total) / 2.0
var y0 := 320.0
var lvl: int = _hand.card_level()
for i in _hand.cards.size():
var card: Dictionary = _hand.cards[i]
var rect := Rect2(x0 + i * (slot_w + gap), y0, slot_w, slot_h)
@@ -26,6 +27,17 @@ func _draw() -> void:
draw_rect(rect, hue)
if _hand.hand_open:
draw_rect(rect.grow(1.0), Color(1, 1, 1, 0.9 if affordable else 0.3), false, 1.0)
# Card level: notches under the slot.
for n in lvl:
draw_rect(Rect2(rect.position.x + n * 8.0, rect.end.y + 3.0, 5.0, 3.0), Color(0.95, 0.8, 0.35))
# Salvage counter: scrap diamond + running total, right of the slots.
var dx := x0 + total + 16.0
var dy := y0 + 14.0
var dia := PackedVector2Array([Vector2(dx, dy - 5), Vector2(dx + 4, dy), Vector2(dx, dy + 5), Vector2(dx - 4, dy)])
draw_colored_polygon(dia, Color(0.95, 0.8, 0.35))
draw_string(ThemeDB.fallback_font, Vector2(dx + 8.0, dy + 4.0), str(_hand.salvage_total),
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color(0.85, 0.85, 0.9))
var pip := 5.0
var pips_w: float = float(_hand.max_charges) * (pip + 3.0) - 3.0
-4
View File
@@ -28,10 +28,6 @@ func _physics_process(delta: float) -> void:
func _on_body_entered(body: Node) -> void:
if body.is_in_group("enemies"):
body.hit(damage, direction * knockback)
# The build generates charges: every landed hit feeds the hand.
var hand := get_tree().get_first_node_in_group("hand")
if hand != null:
hand.on_build_hit()
queue_free()
+51
View File
@@ -0,0 +1,51 @@
extends Area2D
## Scrap drop — the positioning reward gradient (canon 2026-08-07:
## kills drop scrap physically; collecting it gates in-run card
## levels). Drifts to the mech inside the magnet radius, collected on
## contact, fades out if ignored. Constructed in code, no scene.
var value := 1
var magnet_radius := 70.0
var drift_speed := 150.0
var lifetime := 20.0
var _t := 0.0
func _ready() -> void:
add_to_group("salvage")
collision_layer = 0
collision_mask = 2
monitorable = false
var cs := CollisionShape2D.new()
var sh := CircleShape2D.new()
sh.radius = 6.0
cs.shape = sh
add_child(cs)
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
_t += delta
if _t >= lifetime:
queue_free()
return
var mech := get_tree().get_first_node_in_group("mech") as Node2D
if mech != null and global_position.distance_to(mech.global_position) <= magnet_radius:
global_position = global_position.move_toward(mech.global_position, drift_speed * delta)
if _t >= lifetime - 3.0:
queue_redraw()
func _on_body_entered(body: Node) -> void:
if body.is_in_group("mech"):
var hand := get_tree().get_first_node_in_group("hand")
if hand != null:
hand.add_salvage(value)
queue_free()
func _draw() -> void:
var a := 1.0 if _t < lifetime - 3.0 else clampf((lifetime - _t) / 3.0, 0.0, 1.0)
var pts := PackedVector2Array([Vector2(0, -5), Vector2(4, 0), Vector2(0, 5), Vector2(-4, 0)])
draw_colored_polygon(pts, Color(0.95, 0.8, 0.35, a))
+1
View File
@@ -0,0 +1 @@
uid://bs1xhn4u5821d
+39 -12
View File
@@ -89,32 +89,35 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void:
_failures.append("no Hand node in arena")
return
# Charges accrue from build hits alone (~4 hits per charge).
if not await _until(func() -> bool: return hand.charges >= 2, 20.0, "build hits never accrued 2 charges"):
# Charges accrue from kills alone (kills_per_charge, auto-fire only).
if not await _until(func() -> bool: return hand.charges >= 2, 25.0, "kills never accrued 2 charges"):
return
# A grunt must be near the mech before we freeze — cards are radial
# around M03 and nothing walks while time is stopped.
# A grunt must be near the mech when we freeze — cards are radial
# around M03 and nothing walks while time is stopped. The nearest
# grunt can die to auto-fire during the toggle tap, so retry.
var target: Node2D = null
for attempt in 4:
if not await _until(func() -> bool: return _nearest_grunt(mech) != null, 10.0, "no grunt approached within heat radius"):
return
# Open the hand: time must freeze to the dilation knob (default 0).
await _tap("hand_toggle")
if not hand.hand_open:
_failures.append("hand did not open on hand_toggle")
return
# Pick the target while frozen — it can neither die nor wander.
target = _nearest_grunt(mech)
if target != null:
break
await _tap("hand_toggle")
if target == null:
_failures.append("grunt near mech vanished before freeze on 4 attempts")
return
var expect_pause: bool = (hand.meter_mode and hand.frac() >= hand.band_thaw) \
or (not hand.meter_mode and hand.dilation_scale == 0.0)
if expect_pause and not get_tree().paused:
_failures.append("hand open in freeze state did not pause the world")
# Pick the target NOW — the world is frozen, so it can neither die
# nor wander out of the heat radius before the casts land.
var c0: int = hand.charges
var target := _nearest_grunt(mech)
if target == null:
_failures.append("grunt near mech vanished before freeze")
return
# Play HEAT twice: charges spent, heat painted past threshold.
await _tap("card_2")
@@ -140,9 +143,33 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void:
_failures.append("heated grunt failed to detonate after release (heat %.1f)" % target.heat)
await _test_meter(hand)
await _test_salvage(hand, mech)
await _test_chemistry(arena, mech)
func _test_salvage(hand: Node, mech: CharacterBody2D) -> void:
# Kills drop scrap pickups.
if not await _until(func() -> bool: return get_tree().get_first_node_in_group("salvage") != null, 15.0, "kills never dropped salvage"):
return
# Collection: teleport a drop onto the mech, expect pickup + count.
var s: Area2D = get_tree().get_first_node_in_group("salvage")
var s0: int = hand.salvage_total
s.global_position = mech.global_position
for i in 10:
await get_tree().physics_frame
if hand.salvage_total <= s0:
_failures.append("salvage on the mech was not collected (total %d)" % hand.salvage_total)
return
if is_instance_valid(s):
_failures.append("collected salvage node was not freed")
# Level gating: cumulative scrap crosses thresholds, white-box.
if hand.card_level() != 1 and hand.salvage_total < hand.level2_salvage:
_failures.append("card level %d with only %d salvage" % [hand.card_level(), hand.salvage_total])
hand.add_salvage(hand.level3_salvage)
if hand.card_level() != 3:
_failures.append("card level %d after crossing level3 threshold" % hand.card_level())
func _test_meter(hand: Node) -> void:
# Band ladder, white-box: set the meter, open, check the time state.
# THAW band: open, unpaused, slow-mo strictly between 0 and 1.