From 4ae0876e34287f7e175d9ec7277d8f5a4dcfaa19 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Fri, 7 Aug 2026 12:46:59 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20salvage-pickup=20loop=20=E2=80=94=20kil?= =?UTF-8?q?l-fed=20charges,=20scrap=20drops,=20salvage-gated=20card=20leve?= =?UTF-8?q?ls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- persistent-memory.md | 42 ++++++++++++++---------- scenes/burster.tscn | 1 + scripts/arena.gd | 17 +++++++++- scripts/grunt.gd | 1 + scripts/hand.gd | 54 +++++++++++++++++++++++-------- scripts/hand_hud.gd | 12 +++++++ scripts/kinetic_projectile.gd | 4 --- scripts/salvage.gd | 51 +++++++++++++++++++++++++++++ scripts/salvage.gd.uid | 1 + tests/smoke.gd | 61 +++++++++++++++++++++++++---------- 10 files changed, 192 insertions(+), 52 deletions(-) create mode 100644 scripts/salvage.gd create mode 100644 scripts/salvage.gd.uid diff --git a/persistent-memory.md b/persistent-memory.md index b9e994d..c3fbe2d 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -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. diff --git a/scenes/burster.tscn b/scenes/burster.tscn index d3ae2cd..5edfdd0 100644 --- a/scenes/burster.tscn +++ b/scenes/burster.tscn @@ -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 diff --git a/scripts/arena.gd b/scripts/arena.gd index d155075..c2916af 100644 --- a/scripts/arena.gd +++ b/scripts/arena.gd @@ -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: diff --git a/scripts/grunt.gd b/scripts/grunt.gd index 8e88882..37f481a 100644 --- a/scripts/grunt.gd +++ b/scripts/grunt.gd @@ -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 diff --git a/scripts/hand.gd b/scripts/hand.gd index d168137..d07e0cc 100644 --- a/scripts/hand.gd +++ b/scripts/hand.gd @@ -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: diff --git a/scripts/hand_hud.gd b/scripts/hand_hud.gd index 01b00c1..f97f767 100644 --- a/scripts/hand_hud.gd +++ b/scripts/hand_hud.gd @@ -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 diff --git a/scripts/kinetic_projectile.gd b/scripts/kinetic_projectile.gd index ebf82f9..c39b0c9 100644 --- a/scripts/kinetic_projectile.gd +++ b/scripts/kinetic_projectile.gd @@ -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() diff --git a/scripts/salvage.gd b/scripts/salvage.gd new file mode 100644 index 0000000..9c93401 --- /dev/null +++ b/scripts/salvage.gd @@ -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)) diff --git a/scripts/salvage.gd.uid b/scripts/salvage.gd.uid new file mode 100644 index 0000000..43fc706 --- /dev/null +++ b/scripts/salvage.gd.uid @@ -0,0 +1 @@ +uid://bs1xhn4u5821d diff --git a/tests/smoke.gd b/tests/smoke.gd index d0888b7..fd78a1c 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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. - 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") + # 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 + 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.