polats Claude Opus 4.8 (1M context) commited on
Commit
cedb3f2
·
1 Parent(s): 5b530db

Skill forge: usable immediately, character-in-skill art painted in background

Browse files

Forging a skill no longer blocks on image generation. The coding model
defines the skill, it's persisted + learned at once, and a single scene of
THE CHARACTER using the skill is painted in the background (one image now,
used as both icon and detail art — no separate icon + action art).

- skillForge.js: persist with iconPending=true and return immediately;
generateSkillImage() runs fire-and-forget, passing the hero's portrait to
Klein as an identity ref so the actual character appears in the scene.
Status names the coding model (like image/voice backends do).
- app.py: /portrait accepts ref_image; both the sidecar path
(_remote_klein_portrait) and in-process ZeroGPU path (_klein_portrait, via
new _decode_ref_image) feed it as FLUX.2 identity conditioning.
- imagen.js / imagenServer.js: thread refImage through to local + Klein engines.
- tiny.js / skillForgePanel.js: gold spinner loading indicator while an icon
is still painting; swaps to the art when iconPending clears (onRosterChange).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

app.py CHANGED
@@ -823,34 +823,81 @@ def _load_klein_pipe():
823
  return Flux2KleinPipeline.from_pretrained(_KLEIN_MODEL_ID, torch_dtype=torch.bfloat16)
824
 
825
 
826
- def _remote_klein_portrait(prompt, seed=None):
827
  import os as _os
 
 
828
  from gradio_client import Client
829
  client = Client(_KLEIN_SPACE, token=HF_TOKEN or None)
830
- result = client.predict(prompt, int(seed if seed is not None else 42), api_name="/generate")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  path = result[0] if isinstance(result, (tuple, list)) else result
832
  with open(_os.fspath(path), "rb") as f:
833
  return f.read()
834
 
835
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836
  @GPU(duration=60)
837
- def _klein_portrait(prompt, seed=None, width=1024, height=1024):
838
- """FLUX.2 [klein] 4B for ZeroGPU-backed portrait generation."""
 
 
839
  global _klein_pipe
840
  import io
841
  import random
842
  import torch
 
843
  with _klein_lock:
844
  if _klein_pipe is None:
845
  _klein_pipe = _load_klein_pipe()
846
  dev = "cuda" if torch.cuda.is_available() else "cpu"
847
  _klein_pipe.to(dev)
848
  s = int(seed if seed is not None else random.randint(0, 2_147_483_647))
849
- img = _klein_pipe(
850
  prompt=prompt, width=width, height=height,
851
  num_inference_steps=_KLEIN_STEPS, guidance_scale=_KLEIN_GUIDANCE,
852
  generator=torch.Generator(device=dev).manual_seed(s),
853
- ).images[0]
 
 
 
854
  if dev == "cuda":
855
  _klein_pipe.to("cpu")
856
  try:
@@ -925,6 +972,8 @@ async def portrait(request: Request):
925
  seed = body.get("seed")
926
  provider = body.get("provider") or "" # cloud sub-provider hint (e.g. flux-dev)
927
  engine = (body.get("engine") or "").strip().lower() # 'local' | 'klein' | 'cloud' | '' = auto
 
 
928
  if not prompt:
929
  return Response("prompt required", status_code=400)
930
  want_local = engine == "local" or (not engine and IMAGE_MODE == "local")
@@ -944,9 +993,9 @@ async def portrait(request: Request):
944
  if want_klein:
945
  try:
946
  if _KLEIN_SPACE:
947
- png = await asyncio.to_thread(_remote_klein_portrait, prompt, seed)
948
  else:
949
- png = await asyncio.to_thread(_klein_portrait, prompt, seed)
950
  except Exception as e: # noqa: BLE001
951
  return Response(f"klein image error: {e}", status_code=500)
952
  return Response(png, media_type="image/png", headers={"Cache-Control": "no-store"})
 
823
  return Flux2KleinPipeline.from_pretrained(_KLEIN_MODEL_ID, torch_dtype=torch.bfloat16)
824
 
825
 
826
+ def _remote_klein_portrait(prompt, seed=None, ref_image=None):
827
  import os as _os
828
+ import base64 as _b64
829
+ import tempfile as _tmp
830
  from gradio_client import Client
831
  client = Client(_KLEIN_SPACE, token=HF_TOKEN or None)
832
+ s = int(seed if seed is not None else 42)
833
+ ref_path = None
834
+ if ref_image:
835
+ try:
836
+ raw = ref_image.split(",", 1)[1] if "," in ref_image else ref_image # strip a data: URL prefix
837
+ fd, ref_path = _tmp.mkstemp(suffix=".png")
838
+ with _os.fdopen(fd, "wb") as f:
839
+ f.write(_b64.b64decode(raw))
840
+ except Exception: # noqa: BLE001 — a bad ref just falls back to text-only
841
+ ref_path = None
842
+ try:
843
+ if ref_path:
844
+ from gradio_client import handle_file
845
+ # generate(prompt, seed, lora, ref_image[pose], ref_image2[identity]). No LoRA so the
846
+ # identity comes from the reference; pass the portrait as the identity ref.
847
+ result = client.predict(prompt, s, "", None, handle_file(ref_path), api_name="/generate")
848
+ else:
849
+ result = client.predict(prompt, s, api_name="/generate")
850
+ except Exception: # noqa: BLE001 — remote may not accept the extra args; retry text-only
851
+ result = client.predict(prompt, s, api_name="/generate")
852
+ finally:
853
+ if ref_path:
854
+ try:
855
+ _os.unlink(ref_path)
856
+ except Exception: # noqa: BLE001
857
+ pass
858
  path = result[0] if isinstance(result, (tuple, list)) else result
859
  with open(_os.fspath(path), "rb") as f:
860
  return f.read()
861
 
862
 
863
+ def _decode_ref_image(ref_image):
864
+ """base64 PNG / data-URL string -> PIL.Image (RGB), or None. Used as a FLUX.2 identity ref."""
865
+ if not ref_image:
866
+ return None
867
+ try:
868
+ import io
869
+ import base64 as _b64
870
+ from PIL import Image
871
+ raw = ref_image.split(",", 1)[1] if "," in ref_image else ref_image # strip a data: URL prefix
872
+ return Image.open(io.BytesIO(_b64.b64decode(raw))).convert("RGB")
873
+ except Exception: # noqa: BLE001 — a bad ref just falls back to text-only
874
+ return None
875
+
876
+
877
  @GPU(duration=60)
878
+ def _klein_portrait(prompt, seed=None, width=1024, height=1024, ref_image=None):
879
+ """FLUX.2 [klein] 4B for ZeroGPU-backed portrait generation. `ref_image` (base64) is an identity
880
+ reference (e.g. a hero's portrait) so a generated scene depicts THAT character — native multi-image
881
+ conditioning, same as the klein-zerogpu sidecar's generate()."""
882
  global _klein_pipe
883
  import io
884
  import random
885
  import torch
886
+ ref = _decode_ref_image(ref_image)
887
  with _klein_lock:
888
  if _klein_pipe is None:
889
  _klein_pipe = _load_klein_pipe()
890
  dev = "cuda" if torch.cuda.is_available() else "cpu"
891
  _klein_pipe.to(dev)
892
  s = int(seed if seed is not None else random.randint(0, 2_147_483_647))
893
+ kwargs = dict(
894
  prompt=prompt, width=width, height=height,
895
  num_inference_steps=_KLEIN_STEPS, guidance_scale=_KLEIN_GUIDANCE,
896
  generator=torch.Generator(device=dev).manual_seed(s),
897
+ )
898
+ if ref is not None:
899
+ kwargs["image"] = ref
900
+ img = _klein_pipe(**kwargs).images[0]
901
  if dev == "cuda":
902
  _klein_pipe.to("cpu")
903
  try:
 
972
  seed = body.get("seed")
973
  provider = body.get("provider") or "" # cloud sub-provider hint (e.g. flux-dev)
974
  engine = (body.get("engine") or "").strip().lower() # 'local' | 'klein' | 'cloud' | '' = auto
975
+ ref_image = body.get("ref_image") # optional base64 PNG — an identity reference for Klein (e.g. the
976
+ # character's portrait), so a generated skill image shows THAT character.
977
  if not prompt:
978
  return Response("prompt required", status_code=400)
979
  want_local = engine == "local" or (not engine and IMAGE_MODE == "local")
 
993
  if want_klein:
994
  try:
995
  if _KLEIN_SPACE:
996
+ png = await asyncio.to_thread(_remote_klein_portrait, prompt, seed, ref_image)
997
  else:
998
+ png = await asyncio.to_thread(_klein_portrait, prompt, seed, 1024, 1024, ref_image)
999
  except Exception as e: # noqa: BLE001
1000
  return Response(f"klein image error: {e}", status_code=500)
1001
  return Response(png, media_type="image/png", headers={"Cache-Control": "no-store"})
docs/skill-upgrade-plan.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Skill Upgrade Plan — impressive unique VFX + powerful AoE forged skills
2
+
3
+ **Goals (from the ask):**
4
+ 1. Forged skills get **impressive graphics when used** in combat (today they look plain).
5
+ 2. The **coding model makes each skill visually unique** (not just mechanically).
6
+ 3. **Most skills are powerful and hit all units in their range** (AoE).
7
+
8
+ ## Why forged skills look plain today (grounded findings)
9
+ - VFX is config-driven: `combatRenderer.buildSkillPlay(id, def)` reads `def.skillFx[skillId] =
10
+ { animUrl, effects:[{url,cell}] }`. **Canon** skills get this from the sandbox's skill configs;
11
+ **forged** skills (id 9000+) have **no `skillFx` entry**, so on `cast` the renderer falls through
12
+ to `playAttack()` — a **basic sword swing** — and `spawnEffects(undefined)` is a no-op.
13
+ - AoE is **target-centric**: `resolveScope()` supports `self/party/nearby/area/target_and_adjacent/
14
+ adjacent_to_target`, but `area`/`nearby` = **target + units within `ADJACENT_GW=140` of the
15
+ TARGET**. There is **no "all enemies within the caster's range" scope.** Forged skills also don't
16
+ set any `scope`, so each effect hits the single auto-target.
17
+ - Asset palette is rich: `web/assets/effects.json` (228 entries) has element sheets — **fire, ice,
18
+ nature, poison, shock, petrification**, plus `attack` arcs (slash/pierce/shot) and `hit` pops —
19
+ all minifantasy strips with `{cell, rows, cols}`. Great raw material for element-themed VFX.
20
+ - The Game wires VFX per actor: `comboBattler.spawnHero` builds `defsById.P0 = {name, profession,
21
+ ...sheets}` and passes it to `createCombatRenderer`. **Adding `defsById.P0.skillFx` is the hook**
22
+ for the hero's forged-skill VFX. `spawnEffects` positions effects at the **caster's body centre**.
23
+
24
+ ---
25
+
26
+ ## Phase A — Visual identity in the forge IR (model makes each unique)
27
+
28
+ Extend the Skill-Forge output so the coding model emits a **`visual` block** alongside the mechanics.
29
+ Cosmetic, so a bad value just falls back (never crashes the resolver).
30
+
31
+ **New IR fields** (`skillSchema.js` → `validateSkill`):
32
+ ```js
33
+ visual: {
34
+ element: 'fire'|'ice'|'lightning'|'nature'|'poison'|'shadow'|'holy'|'arcane', // → tint + sprite sheet
35
+ shape: 'slash'|'nova'|'ring'|'beam'|'pillar'|'projectile'|'shockwave', // procedural motion
36
+ color: '#rrggbb', // accent (validated hex)
37
+ intensity: 1..3, // particle count / scale / flash
38
+ }
39
+ ```
40
+ All whitelisted/clamped. `element` maps to an `effects.json` sheet; `shape`+`color`+`intensity` drive
41
+ a procedural layer (Phase B). The model picks them to match the flavor → **every skill is visually
42
+ distinct** (element × shape × color × intensity is a huge space), and consistent with its name/lore.
43
+
44
+ **Prompt (`FORGE_SYSTEM`)** gains: "Also choose a `visual` that fits the skill's theme: an element,
45
+ a shape (how it manifests), an accent color, and intensity 1-3." Keep the JSON-only contract.
46
+
47
+ ## Phase B — Render impressive, unique VFX
48
+
49
+ Two composited layers, wired by giving the hero's forged skills a `skillFx` entry at spawn.
50
+
51
+ **B1 — Element sprite layer (reuse existing pipeline).**
52
+ - Build `ELEMENT_FX` map: element → an `effects.json` URL (fire→fire sheet, lightning→shock, etc.).
53
+ - In `comboBattler.spawnHero`, construct `defsById.P0.skillFx` from the equipped forged skills:
54
+ `skillFx[skill.id] = { animUrl: null, effects: [ ELEMENT_FX[skill.visual.element] ] }`.
55
+ `buildSkillPlay` already loads these; `processLog` 'cast' already calls `spawnEffects`. So forged
56
+ skills immediately stop looking like a basic swing.
57
+
58
+ **B2 — Procedural Pixi VFX layer (new `src/render/skillVfx.js`).**
59
+ A small, asset-free effect generator drawn into the combat root (world space), parameterised by
60
+ `visual`: an **expanding tinted ring/nova** (shape `nova/ring/shockwave`, radius = the skill's AoE
61
+ range → reads as "everything in here is hit"), a **beam/line** to the farthest target, a **pillar**,
62
+ or a **projectile** burst. Color = `visual.color`; particle count/scale/duration scale with
63
+ `intensity`. Big skills also trigger a brief **screen flash + shake** (reuse the existing
64
+ `addShake`/juice hooks). This guarantees uniqueness + scale even when no sprite matches.
65
+
66
+ **B3 — Hit feedback on every struck unit.**
67
+ When an AoE skill resolves, pop a small element `hit` sprite (from `effects.json` `hit` category) +
68
+ the yellow skill-name float on each affected enemy — so the player sees the whole area get hit.
69
+
70
+ **Wiring:** comboBattler's `processLog` 'cast' hook (it already runs per cast) calls
71
+ `skillVfx.play(caster, skill.visual, rangeRadius, targets)`. The renderer change is additive.
72
+
73
+ ## Phase C — Powerful + hit all units in range (AoE)
74
+
75
+ **C1 — New engine scope (`teamBattle.resolveScope`):**
76
+ ```js
77
+ case 'area_around_caster': {
78
+ const r = e.range || DEFAULT_AOE // effect-supplied radius (field units)
79
+ return b.actors.filter(x => x.alive && x.team !== src.team && dist(src, x) <= r)
80
+ }
81
+ ```
82
+ Allow effects to carry a clamped `range`. (Also extend `damage/heal/...` op validators to accept
83
+ `scope` + `range`, not just `apply_condition`.)
84
+
85
+ **C2 — Forge defaults to powerful AoE:**
86
+ - Schema: when the model omits scope, **default most forged skills to `area_around_caster`** (a
87
+ config flag, e.g. attack/spell categories get AoE by default; `self`/`ally` targets don't).
88
+ - Prompt: "Most skills should be POWERFUL and hit ALL foes in range — prefer an area scope and
89
+ meaningful damage/conditions."
90
+ - Numbers: raise the forged damage clamp (these are a hero's signature ultimate-ish skills), with a
91
+ balance ceiling. AoE radius derived from the skill's reach/element (clamped to ~the on-screen
92
+ ranged reach so it's readable, not whole-map).
93
+
94
+ **C3 — Readability tie-in:** the AoE radius from C1 drives the Phase-B nova scale, so the visual
95
+ ring exactly covers the hit area — the player *sees* who's caught.
96
+
97
+ ---
98
+
99
+ ## Files to touch
100
+ | File | Change |
101
+ |---|---|
102
+ | `tiny-army/web/skillSchema.js` | `visual` block + validation; `scope`/`range` on more ops; AoE-by-default; higher damage clamp |
103
+ | `tiny-army/web/skillForge.js` | `FORGE_SYSTEM` prompt: emit `visual` + prefer powerful AoE |
104
+ | `auto-battler/src/engine/teamBattle.js` | `area_around_caster` scope + `range` on effects |
105
+ | `auto-battler/src/render/skillVfx.js` (new) | procedural element VFX (nova/ring/beam/pillar/projectile) |
106
+ | `auto-battler/src/render/comboBattler.js` | build `defsById.P0.skillFx` from forged visuals; call `skillVfx.play` on cast; AoE-scaled nova + flash |
107
+ | `auto-battler/src/render/combatRenderer.js` | (light) expose a per-target hit-effect helper for AoE pops |
108
+
109
+ ## Sequencing
110
+ 1. **C (engine AoE + power)** first — biggest gameplay impact, smallest surface; verify in-engine
111
+ that an `area_around_caster` skill damages all foes in radius.
112
+ 2. **A (visual schema + prompt)** — model emits `visual`; validate; re-forge produces visual params.
113
+ 3. **B1 (element sprites)** — forged skills stop looking plain (quick win, reuses pipeline).
114
+ 4. **B2/B3 (procedural VFX + AoE nova + hit pops)** — the "impressive + unique" payoff.
115
+
116
+ ## Decisions (locked 2026-06-13)
117
+ - **Power/AoE:** signature-strong, **capped** — most forged attacks/spells are AoE + high damage, ceiling so they can't trivially one-shot a Champion boss.
118
+ - **VFX:** **both** element sprites + procedural nova/beam scaled to the AoE.
119
+
120
+ ## Open decisions
121
+ 1. **VFX approach** — element sprites only, procedural only, or **both composited** (recommended: both — sprites give theme, procedural gives unique scale/motion + guaranteed coverage).
122
+ 2. **AoE default** — make *most* forged attacks/spells AoE (recommended), or only when the model opts in.
123
+ 3. **Power level** — how strong? Recommend "signature strong": higher damage + AoE, but capped so a forged skill can't trivially one-shot a Champion boss.
124
+ 4. **Should canon (non-forged) skills also get the new procedural VFX?** Optional follow-up — they already have authored sprites.
web/imagen.js CHANGED
@@ -38,6 +38,9 @@ export async function ensureImage(onProgress) { return eng().ensure(onProgress)
38
 
39
  // Generate a portrait → PNG Blob. `prompt` is the appearance description; `seed` keeps it
40
  // reproducible where the engine supports it.
41
- export async function generatePortrait(prompt, { seed } = {}) {
42
- return eng().generate(prompt, { seed })
 
 
 
43
  }
 
38
 
39
  // Generate a portrait → PNG Blob. `prompt` is the appearance description; `seed` keeps it
40
  // reproducible where the engine supports it.
41
+ // `refImage` (optional, base64 PNG / data URL) is an identity reference passed to Klein, so a
42
+ // generated image can depict a specific character (e.g. a skill scene of the hero). Engines that
43
+ // don't support references (cloud FLUX, in-browser) simply ignore it.
44
+ export async function generatePortrait(prompt, { seed, refImage } = {}) {
45
+ return eng().generate(prompt, { seed, refImage })
46
  }
web/imagenServer.js CHANGED
@@ -28,7 +28,7 @@ export const engineLocal = {
28
  label: 'Z-Image-Turbo · local (your GPU)',
29
  available: () => isLocalhost(),
30
  note: 'run the project locally',
31
- generate: (prompt, { seed } = {}) => postPortrait({ prompt, seed, engine: 'local' }),
32
  backendLabel: () => '🖥 local model',
33
  }
34
 
@@ -37,7 +37,7 @@ export const engineKleinZeroGpu = {
37
  id: 'klein-zerogpu',
38
  label: 'FLUX.2 klein 4B · ZeroGPU',
39
  available: () => true,
40
- generate: (prompt, { seed } = {}) => postPortrait({ prompt, seed, engine: 'klein', provider: 'flux-klein-4b' }),
41
  backendLabel: () => 'ZeroGPU FLUX.2 klein 4B',
42
  }
43
 
 
28
  label: 'Z-Image-Turbo · local (your GPU)',
29
  available: () => isLocalhost(),
30
  note: 'run the project locally',
31
+ generate: (prompt, { seed, refImage } = {}) => postPortrait({ prompt, seed, engine: 'local', ref_image: refImage }),
32
  backendLabel: () => '🖥 local model',
33
  }
34
 
 
37
  id: 'klein-zerogpu',
38
  label: 'FLUX.2 klein 4B · ZeroGPU',
39
  available: () => true,
40
+ generate: (prompt, { seed, refImage } = {}) => postPortrait({ prompt, seed, engine: 'klein', provider: 'flux-klein-4b', ref_image: refImage }),
41
  backendLabel: () => 'ZeroGPU FLUX.2 klein 4B',
42
  }
43
 
web/skillForge.js CHANGED
@@ -3,13 +3,12 @@
3
  // validates/clamps it into an engine-runnable CB skill (skillSchema.js), paints a Klein icon,
4
  // and persists it onto the hero (customSkills + learnedSkills + event). See skillForgePanel.js
5
  // and tiny.js (character sheet).
6
- import { streamCoding } from '/web/codingModel.js'
7
  import { stripThinkFinal } from '/web/personaPrompts.js'
8
  import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js'
9
  import { generatePortrait } from '/web/imagen.js'
10
- import { getPersona, patchPersona, putSkillIcon } from '/web/personaStore.js'
11
  import { appendEvent } from '/web/progression.js'
12
- import { withElapsed } from '/web/elapsed.js'
13
 
14
  export const FORGE_SYSTEM = [
15
  'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a',
@@ -60,40 +59,57 @@ async function generateOnce(p, ask, { think = false, onToken, extra = '' } = {})
60
  return { v: validateSkill(parseSkillJson(clean), { persona: p }), raw, clean }
61
  }
62
 
63
- export function iconPrompt(p, skill) {
64
- return `fantasy game skill icon, "${skill.name}": ${skill.flavor}. ${p.unitClass || ''} ability, dramatic, centered emblem, painterly, square`
 
 
 
 
 
65
  }
66
 
67
- // Forge a skill for `personaId`, persist it, and return { ok, skill, iconBlob } | { ok:false, error }.
68
- // One validation retry, then a hard fail (the caller decides how to surface it). Icon paint is
69
- // best-effort a skill without art is still learned.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  export async function forgeSkillForHero(personaId, request, { onStatus, onToken, think = false } = {}) {
71
  const p = getPersona(personaId)
72
  if (!p) return { ok: false, error: 'no hero' }
73
  const ask = (request || '').trim()
74
  if (!ask) return { ok: false, error: 'describe the skill you want' }
75
 
76
- onStatus && onStatus('forging…')
77
  let { v } = await generateOnce(p, ask, { think, onToken })
78
  if (!v.ok) {
79
- onStatus && onStatus('refining (invalid, retrying)…')
80
  ;({ v } = await generateOnce(p, ask, { think, onToken, extra: `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.` }))
81
  }
82
  if (!v.ok) return { ok: false, error: v.errors.join('; ') }
83
 
84
- const skill = v.skill
85
- let iconBlob = null
86
- try {
87
- iconBlob = await withElapsed((t) => onStatus && onStatus(t), 'painting icon…', generatePortrait(iconPrompt(p, skill), { seed: skill.id }))
88
- if (iconBlob) await putSkillIcon(skill.id, iconBlob)
89
- } catch (e) { console.warn('[tinyarmy:skill] icon generation failed (skill still learned):', (e && e.message) || e) } // logged via diag; art is optional
90
-
91
  const fresh = getPersona(p.id) || p
92
  patchPersona(p.id, {
93
  customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
94
  learnedSkills: [...(fresh.learnedSkills || []), skill.id],
95
  events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }),
96
  })
97
- onStatus && onStatus('done')
98
- return { ok: true, skill, iconBlob }
 
99
  }
 
3
  // validates/clamps it into an engine-runnable CB skill (skillSchema.js), paints a Klein icon,
4
  // and persists it onto the hero (customSkills + learnedSkills + event). See skillForgePanel.js
5
  // and tiny.js (character sheet).
6
+ import { streamCoding, currentCodingModel } from '/web/codingModel.js'
7
  import { stripThinkFinal } from '/web/personaPrompts.js'
8
  import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js'
9
  import { generatePortrait } from '/web/imagen.js'
10
+ import { getPersona, patchPersona, putSkillIcon, getPortrait } from '/web/personaStore.js'
11
  import { appendEvent } from '/web/progression.js'
 
12
 
13
  export const FORGE_SYSTEM = [
14
  'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a',
 
59
  return { v: validateSkill(parseSkillJson(clean), { persona: p }), raw, clean }
60
  }
61
 
62
+ const blobToDataURL = (blob) => new Promise((res) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => res(null); r.readAsDataURL(blob) })
63
+
64
+ // The skill's image: a scene of THE CHARACTER performing the skill. Klein gets the hero's portrait
65
+ // as an identity reference (refImage) so the actual character appears. This single image is the
66
+ // skill's icon (and the detail-sheet art) — no separate icon + art any more.
67
+ export function skillImagePrompt(p, skill) {
68
+ return `${p.name || 'A warrior'}, a ${p.unitClass || 'fighter'}, unleashing the skill "${skill.name}": ${skill.flavor || ''}. dynamic fantasy action scene, the character mid-cast, motion and magical impact, dramatic lighting, painterly`
69
  }
70
 
71
+ // Generate the skill image in the BACKGROUND (the skill is already usable). Stores it as the icon
72
+ // and clears iconPending the UI swaps its spinner for the image (via onRosterChange).
73
+ async function generateSkillImage(personaId, skill) {
74
+ let refImage = null
75
+ try { const portrait = await getPortrait(personaId); if (portrait) refImage = await blobToDataURL(portrait) } catch { /* no portrait → text-only */ }
76
+ try {
77
+ const blob = await generatePortrait(skillImagePrompt(getPersona(personaId) || {}, skill), { seed: skill.id, refImage })
78
+ if (blob) await putSkillIcon(skill.id, blob)
79
+ } catch (e) { console.warn('[tinyarmy:skill] image generation failed (skill still usable):', (e && e.message) || e) }
80
+ finally {
81
+ const cur = getPersona(personaId)
82
+ if (cur && cur.customSkills && cur.customSkills[skill.id]) {
83
+ patchPersona(personaId, { customSkills: { ...cur.customSkills, [skill.id]: { ...cur.customSkills[skill.id], iconPending: false } } })
84
+ }
85
+ }
86
+ }
87
+
88
+ // Forge a skill for `personaId`. The skill is defined by the coding model, persisted IMMEDIATELY
89
+ // (so it's usable at once), and its image is painted in the BACKGROUND. Returns { ok, skill } as
90
+ // soon as the skill is defined; the image arrives later (iconPending → spinner until then).
91
  export async function forgeSkillForHero(personaId, request, { onStatus, onToken, think = false } = {}) {
92
  const p = getPersona(personaId)
93
  if (!p) return { ok: false, error: 'no hero' }
94
  const ask = (request || '').trim()
95
  if (!ask) return { ok: false, error: 'describe the skill you want' }
96
 
97
+ onStatus && onStatus(`forging via ${currentCodingModel().label}`) // name the model (like image/voice)
98
  let { v } = await generateOnce(p, ask, { think, onToken })
99
  if (!v.ok) {
100
+ onStatus && onStatus(`refining via ${currentCodingModel().label} (retrying)…`)
101
  ;({ v } = await generateOnce(p, ask, { think, onToken, extra: `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.` }))
102
  }
103
  if (!v.ok) return { ok: false, error: v.errors.join('; ') }
104
 
105
+ const skill = { ...v.skill, iconPending: true } // image paints in the background
 
 
 
 
 
 
106
  const fresh = getPersona(p.id) || p
107
  patchPersona(p.id, {
108
  customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
109
  learnedSkills: [...(fresh.learnedSkills || []), skill.id],
110
  events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }),
111
  })
112
+ onStatus && onStatus('learned ✓ (painting its scene…)')
113
+ generateSkillImage(p.id, skill) // fire-and-forget don't block use of the skill
114
+ return { ok: true, skill }
115
  }
web/skillForgePanel.js CHANGED
@@ -6,6 +6,17 @@ import { listPersonas, getPersona, onRosterChange, getSkillIcon } from '/web/per
6
  import { effectSummary } from '/web/skillSchema.js'
7
  import { forgeSkillForHero } from '/web/skillForge.js'
