polats Claude Opus 4.8 (1M context) commited on
Commit
9fe1971
·
1 Parent(s): f09b56f

Phase 2: Skill Forge writes real engine skills + character-page detail screen

Browse files

skillSchema.js validates/clamps model output into an engine-resolvable CB skill
(safe op/condition whitelist, 9000+ ids, effect summaries, equip resolution). The
forge panel now emits that IR, validates with one retry + fallback, paints a Klein
icon, and persists the skill onto the hero (customSkills/learnedSkills). The Game
character sheet lists forged skills as cards; tapping opens a detail bottom-sheet
with a lazily-painted Klein action illustration of the hero performing the skill,
flavor, effects, and equip/unequip (max 3). Equipped skills flow into live combat.
Rebuilt engine/combo/sandbox bundles for the object-or-id skill bar.

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

web/classesSandbox.js CHANGED
@@ -978,7 +978,7 @@ function makeActor(unit, team, id, slot) {
978
  const tpl = templateFor(unit);
979
  const p = FORMATION[slot % FORMATION.length];
980
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
981
- const bar = (unit.skills || []).map(skillById).filter(Boolean);
982
  return {
983
  id,
984
  team,
 
978
  const tpl = templateFor(unit);
979
  const p = FORMATION[slot % FORMATION.length];
980
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
981
+ const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
982
  return {
983
  id,
984
  team,
web/comboBattler.js CHANGED
@@ -5058,7 +5058,7 @@ function makeActor(unit, team, id, slot) {
5058
  const tpl = templateFor(unit);
5059
  const p = FORMATION[slot % FORMATION.length];
5060
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
5061
- const bar = (unit.skills || []).map(skillById).filter(Boolean);
5062
  return {
5063
  id,
5064
  team,
 
5058
  const tpl = templateFor(unit);
5059
  const p = FORMATION[slot % FORMATION.length];
5060
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
5061
+ const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
5062
  return {
5063
  id,
5064
  team,
web/enemiesSandbox.js CHANGED
@@ -811,7 +811,7 @@ function makeActor(unit, team, id, slot) {
811
  const tpl = templateFor(unit);
812
  const p = FORMATION[slot % FORMATION.length];
813
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
814
- const bar = (unit.skills || []).map(skillById).filter(Boolean);
815
  return {
816
  id,
817
  team,
 
811
  const tpl = templateFor(unit);
812
  const p = FORMATION[slot % FORMATION.length];
813
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
814
+ const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
815
  return {
816
  id,
817
  team,
web/engine.js CHANGED
@@ -785,7 +785,7 @@ function makeActor(unit, team, id, slot) {
785
  const tpl = templateFor(unit);
786
  const p = FORMATION[slot % FORMATION.length];
787
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
788
- const bar = (unit.skills || []).map(skillById).filter(Boolean);
789
  return {
790
  id,
791
  team,
 
785
  const tpl = templateFor(unit);
786
  const p = FORMATION[slot % FORMATION.length];
787
  const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
788
+ const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
789
  return {
790
  id,
791
  team,
web/skillForgePanel.js CHANGED
@@ -1,16 +1,20 @@
1
- // Skill Forge — the Sandbox surface that uses the Coding Model to author a combat
2
- // skill for a chosen hero. Pick a recruited persona, describe the skill you want, and
3
- // the coding model (Nemotron via NIM, or Mellum2 via ZeroGPU see Settings → Coding
4
- // Model) writes a self-contained skill definition tailored to that hero. For now it
5
- // just generates and shows the skill; wiring it into the battle engine comes later.
6
  import { streamCoding, currentCodingModel, onCodingModelChange } from '/web/codingModel.js'
7
- import { listPersonas, getPersona, onRosterChange } from '/web/personaStore.js'
8
  import { stripThink, stripThinkFinal } from '/web/personaPrompts.js'
 
 
 
9
 
10
  function el(tag, props = {}, kids = []) {
11
  const n = document.createElement(tag)
12
  for (const [k, v] of Object.entries(props)) {
13
  if (k === 'class') n.className = v
 
14
  else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
15
  else if (v != null) n.setAttribute(k, v)
16
  }
@@ -18,13 +22,21 @@ function el(tag, props = {}, kids = []) {
18
  return n
19
  }
20
 
 
 
21
  const SYSTEM = [
22
- 'You are the Skill Forge for a fantasy auto-battler. You author ONE combat skill for a',
23
- 'specific hero, tailored to their class, story and personality. Respond with exactly:',
24
- 'a one-line skill name, a one-sentence flavour description, then a fenced ```json block',
25
- 'with fields {name, type:"active"|"passive", target:"self"|"ally"|"enemy"|"area",',
26
- 'cooldown:<int seconds>, effect:"<short mechanical description>"}.',
27
- 'Keep it concise and balanced. Output only the skill — no preamble, no commentary.',
 
 
 
 
 
 
28
  ].join(' ')
29
 
30
  function personaBlock(p) {
@@ -37,26 +49,31 @@ function personaBlock(p) {
37
  ].filter(Boolean).join('\n')
38
  }
39
 
 
 
 
 
 
 
 
 
 
40
  export function mountSkillForgePanel(host) {
41
  const sel = el('select', { class: 'persona-input skillforge-hero' })
42
  const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4,
43
  placeholder: 'what skill should this hero learn? (e.g. “a defensive shout that shields nearby allies”)' })
44
  const btn = el('button', { class: 'persona-go', type: 'button' }, '⚒ Forge skill')
45
  const status = el('div', { class: 'persona-status' })
46
- const out = el('pre', { class: 'persona-think skillforge-out' })
47
  const empty = el('div', { class: 'persona-roster-empty' },
48
  'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.')
49
 
50
- // "Show thinking" reveals the reasoning models' <think> trace (Nemotron, BLS) in the debug
51
- // panel below; off by default so forging stays fast/clean. (Mellum2 has no reasoning.)
52
  const thinkChk = el('input', { type: 'checkbox', class: 'skillforge-think' })
53
- const thinkLabel = el('label', { class: 'persona-label skillforge-think-label' },
54
- [thinkChk, ' show model thinking'])
55
  const dbgEl = el('pre', { class: 'persona-think' })
56
  const copyBtn = el('button', { class: 'persona-copy', type: 'button',
57
  onclick: () => navigator.clipboard?.writeText(dbgEl.textContent || '') }, 'copy')
58
- const dbgWrap = el('details', { class: 'persona-think-wrap' },
59
- [el('summary', {}, 'model thinking / raw'), copyBtn, dbgEl])
60
  dbgWrap.style.display = thinkChk.checked ? '' : 'none'
61
 
62
  const controls = el('aside', { class: 'persona-controls skillforge' }, [
@@ -67,7 +84,7 @@ export function mountSkillForgePanel(host) {
67
  thinkLabel,
68
  el('div', { class: 'persona-prompt-actions' }, [btn]),
69
  status,
70
- el('label', { class: 'persona-label' }, 'Forged skill'), out,
71
  dbgWrap,
72
  ])
73
  host.append(controls)
@@ -84,9 +101,37 @@ export function mountSkillForgePanel(host) {
84
  sel.style.display = none ? 'none' : ''
85
  btn.disabled = none
86
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
- function refreshStatus() {
89
- if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}`
 
 
 
 
 
 
 
 
 
 
 
 
90
  }
91
 
92
  let running = false
@@ -97,27 +142,36 @@ export function mountSkillForgePanel(host) {
97
  const ask = req.value.trim()
98
  if (!ask) { status.textContent = 'Describe the skill you want.'; return }
99
  running = true; status.dataset.busy = '1'; btn.disabled = true
100
- out.textContent = ''; dbgEl.textContent = ''
101
- status.textContent = `Forging with ${currentCodingModel().label}…`
102
- const user = `${personaBlock(p)}\n\nSkill to create: ${ask}`
103
  const showThink = thinkChk.checked
104
- let raw = ''
105
  try {
106
- const { stats } = await streamCoding(SYSTEM, user, {
107
- maxTokens: 512,
108
- temperature: 0.6,
109
- think: showThink,
110
- // Reasoning streams inside <think>…</think>; show the raw trace in the debug panel and
111
- // the stripped answer in the output (same split the persona panel uses).
112
- onToken: (t) => {
113
- raw += t
114
- out.textContent = stripThink(raw)
115
- if (showThink) { dbgEl.textContent = raw; dbgWrap.open = true; dbgEl.scrollTop = dbgEl.scrollHeight }
116
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  })
118
- out.textContent = stripThinkFinal(raw)
119
- const tps = stats && stats.tokPerSec ? ` · ${stats.tokPerSec} tok/s` : ''
120
- status.textContent = `Done${tps}.`
121
  } catch (e) {
122
  status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e)
123
  } finally {
 
1
+ // Skill Forge — the Sandbox surface that uses the Coding Model to author a REAL, engine-
2
+ // resolvable combat skill for a chosen hero. Pick a recruited persona, describe the skill,
3
+ // and the coding model writes a CB skill object (validated + clamped by skillSchema.js) that
4
+ // the battle engine can actually run. The forge then paints a Klein icon, saves the skill onto
5
+ // the hero (customSkills + learnedSkills), and shows a card you can view on the character page.
6
  import { streamCoding, currentCodingModel, onCodingModelChange } from '/web/codingModel.js'
7
+ import { listPersonas, getPersona, onRosterChange, patchPersona, putSkillIcon, getSkillIcon } from '/web/personaStore.js'
8
  import { stripThink, stripThinkFinal } from '/web/personaPrompts.js'
9
+ import { validateSkill, effectSummary, SAFE_OPS, SAFE_CONDITIONS } from '/web/skillSchema.js'
10
+ import { generatePortrait } from '/web/imagen.js'
11
+ import { appendEvent } from '/web/progression.js'
12
 
13
  function el(tag, props = {}, kids = []) {
14
  const n = document.createElement(tag)
15
  for (const [k, v] of Object.entries(props)) {
16
  if (k === 'class') n.className = v
17
+ else if (k === 'style') n.style.cssText = v
18
  else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
19
  else if (v != null) n.setAttribute(k, v)
20
  }
 
22
  return n
23
  }
24
 
25
+ // The forge contract: emit ONE fenced json skill object the engine can resolve. We constrain
26
+ // the op + condition vocabulary to skillSchema's whitelist so the result is always runnable.
27
  const SYSTEM = [
28
+ 'You are the Skill Forge for a fantasy auto-battler. Author ONE combat skill for a specific',
29
+ 'hero, tailored to their class, story and personality. Respond with a SINGLE fenced ```json',
30
+ 'block (no prose outside it) of the form:',
31
+ '{"name": string, "flavor": string (one vivid in-world sentence),',
32
+ '"category": "melee_attack"|"ranged_attack"|"spell"|"shout"|"stance",',
33
+ '"target": "foe"|"self"|"ally"|"area", "cost": int, "recharge": int seconds,',
34
+ '"effects": [ {"op": <op>, ...args} ] }.',
35
+ `Allowed effect ops: ${SAFE_OPS.join(', ')}.`,
36
+ 'damage/bonus_damage/heal/life_steal take {"amount": int}. apply_condition takes',
37
+ `{"condition": one of [${SAFE_CONDITIONS.join(', ')}], "duration": int seconds}.`,
38
+ 'knockdown takes {"duration": int}. interrupt and lose_all_adrenaline take no args.',
39
+ 'Use 1-3 effects. Keep it concise, flavourful and balanced. Output ONLY the json block.',
40
  ].join(' ')
41
 
42
  function personaBlock(p) {
 
49
  ].filter(Boolean).join('\n')
50
  }
51
 
52
+ // Pull the first JSON object out of model text (fenced or bare).
53
+ function parseSkillJson(text) {
54
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
55
+ const body = fence ? fence[1] : text
56
+ const a = body.indexOf('{'), b = body.lastIndexOf('}')
57
+ if (a < 0 || b <= a) return null
58
+ try { return JSON.parse(body.slice(a, b + 1)) } catch { return null }
59
+ }
60
+
61
  export function mountSkillForgePanel(host) {
62
  const sel = el('select', { class: 'persona-input skillforge-hero' })
63
  const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4,
64
  placeholder: 'what skill should this hero learn? (e.g. “a defensive shout that shields nearby allies”)' })
65
  const btn = el('button', { class: 'persona-go', type: 'button' }, '⚒ Forge skill')
66
  const status = el('div', { class: 'persona-status' })
67
+ const card = el('div', { class: 'skillforge-card', style: 'display:none;margin-top:10px;border:1px solid #2a3340;border-radius:12px;padding:12px;background:rgba(20,24,33,.6)' })
68
  const empty = el('div', { class: 'persona-roster-empty' },
69
  'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.')
70
 
 
 
71
  const thinkChk = el('input', { type: 'checkbox', class: 'skillforge-think' })
72
+ const thinkLabel = el('label', { class: 'persona-label skillforge-think-label' }, [thinkChk, ' show model thinking'])
 
73
  const dbgEl = el('pre', { class: 'persona-think' })
74
  const copyBtn = el('button', { class: 'persona-copy', type: 'button',
75
  onclick: () => navigator.clipboard?.writeText(dbgEl.textContent || '') }, 'copy')
76
+ const dbgWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model thinking / raw'), copyBtn, dbgEl])
 
77
  dbgWrap.style.display = thinkChk.checked ? '' : 'none'
78
 
79
  const controls = el('aside', { class: 'persona-controls skillforge' }, [
 
84
  thinkLabel,
85
  el('div', { class: 'persona-prompt-actions' }, [btn]),
86
  status,
87
+ card,
88
  dbgWrap,
89
  ])
90
  host.append(controls)
 
101
  sel.style.display = none ? 'none' : ''
102
  btn.disabled = none
103
  }
104
+ function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` }
105
+
106
+ // Stream the coding model once; return the validated skill (or null + reason). `extra` lets a
107
+ // retry feed the validation errors back to the model.
108
+ async function generateSkill(p, ask, showThink, extra = '') {
109
+ const user = `${personaBlock(p)}\n\nSkill to create: ${ask}${extra}`
110
+ let raw = ''
111
+ const { } = await streamCoding(SYSTEM, user, {
112
+ maxTokens: 512, temperature: 0.6, think: showThink,
113
+ onToken: (t) => { raw += t; if (showThink) { dbgEl.textContent = raw; dbgWrap.open = true; dbgEl.scrollTop = dbgEl.scrollHeight } },
114
+ })
115
+ const clean = stripThinkFinal(raw)
116
+ const parsed = parseSkillJson(clean)
117
+ const v = validateSkill(parsed, { persona: p })
118
+ return { v, clean }
119
+ }
120
 
121
+ function renderCard(skill, iconBlob) {
122
+ card.style.display = ''
123
+ card.replaceChildren()
124
+ const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
125
+ 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' })
126
+ if (iconBlob) icon.style.backgroundImage = `url(${URL.createObjectURL(iconBlob)})`
127
+ const meta = el('div', {}, [
128
+ el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
129
+ el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
130
+ ])
131
+ head.append(icon, meta)
132
+ const flav = el('div', { style: 'color:#c2c8d2;font-style:italic;margin:10px 0 0;line-height:1.45' }, skill.flavor || '')
133
+ const saved = el('div', { style: 'color:#34d058;font-size:12px;margin-top:8px' }, '✓ Learned — view it on the hero’s character sheet.')
134
+ card.append(head, flav, saved)
135
  }
136
 
137
  let running = false
 
142
  const ask = req.value.trim()
143
  if (!ask) { status.textContent = 'Describe the skill you want.'; return }
144
  running = true; status.dataset.busy = '1'; btn.disabled = true
145
+ card.style.display = 'none'; dbgEl.textContent = ''
 
 
146
  const showThink = thinkChk.checked
 
147
  try {
148
+ status.textContent = `Forging with ${currentCodingModel().label}…`
149
+ let { v } = await generateSkill(p, ask, showThink)
150
+ if (!v.ok) {
151
+ status.textContent = 'Refining (invalid skill, retrying)…'
152
+ const fix = `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.`
153
+ ;({ v } = await generateSkill(p, ask, showThink, fix))
154
+ }
155
+ if (!v.ok) { status.textContent = 'Forge failed to produce a valid skill: ' + v.errors.join('; '); return }
156
+ const skill = v.skill
157
+ // Paint the icon (best-effort; a skill without art is still learned).
158
+ status.textContent = 'Painting skill icon…'
159
+ let iconBlob = null
160
+ try {
161
+ iconBlob = await generatePortrait(
162
+ `fantasy game skill icon, "${skill.name}": ${skill.flavor}. ${p.unitClass || ''} ability, dramatic, centered emblem, painterly, square`,
163
+ { seed: skill.id })
164
+ if (iconBlob) await putSkillIcon(skill.id, iconBlob)
165
+ } catch { /* art is optional */ }
166
+ // Persist onto the hero.
167
+ const fresh = getPersona(p.id) || p
168
+ patchPersona(p.id, {
169
+ customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
170
+ learnedSkills: [...(fresh.learnedSkills || []), skill.id],
171
+ events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }),
172
  })
173
+ renderCard(skill, iconBlob)
174
+ status.textContent = 'Done.'
 
175
  } catch (e) {
176
  status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e)
177
  } finally {
web/skillSchema.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Skill Forge validator — turns a model-authored skill into a SAFE, engine-resolvable
2
+ // CB skill object. The combat resolver (auto-battler src/engine/teamBattle.js) switches on
3
+ // effect `op`s and reads {category, target, cost, recharge, effects[]}; anything outside the
4
+ // whitelist here is dropped or clamped so a forged skill can never crash the resolver or be
5
+ // wildly unbalanced. Mirrors the op/condition vocabulary in teamBattle.js — keep in sync.
6
+
7
+ // Effect ops the resolver understands AND we consider safe to expose to generation.
8
+ // Each entry clamps/normalizes its payload to engine-ready numbers.
9
+ const NUM = (v, lo, hi, dflt) => {
10
+ let n = typeof v === 'number' ? v : (v && Array.isArray(v.scale) ? (v.scale[1] ?? v.scale[0]) : Number(v))
11
+ if (!Number.isFinite(n)) n = dflt
12
+ return Math.max(lo, Math.min(hi, Math.round(n)))
13
+ }
14
+ const CONDITIONS = ['bleeding', 'poison', 'burning', 'deepWound', 'weakness', 'crippled', 'blind', 'dazed']
15
+ const SCOPES = ['self', 'party', 'nearby', 'area', 'target_and_adjacent', 'adjacent_to_target']
16
+
17
+ const OPS = {
18
+ damage: (e) => ({ op: 'damage', amount: NUM(e.amount, 1, 60, 15) }),
19
+ bonus_damage: (e) => ({ op: 'bonus_damage', amount: NUM(e.amount, 1, 40, 12) }),
20
+ heal: (e) => ({ op: 'heal', amount: NUM(e.amount, 1, 60, 20) }),
21
+ life_steal: (e) => ({ op: 'life_steal', amount: NUM(e.amount, 1, 40, 12) }),
22
+ apply_condition: (e) => CONDITIONS.includes(e.condition)
23
+ ? { op: 'apply_condition', condition: e.condition, duration: NUM(e.duration, 1, 25, 8), ...(SCOPES.includes(e.scope) ? { scope: e.scope } : {}) }
24
+ : null,
25
+ knockdown: (e) => ({ op: 'knockdown', duration: NUM(e.duration, 1, 5, 2) }),
26
+ interrupt: () => ({ op: 'interrupt' }),
27
+ lose_all_adrenaline: () => ({ op: 'lose_all_adrenaline' }),
28
+ }
29
+ const CATEGORIES = ['melee_attack', 'ranged_attack', 'spell', 'shout', 'stance']
30
+ const TARGETS = ['foe', 'self', 'ally', 'area']
31
+
32
+ export const SAFE_OPS = Object.keys(OPS)
33
+ export const SAFE_CONDITIONS = CONDITIONS.slice()
34
+
35
+ // A fresh id that won't collide with the canon skills (ids ~0–700) or the hero's own forged
36
+ // skills. Forged ids live in the 9000+ band.
37
+ export function nextSkillId(persona) {
38
+ const used = Object.keys((persona && persona.customSkills) || {}).map((k) => +k).filter((n) => n >= 9000)
39
+ return used.length ? Math.max(...used) + 1 : 9000
40
+ }
41
+
42
+ // Validate + normalize a raw model object into an engine-ready skill. Returns
43
+ // { ok, skill, errors }. `skill` is always engine-safe when ok is true.
44
+ export function validateSkill(raw, { persona, profession } = {}) {
45
+ const errors = []
46
+ if (!raw || typeof raw !== 'object') return { ok: false, skill: null, errors: ['not an object'] }
47
+ const name = String(raw.name || '').trim().slice(0, 40)
48
+ if (!name) errors.push('missing name')
49
+ const category = CATEGORIES.includes(raw.category) ? raw.category : 'melee_attack'
50
+ const target = TARGETS.includes(raw.target) ? raw.target : 'foe'
51
+ const rawEffects = Array.isArray(raw.effects) ? raw.effects : []
52
+ const effects = rawEffects.map((e) => (e && OPS[e.op] ? OPS[e.op](e) : null)).filter(Boolean).slice(0, 3)
53
+ if (!effects.length) errors.push('no valid effects')
54
+ // Cost: keep it cheap enough to actually fire. Casters pay energy, fighters adrenaline.
55
+ const isCaster = category === 'spell'
56
+ const cost = isCaster ? { energy: NUM(raw.cost?.energy ?? raw.cost, 1, 15, 5) }
57
+ : { adrenaline: NUM(raw.cost?.adrenaline ?? raw.cost, 0, 10, 4) }
58
+ const recharge = NUM(raw.recharge ?? raw.cooldown, 0, 30, isCaster ? 4 : 0)
59
+ const flavor = String(raw.flavor || raw.description || '').trim().slice(0, 200)
60
+ if (errors.length) return { ok: false, skill: null, errors }
61
+ const skill = {
62
+ id: nextSkillId(persona),
63
+ name,
64
+ profession: profession || (persona && persona.unitClass) || 'Warrior',
65
+ attribute: 'Forged',
66
+ category, target,
67
+ cost, cast: isCaster ? NUM(raw.cast, 0, 3, 1) : 0, recharge,
68
+ requires: category === 'melee_attack' || category === 'ranged_attack' ? ['on_hit'] : [],
69
+ effects,
70
+ // ── metadata (ignored by the resolver, used by the UI) ──
71
+ custom: true, flavor,
72
+ }
73
+ return { ok: true, skill, errors: [] }
74
+ }
75
+
76
+ // Human one-line effect summary for skill cards (mirrors the engine's label style).
77
+ export function effectSummary(skill) {
78
+ if (!skill || !Array.isArray(skill.effects)) return ''
79
+ const parts = skill.effects.map((e) => {
80
+ switch (e.op) {
81
+ case 'damage': case 'bonus_damage': return `+${e.amount} dmg`
82
+ case 'heal': return `+${e.amount} heal`
83
+ case 'life_steal': return `${e.amount} life steal`
84
+ case 'apply_condition': return e.condition
85
+ case 'knockdown': return 'knockdown'
86
+ case 'interrupt': return 'interrupt'
87
+ case 'lose_all_adrenaline': return 'spend adrenaline'
88
+ default: return e.op
89
+ }
90
+ })
91
+ return parts.join(' · ')
92
+ }
93
+
94
+ // Resolve a persona's equipped skill ids into engine-ready objects (custom objects pass
95
+ // through; numeric ids are left for the engine to resolve against CB_SKILLS).
96
+ export function resolveEquipped(persona) {
97
+ if (!persona) return []
98
+ const custom = persona.customSkills || {}
99
+ return (persona.equippedSkills || [])
100
+ .map((id) => custom[id] || (typeof id === 'number' ? id : null))
101
+ .filter((x) => x != null)
102
+ .slice(0, 3)
103
+ }
web/tiny.js CHANGED
@@ -17,6 +17,9 @@ 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 } from '/web/progression.js'
 
 
 
20
  import { mountDiaryPanel } from '/web/diaryPanel.js'
21
  import { mountSettingsPanel } from '/web/settingsPanel.js'
22
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
@@ -219,7 +222,12 @@ const ENEMY_AGGRO = 220 // FIELD units (~8 tiles): enemy idles until the player
219
  // A persona → controllable hero (class → sheets + engine profession).
220
  const buildPlayer = (chars, p) => {
221
  const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
222
- return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' } }
 
 
 
 
 
223
  }
224
  const PICK_CARD_CSS = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
225
  // Fill a square box with the hero's saved PORTRAIT (if any), else an animated idle GIF-style sprite
@@ -404,6 +412,45 @@ whenEl('battle-stage', async (el) => {
404
  sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:44px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 18px var(--tac-font,system-ui);cursor:pointer'
405
  const sheet = document.createElement('aside')
406
  sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  const renderSheet = () => {
408
  sheet.innerHTML = ''
409
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
@@ -444,6 +491,27 @@ whenEl('battle-stage', async (el) => {
444
  const h = document.createElement('div'); h.textContent = 'Skills'; h.style.cssText = 'font:600 10px var(--tac-font,system-ui);letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin:12px 0 6px'; sheet.append(h)
445
  skills.forEach((s, i) => { const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;align-items:center;padding:7px 9px;border:1px solid #232b36;border-radius:8px;margin-bottom:6px'; const n = document.createElement('span'); n.textContent = String(i + 1); n.style.cssText = 'width:18px;height:18px;border-radius:50%;background:#222a35;display:flex;align-items:center;justify-content:center;font-size:11px;color:#9aa4b2;flex:none'; const t = document.createElement('span'); t.textContent = s; row.append(n, t); sheet.append(row) })
446
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  if (currentHero.about) { const a = document.createElement('div'); a.textContent = currentHero.about; a.style.cssText = 'color:#c2c8d2;margin:12px 0;line-height:1.45'; sheet.append(a) }
448
  const sw = document.createElement('button'); sw.type = 'button'; sw.textContent = '⇄ Switch / New hero'
449
  sw.style.cssText = 'width:100%;margin-top:14px;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer'
 
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 } 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 { mountDiaryPanel } from '/web/diaryPanel.js'
24
  import { mountSettingsPanel } from '/web/settingsPanel.js'
25
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
 
222
  // A persona → controllable hero (class → sheets + engine profession).
223
  const buildPlayer = (chars, p) => {
224
  const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
225
+ // Equipped Skill-Forge skills (objects) flow straight into the engine bar; if none are
226
+ // equipped, leaving skills unset lets comboBattler pick the class's default three.
227
+ const equipped = resolveEquipped(p)
228
+ const unit = { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }
229
+ if (equipped.length) unit.skills = equipped
230
+ return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
231
  }
232
  const PICK_CARD_CSS = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
233
  // Fill a square box with the hero's saved PORTRAIT (if any), else an animated idle GIF-style sprite
 
412
  sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:44px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 18px var(--tac-font,system-ui);cursor:pointer'
413
  const sheet = document.createElement('aside')
414
  sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
415
+ // A forged-skill detail bottom-sheet: big action illustration (lazy Klein render, cached),
416
+ // flavor, effects, and an equip/unequip toggle (max 3 equipped).
417
+ let skillSheet = null
418
+ const openSkillDetail = (sk) => {
419
+ skillSheet?.remove()
420
+ const back = document.createElement('div'); back.style.cssText = 'position:absolute;inset:0;z-index:10;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'
421
+ const sh = document.createElement('div'); sh.style.cssText = 'width:min(440px,96vw);max-height:90%;overflow-y:auto;background:#11151c;border:1px solid #2a3340;border-radius:16px 16px 0 0;padding:16px;color:#e8e8e8;box-sizing:border-box'
422
+ const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.style.cssText = 'float:right;background:none;border:none;color:#9aa4b2;font-size:20px;cursor:pointer'
423
+ const closeDetail = () => { back.remove(); skillSheet = null }
424
+ x.addEventListener('click', closeDetail); sh.append(x)
425
+ 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'
426
+ art.textContent = 'summoning the vision…'; sh.append(art)
427
+ const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:700 19px var(--tac-font,system-ui)'; sh.append(nm)
428
+ 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)
429
+ 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) }
430
+ const eqBtn = document.createElement('button'); eqBtn.type = 'button'
431
+ const isEq = () => (getPersona(currentHero.id)?.equippedSkills || []).includes(sk.id)
432
+ const paintEq = () => { const on = isEq(); eqBtn.textContent = on ? '★ Equipped — tap to remove' : '☆ Equip (uses a skill slot)'; eqBtn.style.cssText = `width:100%;padding:12px;border-radius:10px;border:1px solid ${on ? '#c9a227' : '#2a3340'};background:${on ? '#2a2410' : '#1a2230'};color:${on ? '#ffe082' : '#e8e8e8'};font:600 13px var(--tac-font,system-ui);cursor:pointer` }
433
+ eqBtn.addEventListener('click', () => {
434
+ const cur = getPersona(currentHero.id); if (!cur) return
435
+ let eq = (cur.equippedSkills || []).slice()
436
+ if (eq.includes(sk.id)) eq = eq.filter((i) => i !== sk.id)
437
+ else { if (eq.length >= 3) { eqBtn.textContent = 'Slots full (3) — remove one first'; return } eq.push(sk.id) }
438
+ patchPersona(cur.id, { equippedSkills: eq }); currentHero = getPersona(cur.id); paintEq(); renderSheet()
439
+ })
440
+ paintEq(); sh.append(eqBtn)
441
+ back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
442
+ back.append(sh); el.append(back); skillSheet = back
443
+ // Action illustration: serve cached, else paint once with Klein and cache.
444
+ getSkillArt(sk.id).then(async (b) => {
445
+ if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})`; return }
446
+ try {
447
+ 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`
448
+ const blob = await generatePortrait(prompt, { seed: sk.id + 1 })
449
+ if (blob) { await putSkillArt(sk.id, blob); art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(blob)})` }
450
+ else art.textContent = 'no art'
451
+ } catch { art.textContent = 'art unavailable' }
452
+ })
453
+ }
454
  const renderSheet = () => {
455
  sheet.innerHTML = ''
456
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
 
491
  const h = document.createElement('div'); h.textContent = 'Skills'; h.style.cssText = 'font:600 10px var(--tac-font,system-ui);letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin:12px 0 6px'; sheet.append(h)
492
  skills.forEach((s, i) => { const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;align-items:center;padding:7px 9px;border:1px solid #232b36;border-radius:8px;margin-bottom:6px'; const n = document.createElement('span'); n.textContent = String(i + 1); n.style.cssText = 'width:18px;height:18px;border-radius:50%;background:#222a35;display:flex;align-items:center;justify-content:center;font-size:11px;color:#9aa4b2;flex:none'; const t = document.createElement('span'); t.textContent = s; row.append(n, t); sheet.append(row) })
493
  }
494
+ // ── Forged skills — tap a card for the detail sheet (action art + equip) ──
495
+ {
496
+ const cs = currentHero.customSkills || {}
497
+ const learned = (currentHero.learnedSkills || []).map((id) => cs[id]).filter(Boolean)
498
+ if (learned.length) {
499
+ const equipped = new Set(currentHero.equippedSkills || [])
500
+ const h = document.createElement('div'); h.textContent = `Forged skills (${equipped.size}/3 equipped)`; h.style.cssText = 'font:600 10px var(--tac-font,system-ui);letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin:16px 0 6px'; sheet.append(h)
501
+ learned.forEach((sk) => {
502
+ const row = document.createElement('button'); row.type = 'button'
503
+ row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:8px;border:1px solid ${equipped.has(sk.id) ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6);color:#e8e8e8;cursor:pointer`
504
+ const ic = document.createElement('div'); ic.style.cssText = 'width:40px;height:40px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none'
505
+ getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
506
+ const txt = document.createElement('div')
507
+ const nm = document.createElement('div'); nm.textContent = sk.name + (equipped.has(sk.id) ? ' ★' : ''); nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
508
+ const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
509
+ txt.append(nm, ef); row.append(ic, txt)
510
+ row.addEventListener('click', () => openSkillDetail(sk))
511
+ sheet.append(row)
512
+ })
513
+ }
514
+ }
515
  if (currentHero.about) { const a = document.createElement('div'); a.textContent = currentHero.about; a.style.cssText = 'color:#c2c8d2;margin:12px 0;line-height:1.45'; sheet.append(a) }
516
  const sw = document.createElement('button'); sw.type = 'button'; sw.textContent = '⇄ Switch / New hero'
517
  sw.style.cssText = 'width:100%;margin-top:14px;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer'