Learning autopilot (implementation plan)
Learning Autopilot Implementation Plan
Section titled “Learning Autopilot 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. In this repo, read skillbh-sdd-concurrentbefore Task 1, dispatch implementers into their own worktree (.claude/worktrees/<name>, branch offorigin/main), and usemodel: "opus"for code agents.
Goal: The Dev-build autopilot flies evolving “personas” (a genome of build and playstyle genes), posts each run’s result to a shared MAP-Elites archive (4 roles × 3 skills), and shows what it is trying and learning on screen and in the Remote Console. A headless soak loop does the bulk evolution overnight.
Architecture: Pure RefCounted GDScript under ai/persona/ holds the genome, the persona controller and job selection/breeding. The game and soak share it. The control Worker (control/) stores samples and elites and does the one atomic promotion upsert. PersonaClient (Node) fetches and posts from devices. PersonaOverlay and a console “Personas” tab are the watch surfaces.
Tech Stack: Godot 4.7 typed GDScript (warnings as errors), GUT; Cloudflare Worker JS + D1, node:test; zsh scripts.
Spec: docs/superpowers/specs/2026-10-11-learning-autopilot-design.md. Its §8 “Plan-time amendments” overrides §4/§5 where they conflict.
Global Constraints
Section titled “Global Constraints”/simis untouched. Personas are controllers only, and the sim’s determinism baselines (tests/test_determinism*.gd) must stay green without re-pinning.- Learning off means today’s behaviour, byte for byte.
SkilledAutoPlayerwith its default knob vars must behave exactly as with the old consts. - Dev builds only: gated on
BuildConfig.dev_tools()andDevFlags.learning_autopilot. The flag defaults to on in Dev and is always off headless (DevFlags.flags_active). - Learning runs use survival mode, a fresh
MetaState.new(),ShipBonuses.DEFAULT_SHIP, and no LAN (net_startfalse). - Personas always set
humanize = trueandthrottle_planner = true. - Cells are exactly
"<role>:<skill>", with role ∈["tank","support","damage","utility"]and skill ∈["weak","median","strong"]. - Worker
PROMOTE_MIN_SAMPLES = 3. Soak seeds per child default to 5. finderis a device kind (OS.get_name().to_lower()or"soak"), never a device name.- Every new tuning number that can be a console tunable is one: a
static var, registered innet/tunables_registry.gdwith"local": true, plus atools/tunable_docs/docs.d/*.jsonlabel. Then regeneratecontrol/src/tunable_docs.jswithnode tools/tunable_docs/generate.mjs(never hand-edit it). - Panel string modules (
control/src/panel/*.js) are single template literals and must contain no${, no backslash, and no backtick (control/test/panel-modules.test.mjs). - GDScript: annotate
Dictionary.get()results asVariantor with an explicit cast. Never use inferred:=on aVariant. - Gate before every merge:
scripts/gate.sh. To check a suite, usescripts/check-test-count.shand grep$GUT_LOG; never read a raw GUT run. - Every new
docs/**/*.mdneedstitle:frontmatter. - Commit with
git commit --file=<msgfile>, ending with the session attribution lines.
File structure
Section titled “File structure”| File | Responsibility |
|---|---|
ai/persona/persona_genome.gd (new) |
Genes, defaults, clamps, (de)serialise, hash, mutate, crossover, diff text |
ai/persona/persona_tuning.gd (new) |
Console-tunable learning knobs (static var) |
ai/persona/persona_auto_player.gd (new) |
ProfiledAutoPlayer subclass that applies a genome; genome-scored card picks; role tally |
ai/persona/persona_jobs.gd (new) |
Cells, role measurement, job selection and breeding from an archive dict, result-sample builder |
ai/skilled_auto_player.gd (modify) |
8 consts become read through instance vars (defaults are the same consts) |
control/migrations/0003_personas.sql (new), control/schema.sql (modify) |
Persona tables |
control/src/personas.js (new), control/src/worker.js (modify) |
/personas* routes and promotion |
control/test/personas.test.mjs (new) |
Worker tests |
tools/soak/soak_run.gd (modify) |
persona config key and persona_tally in the report |
tools/persona_evolve/evolve_runner.gd (new), scripts/personas-evolve.sh (new) |
Headless learner |
net/persona_client.gd (new) |
Device fetch/cache and post queue |
meta/dev_flags.gd, ui/debug_settings_panel.gd, main.gd (modify) |
Toggle and run hooks |
ui/persona_overlay.gd (new) |
On-device watch panel |
control/src/panel/markup.js, control/src/panel/client.js (modify) |
Console Personas tab |
docs/architecture/learning-autopilot.md (new), CLAUDE.md (modify) |
Docs |
Task 1: PersonaGenome + PersonaTuning
Section titled “Task 1: PersonaGenome + PersonaTuning”Files:
- Create:
ai/persona/persona_genome.gd,ai/persona/persona_tuning.gd - Modify:
net/tunables_registry.gd(ENTRIES + a path const),tools/tunable_docs/docs.d/persona.json(new), regeneratecontrol/src/tunable_docs.js - Test:
tests/test_persona_genome.gd
Interfaces:
-
Produces:
PersonaGenome.new()gives the default genome.static from_dict(d: Dictionary) -> PersonaGenometo_dict() -> Dictionary({"v":1,"genes":{…},"weapon_pref":{…}})hash_id() -> String(16 hex chars)g(name: String) -> floatwpref(wid: String) -> floatis_default_build() -> boolmutated(rng: RandomNumberGenerator, sigma: float, n_genes: int) -> PersonaGenomecrossed(other: PersonaGenome, rng: RandomNumberGenerator) -> PersonaGenomediff_lines(parent: PersonaGenome) -> Array[String]- consts
ROLES,SKILLS,SCALAR_GENES,BUILD_GENES
-
PersonaTuningstatic vars:MUTATE_SHARE,CANDIDATE_SHARE,SIGMA,CROSSOVER_P,SOAK_SEEDS. -
Step 1: Write the failing test
tests/test_persona_genome.gd
extends GutTest
func test_default_genome_round_trips_and_is_default() -> void: var g := PersonaGenome.new() assert_true(g.is_default_build()) var back := PersonaGenome.from_dict(g.to_dict()) assert_eq(back.hash_id(), g.hash_id()) assert_eq(g.hash_id().length(), 16) assert_eq(g.g("w_base"), 1.0) assert_eq(g.g("kite"), 1.0)
func test_from_dict_clamps_and_ignores_unknown() -> void: var g := PersonaGenome.from_dict({"v": 1, "genes": {"kite": 99.0, "w_tank": -50.0, "bogus": 3.0}, "weapon_pref": {"blade": 7.0, "not_a_weapon": 1.0}}) assert_eq(g.g("kite"), float(PersonaGenome.SCALAR_GENES["kite"][2])) assert_eq(g.g("w_tank"), -2.0) assert_eq(g.wpref("blade"), 2.0) assert_false(g.to_dict()["weapon_pref"].has("not_a_weapon")) assert_false(g.to_dict()["genes"].has("bogus"))
func test_wrong_schema_gives_default() -> void: assert_eq(PersonaGenome.from_dict({"v": 999, "genes": {"kite": 2.0}}).hash_id(), PersonaGenome.new().hash_id())
func test_mutation_is_deterministic_bounded_and_changes_n_genes() -> void: var a := RandomNumberGenerator.new(); a.seed = 7 var b := RandomNumberGenerator.new(); b.seed = 7 var m1 := PersonaGenome.new().mutated(a, 0.15, 3) var m2 := PersonaGenome.new().mutated(b, 0.15, 3) assert_eq(m1.hash_id(), m2.hash_id(), "same rng seed -> same child") assert_ne(m1.hash_id(), PersonaGenome.new().hash_id()) var changed := m1.diff_lines(PersonaGenome.new()).size() assert_between(changed, 1, 3, "at most n genes move (a nudge can round back to the parent value)") for k: String in PersonaGenome.SCALAR_GENES: var spec: Array = PersonaGenome.SCALAR_GENES[k] assert_between(m1.g(k), float(spec[1]), float(spec[2]), k)
func test_crossover_takes_each_gene_from_a_parent() -> void: var rng := RandomNumberGenerator.new(); rng.seed = 3 var p := PersonaGenome.from_dict({"v": 1, "genes": {"kite": 2.0, "orbit": 2.0}, "weapon_pref": {}}) var q := PersonaGenome.from_dict({"v": 1, "genes": {"kite": 0.5, "orbit": 0.5}, "weapon_pref": {}}) var c := p.crossed(q, rng) assert_true(c.g("kite") in [2.0, 0.5]) assert_true(c.g("orbit") in [2.0, 0.5])
func test_diff_lines_names_old_and_new() -> void: var p := PersonaGenome.new() var c := PersonaGenome.from_dict({"v": 1, "genes": {"kite": 1.5}, "weapon_pref": {"railgun": 1.0}}) var lines := c.diff_lines(p) assert_true("kite 1.00 -> 1.50" in lines, str(lines)) assert_true("pref railgun 0.00 -> 1.00" in lines, str(lines)) assert_false(c.is_default_build(), "a weapon pref is a build gene")- Step 2: Run to verify it fails
Run: GUT_LOG=$TMPDIR/persona-t1.log scripts/check-test-count.sh; grep -A3 "test_persona_genome" $TMPDIR/persona-t1.log | head -20
Expected: FAIL (PersonaGenome not declared).
- Step 3: Implement
ai/persona/persona_tuning.gd
class_name PersonaTuningextends RefCounted
# Learning-autopilot knobs (spec 2026-10-11-learning-autopilot-design.md §4), console tunables# ("local": true in net/tunables_registry.gd). Read at job-pick time, so a change applies to the# next learning run.static var MUTATE_SHARE: float = 0.7 # share of runs that fly a new mutantstatic var CANDIDATE_SHARE: float = 0.2 # share that re-flies a promising candidate (rest: elite re-check)static var SIGMA: float = 0.15 # mutation step, as a fraction of each gene's rangestatic var CROSSOVER_P: float = 0.15 # chance a mutant first crosses its parent with another elitestatic var SOAK_SEEDS: int = 5 # seeds each soak child is flown on (evolve_runner default)ai/persona/persona_genome.gd:
class_name PersonaGenomeextends RefCounted
# One persona's genes (spec 2026-10-11-learning-autopilot-design.md §3). Controller-only: nothing# here reaches /sim. Values are clamped on load and after every mutation, and snapped to 0.001 so# hash_id() is stable across save/load.
const SCHEMA_VERSION: int = 1const ROLES: Array[String] = ["tank", "support", "damage", "utility"]const SKILLS: Array[String] = ["weak", "median", "strong"]
# name -> [default, min, max]const SCALAR_GENES := { "w_base": [1.0, 0.0, 2.0], # weight on the normalised upgrade_effects score "w_tank": [0.0, -2.0, 2.0], "w_support": [0.0, -2.0, 2.0], "w_damage": [0.0, -2.0, 2.0], "w_utility": [0.0, -2.0, 2.0], "w_drone": [0.0, -2.0, 2.0], "new_weapon_bias": [0.0, -2.0, 2.0], # Playstyle: multipliers on SkilledAutoPlayer's defaults. Floors keep threat-avoidance sane. "engage_seek": [1.0, 0.25, 3.0], "orbit": [1.0, 0.25, 3.0], "kite": [1.0, 0.0, 3.0], "dash_min": [1.0, 0.5, 2.0], "dash_max": [1.0, 0.5, 2.0], "warp_gap": [1.0, 0.25, 3.0], "elite_danger": [1.0, 0.5, 3.0], "boss_flee": [1.0, 0.5, 3.0],}const BUILD_GENES: Array[String] = ["w_base", "w_tank", "w_support", "w_damage", "w_utility", "w_drone", "new_weapon_bias"]const WPREF_MIN: float = -2.0const WPREF_MAX: float = 2.0const SNAP: float = 0.001
var _genes: Dictionary = {}var _wpref: Dictionary = {} # weapon id -> float; absent = 0.0
func _init() -> void: for k: String in SCALAR_GENES: _genes[k] = float((SCALAR_GENES[k] as Array)[0])
static func from_dict(d: Dictionary) -> PersonaGenome: var out := PersonaGenome.new() if int(d.get("v", 0)) != SCHEMA_VERSION: return out var genes: Variant = d.get("genes", {}) if genes is Dictionary: for k: String in SCALAR_GENES: var v: Variant = (genes as Dictionary).get(k, null) if v is float or v is int: out._set_gene(k, float(v)) var wp: Variant = d.get("weapon_pref", {}) if wp is Dictionary: for wid: String in UpgradeSystem.WEAPON_ORDER: var v: Variant = (wp as Dictionary).get(wid, null) if v is float or v is int: out._set_wpref(wid, float(v)) return out
func to_dict() -> Dictionary: var wp := {} for wid: String in _wpref: if float(_wpref[wid]) != 0.0: wp[wid] = _wpref[wid] return {"v": SCHEMA_VERSION, "genes": _genes.duplicate(), "weapon_pref": wp}
func hash_id() -> String: return JSON.stringify(to_dict(), "", true).sha256_text().substr(0, 16)
func g(name: String) -> float: return float(_genes.get(name, 0.0))
func wpref(wid: String) -> float: return float(_wpref.get(wid, 0.0))
func is_default_build() -> bool: for k: String in BUILD_GENES: if g(k) != float((SCALAR_GENES[k] as Array)[0]): return false return to_dict()["weapon_pref"].is_empty()
func copy() -> PersonaGenome: return PersonaGenome.from_dict(to_dict())
# Every mutable gene key: scalar genes plus "wp:<weapon id>" per offerable weapon.static func gene_keys() -> Array[String]: var keys: Array[String] = [] for k: String in SCALAR_GENES: keys.append(k) for wid: String in UpgradeSystem.WEAPON_ORDER: keys.append("wp:" + wid) return keys
func mutated(rng: RandomNumberGenerator, sigma: float, n_genes: int) -> PersonaGenome: var child := copy() var keys := gene_keys() for _i in range(n_genes): var key: String = keys[rng.randi_range(0, keys.size() - 1)] if key.begins_with("wp:"): var wid := key.substr(3) child._set_wpref(wid, child.wpref(wid) + rng.randfn(0.0, sigma * (WPREF_MAX - WPREF_MIN))) else: var spec: Array = SCALAR_GENES[key] child._set_gene(key, child.g(key) + rng.randfn(0.0, sigma * (float(spec[2]) - float(spec[1])))) return child
func crossed(other: PersonaGenome, rng: RandomNumberGenerator) -> PersonaGenome: var child := copy() for k: String in SCALAR_GENES: if rng.randf() < 0.5: child._set_gene(k, other.g(k)) for wid: String in UpgradeSystem.WEAPON_ORDER: if rng.randf() < 0.5: child._set_wpref(wid, other.wpref(wid)) return child
# Human-readable gene changes vs a parent, for the overlay: "kite 1.00 -> 1.50".func diff_lines(parent: PersonaGenome) -> Array[String]: var out: Array[String] = [] for k: String in SCALAR_GENES: if g(k) != parent.g(k): out.append("%s %.2f -> %.2f" % [k, parent.g(k), g(k)]) for wid: String in UpgradeSystem.WEAPON_ORDER: if wpref(wid) != parent.wpref(wid): out.append("pref %s %.2f -> %.2f" % [wid, parent.wpref(wid), wpref(wid)]) return out
func _set_gene(k: String, v: float) -> void: var spec: Array = SCALAR_GENES[k] _genes[k] = snappedf(clampf(v, float(spec[1]), float(spec[2])), SNAP)
func _set_wpref(wid: String, v: float) -> void: var s := snappedf(clampf(v, WPREF_MIN, WPREF_MAX), SNAP) if s == 0.0: _wpref.erase(wid) else: _wpref[wid] = sRegister the tunables in net/tunables_registry.gd. Add a path const beside the others (e.g. after const HARD_TUNING := …):
const PERSONA_TUNING := "res://ai/persona/persona_tuning.gd"and these ENTRIES rows (same shape as the PatchToast.HOLD_S local row):
{"key": "PersonaTuning.MUTATE_SHARE", "script": PERSONA_TUNING, "name": "MUTATE_SHARE", "min": 0.0, "max": 1.0, "local": true}, {"key": "PersonaTuning.CANDIDATE_SHARE", "script": PERSONA_TUNING, "name": "CANDIDATE_SHARE", "min": 0.0, "max": 1.0, "local": true}, {"key": "PersonaTuning.SIGMA", "script": PERSONA_TUNING, "name": "SIGMA", "min": 0.01, "max": 0.5, "local": true}, {"key": "PersonaTuning.CROSSOVER_P", "script": PERSONA_TUNING, "name": "CROSSOVER_P", "min": 0.0, "max": 1.0, "local": true}, {"key": "PersonaTuning.SOAK_SEEDS", "script": PERSONA_TUNING, "name": "SOAK_SEEDS", "min": 1, "max": 20, "local": true},tools/tunable_docs/docs.d/persona.json:
{ "PersonaTuning.MUTATE_SHARE": {"label": "Learning autopilot: mutant share", "desc": "Dev builds: share of learning runs that fly a brand-new mutant persona. Applies from the next run.", "unit": "", "src": "code"}, "PersonaTuning.CANDIDATE_SHARE": {"label": "Learning autopilot: candidate re-check share", "desc": "Share of learning runs that re-fly a promising candidate to add samples; the rest re-check an elite. Next run.", "unit": "", "src": "code"}, "PersonaTuning.SIGMA": {"label": "Learning autopilot: mutation step", "desc": "How far a mutation nudges a gene, as a fraction of its range. Bigger = wilder experiments. Next run.", "unit": "", "src": "code"}, "PersonaTuning.CROSSOVER_P": {"label": "Learning autopilot: crossover chance", "desc": "Chance a mutant first mixes its parent with another cell's elite. Next run.", "unit": "", "src": "code"}, "PersonaTuning.SOAK_SEEDS": {"label": "Learning autopilot: soak seeds per child", "desc": "Headless evolve only: how many seeds each soak child is flown on before its result is posted.", "unit": "", "src": "code"}}Then run node tools/tunable_docs/generate.mjs. If the registry has a safety-bounds or docs-coverage test (tests/test_tunable_safety_bounds.gd, control/test/tunable-docs.test.mjs), it must pass unchanged.
- Step 4: Run tests to verify they pass
Run: GUT_LOG=$TMPDIR/persona-t1.log scripts/check-test-count.sh && node --test control/test/tunable-docs.test.mjs
Expected: PASS, with the test count increased by 1 script.
- Step 5: Commit
git add ai/persona/persona_genome.gd ai/persona/persona_tuning.gd tests/test_persona_genome.gd net/tunables_registry.gd tools/tunable_docs/docs.d/persona.json control/src/tunable_docs.jsgit commit --file=<msg: "feat(persona): PersonaGenome + PersonaTuning tunables">Task 2: Genome-driven SkilledAutoPlayer knobs + PersonaAutoPlayer
Section titled “Task 2: Genome-driven SkilledAutoPlayer knobs + PersonaAutoPlayer”Files:
- Modify:
ai/skilled_auto_player.gd(lines 22, 28, 34-35, 42, 67, 185, 187 declarations stay; reads at 202, 214, 269, 398, 530, 534, 547, 582 change) - Create:
ai/persona/persona_auto_player.gd - Modify:
tools/soak/soak_run.gd:50-78(persona selection),:133-161(report key) - Test:
tests/test_persona_auto_player.gd
Interfaces:
-
Consumes:
PersonaGenome(Task 1);ProfiledAutoPlayer._init(knobs: Dictionary, seed_value: int);SkillProfile.knobs(name);ArchetypeTags.for_card(content: ContentDB, id: String, eff: Dictionary) -> Array[String];sim.upgrade_system.upgrade_effects(sim, pilot, id) -> Dictionary(its"score"key). -
Produces:
PersonaAutoPlayer.new(genome: PersonaGenome, skill: String, seed_value: int)- vars
genome: PersonaGenome,skill: String,tally: Dictionary(role → float),picks: Array[String],last_pick_note: String static weapon_of(id: String) -> Stringscore_card(content: ContentDB, id: String, eff: Dictionary, best_raw: float) -> float- SoakRun config key
"persona": {"genome": Dictionary, "skill": String}and report key"persona_tally": Dictionary.
-
Step 1: Write the failing test
tests/test_persona_auto_player.gd
extends GutTest
func _soak(cfg: Dictionary) -> Dictionary: var base := {"seed": 4242, "ship": ShipBonuses.DEFAULT_SHIP, "coop": false, "meta": MetaState.new(), "max_ticks": 3600} return SoakRun.new(base.merged(cfg, true)).execute()
func test_default_genome_flies_identically_to_profiled_strong() -> void: # Same controller config both ways (humanize + throttle, as PersonaAutoPlayer forces), so the only # difference is the genome plumbing; a default genome must not move a single input. var ref := ProfiledAutoPlayer.new(SkillProfile.knobs("strong"), 4242) ref.humanize = true ref.throttle_planner = true var per := PersonaAutoPlayer.new(PersonaGenome.new(), "strong", 4242) var sim_a := Sim.new(4242, SimContentFixture.db()) var sim_b := Sim.new(4242, SimContentFixture.db()) sim_a.enable_survival() sim_b.enable_survival() for t in range(1800): var ia := InputState.new(ref.compute_move_for(sim_a, sim_a.player), ref.compute_aim_for(sim_a, sim_a.player), ref.wants_decoy(sim_a, sim_a.player), ref.wants_dash(sim_a, sim_a.player)) var ib := InputState.new(per.compute_move_for(sim_b, sim_b.player), per.compute_aim_for(sim_b, sim_b.player), per.wants_decoy(sim_b, sim_b.player), per.wants_dash(sim_b, sim_b.player)) assert_eq(ib.move_dir, ia.move_dir, "tick %d move" % t) if ib.move_dir != ia.move_dir: return sim_a.tick(ia) sim_b.tick(ib) while sim_a.player.pending_levelups > 0 and sim_b.player.pending_levelups > 0: var ids_a := sim_a.upgrade_system.roll_upgrade_choices(sim_a, sim_a.player, 3) var ids_b := sim_b.upgrade_system.roll_upgrade_choices(sim_b, sim_b.player, 3) var pa := ref.pick_upgrade_for(sim_a, sim_a.player, ids_a) var pb := per.pick_upgrade_for(sim_b, sim_b.player, ids_b) assert_eq(pb, pa, "tick %d pick" % t) sim_a.upgrade_system.apply_upgrade(sim_a, sim_a.player, pa) sim_b.upgrade_system.apply_upgrade(sim_b, sim_b.player, pb) if sim_a.game_over: break
func test_playstyle_genes_reach_the_knob_vars() -> void: var g := PersonaGenome.from_dict({"v": 1, "genes": {"kite": 2.0, "dash_min": 2.0, "dash_max": 0.5, "boss_flee": 1.5}, "weapon_pref": {}}) var p := PersonaAutoPlayer.new(g, "median", 1) assert_almost_eq(p.kite_weight, SkilledAutoPlayer.HZ_KITE_WEIGHT * 2.0, 0.0001) assert_almost_eq(p.boss_flee_weight, SkilledAutoPlayer.BOSS_FLEE_WEIGHT * 1.5, 0.0001) assert_true(p.dash_window_max_mult >= p.dash_window_min_mult, "window never inverts") assert_true(p.humanize and p.throttle_planner) assert_eq(p.decide_every, int(SkillProfile.knobs("median")["decide_every"]))
func test_tag_weights_steer_the_pick_and_fill_the_tally() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() var g := PersonaGenome.from_dict({"v": 1, "genes": {"w_base": 0.0, "w_tank": 2.0, "w_damage": -2.0}, "weapon_pref": {}}) var p := PersonaAutoPlayer.new(g, "strong", 1) var ids := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3) var picked := p.pick_upgrade_for(sim, sim.player, ids) var best := -INF for raw in ids: var id := String(raw) var eff: Dictionary = sim.upgrade_system.upgrade_effects(sim, sim.player, id) best = maxf(best, p.score_card(sim.content, id, eff, 1.0)) var eff_p: Dictionary = sim.upgrade_system.upgrade_effects(sim, sim.player, picked) assert_eq(p.score_card(sim.content, picked, eff_p, 1.0), best, "picked the top-scoring card") assert_eq(p.picks, [picked]) assert_ne(p.last_pick_note, "")
func test_weapon_of() -> void: assert_eq(PersonaAutoPlayer.weapon_of("weapon:railgun"), "railgun") assert_eq(PersonaAutoPlayer.weapon_of("wm:blade:pierce"), "blade") assert_eq(PersonaAutoPlayer.weapon_of("evolve:orbit"), "orbit") assert_eq(PersonaAutoPlayer.weapon_of("armor"), "")
func test_soak_accepts_a_persona_and_reports_its_tally() -> void: var rep := _soak({"player": "skilled", "persona": {"genome": PersonaGenome.new().to_dict(), "skill": "strong"}}) assert_true(rep.has("persona_tally")) assert_eq(String(rep.get("profile", "")), "") # no named profile requested assert_true(rep["invariant_failures"].is_empty(), str(rep["invariant_failures"]))Note for the implementer: if SoakRun reads only some of the base keys above, keep the ones tools/economy_sim/calibrate_runner.gd:26-28 passes (seed, ship, coop, meta) and add max_ticks. Check the exact SimContentFixture and Sim.tick names against tests/test_skilled_auto_player.gd and fix them to match before running the test.
- Step 2: Run to verify it fails
Run: GUT_LOG=$TMPDIR/persona-t2.log scripts/check-test-count.sh; grep -B2 -A6 "test_persona_auto_player" $TMPDIR/persona-t2.log | head -30
Expected: FAIL (PersonaAutoPlayer not declared).
- Step 3a: Knob vars in
ai/skilled_auto_player.gd. Keep every const. Add these instance vars aftervar humanize := false(line 173):
# Per-instance copies of the tunable steering consts (learning autopilot, spec 2026-10-11 §3). They# default to the consts, so SkilledAutoPlayer behaves exactly as before; PersonaAutoPlayer scales# them from its genome. Survival-critical dodge consts (ESCAPE_RINGS, REACTION_S, ...) stay const.var engage_seek_weight: float = ENGAGE_SEEK_WEIGHTvar orbit_weight: float = ORBIT_WEIGHTvar kite_weight: float = HZ_KITE_WEIGHTvar dash_window_min_mult: float = DASH_WINDOW_MIN_MULTvar dash_window_max_mult: float = DASH_WINDOW_MAX_MULTvar offensive_warp_gap_s: Vector2 = HZ_OFFENSIVE_WARP_GAP_Svar elite_danger_weight: float = ELITE_DANGER_WEIGHTvar boss_flee_weight: float = BOSS_FLEE_WEIGHTReplace each read site. These are verified line numbers; re-grep before editing, because concurrent work may shift them:
| Line | Old | New |
|---|---|---|
| 202 | "warp_gap": HZ_OFFENSIVE_WARP_GAP_S.x |
"warp_gap": offensive_warp_gap_s.x |
| 214 | _hz_rng.randf_range(HZ_OFFENSIVE_WARP_GAP_S.x, HZ_OFFENSIVE_WARP_GAP_S.y) |
_hz_rng.randf_range(offensive_warp_gap_s.x, offensive_warp_gap_s.y) |
| 269 | * HZ_KITE_WEIGHT |
* kite_weight |
| 398 | median * DASH_WINDOW_MIN_MULT and dist <= median * DASH_WINDOW_MAX_MULT |
median * dash_window_min_mult and dist <= median * dash_window_max_mult |
| 530 | ELITE_DANGER_WEIGHT if |
elite_danger_weight if |
| 534 | * ORBIT_WEIGHT |
* orbit_weight |
| 547 | * BOSS_FLEE_WEIGHT |
* boss_flee_weight |
| 582 | * ENGAGE_SEEK_WEIGHT |
* engage_seek_weight |
If any of these reads is inside a static func, stop and report it rather than converting the function. Run /usr/bin/grep -nE "ENGAGE_SEEK_WEIGHT|ORBIT_WEIGHT|HZ_KITE_WEIGHT|DASH_WINDOW_M|HZ_OFFENSIVE_WARP_GAP_S|ELITE_DANGER_WEIGHT|BOSS_FLEE_WEIGHT" ai/ afterwards: only the const declarations and the var defaults may remain.
- Step 3b: Create
ai/persona/persona_auto_player.gd
class_name PersonaAutoPlayerextends ProfiledAutoPlayer
# A learning-autopilot persona (spec 2026-10-11-learning-autopilot-design.md §3): the skilled AI with# a genome's playstyle multipliers, genome-scored level-up picks, and the cell's SkillProfile limits.# Always humanized + throttled so soak scores match what devices fly (spec §8). The default genome# reproduces ProfiledAutoPlayer(strong) with humanize + throttle exactly (tests/test_persona_auto_player.gd).
var genome: PersonaGenomevar skill: Stringvar tally: Dictionary = {"tank": 0.0, "support": 0.0, "damage": 0.0, "utility": 0.0}var picks: Array[String] = []var last_pick_note: String = ""
func _init(g: PersonaGenome, skill_name: String, seed_value: int) -> void: super(SkillProfile.knobs(skill_name), seed_value) genome = g skill = skill_name humanize = true throttle_planner = true engage_seek_weight = ENGAGE_SEEK_WEIGHT * g.g("engage_seek") orbit_weight = ORBIT_WEIGHT * g.g("orbit") kite_weight = HZ_KITE_WEIGHT * g.g("kite") dash_window_min_mult = DASH_WINDOW_MIN_MULT * g.g("dash_min") dash_window_max_mult = maxf(DASH_WINDOW_MAX_MULT * g.g("dash_max"), dash_window_min_mult) offensive_warp_gap_s = HZ_OFFENSIVE_WARP_GAP_S * g.g("warp_gap") elite_danger_weight = ELITE_DANGER_WEIGHT * g.g("elite_danger") boss_flee_weight = BOSS_FLEE_WEIGHT * g.g("boss_flee")
# runs.auto_profile (GameplayTelemetry.autopilot_fields): "persona-strong:<hash8>".func _profile_base() -> String: return "persona-%s:%s" % [skill, genome.hash_id().substr(0, 8)]
static func weapon_of(id: String) -> String: if id.begins_with("weapon:") or id.begins_with("evolve:"): return id.get_slice(":", 1) if id.begins_with("wm:"): return id.get_slice(":", 1) return ""
func score_card(content: ContentDB, id: String, eff: Dictionary, best_raw: float) -> float: var raw := float(eff.get("score", 0.0)) var s := genome.g("w_base") * (raw / best_raw if best_raw > 0.0 else 0.0) for t: String in ArchetypeTags.for_card(content, id, eff): s += genome.g("w_" + t) var wid := weapon_of(id) if wid != "": s += genome.wpref(wid) if id.begins_with("weapon:"): s += genome.g("new_weapon_bias") return s
func pick_upgrade_for(sim: Sim, pilot: PlayerState, choice_ids: Array) -> String: var picked: String if genome.is_default_build() or choice_ids.is_empty() or sim.tutorial != null: picked = super.pick_upgrade_for(sim, pilot, choice_ids) else: var effs := {} var best_raw := 0.0 for raw_id in choice_ids: var id := String(raw_id) var e: Dictionary = sim.upgrade_system.upgrade_effects(sim, pilot, id) effs[id] = e best_raw = maxf(best_raw, absf(float(e.get("score", 0.0)))) picked = String(choice_ids[0]) var best := -INF for raw_id in choice_ids: var id := String(raw_id) var s := score_card(sim.content, id, effs[id], best_raw) if s > best: best = s picked = id if picked != "": _note_pick(sim, pilot, picked) return picked
# Role tally: each non-drone archetype tag of the card taken gets an equal share of 1.0.func _note_pick(sim: Sim, pilot: PlayerState, id: String) -> void: var eff: Dictionary = sim.upgrade_system.upgrade_effects(sim, pilot, id) var tags := ArchetypeTags.for_card(sim.content, id, eff) var roles: Array[String] = [] for t: String in tags: if tally.has(t): roles.append(t) for r: String in roles: tally[r] = float(tally[r]) + 1.0 / float(roles.size()) picks.append(id) last_pick_note = "took %s (%s)" % [id, ", ".join(tags) if not tags.is_empty() else "untagged"]upgrade_effects must not draw RNG. SkilledAutoPlayer.pick_upgrade_for already relies on that (fact-checked: “No RNG is drawn”). If the equivalence test fails only on picks, check that first.
- Step 3c: Soak support in
tools/soak/soak_run.gd. In the auto-selection chain (lines 50-78), add a branch first, beforeif String(cfg.get("player", "dumb")) != "skilled":, and turn the oldifinto anelif:
var persona_cfg: Variant = cfg.get("persona", {}) if persona_cfg is Dictionary and not (persona_cfg as Dictionary).is_empty(): var pd := persona_cfg as Dictionary var gd: Variant = pd.get("genome", {}) auto = PersonaAutoPlayer.new(PersonaGenome.from_dict(gd if gd is Dictionary else {}), String(pd.get("skill", "strong")), int(cfg.seed)) elif String(cfg.get("player", "dumb")) != "skilled":In the report dictionary (lines 133-161), add:
"persona_tally": (auto as PersonaAutoPlayer).tally.duplicate() if auto is PersonaAutoPlayer else {},- Step 4: Run tests
Run: GUT_LOG=$TMPDIR/persona-t2.log scripts/check-test-count.sh
Expected: PASS. The determinism tests must pass with no re-pin. If one fails, a knob read was converted wrongly. Fix it; don’t re-pin.
- Step 5: Commit
git add ai/skilled_auto_player.gd ai/persona/persona_auto_player.gd tools/soak/soak_run.gd tests/test_persona_auto_player.gdgit commit --file=<msg: "feat(persona): genome-driven knobs + PersonaAutoPlayer; soak persona config">Task 3: PersonaJobs (cells, role measurement, job selection, breeding, result sample)
Section titled “Task 3: PersonaJobs (cells, role measurement, job selection, breeding, result sample)”Files:
- Create:
ai/persona/persona_jobs.gd - Test:
tests/test_persona_jobs.gd
Interfaces:
-
Consumes:
PersonaGenome,PersonaTuning,PersonaAutoPlayer.tally. -
Archive dict shape (the GET
/personasbody, defined in Task 4):{"epoch": int, "elites": [{"cell","genome_hash","genome","persona_id","name","generation","mean_s","samples"}…], "candidates": [{"genome_hash","genome","cell","parent_id","generation","mean_s","samples"}…], "pinned": Dictionary|null}
-
Produces:
PersonaJobs.CELLS: Array[String](12, role-major order)static cell(role, skill) -> String,static role_of_cell(c) -> String,static skill_of_cell(c) -> Stringstatic role_of(tally: Dictionary, fallback: String) -> Stringstatic pick_job(archive: Dictionary, rng: RandomNumberGenerator, allow_pinned: bool) -> Dictionary- returns
{"job": String, "genome": PersonaGenome, "parent": PersonaGenome, "target_cell": String, "skill": String, "parent_id": String, "generation": int, "label": String, "epoch": int, "elite_mean_s": float};job∈mutate|candidate|elite|pinned
- returns
static sample_for(job: Dictionary, tally: Dictionary, survival_s: float, gold: float, run_seed_v: int, build: int, finder: String) -> Dictionary(the POST body minuskey)
-
Step 1: Write the failing test
tests/test_persona_jobs.gd
extends GutTest
func _rng(s: int) -> RandomNumberGenerator: var r := RandomNumberGenerator.new() r.seed = s return r
func _elite(cell: String, samples: int, mean: float, genes: Dictionary = {}) -> Dictionary: var g := PersonaGenome.from_dict({"v": 1, "genes": genes, "weapon_pref": {}}) return {"cell": cell, "genome_hash": g.hash_id(), "genome": g.to_dict(), "persona_id": g.hash_id().substr(0, 8), "name": "x", "generation": 3, "mean_s": mean, "samples": samples}
func test_twelve_cells() -> void: assert_eq(PersonaJobs.CELLS.size(), 12) assert_eq(PersonaJobs.CELLS[0], "tank:weak") assert_eq(PersonaJobs.role_of_cell("utility:median"), "utility") assert_eq(PersonaJobs.skill_of_cell("utility:median"), "median")
func test_role_of_takes_largest_share_and_falls_back() -> void: assert_eq(PersonaJobs.role_of({"tank": 1.0, "support": 0.0, "damage": 2.5, "utility": 0.5}, "tank"), "damage") assert_eq(PersonaJobs.role_of({"tank": 0.0, "support": 0.0, "damage": 0.0, "utility": 0.0}, "support"), "support") assert_eq(PersonaJobs.role_of({"tank": 1.0, "support": 1.0, "damage": 0.0, "utility": 0.0}, "support"), "support", "tie -> fallback if tied")
func test_empty_archive_mutates_default_into_an_empty_cell() -> void: var job := PersonaJobs.pick_job({"epoch": 1, "elites": [], "candidates": [], "pinned": null}, _rng(1), true) assert_eq(job["job"], "mutate") assert_true(job["target_cell"] in PersonaJobs.CELLS) assert_eq(int(job["generation"]), 1) assert_eq(job["parent_id"], "") assert_ne((job["genome"] as PersonaGenome).hash_id(), PersonaGenome.new().hash_id())
func test_mutate_targets_an_empty_cell_before_a_filled_one() -> void: var elites: Array = [] for c: String in PersonaJobs.CELLS: if c != "support:median": elites.append(_elite(c, 9, 60.0)) PersonaTuning.MUTATE_SHARE = 1.0 var job := PersonaJobs.pick_job({"epoch": 2, "elites": elites, "candidates": [], "pinned": null}, _rng(5), true) PersonaTuning.MUTATE_SHARE = 0.7 assert_eq(job["target_cell"], "support:median") assert_eq(job["skill"], "median") assert_eq(int(job["epoch"]), 2)
func test_pinned_wins_when_allowed_only() -> void: var pin := PersonaGenome.from_dict({"v": 1, "genes": {"kite": 2.0}, "weapon_pref": {}}).to_dict() var arch := {"epoch": 1, "elites": [], "candidates": [], "pinned": {"genome": pin, "cell": "tank:strong"}} assert_eq(PersonaJobs.pick_job(arch, _rng(1), true)["job"], "pinned") assert_ne(PersonaJobs.pick_job(arch, _rng(1), false)["job"], "pinned")
func test_job_split_is_weighted() -> void: var arch := {"epoch": 1, "elites": [_elite("tank:strong", 5, 50.0)], "candidates": [{"genome_hash": "h", "genome": PersonaGenome.new().to_dict(), "cell": "damage:weak", "parent_id": "", "generation": 2, "mean_s": 40.0, "samples": 1}], "pinned": null} var counts := {"mutate": 0, "candidate": 0, "elite": 0} var rng := _rng(99) for i in range(2000): counts[PersonaJobs.pick_job(arch, rng, true)["job"]] += 1 assert_between(counts["mutate"], 1300, 1500) assert_between(counts["candidate"], 300, 500) assert_between(counts["elite"], 120, 280)
func test_sample_for_scores_into_the_played_role() -> void: var job := PersonaJobs.pick_job({"epoch": 4, "elites": [], "candidates": [], "pinned": null}, _rng(2), true) var s := PersonaJobs.sample_for(job, {"tank": 3.0, "support": 0.0, "damage": 1.0, "utility": 0.0}, 71.5, 300.0, 9, 305, "soak") assert_eq(s["cell"], "tank:" + String(job["skill"])) assert_eq(s["target_cell"], job["target_cell"]) assert_eq(int(s["epoch"]), 4) assert_eq(s["genome_hash"], (job["genome"] as PersonaGenome).hash_id()) for k: String in ["genome", "parent_id", "generation", "job", "survival_s", "gold", "seed", "build", "finder"]: assert_true(s.has(k), k)-
Step 2: Run to verify it fails. Run the same check-test-count command with
GUT_LOG=$TMPDIR/persona-t3.log. Expected: FAIL. -
Step 3: Implement
ai/persona/persona_jobs.gd
class_name PersonaJobsextends RefCounted
# Learning-autopilot job selection + breeding (spec 2026-10-11-learning-autopilot-design.md §4).# Pure: the archive comes in as the control Worker's GET /personas dict; the rng is the caller's# (seeded from the run seed on device, from --seed_base in soak). Promotion is NOT here: the Worker# does it atomically (control/src/personas.js).
static var CELLS: Array[String] = _make_cells()
static func _make_cells() -> Array[String]: var out: Array[String] = [] for r: String in PersonaGenome.ROLES: for s: String in PersonaGenome.SKILLS: out.append(cell(r, s)) return out
static func cell(role: String, skill: String) -> String: return "%s:%s" % [role, skill]
static func role_of_cell(c: String) -> String: return c.get_slice(":", 0)
static func skill_of_cell(c: String) -> String: return c.get_slice(":", 1)
# The played role: the largest share; a tie at the top (incl. all zero) keeps the fallback when it# is among the tied, else the first tied role in ROLES order.static func role_of(tally: Dictionary, fallback: String) -> String: var best := -1.0 for r: String in PersonaGenome.ROLES: best = maxf(best, float(tally.get(r, 0.0))) var tied: Array[String] = [] for r: String in PersonaGenome.ROLES: if is_equal_approx(float(tally.get(r, 0.0)), best): tied.append(r) return fallback if fallback in tied else tied[0]
static func _elite_by_cell(elites: Array) -> Dictionary: var out := {} for e: Variant in elites: if e is Dictionary: out[String((e as Dictionary).get("cell", ""))] = e return out
static func pick_job(archive: Dictionary, rng: RandomNumberGenerator, allow_pinned: bool) -> Dictionary: var epoch := int(archive.get("epoch", 1)) var elites_v: Variant = archive.get("elites", []) var elites: Array = elites_v if elites_v is Array else [] var cands_v: Variant = archive.get("candidates", []) var cands: Array = cands_v if cands_v is Array else [] var by_cell := _elite_by_cell(elites) var pinned: Variant = archive.get("pinned", null) if allow_pinned and pinned is Dictionary: var pd := pinned as Dictionary var pg_v: Variant = pd.get("genome", {}) var pg := PersonaGenome.from_dict(pg_v if pg_v is Dictionary else {}) var pc := String(pd.get("cell", "damage:strong")) if not pc in CELLS: pc = "damage:strong" return _job("pinned", pg, pg, pc, "", 0, "PINNED persona", epoch, by_cell) var r := rng.randf() if r < PersonaTuning.MUTATE_SHARE or (elites.is_empty() and cands.is_empty()): return _mutate(rng, epoch, elites, by_cell) if r < PersonaTuning.MUTATE_SHARE + PersonaTuning.CANDIDATE_SHARE and not cands.is_empty(): var best: Dictionary = cands[0] for c: Variant in cands: if c is Dictionary and float((c as Dictionary).get("mean_s", 0.0)) > float(best.get("mean_s", 0.0)): best = c var cg_v: Variant = best.get("genome", {}) var cg := PersonaGenome.from_dict(cg_v if cg_v is Dictionary else {}) var cc := String(best.get("cell", "")) if not cc in CELLS: return _mutate(rng, epoch, elites, by_cell) return _job("candidate", cg, cg, cc, String(best.get("parent_id", "")), int(best.get("generation", 0)), "RE-CHECK candidate %d/3" % (int(best.get("samples", 0)) + 1), epoch, by_cell) if elites.is_empty(): return _mutate(rng, epoch, elites, by_cell) var e: Dictionary = elites[rng.randi_range(0, elites.size() - 1)] var eg_v: Variant = e.get("genome", {}) var eg := PersonaGenome.from_dict(eg_v if eg_v is Dictionary else {}) return _job("elite", eg, eg, String(e.get("cell", "")), String(e.get("persona_id", "")), int(e.get("generation", 0)), "ELITE re-check: %s" % String(e.get("name", "")), epoch, by_cell)
static func _mutate(rng: RandomNumberGenerator, epoch: int, elites: Array, by_cell: Dictionary) -> Dictionary: # Target: an empty cell first, else the least-sampled; ties broken by rng. var best_key := 1 << 30 var tied: Array[String] = [] for c: String in CELLS: var key := int((by_cell[c] as Dictionary).get("samples", 0)) if by_cell.has(c) else -1 if key < best_key: best_key = key tied = [c] elif key == best_key: tied.append(c) var target: String = tied[rng.randi_range(0, tied.size() - 1)] # Parent: the target's elite, else a same-role elite, else a same-skill elite, else any, else default. var parent_d: Dictionary = {} if by_cell.has(target): parent_d = by_cell[target] else: for pass_i in range(3): for e: Variant in elites: var ed := e as Dictionary var ec := String(ed.get("cell", "")) if (pass_i == 0 and role_of_cell(ec) == role_of_cell(target)) \ or (pass_i == 1 and skill_of_cell(ec) == skill_of_cell(target)) or pass_i == 2: parent_d = ed break if not parent_d.is_empty(): break var pg_v: Variant = parent_d.get("genome", {}) var parent := PersonaGenome.from_dict(pg_v if pg_v is Dictionary else {}) if not parent_d.is_empty() else PersonaGenome.new() var start := parent if elites.size() >= 2 and rng.randf() < PersonaTuning.CROSSOVER_P: var other: Dictionary = elites[rng.randi_range(0, elites.size() - 1)] if String(other.get("cell", "")) != String(parent_d.get("cell", "")): var og_v: Variant = other.get("genome", {}) start = parent.crossed(PersonaGenome.from_dict(og_v if og_v is Dictionary else {}), rng) var child := start.mutated(rng, PersonaTuning.SIGMA, rng.randi_range(2, 3)) if child.hash_id() == parent.hash_id(): child = child.mutated(rng, PersonaTuning.SIGMA, 1) var pname := String(parent_d.get("name", "default")) return _job("mutate", child, parent, target, String(parent_d.get("persona_id", "")), int(parent_d.get("generation", 0)) + 1, "MUTANT of %s" % pname, epoch, by_cell)
static func _job(kind: String, g: PersonaGenome, parent: PersonaGenome, target: String, parent_id: String, generation: int, label: String, epoch: int, by_cell: Dictionary) -> Dictionary: var em := float((by_cell[target] as Dictionary).get("mean_s", 0.0)) if by_cell.has(target) else 0.0 return {"job": kind, "genome": g, "parent": parent, "target_cell": target, "skill": skill_of_cell(target), "parent_id": parent_id, "generation": generation, "label": label, "epoch": epoch, "elite_mean_s": em}
static func sample_for(job: Dictionary, tally: Dictionary, survival_s: float, gold: float, run_seed_v: int, build: int, finder: String) -> Dictionary: var g: PersonaGenome = job["genome"] var target := String(job["target_cell"]) return {"epoch": int(job["epoch"]), "genome": g.to_dict(), "genome_hash": g.hash_id(), "parent_id": String(job["parent_id"]), "generation": int(job["generation"]), "target_cell": target, "cell": cell(role_of(tally, role_of_cell(target)), String(job["skill"])), "job": String(job["job"]), "survival_s": survival_s, "gold": gold, "seed": run_seed_v, "build": build, "finder": finder}- Step 4: Run tests. Expected: PASS.
- Step 5: Commit
ai/persona/persona_jobs.gd tests/test_persona_jobs.gdwith message “feat(persona): job selection, breeding and result samples”.
Task 4: Control Worker persona store + promotion
Section titled “Task 4: Control Worker persona store + promotion”Files:
- Create:
control/migrations/0003_personas.sql,control/src/personas.js,control/test/personas.test.mjs - Modify:
control/schema.sql(append the same DDL, so a fresh DB matches),control/src/worker.js(dispatch),control/README.md(migration line)
Interfaces:
-
GET /personas?key=K→{ok, epoch, min_samples, elites:[…], candidates:[…], pinned: {genome, cell}|null, recent:[…≤50], history:[…]}. Genomes are parsed objects. -
POST /personas/resultbody{key, …PersonaJobs.sample_for fields}→{ok, promoted, cell, mean_s, samples, elite_mean_s}. -
POST /personas/epoch{key, note}→{ok, epoch}. -
POST /personas/pin{key, genome, cell}(genome: nullclears) →{ok}. -
Exports:
personasRoute(req, env, url) -> Promise<Response|null>,CELLS,PROMOTE_MIN_SAMPLES,personaName(cell, generation). -
Step 1: Write the failing test
control/test/personas.test.mjs
// Learning autopilot (2026-10-11): the /personas archive. Runs the real worker on the sqlite D1 stand-in.import { test } from 'node:test';import assert from 'node:assert/strict';import worker from '../src/worker.js';import { fakeD1 } from './_d1.mjs';import { CELLS, PROMOTE_MIN_SAMPLES, personaName } from '../src/personas.js';
const KEY = 'k';const env = () => ({ ...fakeD1(), CONTROL_KEY: KEY });const post = (e, path, body, key = KEY) => worker.fetch(new Request('https://x.test' + path, { method: 'POST', body: JSON.stringify({ key, ...body }) }), e);const get = async (e, key = KEY) => (await worker.fetch(new Request('https://x.test/personas?key=' + key), e));const G = (kite) => ({ v: 1, genes: { kite }, weapon_pref: {} });const sample = (hash, cell, s, extra = {}) => ({ epoch: 1, genome: G(1.5), genome_hash: hash, parent_id: '', generation: 2, target_cell: cell, cell, job: 'mutate', survival_s: s, gold: 100, seed: 1, build: 305, finder: 'soak', ...extra });
test('12 cells, min samples 3, names read Role-Skill #gen', () => { assert.equal(CELLS.length, 12); assert.equal(PROMOTE_MIN_SAMPLES, 3); assert.equal(personaName('tank:strong', 14), 'Tank-Strong #14');});
test('every route is fail-closed on CONTROL_KEY', async () => { const e = env(); assert.equal((await get(e, 'nope')).status, 401); assert.equal((await post(e, '/personas/result', sample('a', 'tank:weak', 10), 'nope')).status, 401); assert.equal((await post(e, '/personas/epoch', {}, 'nope')).status, 401); assert.equal((await post(e, '/personas/pin', { genome: null }, 'nope')).status, 401);});
test('a candidate is promoted only at 3 samples, then only by a better mean', async () => { const e = env(); for (const s of [60, 70]) assert.equal((await (await post(e, '/personas/result', sample('a', 'tank:weak', s))).json()).promoted, false); const third = await (await post(e, '/personas/result', sample('a', 'tank:weak', 80))).json(); assert.equal(third.promoted, true); assert.equal(third.samples, 3); let arch = await (await get(e)).json(); assert.equal(arch.elites.length, 1); assert.equal(arch.elites[0].mean_s, 70); assert.deepEqual(arch.elites[0].genome, G(1.5)); for (const s of [50, 50, 50]) await post(e, '/personas/result', sample('b', 'tank:weak', s)); arch = await (await get(e)).json(); assert.equal(arch.elites[0].genome_hash, 'a', 'worse candidate does not displace'); for (const s of [90, 90, 90]) await post(e, '/personas/result', sample('c', 'tank:weak', s)); arch = await (await get(e)).json(); assert.equal(arch.elites[0].genome_hash, 'c'); assert.equal(arch.history.length, 2);});
test('an elite re-check updates its own mean even downward', async () => { const e = env(); for (const s of [90, 90, 90]) await post(e, '/personas/result', sample('a', 'damage:strong', s)); await post(e, '/personas/result', sample('a', 'damage:strong', 10, { job: 'elite' })); const arch = await (await get(e)).json(); assert.equal(arch.elites[0].samples, 4); assert.equal(arch.elites[0].mean_s, 70);});
test('two concurrent better candidates: the higher mean holds the cell', async () => { const e = env(); for (const s of [60, 60]) { await post(e, '/personas/result', sample('x', 'utility:median', s)); await post(e, '/personas/result', sample('y', 'utility:median', s + 20)); } await Promise.all([post(e, '/personas/result', sample('x', 'utility:median', 60)), post(e, '/personas/result', sample('y', 'utility:median', 80))]); const arch = await (await get(e)).json(); assert.equal(arch.elites[0].genome_hash, 'y');});
test('candidates list sub-threshold genomes, best mean first', async () => { const e = env(); await post(e, '/personas/result', sample('a', 'support:weak', 30)); await post(e, '/personas/result', sample('b', 'support:weak', 45)); const arch = await (await get(e)).json(); assert.deepEqual(arch.candidates.map((c) => c.genome_hash), ['b', 'a']); assert.equal(arch.recent.length, 2);});
test('bad input is rejected: unknown cell, wrong epoch, oversized genome, bad finder', async () => { const e = env(); assert.equal((await post(e, '/personas/result', sample('a', 'wizard:weak', 10))).status, 400); assert.equal((await post(e, '/personas/result', sample('a', 'tank:weak', 10, { epoch: 9 }))).status, 409); assert.equal((await post(e, '/personas/result', sample('a', 'tank:weak', 10, { genome: { pad: 'x'.repeat(5000) } }))).status, 400); assert.equal((await post(e, '/personas/result', sample('a', 'tank:weak', 10, { finder: 'Chris iPhone!' }))).status, 400);});
test('new epoch seeds candidates from the previous elites and prunes old samples', async () => { const e = env(); for (const s of [90, 90, 90]) await post(e, '/personas/result', sample('a', 'tank:strong', s)); assert.equal((await (await post(e, '/personas/epoch', { note: 'balance patch' })).json()).epoch, 2); const arch = await (await get(e)).json(); assert.equal(arch.epoch, 2); assert.equal(arch.elites.length, 0); assert.equal(arch.candidates[0].genome_hash, 'a'); assert.equal(arch.candidates[0].samples, 0); assert.equal(arch.candidates[0].mean_s, 90);});
test('pin is returned until a pinned result arrives', async () => { const e = env(); await post(e, '/personas/pin', { genome: G(2), cell: 'tank:weak' }); assert.deepEqual((await (await get(e)).json()).pinned, { genome: G(2), cell: 'tank:weak' }); await post(e, '/personas/result', sample('p', 'tank:weak', 30, { job: 'pinned' })); assert.equal((await (await get(e)).json()).pinned, null);});-
Step 2: Run to verify it fails. Run:
node --test control/test/personas.test.mjs. Expected: FAIL (module not found). -
Step 3: Implement.
control/migrations/0003_personas.sql(append the identical DDL tocontrol/schema.sql):
-- Learning autopilot (2026-10-11): MAP-Elites persona archive. docs/superpowers/specs/2026-10-11-learning-autopilot-design.md-- No personal data: finder is a device KIND (tvos/ios/macos/soak). Samples from epochs older than the previous one-- are pruned when a new epoch starts (POST /personas/epoch).CREATE TABLE IF NOT EXISTS persona_epochs (epoch INTEGER PRIMARY KEY, started_ms INTEGER NOT NULL, note TEXT NOT NULL DEFAULT '');INSERT OR IGNORE INTO persona_epochs(epoch, started_ms, note) VALUES (1, 0, 'initial');CREATE TABLE IF NOT EXISTS persona_elites ( epoch INTEGER NOT NULL, cell TEXT NOT NULL, genome_hash TEXT NOT NULL, genome TEXT NOT NULL, persona_id TEXT NOT NULL, name TEXT NOT NULL, parent_id TEXT NOT NULL DEFAULT '', generation INTEGER NOT NULL, mean_s REAL NOT NULL, mean_gold REAL NOT NULL, samples INTEGER NOT NULL, finder TEXT NOT NULL, build INTEGER NOT NULL, updated_ms INTEGER NOT NULL, PRIMARY KEY (epoch, cell));CREATE TABLE IF NOT EXISTS persona_samples ( id INTEGER PRIMARY KEY AUTOINCREMENT, epoch INTEGER NOT NULL, genome_hash TEXT NOT NULL, genome TEXT NOT NULL, parent_id TEXT NOT NULL DEFAULT '', generation INTEGER NOT NULL, target_cell TEXT NOT NULL, cell TEXT NOT NULL, job TEXT NOT NULL, survival_s REAL NOT NULL, gold REAL NOT NULL, seed INTEGER NOT NULL, build INTEGER NOT NULL, finder TEXT NOT NULL, ts INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS persona_samples_agg ON persona_samples(epoch, genome_hash, cell);CREATE TABLE IF NOT EXISTS persona_promotions ( id INTEGER PRIMARY KEY AUTOINCREMENT, epoch INTEGER NOT NULL, cell TEXT NOT NULL, genome_hash TEXT NOT NULL, name TEXT NOT NULL, mean_s REAL NOT NULL, samples INTEGER NOT NULL, ts INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS persona_pin (id INTEGER PRIMARY KEY CHECK (id = 1), genome TEXT NOT NULL, cell TEXT NOT NULL, set_ms INTEGER NOT NULL);control/src/personas.js:
// Learning autopilot archive (2026-10-11, spec docs/superpowers/specs/2026-10-11-learning-autopilot-design.md).// The game breeds and picks jobs (ai/persona/persona_jobs.gd); this module only stores samples and does// the one atomic promotion upsert, so concurrent posts from soak + devices can't clobber a better elite.import { keyOk } from './queue.js';
export const ROLES = ['tank', 'support', 'damage', 'utility'];export const SKILLS = ['weak', 'median', 'strong'];export const CELLS = ROLES.flatMap((r) => SKILLS.map((s) => `${r}:${s}`));export const PROMOTE_MIN_SAMPLES = 3;const MAX_GENOME_BYTES = 4096;const FINDER_RE = /^[a-z0-9_-]{1,16}$/;const JOBS = ['mutate', 'candidate', 'elite', 'pinned'];const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS' };const j = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json', ...CORS } });const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
export function personaName(cell, generation) { const [r, s] = cell.split(':'); return `${cap(r)}-${cap(s)} #${generation}`;}
const parse = (t) => { try { return JSON.parse(t); } catch { return null; } };
async function currentEpoch(db) { const r = await db.prepare('SELECT MAX(epoch) AS e FROM persona_epochs').first(); return r?.e ?? 1;}
function validSample(b) { const num = (v) => typeof v === 'number' && Number.isFinite(v); if (!CELLS.includes(b.cell) || !CELLS.includes(b.target_cell)) return 'bad cell'; if (!JOBS.includes(b.job)) return 'bad job'; if (typeof b.genome_hash !== 'string' || !/^[0-9a-z]{1,32}$/.test(b.genome_hash)) return 'bad hash'; if (!b.genome || typeof b.genome !== 'object' || JSON.stringify(b.genome).length > MAX_GENOME_BYTES) return 'bad genome'; if (typeof b.finder !== 'string' || !FINDER_RE.test(b.finder)) return 'bad finder'; for (const k of ['epoch', 'generation', 'survival_s', 'gold', 'seed', 'build']) if (!num(b[k])) return 'bad ' + k; if (typeof b.parent_id !== 'string' || b.parent_id.length > 32) return 'bad parent'; return null;}
const UPSERT = `INSERT INTO persona_elites(epoch,cell,genome_hash,genome,persona_id,name,parent_id,generation,mean_s,mean_gold,samples,finder,build,updated_ms)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)ON CONFLICT(epoch,cell) DO UPDATE SET genome_hash=excluded.genome_hash, genome=excluded.genome, persona_id=excluded.persona_id, name=excluded.name, parent_id=excluded.parent_id, generation=excluded.generation, mean_s=excluded.mean_s, mean_gold=excluded.mean_gold, samples=excluded.samples, finder=excluded.finder, build=excluded.build, updated_ms=excluded.updated_msWHERE excluded.genome_hash = persona_elites.genome_hash OR excluded.mean_s > persona_elites.mean_s`;
async function result(env, b) { const bad = validSample(b); if (bad) return j({ ok: false, error: bad }, 400); const db = env.DB; const epoch = await currentEpoch(db); if (b.epoch !== epoch) return j({ ok: false, error: 'stale epoch', epoch }, 409); const now = Date.now(); const genome = JSON.stringify(b.genome); await db.prepare('INSERT INTO persona_samples(epoch,genome_hash,genome,parent_id,generation,target_cell,cell,job,survival_s,gold,seed,build,finder,ts) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)') .bind(epoch, b.genome_hash, genome, b.parent_id, b.generation, b.target_cell, b.cell, b.job, b.survival_s, b.gold, b.seed, b.build, b.finder, now).run(); if (b.job === 'pinned') await db.prepare('DELETE FROM persona_pin').run(); const agg = await db.prepare('SELECT COUNT(*) AS n, AVG(survival_s) AS m, AVG(gold) AS g FROM persona_samples WHERE epoch=? AND genome_hash=? AND cell=?') .bind(epoch, b.genome_hash, b.cell).first(); const elite = await db.prepare('SELECT genome_hash, name, mean_s FROM persona_elites WHERE epoch=? AND cell=?').bind(epoch, b.cell).first(); const isElite = !!elite && elite.genome_hash === b.genome_hash; let promoted = false; if (isElite || agg.n >= PROMOTE_MIN_SAMPLES) { const name = isElite ? elite.name : personaName(b.cell, b.generation); const r = await db.prepare(UPSERT).bind(epoch, b.cell, b.genome_hash, genome, b.genome_hash.slice(0, 8), name, b.parent_id, b.generation, agg.m, agg.g, agg.n, b.finder, b.build, now).run(); promoted = !isElite && r.meta.changes > 0; if (promoted) { await db.prepare('INSERT INTO persona_promotions(epoch,cell,genome_hash,name,mean_s,samples,ts) VALUES(?,?,?,?,?,?,?)') .bind(epoch, b.cell, b.genome_hash, name, agg.m, agg.n, now).run(); } } const after = await db.prepare('SELECT mean_s FROM persona_elites WHERE epoch=? AND cell=?').bind(epoch, b.cell).first(); return j({ ok: true, promoted, cell: b.cell, mean_s: agg.m, samples: agg.n, elite_mean_s: after?.mean_s ?? null });}
async function archive(env) { const db = env.DB; const epoch = await currentEpoch(db); const elites = ((await db.prepare('SELECT * FROM persona_elites WHERE epoch=?').bind(epoch).all()).results || []) .map((e) => ({ ...e, genome: parse(e.genome) })); const eliteHash = new Set(elites.map((e) => e.genome_hash + '|' + e.cell)); const sub = ((await db.prepare(`SELECT genome_hash, cell, MAX(genome) AS genome, MAX(parent_id) AS parent_id, MAX(generation) AS generation, AVG(survival_s) AS mean_s, COUNT(*) AS samples FROM persona_samples WHERE epoch=? GROUP BY genome_hash, cell HAVING COUNT(*) < ? ORDER BY mean_s DESC LIMIT 20`).bind(epoch, PROMOTE_MIN_SAMPLES).all()).results || []) .filter((c) => !eliteHash.has(c.genome_hash + '|' + c.cell)) .map((c) => ({ ...c, genome: parse(c.genome) })); const seen = new Set(((await db.prepare('SELECT DISTINCT genome_hash FROM persona_samples WHERE epoch=?').bind(epoch).all()).results || []).map((r) => r.genome_hash)); const seeds = epoch > 1 ? ((await db.prepare('SELECT * FROM persona_elites WHERE epoch=?').bind(epoch - 1).all()).results || []) .filter((e) => !seen.has(e.genome_hash)) .map((e) => ({ genome_hash: e.genome_hash, cell: e.cell, genome: parse(e.genome), parent_id: e.parent_id, generation: e.generation, mean_s: e.mean_s, samples: 0, seed: true })) : []; const pinRow = await db.prepare('SELECT genome, cell FROM persona_pin WHERE id=1').first(); const recent = (await db.prepare('SELECT ts, cell, target_cell, job, survival_s, finder, genome_hash, generation FROM persona_samples WHERE epoch=? ORDER BY id DESC LIMIT 50').bind(epoch).all()).results || []; const history = (await db.prepare('SELECT ts, cell, name, genome_hash, mean_s, samples FROM persona_promotions WHERE epoch=? ORDER BY id').bind(epoch).all()).results || []; return j({ ok: true, epoch, min_samples: PROMOTE_MIN_SAMPLES, elites, candidates: [...seeds, ...sub].sort((a, b) => b.mean_s - a.mean_s), pinned: pinRow ? { genome: parse(pinRow.genome), cell: pinRow.cell } : null, recent, history });}
export async function personasRoute(req, env, url) { const p = url.pathname; if (p !== '/personas' && !p.startsWith('/personas/')) return null; if (req.method === 'GET' && p === '/personas') { if (!keyOk(env.CONTROL_KEY, url.searchParams.get('key'))) return j({ ok: false, error: 'unauthorized' }, 401); return archive(env); } if (req.method !== 'POST') return j({ ok: false, error: 'not found' }, 404); const b = parse(await req.text()); if (!b) return j({ ok: false, error: 'bad json' }, 400); if (!keyOk(env.CONTROL_KEY, b.key)) return j({ ok: false, error: 'unauthorized' }, 401); if (p === '/personas/result') return result(env, b); if (p === '/personas/epoch') { const next = (await currentEpoch(env.DB)) + 1; await env.DB.prepare('INSERT INTO persona_epochs(epoch, started_ms, note) VALUES(?,?,?)').bind(next, Date.now(), String(b.note || '').slice(0, 200)).run(); await env.DB.prepare('DELETE FROM persona_samples WHERE epoch < ?').bind(next - 1).run(); return j({ ok: true, epoch: next }); } if (p === '/personas/pin') { if (b.genome === null) { await env.DB.prepare('DELETE FROM persona_pin').run(); return j({ ok: true }); } if (!b.genome || typeof b.genome !== 'object' || JSON.stringify(b.genome).length > MAX_GENOME_BYTES || !CELLS.includes(b.cell)) return j({ ok: false, error: 'bad pin' }, 400); await env.DB.prepare('INSERT INTO persona_pin(id,genome,cell,set_ms) VALUES(1,?,?,?) ON CONFLICT(id) DO UPDATE SET genome=excluded.genome, cell=excluded.cell, set_ms=excluded.set_ms') .bind(JSON.stringify(b.genome), b.cell, Date.now()).run(); return j({ ok: true }); } return j({ ok: false, error: 'not found' }, 404);}In control/src/worker.js, add import { personasRoute } from './personas.js'; beside the other imports. As the first line inside fetch after the OPTIONS check (line ~92), add:
const pr = await personasRoute(req, env, url); if (pr) return pr;In control/README.md, after the 0002_mp_hosts.sql line (77), add:
npx wrangler d1 execute bullet-heaven-control --remote --file=migrations/0003_personas.sql # 2026-10-11: learning autopilotThe concurrency test passes because the upsert’s WHERE guard makes “better mean wins” atomic per statement. If node’s sqlite shim serialises anyway, the test still pins the outcome.
- Step 4: Run tests. Run:
node --test control/test/*.test.mjs. Expected: all PASS. - Step 5: Commit (message “feat(control): persona archive routes + atomic promotion”). Do not deploy or apply the migration in this task. That happens in Task 9’s release step, with Chris’s go-ahead.
Task 5: Headless evolve runner + script
Section titled “Task 5: Headless evolve runner + script”Files:
- Create:
tools/persona_evolve/evolve_runner.gd,scripts/personas-evolve.sh - Test:
tests/test_persona_evolve.gd
Interfaces:
-
Consumes:
PersonaJobs.pick_job/sample_for, SoakRunpersonaconfig (Task 2), the archive JSON (Task 4). -
Produces:
- CLI
godot --headless --path . -s res://tools/persona_evolve/evolve_runner.gd -- --archive=<json> --out=<json> --children=N --seeds=S --seed_base=B --max_ticks=Twrites{"samples": [sample…]}. EvolveRunner.run_batch(archive: Dictionary, children: int, seeds: int, seed_base: int, max_ticks: int) -> Array(static, testable).
- CLI
-
Step 1: Write the failing test
tests/test_persona_evolve.gd
extends GutTest
func test_batch_yields_children_times_seeds_valid_samples() -> void: var samples := EvolveRunner.run_batch({"epoch": 1, "elites": [], "candidates": [], "pinned": null}, 2, 2, 777, 1200) assert_eq(samples.size(), 4) for s: Dictionary in samples: assert_true(String(s["cell"]) in PersonaJobs.CELLS) assert_eq(String(s["finder"]), "soak") assert_true(float(s["survival_s"]) > 0.0) assert_eq(samples[0]["genome_hash"], samples[1]["genome_hash"], "one child, two seeds") assert_ne(samples[0]["seed"], samples[1]["seed"])
func test_batch_is_reproducible_from_seed_base() -> void: var arch := {"epoch": 1, "elites": [], "candidates": [], "pinned": null} var a := EvolveRunner.run_batch(arch, 1, 1, 31, 600) var b := EvolveRunner.run_batch(arch, 1, 1, 31, 600) assert_eq(a[0]["genome_hash"], b[0]["genome_hash"]) assert_eq(a[0]["survival_s"], b[0]["survival_s"])-
Step 2: Run to verify it fails. Expected: FAIL (
EvolveRunnerundeclared). -
Step 3: Implement
tools/persona_evolve/evolve_runner.gd
class_name EvolveRunnerextends SceneTree
# Headless learning-autopilot batch (spec 2026-10-11 §4 "Soak learner"). Breeds `children` jobs from an# archive JSON (the control Worker's GET /personas body) and flies each on `seeds` consecutive seeds with# a fresh save + default ship, writing one result sample per run. scripts/personas-evolve.sh fetches the# archive, runs K of these in parallel and POSTs the samples.
func _init() -> void: var a := _parse(OS.get_cmdline_user_args()) var arch: Dictionary = {} var f := FileAccess.open(String(a.get("archive", "")), FileAccess.READ) if f != null: var parsed: Variant = JSON.parse_string(f.get_as_text()) if parsed is Dictionary: arch = parsed var samples := run_batch(arch, int(a.get("children", "4")), int(a.get("seeds", str(PersonaTuning.SOAK_SEEDS))), int(a.get("seed_base", "1")), int(a.get("max_ticks", "72000"))) var out := FileAccess.open(String(a.get("out", "user://persona_samples.json")), FileAccess.WRITE) out.store_string(JSON.stringify({"samples": samples})) out.close() print("evolve_runner: %d samples" % samples.size()) quit(0)
static func run_batch(archive: Dictionary, children: int, seeds: int, seed_base: int, max_ticks: int) -> Array: var rng := RandomNumberGenerator.new() rng.seed = seed_base var out: Array = [] for c in range(children): var job := PersonaJobs.pick_job(archive, rng, false) var g: PersonaGenome = job["genome"] for i in range(seeds): var run_seed_v := seed_base * 1000 + c * seeds + i var rep := SoakRun.new({"seed": run_seed_v, "ship": ShipBonuses.DEFAULT_SHIP, "coop": false, "meta": MetaState.new(), "max_ticks": max_ticks, "player": "skilled", "persona": {"genome": g.to_dict(), "skill": String(job["skill"])}}).execute() var tally: Variant = rep.get("persona_tally", {}) out.append(PersonaJobs.sample_for(job, tally if tally is Dictionary else {}, float(rep["run_time"]), float(rep["run_gold"]), run_seed_v, Sim_Const.BUILD, "soak")) return out
func _parse(argv: PackedStringArray) -> Dictionary: var out := {} for a in argv: if a.begins_with("--") and "=" in a: var kv := a.substr(2).split("=", true, 1) out[kv[0]] = kv[1] return outIf class_name on a SceneTree script trips the headless class-cache issue, move run_batch into a sibling RefCounted tools/persona_evolve/evolve_batch.gd (class_name EvolveBatch), have the runner and test call that instead, and note it in the commit.
scripts/personas-evolve.sh (chmod +x):
#!/bin/zsh# Learning autopilot soak learner (spec 2026-10-11 §4). Loops until --hours elapse: fetch the archive,# breed+fly K parallel batches headless, POST every sample. Needs BULLET_HEAVEN_CONTROL_KEY (~/.secrets).# Usage: scripts/personas-evolve.sh --hours 8 --jobs 8 [--children 4] [--seeds 5] [--max_ticks 72000]cd "$(dirname "$0")/.."source ~/.secretsHOURS=1; JOBS=8; CHILDREN=4; SEEDS=5; MAX_TICKS=72000while [ $# -gt 0 ]; do case "$1" in --hours) HOURS=$2; shift 2;; --jobs) JOBS=$2; shift 2;; --children) CHILDREN=$2; shift 2;; --seeds) SEEDS=$2; shift 2;; --max_ticks) MAX_TICKS=$2; shift 2;; *) echo "unknown arg $1" >&2; exit 2;; esacdone[ -n "$BULLET_HEAVEN_CONTROL_KEY" ] || { echo "BULLET_HEAVEN_CONTROL_KEY not set" >&2; exit 2; }API=https://bullet-heaven-control.chris-allen-06f.workers.devWORK=$(mktemp -d)END=$(( $(date +%s) + HOURS * 3600 ))BATCH=0godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres 2>/dev/nullwhile [ "$(date +%s)" -lt "$END" ]; do curl -sf "$API/personas?key=$BULLET_HEAVEN_CONTROL_KEY" -o "$WORK/archive.json" || { echo "archive fetch failed; retry in 60s"; sleep 60; continue; } pids=() for k in $(seq 1 $JOBS); do BATCH=$((BATCH + 1)) perl -e 'alarm 3600; exec @ARGV' godot --headless --path . -s res://tools/persona_evolve/evolve_runner.gd -- \ --archive="$WORK/archive.json" --out="$WORK/out-$k.json" --children=$CHILDREN --seeds=$SEEDS \ --seed_base=$(( $(date +%s) % 100000 * 100 + BATCH )) --max_ticks=$MAX_TICKS >"$WORK/log-$k.txt" 2>&1 & pids+=($!) done for p in $pids; do wait $p; done posted=0; promoted=0 for k in $(seq 1 $JOBS); do [ -f "$WORK/out-$k.json" ] || { echo "batch $k produced no output:"; tail -5 "$WORK/log-$k.txt"; continue; } python3 -I - "$WORK/out-$k.json" "$BULLET_HEAVEN_CONTROL_KEY" "$API" <<'PY' | while read line; do echo "$line"; doneimport json, sys, urllib.requestpath, key, api = sys.argv[1:4]for s in json.load(open(path))["samples"]: s["key"] = key req = urllib.request.Request(api + "/personas/result", data=json.dumps(s).encode(), method="POST") try: r = json.load(urllib.request.urlopen(req, timeout=20)) print("PROMOTED %s %.1fs" % (r["cell"], r["mean_s"]) if r.get("promoted") else "ok") except Exception as e: print("post failed: %s" % e)PY rm -f "$WORK/out-$k.json" done echo "$(date +%H:%M) round done"donerm -rf "$WORK"- Step 4: Run tests (GUT). Then run a smoke without network:
godot --headless --path . -s res://tools/persona_evolve/evolve_runner.gd -- --archive=/dev/null --out=$TMPDIR/ev.json --children=2 --seeds=2 --seed_base=5 --max_ticks=3600. Expected:evolve_runner: 4 samples. Record the wall time per run in the commit message: that’s the measured soak throughput the spec asked for. - Step 5: Commit (message “feat(persona): headless evolve runner + personas-evolve.sh”).
Task 6: Device learning: dev flag, PersonaClient, main.gd hooks
Section titled “Task 6: Device learning: dev flag, PersonaClient, main.gd hooks”Files:
- Create:
net/persona_client.gd - Modify:
meta/dev_flags.gd(var +load_for+save),ui/debug_settings_panel.gd(DEV_FLAG_TOGGLESrow, desc,dev_flag/set_dev_flagbranches),main.gd(members,_readynear:387,_new_runmeta block:1272-1277, attract AI:1706-1709, take-over:1812-1813,_bank_run:2166-2169) - Test:
tests/test_persona_client.gd,tests/test_learning_autopilot_main.gd
Interfaces:
-
Consumes:
PersonaJobs,PersonaAutoPlayer,ControlClient.ENDPOINT,ControlClient.CONTROL_KEY,LocalJson.read/write. -
Produces:
PersonaClientwitharchive() -> Dictionary,refresh(),submit(sample: Dictionary),static finder_kind() -> String, signalresult_posted(resp: Dictionary),var io_enabled := true, and the injectablevar post_fn: Callable(tests).DevFlags.learning_autopilot: boolmain.learning_run_wanted(human: bool, mode: String, net: bool) -> bool(static, testable)main._persona_job: Dictionary
-
Step 1: Write the failing tests
tests/test_persona_client.gd:
extends GutTest
func test_submit_queues_and_flushes_through_post_fn() -> void: var c := PersonaClient.new() c.io_enabled = false # no HTTP; post_fn stands in var sent: Array = [] c.post_fn = func(body: Dictionary) -> void: sent.append(body) add_child_autofree(c) c.submit({"cell": "tank:weak", "survival_s": 10.0}) c.flush_for_test() assert_eq(sent.size(), 1) assert_true(sent[0].has("key"), "the control key is attached") assert_eq(String(sent[0]["cell"]), "tank:weak")
func test_pending_is_capped() -> void: var c := PersonaClient.new() c.io_enabled = false add_child_autofree(c) for i in range(PersonaClient.MAX_PENDING + 10): c.submit({"i": i}) assert_eq(c.pending_count(), PersonaClient.MAX_PENDING)
func test_finder_kind_is_a_safe_slug() -> void: assert_true(RegEx.create_from_string("^[a-z0-9_-]{1,16}$").search(PersonaClient.finder_kind()) != null)tests/test_learning_autopilot_main.gd:
extends GutTest
const Main := preload("res://main.gd")
func test_learning_only_for_unattended_local_survival_with_flag() -> void: DevFlags.learning_autopilot = true assert_eq(Main.learning_run_wanted(false, "survival", false), BuildConfig.dev_tools()) assert_false(Main.learning_run_wanted(true, "survival", false), "a human run never learns") assert_false(Main.learning_run_wanted(false, "story", false)) assert_false(Main.learning_run_wanted(false, "survival", true), "LAN runs never learn") DevFlags.learning_autopilot = false assert_false(Main.learning_run_wanted(false, "survival", false))
func test_flag_is_off_headless_even_if_saved_on() -> void: DevFlags.load_for(DevFlags.flags_active(true, "headless"), {"learning_autopilot": true}) assert_false(DevFlags.learning_autopilot) DevFlags.load_for(true, {}) assert_true(DevFlags.learning_autopilot, "defaults ON in a windowed dev build") DevFlags.load_for(false, {})The existing tests/test_debug_console_parity.gd and the desc-coverage check in tests/test_main.gd must pass once the row and desc are added.
-
Step 2: Run to verify they fail. Expected: FAIL.
-
Step 3a: DevFlags (
meta/dev_flags.gd). Addstatic var learning_autopilot: bool = falsebesidehard_tuning. Inload_for, after thehard_tuningline:
# Learning autopilot (spec 2026-10-11): defaults ON in a windowed dev build; absent from the json = on. learning_autopilot = is_dev and (d.get("learning_autopilot", true) is bool) and bool(d.get("learning_autopilot", true))In save(), add "learning_autopilot": learning_autopilot, to the dict.
- Step 3b: Debug row (
ui/debug_settings_panel.gd). Append toDEV_FLAG_TOGGLES:
["learning_autopilot", "Dev: learning autopilot (personas)"],Add to the desc dict (beside "hard_tuning"):
"learning_autopilot": "Survival autopilot runs fly an evolving persona from the shared archive (fresh save, default ship), show what it is trying in a corner panel, and post the result so the archive learns. Off = the fixed attract AI. Applies from the next run.",In dev_flag(field), add elif field == "learning_autopilot": return DevFlags.learning_autopilot (same shape as the hard_tuning branch). In set_dev_flag, add elif field == "learning_autopilot": DevFlags.learning_autopilot = v.
- Step 3c:
net/persona_client.gd
class_name PersonaClientextends Node
# Learning autopilot device side (spec 2026-10-11 §4): fetches the persona archive from the control# Worker (cached in user:// so an offline run still has parents) and posts each learning run's sample,# queued in user:// until the Worker answers. Dev/playtest builds only: it uses ControlClient's key.
signal result_posted(resp: Dictionary)
const CACHE_FILE := "persona_archive.json"const PENDING_FILE := "persona_pending.json"const MAX_PENDING := 50
var io_enabled := truevar post_fn: Callable = Callable() # tests: replaces the HTTP POSTvar _archive: Dictionary = {}var _pending: Array = []var _get_http: HTTPRequestvar _post_http: HTTPRequestvar _posting := false
func _ready() -> void: _archive = LocalJson.read(CACHE_FILE) var p: Variant = LocalJson.read(PENDING_FILE).get("items", []) _pending = p if p is Array else []
func live() -> bool: return io_enabled and not OS.has_feature("editor") and DisplayServer.get_name() != "headless"
static func finder_kind() -> String: var n := OS.get_name().to_lower().replace(" ", "") return n.substr(0, 16) if n != "" else "unknown"
func archive() -> Dictionary: return _archive
func pending_count() -> int: return _pending.size()
func refresh() -> void: if not live(): return if _get_http == null: _get_http = HTTPRequest.new() _get_http.timeout = 10.0 add_child(_get_http) _get_http.request_completed.connect(_on_get_done) _get_http.cancel_request() _get_http.request(ControlClient.ENDPOINT + "/personas?key=" + ControlClient.CONTROL_KEY.uri_encode()) _flush()
func _on_get_done(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or code != 200: return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if parsed is Dictionary and bool((parsed as Dictionary).get("ok", false)): _archive = parsed LocalJson.write(CACHE_FILE, _archive)
func submit(sample: Dictionary) -> void: _pending.append(sample) while _pending.size() > MAX_PENDING: _pending.pop_front() _save_pending() _flush()
func flush_for_test() -> void: _flush()
func _flush() -> void: if _posting or _pending.is_empty(): return var body: Dictionary = (_pending[0] as Dictionary).duplicate() body["key"] = ControlClient.CONTROL_KEY if post_fn.is_valid(): post_fn.call(body) _pending.pop_front() _save_pending() _flush() return if not live(): return if _post_http == null: _post_http = HTTPRequest.new() _post_http.timeout = 15.0 add_child(_post_http) _post_http.request_completed.connect(_on_post_done) _posting = true _post_http.request(ControlClient.ENDPOINT + "/personas/result", ["Content-Type: application/json"], HTTPClient.METHOD_POST, JSON.stringify(body))
func _on_post_done(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void: _posting = false if result != HTTPRequest.RESULT_SUCCESS: return # offline: keep it queued for the next refresh if code == 200 or (code >= 400 and code < 500): _pending.pop_front() # 4xx = stale epoch / invalid: never retry it _save_pending() if code == 200: var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if parsed is Dictionary: result_posted.emit(parsed) _flush()
func _save_pending() -> void: LocalJson.write(PENDING_FILE, {"items": _pending})- Step 3d:
main.gdhooks (verified anchors; re-grep before editing).
- Members, near
var run_seed: int = 0(:264):
# Learning autopilot (spec 2026-10-11): the device persona client, this run's job ({} = not a learning run)# and its overlay. Dev builds only; learning_run_wanted() is the single gate.var persona_client: PersonaClientvar persona_overlay: PersonaOverlayvar _persona_job: Dictionary = {}var _learning_this_run := false
static func learning_run_wanted(human: bool, mode: String, net: bool) -> bool: return BuildConfig.dev_tools() and DevFlags.learning_autopilot and not human and mode == "survival" and not net- In
_ready, afterremote_config.setup(Sim_Const.BUILD, meta.install_id)(:387):
persona_client = PersonaClient.new() add_child(persona_client) persona_client.result_posted.connect(_on_persona_result)- In
_new_run(human), replace the meta block at:1272-1277(if meta != null and not net_start:…MetaRunSetup.apply(...)) with:
_learning_this_run = learning_run_wanted(human, _mode, net_start) if _learning_this_run: # Learning runs fly a fresh save on the default hull so soak and device scores compare (spec §2). var fresh := MetaState.new() _run_ship = ShipBonuses.DEFAULT_SHIP _run_drones = ",".join(fresh.drone_loadout) MetaRunSetup.apply(sim, fresh, _run_ship, false) persona_client.refresh() elif meta != null and not net_start: # LAN co-op: base ship on every peer (saves differ)Keep the original body of that if unchanged under the new elif.
- After
_auto = not _is_story() and not human(:1709), before theevent_queue.push("run_start", …)call:
_persona_job = {} if _auto and _learning_this_run: var prng := RandomNumberGenerator.new() prng.seed = run_seed ^ 0x9e15 # the job is reproducible from the run seed _persona_job = PersonaJobs.pick_job(persona_client.archive(), prng, true) var pap := PersonaAutoPlayer.new(_persona_job["genome"], String(_persona_job["skill"]), run_seed) auto_player = pap # the setter keeps humanize on, as for attract_aiThe overlay itself is opened in Task 7.
- Human take-over (
:1812-1813):
if human_takes_over(human): _auto = false # a human moved, aimed, warped or tapped the drone button — hand over control _persona_job = {} # a mixed human/AI run never scores (spec §8)- In
_bank_run, right after thegameplay_telemetry.report_run(...)call (:2166-2169):
if not _persona_job.is_empty() and _auto and auto_player is PersonaAutoPlayer: persona_client.submit(PersonaJobs.sample_for(_persona_job, (auto_player as PersonaAutoPlayer).tally, sim.run_time, float(sim.run_gold), run_seed, Sim_Const.BUILD, PersonaClient.finder_kind())) _persona_job = {}- Add
func _on_persona_result(_resp: Dictionary) -> void: passas a stub. Task 7 fills it in.
- Step 4: Run
scripts/gate.sh(the boot smokes exercise_ready). Expected: PASS, includingtest_debug_console_parity.gd. - Step 5: Commit (message “feat(persona): device learning runs (dev flag, PersonaClient, main hooks)”).
Task 7: On-device PersonaOverlay
Section titled “Task 7: On-device PersonaOverlay”Files:
- Create:
ui/persona_overlay.gd,tools/tunable_docs/docs.d/persona_overlay.json - Modify:
main.gd(open/bind at learning-run start,_on_persona_result),net/tunables_registry.gd(3 local rows + path const), regeneratecontrol/src/tunable_docs.js - Test:
tests/test_persona_overlay.gd
Interfaces:
-
Consumes: the job dict (Task 3),
PersonaAutoPlayer.tally/last_pick_note/picks, theresult_postedresponse{promoted, cell, mean_s, samples, elite_mean_s}. -
Produces:
PersonaOverlay(extends PanelContainer) withbind(ai: PersonaAutoPlayer, job: Dictionary, archive: Dictionary, time_fn: Callable)andshow_verdict(resp: Dictionary)- static
job_line(job) -> String,verdict_line(resp) -> String,role_line(tally) -> String - static vars
CORNER: int(0=TL,1=TR,2=BL,3=BR; default 2),OPACITY: float(0.85),VERDICT_S: float(4.0)
-
Step 1: Write the failing test
tests/test_persona_overlay.gd
extends GutTest
const DEVICES: Array[String] = ["tv", "iphone", "ipad"]var _saved_platform := ""
func before_all() -> void: Tokens.ensure_loaded() _saved_platform = LiveTokens.platform()
func after_each() -> void: Platform.force_safe_insets_pt = Vector4(-1, -1, -1, -1) Platform.force_screen_scale = 0.0 LiveTokens.set_platform(_saved_platform) await get_tree().process_frame
func _job() -> Dictionary: var parent := PersonaGenome.new() var child := PersonaGenome.from_dict({"v": 1, "genes": {"kite": 1.6, "w_tank": 1.2}, "weapon_pref": {"railgun": 1.0}}) return {"job": "mutate", "genome": child, "parent": parent, "target_cell": "tank:strong", "skill": "strong", "parent_id": "abcd1234", "generation": 14, "label": "MUTANT of Tank-Strong #13", "epoch": 1, "elite_mean_s": 64.0}
func test_text_helpers() -> void: assert_eq(PersonaOverlay.job_line(_job()), "Tank · Strong — MUTANT of Tank-Strong #13") assert_eq(PersonaOverlay.verdict_line({"promoted": true, "cell": "tank:strong", "mean_s": 71.0, "samples": 3, "elite_mean_s": 71.0}), "PROMOTED: new Tank-Strong elite (71 s)") assert_eq(PersonaOverlay.verdict_line({"promoted": false, "cell": "utility:median", "mean_s": 71.0, "samples": 1, "elite_mean_s": 64.0}), "Candidate Utility-Median 71 s vs elite 64 s (1/3)") assert_eq(PersonaOverlay.role_line({"tank": 3.0, "support": 0.0, "damage": 1.0, "utility": 0.0}), "Tank 75% · Damage 25%")
func test_fits_inside_every_device_viewport() -> void: for dev: String in DEVICES: var pr := ShopLayoutFixture.profile(dev) Platform.force_safe_insets_pt = (pr["insets"] as Vector4) / float(pr["upt"]) Platform.force_screen_scale = float(ShopLayoutFixture.DEVICES[dev]["scale"]) LiveTokens.set_platform("tv" if dev == "tv" else _saved_platform) TokenBind.flush_now() var vp := SubViewport.new() vp.size = Vector2i((pr["vp"] as Vector2).round()) add_child_autofree(vp) var ov := PersonaOverlay.new() vp.add_child(ov) var ai := PersonaAutoPlayer.new(_job()["genome"], "strong", 1) ai.tally = {"tank": 9.0, "support": 2.0, "damage": 4.0, "utility": 1.0} ai.last_pick_note = "took wm:momentum_core:overdrive (damage, utility, drone)" ov.bind(ai, _job(), {"elites": []}, func() -> float: return 512.0) ov.show_verdict({"promoted": false, "cell": "utility:median", "mean_s": 171.0, "samples": 2, "elite_mean_s": 164.0}) await get_tree().process_frame await get_tree().process_frame var r := ov.get_global_rect() var vr := Rect2(Vector2.ZERO, Vector2(vp.size)) assert_true(vr.encloses(r), "%s: overlay %s inside %s" % [dev, r, vr]) assert_true(r.size.x <= vr.size.x * 0.4, "%s: stays a corner panel" % dev)-
Step 2: Run to verify it fails. Expected: FAIL.
-
Step 3: Implement
ui/persona_overlay.gd
class_name PersonaOverlayextends PanelContainer
# Learning-autopilot watch panel (spec 2026-10-11 §5): which persona is flying, what changed vs its# parent, the role it is actually playing, survival vs the cell elite, its last pick, and the run's# verdict from the Worker. Dev builds, learning runs only (main.gd opens it). Read-only on the AI.
static var CORNER: int = 2 # 0 TL, 1 TR, 2 BL, 3 BRstatic var OPACITY: float = 0.85static var VERDICT_S: float = 4.0const MAX_W_FRAC: float = 0.34const MARGIN: float = 16.0
var _ai: PersonaAutoPlayervar _job: Dictionary = {}var _time_fn: Callablevar _title: Labelvar _genes: Labelvar _role: Labelvar _ghost: ProgressBarvar _time: Labelvar _pick: Labelvar _verdict: Labelvar _verdict_left := 0.0
func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE var box := VBoxContainer.new() add_child(box) for l: String in ["_title", "_genes", "_role", "_time", "_pick", "_verdict"]: var lab := Label.new() lab.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART lab.add_theme_font_size_override("font_size", 14) set(l, lab) _ghost = ProgressBar.new() _ghost.show_percentage = false _ghost.custom_minimum_size = Vector2(0, 6) for n: Control in [_title, _genes, _role, _ghost, _time, _pick, _verdict]: box.add_child(n) _verdict.visible = false modulate.a = OPACITY
static func _cell_name(c: String) -> String: return "%s · %s" % [c.get_slice(":", 0).capitalize(), c.get_slice(":", 1).capitalize()]
static func job_line(job: Dictionary) -> String: return "%s — %s" % [_cell_name(String(job.get("target_cell", ""))), String(job.get("label", ""))]
static func verdict_line(resp: Dictionary) -> String: var c := String(resp.get("cell", "")) var cname := "%s-%s" % [c.get_slice(":", 0).capitalize(), c.get_slice(":", 1).capitalize()] if bool(resp.get("promoted", false)): return "PROMOTED: new %s elite (%d s)" % [cname, roundi(float(resp.get("mean_s", 0.0)))] var em: Variant = resp.get("elite_mean_s", null) var vs := " vs elite %d s" % roundi(float(em)) if em != null else " (cell empty)" return "Candidate %s %d s%s (%d/3)" % [cname, roundi(float(resp.get("mean_s", 0.0))), vs, int(resp.get("samples", 0))]
static func role_line(tally: Dictionary) -> String: var total := 0.0 for r: String in PersonaGenome.ROLES: total += float(tally.get(r, 0.0)) if total <= 0.0: return "role: (no picks yet)" var parts: Array[String] = [] for r: String in PersonaGenome.ROLES: var v := float(tally.get(r, 0.0)) if v > 0.0: parts.append("%s %d%%" % [r.capitalize(), roundi(v / total * 100.0)]) return " · ".join(parts)
func bind(ai: PersonaAutoPlayer, job: Dictionary, _archive: Dictionary, time_fn: Callable) -> void: _ai = ai _job = job _time_fn = time_fn if not is_node_ready(): await ready _title.text = job_line(job) var parent: PersonaGenome = job.get("parent", PersonaGenome.new()) var lines := (job["genome"] as PersonaGenome).diff_lines(parent) _genes.text = "\n".join(lines.slice(0, 3)) if not lines.is_empty() else "(unchanged genome)" _ghost.max_value = maxf(float(job.get("elite_mean_s", 0.0)), 1.0) _place()
func show_verdict(resp: Dictionary) -> void: if not is_node_ready(): await ready _verdict.text = verdict_line(resp) _verdict.visible = true _verdict_left = VERDICT_S
func _process(delta: float) -> void: modulate.a = OPACITY if _ai != null: _role.text = role_line(_ai.tally) _pick.text = _ai.last_pick_note var t := float(_time_fn.call()) if _time_fn.is_valid() else 0.0 _ghost.value = minf(t, _ghost.max_value) var em := float(_job.get("elite_mean_s", 0.0)) _time.text = "%d s vs elite %d s" % [roundi(t), roundi(em)] if em > 0.0 else "%d s (cell empty)" % roundi(t) if _verdict_left > 0.0: _verdict_left -= delta if _verdict_left <= 0.0: _verdict.visible = false _place()
func _place() -> void: var vs := get_viewport_rect().size var ins := Platform.safe_insets() if Platform.has_method("safe_insets") else Vector4.ZERO custom_minimum_size.x = 0.0 size.x = minf(size.x, vs.x * MAX_W_FRAC) custom_minimum_size.x = minf(320.0, vs.x * MAX_W_FRAC) var right := CORNER == 1 or CORNER == 3 var bottom := CORNER >= 2 position.x = (vs.x - size.x - MARGIN - ins.z) if right else (MARGIN + ins.x) position.y = (vs.y - size.y - MARGIN - ins.w) if bottom else (MARGIN + ins.y)Implementer: check the real safe-insets accessor on Platform (grep static func in its script) and use it directly instead of the has_method guard. Style the panel with the project’s glass/token theme the way ui/patch_toast.gd does: copy its StyleBox/TokenBind setup, not new literals, because the literal ratchet test will flag raw colours. Use ui-tokens.md font sizes instead of 14 if a token exists.
Tunables: add const PERSONA_OVERLAY := "res://ui/persona_overlay.gd" and these rows:
{"key": "PersonaOverlay.CORNER", "script": PERSONA_OVERLAY, "name": "CORNER", "min": 0, "max": 3, "local": true}, {"key": "PersonaOverlay.OPACITY", "script": PERSONA_OVERLAY, "name": "OPACITY", "min": 0.2, "max": 1.0, "local": true}, {"key": "PersonaOverlay.VERDICT_S", "script": PERSONA_OVERLAY, "name": "VERDICT_S", "min": 1.0, "max": 15.0, "local": true},tools/tunable_docs/docs.d/persona_overlay.json:
{ "PersonaOverlay.CORNER": {"label": "Learning panel: corner", "desc": "Dev builds: where the learning-autopilot panel sits. 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right. Live.", "unit": "", "src": "code"}, "PersonaOverlay.OPACITY": {"label": "Learning panel: opacity", "desc": "How see-through the learning-autopilot panel is. Live.", "unit": "", "src": "code"}, "PersonaOverlay.VERDICT_S": {"label": "Learning panel: verdict time", "desc": "How long the run's verdict (candidate / PROMOTED) stays on screen. Live.", "unit": "s", "src": "code"}}Regenerate control/src/tunable_docs.js.
main.gd: at the end of the Task 6 step-4 block (inside if _auto and _learning_this_run:), add:
persona_overlay = PersonaOverlay.new() auto_layer.add_child(persona_overlay) # auto_layer is rebuilt per run (main.gd ~1725), so no stale panel persona_overlay.bind(pap, _persona_job, persona_client.archive(), func() -> float: return sim.run_time if sim != null else 0.0)On human take-over (Task 6 step 5), add if persona_overlay != null and is_instance_valid(persona_overlay): persona_overlay.queue_free(). Replace the _on_persona_result stub:
func _on_persona_result(resp: Dictionary) -> void: if persona_overlay != null and is_instance_valid(persona_overlay): persona_overlay.show_verdict(resp)If auto_layer is created after the step-4 insertion point, move this overlay block to just after auto_layer is added (:1725-1757), still guarded by not _persona_job.is_empty().
- Step 4: Run
scripts/gate.sh. Expected: PASS, including the literal-ratchet and tunable-docs tests. Then do a windowed eyeball on the Mac:godot --path .with a Dev flag file that haslearning_autopiloton, start Survival and leave it idle. Confirm the panel appears and updates, thengit checkout -- project.godot audio/bus_layout.tres. - Step 5: Commit (message “feat(persona): on-device learning overlay”).
Task 8: Remote Console “Personas” tab
Section titled “Task 8: Remote Console “Personas” tab”Files:
- Modify:
control/src/panel/markup.js(pane),control/src/panel/client.js(TABS entry + loader/render + actions) - Test:
control/test/panel-personas.test.mjs
Interfaces:
-
Consumes:
GET /personas,POST /personas/epoch,POST /personas/pin(Task 4). Learning on/off is the existingdev_flagcommand{kind:'dev_flag', field:'learning_autopilot', on}, already on the Settings tab via parity, so it isn’t duplicated here. -
Produces: a pane
#tab-personaswith#personas-grid,#personas-detail,#personas-chart,#personas-feed, and#personas-epochbutton. -
Step 1: Write the failing test
control/test/panel-personas.test.mjs
import { test } from 'node:test';import assert from 'node:assert/strict';import { panelHtml } from '../src/panel.html.js';
test('the Personas tab is registered with its pane and data hooks', () => { const html = panelHtml(); assert.match(html, /\['personas','Personas',/); for (const id of ['tab-personas', 'personas-grid', 'personas-detail', 'personas-chart', 'personas-feed', 'personas-epoch']) { assert.ok(html.includes('id="' + id + '"'), id); } assert.ok(html.includes("fetch('/personas?key='"), 'reads the archive from the control worker (CSP self)'); assert.ok(html.includes("'/personas/pin'") && html.includes("'/personas/epoch'"));});-
Step 2: Run
node --test control/test/panel-personas.test.mjs. Expected: FAIL. -
Step 3: Implement. In
client.jsTABS(line ~204), insert['personas','Personas','◎'],before['settings',…]. Inmarkup.js, after thetab-perfpane:
<div class="pane" id="tab-personas"> <h2>Personas</h2> <div class="card"> <h3>Archive</h3> <p class="hint">Learning autopilot: the best persona per role x skill. Click a cell for its genome and lineage. Score = mean survival over its samples (3 needed to hold a cell).</p> <div id="personas-grid"></div> <div id="personas-detail" class="hint"></div> </div> <div class="card"><h3>Learning curve</h3><div id="personas-chart"></div></div> <div class="card"><h3>Latest runs</h3><div id="personas-feed"></div></div> <div class="card"><h3>Epoch</h3><p class="hint">Start a new epoch after a balance change: old elites become candidates and must re-earn their cells.</p> <button id="personas-epoch" onclick="personasNewEpoch()">New epoch</button></div></div>In client.js, append the code below. No template literals, no backslashes, no ${: use string concatenation, and use textContent for any game-written strings.
// ── Personas (learning autopilot, 2026-10-11) ───────────────────const P_ROLES = ['tank','support','damage','utility'];const P_SKILLS = ['weak','median','strong'];let _pArch = null;async function personasLoad(){ try { const r = await fetch('/personas?key='+encodeURIComponent(KEY)); if(!r.ok) return; _pArch = await r.json(); personasRender(); } catch(e) {}}function personasRender(){ const a = _pArch; if(!a) return; const by = {}; for(const e of a.elites) by[e.cell] = e; const grid = document.getElementById('personas-grid'); grid.innerHTML=''; const t = document.createElement('table'); const hr = document.createElement('tr'); hr.appendChild(document.createElement('th')); for(const s of P_SKILLS){ const th=document.createElement('th'); th.textContent=s; hr.appendChild(th); } t.appendChild(hr); for(const r of P_ROLES){ const tr = document.createElement('tr'); const th=document.createElement('th'); th.textContent=r; tr.appendChild(th); for(const s of P_SKILLS){ const td = document.createElement('td'); const e = by[r+':'+s]; td.textContent = e ? (e.name+' · '+Math.round(e.mean_s)+'s · n'+e.samples) : '—'; if(e){ td.style.cursor='pointer'; td.onclick = function(){ personasDetail(e); }; } tr.appendChild(td); } t.appendChild(tr); } grid.appendChild(t); personasChart(a.history); const feed = document.getElementById('personas-feed'); feed.innerHTML=''; for(const s of a.recent.slice(0,20)){ const d = document.createElement('div'); d.textContent = new Date(s.ts).toLocaleTimeString()+' '+s.finder+' '+s.job+' '+s.target_cell+' -> '+s.cell+' '+Math.round(s.survival_s)+'s (gen '+s.generation+')'; feed.appendChild(d); }}function personasDetail(e){ const box = document.getElementById('personas-detail'); box.innerHTML=''; const pre = document.createElement('pre'); pre.textContent = e.name+' id '+e.persona_id+' parent '+(e.parent_id||'-')+' gen '+e.generation+' found by '+e.finder+' on build '+e.build+'\n'+JSON.stringify(e.genome, null, 1); box.appendChild(pre); const b = document.createElement('button'); b.textContent = 'Fly this next (pin)'; b.onclick = async function(){ await fetch('/personas/pin',{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify({key:KEY,genome:e.genome,cell:e.cell})}); b.textContent='Pinned: the next learning run flies it'; }; box.appendChild(b);}function personasChart(history){ const el = document.getElementById('personas-chart'); el.innerHTML=''; if(!history.length){ el.textContent='No promotions yet this epoch.'; return; } const W=600, H=160, t0=history[0].ts, t1=Math.max(history[history.length-1].ts, t0+1); let maxS=1; for(const h of history) maxS=Math.max(maxS,h.mean_s); const ns='http://www.w3.org/2000/svg'; const svg=document.createElementNS(ns,'svg'); svg.setAttribute('viewBox','0 0 '+W+' '+H); svg.setAttribute('width','100%'); const byCell={}; for(const h of history){ (byCell[h.cell]=byCell[h.cell]||[]).push(h); } for(const cell in byCell){ const pts = byCell[cell].map(function(h){ return ((h.ts-t0)/(t1-t0)*(W-20)+10).toFixed(1)+','+(H-10-(h.mean_s/maxS)*(H-20)).toFixed(1); }).join(' '); const pl=document.createElementNS(ns,'polyline'); pl.setAttribute('points',pts); pl.setAttribute('fill','none'); pl.setAttribute('stroke','currentColor'); pl.setAttribute('stroke-opacity','0.7'); const tt=document.createElementNS(ns,'title'); tt.textContent=cell; pl.appendChild(tt); svg.appendChild(pl); } el.appendChild(svg);}async function personasNewEpoch(){ if(!confirm('Start a new epoch? Current elites become candidates.')) return; await fetch('/personas/epoch',{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify({key:KEY,note:'console'})}); personasLoad();}window.personasNewEpoch = personasNewEpoch;The '\n' in personasDetail is a backslash, which the panel-modules test forbids. Use String.fromCharCode(10) instead. Finally, hook loading: wherever tab switching calls a per-tab loader (grep function showTab or the TABS click handler in client.js), call personasLoad() when id === 'personas', and refresh every 15 s while that tab is visible, using the existing interval pattern in that file.
- Step 4: Run
node --test control/test/*.test.mjs. Expected: PASS, includingpanel-modules.test.mjs. - Step 5: Commit (message “feat(control): Personas console tab”).
Task 9: Docs, release steps, end-to-end check
Section titled “Task 9: Docs, release steps, end-to-end check”Files:
-
Create:
docs/architecture/learning-autopilot.md(withtitle:frontmatter) -
Modify:
CLAUDE.md(one row in the subsystem table),docs/superpowers/specs/2026-10-11-learning-autopilot-design.md(Status → Implemented) -
Step 1: Write
docs/architecture/learning-autopilot.md. Cover:- the file map from this plan
- the archive JSON shape
- that promotion is Worker-side (
PROMOTE_MIN_SAMPLES) - the fixed learning conditions, and that a human take-over cancels the run
- how to run
scripts/personas-evolve.sh new-epochafter balance changes- that the default genome == today’s AI (pinned by
tests/test_persona_auto_player.gd) - that
finderis a device kind only
Add to the
CLAUDE.mdsubsystem table:
| The learning autopilot (personas, MAP-Elites archive, `scripts/personas-evolve.sh`, the overlay + console Personas tab) | `docs/architecture/learning-autopilot.md` |-
Step 2:
scripts/gate.sh. Expected: PASS. -
Step 3: Merge. Follow
docs/reference/builds-and-releases.mdand skillbh-merge-reviewed-branch, after abh-reviewerMERGE verdict. -
Step 4: Release (needs Chris’s go-ahead; outward-facing).
- Apply
control/migrations/0003_personas.sqlremote. - Deploy the control Worker using the command in
control/README.md. - Smoke it:
curl -s "$API/personas?key=$BULLET_HEAVEN_CONTROL_KEY"returns{"ok":true,"epoch":1,…}.
- Apply
-
Step 5: End-to-end.
- Run
scripts/personas-evolve.sh --hours 0.25 --jobs 4 --children 5. Expected: at least 3 cells filled in GET/personas(20 children × 5 seeds). - Then do a Dev install on one device (
bh-deploy/bh-worktree-device-install) and leave Survival in attract mode. Expected: the overlay shows the persona; after death, a verdict appears and the run appears in the console Personas “Latest runs” feed with the device’s finder kind.
Report both results to Chris with screenshots.
- Run
Execution handoff
Section titled “Execution handoff”Written 2026-10-11 at the end of the planning session, for an executor with no conversation context.
Chris’s decisions (brainstorm, 2026-10-11):
- He asked “is the autopilot storing run builds and trying out different options and evolving from the results”. The answer was no: it is a fixed heuristic, and
ExperimentalUpgradePlayeris only one-off soak comparisons. He then said “lets build it, I want to be able to watch the autopilot trying out different things and learning when I use it to test the game”. - On scope he said “everything”: the same work will later produce AI-type players the real player meets at points in the game, and will feed the planned class roles. That was split into sub-projects. This plan is A + B + C only. D (personas as in-game AI players or co-op pilots) and E (class roles, which first need the undesigned “what is a class” brainstorm) are later, separate specs.
- Chose: a shared archive with both learners (soak plus Dev devices); niches role × skill (12 cells) scored on survival with gold as tiebreak; Dev builds only.
- Approved approach 1 (the brain in the game, the Worker only stores) and every design section as presented.
Plan-time rulings (in spec §8, repeated here): the archive lives in the control Worker, not the telemetry Worker; there is no RETENTION_TABLES entry; promotion happens in the Worker; personas always fly humanized and throttled; card scores are normalised; a human take-over cancels the run.
Deliberately dropped: the spec §5 mini 4×3 grid that “flashes” on the device overlay. The verdict line names the cell instead, and the full grid lives on the console tab. Add it later if Chris asks.
Authorised vs. needs a same-day OK:
- Authorised: implementing on a worktree branch, gating, and merging to
mainafter abh-reviewerMERGE verdict, then pushing. - Needs Chris’s OK that day: applying the remote D1 migration
0003_personas.sql, deploying the control Worker, any device install, and runningpersonas-evolve.shagainst the live Worker.
Already done: spec committed (f51c5797, plus §8 amendments in this commit); this plan committed. No code, deploys, or other-repo changes.
Gotchas found while planning:
- Panel modules forbid
${, backslashes and backticks. SeededRnghas norandfn, so the genome usesRandomNumberGenerator.HZ_KITE_WEIGHTandHZ_OFFENSIVE_WARP_GAP_Sonly act whenhumanizeis true.ArchetypeTags.for_cardneeds the card’supgrade_effectsdict (crystal cards).- In
_new_run,MetaRunSetup.applyruns before the attract AI is created, which is why the learning decision is made at the meta block. - Don’t name a variable
seed: it shadows the globalseed()(warnings-as-errors). - The existing
CONTROL_KEYis committed innet/control_client.gd(pre-existing; out of scope; don’t copy it anywhere new).
Open questions for Chris: none blocking.
Next action: read bh-sdd-concurrent, create a worktree off origin/main, and dispatch Task 1 (model: "opus"), subagent-driven.
Pre-flight rulings (2026-10-11, execution session)
Section titled “Pre-flight rulings (2026-10-11, execution session)”A pre-flight scan against live origin/main (a04577f0) found these defects. Each ruling overrides the task text it names. Implementers: apply the rows for your task.
| # | Task | Plan line | Ruling |
|---|---|---|---|
| F1 | 2 | ~388 | Sim.tick takes an Array of inputs; single-pilot tests call sim.tick_single(ia). |
| F2 | 2, 5 | ~366, ~1221 | SoakRun.execute() reads cfg.content. Tests pass SimContentFixture.db(); EvolveRunner loads data/bible.json once and reuses it. |
| F3 | 7 | ~1801 | Create the overlay AFTER auto_layer is declared (main.gd ~1725), next to patch_toast. |
| F4 | 6 | ~1524 | Add persona_client to _sweep_run_children()’s keep-list (main.gd ~834) so it survives new runs. |
| F5 | 6, 7 | ~1555, ~1702, ~1743 | Register LiveScope entries for every new tunable: MUTATE_SHARE, CANDIDATE_SHARE = next-run; overlay OPACITY, CORNER = live. tests/test_live_scope* must stay green. |
| F6 | 2 | ~594 | Before refactoring the 8 consts, record a skilled-autopilot soak checksum (fixed seeds, SoakRun with SkilledAutoPlayer, humanize on and off) on the pre-change code; add a test asserting the same values after. This is the “learning off = byte for byte” proof. |
| F7 | 7 | ~1769 | Platform.safe_insets does not exist: use Platform.safe_insets_units(vp); the fit test checks the panel stays inside the safe rect at every ShopLayoutFixture profile. |
| F8 | 5 | ~1259, ~1986 | --hours accepts fractions: convert to whole seconds (int(hours*3600)), loop on seconds. |
| F9 | 4 | ~1045 | Promotion upsert breaks survival ties on gold (spec: survival score, gold tiebreak). Add a test. |
| F10 | 4 | ~1028 | Worker rejects a sample whose genome.v differs from the supported version with 400. Add a test. |
| F11 | spec | ~1429 | finder = OS.get_name().to_lower(), so iPhone and iPad both report ios. Accepted; Task 9 amends spec §8 to say so. |
| F12 | 7, 8 | — | No hard-coded “/3”: read min_samples from the archive response. |
| F13 | 4 | — | The local D1 stand-in serialises statements, so the “concurrent” test cannot fail: rename it to what it proves and add a direct test of the single-statement upsert’s conditional. |
| F14 | 5 | — | No SceneTree tool here has a class_name: make the plan’s fallback (EvolveBatch as a RefCounted class driven by a thin SceneTree script) the primary. |
| F15 | 6 | — | Device learning skips (no fetch-job, no post) when DevFlags spawn overrides or hard tuning are active; each sample records the active remote-config patch id (or ""). |
| F16 | 6 | — | A human take-over cancels the learning run and nothing posts; the rest of that run keeps the fresh save + default ship. Accepted (Dev-only); documented in Task 9. |
| F17 | 6 | — | PersonaClient also refreshes the archive at boot, not only after each run. |
| F18 | 8 | ~1950 | Add an if (id === 'personas') loader hook in showTab and a timed re-poll while the tab is visible. |
| F19 | 8 | ~1917 | No '\n' in panel modules: use String.fromCharCode(10). |
| F20 | 2 | ~348, ~573, ~585 | Soak line ranges are stale: anchor by text. |
| F21 | 7 | ~1694 | No font-size literal: use a ui-tokens font token via TokenBind, as patch_toast does. |
| F22 | 7, 9 | ~1427, ~1819 | live() is false in the editor, so the Mac eyeball run checks the panel only, not fetch/post. |
| F24 | 3 | ~662 | Restore modified static tunables in after_each, not inline. |