8
 
 
 
 
 
 
 
 
 
 
 
 
9
  function el(tag, props = {}, kids = []) {
10
  const n = document.createElement(tag)
11
  for (const [k, v] of Object.entries(props)) {
@@ -51,22 +62,36 @@ export function mountSkillForgePanel(host) {
51
  }
52
  function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` }
53
 
 
54
  async function renderCard(skill) {
 
55
  card.style.display = ''
56
  card.replaceChildren()
57
  const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
58
- const icon = el('div', { style: 'width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' })
59
- getSkillIcon(skill.id).then((b) => { if (b) icon.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
 
 
60
  const meta = el('div', {}, [
61
  el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
62
  el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
63
  ])
64
  head.append(icon, meta)
65
  const flav = el('div', { style: 'color:#c2c8d2;font-style:italic;margin:10px 0 0;line-height:1.45' }, skill.flavor || '')
66
- const saved = el('div', { style: 'color:#34d058;font-size:12px;margin-top:8px' }, '✓ Learned — view it on the hero’s character sheet.')
 
67
  card.append(head, flav, saved)
68
  }
69
 
 
 
 
 
 
 
 
 
 
70
  let running = false
71
  async function forge() {
72
  if (running) return
@@ -87,7 +112,7 @@ export function mountSkillForgePanel(host) {
87
  }
88
 
89
  btn.addEventListener('click', forge)
90
- onRosterChange(refreshHeroes)
91
  onCodingModelChange(refreshStatus)
92
  refreshHeroes(); refreshStatus()
93
  return { refresh: refreshHeroes }
 
6
  import { effectSummary } from '/web/skillSchema.js'
7
  import { forgeSkillForHero } from '/web/skillForge.js'
8
 
9
+ // Loading spinner for a skill whose scene is still painting. Idempotent — tiny.js injects the same
10
+ // keyframes/class; we re-inject (id-guarded) so the Forge tab works even before the game module loads.
11
+ ;(function injectSkillSpin() {
12
+ try {
13
+ if (document.getElementById('ta-skill-spin-css')) return
14
+ const s = document.createElement('style'); s.id = 'ta-skill-spin-css'
15
+ s.textContent = '@keyframes ta-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}.ta-skill-spin{position:absolute;top:50%;left:50%;width:18px;height:18px;border:2px solid #2a3340;border-top-color:#c9a227;border-radius:50%;animation:ta-spin .8s linear infinite}'
16
+ document.head.appendChild(s)
17
+ } catch { /* ignore */ }
18
+ })()
19
+
20
  function el(tag, props = {}, kids = []) {
21
  const n = document.createElement(tag)
22
  for (const [k, v] of Object.entries(props)) {
 
62
  }
63
  function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` }
64
 
65
+ let shownSkillId = null // skill currently in the card — so we can repaint its icon when it finishes
66
  async function renderCard(skill) {
67
+ shownSkillId = skill.id
68
  card.style.display = ''
69
  card.replaceChildren()
70
  const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
71
+ const icon = el('div', { style: 'position:relative;width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' })
72
+ const b = await getSkillIcon(skill.id)
73
+ if (b) icon.style.backgroundImage = `url(${URL.createObjectURL(b)})`
74
+ else if (skill.iconPending) icon.append(el('div', { class: 'ta-skill-spin', title: 'painting its scene…' })) // loading indicator (CSS injected by tiny.js)
75
  const meta = el('div', {}, [
76
  el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
77
  el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
78
  ])
79
  head.append(icon, meta)
80
  const flav = el('div', { style: 'color:#c2c8d2;font-style:italic;margin:10px 0 0;line-height:1.45' }, skill.flavor || '')
81
+ const note = skill.iconPending ? 'painting its scene… (already usable on the character sheet)' : '✓ Learned — view it on the hero’s character sheet.'
82
+ const saved = el('div', { style: 'color:#34d058;font-size:12px;margin-top:8px' }, note)
83
  card.append(head, flav, saved)
84
  }
85
 
86
+ // When the background image finishes, the hero's skill flips iconPending → false (onRosterChange).
87
+ // If that skill is the one in the card, repaint it so the spinner swaps for the finished art.
88
+ function repaintShownCard() {
89
+ if (!shownSkillId || card.style.display === 'none') return
90
+ const owner = listPersonas().find((p) => p.customSkills && p.customSkills[shownSkillId])
91
+ const sk = owner && owner.customSkills[shownSkillId]
92
+ if (sk && !sk.iconPending) renderCard(sk)
93
+ }
94
+
95
  let running = false
96
  async function forge() {
97
  if (running) return
 
112
  }
113
 
114
  btn.addEventListener('click', forge)
115
+ onRosterChange(() => { refreshHeroes(); repaintShownCard() })
116
  onCodingModelChange(refreshStatus)
117
  refreshHeroes(); refreshStatus()
118
  return { refresh: refreshHeroes }
web/tiny.js CHANGED
@@ -17,9 +17,8 @@ import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
  import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId } from '/web/personaStore.js'
19
  import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent, statBonuses } from '/web/progression.js'
20
- import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
22
- import { generatePortrait } from '/web/imagen.js'
23
  import { mountCharacterChat } from '/web/characterChat.js'
24
  import { forgeSkillForHero } from '/web/skillForge.js'
25
  import { mountAfterAction } from '/web/afterAction.js'
@@ -95,6 +94,16 @@ function whenEl(id, cb) {
95
  })
96
  })()
97
 
 
 
 
 
 
 
 
 
 
 
98
  // Gradio wraps each gr.HTML block in a `<div class="prose gradio-style …">` whose versioned rules
99
  // (.gradio-container-X .prose h2 / .prose * ; .gradio-container-X .gradio-style button) outrank the
100
  // shared component CSS — recolouring/resizing our headings and stripping pill/button borders +
@@ -501,7 +510,7 @@ whenEl('battle-stage', async (el) => {
501
  const closeDetail = () => { back.remove(); skillSheet = null }
502
  x.addEventListener('click', closeDetail); sh.append(x)
503
  const art = document.createElement('div'); art.style.cssText = 'width:100%;aspect-ratio:1/1;border-radius:12px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;margin:6px 0 12px;display:flex;align-items:center;justify-content:center;color:#5a6573;font-size:12px'
504
- art.textContent = 'summoning the vision…'; sh.append(art)
505
  const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:700 19px var(--tac-font,system-ui)'; sh.append(nm)
506
  const cat = document.createElement('div'); cat.textContent = `${(sk.category || '').replace('_', ' ')} · ${effectSummary(sk)}`; cat.style.cssText = 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:3px 0 10px'; sh.append(cat)
507
  if (sk.flavor) { const fl = document.createElement('div'); fl.textContent = sk.flavor; fl.style.cssText = 'font-style:italic;color:#c2c8d2;line-height:1.5;margin-bottom:14px'; sh.append(fl) }
@@ -518,16 +527,8 @@ whenEl('battle-stage', async (el) => {
518
  paintEq(); sh.append(eqBtn)
519
  back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
520
  back.append(sh); el.append(back); skillSheet = back
521
- // Action illustration: serve cached, else paint once with Klein and cache.
522
- getSkillArt(sk.id).then(async (b) => {
523
- if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})`; return }
524
- try {
525
- const prompt = `${currentHero.name || 'A fighter'}, a ${currentHero.unitClass || ''}, performing "${sk.name}": ${sk.flavor || ''}. dynamic action scene, motion and impact, fantasy game art, dramatic lighting`
526
- const blob = await generatePortrait(prompt, { seed: sk.id + 1 })
527
- if (blob) { await putSkillArt(sk.id, blob); art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(blob)})` }
528
- else art.textContent = 'no art'
529
- } catch { art.textContent = 'art unavailable' }
530
- })
531
  }
532
  // Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
533
  let chatSheet = null
@@ -598,7 +599,7 @@ whenEl('battle-stage', async (el) => {
598
  const row = document.createElement('button'); row.type = 'button'
599
  row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:9px;border:1px solid ${on ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6);color:#e8e8e8;cursor:pointer`
600
  const ic = document.createElement('div'); ic.style.cssText = 'width:36px;height:36px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none'
601
- getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
602
  const t = document.createElement('div')
603
  const nm = document.createElement('div'); nm.textContent = sk.name + (on ? ' ★' : elsewhere ? ' (in another slot)' : ''); nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
604
  const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
@@ -694,7 +695,7 @@ whenEl('battle-stage', async (el) => {
694
  slot.style.cssText = `flex:1;aspect-ratio:1/1;border-radius:10px;border:1px ${sk ? 'solid #c9a227' : 'dashed #2a3340'};background:#0d1015;background-size:cover;background-position:center;cursor:pointer;position:relative;padding:0;overflow:hidden`
695
  const num = document.createElement('span'); num.textContent = String(i + 1); num.style.cssText = 'position:absolute;top:3px;left:5px;font-size:10px;color:#9aa4b2;text-shadow:0 1px 2px #000;z-index:1'; slot.append(num)
696
  if (sk) {
697
- getSkillIcon(sk.id).then((b) => { if (b) slot.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
698
  const cap = document.createElement('span'); cap.textContent = sk.name; cap.style.cssText = 'position:absolute;left:0;right:0;bottom:0;background:rgba(0,0,0,.62);padding:2px 3px;font-size:9px;color:#e8e8e8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; slot.append(cap)
699
  } else { const plus = document.createElement('span'); plus.textContent = '+'; plus.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;color:#3a4250'; slot.append(plus) }
700
  slot.addEventListener('click', () => openBarPicker(i)); bar.append(slot)
@@ -706,8 +707,8 @@ whenEl('battle-stage', async (el) => {
706
  learned.forEach((sk) => {
707
  const onBar = eq.includes(sk.id)
708
  const row = document.createElement('div'); row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;padding:8px;border:1px solid ${onBar ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6)`
709
- const ic = document.createElement('button'); ic.type = 'button'; ic.title = 'Details'; ic.style.cssText = 'width:40px;height:40px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none;cursor:pointer;padding:0'
710
- getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
711
  ic.addEventListener('click', () => openSkillDetail(sk))
712
  const txt = document.createElement('button'); txt.type = 'button'; txt.style.cssText = 'flex:1;text-align:left;background:none;border:none;color:#e8e8e8;cursor:pointer;padding:0'
713
  const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
@@ -771,7 +772,7 @@ whenEl('battle-stage', async (el) => {
771
  if (sheetOpen) updateHp(comboCtrl.getHero?.())
772
  if (s.player) { if (!moveAnchor) moveAnchor = { x: s.player.x, y: s.player.y }; else if (Math.hypot(s.player.x - moveAnchor.x, s.player.y - moveAnchor.y) > 40) { moveAnchor = { x: s.player.x, y: s.player.y }; try { tut.emit('move') } catch { /* ignore */ } } }
773
  })
774
- onRosterChange(refreshPicker)
775
  // First-run guided tour (skippable, persisted). Kicks off shortly after the Game mounts.
776
  if (tut.shouldAutoStart()) setTimeout(() => tut.startOrResume(), 900)
777
  })
 
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
  import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId } from '/web/personaStore.js'
19
  import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent, statBonuses } from '/web/progression.js'
20
+ import { getSkillIcon } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
 
22
  import { mountCharacterChat } from '/web/characterChat.js'
23
  import { forgeSkillForHero } from '/web/skillForge.js'
24
  import { mountAfterAction } from '/web/afterAction.js'
 
94
  })
95
  })()
96
 
97
+ // Paint a forged skill's icon into a square box, or a spinner if its image is still generating in
98
+ // the background (iconPending). Shared by the bar slots + skill cards.
99
+ ;(function injectSkillSpin() { try { const s = document.createElement('style'); s.textContent = '@keyframes ta-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}.ta-skill-spin{position:absolute;top:50%;left:50%;width:18px;height:18px;border:2px solid #2a3340;border-top-color:#c9a227;border-radius:50%;animation:ta-spin .8s linear infinite}'; document.head.appendChild(s) } catch { /* ignore */ } })()
100
+ function paintSkillIcon(box, sk) {
101
+ getSkillIcon(sk.id).then((b) => {
102
+ if (b) { box.style.backgroundImage = `url(${URL.createObjectURL(b)})` }
103
+ else if (sk.iconPending) { box.style.position = box.style.position || 'relative'; const sp = document.createElement('div'); sp.className = 'ta-skill-spin'; box.appendChild(sp) }
104
+ })
105
+ }
106
+
107
  // Gradio wraps each gr.HTML block in a `<div class="prose gradio-style …">` whose versioned rules
108
  // (.gradio-container-X .prose h2 / .prose * ; .gradio-container-X .gradio-style button) outrank the
109
  // shared component CSS — recolouring/resizing our headings and stripping pill/button borders +
 
510
  const closeDetail = () => { back.remove(); skillSheet = null }
511
  x.addEventListener('click', closeDetail); sh.append(x)
512
  const art = document.createElement('div'); art.style.cssText = 'width:100%;aspect-ratio:1/1;border-radius:12px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;margin:6px 0 12px;display:flex;align-items:center;justify-content:center;color:#5a6573;font-size:12px'
513
+ art.textContent = sk.iconPending ? ' painting the scene…' : ''; sh.append(art)
514
  const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:700 19px var(--tac-font,system-ui)'; sh.append(nm)
515
  const cat = document.createElement('div'); cat.textContent = `${(sk.category || '').replace('_', ' ')} · ${effectSummary(sk)}`; cat.style.cssText = 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:3px 0 10px'; sh.append(cat)
516
  if (sk.flavor) { const fl = document.createElement('div'); fl.textContent = sk.flavor; fl.style.cssText = 'font-style:italic;color:#c2c8d2;line-height:1.5;margin-bottom:14px'; sh.append(fl) }
 
527
  paintEq(); sh.append(eqBtn)
528
  back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
529
  back.append(sh); el.append(back); skillSheet = back
530
+ // The skill's single image (the character performing it) generated in the background by the forge.
531
+ getSkillIcon(sk.id).then((b) => { if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})` } else if (!sk.iconPending) art.textContent = 'no image' })
 
 
 
 
 
 
 
 
532
  }
533
  // Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
534
  let chatSheet = null
 
599
  const row = document.createElement('button'); row.type = 'button'
600
  row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:9px;border:1px solid ${on ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6);color:#e8e8e8;cursor:pointer`
601
  const ic = document.createElement('div'); ic.style.cssText = 'width:36px;height:36px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none'
602
+ paintSkillIcon(ic, sk)
603
  const t = document.createElement('div')
604
  const nm = document.createElement('div'); nm.textContent = sk.name + (on ? ' ★' : elsewhere ? ' (in another slot)' : ''); nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
605
  const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
 
695
  slot.style.cssText = `flex:1;aspect-ratio:1/1;border-radius:10px;border:1px ${sk ? 'solid #c9a227' : 'dashed #2a3340'};background:#0d1015;background-size:cover;background-position:center;cursor:pointer;position:relative;padding:0;overflow:hidden`
696
  const num = document.createElement('span'); num.textContent = String(i + 1); num.style.cssText = 'position:absolute;top:3px;left:5px;font-size:10px;color:#9aa4b2;text-shadow:0 1px 2px #000;z-index:1'; slot.append(num)
697
  if (sk) {
698
+ paintSkillIcon(slot, sk)
699
  const cap = document.createElement('span'); cap.textContent = sk.name; cap.style.cssText = 'position:absolute;left:0;right:0;bottom:0;background:rgba(0,0,0,.62);padding:2px 3px;font-size:9px;color:#e8e8e8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; slot.append(cap)
700
  } else { const plus = document.createElement('span'); plus.textContent = '+'; plus.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;color:#3a4250'; slot.append(plus) }
701
  slot.addEventListener('click', () => openBarPicker(i)); bar.append(slot)
 
707
  learned.forEach((sk) => {
708
  const onBar = eq.includes(sk.id)
709
  const row = document.createElement('div'); row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;padding:8px;border:1px solid ${onBar ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6)`
710
+ const ic = document.createElement('button'); ic.type = 'button'; ic.title = 'Details'; ic.style.cssText = 'position:relative;width:40px;height:40px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none;cursor:pointer;padding:0'
711
+ paintSkillIcon(ic, sk)
712
  ic.addEventListener('click', () => openSkillDetail(sk))
713
  const txt = document.createElement('button'); txt.type = 'button'; txt.style.cssText = 'flex:1;text-align:left;background:none;border:none;color:#e8e8e8;cursor:pointer;padding:0'
714
  const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
 
772
  if (sheetOpen) updateHp(comboCtrl.getHero?.())
773
  if (s.player) { if (!moveAnchor) moveAnchor = { x: s.player.x, y: s.player.y }; else if (Math.hypot(s.player.x - moveAnchor.x, s.player.y - moveAnchor.y) > 40) { moveAnchor = { x: s.player.x, y: s.player.y }; try { tut.emit('move') } catch { /* ignore */ } } }
774
  })
775
+ onRosterChange(() => { refreshPicker(); if (sheetOpen && currentHero?.id) { currentHero = getPersona(currentHero.id) || currentHero; renderSheet() } })
776
  // First-run guided tour (skippable, persisted). Kicks off shortly after the Game mounts.
777
  if (tut.shouldAutoStart()) setTimeout(() => tut.startOrResume(), 900)
778
  })